import { confirm, log } from "@clack/prompts" import { defineCommand } from "../lib/command" import { globalOptions, globalOptionsSchema } from "../lib/global-options" import { spawn } from "node:child_process" import { existsSync } from "node:fs" import { mkdir, readFile, stat, writeFile } from "node:fs/promises" import path from "node:path" import { resolveCommand } from "package-manager-detector/commands" import { detect, getUserAgent } from "package-manager-detector/detect" import { differenceWith } from "remeda" import z from "zod" import { apiClient } from "../lib/api-client" import { findAutomationFiles } from "../lib/automation-build" import { configFileNames } from "../lib/config-dir" import { accent, dimmed } from "../lib/io" import { output, PROJECT_OPT, projectOptsSchema, safelyPrompt, sessionIntro, useProject, requireSession, PROJECT_OPTION_DESCRIPTIONS, } from "../utils" const AUTOMATE_PACKAGE = "automate.ax" const AUTOMATIONS_DIR_NAME = "automations" const CONFIG_FILE_NAME = "automate.config.ts" const EXAMPLE_AUTOMATION_FILE_NAME = "example.automation.ts" const TSCONFIG_FILE_NAME = "tsconfig.json" const AUTHENTICATED_EXAMPLE_AUTOMATION = `import { automation, onHttpRequest, sendEmail, t } from "automate.ax" export default automation("Send an email from an HTTP request", () => { const request = onHttpRequest() sendEmail({ subject: "Hello from Automate.ax", text: t\`Received a \${request.method} request at \${request.path}.\`, // Send to every member of the project's organization. to: "*", }) }) ` const TSCONFIG = `{ "compilerOptions": { "module": "Preserve", "moduleResolution": "Bundler", "noEmit": true, "skipLibCheck": true, "strict": true, "target": "ESNext", "verbatimModuleSyntax": true }, "include": ["**/*.ts"] } ` /** Options accepted by project initialization. */ const initOptsSchema = projectOptsSchema.extend({ install: z.boolean().default(true), yes: z.boolean().default(false), }) /** Dependency fields read without rejecting unrelated package metadata. */ const packageJsonSchema = z.looseObject({ dependencies: z.record(z.string(), z.string()).optional(), devDependencies: z.record(z.string(), z.string()).optional(), optionalDependencies: z.record(z.string(), z.string()).optional(), peerDependencies: z.record(z.string(), z.string()).optional(), }) export const initCommand = defineCommand({ name: "init", description: "Initialize an Automate.ax project in the current directory", options: { ...globalOptions, ...PROJECT_OPT, install: { type: "boolean", default: true, }, yes: { type: "boolean", short: "y", default: false, }, }, optionDescriptions: { ...PROJECT_OPTION_DESCRIPTIONS, install: `Install the ${AUTOMATE_PACKAGE} package`, yes: "Continue when local and deployed automation paths differ", }, negativeOptionDescriptions: { install: "Skip dependency installation", }, schema: globalOptionsSchema.extend(initOptsSchema.shape), run: initProject, }) /** * Initializes the current directory for one existing or newly created project. * * @param opts - Validated project, installation, and confirmation options. */ export async function initProject(opts: z.infer) { const cwd = process.cwd() assertProjectDirectoryIsUninitialized(cwd) const session = await requireSession() output.normal(() => sessionIntro(session)) const project = await useProject(opts, { mode: "select-or-create" }) const [projectDetails, localAutomations] = await Promise.all([ apiClient.project.get({ projectId: project.id }), findAutomationFiles(cwd), ]) const remoteOnly = differenceWith( projectDetails.activeAutomations, localAutomations, ({ identityKey: a }, { relativePath: b }) => a === b, ).map(({ identityKey }) => identityKey) if (remoteOnly.length > 0) { output.normal(() => log.warn( ` ${accent(project.name)} already has automations deployed that are missing from this directory. Existing automations: ${remoteOnly.map(dimmed).join(", ")} `.trim(), ), ) if ( !opts.yes && !(await safelyPrompt( () => confirm({ message: "Initialize this directory for the project anyway?", initialValue: false, }), "--yes", )) ) { output.json({ cancelled: true, project }) return } } const { createdFiles, packageInstalled } = await scaffoldProjectDirectory( cwd, project.id, ) const packageManager = opts.install && !packageInstalled ? ((await detect({ cwd }))?.agent ?? getUserAgent() ?? "bun") : null if (packageManager) { const installCommand = resolveCommand(packageManager, "add", [ AUTOMATE_PACKAGE, ]) if (!installCommand) { throw new Error( `Unable to install ${AUTOMATE_PACKAGE} with ${packageManager}.`, ) } await runPackageManager(installCommand.command, installCommand.args, cwd) } output .normal(() => log.success( `Initialized ${accent(project.name)} ${dimmed(project.id)} in ${dimmed(cwd)}`, ), ) .json({ createdFiles, installed: packageManager != null, packageManager, project, }) } /** * Rejects initialization before API or filesystem mutation when a config * exists. * * @param cwd - Directory that would be initialized. * @throws When the directory contains a recognized config file. */ export function assertProjectDirectoryIsUninitialized(cwd: string) { const existingConfig = configFileNames.find((fileName) => existsSync(path.join(cwd, fileName)), ) if (existingConfig) { throw new Error(`Config already exists: ${path.join(cwd, existingConfig)}`) } } /** * Writes the non-destructive config, package manifest, and example scaffold. * * @param cwd - Directory to initialize. * @param projectId - Project identity written to the config file. */ export async function scaffoldProjectDirectory(cwd: string, projectId: string) { const packageJsonPath = path.join(cwd, "package.json") const packageJsonExists = existsSync(packageJsonPath) const packageJson = packageJsonExists ? packageJsonSchema.parse( JSON.parse(await readFile(packageJsonPath, "utf8")), ) : null const automationsDir = path.join(cwd, AUTOMATIONS_DIR_NAME) const automationsDirExists = existsSync(automationsDir) if (automationsDirExists && !(await stat(automationsDir)).isDirectory()) { throw new Error(`${automationsDir} exists and is not a directory.`) } const createdFiles = [CONFIG_FILE_NAME] if (!packageJsonExists) { await writeFile( packageJsonPath, `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, { flag: "wx" }, ) createdFiles.push("package.json") } if (!existsSync(path.join(cwd, TSCONFIG_FILE_NAME))) { await writeFile(path.join(cwd, TSCONFIG_FILE_NAME), TSCONFIG, { flag: "wx", }) createdFiles.push(TSCONFIG_FILE_NAME) } if (!automationsDirExists) { await mkdir(automationsDir) await writeFile( path.join(automationsDir, EXAMPLE_AUTOMATION_FILE_NAME), AUTHENTICATED_EXAMPLE_AUTOMATION, { flag: "wx" }, ) createdFiles.push(`${AUTOMATIONS_DIR_NAME}/${EXAMPLE_AUTOMATION_FILE_NAME}`) } await writeFile( path.join(cwd, CONFIG_FILE_NAME), `import { defineConfig } from "automate.ax"\n\nexport default defineConfig({\n projectId: "${projectId}",\n})\n`, { flag: "wx" }, ) return { createdFiles, packageInstalled: [ packageJson?.dependencies, packageJson?.devDependencies, packageJson?.optionalDependencies, packageJson?.peerDependencies, ].some((dependencies) => dependencies?.[AUTOMATE_PACKAGE] != null), } } /** * Runs a package-manager command while retaining useful failure output. * * @param command - Package-manager executable. * @param args - Arguments passed directly to the executable. * @param cwd - Directory receiving the dependency. * @throws When the executable cannot start or exits unsuccessfully. */ function runPackageManager(command: string, args: string[], cwd: string) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, stdio: ["ignore", "ignore", "pipe"], }) let errorOutput = "" child.stderr.setEncoding("utf8") child.stderr.on("data", (chunk: string) => { errorOutput += chunk }) child.once("error", reject) child.once("close", (code) => { if (code === 0) { resolve() return } reject( new Error( `${command} ${args.join(" ")} failed${errorOutput.trim() ? `:\n${errorOutput.trim()}` : "."}`, ), ) }) }) }