import { confirm, intro, log, multiselect, select, text } from "@clack/prompts" import { getIntegrationService, getTriggerPresentation, isFormFieldValueValid, getFormFieldValueSchema, normalizeFormFieldValue, isTriggerVisible, type FormField, type IntegrationServiceDefinition, } from "@automate.ax/catalog" import { encode, type Encodable } from "@automate.ax/codec" import { isDefinedError, safe } from "@orpc/client" import { defineCommand } from "../lib/command" import { globalOptions, globalOptionsSchema } from "../lib/global-options" import { execFile } from "node:child_process" import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { setTimeout } from "node:timers/promises" import open from "open" import mime from "mime" import { parseURL, stringifyParsedURL } from "ufo" import z from "zod" import { apiClient } from "../lib/api-client" import { DeploymentSetupRequiredError, type DeploymentState, openDeploymentAuthorization, pollDeployment, } from "../lib/deployment-lifecycle" import { env } from "../lib/env" import { accent, dimmed } from "../lib/io" import { AUTOMATION_BUILD_DIR, buildAutomations, findAutomationFiles, } from "../lib/automation-build" import { loadProjectConfig } from "../lib/project-config" import { canPrompt, CLI_PREFIX, configDirOptsSchema, output, pluralize, sessionIntro, safelyPrompt, useConfigDir, requireSession, } from "../utils" import { clearSpinner, startSpinner, withSpinner } from "../lib/spinner" import { renderDetailBlock } from "../lib/detail-block" import { printIntegrationConnectionNotice, promptIntegrationCredentials, } from "../lib/integration-connection" import { isJsonOutput } from "../lib/json-output" import { typecheckProject } from "../lib/project-typecheck" const CONNECT_NEW_ACCOUNT = "__connect_new_account__" const AUTHORIZATION_POLL_INTERVAL_MS = 2000 const DEPLOYMENT_MANIFEST_PATH = ".automate/deployment.md" /** Immutable inputs used to submit a built deployment. */ interface CreateDeploymentOptions { artifact: Blob cancelExisting: boolean manifest: { identityKey: string }[] projectId: string rebindAccounts: boolean runtimeProtocolVersion?: number git: NonNullable | undefined } /** Options accepted by the deployment command. */ const deployOptsSchema = configDirOptsSchema.extend({ allowEmpty: z.boolean().default(false), cancelExisting: z.boolean().default(false), cliAuthorization: z.boolean().default(false), rebindAccounts: z.boolean().default(false), skipTypecheck: z.boolean().default(false), }) /** One deployment binding returned by the authorization API. */ type AuthorizationRequirement = Awaited< ReturnType >["requirements"][number] /** One shared integration service definition. */ type IntegrationService = IntegrationServiceDefinition export const deployCommand = defineCommand({ name: "deploy", description: "Deploy project code", options: { ...globalOptions, allowEmpty: { type: "boolean", default: false, }, cancelExisting: { type: "boolean", default: false, }, cliAuthorization: { type: "boolean", default: false, }, dir: { type: "string", short: "d", }, rebindAccounts: { type: "boolean", default: false, }, skipTypecheck: { type: "boolean", default: false, }, }, optionDescriptions: { allowEmpty: "Allow deployment with no automation files", cancelExisting: "Cancel and replace an in-progress deployment", cliAuthorization: "Choose and connect deployment accounts in the terminal", dir: "Directory containing a config file", rebindAccounts: "Select accounts again instead of reusing saved bindings", skipTypecheck: "Build and deploy without typechecking the project", }, schema: globalOptionsSchema.extend(deployOptsSchema.shape), run: deploy, }) /** * Builds and deploys every automation found in a project's config directory. * * @param opts - Validated options selecting the project directory. * @throws When the directory contains no automation source files unless empty * deployments are allowed. */ export async function deploy(opts: z.infer) { if (env.AUTOMATE_AX_API_KEY) { output.normal(() => intro(`${CLI_PREFIX} ${dimmed("Organization API key")}`), ) } else { const session = await requireSession() output.normal(() => sessionIntro(session)) } const configDir = await useConfigDir(opts) const { projectId } = await loadProjectConfig(configDir.configPath) const [automationFiles, git] = await Promise.all([ findAutomationFiles(configDir.dir), detectGitSource(configDir.dir), ]) if (automationFiles.length === 0 && !opts.allowEmpty) { throw new Error( `No automation files found in ${formatDisplayPath(configDir.dir)}. Expected at least one *.automation.ts file or --allow-empty.`, ) } if (!opts.skipTypecheck) { await withSpinner("Typechecking project", () => typecheckProject(configDir.dir), ) } const buildDir = path.resolve(configDir.dir, AUTOMATION_BUILD_DIR) startSpinner("Building automations") const build = await buildAutomations(configDir.dir, automationFiles).finally( () => rm(buildDir, { recursive: true, force: true }), ) clearSpinner() startSpinner("Deploying") const deploymentId = await createDeployment({ artifact: build.artifact, cancelExisting: opts.cancelExisting, manifest: build.manifest, projectId, rebindAccounts: opts.rebindAccounts, runtimeProtocolVersion: build.runtimeProtocolVersion, git, }) if (!deploymentId) return let deployment = await pollDeployment(deploymentId, { returnForAuthorization: true, }) clearSpinner() if (deployment.status === "awaiting_authorization") { if (isJsonOutput() || !canPrompt()) { throw new DeploymentSetupRequiredError(deployment) } if (opts.cliAuthorization) { while (deployment.status === "awaiting_authorization") { if (hasUnresolvedParameters(deployment)) { await configureParameters(deployment) } else { await configureAuthorization( deploymentId, await apiClient.authorization.get({ deploymentId }), ) } startSpinner("Deploying") deployment = await pollDeployment(deploymentId, { returnForAuthorization: true, }) clearSpinner() } } else { await openDeploymentAuthorization(deployment, { openBrowser: true }) startSpinner( "Waiting for project setup", `Stopped waiting. Deployment ${deploymentId} will continue in the background.`, ) deployment = await pollDeployment(deploymentId, { returnForAuthorization: false, }) clearSpinner() } } const deploymentPresentation = presentDeployment(deployment) const manifestPath = await writeDeploymentManifest(configDir.dir, deployment) output.normal(() => log.info( renderDetailBlock( { [dimmed`Directory`]: formatDisplayPath(configDir.dir), [dimmed`Project`]: `${accent(deploymentPresentation.project.name)}\n${dimmed(deploymentPresentation.project.id)}`, [dimmed`Automations`]: formatAutomationDetails( deploymentPresentation.automations, ), [dimmed`Manifest`]: formatDisplayPath(manifestPath), }, { keyAlignment: "right", lineSpacing: 1 }, ), ), ) output .normal(() => log.success( `Successfully deployed ${accent(String(deploymentPresentation.automations.length))} ${pluralize(deploymentPresentation.automations.length, "automation")}`, ), ) .json({ ...configDir, automationFiles, build, deployment: deploymentPresentation, manifestPath, }) } /** * Returns whether planning stopped for missing or invalid parameter values. * * @param deployment - Current deployment state. */ function hasUnresolvedParameters(deployment: DeploymentState) { return deployment.automations.some( ({ parameterIssues }) => (parameterIssues?.length ?? 0) > 0, ) } /** * Prompts for unresolved parameters and resumes planning. * * @param deployment - Awaiting deployment and parameter declarations. */ async function configureParameters(deployment: DeploymentState) { const values = [] for (const automation of deployment.automations) { for (const name of automation.parameterIssues ?? []) { const field = automation.parameters?.find( (candidate) => candidate.name === name, ) if (!field) { throw new Error(`Project parameter ${name} has no field definition.`) } values.push({ automationId: automation.id, name, value: Buffer.from(await encode(await promptParameter(field))).toString( "base64", ), }) } } await apiClient.deployment.configureParameters({ deploymentId: deployment.id, values, }) } /** * Collects one value using the parameter's shared dashboard field format. * * @param field - Parameter field to prompt. */ async function promptParameter(field: FormField): Promise { const message = field.description ? `${field.label ?? field.name} — ${field.description}` : (field.label ?? field.name) if (field.type === "file") { while (true) { const filePath = ( await safelyPrompt( () => text({ message: `${message} (file path)`, validate: (value) => field.required !== false && !value?.trim() ? "Enter a file path." : undefined, }), field.label, ) ).trim() if (!filePath && field.required === false) return null const file = await readParameterFile(filePath) if (!file) { log.warn("File not found.") continue } return getFormFieldValueSchema(field).parse(file) } } if (field.type === "checkbox") { while (true) { const value = await safelyPrompt( () => confirm({ initialValue: field.defaultValue, message, }), field.label, ) if (isFormFieldValueValid(field, value)) return value log.warn(`${field.label ?? field.name} must be selected.`) } } if (field.type === "select" || field.type === "radio") { return await safelyPrompt( () => select({ initialValue: field.defaultValue, message, options: [...field.options], }), field.label, ) } if (field.type === "multi-select") { return await safelyPrompt( () => multiselect({ initialValues: field.defaultValue ? [...field.defaultValue] : [], message, options: [...field.options], required: field.required, }), field.label, ) } return getFormFieldValueSchema(field).parse( normalizeFormFieldValue( field, await safelyPrompt( () => text({ initialValue: field.defaultValue === undefined ? undefined : String(field.defaultValue), message, placeholder: "placeholder" in field ? field.placeholder : undefined, validate: (candidate) => isFormFieldValueValid( field, normalizeFormFieldValue(field, candidate ?? ""), ) ? undefined : `${field.label ?? field.name} is invalid.`, }), field.label, ), ), ) } /** * Reads a local parameter file with its name, media type, and modification * time. * * @param filePath - Local path entered at the parameter prompt. */ export async function readParameterFile( filePath: string, ): Promise { const metadata = await stat(filePath).catch((error: unknown) => { if (error instanceof Error && "code" in error && error.code === "ENOENT") { return undefined } throw error }) if (!metadata?.isFile()) return undefined return new File([await readFile(filePath)], path.basename(filePath), { type: mime.getType(filePath) ?? "application/octet-stream", lastModified: metadata.mtimeMs, }) } /** * Creates a deployment, offering to replace an eligible concurrent deployment. * * @param input - Immutable deployment inputs and override preference. */ async function createDeployment(input: CreateDeploymentOptions) { let cancelExisting = input.cancelExisting while (true) { const [error, deployment] = await safe( apiClient.deployment.create({ ...input, cancelExisting, mode: "reconcile", }), ) if (!error) return deployment.id if (!isDefinedError(error)) throw error if (error.code !== "DEPLOYMENT_IN_PROGRESS") throw error clearSpinner() if ( !["planning", "awaiting_authorization", "configuring"].some( (status) => status === error.data.status, ) ) { throw new Error( `Deployment ${error.data.deploymentId} is ${error.data.status} and can no longer be cancelled.`, ) } if (!cancelExisting && !canPrompt()) throw error if ( !cancelExisting && !(await safelyPrompt( () => confirm({ message: `Deployment ${error.data.deploymentId} is already ${error.data.status}. Cancel it and deploy instead?`, initialValue: false, }), "--cancel-existing", )) ) { output.json({ cancelled: true, deployment: error.data }) return } cancelExisting = true startSpinner("Deploying") } } /** * Interactively assigns existing eligible accounts to unmet bindings. * * @param deploymentId - Deployment whose requirements should be assigned. * @param authorization - Current accounts and deployment requirements. */ async function configureAuthorization( deploymentId: string, authorization: Awaited>, ) { let current = authorization while (true) { const requirement = current.requirements.find(({ satisfied }) => !satisfied) if (!requirement) { await apiClient.authorization.proceed({ deploymentId }) return } const registeredService = getIntegrationService(requirement.serviceId) if (!registeredService) { throw new Error( `Integration service ${requirement.serviceId} is unavailable.`, ) } const service = { ...registeredService, connectionMethods: registeredService.connectionMethods.filter( (connection) => requirement.connections.some( ({ connectionMethodId }) => connectionMethodId === connection.id, ), ), } if (service.connectionMethods.length === 0) { throw new Error( `Integration service ${requirement.serviceId} has no allowed connection methods.`, ) } const accounts = current.accounts.filter((account) => requirement.eligibleAccountIds.includes(account.id), ) if (accounts.length > 0) { const selectedAccountId = await safelyPrompt( () => select({ message: `Choose an account for ${service.name}/${requirement.binding}`, options: [ ...accounts.map((account) => ({ label: service.connectionMethods.length === 1 ? account.label : `${account.label} (${service.connectionMethods.find(({ id }) => id === account.connectionMethodId)!.name})`, value: account.id, })), { label: `Connect new ${service.name} account`, value: CONNECT_NEW_ACCOUNT, }, ], }), "Account", ) if (selectedAccountId !== CONNECT_NEW_ACCOUNT) { await apiClient.authorization.selectAccount({ accountId: selectedAccountId, binding: requirement.binding, deploymentId, }) current = await apiClient.authorization.get({ deploymentId }) continue } } current = await connectAccount(deploymentId, requirement, service) } } /** * Connects and selects a new account using the shared service definition. * * @param deploymentId - Deployment awaiting the account. * @param requirement - Binding that needs an account. * @param service - Shared service presentation. */ async function connectAccount( deploymentId: string, requirement: AuthorizationRequirement, service: IntegrationService, ) { printIntegrationConnectionNotice(service) const selectedConnectionMethodId = service.connectionMethods.length === 1 ? service.connectionMethods[0]!.id : await safelyPrompt( () => select({ initialValue: service.connectionMethods.some( ({ id }) => id === service.preferredConnectionMethodId, ) ? service.preferredConnectionMethodId : undefined, 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 === selectedConnectionMethodId, )! if (connection.type === "form") { // Collect credentials before starting the provider-work spinner. const data = await promptIntegrationCredentials(service.name, connection) startSpinner(`Connecting ${service.name}`) await apiClient.authorization.submitCredentials({ binding: requirement.binding, connectionMethodId: connection.id, data, deploymentId, serviceId: service.id, }) clearSpinner() return apiClient.authorization.get({ deploymentId }) } const { url } = await apiClient.authorization.beginConnection({ binding: requirement.binding, connectionMethodId: connection.id, deploymentId, serviceId: service.id, }) log.info(`Authorize ${service.name} in your browser:\n${url}`) await open(url, { wait: false }).catch(() => null) startSpinner( `Waiting for ${service.name} authorization`, `Stopped waiting. Deployment ${deploymentId} remains pending authorization.`, ) while (true) { const authorization = await apiClient.authorization.get({ deploymentId }) if ( authorization.requirements.find( (candidate) => candidate.binding === requirement.binding && candidate.serviceId === requirement.serviceId, )?.satisfied ) { clearSpinner() return authorization } await setTimeout(AUTHORIZATION_POLL_INTERVAL_MS) } } /** * Formats an absolute path relative to the current directory or home when * possible. * * @param target - Filesystem path to display. */ function formatDisplayPath(target: string) { const absoluteTarget = path.resolve(target) const cwdRelative = path.relative(process.cwd(), absoluteTarget) if (cwdRelative.length > 0 && isRelativeWithin(cwdRelative)) { return `.${path.sep}${cwdRelative}` } const homeRelative = path.relative(os.homedir(), absoluteTarget) if (homeRelative.length === 0) return "~" if (isRelativeWithin(homeRelative)) { return `~${path.sep}${homeRelative}` } return absoluteTarget } /** * Formats deployed automation identities for a CLI detail block. * * @param automations - Planned automations to list. */ function formatAutomationDetails( automations: ReturnType["automations"], ) { if (automations.length === 0) return dimmed`None` return automations .map((automation) => [ automation.description, dimmed(automation.identityKey), ...automation.triggers.flatMap((trigger) => [ ` ${trigger.name}`, ...(trigger.primary ? [` ${accent(trigger.primary.value)}`] : []), ...trigger.details.map( ({ label, value }) => ` ${dimmed(label)} ${accent(value)}`, ), ]), ].join("\n"), ) .join("\n\n") } /** * Checks whether a relative path remains within its base directory. * * @param relative - Relative path to inspect. */ function isRelativeWithin(relative: string) { return ( relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ) } /** * Renders the latest successful deployment as a human-readable Markdown file. * * @param deployment - Completed deployment and subscription facts. * @param generatedAt - Time recorded in the generated artifact. */ export function renderDeploymentManifest( deployment: DeploymentState, generatedAt = new Date(), ) { return [ "# Automate.ax deployment", "", "> Generated by `automate deploy`. Re-run the command to refresh this file.", "", `- Project: ${deployment.project.name} (\`${deployment.project.id}\`)`, `- Deployment: \`${deployment.id}\``, `- Status: ${deployment.status}`, `- Generated: ${generatedAt.toISOString()}`, "", "## Automations", "", ...presentDeployment(deployment).automations.flatMap((automation) => [ `### \`${automation.identityKey}\``, "", automation.description, "", ...automation.triggers.flatMap((trigger) => [ `#### ${trigger.name}`, "", ...(trigger.primary ? [trigger.primary.value] : []), ...(trigger.primary && trigger.details.length > 0 ? [""] : []), ...trigger.details.map(({ label, value }) => `- ${label}: ${value}`), "", ]), ]), ].join("\n") } /** * Refreshes the generated deployment artifact under the project directory. * * @param projectDir - Directory that owns the deployed project. * @param deployment - Completed deployment and subscription facts. */ async function writeDeploymentManifest( projectDir: string, deployment: DeploymentState, ) { const manifestPath = path.join(projectDir, DEPLOYMENT_MANIFEST_PATH) await mkdir(path.dirname(manifestPath), { recursive: true }) await writeFile(manifestPath, `${renderDeploymentManifest(deployment)}\n`) return manifestPath } /** * Derives client-owned trigger presentation from deployment facts. * * @param deployment - Deployment facts returned by the API. */ function presentDeployment(deployment: DeploymentState) { return { ...deployment, automations: deployment.automations.map( ({ scopes, subscriptions, ...automation }) => ({ ...automation, triggers: subscriptions .filter((subscription) => isTriggerVisible(subscription.scopePath, scopes), ) .map((subscription) => getTriggerPresentation({ ...subscription, appOrigin: env.APP_ORIGIN, automationId: automation.id, projectId: deployment.project.id, }), ), }), ), } } /** * Detects the repository and working-tree state represented by a deployment. * * @param cwd - Project directory that will be deployed. */ export async function detectGitSource(cwd: string) { const [repositoryUrl, commitSha, projectPath, status] = await Promise.all([ runGit(cwd, ["remote", "get-url", "origin"]), runGit(cwd, ["rev-parse", "HEAD"]), runGit(cwd, ["rev-parse", "--show-prefix"]), runGit(cwd, ["status", "--porcelain"]), ]) if ( repositoryUrl === null || commitSha === null || projectPath === null || status === null ) { return } return { commitSha, dirty: status.length > 0, projectPath, repositoryUrl: stringifyParsedURL({ ...parseURL(repositoryUrl), auth: undefined, hash: "", search: "", }), } } /** * Runs a read-only Git command, returning null when Git cannot resolve it. * * @param cwd - Working directory for the Git command. * @param args - Git arguments to execute. */ function runGit(cwd: string, args: string[]) { return new Promise((resolve) => { execFile( "git", ["-C", cwd, ...args], { encoding: "utf8" }, (error, stdout) => resolve(error ? null : stdout.trim()), ) }) }