diff --git a/CHANGELOG.md b/CHANGELOG.md index 408c34b4..29730ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Change Log +## 25.1.0 + +* Added: `list-organizations` and `list-projects` run at the root, alongside `login` +* Added: Hint pointing at `/v1` when an endpoint misses the API path +* Fixed: `list-organizations`, `list-projects`, and `organization get` work on self-hosted installs +* Fixed: `login --endpoint` verifies the endpoint before prompting for credentials +* Fixed: Trailing slashes in an endpoint no longer produce double-slashed request paths +* Fixed: HTML and oversized error pages are summarized instead of printed verbatim +* Fixed: `--report` issue links stay within the length GitHub accepts + ## 25.0.0 * Breaking: Removed the `organizations` command group covering billing, plans, invoices, and add-ons diff --git a/README.md b/README.md index 9c8d12b1..a00a47c5 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 -25.0.0 +25.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 -25.0.0 +25.1.0 ``` ## Getting Started diff --git a/cli.ts b/cli.ts index e8c0c90a..b1b90d54 100644 --- a/cli.ts +++ b/cli.ts @@ -47,7 +47,7 @@ import { locale } from './lib/commands/services/locale.js'; import { messaging } from './lib/commands/services/messaging.js'; import { migrations } from './lib/commands/services/migrations.js'; import { notifications } from './lib/commands/services/notifications.js'; -import { oauth2 } from './lib/commands/services/oauth2.js'; +import { oauth2, oauth2ListOrganizationsRootCommand, oauth2ListProjectsRootCommand } from './lib/commands/services/oauth2.js'; import { organization } from './lib/commands/services/organization.js'; import { presences } from './lib/commands/services/presences.js'; import { project } from './lib/commands/services/project.js'; @@ -227,6 +227,8 @@ if (process.argv.includes('-v') || process.argv.includes('--version')) { .addCommand(migrations) .addCommand(notifications) .addCommand(oauth2) + .addCommand(oauth2ListOrganizationsRootCommand) + .addCommand(oauth2ListProjectsRootCommand) .addCommand(organization) .addCommand(presences) .addCommand(project) diff --git a/docs/examples/oauth2/list-organizations.md b/docs/examples/oauth2/list-organizations.md index ea69368a..40cc3e46 100644 --- a/docs/examples/oauth2/list-organizations.md +++ b/docs/examples/oauth2/list-organizations.md @@ -1,3 +1,3 @@ ```bash -appwrite oauth2 list-organizations +appwrite list-organizations ``` diff --git a/docs/examples/oauth2/list-projects.md b/docs/examples/oauth2/list-projects.md index c8630be0..5c2f974b 100644 --- a/docs/examples/oauth2/list-projects.md +++ b/docs/examples/oauth2/list-projects.md @@ -1,3 +1,3 @@ ```bash -appwrite oauth2 list-projects +appwrite list-projects ``` diff --git a/install.ps1 b/install.ps1 index 01d4586d..e69cd30f 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/25.0.0/appwrite-cli-win-x64.exe" -$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.0.0/appwrite-cli-win-arm64.exe" +$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.1.0/appwrite-cli-win-x64.exe" +$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/25.1.0/appwrite-cli-win-arm64.exe" $APPWRITE_BINARY_NAME = "appwrite.exe" diff --git a/install.sh b/install.sh index feaf0590..63fe2092 100644 --- a/install.sh +++ b/install.sh @@ -120,7 +120,7 @@ verifyMacOSCodeSignature() { downloadBinary() { echo "[2/5] Downloading executable for $OS ($ARCH) ..." - GITHUB_LATEST_VERSION="25.0.0" + GITHUB_LATEST_VERSION="25.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/login.ts b/lib/auth/login.ts index c70662d0..cec2f1d7 100644 --- a/lib/auth/login.ts +++ b/lib/auth/login.ts @@ -30,6 +30,7 @@ import { removeLegacySessionsExcept, restoreCurrentSession, deleteServerSession, + verifyEndpoint, } from "./session.js"; import { setStoredRefreshToken } from "./refresh-token.js"; @@ -340,7 +341,10 @@ const loginWithOAuthDevice = async ({ }): Promise => { const clientId = OAUTH2_CLIENT_ID; const oauth2 = await getOauth2Service( - await sdkForConsole({ requiresAuth: false, endpointOverride: configEndpoint }), + await sdkForConsole({ + requiresAuth: false, + endpointOverride: configEndpoint, + }), ); globalConfig.addSession(id, { endpoint: configEndpoint, clientId }); @@ -478,6 +482,12 @@ export const loginCommand = async ({ const shouldUseCloudLogin = isCloudLoginEndpoint(configEndpoint); + // Check the endpoint before anything is prompted for, so a wrong endpoint + // fails immediately instead of after the email and password are typed. + if (endpoint && !shouldUseCloudLogin) { + await verifyEndpoint(configEndpoint); + } + if (shouldUseCloudLogin && (email || password || mfa || code)) { throw new Error( `Cloud sign-in happens in your browser. Run '${EXECUTABLE_NAME} login' without --email, --password, --mfa or --code — those options are for self-hosted instances.`, diff --git a/lib/auth/session.ts b/lib/auth/session.ts index d8c509fb..cdb1f77d 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -33,6 +33,55 @@ export const createLegacyConsoleClient = ( return legacyClient; }; +/** + * Confirms an endpoint really is an Appwrite API root before anything is stored + * or prompted for. Failures carry the underlying response's code and type so + * error hints (e.g. a missing `/v1`) can still fire. + */ +export const verifyEndpoint = async ( + endpoint: string, + selfSigned: boolean = globalConfig.getSelfSigned(), +): Promise => { + let protocol = ""; + try { + protocol = new URL(endpoint).protocol; + } catch { + throw new Error(`Invalid endpoint URL: ${endpoint}`); + } + + if (protocol !== "http:" && protocol !== "https:") { + throw new Error(`Invalid endpoint URL: ${endpoint}`); + } + + let caught: { code?: number; type?: string; response?: unknown } = {}; + + try { + const response = (await createLegacyConsoleClient( + endpoint, + selfSigned, + ).call("GET", "/health/version")) as { version?: string }; + + if (response.version) { + return; + } + } catch (e) { + caught = e as { code?: number; type?: string; response?: unknown }; + } + + const failure = new Error( + "Invalid endpoint or your Appwrite server is not running as expected.", + ); + Object.assign( + failure, + { endpoint }, + caught.code === undefined ? {} : { code: caught.code }, + caught.type === undefined ? {} : { type: caught.type }, + caught.response === undefined ? {} : { response: caught.response }, + ); + + throw failure; +}; + export const hasAuthSession = (): boolean => globalConfig.getAccessToken() !== "" || globalConfig.getCookie() !== ""; @@ -117,7 +166,9 @@ export const getSignedInAccounts = (): Array<{ */ export const isLocalOnlySession = (sessionId: string): boolean => { const session = getSession(sessionId); - return Boolean(session && !hasStoredRefreshToken(sessionId) && !session.cookie); + return Boolean( + session && !hasStoredRefreshToken(sessionId) && !session.cookie, + ); }; /** diff --git a/lib/client.ts b/lib/client.ts index 1a24b98d..9278ebfc 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -20,6 +20,7 @@ import { EXECUTABLE_NAME, } from "./constants.js"; import { deleteStoredRefreshToken } from "./auth/refresh-token.js"; +import { describeHttpFailure } from "./errors.js"; class Client { private endpoint: string; @@ -144,7 +145,8 @@ class Client { throw new AppwriteException("Invalid endpoint URL: " + endpoint); } - this.endpoint = endpoint; + // Paths are appended verbatim, so a trailing slash would produce `//account`. + this.endpoint = endpoint.replace(/\/+$/, ""); return this; } @@ -169,6 +171,15 @@ class Client { return { ...this.headers }; } + /** + * Records the endpoint the failed request used, so error hints can talk about + * the endpoint actually called rather than the one in config. + */ + private tagEndpoint(exception: T): T { + (exception as T & { endpoint?: string }).endpoint = this.endpoint; + return exception; + } + async call( method: string, path: string = "", @@ -227,7 +238,7 @@ class Client { }), }); } catch (error) { - throw new AppwriteException((error as Error).message); + throw this.tagEndpoint(new AppwriteException((error as Error).message)); } if (response.status >= 400) { @@ -235,9 +246,33 @@ class Client { let json: { message?: string; code?: number; type?: string } | undefined = undefined; try { - json = JSON.parse(text); + const parsed: unknown = JSON.parse(text); + if (parsed && typeof parsed === "object") { + json = parsed as { message?: string; code?: number; type?: string }; + } } catch (_error) { - throw new AppwriteException(text, response.status, "", text); + json = undefined; + } + + if (!json) { + // Proxies, load balancers and the console answer with HTML or plain + // text. Summarize it — the raw body stays on the exception for + // `--verbose` and `--report`. + const failure = describeHttpFailure( + response.status, + text, + response.statusText, + response.headers.get("content-type") ?? undefined, + ); + + throw this.tagEndpoint( + new AppwriteException( + failure.message, + response.status, + failure.type, + text, + ), + ); } if ( @@ -262,19 +297,25 @@ class Client { /role:\s*guests/i.test(json.message); if (isUnauthorized) { - throw new AppwriteException( - `You are not authenticated. Run '${EXECUTABLE_NAME} login' to authenticate and try again.`, - json.code, - json.type, - text, + throw this.tagEndpoint( + new AppwriteException( + `You are not authenticated. Run '${EXECUTABLE_NAME} login' to authenticate and try again.`, + json.code, + json.type, + text, + ), ); } - throw new AppwriteException( - json.message || text, - json.code, - json.type, - text, + throw this.tagEndpoint( + new AppwriteException( + json.message || + describeHttpFailure(response.status, text, response.statusText) + .message, + json.code ?? response.status, + json.type, + text, + ), ); } diff --git a/lib/commands/generic.ts b/lib/commands/generic.ts index a14d32f2..84e74251 100644 --- a/lib/commands/generic.ts +++ b/lib/commands/generic.ts @@ -1,6 +1,5 @@ import inquirer from "inquirer"; import { Command } from "commander"; -import { Client } from "@appwrite.io/console"; import { endpointsMatch, globalConfig, localConfig } from "../config.js"; import { configuredOrganizationId } from "../context.js"; import { EXECUTABLE_NAME } from "../constants.js"; @@ -29,6 +28,7 @@ import { logoutSessions, planSessionLogout, restoreCurrentSessionFallback, + verifyEndpoint, } from "../auth/session.js"; export { loginCommand }; @@ -288,62 +288,43 @@ export const client = new Command("client") } if (endpoint !== undefined) { - try { - const url = new URL(endpoint); - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(); - } - - const clientInstance = new Client().setEndpoint(endpoint); - clientInstance.setProject("console"); - if (selfSigned || globalConfig.getSelfSigned()) { - clientInstance.setSelfSigned(true); - } - const response = (await clientInstance.call( - "GET", - new URL(endpoint + "/health/version"), - )) as { version?: string }; - if (!response.version) { - throw new Error(); - } + await verifyEndpoint( + endpoint, + selfSigned || globalConfig.getSelfSigned(), + ); - 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); + 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}`); } - } catch (_) { - throw new Error( - "Invalid endpoint or your Appwrite server is not running as expected.", - ); + } 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); } } diff --git a/lib/commands/services/oauth2.ts b/lib/commands/services/oauth2.ts index 8a787465..1b41a56a 100644 --- a/lib/commands/services/oauth2.ts +++ b/lib/commands/services/oauth2.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { sdkForConsole, sdkForProject } from "../../sdks.js"; +import { listOrganizationsForSession, listProjectsForSession } from "../../console-fallback.js"; import { actionRunner, commandDescriptions, @@ -161,7 +162,7 @@ const oauth2LogoutPostCommand = oauth2 const oauth2ListOrganizationsCommand = oauth2 - .command(`list-organizations`) + .command(`list-organizations`, { hidden: true }) .description(`List the organizations the OAuth2 access token can access. Resolves the token's \`organization\` authorization details, expanding the \`*\` wildcard into the concrete set of organizations the user can see.`) .option(`--limit `, `Maximum number of organizations to return. Between 1 and 5000.`, parseInteger) .option(`--offset `, `Number of organizations to skip before returning results. Used for pagination.`, parseInteger) @@ -169,7 +170,22 @@ const oauth2ListOrganizationsCommand = oauth2 .action( actionRunner( async ({ limit, offset, search }) => - parse(await (await getOauth2ConsoleClient()).listOrganizations(limit, offset, search)), + parse(await listOrganizationsForSession(limit, offset, search)), + ), + ); + +export const oauth2ListOrganizationsRootCommand = new Command(`list-organizations`) + .configureHelp({ + helpWidth: process.stdout.columns || 80, + }) + .description(`List the organizations the OAuth2 access token can access. Resolves the token's \`organization\` authorization details, expanding the \`*\` wildcard into the concrete set of organizations the user can see.`) + .option(`--limit `, `Maximum number of organizations to return. Between 1 and 5000.`, parseInteger) + .option(`--offset `, `Number of organizations to skip before returning results. Used for pagination.`, parseInteger) + .option(`--search `, `Search term to filter your list results. Max length: 256 chars.`) + .action( + actionRunner( + async ({ limit, offset, search }) => + parse(await listOrganizationsForSession(limit, offset, search)), ), ); @@ -199,7 +215,22 @@ const oauth2CreatePARCommand = oauth2 const oauth2ListProjectsCommand = oauth2 - .command(`list-projects`) + .command(`list-projects`, { hidden: true }) + .description(`List the projects the OAuth2 access token can access. Resolves the token's \`project\` authorization details, expanding the \`*\` wildcard into the concrete set of projects the user can see.`) + .option(`--limit `, `Maximum number of projects to return. Between 1 and 5000.`, parseInteger) + .option(`--offset `, `Number of projects to skip before returning results. Used for pagination.`, parseInteger) + .option(`--search `, `Search term to filter your list results. Max length: 256 chars.`) + .action( + actionRunner( + async ({ limit, offset, search }) => + parse(await listProjectsForSession(limit, offset, search)), + ), + ); + +export const oauth2ListProjectsRootCommand = new Command(`list-projects`) + .configureHelp({ + helpWidth: process.stdout.columns || 80, + }) .description(`List the projects the OAuth2 access token can access. Resolves the token's \`project\` authorization details, expanding the \`*\` wildcard into the concrete set of projects the user can see.`) .option(`--limit `, `Maximum number of projects to return. Between 1 and 5000.`, parseInteger) .option(`--offset `, `Number of projects to skip before returning results. Used for pagination.`, parseInteger) @@ -207,7 +238,7 @@ const oauth2ListProjectsCommand = oauth2 .action( actionRunner( async ({ limit, offset, search }) => - parse(await (await getOauth2ConsoleClient()).listProjects(limit, offset, search)), + parse(await listProjectsForSession(limit, offset, search)), ), ); diff --git a/lib/commands/services/organization.ts b/lib/commands/services/organization.ts index 73eb1dd6..f1287936 100644 --- a/lib/commands/services/organization.ts +++ b/lib/commands/services/organization.ts @@ -6,6 +6,7 @@ import { parseFilterQuery, } from "../utils/query.js"; import { sdkForConsoleWithOrganization } from "../../sdks.js"; +import { getOrganizationForSession } from "../../console-fallback.js"; import { actionRunner, commandDescriptions, @@ -36,7 +37,7 @@ const organizationGetCommand = organization .action( actionRunner( async ({ organizationId }) => - parse(await (await getOrganizationClient(organizationId)).get()), + parse(await getOrganizationForSession(organizationId)), ), ); diff --git a/lib/console-fallback.ts b/lib/console-fallback.ts new file mode 100644 index 00000000..d02b21a5 --- /dev/null +++ b/lib/console-fallback.ts @@ -0,0 +1,190 @@ +import { Oauth2, Organization, Teams } from "@appwrite.io/console"; +import type { Models } from "@appwrite.io/console"; +import { globalConfig } from "./config.js"; +import { DEFAULT_ENDPOINT } from "./constants.js"; +import { paginate } from "./paginate.js"; +import { sdkForConsole, sdkForConsoleWithOrganization } from "./sdks.js"; +import { getCloudBaseHostname } from "./utils.js"; + +/** + * Endpoints that only exist on Cloud, and the console endpoints that stand in + * for them elsewhere. + * + * The OAuth2 listing endpoints resolve an access token's authorization details, + * so they only exist where the OAuth2 server does — Cloud. Self-hosted installs + * answer `general_route_not_found`, and an email/password session has no token + * to resolve in the first place. These helpers keep the OAuth2 call for sessions + * that can use it and rebuild the same response from the console endpoints + * (`GET /teams`, `GET /organization/projects`) for everyone else. + */ + +/** Server-side defaults of the OAuth2 listing endpoints, mirrored here. */ +const DEFAULT_LIMIT = 25; +const DEFAULT_OFFSET = 0; + +/** Page size used while collecting the full set to window locally. */ +const PAGE_SIZE = 100; + +const hasOauth2Session = (): boolean => globalConfig.getAccessToken() !== ""; + +const isRouteMissing = (error: unknown): boolean => { + const failure = error as { code?: number; type?: string }; + return failure.code === 404 || failure.type === "general_route_not_found"; +}; + +/** Use the Cloud-only route when available, otherwise build its local result. */ +const withRouteFallback = async ( + primary: (() => Promise) | undefined, + fallback: () => Promise, +): Promise => { + if (primary) { + try { + return await primary(); + } catch (error) { + if (!isRouteMissing(error)) { + throw error; + } + } + } + + return fallback(); +}; + +/** + * The OAuth2 endpoints window server-side, so `total` counts every match while + * the items cover one page. Windowing locally keeps that contract. + */ +const applyWindow = (items: T[], limit?: number, offset?: number): T[] => { + const start = offset ?? DEFAULT_OFFSET; + return items.slice(start, start + (limit ?? DEFAULT_LIMIT)); +}; + +/** + * Regional API endpoint for a project, matching the `endpoint` the OAuth2 + * response carries. Regions only have their own hostname on Cloud; anywhere + * else every project is served from the configured endpoint. + */ +const endpointForRegion = (region: string): string => { + const endpoint = globalConfig.getEndpoint() || DEFAULT_ENDPOINT; + + try { + const url = new URL(endpoint); + const base = getCloudBaseHostname(url.hostname); + + if (base !== null && region !== "") { + return `${url.protocol}//${region}.${base}${url.pathname}`; + } + } catch { + // Fall through to the configured endpoint. + } + + return endpoint; +}; + +/** Every organization the session can see, as teams. */ +const listAllOrganizations = async ( + search?: string, +): Promise => { + const teams = new Teams(await sdkForConsole()); + + const response = await paginate( + (args) => teams.list(args.queries as string[], search), + {}, + PAGE_SIZE, + "teams", + ); + + return response.teams; +}; + +const listAllProjects = async ( + organizationId: string, + search?: string, +): Promise => { + const organization = new Organization( + await sdkForConsole({ organizationId }), + ); + + const response = await paginate( + (args) => organization.listProjects(args.queries as string[], search), + {}, + PAGE_SIZE, + "projects", + ); + + return response.projects; +}; + +export const listProjectsForSession = async ( + limit?: number, + offset?: number, + search?: string, +): Promise => { + return withRouteFallback( + hasOauth2Session() + ? async () => + new Oauth2(await sdkForConsole()).listProjects(limit, offset, search) + : undefined, + async () => { + const projects: Models.Oauth2Project[] = []; + + for (const organization of await listAllOrganizations()) { + for (const project of await listAllProjects(organization.$id, search)) { + projects.push({ + $id: project.$id, + region: project.region, + endpoint: endpointForRegion(project.region), + }); + } + } + + return { + total: projects.length, + projects: applyWindow(projects, limit, offset), + }; + }, + ); +}; + +export const listOrganizationsForSession = async ( + limit?: number, + offset?: number, + search?: string, +): Promise => { + return withRouteFallback( + hasOauth2Session() + ? async () => + new Oauth2(await sdkForConsole()).listOrganizations( + limit, + offset, + search, + ) + : undefined, + async () => { + const organizations = (await listAllOrganizations(search)).map( + ({ $id }) => ({ $id }), + ); + + return { + total: organizations.length, + organizations: applyWindow(organizations, limit, offset), + }; + }, + ); +}; + +/** + * Self-hosted installs have no `/organization` — the singular organization + * service is Cloud-only, and an organization there is just its team. The team + * carries the fields the server actually knows ($id, name, timestamps, prefs); + * the billing fields of a Cloud organization have no equivalent to report. + */ +export const getOrganizationForSession = async ( + organizationId?: string, +): Promise => { + const client = await sdkForConsoleWithOrganization(organizationId); + return withRouteFallback( + () => new Organization(client).get(), + () => new Teams(client).get(client.headers["X-Appwrite-Organization"]), + ); +}; diff --git a/lib/constants.ts b/lib/constants.ts index b98330e0..0fd35e8c 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 = '25.0.0'; +export const SDK_VERSION = '25.1.0'; export const SDK_NAME = 'Command Line'; export const SDK_PLATFORM = 'console'; export const SDK_LANGUAGE = 'cli'; diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 00000000..23b8ceb0 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,79 @@ +/** Longest server-provided snippet we print on one line. */ +const MAX_SNIPPET_LENGTH = 300; + +/** Snippet length used when embedding a body in a bug report URL. */ +export const MAX_REPORT_BODY_LENGTH = 500; + +export type HttpFailure = { + message: string; + type: string; +}; + +/** Whether a response is an HTML page rather than an API response. */ +export const looksLikeHtml = (body: string, contentType?: string): boolean => { + if (contentType?.toLowerCase().includes("text/html")) { + return true; + } + + const start = body.trimStart().toLowerCase(); + return start.startsWith(" { + const collapsed = text + .replace(/\u001b\[[0-9;]*[a-zA-Z]/g, "") + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (collapsed.length <= maxLength) { + return collapsed; + } + + return `${collapsed.slice(0, maxLength).trimEnd()}\u2026`; +}; + +const statusLabel = (status: number, statusText?: string): string => { + const text = statusText?.trim(); + return text ? `HTTP ${status} ${text}` : `HTTP ${status}`; +}; + +/** Describe a failed non-JSON response without exposing an HTML document. */ +export const describeHttpFailure = ( + status: number, + body: string, + statusText?: string, + contentType?: string, +): HttpFailure => { + const label = statusLabel(status, statusText); + const message = looksLikeHtml(body, contentType) + ? "" + : sanitizeErrorText(body); + + return { + message: message ? `${message} (${label})` : label, + type: "", + }; +}; + +const formatBytes = (bytes: number): string => + bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`; + +/** Render a response body for verbose output without flooding the terminal. */ +export const summarizeErrorBody = (body: string): string => { + const html = looksLikeHtml(body); + const summary = html ? "" : sanitizeErrorText(body); + + if (!html && body.length <= MAX_SNIPPET_LENGTH) { + return summary; + } + + const kind = html ? "HTML body" : "body"; + return [`<${kind}, ${formatBytes(Buffer.byteLength(body))}>`, summary] + .filter(Boolean) + .join(" "); +}; diff --git a/lib/help.ts b/lib/help.ts index d328013f..f64801d8 100644 --- a/lib/help.ts +++ b/lib/help.ts @@ -4,8 +4,8 @@ import { EXECUTABLE_NAME, SDK_LOGO, SDK_TITLE } from "./constants.js"; /** * The main help screen is grouped by intent rather than listed alphabetically. - * Entries are command paths as typed, so `oauth2 list-projects` can be - * surfaced next to `login` without moving it out of the oauth2 service. + * Entries are command paths as typed, so a root alias such as + * `list-projects` can sit next to `login` in GET STARTED. * * Anything not named here still shows up, under `OTHER`, so a service added * to the spec can never silently disappear from `--help`. @@ -19,8 +19,8 @@ const groups: ReadonlyArray<{ title: "GET STARTED", commands: [ "login", - "oauth2 list-organizations", - "oauth2 list-projects", + "list-organizations", + "list-projects", "init", "pull", "push", @@ -81,8 +81,8 @@ const groups: ReadonlyArray<{ */ const summaries: Record = { login: `Authenticate with your ${SDK_TITLE} account`, - "oauth2 list-organizations": "Organizations your session can access", - "oauth2 list-projects": "Projects your session can access", + "list-organizations": "Organizations your session can access", + "list-projects": "Projects your session can access", init: "Scaffold a project, function, site, or resource", pull: "Pull remote project resources into this directory", push: "Push local project resources", diff --git a/lib/hints.ts b/lib/hints.ts index d8ee106c..183d9a48 100644 --- a/lib/hints.ts +++ b/lib/hints.ts @@ -1,5 +1,7 @@ import type { Command } from "commander"; +import { globalConfig } from "./config.js"; import { EXECUTABLE_NAME } from "./constants.js"; +import { looksLikeHtml } from "./errors.js"; /** * Commands whose response carries identifiers but no detail, mapped to the @@ -7,11 +9,14 @@ import { EXECUTABLE_NAME } from "./constants.js"; * so they can be checked against `--help` output directly. */ const followUpHints: Record = { + "list-projects": `Run \`${EXECUTABLE_NAME} project get --project-id \` to see a project's details.`, + "list-organizations": `Run \`${EXECUTABLE_NAME} organization get --organization-id \` to see an organization's details.`, + // Hidden oauth2 paths kept so the legacy invocations still get a hint. "oauth2 list-projects": `Run \`${EXECUTABLE_NAME} project get --project-id \` to see a project's details.`, "oauth2 list-organizations": `Run \`${EXECUTABLE_NAME} organization get --organization-id \` to see an organization's details.`, }; -/** Command path without the executable name, e.g. `oauth2 list-projects`. */ +/** Command path without the executable name, e.g. `list-projects`. */ const commandPath = (command: Command): string => { const segments: string[] = []; @@ -25,3 +30,54 @@ const commandPath = (command: Command): string => { export const followUpHintFor = (command: Command): string => followUpHints[commandPath(command)] ?? ""; + +const isQueryFailure = (message: string): boolean => + /Invalid query(?: method)?/i.test(message) || + /query[^.:\n]*syntax error|syntax error[^.:\n]*query/i.test(message); + +/** Endpoints without a path are missing the `/v1` the API is served under. */ +const endpointMissingApiPath = (endpoint: string): boolean => { + try { + return new URL(endpoint).pathname.replace(/\/+$/, "") === ""; + } catch { + return false; + } +}; + +/** + * The hints that apply to a failure. Requests made with an explicit + * `--endpoint` record it on the exception, so prefer that over whatever + * endpoint happens to be configured. + */ +export const errorHintsFor = (err: Error, endpoint?: string): string[] => { + const failure = err as Error & { + endpoint?: string; + code?: number; + type?: string; + response?: unknown; + }; + + const hints: string[] = []; + const requestEndpoint = + endpoint ?? failure.endpoint ?? globalConfig.getEndpoint(); + + if (isQueryFailure(failure.message ?? "")) { + hints.push( + `For common list filters, use flags like --limit 25, --sort-desc '$createdAt', or --filter 'status=active'. Raw --queries values must be Appwrite JSON query strings, for example: ${EXECUTABLE_NAME} tablesdb list-rows --queries '{"method":"limit","values":[25]}'`, + ); + } + + const response = typeof failure.response === "string" ? failure.response : ""; + const routeMissing = + failure.code === 404 || + failure.type === "general_route_not_found" || + looksLikeHtml(response); + + if (routeMissing && endpointMissingApiPath(requestEndpoint)) { + hints.push( + `Appwrite's API is served under /v1. Try --endpoint ${new URL(requestEndpoint).origin}/v1`, + ); + } + + return hints; +}; diff --git a/lib/parser.ts b/lib/parser.ts index c64df654..41d6c9c5 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -13,6 +13,12 @@ import { globalConfig } from "./config.js"; import os from "os"; import { Client } from "@appwrite.io/console"; import { getErrorMessage, isCloud } from "./utils.js"; +import { + MAX_REPORT_BODY_LENGTH, + sanitizeErrorText, + summarizeErrorBody, +} from "./errors.js"; +import { errorHintsFor } from "./hints.js"; import type { CliConfig } from "./types.js"; import { SDK_VERSION, @@ -766,18 +772,10 @@ export const drawJSON = (data: unknown): void => { console.log(JSON.stringify(data, null, 2)); }; -const isQueryError = (message: string): boolean => - /Invalid query(?: method)?/i.test(message) || - /query[^.:\n]*syntax error|syntax error[^.:\n]*query/i.test(message); - -const printQueryErrorHint = (err: Error): void => { - if (!isQueryError(err.message)) { - return; +const printErrorHints = (err: Error): void => { + for (const message of errorHintsFor(err)) { + hint(message); } - - hint( - `For common list filters, use flags like --limit 25, --sort-desc '$createdAt', or --filter 'status=active'. Raw --queries values must be Appwrite JSON query strings, for example: ${EXECUTABLE_NAME} tablesdb list-rows --queries '{"method":"limit","values":[25]}'`, - ); }; const ERROR_DETAIL_KEYS = ["code", "type", "response"] as const; @@ -794,7 +792,8 @@ const formatErrorDetail = (value: unknown): string => { value = parsed; } } catch { - return text; + // Not JSON — summarize HTML and oversized proxy responses. + return summarizeErrorBody(text); } } @@ -809,6 +808,42 @@ const formatErrorDetail = (value: unknown): string => { } }; +/** Keeps a summarized message from dominating a bug report title. */ +const MAX_REPORT_TITLE_LENGTH = 120; + +/** + * Plain (uncolored, bounded) error details for a bug report body. + */ +const reportErrorDetails = (err: Error): string[] => { + const lines: string[] = []; + + for (const key of ERROR_DETAIL_KEYS) { + if (!Object.prototype.hasOwnProperty.call(err, key)) { + continue; + } + + const value = (err as unknown as Record)[key]; + const rendered = + typeof value === "string" + ? sanitizeErrorText(value, MAX_REPORT_BODY_LENGTH) + : String(value); + + lines.push(`${key}: ${rendered}`); + } + + return lines; +}; + +/** + * Stack frames only — the message line is rendered separately and, unlike a raw + * stack, frames can never carry a response body. + */ +const errorStackFrames = (err: Error): string[] => + (err.stack ?? "") + .split("\n") + .filter((line) => line.trim().startsWith("at ")) + .map((line) => line.trim()); + export const formatErrorForLog = (err: Error): string => { const lines = [ `${chalk.red.bold(err.name || "Error")}${chalk.red(`: ${getErrorMessage(err)}`)}`, @@ -825,15 +860,13 @@ export const formatErrorForLog = (err: Error): string => { ); } - const frames = (err.stack ?? "") - .split("\n") - .filter((line) => line.trim().startsWith("at ")); + const frames = errorStackFrames(err); if (frames.length > 0) { lines.push( "", chalk.dim(`${ERROR_DETAIL_INDENT}Stack trace:`), ...frames.map((frame) => - chalk.dim(`${ERROR_DETAIL_INDENT.repeat(2)}${frame.trim()}`), + chalk.dim(`${ERROR_DETAIL_INDENT.repeat(2)}${frame}`), ), ); } @@ -863,7 +896,14 @@ export const parseError = (err: Error): void => { const stepsToReproduce = `Running \`${EXECUTABLE_NAME} ${commandArgs.join(" ")}\``; const yourEnvironment = `CLI version: ${version}\nOperation System: ${os.type()}\nAppwrite version: ${appwriteVersion}\nIs Cloud: ${isCloud()}`; - const stack = "```\n" + (err.stack || err.message) + "\n```"; + // Response bodies are summarized and frames are listed separately, so an + // HTML error page can never blow the issue URL past what GitHub accepts. + const details = [ + `${err.name || "Error"}: ${getErrorMessage(err)}`, + ...reportErrorDetails(err), + ...errorStackFrames(err), + ].join("\n"); + const stack = "```\n" + details + "\n```"; const githubIssueUrl = new URL( "https://github.com/appwrite/appwrite/issues/new", @@ -872,7 +912,7 @@ export const parseError = (err: Error): void => { githubIssueUrl.searchParams.append("template", "bug.yaml"); githubIssueUrl.searchParams.append( "title", - `🐛 Bug Report: ${getErrorMessage(err)}`, + `🐛 Bug Report: ${sanitizeErrorText(getErrorMessage(err), MAX_REPORT_TITLE_LENGTH)}`, ); githubIssueUrl.searchParams.append( "actual-behavior", @@ -887,7 +927,7 @@ export const parseError = (err: Error): void => { log( `To report this error you can:\n - Create a support ticket in our Discord server https://appwrite.io/discord \n - Create an issue in our Github\n ${githubIssueUrl.href}\n`, ); - printQueryErrorHint(err); + printErrorHints(err); error("\n Stack Trace: \n"); console.error(formatErrorForLog(err)); @@ -896,11 +936,11 @@ export const parseError = (err: Error): void => { } else { if (cliConfig.verbose) { console.error(formatErrorForLog(err)); - printQueryErrorHint(err); + printErrorHints(err); } else { log("For detailed error pass the --verbose or --report flag"); error(getErrorMessage(err)); - printQueryErrorHint(err); + printErrorHints(err); } process.exit(1); } @@ -1022,7 +1062,7 @@ export const commandDescriptions: Record = { messaging: `The messaging command allows you to manage topics and targets and send messages.`, migrations: `The migrations command allows you to migrate data between services.`, notifications: `The notifications command allows you to read and manage your Appwrite Console notifications.`, - oauth2: `The oauth2 command allows you to authorize apps and issue standards-based OAuth2 and OpenID Connect tokens. The 'list-organizations' and 'list-projects' commands are console-level and report the organizations and projects your current session can access.`, + oauth2: `The oauth2 command allows you to authorize apps and issue standards-based OAuth2 and OpenID Connect tokens.`, organization: `The organization command allows you to manage organization-level projects.`, organizations: `The organizations command allows you to manage organization billing, plans, invoices, and add-ons.`, presences: `The presences command allows you to track and manage real-time user presence in your project.`, diff --git a/lib/utils.ts b/lib/utils.ts index 0c76703d..57a2be72 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -8,6 +8,11 @@ import type { Models } from "@appwrite.io/console"; import { ProjectPolicyId } from "@appwrite.io/console"; import { z } from "zod"; import { globalConfig } from "./config.js"; +import { + describeHttpFailure, + looksLikeHtml, + sanitizeErrorText, +} from "./errors.js"; import { isFlagEnabled } from "./flags.js"; import type { SettingsType } from "./commands/config.js"; import { @@ -138,20 +143,46 @@ export const siteRequiresBuildCommand = (site: SiteBuildConfig): boolean => { return !(site.framework === "other" && site.adapter === "static"); }; +/** Beyond this, a message is a response body rather than a message. */ +const MAX_ERROR_MESSAGE_LENGTH = 2000; + +/** + * Turns a body that carried no usable message into a printable one. Server + * markup — from a proxy, or from a request that missed the API entirely — is + * reduced to the signal it contains instead of being printed verbatim. + */ +const describeErrorBody = (body: string, code?: number): string => { + if (looksLikeHtml(body)) { + return code + ? describeHttpFailure(code, body).message + : "The server returned an HTML error page."; + } + + return sanitizeErrorText(body); +}; + export const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { + const code = (error as { code?: number }).code; const message = typeof error.message === "string" ? error.message.trim() : ""; + + // Errors raised outside our own client can carry a whole response body as + // their message, so summarize markup and runaway bodies before printing. + if (looksLikeHtml(message) || message.length > MAX_ERROR_MESSAGE_LENGTH) { + return describeErrorBody(message, code); + } + if (message) { return message; } // Some error responses carry no `message` field, leaving the exception - // message empty. Fall back to the raw response body so users see more - // than a bare "✗ Error:". + // message empty. Fall back to the response body so users see more than a + // bare "✗ Error:". const response = (error as { response?: unknown }).response; if (typeof response === "string" && response.trim() !== "") { - return response.trim(); + return describeErrorBody(response, code); } return "An unknown error occurred."; diff --git a/package-lock.json b/package-lock.json index fc453328..1b364661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appwrite-cli", - "version": "25.0.0", + "version": "25.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite-cli", - "version": "25.0.0", + "version": "25.1.0", "license": "BSD-3-Clause", "dependencies": { "@appwrite.io/console": "15.8.0", diff --git a/package.json b/package.json index 90ea39eb..792229a3 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": "25.0.0", + "version": "25.1.0", "license": "BSD-3-Clause", "main": "dist/index.cjs", "module": "dist/index.js", diff --git a/scoop/appwrite.config.json b/scoop/appwrite.config.json index fdc505b8..a83a5c49 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": "25.0.0", + "version": "25.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/25.0.0/appwrite-cli-win-x64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/25.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/25.0.0/appwrite-cli-win-arm64.exe", + "url": "https://github.com/appwrite/sdk-for-cli/releases/download/25.1.0/appwrite-cli-win-arm64.exe", "bin": [ [ "appwrite-cli-win-arm64.exe",