diff --git a/CHANGELOG.md b/CHANGELOG.md index 29236f1..89abbfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Change Log +## 24.1.0 + +* Added: `previousKey` on an attribute or column renames it in place on `push` +* Fixed: `client --endpoint` reuses or switches an existing session instead of minting an unauthenticated stub +* Fixed: `client --reset` asks for confirmation before signing out signed-in accounts +* Fixed: Commands that need a session now list signed-in accounts and suggest `login --switch` +* Fixed: Regional cloud endpoints match their canonical host when resolving a session +* Fixed: `push` updates attributes in place instead of recreating them where the API allows it +* Fixed: `push` resizes a `string` attribute in place instead of recreating it +* Fixed: `push` no longer recreates indexes whose attributes were renamed +* Fixed: `push` aborts on a failed attribute update instead of continuing past it +* Fixed: `push` waits for index deletion to finish before recreating indexes +* Fixed: `push` leaves `encrypt` untouched when the config omits it +* Fixed: `logout` signs out the selected account instead of the current session id +* Fixed: Table output wraps long reasons instead of truncating them mid-word +* Updated: Project policy limits for password history, session duration, session count, and users + ## 24.0.0 * Added: `login` against Cloud now signs in through your browser diff --git a/README.md b/README.md index a92f8bc..73949f1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Once the installation is complete, you can verify the install using ```sh $ appwrite -v -24.0.0 +24.1.0 ``` ### Install using prebuilt binaries @@ -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 -24.0.0 +24.1.0 ``` ## Getting Started diff --git a/bun.lock b/bun.lock index 7cba9a4..f2d1bac 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "appwrite-cli", "dependencies": { - "@appwrite.io/console": "15.7.0", + "@appwrite.io/console": "15.8.0", "@napi-rs/keyring": "^1.3.0", "chalk": "4.1.2", "chokidar": "^3.6.0", @@ -54,7 +54,7 @@ "tmp": "^0.2.6", }, "packages": { - "@appwrite.io/console": ["@appwrite.io/console@15.7.0", "", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-43FVjVbqdedkoiMrAKCFpEnAhBb7YruW9fHqUJ/uvzMT2yg8g8cx+s5b0gkmiC276URALa5BzkYUBoTLw/Dnsw=="], + "@appwrite.io/console": ["@appwrite.io/console@15.8.0", "", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-OJvWFBm7x2Si49UzzRfTy9tq5wT7M4AWJ7+ii/xJYfEpiw87wgaCZSDln8LAmPNGqRZNtvyXCalf8K1aaD+0ig=="], "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], @@ -296,7 +296,7 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], - "acorn": ["acorn@8.17.0", "", { "bin": "bin/acorn" }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "acorn": ["acorn@8.18.0", "", { "bin": "bin/acorn" }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], diff --git a/docs/examples/project/update-session-duration-policy.md b/docs/examples/project/update-session-duration-policy.md index dbb80ef..a454de1 100644 --- a/docs/examples/project/update-session-duration-policy.md +++ b/docs/examples/project/update-session-duration-policy.md @@ -1,4 +1,4 @@ ```bash appwrite project update-session-duration-policy \ - --duration 5 + --duration 60 ``` diff --git a/docs/examples/project/update-user-limit-policy.md b/docs/examples/project/update-user-limit-policy.md index 45e0fe9..fcdecd6 100644 --- a/docs/examples/project/update-user-limit-policy.md +++ b/docs/examples/project/update-user-limit-policy.md @@ -1,4 +1,4 @@ ```bash appwrite project update-user-limit-policy \ - --total 1 + --total 0 ``` diff --git a/install.ps1 b/install.ps1 index cc94c18..291334b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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/24.0.0/appwrite-cli-win-x64.exe" -$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/24.0.0/appwrite-cli-win-arm64.exe" +$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/24.1.0/appwrite-cli-win-x64.exe" +$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/24.1.0/appwrite-cli-win-arm64.exe" $APPWRITE_BINARY_NAME = "appwrite.exe" diff --git a/install.sh b/install.sh index aec0f10..8c48296 100644 --- a/install.sh +++ b/install.sh @@ -120,7 +120,7 @@ verifyMacOSCodeSignature() { downloadBinary() { echo "[2/5] Downloading executable for $OS ($ARCH) ..." - GITHUB_LATEST_VERSION="24.0.0" + GITHUB_LATEST_VERSION="24.1.0" GITHUB_FILE="appwrite-cli-${OS}-${ARCH}" GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE" diff --git a/lib/auth/session.ts b/lib/auth/session.ts index 369cfd6..d8c509f 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,4 +1,8 @@ -import { globalConfig, normalizeCloudConsoleEndpoint } from "../config.js"; +import { + endpointsMatch, + globalConfig, + normalizeCloudConsoleEndpoint, +} from "../config.js"; import type { SessionData } from "../types.js"; import ClientLegacy from "../client.js"; import { OAUTH2_CLIENT_ID } from "../constants.js"; @@ -32,6 +36,81 @@ export const createLegacyConsoleClient = ( export const hasAuthSession = (): boolean => globalConfig.getAccessToken() !== "" || globalConfig.getCookie() !== ""; +/** + * Whether a stored session record has console auth material (token or cookie), + * independent of which session is currently active. + */ +export const isAuthenticatedSession = (sessionId: string): boolean => { + const session = getSession(sessionId); + return Boolean(session?.accessToken || session?.cookie); +}; + +/** + * Find existing sessions that match an endpoint. Prefers authenticated + * sessions over endpoint-only stubs so `client --endpoint` can reuse login + * state instead of minting a new unauthenticated record. + */ +export const findSessionForEndpoint = ( + endpoint: string, +): { authenticated?: string; endpointOnly?: string } => { + let authenticated: string | undefined; + let endpointOnly: string | undefined; + + for (const sessionId of globalConfig.getSessionIds()) { + const session = getSession(sessionId); + if (!session?.endpoint || !endpointsMatch(session.endpoint, endpoint)) { + continue; + } + + if (isAuthenticatedSession(sessionId)) { + if (!authenticated) { + authenticated = sessionId; + } + } else if (!endpointOnly) { + endpointOnly = sessionId; + } + } + + return { authenticated, endpointOnly }; +}; + +/** + * Deduped signed-in accounts (email + auth material) for recoverable + * messaging when the current session pointer is unauthenticated. + */ +export const getSignedInAccounts = (): Array<{ + id: string; + email: string; + endpoint: string; +}> => { + const accounts = new Map< + string, + { id: string; email: string; endpoint: string } + >(); + const current = globalConfig.getCurrentSession(); + + for (const sessionId of globalConfig.getSessionIds()) { + const session = getSession(sessionId); + if (!session?.email || !isAuthenticatedSession(sessionId)) { + continue; + } + + const endpoint = normalizeCloudConsoleEndpoint(session.endpoint ?? ""); + const key = `${session.email}|${endpoint}`; + const existing = accounts.get(key); + + if (!existing || sessionId === current || existing.id !== current) { + accounts.set(key, { + id: sessionId, + email: session.email, + endpoint: session.endpoint ?? endpoint, + }); + } + } + + return Array.from(accounts.values()); +}; + /** * A session that exists only in local config (no server-side credential to * revoke), e.g. an endpoint/key-only entry created by `client --endpoint`. diff --git a/lib/commands/config-validations.ts b/lib/commands/config-validations.ts index 1489228..f45acf1 100644 --- a/lib/commands/config-validations.ts +++ b/lib/commands/config-validations.ts @@ -50,6 +50,7 @@ export const validateStringSize = (data: { interface AttributeOrColumn { key: string; + previousKey?: string; } interface Index { @@ -63,7 +64,8 @@ interface CollectionOrTableData { } /** - * Validates duplicate keys in attributes/columns and indexes + * Validates duplicate keys in attributes/columns and indexes, + * plus previousKey rename-hint consistency. */ export const validateContainerDuplicates = ( data: CollectionOrTableData, @@ -88,6 +90,40 @@ export const validateContainerDuplicates = ( seenKeys.add(item.key); } }); + + // Validate previousKey rename hints + const seenPreviousKeys = new Set(); + items.forEach((item, index) => { + if (!item.previousKey) { + return; + } + + if (item.previousKey === item.key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${itemType} '${item.key}' has previousKey equal to key. previousKey must be the old name, not the new one.`, + path: [itemPath, index, "previousKey"], + }); + } + + if (seenKeys.has(item.previousKey) && item.previousKey !== item.key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${itemType} '${item.key}' has previousKey '${item.previousKey}', but another ${itemType.toLowerCase()} already uses that key. Rename cannot proceed while both names exist locally.`, + path: [itemPath, index, "previousKey"], + }); + } + + if (seenPreviousKeys.has(item.previousKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Multiple ${itemType.toLowerCase()}s share previousKey '${item.previousKey}'. Each previousKey must be unique within a ${itemType === "Attribute" ? "collection" : "table"}.`, + path: [itemPath, index, "previousKey"], + }); + } else { + seenPreviousKeys.add(item.previousKey); + } + }); } // Validate duplicate index keys diff --git a/lib/commands/config.ts b/lib/commands/config.ts index d4a148c..3933f5c 100644 --- a/lib/commands/config.ts +++ b/lib/commands/config.ts @@ -248,6 +248,9 @@ const AttributeSchema = z attributes: z.array(z.string()).optional(), orders: z.array(z.string()).optional(), encrypt: z.boolean().optional(), + // Local-only hint for in-place renames via the API's newKey parameter. + // Cleared automatically on pull; ignored once the rename has been applied. + previousKey: z.string().optional(), }) .strict() .refine(validateRequiredDefault, { @@ -330,6 +333,9 @@ const ColumnSchema = z columns: z.array(z.string()).optional(), orders: z.array(z.string()).optional(), encrypt: z.boolean().optional(), + // Local-only hint for in-place renames via the API's newKey parameter. + // Cleared automatically on pull; ignored once the rename has been applied. + previousKey: z.string().optional(), }) .strict() .refine(validateRequiredDefault, { diff --git a/lib/commands/generic.ts b/lib/commands/generic.ts index cb9e601..3d10fcf 100644 --- a/lib/commands/generic.ts +++ b/lib/commands/generic.ts @@ -1,7 +1,7 @@ import inquirer from "inquirer"; import { Command } from "commander"; import { Client } from "@appwrite.io/console"; -import { globalConfig, localConfig } from "../config.js"; +import { endpointsMatch, globalConfig, localConfig } from "../config.js"; import { EXECUTABLE_NAME } from "../constants.js"; import { actionRunner, @@ -11,14 +11,19 @@ import { error, parse, log, + warn, drawTable, cliConfig, } from "../parser.js"; import ID from "../id.js"; -import { questionsLogout } from "../questions.js"; +import { questionsClientReset, questionsLogout } from "../questions.js"; import { getCurrentAccount, loginCommand } from "../auth/login.js"; import { + findSessionForEndpoint, + getSession, + getSignedInAccounts, hasAuthSession, + isAuthenticatedSession, logoutSessions, planSessionLogout, restoreCurrentSessionFallback, @@ -35,20 +40,52 @@ const logMessages = { }, logoutSuccess: "Logged out successfully", clientConfigUpdated: "Client configuration updated", + noUserSignedIn: "No user is signed in. To sign in, run 'appwrite login'", } as const; +const hintSignedInAccounts = (): void => { + if (cliConfig.json || cliConfig.raw) { + return; + } + + const accounts = getSignedInAccounts(); + if (accounts.length === 0) { + return; + } + + log("Signed-in accounts are still available:"); + for (const account of accounts) { + log(` ${account.email} (${account.endpoint})`); + } + log(`Run '${EXECUTABLE_NAME} login --switch' to select one.`); +}; + +const warnDetachedAuthenticatedSession = (previousSessionId: string): void => { + if (!previousSessionId || !isAuthenticatedSession(previousSessionId)) { + return; + } + + const previous = getSession(previousSessionId); + const email = previous?.email ? ` (${previous.email})` : ""; + warn( + `Signed-in account${email} is still available but no longer active. Run '${EXECUTABLE_NAME} login --switch' to return to it.`, + ); +}; + export const whoami = new Command("whoami") .description(commandDescriptions["whoami"]) .action( actionRunner(async () => { if (globalConfig.getEndpoint() === "" || !hasAuthSession()) { - error("No user is signed in. To sign in, run 'appwrite login'"); + error(logMessages.noUserSignedIn); + hintSignedInAccounts(); return; } const account = await getCurrentAccount(); if (!account) { - error("No user is signed in. To sign in, run 'appwrite login'"); + error(logMessages.noUserSignedIn); + hintSignedInAccounts(); return; } @@ -110,13 +147,17 @@ export const logout = new Command("logout") const current = globalConfig.getCurrentSession(); const originalCurrent = current; - if (current === "" || !sessions.length) { + // The picker only offers sessions with an email, so count those — counting + // endpoint-only entries would open a checkbox with no selectable options. + const accounts = sessions.filter((session) => session.email); + + if (current === "" || !accounts.length) { log(logMessages.noActiveSessions); return; } - if (sessions.length === 1) { + if (accounts.length === 1) { const { failed, failedIds, errors } = await logoutSessions( - planSessionLogout([current]), + planSessionLogout([accounts[0].id]), ); if (failed > 0) { @@ -245,7 +286,6 @@ export const client = new Command("client") if (endpoint !== undefined) { try { - const id = ID.unique(); const url = new URL(endpoint); if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(); @@ -263,9 +303,40 @@ export const client = new Command("client") if (!response.version) { throw new Error(); } - globalConfig.setCurrentSession(id); - globalConfig.addSession(id, { endpoint }); - globalConfig.setEndpoint(endpoint); + + const previous = globalConfig.getCurrentSession(); + const match = findSessionForEndpoint(endpoint); + + if ( + previous && + endpointsMatch(getSession(previous)?.endpoint ?? "", endpoint) && + (isAuthenticatedSession(previous) || !match.authenticated) + ) { + // Already on the best available session for this endpoint — keep + // current and refresh the stored value so regional hosts stay as + // requested. + globalConfig.setEndpoint(endpoint); + } else if (match.authenticated) { + globalConfig.setCurrentSession(match.authenticated); + globalConfig.setEndpoint(endpoint); + const email = getSession(match.authenticated)?.email; + if (email) { + log(`Using signed-in account ${email}`); + } + } else if (match.endpointOnly) { + globalConfig.setCurrentSession(match.endpointOnly); + globalConfig.setEndpoint(endpoint); + warnDetachedAuthenticatedSession(previous); + } else if (previous && !isAuthenticatedSession(previous)) { + // Update an existing endpoint-only stub in place. + globalConfig.setEndpoint(endpoint); + } else { + const id = ID.unique(); + globalConfig.addSession(id, { endpoint }); + globalConfig.setCurrentSession(id); + globalConfig.setEndpoint(endpoint); + warnDetachedAuthenticatedSession(previous); + } } catch (_) { throw new Error( "Invalid endpoint or your Appwrite server is not running as expected.", @@ -296,6 +367,21 @@ export const client = new Command("client") } if (reset !== undefined) { + const accounts = getSignedInAccounts(); + if (accounts.length > 0 && !cliConfig.force) { + if (!process.stdin.isTTY) { + throw new Error( + `Resetting will sign out ${accounts.map((account) => account.email).join(", ")}. Re-run with --force to confirm.`, + ); + } + + const answers = await inquirer.prompt(questionsClientReset(accounts)); + if (!answers.confirm) { + log("Reset cancelled."); + return; + } + } + const originalCurrent = globalConfig.getCurrentSession(); const { failed, failedIds, errors } = await logoutSessions( globalConfig.getSessionIds(), diff --git a/lib/commands/push.ts b/lib/commands/push.ts index bef2aa5..0abbb76 100644 --- a/lib/commands/push.ts +++ b/lib/commands/push.ts @@ -2946,6 +2946,23 @@ export class Push { table.columns, table as Collection, ); + + // Server rewrites index column refs during rename; mirror that on the + // stale remote snapshot so the index pass does not spuriously recreate. + const renameMap = new Map( + columnsResult.renames.map((r) => [r.from, r.to]), + ); + if (renameMap.size && Array.isArray(table.remoteVersion.indexes)) { + table.remoteVersion.indexes = table.remoteVersion.indexes.map( + (idx: any) => ({ + ...idx, + columns: idx.columns?.map( + (c: string) => renameMap.get(c) ?? c, + ), + }), + ); + } + const indexesResult = await attributes.attributesToCreate( table.remoteVersion.indexes, table.indexes, @@ -3128,6 +3145,25 @@ export class Push { collection.attributes ?? [], collection as Collection, ); + + // Server rewrites index attribute refs during rename; mirror that on the + // stale remote snapshot so the index pass does not spuriously recreate. + const renameMap = new Map( + collectionAttributesResult.renames.map((r) => [r.from, r.to]), + ); + if ( + renameMap.size && + Array.isArray(collection.remoteVersion!.indexes) + ) { + collection.remoteVersion!.indexes = + collection.remoteVersion!.indexes.map((idx: any) => ({ + ...idx, + attributes: idx.attributes?.map( + (a: string) => renameMap.get(a) ?? a, + ), + })); + } + const indexesResult = await attributesHelper.attributesToCreate( collection.remoteVersion!.indexes, collection.indexes ?? [], diff --git a/lib/commands/services/project.ts b/lib/commands/services/project.ts index b7f2ead..a48edd2 100644 --- a/lib/commands/services/project.ts +++ b/lib/commands/services/project.ts @@ -1464,7 +1464,7 @@ const projectUpdatePasswordHistoryPolicyCommand = project .description(`Updates one of password strength policies. Based on total length configured, previous password hashes are stored, and users cannot choose a new password that is already stored in the passwird history list, when updating an user password, or setting new one through password recovery. Keep in mind, while password history policy is disabled, the history is not being stored. Enabling the policy will not have any history on existing users, and it will only start to collect and enforce the policy on password changes since the policy is enabled.`) - .requiredOption(`--total `, `Set the password history length per user. Value can be between 1 and 5000, or null to disable the limit.`, parseInteger) + .requiredOption(`--total `, `Set the password history length per user. Value can be between 1 and 20, or null to disable the limit.`, parseInteger) .action( actionRunner( async ({ total }) => @@ -1536,7 +1536,7 @@ const projectUpdateSessionAlertPolicyCommand = project const projectUpdateSessionDurationPolicyCommand = project .command(`update-session-duration-policy`) .description(`Update maximum duration how long sessions created within a project should stay active for.`) - .requiredOption(`--duration `, `Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.`, parseInteger) + .requiredOption(`--duration `, `Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.`, parseInteger) .action( actionRunner( async ({ duration }) => @@ -1560,7 +1560,7 @@ const projectUpdateSessionInvalidationPolicyCommand = project const projectUpdateSessionLimitPolicyCommand = project .command(`update-session-limit-policy`) .description(`Update the maximum number of sessions allowed per user. When the limit is hit, the oldest session will be deleted to make room for new one.`) - .requiredOption(`--total `, `Set the maximum number of sessions allowed per user. Value can be between 1 and 5000, or null to disable the limit.`, parseInteger) + .requiredOption(`--total `, `Set the maximum number of sessions allowed per user. Value can be between 1 and 100.`, parseInteger) .action( actionRunner( async ({ total }) => @@ -1572,7 +1572,7 @@ const projectUpdateSessionLimitPolicyCommand = project const projectUpdateUserLimitPolicyCommand = project .command(`update-user-limit-policy`) .description(`Update the maximum number of users in the project. When the limit is hit or amount of existing users already exceeded the limit, all users remain active, but new user sign up will be prohibited.`) - .requiredOption(`--total `, `Set the maximum number of users allowed in the project. Value can be between 1 and 5000, or null to disable the limit.`, parseInteger) + .requiredOption(`--total `, `Set the maximum number of users allowed in the project. Value can be between 0 and 10000. Use 0 or null to disable the limit.`, parseInteger) .action( actionRunner( async ({ total }) => diff --git a/lib/commands/services/tablesdb.ts b/lib/commands/services/tablesdb.ts index 883213f..0570ef5 100644 --- a/lib/commands/services/tablesdb.ts +++ b/lib/commands/services/tablesdb.ts @@ -76,10 +76,11 @@ const tablesDBCreateCommand = tablesDB ) .option(`--specification `, `Database specification. Defaults to \`serverless\`, which creates the database on the shared pool. Any other value provisions a dedicated database on that specification.`) .option(`--replicas `, `Number of high availability replicas (0-5) for the dedicated database backing this database. Requires a dedicated \`specification\`; must be 0 for a serverless database. High availability is enabled when greater than 0.`, parseInteger) + .option(`--sync-mode `, `Replication sync mode for the dedicated database backing this database. Requires a dedicated \`specification\`; the mode is only in force once there is at least one replica. Allowed values: async, sync, quorum.`) .action( actionRunner( - async ({ databaseId, name, enabled, specification, replicas }) => - parse(await (await getTablesDBClient()).create(databaseId, name, enabled, specification, replicas)), + async ({ databaseId, name, enabled, specification, replicas, syncMode }) => + parse(await (await getTablesDBClient()).create(databaseId, name, enabled, specification, replicas, syncMode)), ), ); @@ -212,10 +213,11 @@ const tablesDBUpdateCommand = tablesDB ) .option(`--specification `, `Database specification. Resizing between dedicated specifications changes cpu, memory, storage and the connection ceiling via a rolling cutover with zero downtime. Moving a \`serverless\` database onto a dedicated specification is a data migration, not a resize.`) .option(`--replicas `, `Number of high availability replicas (0-5) for the dedicated database backing this database. Only valid when the database is backed by a dedicated specification. High availability is enabled when greater than 0.`, parseInteger) + .option(`--sync-mode `, `Replication sync mode for the dedicated database backing this database. Only valid when the database is backed by a dedicated specification; the mode is only in force once there is at least one replica. Allowed values: async, sync, quorum.`) .action( actionRunner( - async ({ databaseId, name, enabled, specification, replicas }) => - parse(await (await getTablesDBClient()).update(databaseId, name, enabled, specification, replicas)), + async ({ databaseId, name, enabled, specification, replicas, syncMode }) => + parse(await (await getTablesDBClient()).update(databaseId, name, enabled, specification, replicas, syncMode)), ), ); diff --git a/lib/commands/utils/attributes.ts b/lib/commands/utils/attributes.ts index 3cda601..22c1376 100644 --- a/lib/commands/utils/attributes.ts +++ b/lib/commands/utils/attributes.ts @@ -1,21 +1,96 @@ import chalk from "chalk"; import { getDatabasesService } from "../../services.js"; -import { KeysAttributes } from "../../config.js"; -import { log, success, error, cliConfig, drawTable } from "../../parser.js"; +import { log, warn, success, cliConfig, drawTable } from "../../parser.js"; import { Pools } from "./pools.js"; import inquirer from "inquirer"; import type { Client } from "@appwrite.io/console"; -const changeableKeys = [ - "status", - "required", - "xdefault", - "elements", - "min", - "max", - "default", - "error", -]; +/** + * Per-type field rules for push diffing. + * - updatable: can be changed in place via update*Attribute / update*Column + * - recreate: require delete + recreate (no update API accepts them) + * Fields in neither set are ignored (server-derived or irrelevant). + */ +interface FieldRules { + updatable: string[]; + recreate: string[]; +} + +const COMMON_RECREATE_KEYS = ["type", "array", "encrypt", "format"]; + +const getAttributeFieldRules = (attribute: any): FieldRules => { + const type = attribute?.type; + const format = attribute?.format || ""; + + switch (type) { + case "string": + switch (format) { + case "enum": + return { + updatable: ["required", "default", "elements"], + recreate: COMMON_RECREATE_KEYS, + }; + case "email": + case "url": + case "ip": + return { + updatable: ["required", "default"], + recreate: COMMON_RECREATE_KEYS, + }; + default: + return { + updatable: ["required", "default", "size"], + recreate: COMMON_RECREATE_KEYS, + }; + } + case "varchar": + return { + updatable: ["required", "default", "size"], + recreate: COMMON_RECREATE_KEYS, + }; + case "text": + case "mediumtext": + case "longtext": + case "boolean": + case "datetime": + case "point": + case "linestring": + case "polygon": + return { + updatable: ["required", "default"], + recreate: COMMON_RECREATE_KEYS, + }; + case "integer": + case "bigint": + case "double": + return { + updatable: ["required", "default", "min", "max"], + recreate: COMMON_RECREATE_KEYS, + }; + case "relationship": + return { + updatable: ["onDelete"], + recreate: [ + "type", + "relatedTable", + "relatedCollection", + "relationType", + "twoWay", + "twoWayKey", + ], + }; + default: + return { + updatable: ["required", "default"], + recreate: COMMON_RECREATE_KEYS, + }; + } +}; + +const INDEX_FIELD_RULES: FieldRules = { + updatable: [], + recreate: ["type", "attributes", "columns", "orders"], +}; export interface AttributeChange { key: string; @@ -24,6 +99,13 @@ export interface AttributeChange { action: string; } +export interface AttributeRename { + from: string; + to: string; + /** Remote attribute snapshot used for the update* call (key = from). */ + attribute: any; +} + export interface Collection { $id: string; databaseId: string; @@ -113,33 +195,50 @@ export class Attributes { local: any, reason: string, key: string, + immutable: boolean = false, ): string => { + // Omitted local fields mean "leave remote as-is" (e.g. encrypt not in config). + if (local === undefined) { + return reason; + } + if (this.isEmpty(remote) && this.isEmpty(local)) { return reason; } + const suffix = immutable + ? " (cannot be changed in place, requires recreation)" + : ""; + if (Array.isArray(remote) && Array.isArray(local)) { if (JSON.stringify(remote) !== JSON.stringify(local)) { const bol = reason === "" ? "" : "\n"; - reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}`; + reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}${suffix}`; } } else if (!this.isEqual(remote, local)) { const bol = reason === "" ? "" : "\n"; - reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}`; + reason += `${bol}${key} changed from ${chalk.red(remote)} to ${chalk.green(local)}${suffix}`; } return reason; }; + private getFieldRules = ( + entity: any, + isIndex: boolean = false, + ): FieldRules => (isIndex ? INDEX_FIELD_RULES : getAttributeFieldRules(entity)); + /** - * Check if attribute non-changeable fields has been changed - * If so return the differences as an object. + * Check if attribute fields have changed. + * When recreating=true, only immutable (recreate-forcing) fields are compared. + * When recreating=false, only updatable fields are compared. */ private checkAttributeChanges = ( remote: any, local: any, collection: Collection, recreating: boolean = true, + isIndex: boolean = false, ): AttributeChange | undefined => { if (local === undefined) { return undefined; @@ -149,24 +248,17 @@ export class Attributes { const action = chalk.cyan(recreating ? "recreating" : "changing"); let reason = ""; const attribute = recreating ? remote : local; + const rules = this.getFieldRules(local, isIndex); + const keys = recreating ? rules.recreate : rules.updatable; - for (const key of Object.keys(remote)) { - if (!KeysAttributes.has(key)) { - continue; - } - - if (changeableKeys.includes(key)) { - if (!recreating) { - reason = this.compareAttribute(remote[key], local[key], reason, key); - } - continue; - } - - if (!recreating) { - continue; - } - - reason = this.compareAttribute(remote[key], local[key], reason, key); + for (const key of keys) { + reason = this.compareAttribute( + remote[key], + local[key], + reason, + key, + recreating, + ); } return reason === "" @@ -386,11 +478,41 @@ export class Attributes { } }; + private formatUpdateError = (attribute: any, err: unknown): string => { + const message = String(err); + const key = attribute?.key ?? "unknown"; + const isResize = + message.includes("attribute_invalid_resize") || + message.includes("column_invalid_resize") || + message.includes("invalid_resize"); + + if (isResize) { + return ( + `Failed to update "${key}": existing values exceed the new size. ` + + `Increase the size, shorten existing data, or recreate the attribute. ` + + `(${message})` + ); + } + + return `Failed to update "${key}": ${message}`; + }; + public updateAttribute = async ( databaseId: string, collectionId: string, attribute: any, + newKey?: string, ): Promise => { + // Indexes have no update endpoint; callers must recreate them. + if ( + Array.isArray(attribute.attributes) || + Array.isArray(attribute.columns) + ) { + throw new Error( + `Indexes cannot be updated in place (key: ${attribute.key}). Recreate the index instead.`, + ); + } + const databasesService = await getDatabasesService(this.client); switch (attribute.type) { case "string": @@ -402,6 +524,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "url": return databasesService.updateUrlAttribute({ @@ -410,6 +533,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "ip": return databasesService.updateIpAttribute({ @@ -418,6 +542,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "enum": return databasesService.updateEnumAttribute({ @@ -427,6 +552,7 @@ export class Attributes { elements: attribute.elements, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); default: return databasesService.updateStringAttribute({ @@ -435,6 +561,8 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + size: attribute.size, + newKey: newKey, }); } case "varchar": @@ -445,6 +573,7 @@ export class Attributes { required: attribute.required, xdefault: attribute.default, size: attribute.size, + newKey: newKey, }); case "text": return databasesService.updateTextAttribute({ @@ -453,6 +582,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "mediumtext": return databasesService.updateMediumtextAttribute({ @@ -461,6 +591,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "longtext": return databasesService.updateLongtextAttribute({ @@ -469,6 +600,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "integer": return databasesService.updateIntegerAttribute({ @@ -479,6 +611,7 @@ export class Attributes { min: attribute.min, max: attribute.max, xdefault: attribute.default, + newKey: newKey, }); case "bigint": return databasesService.updateBigIntAttribute({ @@ -489,6 +622,7 @@ export class Attributes { min: attribute.min, max: attribute.max, xdefault: attribute.default, + newKey: newKey, }); case "double": return databasesService.updateFloatAttribute({ @@ -499,6 +633,7 @@ export class Attributes { min: attribute.min, max: attribute.max, xdefault: attribute.default, + newKey: newKey, }); case "boolean": return databasesService.updateBooleanAttribute({ @@ -507,6 +642,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "datetime": return databasesService.updateDatetimeAttribute({ @@ -515,6 +651,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "relationship": return databasesService.updateRelationshipAttribute({ @@ -522,6 +659,7 @@ export class Attributes { collectionId, key: attribute.key, onDelete: attribute.onDelete, + newKey: newKey, }); case "point": return databasesService.updatePointAttribute({ @@ -530,6 +668,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "linestring": return databasesService.updateLineAttribute({ @@ -538,6 +677,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); case "polygon": return databasesService.updatePolygonAttribute({ @@ -546,6 +686,7 @@ export class Attributes { key: attribute.key, required: attribute.required, xdefault: attribute.default, + newKey: newKey, }); default: throw new Error(`Unsupported attribute type: ${attribute.type}`); @@ -585,6 +726,67 @@ export class Attributes { private isChildSideRelationship = (attribute: any): boolean => attribute.type === "relationship" && attribute.side === "child"; + /** + * Resolve previousKey rename hints against the remote snapshot. + * Patches matched remote keys in place (before classification) so a pure + * rename does not surface as delete+add. API calls are executed later. + */ + private resolveRenames = ( + remoteAttributes: any[], + localAttributes: any[], + collection: Collection, + ): { renames: AttributeRename[]; renameChanges: AttributeChange[] } => { + const renames: AttributeRename[] = []; + const renameChanges: AttributeChange[] = []; + + for (const local of localAttributes) { + if (!local.previousKey || local.previousKey === local.key) { + continue; + } + + const remotePrevious = remoteAttributes.find( + (attr) => attr.key === local.previousKey, + ); + const remoteCurrent = remoteAttributes.find( + (attr) => attr.key === local.key, + ); + + if (remotePrevious && !remoteCurrent) { + // Pending rename: patch remote key so classification matches by new name. + renames.push({ + from: local.previousKey, + to: local.key, + attribute: { ...remotePrevious }, + }); + renameChanges.push({ + key: `${chalk.yellow(local.key)} in ${collection.name} (${collection["$id"]})`, + attribute: { ...remotePrevious }, + reason: `key renamed from ${chalk.red(local.previousKey)} to ${chalk.green(local.key)}`, + action: chalk.cyan("renaming"), + }); + remotePrevious.key = local.key; + continue; + } + + if (remoteCurrent && !remotePrevious) { + // Already renamed on the server; hint is stale and harmless. + continue; + } + + if (!remotePrevious && !remoteCurrent) { + // Fresh create; ignore the hint and let the add path handle it. + continue; + } + + // Both keys exist remotely — cannot rename without a collision. + warn( + `Ignoring previousKey "${local.previousKey}" for "${local.key}" in ${collection.name} (${collection["$id"]}): both keys already exist remotely. "${local.previousKey}" will be treated as a deletion if it is absent from the local config.`, + ); + } + + return { renames, renameChanges }; + }; + /** * Filter deleted and recreated attributes, * return list of attributes to create and whether any changes were made @@ -594,7 +796,11 @@ export class Attributes { localAttributes: any[], collection: Collection, isIndex: boolean = false, - ): Promise<{ attributes: any[]; hasChanges: boolean }> => { + ): Promise<{ + attributes: any[]; + hasChanges: boolean; + renames: AttributeRename[]; + }> => { // Filter out child-side relationships from both local and remote attributes for comparison // Child-side relationships are auto-generated by Appwrite when creating two-way relationships // from the parent side, so we should not compare or try to create them directly @@ -605,6 +811,16 @@ export class Attributes { (attr) => !this.isChildSideRelationship(attr), ); + // Resolve previousKey hints before classification so renames do not + // appear as delete+add. Indexes have no rename API — skip entirely. + const { renames, renameChanges } = isIndex + ? { renames: [] as AttributeRename[], renameChanges: [] as AttributeChange[] } + : this.resolveRenames( + filteredRemoteAttributes, + filteredLocalAttributes, + collection, + ); + const deleting = filteredRemoteAttributes .filter( (attribute) => @@ -623,6 +839,8 @@ export class Attributes { attribute, this.attributesContains(attribute, filteredLocalAttributes), collection, + true, + isIndex, ), ) .filter((attribute) => attribute !== undefined) as AttributeChange[]; @@ -633,6 +851,7 @@ export class Attributes { this.attributesContains(attribute, filteredLocalAttributes), collection, false, + isIndex, ), ) .filter((attribute) => attribute !== undefined) @@ -642,9 +861,15 @@ export class Attributes { ) as AttributeChange[]; let changedAttributes: any[] = []; - const changing = [...deleting, ...adding, ...conflicts, ...changes]; + const changing = [ + ...renameChanges, + ...deleting, + ...adding, + ...conflicts, + ...changes, + ]; if (changing.length === 0) { - return { attributes: changedAttributes, hasChanges: false }; + return { attributes: changedAttributes, hasChanges: false, renames: [] }; } log( @@ -690,7 +915,78 @@ export class Attributes { } if ((await this.getConfirmation()) !== true) { - return { attributes: changedAttributes, hasChanges: false }; + return { attributes: changedAttributes, hasChanges: false, renames: [] }; + } + } + + // Apply renames before field updates / deletions so a failed rename + // never leaves data half-destroyed. + if (renames.length > 0) { + const renameResults = await Promise.allSettled( + renames.map((rename) => + this.updateAttribute( + collection["databaseId"], + collection["$id"], + rename.attribute, + rename.to, + ), + ), + ); + + const renameFailures = renameResults + .map((result, index) => + result.status === "rejected" + ? this.formatUpdateError( + { key: renames[index].to }, + result.reason, + ) + : null, + ) + .filter((message): message is string => message !== null); + + if (renameFailures.length > 0) { + throw new Error( + `Error renaming attribute for ${collection["$id"]}:\n${renameFailures.join("\n")}`, + ); + } + + const renameReady = await this.pools.expectAttributes( + collection["databaseId"], + collection["$id"], + renames.map((rename) => rename.to), + ); + + if (!renameReady) { + throw new Error( + `Attribute rename timed out waiting for keys: ${renames.map((r) => r.to).join(", ")}`, + ); + } + } + + // Apply in-place updates first so failures abort before any deletions. + if (changes.length > 0) { + const updateResults = await Promise.allSettled( + changes.map((change) => + this.updateAttribute( + collection["databaseId"], + collection["$id"], + change.attribute, + ), + ), + ); + + const failures = updateResults + .map((result, index) => + result.status === "rejected" + ? this.formatUpdateError(changes[index].attribute, result.reason) + : null, + ) + .filter((message): message is string => message !== null); + + if (failures.length > 0) { + throw new Error( + `Error updating ${isIndex ? "index" : "attribute"} for ${collection["$id"]}:\n${failures.join("\n")}`, + ); } } @@ -706,45 +1002,33 @@ export class Attributes { ); } - if (changes.length > 0) { - changedAttributes = changes.map((change) => change.attribute); - try { - await Promise.all( - changedAttributes.map((changed) => - this.updateAttribute( - collection["databaseId"], - collection["$id"], - changed, - ), - ), - ); - } catch (err) { - error( - `Error updating attribute for ${collection["$id"]}: ${String(err)}`, - ); - } - } - const deletingAttributes = deleting.map((change) => change.attribute); await Promise.all( deletingAttributes.map((attribute) => this.deleteAttribute(collection, attribute, isIndex), ), ); - const attributeKeys = deletingAttributes.map( - (attribute: any) => attribute.key, - ); - if (attributeKeys.length) { - const deleteAttributesPoolStatus = - await this.pools.waitForAttributeDeletion( - collection["databaseId"], - collection["$id"], - attributeKeys, - ); + // Wait for both removals and recreate-driven deletes before creating. + const deletedKeys = [ + ...deletingAttributes, + ...conflicts.map((change) => change.attribute), + ].map((attribute: any) => attribute.key); - if (!deleteAttributesPoolStatus) { - throw new Error("Attribute deletion timed out."); + if (deletedKeys.length) { + const waitForDeletion = isIndex + ? this.pools.waitForIndexDeletion + : this.pools.waitForAttributeDeletion; + const deletePoolStatus = await waitForDeletion( + collection["databaseId"], + collection["$id"], + deletedKeys, + ); + + if (!deletePoolStatus) { + throw new Error( + `${isIndex ? "Index" : "Attribute"} deletion timed out.`, + ); } } @@ -752,7 +1036,7 @@ export class Attributes { (attribute) => !this.attributesContains(attribute, filteredRemoteAttributes), ); - return { attributes: newAttributes, hasChanges: true }; + return { attributes: newAttributes, hasChanges: true, renames }; }; public createIndexes = async ( diff --git a/lib/commands/utils/pools.ts b/lib/commands/utils/pools.ts index d2a9310..db27dd3 100644 --- a/lib/commands/utils/pools.ts +++ b/lib/commands/utils/pools.ts @@ -98,7 +98,7 @@ export class Pools { public waitForAttributeDeletion = async ( databaseId: string, collectionId: string, - attributeKeys: any[], + attributeKeys: string[], iteration: number = 1, ): Promise => { if (iteration > this.pollMaxDebounces) { @@ -138,11 +138,11 @@ export class Pools { "attributes", ); - const ready = attributeKeys.filter((attribute: any) => - attributes.some((a: any) => a.key === attribute.key), + const remaining = attributeKeys.filter((key) => + attributes.some((a: any) => a.key === key), ); - if (ready.length === 0) { + if (remaining.length === 0) { return true; } @@ -156,6 +156,64 @@ export class Pools { ); }; + public waitForIndexDeletion = async ( + databaseId: string, + collectionId: string, + indexKeys: string[], + iteration: number = 1, + ): Promise => { + if (iteration > this.pollMaxDebounces) { + return false; + } + + if (this.pollMaxDebounces === this.POLL_DEFAULT_VALUE) { + const steps = Math.max(1, Math.ceil(indexKeys.length / this.STEP_SIZE)); + if (steps > 1 && iteration === 1) { + this.pollMaxDebounces *= steps; + + log( + "Found a large number of indexes to be deleted. Increasing timeout to " + + (this.pollMaxDebounces * this.POLL_DEBOUNCE) / 1000 / 60 + + " minutes", + ); + } + } + + const { indexes } = await paginate( + async (args: any) => { + const databasesService = await getDatabasesService(this.client); + return await databasesService.listIndexes( + args.databaseId, + args.collectionId, + args.queries || [], + ); + }, + { + databaseId, + collectionId, + }, + 100, + "indexes", + ); + + const remaining = indexKeys.filter((key) => + indexes.some((index: any) => index.key === key), + ); + + if (remaining.length === 0) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, this.POLL_DEBOUNCE)); + + return await this.waitForIndexDeletion( + databaseId, + collectionId, + indexKeys, + iteration + 1, + ); + }; + public expectAttributes = async ( databaseId: string, collectionId: string, diff --git a/lib/config.ts b/lib/config.ts index 13dc807..07afc58 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -118,6 +118,18 @@ export function normalizeCloudConsoleEndpoint(endpoint: string): string { return endpoint; } +/** + * Compare two endpoints for session matching: normalize cloud regional hosts + * and ignore trailing slashes so `https://fra.cloud.appwrite.io/v1` matches + * `https://cloud.appwrite.io/v1`. + */ +export function endpointsMatch(a: string, b: string): boolean { + return ( + normalizeCloudConsoleEndpoint(a).replace(/\/+$/, "") === + normalizeCloudConsoleEndpoint(b).replace(/\/+$/, "") + ); +} + function ensureDirectoryForFile(filePath: string): void { const dir = _path.dirname(filePath); if (!fs.existsSync(dir)) { diff --git a/lib/constants.ts b/lib/constants.ts index 76dbc76..47c9d4d 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,7 +1,7 @@ // SDK export const SDK_TITLE = 'Appwrite'; export const SDK_TITLE_LOWER = 'appwrite'; -export const SDK_VERSION = '24.0.0'; +export const SDK_VERSION = '24.1.0'; export const SDK_NAME = 'Command Line'; export const SDK_PLATFORM = 'console'; export const SDK_LANGUAGE = 'cli'; diff --git a/lib/parser.ts b/lib/parser.ts index 5efc0a0..03227a9 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -6,6 +6,7 @@ BigInt.prototype.toJSON = function () { import chalk from "chalk"; import { InvalidArgumentError } from "commander"; import Table from "cli-table3"; +import stringWidth from "string-width"; import packageJson from "../package.json" with { type: "json" }; const { description } = packageJson; import { globalConfig } from "./config.js"; @@ -367,6 +368,33 @@ export const parse = (data: unknown): void => { const MAX_COL_WIDTH = 40; const MAX_COLUMNS = 6; +/** Visible-width cap for table cells; scales with terminal size. */ +const getMaxColWidth = (): number => { + const columns = process.stdout.columns || 120; + // Leave room for Key + Action + borders; give Reason most of the rest. + return Math.max(MAX_COL_WIDTH, Math.min(120, Math.floor(columns * 0.5))); +}; + +/** + * Truncate by visible width so chalk ANSI codes do not eat the budget and + * cut off readable text while the terminal still has space. + */ +const truncateToVisibleWidth = (str: string, max: number): string => { + if (stringWidth(str) <= max) { + return str; + } + + let result = ""; + for (const char of str) { + const next = result + char; + if (stringWidth(next) > max - 1) { + break; + } + result = next; + } + return `${result}…`; +}; + type NamedTableOptions = { indent?: string; sectionName?: string; @@ -397,7 +425,7 @@ const formatCellValue = (value: unknown): string => { ) ) { const joinedValue = value.map((item) => String(item)).join(", "); - if (joinedValue.length <= MAX_COL_WIDTH) { + if (stringWidth(joinedValue) <= getMaxColWidth()) { return joinedValue; } } @@ -409,11 +437,7 @@ const formatCellValue = (value: unknown): string => { if (keys.length === 0) return "{}"; return `{${keys.length} keys}`; } - const str = String(value); - if (str.length > MAX_COL_WIDTH) { - return str.slice(0, MAX_COL_WIDTH - 1) + "…"; - } - return str; + return truncateToVisibleWidth(String(value), getMaxColWidth()); }; const formatKeyValue = (key: string, value: unknown): string => { @@ -614,7 +638,8 @@ export const drawTable = ( const table = new Table({ head: columns.map((c) => chalk.cyan.italic.bold(c)), colWidths: columns.map(() => null) as (number | null)[], - wordWrap: false, + wordWrap: true, + wrapOnWordBoundary: true, chars: { top: " ", "top-mid": " ", diff --git a/lib/questions.ts b/lib/questions.ts index a6d8b5f..fcd8888 100644 --- a/lib/questions.ts +++ b/lib/questions.ts @@ -938,6 +938,23 @@ export const questionsLogout: Question[] = [ }, ]; +export const questionsClientReset = ( + accounts: Array<{ email: string; endpoint: string }>, +): Question[] => [ + { + type: "confirm", + name: "confirm", + message: `This will sign out ${accounts + .map((account) => + account.endpoint + ? `${account.email} (${account.endpoint})` + : account.email, + ) + .join(", ")}. Continue?`, + default: false, + }, +]; + export const questionsPushResources: Question[] = [ { type: "list", diff --git a/lib/sdks.ts b/lib/sdks.ts index d70c803..79c7a3d 100644 --- a/lib/sdks.ts +++ b/lib/sdks.ts @@ -1,4 +1,5 @@ import { + endpointsMatch, globalConfig, localConfig, normalizeCloudConsoleEndpoint, @@ -21,10 +22,7 @@ import { export const assertSessionEndpointMatches = (endpoint: string): void => { const sessionEndpoint = globalConfig.getEndpoint() || DEFAULT_ENDPOINT; - if ( - normalizeCloudConsoleEndpoint(endpoint).replace(/\/+$/, "") !== - normalizeCloudConsoleEndpoint(sessionEndpoint).replace(/\/+$/, "") - ) { + if (!endpointsMatch(endpoint, sessionEndpoint)) { throw new Error( `Endpoint ${endpoint} does not match the current login session endpoint ${sessionEndpoint}. Switch to an account for this environment with \`${EXECUTABLE_NAME} login --switch\`.`, ); diff --git a/package-lock.json b/package-lock.json index b8c0934..60e887d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "appwrite-cli", - "version": "24.0.0", + "version": "24.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite-cli", - "version": "24.0.0", + "version": "24.1.0", "license": "BSD-3-Clause", "dependencies": { - "@appwrite.io/console": "15.7.0", + "@appwrite.io/console": "15.8.0", "@napi-rs/keyring": "^1.3.0", "chalk": "4.1.2", "chokidar": "^3.6.0", @@ -50,19 +50,12 @@ "tsx": "^4.21.0", "typescript": "^5.3.3", "typescript-eslint": "^8.0.0" - }, - "overrides": { - "phin": "3.7.1", - "@xmldom/xmldom": "^0.9.10", - "tmp": "^0.2.6", - "esbuild": "^0.28.1", - "brace-expansion": "5.0.8" } }, "node_modules/@appwrite.io/console": { - "version": "15.7.0", - "resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-15.7.0.tgz", - "integrity": "sha512-43FVjVbqdedkoiMrAKCFpEnAhBb7YruW9fHqUJ/uvzMT2yg8g8cx+s5b0gkmiC276URALa5BzkYUBoTLw/Dnsw==", + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-15.8.0.tgz", + "integrity": "sha512-OJvWFBm7x2Si49UzzRfTy9tq5wT7M4AWJ7+ii/xJYfEpiw87wgaCZSDln8LAmPNGqRZNtvyXCalf8K1aaD+0ig==", "license": "BSD-3-Clause", "dependencies": { "json-bigint": "1.0.0" @@ -2395,9 +2388,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 4ff852c..18410c3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", - "version": "24.0.0", + "version": "24.1.0", "license": "BSD-3-Clause", "main": "dist/index.cjs", "module": "dist/index.js", @@ -51,7 +51,7 @@ "windows-arm64": "bun build cli.ts --compile --minify --target=bun-windows-arm64 --outfile build/appwrite-cli-win-arm64.exe" }, "dependencies": { - "@appwrite.io/console": "15.7.0", + "@appwrite.io/console": "15.8.0", "@napi-rs/keyring": "^1.3.0", "chalk": "4.1.2", "chokidar": "^3.6.0", diff --git a/scoop/appwrite.config.json b/scoop/appwrite.config.json index f955d79..0ea2889 100644 --- a/scoop/appwrite.config.json +++ b/scoop/appwrite.config.json @@ -1,12 +1,12 @@ { "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", - "version": "24.0.0", + "version": "24.1.0", "description": "The Appwrite CLI is a command-line application that allows you to interact with Appwrite and perform server-side tasks using your terminal.", "homepage": "https://github.com/appwrite/sdk-for-cli", "license": "BSD-3-Clause", "architecture": { "64bit": { - "url": "https://github.com/appwrite/sdk-for-cli/releases/download/24.0.0/appwrite-cli-win-x64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/24.1.0/appwrite-cli-win-x64.exe", "bin": [ [ "appwrite-cli-win-x64.exe", @@ -15,7 +15,7 @@ ] }, "arm64": { - "url": "https://github.com/appwrite/sdk-for-cli/releases/download/24.0.0/appwrite-cli-win-arm64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/24.1.0/appwrite-cli-win-arm64.exe", "bin": [ [ "appwrite-cli-win-arm64.exe",