diff --git a/assistant/src/cli/COMMAND_INVENTORY.md b/assistant/src/cli/COMMAND_INVENTORY.md index 69c9e768822..dc5b326f75a 100644 --- a/assistant/src/cli/COMMAND_INVENTORY.md +++ b/assistant/src/cli/COMMAND_INVENTORY.md @@ -60,15 +60,15 @@ Run `bun run lint:inventory` to validate. | `ui` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `usage` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `webhooks` | `register`, `list` | `ipc` | `webhooks_register`, `webhooks_list` | `THIN` | | -| `oauth/index` | _(namespace registrar)_ | `ipc` | _(pending migration)_ | `LEGACY` | Registers all oauth/\* subcommands | -| `oauth/apps` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | +| `oauth/index` | _(namespace registrar)_ | `ipc` | _(none — namespace only)_ | `THIN` | Registers all oauth/\* subcommands via `registerCommand` | +| `oauth/apps` | `list`, `get`, `upsert`, `delete` | `ipc` | `oauth_apps_get`, `oauth_apps_by_query_get`, `oauth_apps_upsert`, `oauth_apps_delete` | `THIN` | | | `oauth/connect` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `oauth/disconnect` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `oauth/mode` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `oauth/ping` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | -| `oauth/providers` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | +| `oauth/providers` | `list`, `get`, `register`, `update`, `delete` | `ipc` | `oauth_providers_get`, `oauth_providers_by_providerKey_get`, `oauth_providers_post`, `oauth_providers_by_providerKey_patch`, `oauth_providers_by_providerKey_delete` | `THIN` | | | `oauth/request` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | -| `oauth/shared` | _(shared utilities, no direct subcommands)_ | `ipc` | _(none — utility module)_ | `LEGACY` | Utility helpers for oauth commands | +| `oauth/shared` | _(shared utilities, no direct subcommands)_ | `ipc` | _(none — utility module)_ | `THIN` | Exempt: no `registerCommand`, utility helpers consumed by other oauth commands | | `oauth/status` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `oauth/token` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `platform/index` | _(namespace registrar)_ | `ipc` | _(pending migration)_ | `LEGACY` | Registers platform/\* subcommands | diff --git a/assistant/src/cli/commands/oauth/apps.ts b/assistant/src/cli/commands/oauth/apps.ts index fd35a5eb280..32b06666b06 100644 --- a/assistant/src/cli/commands/oauth/apps.ts +++ b/assistant/src/cli/commands/oauth/apps.ts @@ -1,17 +1,35 @@ import type { Command } from "commander"; -import { - deleteApp, - getApp, - getAppByProviderAndClientId, - getMostRecentAppByProvider, - listApps, - upsertApp, -} from "../../../oauth/oauth-store.js"; -import { credentialKey } from "../../../security/credential-key.js"; +import { cliIpcCall, exitFromIpcResult } from "../../../ipc/cli-client.js"; +import { registerCommand } from "../../lib/register-command.js"; import { getCliLogger } from "../../logger.js"; import { shouldOutputJson, writeOutput } from "../../output.js"; +const log = getCliLogger("cli"); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface AppRow { + id: string; + provider_key: string; + client_id: string; + created_at: number; + updated_at: number; +} + +/** Format an app row for CLI output, converting timestamps to ISO strings. */ +function formatAppRow(row: AppRow) { + return { + id: row.id, + providerKey: row.provider_key, + clientId: row.client_id, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + }; +} + /** * Resolve a credential path input to its full internal format. * @@ -33,39 +51,18 @@ function resolveCredentialPath(input: string): string { const service = input.slice(0, lastColon); const field = input.slice(lastColon + 1); - return credentialKey(service, field); -} - -const log = getCliLogger("cli"); - -/** Format an app row for output, converting timestamps to ISO strings. */ -function formatAppRow(row: { - id: string; - provider: string; - clientId: string; - createdAt: number; - updatedAt: number; -}) { - return { - id: row.id, - // Wire key stays `providerKey` for backward compatibility with existing - // CLI script consumers of `assistant oauth apps list/get/upsert --json`; - // the internal Drizzle TS-side field is `provider`. - providerKey: row.provider, - clientId: row.clientId, - createdAt: new Date(row.createdAt).toISOString(), - updatedAt: new Date(row.updatedAt).toISOString(), - }; + return `credential/${service}/${field}`; } export function registerAppCommands(oauth: Command): void { - const apps = oauth - .command("apps") - .description("Manage custom OAuth app registrations"); - - apps.addHelpText( - "after", - ` + registerCommand(oauth, { + name: "apps", + transport: "ipc", + description: "Manage custom OAuth app registrations", + build: (apps) => { + apps.addHelpText( + "after", + ` Apps represent custom OAuth client registrations — a client_id and optional client_secret linked to a provider. Each provider can have multiple apps (e.g. different client IDs for different environments). Only needed if using @@ -78,77 +75,101 @@ Examples: $ assistant oauth apps get --provider google $ assistant oauth apps upsert --provider google --client-id abc123 $ assistant oauth apps delete `, - ); - - // --------------------------------------------------------------------------- - // apps list - // --------------------------------------------------------------------------- - - apps - .command("list") - .description("List all OAuth app registrations") - .option( - "--provider-key ", - "Filter by provider key (exact match). Only apps associated with this provider are returned. Run 'assistant oauth providers list' to see available keys.", - ) - .addHelpText( - "after", - ` + ); + + // ----------------------------------------------------------------------- + // apps list + // ----------------------------------------------------------------------- + + apps + .command("list") + .description("List all OAuth app registrations") + .option( + "--provider-key ", + "Filter by provider key (exact match). Run 'assistant oauth providers list' to see available keys.", + ) + .addHelpText( + "after", + ` Returns registered OAuth apps with their provider key, client ID, and timestamps. Output is an array of app objects. When --provider-key is specified, only apps whose provider exactly matches the given value are returned. Without the flag, all apps are listed. -In JSON mode (--json), returns the array directly. In human mode, logs a -summary count and prints the formatted list. - Examples: $ assistant oauth apps list $ assistant oauth apps list --provider-key google $ assistant oauth apps list --provider-key slack --json`, - ) - .action((opts: { providerKey?: string }, cmd: Command) => { - try { - let rows = listApps().map(formatAppRow); - - if (opts.providerKey) { - rows = rows.filter((r) => r.providerKey === opts.providerKey); - } - - if (!shouldOutputJson(cmd)) { - log.info(`Found ${rows.length} app(s)`); - } - - writeOutput(cmd, rows); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeOutput(cmd, { ok: false, error: message }); - process.exitCode = 1; - } - }); - - // --------------------------------------------------------------------------- - // apps get - // --------------------------------------------------------------------------- - - apps - .command("get") - .description( - "Look up an OAuth app by ID, provider + client-id, or provider", - ) - .option("--id ", "App ID (UUID) from 'assistant oauth apps list'") - .option( - "--provider ", - "Provider key (e.g. google) from 'assistant oauth providers list'", - ) - .option( - "--client-id ", - "OAuth client ID (requires --provider). Find registered client IDs via 'assistant oauth apps list'.", - ) - .addHelpText( - "after", - ` + ) + .action(async (opts: { providerKey?: string }, cmd: Command) => { + if (!opts.providerKey) { + // The IPC route requires provider_key. To support listing all + // apps, we first need to know the providers. For simplicity + // and backward compatibility, list providers first, then + // aggregate. + const provR = await cliIpcCall<{ + providers: Array<{ provider_key: string }>; + }>("oauth_providers_get", { queryParams: {} }); + + if (!provR.ok) return exitFromIpcResult(provR); + + const allRows: ReturnType[] = []; + for (const p of provR.result?.providers ?? []) { + const r = await cliIpcCall<{ + apps: AppRow[]; + }>("oauth_apps_get", { + queryParams: { provider_key: p.provider_key }, + }); + if (r.ok && r.result?.apps) { + allRows.push(...r.result.apps.map(formatAppRow)); + } + } + + if (!shouldOutputJson(cmd)) { + log.info(`Found ${allRows.length} app(s)`); + } + writeOutput(cmd, allRows); + return; + } + + const r = await cliIpcCall<{ apps: AppRow[] }>( + "oauth_apps_get", + { queryParams: { provider_key: opts.providerKey } }, + ); + + if (!r.ok) return exitFromIpcResult(r); + + const rows = (r.result?.apps ?? []).map(formatAppRow); + + if (!shouldOutputJson(cmd)) { + log.info(`Found ${rows.length} app(s)`); + } + + writeOutput(cmd, rows); + }); + + // ----------------------------------------------------------------------- + // apps get + // ----------------------------------------------------------------------- + + apps + .command("get") + .description( + "Look up an OAuth app by ID, provider + client-id, or provider", + ) + .option("--id ", "App ID (UUID) from 'assistant oauth apps list'") + .option( + "--provider ", + "Provider key (e.g. google) from 'assistant oauth providers list'", + ) + .option( + "--client-id ", + "OAuth client ID (requires --provider). Find registered client IDs via 'assistant oauth apps list'.", + ) + .addHelpText( + "after", + ` Three lookup modes are supported: 1. By app ID: @@ -161,80 +182,80 @@ Three lookup modes are supported: $ assistant oauth apps get --provider google At least --id or --provider must be specified.`, - ) - .action( - ( - opts: { id?: string; provider?: string; clientId?: string }, - cmd: Command, - ) => { - try { - let row; - - if (opts.id) { - row = getApp(opts.id); - } else if (opts.provider && opts.clientId) { - row = getAppByProviderAndClientId(opts.provider, opts.clientId); - } else if (opts.provider) { - row = getMostRecentAppByProvider(opts.provider); - } else { - writeOutput(cmd, { - ok: false, - error: - "Provide --id, --provider, or --provider + --client-id. Run 'assistant oauth apps list' to see all registered apps.", - }); - process.exitCode = 1; - return; - } - - if (!row) { - const lookup = opts.id - ? `id=${opts.id}` - : opts.provider && opts.clientId - ? `provider=${opts.provider}, clientId=${opts.clientId}` - : `provider=${opts.provider}`; - writeOutput(cmd, { - ok: false, - error: `No app found for ${lookup}. Run 'assistant oauth apps list' to see registered apps, or 'assistant oauth apps upsert --help' to register a new one.`, - }); - process.exitCode = 1; - return; - } - - writeOutput(cmd, formatAppRow(row)); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeOutput(cmd, { ok: false, error: message }); - process.exitCode = 1; - } - }, - ); - - // --------------------------------------------------------------------------- - // apps upsert - // --------------------------------------------------------------------------- - - apps - .command("upsert") - .description("Create or return an existing OAuth app registration") - .requiredOption( - "--provider ", - "Provider key (e.g. google) from 'assistant oauth providers list'", - ) - .requiredOption( - "--client-id ", - "OAuth client ID from the provider's developer console", - ) - .option( - "--client-secret ", - "OAuth client secret (stored in credential store)", - ) - .option( - "--client-secret-credential-path ", - "Credential reference in service:field format (e.g. google:client_secret). Mutually exclusive with --client-secret.", - ) - .addHelpText( - "after", - ` + ) + .action( + async ( + opts: { id?: string; provider?: string; clientId?: string }, + cmd: Command, + ) => { + if (!opts.id && !opts.provider) { + writeOutput(cmd, { + ok: false, + error: + "Provide --id, --provider, or --provider + --client-id. Run 'assistant oauth apps list' to see all registered apps.", + }); + process.exitCode = 1; + return; + } + + const queryParams: Record = {}; + if (opts.id) queryParams.id = opts.id; + if (opts.provider) queryParams.provider = opts.provider; + if (opts.clientId) queryParams.client_id = opts.clientId; + + const r = await cliIpcCall<{ app: AppRow }>( + "oauth_apps_by_query_get", + { queryParams }, + ); + + if (!r.ok) { + if (r.statusCode === 404) { + const lookup = opts.id + ? `id=${opts.id}` + : opts.provider && opts.clientId + ? `provider=${opts.provider}, clientId=${opts.clientId}` + : `provider=${opts.provider}`; + writeOutput(cmd, { + ok: false, + error: `No app found for ${lookup}. Run 'assistant oauth apps list' to see registered apps, or 'assistant oauth apps upsert --help' to register a new one.`, + }); + process.exitCode = 1; + return; + } + return exitFromIpcResult(r); + } + + const row = r.result?.app; + writeOutput(cmd, row ? formatAppRow(row) : null); + }, + ); + + // ----------------------------------------------------------------------- + // apps upsert + // ----------------------------------------------------------------------- + + apps + .command("upsert") + .description("Create or return an existing OAuth app registration") + .requiredOption( + "--provider ", + "Provider key (e.g. google) from 'assistant oauth providers list'", + ) + .requiredOption( + "--client-id ", + "OAuth client ID from the provider's developer console", + ) + .option( + "--client-secret ", + "OAuth client secret (stored in credential store)", + ) + .option( + "--client-secret-credential-path ", + "Credential reference in service:field format (e.g. google:client_secret). Mutually exclusive with --client-secret.", + ) + .addHelpText( + "after", + ` Creates a new app registration or returns the existing one if an app with the same provider and client ID already exists. The client secret, if provided, is stored in the secure credential store — not in the database. @@ -246,73 +267,81 @@ You can supply the client secret directly via --client-secret, or reference an existing credential in the store via --client-secret-credential-path. These two options are mutually exclusive — providing both is an error. -The --client-secret-credential-path takes a \`service:field\` reference -(e.g. \`google:client_secret\`). - Examples: $ assistant oauth apps upsert --provider google --client-id abc123 $ assistant oauth apps upsert --provider slack --client-id def456 --client-secret s3cret $ assistant oauth apps upsert --provider slack --client-id def456 --client-secret-credential-path "slack:client_secret" $ assistant oauth apps upsert --provider google --client-id abc123 --json`, - ) - .action( - async ( - opts: { - provider: string; - clientId: string; - clientSecret?: string; - clientSecretCredentialPath?: string; - }, - cmd: Command, - ) => { - try { - if (opts.clientSecret && opts.clientSecretCredentialPath) { - writeOutput(cmd, { - ok: false, - error: - "Cannot provide both --client-secret and --client-secret-credential-path", - }); - process.exitCode = 1; - return; - } - - const resolvedPath = opts.clientSecretCredentialPath - ? resolveCredentialPath(opts.clientSecretCredentialPath) - : undefined; - const clientSecretOpts = opts.clientSecret - ? { clientSecretValue: opts.clientSecret } - : resolvedPath - ? { clientSecretCredentialPath: resolvedPath } - : undefined; - const row = await upsertApp( - opts.provider, - opts.clientId, - clientSecretOpts, - ); - - if (!shouldOutputJson(cmd)) { - log.info(`Upserted app: ${row.id} (provider: ${row.provider})`); - } - - writeOutput(cmd, formatAppRow(row)); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeOutput(cmd, { ok: false, error: message }); - process.exitCode = 1; - } - }, - ); - - // --------------------------------------------------------------------------- - // apps delete - // --------------------------------------------------------------------------- - - apps - .command("delete ") - .description("Delete an OAuth app registration by ID") - .addHelpText( - "after", - ` + ) + .action( + async ( + opts: { + provider: string; + clientId: string; + clientSecret?: string; + clientSecretCredentialPath?: string; + }, + cmd: Command, + ) => { + if (opts.clientSecret && opts.clientSecretCredentialPath) { + writeOutput(cmd, { + ok: false, + error: + "Cannot provide both --client-secret and --client-secret-credential-path", + }); + process.exitCode = 1; + return; + } + + const body: Record = { + provider_key: opts.provider, + client_id: opts.clientId, + }; + + if (opts.clientSecret) { + body.client_secret = opts.clientSecret; + } else if (opts.clientSecretCredentialPath) { + body.client_secret_credential_path = resolveCredentialPath( + opts.clientSecretCredentialPath, + ); + } + + const r = await cliIpcCall<{ app: AppRow }>( + "oauth_apps_upsert", + { body }, + ); + + if (!r.ok) { + writeOutput(cmd, { + ok: false, + error: r.error ?? "Unknown error", + }); + process.exitCode = 1; + return; + } + + const row = r.result?.app; + if (row) { + if (!shouldOutputJson(cmd)) { + log.info( + `Upserted app: ${row.id} (provider: ${row.provider_key})`, + ); + } + writeOutput(cmd, formatAppRow(row)); + } + }, + ); + + // ----------------------------------------------------------------------- + // apps delete + // ----------------------------------------------------------------------- + + apps + .command("delete ") + .description("Delete an OAuth app registration by ID") + .addHelpText( + "after", + ` Arguments: id The app UUID to delete (as returned by "apps list" or "apps get") @@ -325,29 +354,31 @@ Exits with code 1 if the app ID is not found. Examples: $ assistant oauth apps delete 550e8400-e29b-41d4-a716-446655440000 $ assistant oauth apps delete 550e8400-e29b-41d4-a716-446655440000 --json`, - ) - .action(async (id: string, _opts: unknown, cmd: Command) => { - try { - const deleted = await deleteApp(id); - - if (!deleted) { - writeOutput(cmd, { - ok: false, - error: `App not found: ${id}. Run 'assistant oauth apps list' to see registered apps and their IDs.`, - }); - process.exitCode = 1; - return; - } - - if (!shouldOutputJson(cmd)) { - log.info(`Deleted app: ${id}`); - } - - writeOutput(cmd, { ok: true, id }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeOutput(cmd, { ok: false, error: message }); - process.exitCode = 1; - } - }); + ) + .action(async (id: string, _opts: unknown, cmd: Command) => { + const r = await cliIpcCall<{ ok: boolean }>( + "oauth_apps_delete", + { pathParams: { id } }, + ); + + if (!r.ok) { + if (r.statusCode === 404) { + writeOutput(cmd, { + ok: false, + error: `App not found: ${id}. Run 'assistant oauth apps list' to see registered apps and their IDs.`, + }); + process.exitCode = 1; + return; + } + return exitFromIpcResult(r); + } + + if (!shouldOutputJson(cmd)) { + log.info(`Deleted app: ${id}`); + } + + writeOutput(cmd, { ok: true, id }); + }); + }, + }); } diff --git a/assistant/src/cli/commands/oauth/index.ts b/assistant/src/cli/commands/oauth/index.ts index f8e5008b5f8..b62074eaa46 100644 --- a/assistant/src/cli/commands/oauth/index.ts +++ b/assistant/src/cli/commands/oauth/index.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; +import { registerCommand } from "../../lib/register-command.js"; import { registerAppCommands } from "./apps.js"; import { registerConnectCommand } from "./connect.js"; import { registerDisconnectCommand } from "./disconnect.js"; @@ -11,16 +12,17 @@ import { registerStatusCommand } from "./status.js"; import { registerTokenCommand } from "./token.js"; export function registerOAuthCommand(program: Command): void { - const oauth = program - .command("oauth") - .description( + registerCommand(program, { + name: "oauth", + transport: "ipc", + description: "Manage the full OAuth lifecycle — registering providers, creating apps, connecting accounts, and making authenticated requests", - ) - .option("--json", "Machine-readable compact JSON output"); + build: (oauth) => { + oauth.option("--json", "Machine-readable compact JSON output"); - oauth.addHelpText( - "after", - ` + oauth.addHelpText( + "after", + ` OAuth providers may support up to two modes – "managed" and "your-own". managed: Requires a Vellum Platform account. For providers that support it, managed mode offloads the burden of needing to create and register an oauth app. @@ -43,59 +45,61 @@ Examples: assistant oauth ping google assistant oauth request --provider google /gmail/v1/users/me/messages assistant oauth disconnect google`, - ); + ); - // --------------------------------------------------------------------------- - // providers — subcommand group - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // providers — subcommand group + // ----------------------------------------------------------------------- - registerProviderCommands(oauth); + registerProviderCommands(oauth); - // --------------------------------------------------------------------------- - // mode — get or set OAuth mode (managed vs your-own) for a provider - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // mode — get or set OAuth mode (managed vs your-own) for a provider + // ----------------------------------------------------------------------- - registerModeCommand(oauth); + registerModeCommand(oauth); - // --------------------------------------------------------------------------- - // apps — subcommand group - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // apps — subcommand group + // ----------------------------------------------------------------------- - registerAppCommands(oauth); + registerAppCommands(oauth); - // --------------------------------------------------------------------------- - // connect — unified connect command (auto-detects managed vs BYO) - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // connect — unified connect command (auto-detects managed vs BYO) + // ----------------------------------------------------------------------- - registerConnectCommand(oauth); + registerConnectCommand(oauth); - // --------------------------------------------------------------------------- - // status — unified connection status - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // status — unified connection status + // ----------------------------------------------------------------------- - registerStatusCommand(oauth); + registerStatusCommand(oauth); - // --------------------------------------------------------------------------- - // ping — ping to see if a provider is connected and healthy - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // ping — ping to see if a provider is connected and healthy + // ----------------------------------------------------------------------- - registerPingCommand(oauth); + registerPingCommand(oauth); - // --------------------------------------------------------------------------- - // request — curl-like authenticated request command - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // request — curl-like authenticated request command + // ----------------------------------------------------------------------- - registerRequestCommand(oauth); + registerRequestCommand(oauth); - // --------------------------------------------------------------------------- - // disconnect — unified disconnect with auto-detected managed/BYO routing - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // disconnect — unified disconnect with auto-detected managed/BYO routing + // ----------------------------------------------------------------------- - registerDisconnectCommand(oauth); + registerDisconnectCommand(oauth); - // --------------------------------------------------------------------------- - // token — retrieve a valid oauth token (your-own mode only) - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // token — retrieve a valid oauth token (your-own mode only) + // ----------------------------------------------------------------------- - registerTokenCommand(oauth); + registerTokenCommand(oauth); + }, + }); } diff --git a/assistant/src/cli/commands/oauth/providers.ts b/assistant/src/cli/commands/oauth/providers.ts index f3f27d0f6a9..cc4b791ef7b 100644 --- a/assistant/src/cli/commands/oauth/providers.ts +++ b/assistant/src/cli/commands/oauth/providers.ts @@ -1,40 +1,64 @@ import { type Command } from "commander"; -import { loadConfig } from "../../../config/loader.js"; -import { - deleteApp, - deleteConnection, - deleteProvider, - disconnectOAuthProvider, - getProvider, - listApps, - listConnections, - listProviders, - registerProvider, - updateProvider, -} from "../../../oauth/oauth-store.js"; -import { - type SerializedProvider, - serializeProvider, -} from "../../../oauth/provider-serializer.js"; -import { isProviderVisible } from "../../../oauth/provider-visibility.js"; -import { SEEDED_PROVIDER_KEYS } from "../../../oauth/seed-providers.js"; +import { cliIpcCall, exitFromIpcResult } from "../../../ipc/cli-client.js"; +import { registerCommand } from "../../lib/register-command.js"; import { getCliLogger } from "../../logger.js"; import { shouldOutputJson, writeOutput } from "../../output.js"; const log = getCliLogger("cli"); -const LOOPBACK_CALLBACK_PATH = "/oauth/callback"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SerializedProvider { + providerKey: string; + displayName?: string | null; + description?: string | null; + supportsManagedMode?: boolean; + managedServiceIsPaid?: boolean; + defaultScopes?: string[]; + authUrl?: string; + tokenUrl?: string; + refreshUrl?: string | null; + dashboardUrl?: string | null; + appType?: string | null; + requiresClientSecret?: boolean; + clientIdPlaceholder?: string | null; + scopeSeparator?: string; + tokenEndpointAuthMethod?: string | null; + tokenExchangeBodyFormat?: string | null; + extraParams?: unknown; + redirectUri?: string | null; + baseUrl?: string | null; + userinfoUrl?: string | null; + pingUrl?: string | null; + pingMethod?: string | null; + pingHeaders?: unknown; + pingBody?: unknown; + revokeUrl?: string | null; + revokeBodyTemplate?: unknown; + loopbackPort?: number | null; + injectionTemplates?: unknown; + identityUrl?: string | null; + identityMethod?: string | null; + identityHeaders?: unknown; + identityBody?: unknown; + identityResponsePaths?: string[] | null; + identityFormat?: string | null; + identityOkField?: string | null; + availableScopes?: unknown; + setupNotes?: string[] | unknown; + featureFlag?: string | null; + logoUrl?: string | null; + createdAt?: string; + updatedAt?: string; +} // --------------------------------------------------------------------------- // Text formatting helpers (non-JSON output) // --------------------------------------------------------------------------- -/** - * Format available scopes for text output. - * Returns a single-line string for URLs, or a multi-line bullet list for - * structured scope arrays. - */ function formatAvailableScopes( availableScopes: unknown, indent: string = " ", @@ -55,7 +79,6 @@ function formatAvailableScopes( return null; } -/** Render a single provider as a concise summary line for `list`. */ function formatProviderSummary(p: SerializedProvider): string { const name = p.displayName ?? p.providerKey; const desc = p.description ? ` — ${p.description}` : ""; @@ -71,7 +94,6 @@ function formatProviderSummary(p: SerializedProvider): string { ); } -/** Format a JSON value as indented text for `get` detail output. */ function formatJsonValue(value: unknown, indent: string = " "): string { const json = JSON.stringify(value, null, 2); return json @@ -80,7 +102,6 @@ function formatJsonValue(value: unknown, indent: string = " "): string { .join("\n"); } -/** Render a single provider as structured text for `get` with all fields. */ function formatProviderDetail(p: SerializedProvider): string { const lines: string[] = []; const name = p.displayName ?? p.providerKey; @@ -159,11 +180,7 @@ function formatProviderDetail(p: SerializedProvider): string { /** * Resolve a logo URL from CLI flags, enforcing mutual exclusion between - * --logo-url and --logo-simpleicons-slug. Returns: - * - `undefined` when neither flag is set (caller should leave the field unchanged) - * - `null` when `--logo-url ""` is passed (clear the stored value) - * - a non-empty string URL otherwise - * Throws when both flags are set simultaneously. + * --logo-url and --logo-simpleicons-slug. */ function resolveLogoUrlFromFlags(opts: { logoUrl?: string; @@ -182,51 +199,22 @@ function resolveLogoUrlFromFlags(opts: { return `https://cdn.simpleicons.org/${encodeURIComponent(slug)}`; } if (opts.logoUrl !== undefined) { - // Trim whitespace so copy-paste-padded URLs don't fail to parse on the - // client. Empty string (after trimming) clears the stored value - // (matches --revoke-url semantics documented in the `update` command - // help text). const trimmed = opts.logoUrl.trim(); return trimmed === "" ? null : trimmed; } return undefined; } -/** - * Resolve the redirect URI for a provider based on its loopback port. - * - * Resolves the loopback redirect URI for display purposes. Gateway - * redirect URIs are resolved dynamically at connect time. - */ -function resolveRedirectUri(loopbackPort: number | null): string | null { - if (!loopbackPort) { - // No fixed port — loopback still works at runtime with an OS-assigned - // port, but we can't predict the redirect URI ahead of time. Return - // a sentinel so callers know the transport is loopback-dynamic rather - // than unsupported. - return "http://localhost:/oauth/callback"; - } - return `http://localhost:${loopbackPort}${LOOPBACK_CALLBACK_PATH}`; -} - -/** Serialize a provider row with the CLI-resolved redirect URI. */ -function parseProviderRow(row: ReturnType) { - if (!row) return row; - return serializeProvider(row, { - redirectUri: resolveRedirectUri(row.loopbackPort), - }); -} - export function registerProviderCommands(oauth: Command): void { - const providers = oauth - .command("providers") - .description( + registerCommand(oauth, { + name: "providers", + transport: "ipc", + description: "Fetch configured OAuth providers and register custom providers of your own", - ); - - providers.addHelpText( - "after", - ` + build: (providers) => { + providers.addHelpText( + "after", + ` Providers define the protocol-level configuration for an OAuth integration: authorization URL, token URL, default scopes, and other endpoint details. @@ -234,26 +222,26 @@ They are seeded on startup for built-in integrations (e.g. Google, Slack, GitHub) but can also be registered dynamically via the "register" subcommand. Each provider is identified by a provider key (e.g. "google").`, - ); + ); - // --------------------------------------------------------------------------- - // providers list - // --------------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // providers list + // ----------------------------------------------------------------------- - providers - .command("list") - .description("List all registered OAuth providers") - .option( - "--provider-key ", - 'Filter by provider key substring (case-insensitive). Comma-separated values are OR\'d (e.g. "google,slack")', - ) - .option( - "--supports-managed", - "Only show providers that support managed mode", - ) - .addHelpText( - "after", - ` + providers + .command("list") + .description("List all registered OAuth providers") + .option( + "--provider-key ", + 'Filter by provider key substring (case-insensitive). Comma-separated values are OR\'d (e.g. "google,slack")', + ) + .option( + "--supports-managed", + "Only show providers that support managed mode", + ) + .addHelpText( + "after", + ` Returns registered OAuth providers, including both built-in providers seeded at startup and any dynamically registered via "providers register". @@ -273,67 +261,76 @@ Examples: $ assistant oauth providers list --provider-key notion --json $ assistant oauth providers list --supports-managed $ assistant oauth providers list --supports-managed --json`, - ) - .action( - ( - opts: { providerKey?: string; supportsManaged?: boolean }, - cmd: Command, - ) => { - try { - const config = loadConfig(); - let allProviders = listProviders(); - allProviders = allProviders.filter((r) => - isProviderVisible(r, config), - ); - let rows = allProviders.map(parseProviderRow); - - if (opts.providerKey) { - const needles = opts.providerKey - .split(",") - .map((n) => n.trim().toLowerCase()) - .filter(Boolean); - rows = rows.filter( - (r) => - r && - needles.some((needle) => - r.providerKey.toLowerCase().includes(needle), - ), - ); - } - - if (opts.supportsManaged) { - rows = rows.filter((r) => r && r.supportsManagedMode); - } + ) + .action( + async ( + opts: { providerKey?: string; supportsManaged?: boolean }, + cmd: Command, + ) => { + const queryParams: Record = {}; + if (opts.supportsManaged) { + queryParams.supports_managed_mode = "true"; + } + const r = await cliIpcCall<{ + providers: SerializedProvider[]; + }>("oauth_providers_get", { + queryParams, + }); - if (shouldOutputJson(cmd)) { - writeOutput(cmd, rows); - } else { - const validRows = rows.filter( - (r): r is NonNullable => r != null, - ); - const lines = validRows.map(formatProviderSummary); - process.stdout.write( - `${validRows.length} provider(s):\n\n${lines.join("\n\n")}\n`, + if (!r.ok) return exitFromIpcResult(r); + + // The route returns snake_case summaries; map to camelCase for + // display consistency with the existing CLI contract. + let rows: SerializedProvider[] = (r.result?.providers ?? []).map( + (p: Record) => ({ + providerKey: p.provider_key as string, + displayName: p.display_name as string | null, + description: p.description as string | null, + supportsManagedMode: p.supports_managed_mode as boolean, + managedServiceIsPaid: p.managed_service_is_paid as boolean, + requiresClientSecret: p.requires_client_secret as boolean, + logoUrl: p.logo_url as string | null, + dashboardUrl: p.dashboard_url as string | null, + clientIdPlaceholder: p.client_id_placeholder as string | null, + featureFlag: p.feature_flag as string | null, + }), ); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeOutput(cmd, { ok: false, error: message }); - process.exitCode = 1; - } - }, - ); - // --------------------------------------------------------------------------- - // providers get - // --------------------------------------------------------------------------- + if (opts.providerKey) { + const needles = opts.providerKey + .split(",") + .map((n) => n.trim().toLowerCase()) + .filter(Boolean); + rows = rows.filter( + (r) => + r && + needles.some((needle) => + r.providerKey.toLowerCase().includes(needle), + ), + ); + } - providers - .command("get ") - .description("Show details of a specific OAuth provider") - .addHelpText( - "after", - ` + if (shouldOutputJson(cmd)) { + writeOutput(cmd, rows); + } else { + const lines = rows.map(formatProviderSummary); + process.stdout.write( + `${rows.length} provider(s):\n\n${lines.join("\n\n")}\n`, + ); + } + }, + ); + + // ----------------------------------------------------------------------- + // providers get + // ----------------------------------------------------------------------- + + providers + .command("get ") + .description("Show details of a specific OAuth provider") + .addHelpText( + "after", + ` Arguments: provider-key Provider key (e.g. "google"). Must match the key used during registration or seeding. @@ -345,184 +342,175 @@ if the provider key is not found. Examples: $ assistant oauth providers get google $ assistant oauth providers get twitter --json`, - ) - .action((provider: string, _opts: unknown, cmd: Command) => { - try { - const row = getProvider(provider); - - if (!row) { - writeOutput(cmd, { - ok: false, - error: `Provider not found: "${provider}". Run 'assistant oauth providers list' to see all registered providers. To register a custom provider, run 'assistant oauth providers register --help'.`, - }); - process.exitCode = 1; - return; - } - - if (!isProviderVisible(row, loadConfig())) { - writeOutput(cmd, { - ok: false, - error: `Provider not found: "${provider}". Run 'assistant oauth providers list' to see all registered providers. To register a custom provider, run 'assistant oauth providers register --help'.`, - }); - process.exitCode = 1; - return; - } - - const parsed = parseProviderRow(row); - if (shouldOutputJson(cmd)) { - writeOutput(cmd, parsed); - } else if (parsed) { - process.stdout.write(formatProviderDetail(parsed) + "\n"); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeOutput(cmd, { ok: false, error: message }); - process.exitCode = 1; - } - }); + ) + .action(async (provider: string, _opts: unknown, cmd: Command) => { + const r = await cliIpcCall<{ provider: SerializedProvider }>( + "oauth_providers_by_providerKey_get", + { pathParams: { providerKey: provider } }, + ); - // --------------------------------------------------------------------------- - // providers register - // --------------------------------------------------------------------------- + if (!r.ok) { + if (r.statusCode === 404) { + writeOutput(cmd, { + ok: false, + error: `Provider not found: "${provider}". Run 'assistant oauth providers list' to see all registered providers. To register a custom provider, run 'assistant oauth providers register --help'.`, + }); + process.exitCode = 1; + return; + } + return exitFromIpcResult(r); + } - providers - .command("register") - .description("Register a new OAuth provider configuration") - .requiredOption( - "--provider-key ", - "Unique provider key (e.g. \"custom-service\"). Must not collide with an existing key from 'assistant oauth providers list'.", - ) - .requiredOption( - "--auth-url ", - "OAuth authorization endpoint URL (e.g. https://accounts.example.com/o/oauth2/auth)", - ) - .requiredOption( - "--token-url ", - "OAuth token endpoint URL (e.g. https://oauth2.example.com/token)", - ) - .option( - "--refresh-url ", - "OAuth token refresh endpoint URL. Defaults to --token-url when omitted. Set this when the provider uses a different endpoint for the refresh_token grant than for the authorization_code grant.", - ) - .option("--base-url ", "API base URL for the service") - .option("--userinfo-url ", "OpenID Connect userinfo endpoint URL") - .option( - "--scopes ", - 'Comma-separated default scopes (e.g. "read,write,profile")', - ) - .option( - "--scope-separator ", - 'Separator used to join scopes in the authorize URL (default: " "). Use "," for providers like Linear that expect comma-separated scopes.', - ) - .option( - "--token-auth-method ", - 'How the client authenticates at the token endpoint: "client_secret_post" or "client_secret_basic"', - ) - .option( - "--token-exchange-body-format ", - 'Body encoding for the token exchange request: "form" (application/x-www-form-urlencoded, default) or "json" (application/json)', - "form", - ) - .option( - "--ping-url ", - 'Health-check endpoint URL for token validation (e.g. "https://api.example.com/user"). Used by "assistant oauth ping" to verify a stored token.', - ) - .option( - "--ping-method ", - "HTTP method for the ping endpoint: GET (default) or POST", - ) - .option( - "--ping-headers ", - 'JSON object of extra headers for the ping request (e.g. \'{"Notion-Version":"2022-06-28"}\')', - ) - .option( - "--ping-body ", - 'JSON body to send with the ping request (e.g. \'{"query":"{ viewer { id } }"}\')', - ) - .option( - "--revoke-url ", - 'OAuth token revocation endpoint URL. Called best-effort during disconnect to invalidate the access token upstream (e.g. "https://oauth2.googleapis.com/revoke"). When omitted, disconnect is local-only — the upstream token is left valid until it naturally expires.', - ) - .option( - "--revoke-body-template ", - 'JSON object body template for the revoke request, supporting {access_token} and {client_id} substitution (e.g. \'{"token":"{access_token}","client_id":"{client_id}"}\'). The body is form-encoded and POSTed to --revoke-url.', - ) - .option( - "--display-name ", - "Human-readable display name for the provider", - ) - .option("--description ", "Short description of the provider") - .option( - "--dashboard-url ", - "URL to the provider's developer console / dashboard", - ) - .option( - "--logo-url ", - "URL to the provider's logo image (SVG or PNG). Mutually exclusive with --logo-simpleicons-slug.", - ) - .option( - "--logo-simpleicons-slug ", - 'Simple Icons slug (e.g. "notion", "linear"). Resolves to https://cdn.simpleicons.org/. Mutually exclusive with --logo-url.', - ) - .option( - "--client-id-placeholder ", - "Placeholder text shown in the client ID input field", - ) - .option( - "--no-client-secret", - "Mark this provider as not requiring a client secret (default: required)", - ) - .option( - "--loopback-port ", - "Fixed port for the local OAuth callback server (e.g. 17322). When set, the redirect URI is http://localhost:/oauth/callback", - ) - .option( - "--injection-templates ", - 'JSON array of token injection templates — each with hostPattern, injectionType, headerName, valuePrefix (e.g. \'[{"hostPattern":"api.example.com","injectionType":"header","headerName":"Authorization","valuePrefix":"Bearer "}]\')', - ) - .option( - "--app-type ", - 'What the provider calls its OAuth apps (e.g. "OAuth App", "Desktop app", "Public integration")', - ) - .option( - "--identity-url ", - "Identity verification endpoint URL — called after OAuth to identify the connected account", - ) - .option( - "--identity-method ", - "HTTP method for the identity endpoint: GET (default) or POST", - ) - .option( - "--identity-headers ", - 'JSON object of extra headers for the identity request (e.g. \'{"Notion-Version":"2022-06-28"}\')', - ) - .option( - "--identity-body ", - 'JSON body to send with the identity request (e.g. \'{"query":"{ viewer { email } }"}\')', - ) - .option( - "--identity-response-paths ", - 'Comma-separated dot-notation paths to extract identity from the response (e.g. "email,name,person.email")', - ) - .option( - "--identity-format