diff --git a/packages/cli/src/commands/cloudrun.test.ts b/packages/cli/src/commands/cloudrun.test.ts new file mode 100644 index 0000000000..52271e7e11 --- /dev/null +++ b/packages/cli/src/commands/cloudrun.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isMissingCloudRunAdapterError, missingCloudRunAdapterMessage } from "./cloudrun.js"; + +describe("Cloud Run adapter preflight", () => { + it("identifies only the missing adapter, not a missing transitive dependency", () => { + const adapterError = Object.assign( + new Error("Cannot find package '@hyperframes/gcp-cloud-run' imported from cli.js"), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + const transitiveError = Object.assign(new Error("Cannot find package 'google-auth-library'"), { + code: "ERR_MODULE_NOT_FOUND", + }); + + expect(isMissingCloudRunAdapterError(adapterError)).toBe(true); + expect(isMissingCloudRunAdapterError(transitiveError)).toBe(false); + }); + + it("provides global and project-local install recovery commands", () => { + const message = missingCloudRunAdapterMessage("deploy"); + expect(message).toContain("hyperframes cloudrun deploy"); + expect(message).toContain("npm install -g @hyperframes/gcp-cloud-run"); + expect(message).toContain("npm install @hyperframes/gcp-cloud-run"); + }); +}); diff --git a/packages/cli/src/commands/cloudrun.ts b/packages/cli/src/commands/cloudrun.ts index 65c2bda026..7ff0f9a3f3 100644 --- a/packages/cli/src/commands/cloudrun.ts +++ b/packages/cli/src/commands/cloudrun.ts @@ -13,10 +13,9 @@ */ import { spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { defineCommand } from "citty"; import { type CanvasResolution, @@ -89,6 +88,35 @@ interface StackState { workflowId: string; } +type CloudRunAdapter = typeof import("@hyperframes/gcp-cloud-run/sdk") & + typeof import("@hyperframes/gcp-cloud-run/terraform"); +let cloudRunAdapterPromise: Promise | undefined; + +function loadCloudRunAdapter(): Promise { + cloudRunAdapterPromise ??= Promise.all([ + import("@hyperframes/gcp-cloud-run/sdk"), + import("@hyperframes/gcp-cloud-run/terraform"), + ]).then(([sdk, terraform]) => ({ ...sdk, ...terraform })); + return cloudRunAdapterPromise; +} + +export function isMissingCloudRunAdapterError(error: unknown): boolean { + return ( + (error as NodeJS.ErrnoException)?.code === "ERR_MODULE_NOT_FOUND" && + normalizeErrorMessage(error).includes("@hyperframes/gcp-cloud-run") + ); +} + +export function missingCloudRunAdapterMessage(subcommand: string): string { + return ( + `${c.error("@hyperframes/gcp-cloud-run is not installed.")} The ${c.accent(`hyperframes cloudrun ${subcommand}`)} command needs it at runtime.\n` + + `Install it alongside the CLI:\n` + + ` ${c.accent("npm install -g @hyperframes/gcp-cloud-run")}\n` + + `Or, for an opt-in project setup:\n` + + ` ${c.accent("npm install @hyperframes/gcp-cloud-run")}` + ); +} + export default defineCommand({ meta: { name: "cloudrun", description: "Deploy and drive renders on Google Cloud Run" }, args: { @@ -205,6 +233,25 @@ export default defineCommand({ console.log(HELP); return; } + const verbsNeedingAdapter = new Set([ + "deploy", + "sites", + "render", + "render-batch", + "progress", + "destroy", + ]); + if (verbsNeedingAdapter.has(subcommand)) { + try { + await loadCloudRunAdapter(); + } catch (error) { + if (isMissingCloudRunAdapterError(error)) { + console.error(missingCloudRunAdapterMessage(subcommand)); + process.exit(1); + } + throw error; + } + } switch (subcommand) { case "deploy": return runDeploy(args); @@ -267,13 +314,6 @@ function stripUndefined>(o: T): Partial { return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)) as Partial; } -/** Resolve the Terraform module dir shipped with @hyperframes/gcp-cloud-run. */ -function terraformDir(): string { - const require = createRequire(import.meta.url); - const pkgJson = require.resolve("@hyperframes/gcp-cloud-run/package.json"); - return join(dirname(pkgJson), "terraform"); -} - function run(cmd: string, cmdArgs: string[], opts: { cwd?: string } = {}): void { const res = spawnSync(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd }); if (res.status !== 0) { @@ -291,7 +331,7 @@ function capture(cmd: string, cmdArgs: string[], opts: { cwd?: string } = {}): s // ── deploy ────────────────────────────────────────────────────────────────── // fallow-ignore-next-line complexity -function runDeploy(args: Record): void { +async function runDeploy(args: Record): Promise { const project = args.project as string | undefined; if (!project) { console.error("[cloudrun deploy] --project is required."); @@ -299,7 +339,8 @@ function runDeploy(args: Record): void { } const region = (args.region as string | undefined) ?? "us-central1"; const repo = (args.repo as string | undefined) ?? "hyperframes"; - const tfDir = terraformDir(); + const { getTerraformModuleDir } = await loadCloudRunAdapter(); + const tfDir = getTerraformModuleDir(); const repoRoot = findRepoRoot(tfDir); console.log(`→ Enabling required APIs on ${project}`); @@ -467,7 +508,7 @@ async function runSites(args: Record): Promise { process.exit(1); } const state = readState(args); - const { deploySite } = await import("@hyperframes/gcp-cloud-run/sdk"); + const { deploySite } = await loadCloudRunAdapter(); const handle = await deploySite({ projectDir: resolve(projectDir), bucketName: state.bucketName, @@ -510,7 +551,7 @@ async function runRender(args: Record): Promise { const variables = resolveAndValidateVariables(args, resolve(projectDir)); const config = buildRenderConfig(args, fps, width, height, variables); - const { renderToCloudRun, getRenderProgress } = await import("@hyperframes/gcp-cloud-run/sdk"); + const { renderToCloudRun, getRenderProgress } = await loadCloudRunAdapter(); const handle = await renderToCloudRun({ projectDir: resolve(projectDir), config: config as Parameters[0]["config"], @@ -565,7 +606,7 @@ async function runProgress(args: Record): Promise { console.error("[cloudrun progress] usage: hyperframes cloudrun progress "); process.exit(1); } - const { getRenderProgress } = await import("@hyperframes/gcp-cloud-run/sdk"); + const { getRenderProgress } = await loadCloudRunAdapter(); const progress = await getRenderProgress({ executionName }); if (args.json) { console.log(JSON.stringify(progress, null, 2)); @@ -641,7 +682,7 @@ async function runRenderBatch(args: Record): Promise { const state = readState(args); const maxConcurrent = parsePositiveInt(args["max-concurrent"], "--max-concurrent") ?? DEFAULT_BATCH_MAX_CONCURRENT; - const { deploySite, renderToCloudRun } = await import("@hyperframes/gcp-cloud-run/sdk"); + const { deploySite, renderToCloudRun } = await loadCloudRunAdapter(); // Upload the project once; every entry reuses the same content-addressed // site handle so the tar+upload cost is paid a single time. @@ -727,8 +768,9 @@ function parseBatchFile(path: string): BatchEntry[] { // ── destroy ────────────────────────────────────────────────────────────── // fallow-ignore-next-line complexity -function runDestroy(args: Record): void { - const tfDir = terraformDir(); +async function runDestroy(args: Record): Promise { + const { getTerraformModuleDir } = await loadCloudRunAdapter(); + const tfDir = getTerraformModuleDir(); const state = existsSync(statePath()) ? (JSON.parse(readFileSync(statePath(), "utf8")) as Partial) : {}; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index fcc64add5e..e70b096e0c 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -59,6 +59,7 @@ var __dirname = __hf_dirname(__filename);`, // from the `dependencies`/workspace entry, not the bundled CLI. "@hyperframes/gcp-cloud-run", "@hyperframes/gcp-cloud-run/sdk", + "@hyperframes/gcp-cloud-run/terraform", ], noExternal: [ "@hyperframes/core", @@ -83,8 +84,6 @@ var __dirname = __hf_dirname(__filename);`, // Exact subpaths are generated from the same contracts as package // exports, avoiding esbuild's root-alias prefix substitution trap. ...sourceAliases(resolve(__dirname, "../producer"), [".", "./distributed"]), - ...sourceAliases(resolve(__dirname, "../aws-lambda"), ["./sdk"]), - ...sourceAliases(resolve(__dirname, "../gcp-cloud-run"), ["./sdk"]), ...sourceAliases(resolve(__dirname, "../engine"), [".", "./shader-transitions"]), }; options.loader = { ...options.loader, ".browser.js": "text" }; diff --git a/packages/gcp-cloud-run/build.mjs b/packages/gcp-cloud-run/build.mjs index d9a8b0b3a0..16ab6c1fd9 100644 --- a/packages/gcp-cloud-run/build.mjs +++ b/packages/gcp-cloud-run/build.mjs @@ -11,6 +11,7 @@ * ./server (the Cloud Run runtime entry — what the Dockerfile runs) * ./sdk (client-side helpers — GCS + Workflows clients only, no * chromium/puppeteer) + * ./terraform (stable resolver for the packaged Terraform module) * * All production deps are kept external so consumers (and the container * image) resolve them via their own node_modules. @@ -46,6 +47,7 @@ await Promise.all([ build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }), build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/server.js" }), build({ ...sharedOpts, entryPoints: ["src/sdk/index.ts"], outfile: "dist/sdk/index.js" }), + build({ ...sharedOpts, entryPoints: ["src/terraform.ts"], outfile: "dist/terraform.js" }), ]); // esbuild doesn't emit .d.ts. tsc does, with a build-only tsconfig that @@ -55,4 +57,4 @@ await Promise.all([ // violate rootDir). execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" }); -console.log("[Build] Complete: dist/{index,server,sdk/index}.js + .d.ts"); +console.log("[Build] Complete: dist/{index,server,sdk/index,terraform}.js + .d.ts"); diff --git a/packages/gcp-cloud-run/package-subpaths.json b/packages/gcp-cloud-run/package-subpaths.json index 22cafe9004..a4caefe633 100644 --- a/packages/gcp-cloud-run/package-subpaths.json +++ b/packages/gcp-cloud-run/package-subpaths.json @@ -18,6 +18,12 @@ "runtime": "./dist/sdk/index.js", "types": "./dist/sdk/index.d.ts", "environments": ["bun", "node"] + }, + "./terraform": { + "source": "./src/terraform.ts", + "runtime": "./dist/terraform.js", + "types": "./dist/terraform.d.ts", + "environments": ["bun", "node"] } } } diff --git a/packages/gcp-cloud-run/package.json b/packages/gcp-cloud-run/package.json index 80b11cc367..4afefbf499 100644 --- a/packages/gcp-cloud-run/package.json +++ b/packages/gcp-cloud-run/package.json @@ -31,6 +31,11 @@ "bun": "./src/sdk/index.ts", "import": "./src/sdk/index.ts", "types": "./src/sdk/index.ts" + }, + "./terraform": { + "bun": "./src/terraform.ts", + "import": "./src/terraform.ts", + "types": "./src/terraform.ts" } }, "publishConfig": { @@ -47,6 +52,10 @@ "./sdk": { "import": "./dist/sdk/index.js", "types": "./dist/sdk/index.d.ts" + }, + "./terraform": { + "import": "./dist/terraform.js", + "types": "./dist/terraform.d.ts" } }, "registry": "https://registry.npmjs.org/" diff --git a/packages/gcp-cloud-run/src/index.ts b/packages/gcp-cloud-run/src/index.ts index 3ecf8df6d1..cb34289e7a 100644 --- a/packages/gcp-cloud-run/src/index.ts +++ b/packages/gcp-cloud-run/src/index.ts @@ -62,3 +62,4 @@ export { type RenderCost, } from "./sdk/costAccounting.js"; export { InvalidConfigError, validateDistributedRenderConfig } from "./sdk/validateConfig.js"; +export { getTerraformModuleDir } from "./terraform.js"; diff --git a/packages/gcp-cloud-run/src/terraform.test.ts b/packages/gcp-cloud-run/src/terraform.test.ts new file mode 100644 index 0000000000..c8f8336710 --- /dev/null +++ b/packages/gcp-cloud-run/src/terraform.test.ts @@ -0,0 +1,12 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { getTerraformModuleDir } from "./terraform.js"; + +describe("getTerraformModuleDir", () => { + it("resolves the adapter-owned Terraform assets through public code", () => { + const directory = getTerraformModuleDir(); + expect(existsSync(join(directory, "main.tf"))).toBe(true); + expect(existsSync(join(directory, "workflow.yaml"))).toBe(true); + }); +}); diff --git a/packages/gcp-cloud-run/src/terraform.ts b/packages/gcp-cloud-run/src/terraform.ts new file mode 100644 index 0000000000..d023c051df --- /dev/null +++ b/packages/gcp-cloud-run/src/terraform.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +/** + * Absolute path to the Terraform module shipped with this adapter. + * + * This module is built as `dist/terraform.js`, preserving the same one-level + * relationship to the package-owned `terraform/` directory as the source + * file. Consumers should use this API instead of resolving package.json, + * which is not a stable public subpath. + */ +export function getTerraformModuleDir(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "..", "terraform"); +} diff --git a/scripts/verify-packed-manifests.mjs b/scripts/verify-packed-manifests.mjs index 1e52f3ffea..b127466701 100644 --- a/scripts/verify-packed-manifests.mjs +++ b/scripts/verify-packed-manifests.mjs @@ -407,13 +407,17 @@ function writeConsumerFixture(packDir, packedWorkspaces) { .map(({ specifier }) => specifier); writeFileSync( join(fixtureDir, "consumer-smoke.mjs"), - `const specifiers = ${JSON.stringify(specifiers, null, 2)};\n` + + `import { existsSync } from "node:fs";\n` + + `import { join } from "node:path";\n` + + `const specifiers = ${JSON.stringify(specifiers, null, 2)};\n` + `const nodeSpecifiers = ${JSON.stringify(nodeSpecifiers, null, 2)};\n` + `for (const specifier of specifiers) import.meta.resolve(specifier);\n` + `for (const specifier of nodeSpecifiers) {\n` + ` const options = specifier.endsWith(".json") ? { with: { type: "json" } } : undefined;\n` + ` await import(specifier, options);\n` + `}\n` + + `const terraform = await import("@hyperframes/gcp-cloud-run/terraform");\n` + + `if (!existsSync(join(terraform.getTerraformModuleDir(), "main.tf"))) throw new Error("packed Terraform module missing");\n` + `console.log(\`Resolved \${specifiers.length} packed exports and executed \${nodeSpecifiers.length} Node exports.\`);\n` + `process.exit(0);\n`, );