import { autocomplete, confirm, log, select } from "@clack/prompts" import type { IntegrationServiceDefinition } from "@automate.ax/catalog" import { defineCommand } from "../lib/command" import { globalOptions, globalOptionsSchema } from "../lib/global-options" import { setTimeout } from "node:timers/promises" import open from "open" import z from "zod" import { apiClient } from "../lib/api-client" import { collectCursorPages } from "../lib/pagination" import { printIntegrationConnectionNotice, promptIntegrationCredentials, } from "../lib/integration-connection" import { accent, dimmed } from "../lib/io" import { isJsonOutput } from "../lib/json-output" import { clearSpinner, startSpinner } from "../lib/spinner" import { ORG_OPT, PAGINATION_OPTS, orgOptsSchema, output, paginationOptsSchema, pluralize, requireSession, safelyPrompt, sessionIntro, useOrganization, ORG_OPTION_DESCRIPTIONS, PAGINATION_OPTION_DESCRIPTIONS, } from "../utils" const CONNECT_ACCOUNT = "__connect_account__" const DONE = "__done__" const BACK = "__back__" const DISCONNECT = "__disconnect__" const REVOKE = "__revoke__" const AUTHORIZATION_POLL_INTERVAL_MS = 2000 type IntegrationService = IntegrationServiceDefinition interface AccountConnectionDependencies { accountApi: Pick< typeof apiClient.account, "beginConnection" | "connectionStatus" | "get" | "submitCredentials" > registerInterrupt: (handler: () => void) => () => void } const DEFAULT_ACCOUNT_CONNECTION_DEPENDENCIES: AccountConnectionDependencies = { accountApi: apiClient.account, registerInterrupt(handler) { process.once("SIGINT", handler) return () => process.removeListener("SIGINT", handler) }, } const ACCOUNT_SELECTOR_ARGS = { ...ORG_OPT, account: { type: "string" as const, short: "a", }, } const ACCOUNT_SELECTOR_DESCRIPTIONS = { ...ORG_OPTION_DESCRIPTIONS, account: "Integration account ID", } const ACCOUNT_SELECTOR_SCHEMA = orgOptsSchema.extend({ account: z.string().min(1), }) const ACCOUNT_MUTATION_SCHEMA = ACCOUNT_SELECTOR_SCHEMA.extend({ yes: z.boolean().default(false), }) const CONNECT_ACCOUNT_OPTS_SCHEMA = orgOptsSchema.extend({ connectionMethod: z.string().min(1).optional(), noOpen: z.boolean().default(false), noWait: z.boolean().default(false), service: z.string().min(1).optional(), }) const CONNECTION_STATUS_OPTS_SCHEMA = orgOptsSchema.extend({ flow: z.string().min(1), }) const ACCOUNT_LIST_OPTS_SCHEMA = orgOptsSchema.extend( paginationOptsSchema.shape, ) const ACCOUNT_MANAGER_ARGS = { ...ORG_OPT, yes: { type: "boolean" as const, short: "y", default: false, }, } const ACCOUNT_MANAGER_DESCRIPTIONS = { ...ORG_OPTION_DESCRIPTIONS, yes: "Skip disconnect confirmation", } /** Validates account manager organization and confirmation options. */ const accountsOptsSchema = orgOptsSchema.extend({ yes: z.boolean().default(false), }) const ACCOUNT_LIST_COMMAND = defineCommand({ name: "list", description: "List connected integration accounts", options: { ...globalOptions, ...ORG_OPT, ...PAGINATION_OPTS, }, optionDescriptions: { ...ORG_OPTION_DESCRIPTIONS, ...PAGINATION_OPTION_DESCRIPTIONS, }, schema: globalOptionsSchema.extend(ACCOUNT_LIST_OPTS_SCHEMA.shape), run: listAccounts, }) const ACCOUNT_SERVICES_COMMAND = defineCommand({ name: "services", description: "List available services and connection methods", options: globalOptions, schema: globalOptionsSchema, run: listServices, }) const ACCOUNT_SHOW_COMMAND = defineCommand({ name: "show", description: "Show one connected integration account", options: { ...globalOptions, ...ACCOUNT_SELECTOR_ARGS, }, optionDescriptions: { ...ACCOUNT_SELECTOR_DESCRIPTIONS, }, requiredOptions: ["account"], schema: globalOptionsSchema.extend(ACCOUNT_SELECTOR_SCHEMA.shape), run: showAccount, }) const ACCOUNT_CONNECT_COMMAND = defineCommand({ name: "connect", description: "Connect an integration account", options: { ...globalOptions, ...ORG_OPT, service: { type: "string", short: "s", }, connectionMethod: { type: "string", short: "m", }, noOpen: { type: "boolean", default: false, }, noWait: { type: "boolean", default: false, }, }, optionDescriptions: { ...ORG_OPTION_DESCRIPTIONS, service: "Integration service ID", connectionMethod: "Connection method ID", noOpen: "Print the authorization URL without opening it", noWait: "Return after starting browser authorization", }, schema: globalOptionsSchema.extend(CONNECT_ACCOUNT_OPTS_SCHEMA.shape), run: connectAccountCommand, }) const ACCOUNT_CONNECTION_STATUS_COMMAND = defineCommand({ name: "status", description: "Check a browser authorization flow", options: { ...globalOptions, ...ORG_OPT, flow: { type: "string", short: "f", }, }, optionDescriptions: { ...ORG_OPTION_DESCRIPTIONS, flow: "Connection flow ID", }, requiredOptions: ["flow"], schema: globalOptionsSchema.extend(CONNECTION_STATUS_OPTS_SCHEMA.shape), run: connectionStatus, }) const ACCOUNT_DISCONNECT_COMMAND = defineCommand({ name: "disconnect", description: "Disconnect an account from one organization", options: { ...globalOptions, ...ACCOUNT_SELECTOR_ARGS, yes: { type: "boolean", short: "y", default: false, }, }, optionDescriptions: { ...ACCOUNT_SELECTOR_DESCRIPTIONS, yes: "Skip confirmation prompt", }, requiredOptions: ["account"], schema: globalOptionsSchema.extend(ACCOUNT_MUTATION_SCHEMA.shape), run: disconnectAccountCommand, }) const ACCOUNT_REVOKE_COMMAND = defineCommand({ name: "revoke", description: "Revoke an account across every organization", options: { ...globalOptions, ...ACCOUNT_SELECTOR_ARGS, yes: { type: "boolean", short: "y", default: false, }, }, optionDescriptions: { ...ACCOUNT_SELECTOR_DESCRIPTIONS, yes: "Confirm platform-wide revocation", }, requiredOptions: ["account"], schema: globalOptionsSchema.extend(ACCOUNT_MUTATION_SCHEMA.shape), run: revokeAccountCommand, }) const ACCOUNT_MANAGE_COMMAND = defineCommand({ name: "manage", description: "Open the interactive connected-account manager", hidden: true, options: { ...globalOptions, ...ACCOUNT_MANAGER_ARGS, }, optionDescriptions: { ...ACCOUNT_MANAGER_DESCRIPTIONS, }, schema: globalOptionsSchema.extend(accountsOptsSchema.shape), run: manageAccounts, }) export const accountsCommand = defineCommand({ name: "accounts", description: "Manage connected integration accounts", options: { ...globalOptions, ...ACCOUNT_MANAGER_ARGS, }, optionDescriptions: { ...ACCOUNT_MANAGER_DESCRIPTIONS, }, defaultSubcommand: ACCOUNT_MANAGE_COMMAND, subcommands: [ ACCOUNT_CONNECT_COMMAND, ACCOUNT_DISCONNECT_COMMAND, ACCOUNT_LIST_COMMAND, ACCOUNT_MANAGE_COMMAND, ACCOUNT_REVOKE_COMMAND, ACCOUNT_SERVICES_COMMAND, ACCOUNT_SHOW_COMMAND, ACCOUNT_CONNECTION_STATUS_COMMAND, ], }) /** * Lists the connected accounts available to one organization. * * @param opts - Organization selector. */ export async function listAccounts( opts: z.infer, ) { const { organization } = await useAccountsOrganization(opts) startSpinner("Fetching connected accounts") const page = await apiClient.account.list({ cursor: opts.cursor, limit: opts.limit, organizationId: organization.id, }) clearSpinner() output .normal(() => { const accounts = page.items if (accounts.length === 0) { log.warn(`No accounts connected to ${accent(organization.name)}.`) return } log.message( `${accent(accounts.length)} connected ${pluralize(accounts.length, "account")}`, ) for (const account of accounts) { log.info( `${accent(account.label)} ${dimmed(account.id)}\n${account.serviceId} via ${account.connectionMethodId} · ${account.status}`, ) } if (page.pageInfo.nextCursor) { log.info(`More results: --cursor ${page.pageInfo.nextCursor}`) } log.message() }) .json(page) } /** Lists enabled services using the server-owned connection catalog. */ export async function listServices() { const session = await requireSession() output.normal(() => sessionIntro(session)) startSpinner("Fetching integration services") const services = await apiClient.account.listServices({}) clearSpinner() output .normal(() => { for (const service of services) { log.info( `${accent(service.name)} ${dimmed(service.id)}\n${service.connectionMethods.map(({ id, name }) => `${name} (${id})`).join(", ")}`, ) } log.message() }) .json({ services }) } /** * Shows one organization-linked integration account. * * @param opts - Organization and account selectors. */ export async function showAccount( opts: z.infer, ) { const { organization } = await useAccountsOrganization(opts) startSpinner("Fetching integration account") const account = await apiClient.account.get({ accountId: opts.account, organizationId: organization.id, }) clearSpinner() output .normal(() => log.info( `${accent(account.label)} ${dimmed(account.id)}\n${account.serviceId} via ${account.connectionMethodId} · ${account.status}`, ), ) .json({ account, organization }) } /** * Starts a form or browser-based integration account connection. * * @param opts - Connection service, method, and browser behavior. */ export async function connectAccountCommand( opts: z.infer, ) { const { organization } = await useAccountsOrganization(opts) output.json({ organization, ...(await connectAccount( organization.id, await fetchIntegrationServices(), opts, )), }) } /** * Returns the durable state of one browser authorization flow. * * @param opts - Organization and connection-flow selectors. */ export async function connectionStatus( opts: z.infer, ) { const { organization } = await useAccountsOrganization(opts) startSpinner("Checking authorization") const status = await apiClient.account.connectionStatus({ flowId: opts.flow, organizationId: organization.id, }) clearSpinner() output .normal(() => { if (status.status === "succeeded") { log.success("Account authorization succeeded") } else if (status.status === "failed") { log.error(status.message) } else { log.info("Account authorization is still pending") } }) .json({ flowId: opts.flow, organization, ...status }) } /** * Disconnects one account only from the selected organization. * * @param opts - Account selector and confirmation behavior. */ export async function disconnectAccountCommand( opts: z.infer, ) { const { organization } = await useAccountsOrganization(opts) const account = await apiClient.account.get({ accountId: opts.account, organizationId: organization.id, }) if ( !opts.yes && !(await safelyPrompt( () => confirm({ initialValue: false, message: `Disconnect ${accent(account.label)} from ${accent(organization.name)}?`, }), "--yes", )) ) { output.json({ cancelled: true, account, organization }) return } startSpinner(`Disconnecting ${account.label}`) await apiClient.account.disconnect({ accountId: account.id, organizationId: organization.id, }) clearSpinner() output .normal(() => log.success(`Disconnected ${accent(account.label)}`)) .json({ account, disconnected: true, organization }) } /** * Revokes a shared account after showing its platform-wide impact. * * @param opts - Account selector and confirmation behavior. */ export async function revokeAccountCommand( opts: z.infer, ) { const { organization } = await useAccountsOrganization(opts) startSpinner("Calculating revocation impact") const impact = await apiClient.account.revocationImpact({ accountId: opts.account, organizationId: organization.id, }) clearSpinner() output.normal(() => printRevocationImpact(impact)) if ( !opts.yes && !(await safelyPrompt( () => confirm({ initialValue: false, message: `Revoke ${accent(impact.account.label)} across every organization? This cannot be undone.`, }), "--yes", )) ) { output.json({ cancelled: true, impact, organization }) return } startSpinner(`Revoking ${impact.account.label}`) const result = await apiClient.account.revoke({ accountId: opts.account, organizationId: organization.id, }) clearSpinner() output .normal(() => { log.success( `Blocked ${accent(impact.account.label)} immediately; provider revocation is ${result.revocation.status}.`, ) }) .json({ impact, organization, ...result }) } /** * Opens the interactive connected-account manager for one organization. * * @param opts - Organization selector and confirmation behavior. */ export async function manageAccounts(opts: z.infer) { const { organization } = await useAccountsOrganization(opts) const services = await fetchIntegrationServices() startSpinner("Fetching connected accounts") let accounts = await fetchOrganizationAccounts(organization.id) clearSpinner() if (isJsonOutput()) { output.json({ accounts, organization, services }) return } while (true) { if (accounts.length === 0) { log.warn(`No accounts connected to ${accent(organization.name)}.`) await connectAccount(organization.id, services, {}) accounts = await fetchOrganizationAccounts(organization.id) continue } const selection = await safelyPrompt( () => autocomplete({ message: `Manage accounts for ${organization.name}`, options: [ { label: "Connect another account", value: CONNECT_ACCOUNT }, ...accounts.map((account) => ({ hint: account.error?.message ?? account.status, label: accountLabel(account, services), value: account.id, })), { label: "Done", value: DONE }, ], }), "Account", ) if (selection === DONE) return if (selection === CONNECT_ACCOUNT) { await connectAccount(organization.id, services, {}) accounts = await fetchOrganizationAccounts(organization.id) continue } const account = accounts.find(({ id }) => id === selection)! const action = await safelyPrompt( () => select({ message: accountLabel(account, services), options: [ { label: "Disconnect from this organization", value: DISCONNECT }, { label: "Revoke across every organization", value: REVOKE }, { label: "Back", value: BACK }, ], }), "Action", ) if (action === BACK) continue if (action === REVOKE) { await revokeAccountCommand({ account: account.id, org: organization.id, yes: opts.yes, }) } else { await disconnectAccountCommand({ account: account.id, org: organization.id, yes: opts.yes, }) } accounts = await fetchOrganizationAccounts(organization.id) } } /** * Connects one service through its selected connection method. * * @param organizationId - Organization receiving the account link. * @param services - Server-enabled integration service manifests. * @param opts - Explicit selectors and browser handoff behavior. * @param dependencies - Account API and interrupt registration used by the * connection flow. */ export async function connectAccount( organizationId: string, services: readonly IntegrationService[], opts: Partial>, dependencies: AccountConnectionDependencies = DEFAULT_ACCOUNT_CONNECTION_DEPENDENCIES, ) { const { accountApi } = dependencies if (services.length === 0) { throw new Error("No integration services are available.") } const serviceId = opts.service ?? (await safelyPrompt( () => autocomplete({ message: "Choose an integration to connect", maxItems: 8, options: services.map((service) => ({ hint: service.description, label: service.name, value: service.id, })), placeholder: "Search integrations", }), "--service", )) const service = services.find(({ id }) => id === serviceId) if (!service) throw new Error(`Integration service not found: ${serviceId}`) printIntegrationConnectionNotice(service) const connectionMethodId = opts.connectionMethod ?? (service.connectionMethods.length === 1 ? service.connectionMethods[0]!.id : await safelyPrompt( () => select({ initialValue: service.preferredConnectionMethodId, message: `Connect ${service.name} using`, options: service.connectionMethods.map(({ id, name }) => ({ label: `${name}${id === service.preferredConnectionMethodId ? " (Recommended)" : ""}`, value: id, })), }), "--connection-method", )) const connection = service.connectionMethods.find( ({ id }) => id === connectionMethodId, ) if (!connection) { throw new Error( `Connection method not found for ${service.id}: ${connectionMethodId}`, ) } if (connection.type === "form") { // Credential prompts must finish before provider work starts the spinner. const data = await promptIntegrationCredentials(service.name, connection) startSpinner(`Connecting ${service.name}`) const { accountId } = await accountApi.submitCredentials({ connectionMethodId, data, organizationId, serviceId, }) // Resolve the stable public result while the connection spinner is active. const account = await accountApi.get({ accountId, organizationId }) clearSpinner() output.normal(() => log.success(`Connected ${accent(service.name)}`)) return { account, connectionMethodId, serviceId, status: "succeeded" } } const { flowId, url } = await accountApi.beginConnection({ connectionMethodId, organizationId, serviceId, }) output.normal(() => log.info(`Authorize ${service.name} in your browser:\n${dimmed(url)}`), ) const resumeCommand = `automate accounts status --org ${organizationId} --flow ${flowId}` output.normal(() => log.info( `Authorization flow ${dimmed(flowId)}\nResume with ${accent(resumeCommand)}.`, ), ) if (!opts.noOpen) await open(url, { wait: false }).catch(() => null) if (opts.noWait) { output.normal(() => log.info( `Authorization started. Check it with ${accent(`automate accounts status --org ${organizationId} --flow ${flowId}`)}.`, ), ) return { connectionMethodId, flowId, resumeCommand, serviceId, status: "pending", url, } } startSpinner(`Waiting for ${service.name} authorization`) const interruptController = new AbortController() const handleInterrupt = () => interruptController.abort() const unregisterInterrupt = dependencies.registerInterrupt(handleInterrupt) try { while (true) { const connectionState = await accountApi.connectionStatus({ flowId, organizationId, }) if (connectionState.status === "failed") { clearSpinner() throw new Error(connectionState.message) } if (connectionState.status === "succeeded") { // Resolve the stable public result before stopping the wait spinner. const account = connectionState.accountId ? await accountApi.get({ accountId: connectionState.accountId, organizationId, }) : null clearSpinner() output.normal(() => log.success(`Connected ${accent(service.name)}`)) return { account, connectionMethodId, flowId, serviceId, status: "succeeded", url, } } await setTimeout(AUTHORIZATION_POLL_INTERVAL_MS, undefined, { signal: interruptController.signal, }) } } catch (error) { if (!interruptController.signal.aborted) throw error clearSpinner() output.normal(() => log.warn( `Authorization wait interrupted. The flow is still pending; resume with ${accent(resumeCommand)}.`, ), ) return { connectionMethodId, flowId, interrupted: true, resumeCommand, serviceId, status: "pending", url, } } finally { unregisterInterrupt() } } /** Loads server-owned service and connection-method manifests. */ async function fetchIntegrationServices() { startSpinner("Fetching integration services") try { return await apiClient.account.listServices({}) } finally { clearSpinner() } } /** * Resolves authentication and the organization selected by account commands. * * @param opts - Organization selector. */ async function useAccountsOrganization(opts: z.infer) { const session = await requireSession() output.normal(() => sessionIntro(session)) const organization = await useOrganization(opts) return { organization, session } } /** * Formats an account with its registered service and connection method. * * @param account - Account being presented. * @param services - Server-enabled integration service manifests. */ function accountLabel( account: Awaited>["items"][number], services: readonly IntegrationService[], ) { const service = services.find(({ id }) => id === account.serviceId) const connection = service?.connectionMethods.find( ({ id }) => id === account.connectionMethodId, ) return `${service?.name ?? account.serviceId}: ${account.label}${service && connection && service.connectionMethods.length > 1 ? ` (${connection.name})` : ""}` } /** * Loads every account page for interactive selection workflows. * * @param organizationId - Organization whose accounts should be collected. */ function fetchOrganizationAccounts(organizationId: string) { return collectCursorPages((cursor) => apiClient.account.list({ cursor, limit: 100, organizationId }), ) } /** * Prints redacted platform-wide impact before destructive revocation. * * @param impact - Public account revocation preview. */ function printRevocationImpact( impact: Awaited>, ) { const { totals } = impact log.warn( [ `Revoking ${accent(impact.account.label)} affects ${totals.automations} ${pluralize(totals.automations, "automation")} in ${totals.projects} ${pluralize(totals.projects, "project")} across ${totals.organizations} ${pluralize(totals.organizations, "organization")}.`, `${totals.pendingDeployments} pending ${pluralize(totals.pendingDeployments, "deployment")} will return to authorization.`, impact.hiddenOrganizationCount > 0 ? `${impact.hiddenOrganizationCount} other ${pluralize(impact.hiddenOrganizationCount, "organization")} ${impact.hiddenOrganizationCount === 1 ? "is" : "are"} hidden.` : null, impact.account.providerRevocationSupported ? "The provider credential will also be revoked." : "The provider does not support remote revocation; Automate.ax will still block the credential immediately.", "Affected automations will be disabled and will not be re-enabled automatically.", ] .filter(Boolean) .join("\n"), ) }