import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import path from "node:path" import { randomUUID } from "node:crypto" import { pathToFileURL } from "node:url" import { build as esbuild, type Metafile } from "esbuild" import { zipSync } from "fflate" import { globby } from "globby" export const AUTOMATION_BUILD_DIR = ".automate/build" export const AUTOMATION_PROJECT_ENTRYPOINT = "project.mjs" const ARTIFACT_DIR = "artifact" const ARTIFACT_FILENAME = "project.zip" const GENERATED_ENTRYPOINT_FILENAME = "project-entrypoint.mjs" const RUNTIME_ASSETS = [ { inputSuffix: path.join( "node_modules", "@1password", "sdk-core", "nodejs", "core.js", ), outputFilename: "core_bg.wasm", sourceFilename: "core_bg.wasm", }, { inputSuffix: path.join( "node_modules", "libpg-query", "wasm", "libpg-query.js", ), outputFilename: "libpg-query.wasm", sourceFilename: "libpg-query.wasm", }, ] as const export interface AutomationFile { path: string relativePath: string } export interface AutomationBuildManifestEntry { identityKey: string } export interface AutomationBuildResult { artifact: Blob manifest: AutomationBuildManifestEntry[] metafile?: Metafile runtimeProtocolVersion?: number } export interface AutomationBuildOptions { /** Include esbuild's module contribution metadata in the result. */ metafile?: boolean } /** * Finds automation entry points below a project directory. * * @param dir - Project directory to search. */ export async function findAutomationFiles(dir: string) { const resolvedDir = path.resolve(dir) return ( await globby("**/*.automation.ts", { absolute: true, cwd: resolvedDir, gitignore: true, onlyFiles: true, }) ) .map( (automationPath) => ({ path: automationPath, relativePath: toArtifactPath( path.relative(resolvedDir, automationPath), ), }) satisfies AutomationFile, ) .toSorted((a, b) => a.relativePath.localeCompare(b.relativePath)) } /** * Builds every automation into one statically dispatched project artifact. * * @param projectDir - Project directory that owns the automations. * @param automationFiles - Automation entry points to build. * @param options - Optional build diagnostics. */ export async function buildAutomations( projectDir: string, automationFiles: AutomationFile[], options: AutomationBuildOptions = {}, ): Promise { if ( new Set(automationFiles.map(({ relativePath }) => relativePath)).size !== automationFiles.length ) { throw new Error("Automation identity keys must be unique within a project") } const resolvedProjectDir = path.resolve(projectDir) const buildDir = path.join(resolvedProjectDir, AUTOMATION_BUILD_DIR) const artifactDir = path.join(buildDir, ARTIFACT_DIR) const generatedEntrypoint = path.join(buildDir, GENERATED_ENTRYPOINT_FILENAME) await rm(buildDir, { recursive: true, force: true }) await mkdir(artifactDir, { recursive: true }) await writeFile(generatedEntrypoint, createProjectEntrypoint(automationFiles)) const { metafile } = await esbuild({ absWorkingDir: resolvedProjectDir, banner: { js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', }, bundle: true, conditions: ["bun"], entryNames: "[name]", entryPoints: { project: generatedEntrypoint }, format: "esm", logLevel: "silent", keepNames: true, metafile: true, minify: true, outdir: artifactDir, outExtension: { ".js": ".mjs" }, platform: "node", target: "node24", write: true, }) await copyRuntimeAssets(resolvedProjectDir, artifactDir, metafile) let runtimeProtocolModule: string | undefined try { runtimeProtocolModule = createRequire( path.join(resolvedProjectDir, "package.json"), ).resolve("automate.ax/runtime-protocol") } catch { runtimeProtocolModule = undefined } let projectBundle: unknown if (runtimeProtocolModule) { const runtimeProtocolModuleUrl = pathToFileURL(runtimeProtocolModule) runtimeProtocolModuleUrl.searchParams.set("build", randomUUID()) projectBundle = await import(runtimeProtocolModuleUrl.href) } const runtimeProtocolVersion = typeof projectBundle === "object" && projectBundle !== null && "AUTOMATION_RUNTIME_PROTOCOL_VERSION" in projectBundle && typeof projectBundle.AUTOMATION_RUNTIME_PROTOCOL_VERSION === "number" && Number.isInteger(projectBundle.AUTOMATION_RUNTIME_PROTOCOL_VERSION) && projectBundle.AUTOMATION_RUNTIME_PROTOCOL_VERSION > 0 ? projectBundle.AUTOMATION_RUNTIME_PROTOCOL_VERSION : undefined const artifactBytes = zipSync( Object.fromEntries( await Promise.all( ( await globby("**/*", { absolute: true, cwd: artifactDir, onlyFiles: true, }) ).map(async (filePath) => [ toArtifactPath(path.relative(artifactDir, filePath)), new Uint8Array(await readFile(filePath)), ]), ), ), { level: 9 }, ) await writeFile(path.join(buildDir, ARTIFACT_FILENAME), artifactBytes) return { artifact: new Blob([artifactBytes], { type: "application/zip" }), manifest: automationFiles.map(({ relativePath: identityKey }) => ({ identityKey, })), ...(runtimeProtocolVersion ? { runtimeProtocolVersion } : {}), ...(options.metafile ? { metafile } : {}), } } /** * Copies package-owned files loaded dynamically at runtime into the artifact. * * @param projectDir - Project directory used to resolve metafile input paths. * @param artifactDir - Esbuild output directory packaged for the runtime. * @param metafile - Complete esbuild input graph for the project bundle. */ async function copyRuntimeAssets( projectDir: string, artifactDir: string, metafile: Metafile, ) { await Promise.all( RUNTIME_ASSETS.flatMap((asset) => { const ownerModule = Object.keys(metafile.inputs).find((input) => path.normalize(input).endsWith(asset.inputSuffix), ) if (!ownerModule) return [] return copyFile( path.join( path.dirname(path.resolve(projectDir, ownerModule)), asset.sourceFilename, ), path.join(artifactDir, asset.outputFilename), ) }), ) } /** * Creates the static dispatcher that becomes the project's Lambda module. * * @param automationFiles - Automations to statically import and dispatch. */ function createProjectEntrypoint(automationFiles: AutomationFile[]) { return ` import { runAutomationInvocation } from "automate.ax/runtime" const automations = { ${automationFiles .map( ({ path: automationPath, relativePath }) => ` ${JSON.stringify(relativePath)}: () => import(${JSON.stringify(toArtifactPath(automationPath))}),`, ) .join("\n")} } export async function runProjectAutomation(input, identityKey) { const loadAutomation = automations[identityKey] if (!loadAutomation) { throw new Error(\`Automation \${identityKey} is not part of this project build.\`) } return await runAutomationInvocation(input, loadAutomation) } `.trimStart() } /** * Converts a platform path into the portable path stored in an artifact. * * @param filePath - Path to normalize. */ function toArtifactPath(filePath: string) { return filePath.split(path.sep).join(path.posix.sep) }