Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Change Log

## 22.2.0

* Added: OAuth refresh tokens now stored in the OS keychain via `@napi-rs/keyring`, falling back to config
* Added: `--type` option to `functions list-specifications` and `sites list-specifications`
* Updated: Bumped `@appwrite.io/console` dependency to `^15.1.1`
* Updated: Cleaner account selection prompt for `logout` with a `(current)` marker
* Fixed: OAuth login now clears the stale legacy session cookie
* Fixed: Browser launch on Windows now uses `rundll32` for OAuth flows

## 22.1.3

* Added: `--resource` option to `oauth2 authorize`, `create-device-authorization`, and `create-token` for RFC 8707 resource indicators
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Once the installation is complete, you can verify the install using

```sh
$ appwrite -v
22.1.3
22.2.0
```

### Install using prebuilt binaries
Expand Down Expand Up @@ -83,7 +83,7 @@ $ scoop install https://raw.githubusercontent.com/appwrite/sdk-for-cli/master/sc
Once the installation completes, you can verify your install using
```
$ appwrite -v
22.1.3
22.2.0
```

## Getting Started
Expand Down
57 changes: 42 additions & 15 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# You can use "View source" of this page to see the full script.

# REPO
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/22.1.3/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/22.1.3/appwrite-cli-win-arm64.exe"
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/22.2.0/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/22.2.0/appwrite-cli-win-arm64.exe"

$APPWRITE_BINARY_NAME = "appwrite.exe"

Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ verifyMacOSCodeSignature() {
downloadBinary() {
echo "[2/5] Downloading executable for $OS ($ARCH) ..."

GITHUB_LATEST_VERSION="22.1.3"
GITHUB_LATEST_VERSION="22.2.0"
GITHUB_FILE="appwrite-cli-${OS}-${ARCH}"
GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE"

Expand Down
11 changes: 5 additions & 6 deletions lib/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
restoreCurrentSession,
deleteServerSession,
} from "./session.js";
import { setStoredRefreshToken } from "./refresh-token.js";

const DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1";

Expand Down Expand Up @@ -71,7 +72,7 @@ const startWaitingForApprovalSpinner = (): (() => void) => {
};
};

const listenForBrowserOpen = (
export const listenForBrowserOpen = (
url: string,
onCancel: () => void,
): (() => void) => {
Expand All @@ -90,17 +91,14 @@ const listenForBrowserOpen = (
if (shouldRestoreRawMode) {
stdin.setRawMode?.(true);
}
const shouldPause = stdin.isPaused();
stdin.resume();

const cleanup = (): void => {
stdin.off("data", onData);
if (shouldRestoreRawMode) {
stdin.setRawMode?.(false);
}
if (shouldPause) {
stdin.pause();
}
stdin.pause();
Comment thread
ChiragAgg5k marked this conversation as resolved.
};

// Open the browser at most once; keep listening afterwards so Ctrl+C still
Expand Down Expand Up @@ -393,7 +391,7 @@ const loginWithOAuthDevice = async ({

const tokenExpiry = Date.now() + token.expires_in * 1000;
globalConfig.setAccessToken(token.access_token);
globalConfig.setRefreshToken(token.refresh_token || "");
setStoredRefreshToken(id, token.refresh_token || "");
globalConfig.setTokenExpiry(tokenExpiry);

let tokenEmail = "";
Expand All @@ -419,6 +417,7 @@ const loginWithOAuthDevice = async ({
}

globalConfig.setEmail(account.email);
globalConfig.removeCookie();

const { removed: removedLegacySessions, failed: failedLegacySessions } =
await removeLegacySessionsExcept(id);
Expand Down
105 changes: 105 additions & 0 deletions lib/auth/refresh-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Entry } from "@napi-rs/keyring";
import { globalConfig } from "../config.js";
import { EXECUTABLE_NAME } from "../constants.js";
import type { SessionData } from "../types.js";

const REFRESH_TOKEN_SERVICE = `${EXECUTABLE_NAME}-oauth-refresh-token`;

interface KeyringEntry {
setPassword(password: string): void;
getPassword(): string | null;
deletePassword(): boolean;
}

type KeyringEntryFactory = (service: string, account: string) => KeyringEntry;

let keyringEntryFactory: KeyringEntryFactory = (service, account) =>
new Entry(service, account);

const getSessionData = (sessionId: string): SessionData | undefined =>
globalConfig.get(sessionId) as SessionData | undefined;

const setSessionData = (sessionId: string, data: SessionData): void => {
globalConfig.addSession(sessionId, data);
};

const setPrefsRefreshToken = (sessionId: string, refreshToken: string): void => {
const session = getSessionData(sessionId);
if (!session) return;

setSessionData(sessionId, {
...session,
refreshToken,
});
};

export const deletePrefsRefreshToken = (sessionId: string): void => {
const session = getSessionData(sessionId);
if (!session?.refreshToken) return;

const { refreshToken: _refreshToken, ...rest } = session;
setSessionData(sessionId, rest);
};

export const setRefreshTokenEntryFactoryForTests = (
factory: KeyringEntryFactory,
): (() => void) => {
if (process.env.NODE_ENV !== "test") {
throw new Error("setRefreshTokenEntryFactoryForTests is for tests only");
}

const previousFactory = keyringEntryFactory;
keyringEntryFactory = factory;
return () => {
keyringEntryFactory = previousFactory;
};
};

export const getStoredRefreshToken = (sessionId: string): string => {
try {
const refreshToken = keyringEntryFactory(
REFRESH_TOKEN_SERVICE,
sessionId,
).getPassword();

if (refreshToken) {
return refreshToken;
}
} catch (_error) {
// Fall through to prefs fallback below.
}

return getSessionData(sessionId)?.refreshToken ?? "";
};

export const hasStoredRefreshToken = (sessionId: string): boolean =>
getStoredRefreshToken(sessionId) !== "";

export const setStoredRefreshToken = (
sessionId: string,
refreshToken: string,
): void => {
if (!refreshToken) {
deleteStoredRefreshToken(sessionId);
return;
}

try {
keyringEntryFactory(REFRESH_TOKEN_SERVICE, sessionId).setPassword(
refreshToken,
);
deletePrefsRefreshToken(sessionId);
} catch (_error) {
setPrefsRefreshToken(sessionId, refreshToken);
}
};

export const deleteStoredRefreshToken = (sessionId: string): void => {
try {
keyringEntryFactory(REFRESH_TOKEN_SERVICE, sessionId).deletePassword();
} catch (_error) {
// Missing or unavailable secure storage must not block local cleanup.
}

deletePrefsRefreshToken(sessionId);
};
16 changes: 13 additions & 3 deletions lib/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import type { SessionData } from "../types.js";
import ClientLegacy from "../client.js";
import { OAUTH2_CLIENT_ID } from "../constants.js";
import { revokeRefreshToken } from "./oauth.js";
import {
deleteStoredRefreshToken,
getStoredRefreshToken,
hasStoredRefreshToken,
} from "./refresh-token.js";

/**
* Typed accessor for a stored session, avoiding repeated inline casts.
Expand Down Expand Up @@ -32,7 +37,7 @@ export const hasAuthSession = (): boolean =>
*/
export const isLocalOnlySession = (sessionId: string): boolean => {
const session = getSession(sessionId);
return Boolean(session && !session.refreshToken && !session.cookie);
return Boolean(session && !hasStoredRefreshToken(sessionId) && !session.cookie);
};

/**
Expand Down Expand Up @@ -72,6 +77,7 @@ export const restoreCurrentSessionFallback = (
export const removeCurrentSession = (): void => {
const current = globalConfig.getCurrentSession();
globalConfig.setCurrentSession("");
deleteStoredRefreshToken(current);
globalConfig.removeSession(current);
};

Expand All @@ -89,10 +95,11 @@ export const deleteServerSession = async (
}

try {
if (session.refreshToken) {
const refreshToken = getStoredRefreshToken(sessionId);
if (refreshToken) {
await revokeRefreshToken(
session.endpoint,
session.refreshToken,
refreshToken,
session.clientId || OAUTH2_CLIENT_ID,
);
return { deleted: true };
Expand Down Expand Up @@ -136,13 +143,15 @@ export const logoutSessions = async (

for (const sessionId of sessionIds) {
if (isLocalOnlySession(sessionId)) {
deleteStoredRefreshToken(sessionId);
globalConfig.removeSession(sessionId);
continue;
}

globalConfig.setCurrentSession(sessionId);
const result = await deleteServerSession(sessionId);
if (result.deleted) {
deleteStoredRefreshToken(sessionId);
globalConfig.removeSession(sessionId);
} else {
failed++;
Expand All @@ -169,6 +178,7 @@ export const removeLegacySessionsExcept = async (

const result = await deleteServerSession(sessionId);
if (result.deleted) {
deleteStoredRefreshToken(sessionId);
globalConfig.removeSession(sessionId);
removed++;
} else {
Expand Down
2 changes: 2 additions & 0 deletions lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
SDK_TITLE,
EXECUTABLE_NAME,
} from "./constants.js";
import { deleteStoredRefreshToken } from "./auth/refresh-token.js";

class Client {
private endpoint: string;
Expand Down Expand Up @@ -250,6 +251,7 @@ class Client {

const current = globalConfig.getCurrentSession();
globalConfig.setCurrentSession("");
deleteStoredRefreshToken(current);
globalConfig.removeSession(current);
}

Expand Down
4 changes: 3 additions & 1 deletion lib/commands/services/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ const functionsListRuntimesCommand = functions
const functionsListSpecificationsCommand = functions
.command(`list-specifications`)
.description(`List allowed function specifications for this instance.`)
.option(`--type <type>`, `Specification type to list. Can be one of: runtimes, builds.`)
.action(
actionRunner(
async () => parse(await (await getFunctionsClient()).listSpecifications()),
async ({ type }) =>
parse(await (await getFunctionsClient()).listSpecifications(type)),
),
);

Expand Down
4 changes: 3 additions & 1 deletion lib/commands/services/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ const sitesListFrameworksCommand = sites
const sitesListSpecificationsCommand = sites
.command(`list-specifications`)
.description(`List allowed site specifications for this instance.`)
.option(`--type <type>`, `Specification type to list. Can be one of: runtimes, builds.`)
.action(
actionRunner(
async () => parse(await (await getSitesClient()).listSpecifications()),
async ({ type }) =>
parse(await (await getSitesClient()).listSpecifications(type)),
),
);

Expand Down
17 changes: 17 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,10 @@ class Global extends Config<GlobalConfigData> {
this.setTo(Global.PREFERENCE_COOKIE, cookie);
}

removeCookie(): void {
this.deleteFrom(Global.PREFERENCE_COOKIE);
}

getProject(): string {
if (!this.hasFrom(Global.PREFERENCE_PROJECT)) {
return "";
Expand Down Expand Up @@ -1442,6 +1446,19 @@ class Global extends Config<GlobalConfigData> {
this.write();
}
}

deleteFrom(key: string): void {
const current = this.getCurrentSession();

if (current) {
const config = this.get(current as any);

if (config && (config as any)[key] !== undefined) {
delete (config as any)[key];
this.write();
}
}
}
}

export const localConfig = new Local();
Expand Down
2 changes: 1 addition & 1 deletion lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SDK
export const SDK_TITLE = 'Appwrite';
export const SDK_TITLE_LOWER = 'appwrite';
export const SDK_VERSION = '22.1.3';
export const SDK_VERSION = '22.2.0';
export const SDK_NAME = 'Command Line';
export const SDK_PLATFORM = 'console';
export const SDK_LANGUAGE = 'cli';
Expand Down
17 changes: 8 additions & 9 deletions lib/questions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,26 +910,25 @@ export const questionsLogout: Question[] = [
{
type: "checkbox",
name: "accounts",
message: "Select accounts to logout from",
message: "Select accounts to log out",
validate: (value: any) => validateRequired("account", value),
choices() {
const sessions = globalConfig.getSessions();
const current = globalConfig.getCurrentSession();

const data: Choice[] = [];

const longestEmail = sessions.reduce((prev: any, current: any) =>
prev && (prev.email ?? "").length > (current.email ?? "").length
? prev
: current,
).email.length;

sessions.forEach((session: any) => {
if (session.email) {
const isCurrent = current === session.id;
const currentLabel = isCurrent
? ` ${chalk.green.bold("(current)")}`
: "";
data.push({
current: current === session.id,
current: isCurrent,
value: session.id,
name: `${session.email.padEnd(longestEmail)} ${current === session.id ? chalk.green.bold("current") : " ".repeat(6)} ${session.endpoint}`,
name: `${session.email}${currentLabel} - ${session.endpoint}`,
short: `${session.email}${isCurrent ? " (current)" : ""}`,
});
}
});
Expand Down
Loading