import { cancel, log as clackLog, confirm, intro, isCancel, isCI, select, text, type TextOptions, } from "@clack/prompts" import { bgYellow } from "ansis" import path from "node:path" import { z, ZodError, type ZodType } from "zod" import { apiClient } from "./lib/api-client" import { authClient, type CliAuthSession } from "./lib/auth-client" import { accent, dimmed } from "./lib/io" import { isJsonOutput, writeJson } from "./lib/json-output" import { collectCursorPages } from "./lib/pagination" import { findConfigDirMatches, findExplicitConfigDirMatch, type ConfigDirMatch, } from "./lib/config-dir" import { findProjectConfig } from "./lib/project-config" import { clearSpinner, startSpinner } from "./lib/spinner" export const REASONABLE_NAME_SCHEMA = z.string().min(1).max(255).trim() export const ORG_OPT = { org: { type: "string" as const, short: "o", }, } export const ORG_OPTION_DESCRIPTIONS = { org: "Organization name or ID" } export const API_KEY_OPT = { key: { type: "string" as const, short: "k", }, } export const API_KEY_OPTION_DESCRIPTIONS = { key: "API key name, prefix, or ID", } export const PROJECT_OPT = { project: { type: "string" as const, short: "p", }, } export const PROJECT_OPTION_DESCRIPTIONS = { project: "Project name or ID" } export const AUTOMATION_OPT = { automation: { type: "string" as const, short: "a", }, } export const AUTOMATION_OPTION_DESCRIPTIONS = { automation: "Automation identity key or ID", } export const PAGINATION_OPTS = { cursor: { type: "string" as const, }, limit: { type: "string" as const, }, } export const PAGINATION_OPTION_DESCRIPTIONS = { cursor: "Continue after an opaque result cursor", limit: "Maximum results to return (1-100)", } export const CLI_PREFIX = bgYellow.black.bold` automate.ax ` /** Checks whether the current process can safely show interactive prompts. */ export function canPrompt() { return process.stdin.isTTY && !isCI() && !isJsonOutput() } /** * Makes an option nullable only when the CLI can ask for it interactively. * * @param schema - Schema for the option's explicit value. * @param optionName - Human-readable option name used in validation errors. */ export function promptableOption( schema: T, optionName?: string, ) { return schema .nullable() .prefault(null) .refine( (value) => value != null || canPrompt(), `${optionName ?? "Option"} must be passed explicitly in non-interactive mode.`, ) } /** * Runs an interactive prompt and handles cancellation consistently. * * @param promptFactory - Function that starts the prompt. * @param optionName - Explicit option users can pass in non-interactive mode. * @throws {Error} When prompting is unavailable. */ export async function safelyPrompt( promptFactory: () => Promise, optionName?: string, ): Promise { if (!canPrompt()) { throw new Error( `${optionName ?? "Option"} must be passed explicitly in non-interactive mode.`, ) } const result = await promptFactory() if (isCancel(result)) { clearSpinner() cancel("Cancelled") process.exit(0) } return result } /** * Prompts for text, displays schema validation errors, and parses the result. * * @param promptOptions - Text prompt labels and defaults. * @param schema - Schema used to validate and transform the response. * @param optionName - Explicit option users can pass instead of prompting. */ export async function safelyPromptTextWithValidation( promptOptions: TextOptions, schema: T, optionName?: string, ): Promise> { return schema.parseAsync( await safelyPrompt( () => text({ validate: (v) => { const validation = schema.safeParse(v) return validation.success ? undefined : z.prettifyError(validation.error) }, ...promptOptions, }), optionName, ), ) } /** * Extracts the most specific message from common API error envelopes. * * @param error - Unknown error value returned by a command dependency. */ function extractErrorMessage(error: unknown) { const errorWithMessage = z.object({ message: z.string() }).safeParse(error) const bodyWithMessage = z .looseObject({ body: z.looseObject({ message: z.string() }) }) .safeParse(error) const causeWithMessage = z .looseObject({ cause: z.looseObject({ message: z.string() }) }) .safeParse(error) const causeBodyWithMessage = z .looseObject({ cause: z.looseObject({ body: z.looseObject({ message: z.string() }), }), }) .safeParse(error) return errorWithMessage.success ? errorWithMessage.data.message : bodyWithMessage.success ? bodyWithMessage.data.body.message : causeWithMessage.success ? causeWithMessage.data.cause.message : causeBodyWithMessage.success ? causeBodyWithMessage.data.cause.body.message : String(error) } /** * Formats an error for human-readable CLI output. * * @param error - Error to format. */ export function formatErrorMessage(error: unknown) { if (error instanceof ZodError) return z.prettifyError(error) return extractErrorMessage(error).replaceAll( /`([^`]+)`/g, (_, command: string) => accent(command), ) } /** * Converts an error into the CLI's stable JSON error envelope. * * @param error - Error to serialize. */ export function formatJsonError(error: unknown) { if (error instanceof ZodError) { return { error: { message: "Failed validation", ...z.flattenError(error), }, } } const structuredError = z .looseObject({ code: z.string(), data: z.unknown().optional(), message: z.string(), status: z.number().int().optional(), }) .safeParse(error) if (structuredError.success) { const safeData = z.json().safeParse(structuredError.data.data) return { error: { code: structuredError.data.code, ...(structuredError.data.status === undefined ? {} : { status: structuredError.data.status }), ...(safeData.success ? { data: safeData.data } : {}), message: structuredError.data.message, }, } } return { error: { message: extractErrorMessage(error), }, } } /** * Writes a serialized CLI error to stderr. * * @param error - Error to serialize and write. */ export function writeJsonError(error: unknown) { process.stderr.write(`${JSON.stringify(formatJsonError(error), null, 2)}\n`) } export const output = { normal: (printFn: () => unknown) => { if (!isJsonOutput()) printFn() return output }, json: (value: unknown) => { if (isJsonOutput()) writeJson(value) return output }, } /** * Returns the current session or asks the user to log in. * * @param action - User-facing action that requires authentication. * @throws {Error} When the current user is signed out. */ export async function requireSession(action = "continue") { const session = await authClient.getSession() if (session) return session output.normal(() => sessionIntro(null)) throw new Error( `You must log in with ${accent("automate login")} to ${action}.`, ) } /** * Prints the CLI banner and current authentication identity. * * @param session - Session whose identity should be displayed. */ export function sessionIntro(session: CliAuthSession | null) { intro( `${CLI_PREFIX} ${dimmed(session ? session.user.email : "Not logged in")}`, ) } /** Shared schema fragment for commands that operate on an organization. */ export const orgOptsSchema = z.object({ org: z.string().optional(), }) /** Shared schema fragment for cursor-paginated list commands. */ export const paginationOptsSchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(50), }) /** Shared schema fragment for commands that select an organization API key. */ export const apiKeyOptsSchema = orgOptsSchema.extend({ key: z.string().optional(), }) type OrgList = { id: string; name: string; plan?: string | null }[] export interface UseOrganizationOptions { /** What to do when the current user belongs to no organizations. */ whenEmpty?: "error" | "prompt-create" } /** * Resolves an organization from CLI flags, the nearby project config, or an * interactive prompt. Callers opt into inline creation so destructive/update * flows don't create empty organizations. * * @param opts - Parsed organization CLI options. * @param options - Empty-organization behavior options. * @param options.whenEmpty - Behavior when the user belongs to no * organizations. */ export async function useOrganization( opts: z.infer, { whenEmpty = "error" }: UseOrganizationOptions = {}, ) { const configuredProjectId = opts.org ? undefined : (await findProjectConfig())?.projectId startSpinner("Fetching organizations") const [organizations, configuredProjects] = await Promise.all([ collectCursorPages((cursor) => apiClient.org.list({ cursor, limit: 100, query: opts.org, }), ), configuredProjectId ? collectCursorPages((cursor) => apiClient.project.list({ cursor, limit: 100, query: configuredProjectId, }), ) : Promise.resolve([]), ]) clearSpinner() if (opts.org) { const exactIdMatch = organizations.find((org) => org.id === opts.org) if (exactIdMatch) return exactIdMatch if (organizations.length === 0) { throw new Error(`Organization not found: ${opts.org}`) } if (organizations.length === 1) return organizations[0]! return promptOrg(organizations, { allowCreate: false }) } if (configuredProjectId) { const configuredProject = configuredProjects.find( (project) => project.id === configuredProjectId, ) if (!configuredProject) { throw new Error(`Project not found: ${configuredProjectId}`) } const configuredOrganization = organizations.find( (organization) => organization.id === configuredProject.organizationId, ) if (!configuredOrganization) { throw new Error( `Organization not found: ${configuredProject.organizationId}`, ) } return configuredOrganization } if (organizations.length === 0) { if (whenEmpty === "prompt-create") return promptCreateOrganization() throw new Error("You don't belong to any organizations.") } if (organizations.length === 1) return organizations[0]! return promptOrg(organizations, { allowCreate: whenEmpty === "prompt-create", }) } /** * Resolves an API key owned by an organization from `--key` or an interactive * prompt. * * @param organizationId - Organization whose API keys may be selected. * @param opts - Parsed API key selector. */ export async function useOrganizationApiKey( organizationId: string, opts: z.infer, ) { startSpinner("Fetching API keys") const apiKeys = await collectCursorPages((cursor) => apiClient.apiKey.list({ cursor, limit: 100, organizationId }), ) clearSpinner() if (apiKeys.length === 0) { throw new Error( `No API keys. Create one with \`automate org apikey create\``, ) } if (opts.key) { const matches = apiKeys.filter( (apiKey) => apiKey.id === opts.key || apiKey.start === opts.key || apiKey.name === opts.key, ) if (matches.length === 0) { throw new Error(`API key not found: ${opts.key}`) } if (matches.length === 1) return matches[0]! return promptOrganizationApiKey(matches) } if (apiKeys.length === 1) return apiKeys[0]! return promptOrganizationApiKey(apiKeys) } /** * Selects an existing organization without offering creation. * * @param organizations - Organizations available for selection. * @param options - Prompt options with creation disabled. * @param options.allowCreate - Disables inline organization creation. */ export function promptOrg( organizations: T[], options: { allowCreate: false }, ): Promise /** * Selects an organization and optionally offers inline creation. * * @param organizations - Organizations available for selection. * @param options - Prompt options. * @param options.allowCreate - Whether to offer inline organization creation. */ export function promptOrg( organizations: OrgList, options: { allowCreate: boolean }, ): Promise /** * Selects an organization, offering inline creation by default. * * @param organizations - Organizations available for selection. * @param options - Optional prompt behavior. * @param options.allowCreate - Whether to offer inline organization creation. */ export function promptOrg( organizations: OrgList, options?: { allowCreate?: true }, ): Promise export async function promptOrg( organizations: OrgList, { allowCreate = true }: { allowCreate?: boolean } = {}, ) { if (organizations.length === 0) return promptCreateOrganization() const orgOptions = organizations.map((o) => ({ label: `${o.plan === "pro" ? "★ " : ""}${o.name} ${dimmed(o.id)}`, value: o.id, })) const selected = await safelyPrompt( () => select({ message: "Which organization?", options: allowCreate ? [...orgOptions, { label: "Create a new organization", value: null }] : orgOptions, }), "--org", ) if (selected == null) return createOrgInline() return organizations.find((o) => o.id === selected)! } /** * Prompts for one API key from an organization-scoped list. * * @param apiKeys - API keys available for selection. */ async function promptOrganizationApiKey< T extends { id: string; name: string | null; start: string | null }, >(apiKeys: T[]) { const selected = await safelyPrompt( () => select({ message: "Which API key?", options: apiKeys.map((apiKey) => ({ label: `${apiKey.name ?? "Unnamed"} ${dimmed(apiKey.start ?? apiKey.id)}`, value: apiKey.id, })), }), "--key", ) return apiKeys.find((apiKey) => apiKey.id === selected)! } /** Walks the user through creating an organization. */ async function createOrgInline() { const session = await requireSession("create an organization") const defaultName = session.user.name && !z.email().safeParse(session.user.name).success ? `${session.user.name}'s Organization` : "My Organization" startSpinner("Creating organization") const org = await apiClient.org.create({ name: await safelyPromptTextWithValidation( { message: "What should we name the organization?", placeholder: defaultName, defaultValue: defaultName, }, REASONABLE_NAME_SCHEMA, "--name", ), }) clearSpinner() clackLog.success(`Created ${accent(org.name)} ${dimmed(org.id)}`) return org } /** * Asks whether to create the user's first organization, then creates it. * * @throws {Error} When the user declines creation. */ export async function promptCreateOrganization() { if ( !(await safelyPrompt( () => confirm({ message: "You don't have any organizations. Create one now?", initialValue: true, }), "organization", )) ) { throw new Error("You don't belong to any organizations.") } return createOrgInline() } /** Shared schema fragment for commands that operate on a project. */ export const projectOptsSchema = z.object({ project: z.string().optional(), }) /** Shared schema fragment for commands that select an automation. */ export const automationOptsSchema = projectOptsSchema.extend({ automation: z.string().optional(), }) /** Shared schema fragment for commands that operate on a config directory. */ export const configDirOptsSchema = z.object({ dir: z.string().optional(), }) export interface UseProjectOptions { /** Whether project resolution may create a project interactively. */ mode?: "select" | "select-or-create" /** Organization that selected or created projects must belong to. */ organizationId?: string } interface ProjectScopeResolvers { resolveOrganization: typeof useOrganization resolveProject: typeof useProject } /** * Resolves a project from CLI flags, the nearby project config, or an * interactive prompt. Fetches projects across all organizations so the user * doesn't need to specify one. * * @param opts - Parsed project CLI options. * @param options - Project resolution behavior. * @param options.mode - Whether interactive project creation is allowed. * @param options.organizationId - Optional organization scope. */ export async function useProject( opts: z.infer, { mode = "select-or-create", organizationId }: UseProjectOptions = {}, ) { const configuredProjectId = opts.project ? undefined : (await findProjectConfig())?.projectId startSpinner("Fetching projects") let allProjects = await collectCursorPages((cursor) => apiClient.project.list({ cursor, limit: 100, organizationId, query: opts.project ?? configuredProjectId, }), ) clearSpinner() if (opts.project) { const exactIdMatch = allProjects.find( (project) => project.id === opts.project, ) if (exactIdMatch) return exactIdMatch if (allProjects.length === 0) { throw new Error(`Project not found: ${opts.project}`) } if (allProjects.length === 1) return allProjects[0]! return promptProject(allProjects, { allowCreate: false }) } if (configuredProjectId) { const configuredProject = allProjects.find( (project) => project.id === configuredProjectId, ) if (configuredProject) return configuredProject if (!organizationId) { throw new Error(`Project not found: ${configuredProjectId}`) } startSpinner("Fetching projects") allProjects = await collectCursorPages((cursor) => apiClient.project.list({ cursor, limit: 100, organizationId }), ) clearSpinner() } if (allProjects.length === 1) return allProjects[0]! if (mode !== "select-or-create") { if (allProjects.length === 0) { throw new Error("No projects. Create one with `automate project create`") } return promptProject(allProjects, { allowCreate: false }) } const organizations = await collectCursorPages((cursor) => apiClient.org.list({ cursor, limit: 100, query: organizationId }), ) return promptProject(allProjects, { allowCreate: true, organizations: organizations.length > 0 ? organizations : [await promptCreateOrganization()], }) } /** * Resolves one active automation by ID, identity key, or interactive choice. * * @param opts - Automation and optional project selectors. */ export async function useAutomation( opts: z.infer, ) { if (opts.automation && !opts.project) { startSpinner("Fetching automations") const explicitId = resolveAutomationId( await collectCursorPages((cursor) => apiClient.automation.list({ cursor, limit: 100, query: opts.automation, }), ), opts.automation, ) clearSpinner() if (explicitId) return explicitId } const project = await useProject( { project: opts.project }, { mode: "select" }, ) startSpinner("Fetching automations") const automations = await collectCursorPages((cursor) => apiClient.automation.list({ cursor, limit: 100, projectId: project.id, query: opts.automation, }), ) clearSpinner() if (opts.automation) { const explicit = resolveAutomationSelector(automations, opts.automation) if (explicit) return explicit if (automations.length === 0) { throw new Error(`Automation not found: ${opts.automation}`) } } if (automations.length === 0) { throw new Error("No active automations found.") } if (automations.length === 1) return automations[0]! const selected = await safelyPrompt( () => select({ message: "Which automation?", options: automations.map((automation) => ({ label: `${automation.project.organization.name} › ${automation.project.name} › ${automation.identityKey}`, value: automation.id, })), }), "--automation", ) return automations.find(({ id }) => id === selected)! } /** * Resolves the project and its organization for commands with both options. * * @param opts - Explicit project and organization selectors. * @param opts.org - Optional organization name or ID. * @param opts.project - Optional project name or ID. * @param resolvers - Resource resolvers, replaceable for focused tests. */ export async function useProjectScope( opts: { org?: string project?: string }, resolvers: ProjectScopeResolvers = { resolveOrganization: useOrganization, resolveProject: useProject, }, ) { const organization = opts.org ? await resolvers.resolveOrganization({ org: opts.org }) : undefined const project = await resolvers.resolveProject( { project: opts.project }, { mode: "select", organizationId: organization?.id }, ) const inferredOrganization = organization ?? (await resolvers.resolveOrganization({ org: project.organizationId })) if (project.organizationId !== inferredOrganization.id) { throw new Error( `${project.name} does not belong to ${inferredOrganization.name}.`, ) } return { organization: inferredOrganization, project } } /** * Resolves an explicit automation ID or unambiguous identity key. * * @param automations - Visible active automations matching the selector query. * @param selector - Stable automation ID or source identity key. * @throws When the identity key exists in more than one project. */ export function resolveAutomationSelector< T extends { id: string; identityKey: string }, >(automations: readonly T[], selector: string) { const exactId = resolveAutomationId(automations, selector) if (exactId) return exactId const exactIdentity = automations.filter( ({ identityKey }) => identityKey === selector, ) if (exactIdentity.length === 1) return exactIdentity[0] if (exactIdentity.length > 1) { throw new Error( `Automation identity is ambiguous across projects: ${selector}. Pass --project or use a stable automation ID.`, ) } } /** * Resolves only a stable automation ID, leaving identity keys project-scoped. * * @param automations - Visible active automations matching the selector query. * @param selector - Possible stable automation ID. */ export function resolveAutomationId( automations: readonly T[], selector: string, ) { return automations.find(({ id }) => id === selector) } /** * Interactive project selection prompt showing "Org > Project" format with an * option to create a new project inline. When there are no existing projects, * skips straight to creation. * * @param projects - Projects available for selection. * @param options - Selection and inline-creation options. */ async function promptProject< T extends { name: string; id: string; org: OrgList[number] }, >( projects: T[], options: | { allowCreate: false } | { allowCreate: true; organizations: OrgList }, ) { if (projects.length === 0) { if (!options.allowCreate) { throw new Error("No projects. Create one with `automate project create`") } return createProjectInline(options.organizations) } const projectOptions = projects.map((p) => ({ label: `${p.org.name} › ${p.name}`, value: p.id, })) const selected = await safelyPrompt( () => select({ message: "Which project?", options: options.allowCreate ? [...projectOptions, { label: "Create a new project", value: null }] : projectOptions, }), "--project", ) if (selected == null && options.allowCreate) { return createProjectInline(options.organizations) } return projects.find((p) => p.id === selected)! } /** * Walks the user through creating a project. * * @param organizations - Organizations that may own the project. */ async function createProjectInline(organizations: OrgList) { // Resolve interactive organization selection before starting the creation spinner. const org = organizations.length === 1 ? organizations[0]! : await promptOrg(organizations, { allowCreate: true, }) startSpinner("Creating project") const project = await apiClient.project.create({ organizationId: org.id, name: await safelyPromptTextWithValidation( { message: "What should we name the project?", }, REASONABLE_NAME_SCHEMA, "--name", ), }) clearSpinner() clackLog.success(`Created ${accent(project.name)} ${dimmed(project.id)}`) return project } /** * Resolves the config directory from a positional directory or config search. * Precedence: explicit directory > current directory > nearest parent > * downward search. Multiple downward matches are selected interactively. * * @param opts - Parsed config-directory CLI options. */ export async function useConfigDir(opts: z.infer) { if (opts.dir) return findExplicitConfigDirMatch(opts.dir) const matches = await findConfigDirMatches() if (matches.length === 0) { throw new Error( "No config file found. Run from a project directory or pass a directory explicitly.", ) } if (matches.length === 1) return matches[0]! return promptConfigDir(matches) } /** * Prompts the user to select from multiple matching config directories. * * @param matches - Config directory matches discovered by the search. */ async function promptConfigDir(matches: ConfigDirMatch[]) { const cwd = process.cwd() const selected = await safelyPrompt( () => select({ message: "Which config directory?", options: matches.map((match) => ({ label: formatRelativePath(match.dir, cwd), value: match.dir, })), }), "--dir", ) return matches.find((match) => match.dir === selected)! } /** * Formats a path relative to the current working directory. * * @param target - Absolute path to display. * @param cwd - Working directory used as the relative base. */ function formatRelativePath(target: string, cwd: string) { const relative = path.relative(cwd, target) return relative.length > 0 ? relative : "." } /** * Selects a simple English singular or plural word form. * * @param count - Quantity determining which form to use. * @param singularWord - Singular noun to pluralize when needed. */ export function pluralize(count: number, singularWord: string) { if (count === 1) return singularWord if (singularWord.endsWith("y") && !/[aeiou]y$/i.test(singularWord)) { return singularWord.slice(0, -1) + "ies" } return `${singularWord}s` }