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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change Log

## 20.1.0

* Added `--switch` and `--new` flags on `appwrite login` to explicitly manage multiple saved accounts
* Fixed `appwrite login` to verify the current session via the API and clean up stale guest/unauthorized sessions instead of trusting local metadata
* Updated Cloud account login to normalize endpoints to `https://cloud.appwrite.io/v1` and reject regional Cloud endpoints

## 20.0.0

* Breaking: Removed `projects delete`. Use `project delete` instead
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
20.0.0
20.1.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
20.0.0
20.1.0
```

## Getting Started
Expand Down
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/20.0.0/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/20.0.0/appwrite-cli-win-arm64.exe"
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/20.1.0/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/20.1.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="20.0.0"
GITHUB_LATEST_VERSION="20.1.0"
GITHUB_FILE="appwrite-cli-${OS}-${ARCH}"
GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE"

Expand Down
149 changes: 127 additions & 22 deletions lib/commands/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import inquirer from "inquirer";
import { Command } from "commander";
import { Client } from "@appwrite.io/console";
import { sdkForConsole } from "../sdks.js";
import { globalConfig, localConfig } from "../config.js";
import {
globalConfig,
localConfig,
normalizeCloudConsoleEndpoint,
} from "../config.js";
import { EXECUTABLE_NAME } from "../constants.js";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 DEFAULT_ENDPOINT is already exported from lib/constants.ts (which questions.ts and other modules already import). Defining a private duplicate here creates a maintenance risk — if the canonical value ever changes, this copy would silently diverge.

Suggested change
import { EXECUTABLE_NAME } from "../constants.js";
import { EXECUTABLE_NAME, DEFAULT_ENDPOINT } from "../constants.js";

import {
actionRunner,
Expand All @@ -16,14 +20,20 @@ import {
drawTable,
cliConfig,
} from "../parser.js";
import { isCloudHostname } from "../utils.js";
import ID from "../id.js";
import {
questionsLogin,
questionsLogout,
questionsListFactors,
questionsMFAChallenge,
questionsSwitchAccount,
} from "../questions.js";
import { Account, Client as ConsoleClient } from "@appwrite.io/console";
import {
Account,
Client as ConsoleClient,
type Models,
} from "@appwrite.io/console";
import ClientLegacy from "../client.js";

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate DEFAULT_ENDPOINT constant

This local constant duplicates the exported DEFAULT_ENDPOINT from lib/constants.ts. Remove this line once the import above is updated to include DEFAULT_ENDPOINT.

Expand All @@ -37,6 +47,56 @@ const isMfaRequiredError = (err: unknown): err is AppwriteError =>
(err as AppwriteError)?.type === "user_more_factors_required" ||
(err as AppwriteError)?.response === "user_more_factors_required";

const isGuestUnauthorizedError = (err: unknown): err is AppwriteError =>
(err as AppwriteError)?.type === "general_unauthorized_scope" ||
(err as AppwriteError)?.response === "general_unauthorized_scope";

const isRegionalCloudEndpoint = (endpoint: string): boolean => {
try {
const hostname = new URL(endpoint).hostname;
return isCloudHostname(hostname) && hostname !== "cloud.appwrite.io";
} catch (_error) {
return false;
}
};

const restoreCurrentSession = (sessionId: string): void => {
globalConfig.setCurrentSession(
globalConfig.getSessionIds().includes(sessionId) ? sessionId : "",
);
};

const removeCurrentSession = (): void => {
const current = globalConfig.getCurrentSession();
globalConfig.setCurrentSession("");
globalConfig.removeSession(current);
};

const getCurrentAccount = async (): Promise<Models.User | null> => {
if (globalConfig.getEndpoint() === "" || globalConfig.getCookie() === "") {
return null;
}

const endpoint = normalizeCloudConsoleEndpoint(globalConfig.getEndpoint());
if (endpoint !== globalConfig.getEndpoint()) {
globalConfig.setEndpoint(endpoint);
}

const client = await sdkForConsole(false);
const accountClient = new Account(client);

try {
const account = await accountClient.get();
globalConfig.setEmail(account.email);
return account;
} catch (err) {
if (isGuestUnauthorizedError(err)) {
removeCurrentSession();
}
return null;
}
};
Comment on lines +75 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 getCurrentAccount carries silent, destructive side effects

Beyond returning the current user, this function: (1) mutates globalConfig by normalizing and persisting the endpoint on every call, and (2) permanently removes the current session from local config when general_unauthorized_scope is returned. A transient network error (connection refused, timeout) causes the catch block to skip removal and return null, which causes loginCommand to bypass the "already logged in" early-return and re-prompt the user — potentially creating a duplicate session for the same account. At minimum, network errors should be distinguished from authorization errors so callers can decide how to handle each case.


const createLegacyConsoleClient = (endpoint: string): ClientLegacy => {
const legacyClient = new ClientLegacy();
legacyClient.setEndpoint(endpoint);
Expand Down Expand Up @@ -115,7 +175,9 @@ const getSessionAccountKey = (sessionId: string): string | undefined => {
| { email?: string; endpoint?: string }
| undefined;
if (!session) return undefined;
return `${session.email ?? ""}|${session.endpoint ?? ""}`;
return `${session.email ?? ""}|${normalizeCloudConsoleEndpoint(
session.endpoint ?? "",
)}`;
};

/**
Expand Down Expand Up @@ -165,33 +227,62 @@ export const loginCommand = async ({
endpoint,
mfa,
code,
switch: switchAccount,
new: newAccount,
}: {
email?: string;
password?: string;
endpoint?: string;
mfa?: string;
code?: string;
switch?: boolean;
new?: boolean;
}): Promise<void> => {
const oldCurrent = globalConfig.getCurrentSession();
let oldCurrent = globalConfig.getCurrentSession();

if (switchAccount && newAccount) {
throw new Error("Use either --switch or --new, not both.");
}

const configEndpoint =
(endpoint ?? globalConfig.getEndpoint()) || DEFAULT_ENDPOINT;
if (endpoint && isRegionalCloudEndpoint(endpoint)) {
throw new Error(
`Cloud login uses ${DEFAULT_ENDPOINT}. Regional Cloud endpoints are for project API calls, not account login.`,
);
}

const configEndpoint = normalizeCloudConsoleEndpoint(
(endpoint ?? globalConfig.getEndpoint()) || DEFAULT_ENDPOINT,
);

if (globalConfig.getCurrentSession() !== "") {
log("You are currently signed in as " + globalConfig.getEmail());
const account = await getCurrentAccount();
oldCurrent = globalConfig.getCurrentSession();

if (globalConfig.getSessions().length === 1) {
hint("You can sign in and manage multiple accounts with Appwrite CLI");
if (account) {
if (!email && !password && !endpoint && !switchAccount && !newAccount) {
success("Already logged in as " + account.email);
hint(`Use '${EXECUTABLE_NAME} login --new' to add another account`);
return;
}
}
}

const answers =
email && password
? { email, password }
: await inquirer.prompt(questionsLogin);
let answers;
if (switchAccount) {
if (!globalConfig.getSessions().some((session) => session.email)) {
throw new Error(
`No signed-in accounts found. Run '${EXECUTABLE_NAME} login' to sign in.`,
);
}
answers = await inquirer.prompt(questionsSwitchAccount);
} else if (email && password) {
answers = { email, password };
} else {
answers = await inquirer.prompt(questionsLogin);
}

if (!answers.method) {
answers.method = "login";
answers.method = switchAccount ? "select" : "login";
}

if (answers.method === "select") {
Expand All @@ -201,7 +292,21 @@ export const loginCommand = async ({
throw Error("Session ID not found");
}

if (accountId === oldCurrent) {
const account = await getCurrentAccount();
if (account) {
success(`Already using ${account.email}`);
return;
}
throw new Error(
`Selected account session is no longer valid. Run '${EXECUTABLE_NAME} login --switch' again.`,
);
}

globalConfig.setCurrentSession(accountId);
globalConfig.setEndpoint(
normalizeCloudConsoleEndpoint(globalConfig.getEndpoint()),
);

const client = await sdkForConsole(false);
const accountClient = new Account(client);
Expand All @@ -213,6 +318,10 @@ export const loginCommand = async ({
await accountClient.get();
} catch (err) {
if (!isMfaRequiredError(err)) {
if (isGuestUnauthorizedError(err)) {
globalConfig.removeSession(accountId);
}
restoreCurrentSession(oldCurrent);
throw err;
}

Expand Down Expand Up @@ -306,14 +415,8 @@ export const whoami = new Command("whoami")
return;
}

const client = await sdkForConsole(false);
const accountClient = new Account(client);

let account;

try {
account = await accountClient.get();
} catch (_) {
const account = await getCurrentAccount();
if (!account) {
error("No user is signed in. To sign in, run 'appwrite login'");
return;
}
Expand Down Expand Up @@ -358,6 +461,8 @@ export const login = new Command("login")
`Multi-factor authentication login factor: totp, email, phone or recoveryCode`,
)
.option(`--code [code]`, `Multi-factor code`)
.option(`--switch`, `Switch to another signed-in account`)
.option(`--new`, `Sign in to another account`)
.configureHelp({
helpWidth: process.stdout.columns || 80,
})
Expand Down
27 changes: 25 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

export function normalizeCloudConsoleEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint);
if (
url.hostname === "cloud.appwrite.io" ||
url.hostname.endsWith(".cloud.appwrite.io")
) {
return "https://cloud.appwrite.io/v1";
}
} catch (_error) {
return endpoint;
}

return endpoint;
}

function ensureDirectoryForFile(filePath: string): void {
const dir = _path.dirname(filePath);
if (!fs.existsSync(dir)) {
Expand Down Expand Up @@ -1201,10 +1217,17 @@ class Global extends Config<GlobalConfigData> {
sessions.forEach((sessionId) => {
const sessionData = (this.data as any)[sessionId];
const email = sessionData[Global.PREFERENCE_EMAIL] ?? "";
const endpoint = sessionData[Global.PREFERENCE_ENDPOINT] ?? "";
const endpoint = normalizeCloudConsoleEndpoint(
sessionData[Global.PREFERENCE_ENDPOINT] ?? "",
);
const key = `${email}|${endpoint}`;
const existingSession = sessionMap.get(key);

if (sessionId === current || !sessionMap.has(key)) {
if (
!existingSession ||
sessionId === current ||
existingSession.id !== current
) {
sessionMap.set(key, {
id: sessionId,
endpoint,
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 = '20.0.0';
export const SDK_VERSION = '20.1.0';
export const SDK_NAME = 'Command Line';
export const SDK_PLATFORM = 'console';
export const SDK_LANGUAGE = 'cli';
Expand Down
Loading