gcp-job-runner 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,7 @@ Same code, same arguments, same secrets. The cloud command automatically builds
44
44
  - **Cloud Run deployment** — no Terraform or manual GCP config needed, just `job cloud run`
45
45
  - **Smart caching** — a single Docker image contains all jobs; running different jobs or different arguments doesn't rebuild
46
46
  - **GCP Secret Manager** — secrets are loaded transparently for both local and cloud execution
47
+ - **Unified artifact output** — `getExportsWriter()` writes JSON/CSV/binary to a local directory during development and to a Cloud Storage bucket in production, with the same handler code
47
48
  - **Multi-environment** — configure staging, production, etc. and switch with a single argument
48
49
 
49
50
  ## Install
package/dist/cli.mjs CHANGED
@@ -203,6 +203,12 @@ async function handleLocalRun(options) {
203
203
  if (envConfig.env) for (const [key, value] of Object.entries(envConfig.env)) process.env[key] = value;
204
204
  /** Project always takes highest precedence */
205
205
  process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
206
+ /**
207
+ * Resolve local exportsPath values against the service directory so jobs
208
+ * can use consistent relative config (e.g. "./exports") from any cwd.
209
+ * `gs://` URIs pass through unchanged.
210
+ */
211
+ if (envConfig.exportsPath) process.env.JOB_EXPORTS_PATH = envConfig.exportsPath.startsWith("gs://") ? envConfig.exportsPath : path.resolve(process.cwd(), envConfig.exportsPath);
206
212
  if (envConfig.secrets && envConfig.secrets.length > 0) {
207
213
  const secrets = await getSecrets(envConfig.secrets);
208
214
  for (const [key, value] of Object.entries(secrets)) process.env[key] = value;
@@ -252,6 +258,7 @@ async function handleCloudDeploy(options) {
252
258
  consola.error("No cloud configuration found in runner config.\nAdd a `cloud` section to your job-runner.config.ts");
253
259
  process.exit(1);
254
260
  }
261
+ assertCloudExportsPath(envConfig);
255
262
  const { imageUri } = await prepareImage({
256
263
  cloud,
257
264
  envConfig,
@@ -260,6 +267,16 @@ async function handleCloudDeploy(options) {
260
267
  consola.info(`Image: ${imageUri}`);
261
268
  consola.success("Deploy complete");
262
269
  }
270
+ /**
271
+ * Reject local exportsPath values for cloud commands — they would silently
272
+ * write to a container filesystem that is discarded on task exit.
273
+ */
274
+ function assertCloudExportsPath(envConfig) {
275
+ if (envConfig.exportsPath && !envConfig.exportsPath.startsWith("gs://")) {
276
+ consola.error(`exportsPath "${envConfig.exportsPath}" is a local path but this is a cloud command.\nUse a gs:// URI for cloud environments (e.g. gs://my-bucket/exports).`);
277
+ process.exit(1);
278
+ }
279
+ }
263
280
  async function handleCloudRun(options) {
264
281
  const { config, envConfig, jobsDirectory, jobNameFromArgs, jobFlags, isInteractive, isAsync, tasks, parallelism } = options;
265
282
  const cloud = config.cloud;
@@ -267,6 +284,7 @@ async function handleCloudRun(options) {
267
284
  consola.error("No cloud configuration found in runner config.\nAdd a `cloud` section to your job-runner.config.ts");
268
285
  process.exit(1);
269
286
  }
287
+ assertCloudExportsPath(envConfig);
270
288
  /** Override parallelism from CLI flag */
271
289
  if (parallelism !== void 0) cloud.resources = {
272
290
  ...cloud.resources,
package/dist/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { execSync } from \"node:child_process\";\nimport path from \"node:path\";\nimport process from \"node:process\";\nimport { consola } from \"consola\";\nimport type { ZodObject, ZodRawShape } from \"zod\";\nimport type { RunnerConfig } from \"./config\";\nimport {\n createOrUpdateJob,\n deployIfChanged,\n prepareImage,\n} from \"./cloud/deploy\";\nimport { execute } from \"./cloud/execute\";\nimport { deriveJobResourceName } from \"./cloud/job-name\";\nimport { discoverJobs } from \"./discover-jobs\";\nimport { promptForArgs, selectJob } from \"./interactive\";\nimport { runJob } from \"./run-job\";\nimport { getSecrets } from \"./secrets\";\nimport { parseEnvFiles } from \"./env-file\";\nimport type { JobFunction } from \"./types\";\n\nconst BIN_NAME = \"job\";\nconst CONFIG_FILE = \"job-runner.config.ts\";\nconst DEFAULT_BUILD_COMMAND = \"turbo build\";\nconst DEFAULT_JOBS_DIRECTORY = \"dist/jobs\";\n\nconst USAGE = `Usage: ${BIN_NAME} local run <env> <job-name> [options]\n ${BIN_NAME} cloud run <env> <job-name> [options]\n ${BIN_NAME} cloud deploy <env>\n ${BIN_NAME} --list\n\nCloud run options:\n --tasks <n> Number of parallel tasks for this execution\n --parallelism <n> Max concurrent tasks (sets job resource default)`;\n\nfunction validateInteger(value: number, flagName: string): number {\n if (Number.isNaN(value) || !Number.isInteger(value) || value < 0) {\n consola.error(\n `Invalid value for ${flagName}: expected a non-negative integer`,\n );\n process.exit(1);\n }\n return value;\n}\n\n/**\n * Extract a numeric flag value from args.\n * Supports both `--flag N` and `--flag=N` syntax.\n * Returns undefined if the flag is not present.\n */\nfunction extractNumberFlag(\n args: string[],\n flagName: string,\n): number | undefined {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n\n if (arg === flagName) {\n const next = args[i + 1];\n if (next === undefined || next.startsWith(\"-\")) {\n consola.error(`${flagName} requires a value`);\n process.exit(1);\n }\n return validateInteger(Number(next), flagName);\n }\n\n if (arg.startsWith(`${flagName}=`)) {\n return validateInteger(Number(arg.slice(flagName.length + 1)), flagName);\n }\n }\n\n return undefined;\n}\n\nasync function main(): Promise<void> {\n const args = process.argv.slice(2);\n\n /** Extract flags from anywhere in args */\n const noBuild = args.includes(\"--no-build\");\n const isInteractive = args.includes(\"--interactive\") || args.includes(\"-i\");\n const isAsync = args.includes(\"--async\");\n\n /** Determine mode early so cloud-only flags are only parsed in cloud mode */\n const mode = args.find((arg) => !arg.startsWith(\"-\"));\n const isCloudMode = mode === \"cloud\";\n\n const tasks = isCloudMode ? extractNumberFlag(args, \"--tasks\") : undefined;\n if (tasks === 0) {\n consola.error(\"--tasks must be at least 1\");\n process.exit(1);\n }\n const parallelism = isCloudMode\n ? extractNumberFlag(args, \"--parallelism\")\n : undefined;\n\n const configPath = path.resolve(process.cwd(), CONFIG_FILE);\n\n /** Load config directly (config should only depend on gcp-job-runner) */\n let config: RunnerConfig;\n try {\n const module = (await import(configPath)) as { default: RunnerConfig };\n config = module.default;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n consola.error(\n `Failed to load runner config from ${configPath}\\n${message}`,\n );\n process.exit(1);\n }\n\n /** Resolve jobs directory with default */\n const jobsDirectory =\n config.jobsDirectory ?? path.resolve(process.cwd(), DEFAULT_JOBS_DIRECTORY);\n\n const envNames = Object.keys(config.environments);\n\n /** Handle --list: discover and print all available jobs */\n if (args.includes(\"--list\")) {\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n const jobs = await discoverJobs(jobsDirectory);\n if (jobs.length === 0) {\n consola.info(\"No jobs found.\");\n } else {\n consola.info(\"Available jobs:\");\n for (const job of jobs) {\n consola.log(` ${job.name}`);\n }\n }\n return;\n }\n\n /**\n * Parse positional arguments. In cloud mode, skip values consumed by\n * numeric flags like `--tasks 5` so that `5` is not treated as a positional.\n */\n const consumedIndices = new Set<number>();\n if (isCloudMode) {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n if (\n (arg === \"--tasks\" || arg === \"--parallelism\") &&\n i + 1 < args.length\n ) {\n consumedIndices.add(i + 1);\n }\n }\n }\n\n const positionals = args.filter(\n (arg, index) => !arg.startsWith(\"-\") && !consumedIndices.has(index),\n );\n\n if (mode !== \"local\" && mode !== \"cloud\") {\n consola.error(\n `Unknown or missing mode \"${mode ?? \"\"}\".\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const action = positionals[1];\n if (mode === \"cloud\" && action !== \"run\" && action !== \"deploy\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for cloud mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n if (mode === \"local\" && action !== \"run\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for local mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n\n const envName = positionals[2];\n if (!envName) {\n consola.error(\n `No environment specified.\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n if (!envNames.includes(envName)) {\n consola.error(\n `Unknown environment \"${envName}\".\\n\\n` +\n `Available environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const envConfig = config.environments[envName]!;\n\n /**\n * Remaining positionals after env become the job name/path. For example\n * `job cloud run stag test/countdown` -> positionals[3] = \"test/countdown\".\n */\n const jobNameFromArgs = positionals[3];\n\n /**\n * Collect job flags: everything after `<env>` in the original args that\n * isn't a consumed positional or a known global flag.\n */\n const consumedPositionals = new Set(\n [mode, action, envName, jobNameFromArgs].filter(Boolean),\n );\n const globalFlags = new Set([\n \"--no-build\",\n \"--interactive\",\n \"-i\",\n \"--async\",\n \"--list\",\n ]);\n\n /** --tasks and --parallelism are cloud-only; don't strip them in local mode */\n if (mode === \"cloud\") {\n globalFlags.add(\"--tasks\");\n globalFlags.add(\"--parallelism\");\n }\n\n const envIndex = args.indexOf(envName);\n const jobFlags = args.slice(envIndex + 1).filter((arg, index, arr) => {\n if (consumedPositionals.has(arg)) return false;\n if (globalFlags.has(arg)) return false;\n if (mode === \"cloud\") {\n /** Filter out `--flag=value` forms of number flags */\n if (arg.startsWith(\"--tasks=\") || arg.startsWith(\"--parallelism=\"))\n return false;\n /** Filter out values that follow --tasks or --parallelism */\n const previous = arr[index - 1];\n if (previous === \"--tasks\" || previous === \"--parallelism\") return false;\n }\n return true;\n });\n\n /** Build unless skipped */\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n if (mode === \"local\") {\n await handleLocalRun({\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n });\n } else if (action === \"deploy\") {\n await handleCloudDeploy({ config, envConfig });\n } else {\n await handleCloudRun({\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n });\n }\n}\n\ninterface LocalRunOptions {\n config: RunnerConfig;\n envName: string;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n}\n\nasync function handleLocalRun(options: LocalRunOptions): Promise<void> {\n const {\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n } = options;\n\n /** Set environment variables for local execution */\n process.env.NODE_ENV ??= \"development\";\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n /** Load envFile variables (don't overwrite existing process.env values) */\n const envFileVars = parseEnvFiles(envConfig.envFile);\n for (const [key, value] of Object.entries(envFileVars)) {\n process.env[key] ??= value;\n }\n\n /** Explicit env values override envFile values */\n if (envConfig.env) {\n for (const [key, value] of Object.entries(envConfig.env)) {\n process.env[key] = value;\n }\n }\n\n /** Project always takes highest precedence */\n process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;\n\n if (envConfig.secrets && envConfig.secrets.length > 0) {\n const secrets = await getSecrets(envConfig.secrets);\n for (const [key, value] of Object.entries(secrets)) {\n process.env[key] = value;\n }\n }\n\n if (isInteractive) {\n const { jobArgv } = await resolveInteractiveJob(jobsDirectory);\n\n process.argv = [process.argv[0]!, process.argv[1]!, ...jobArgv];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n return;\n }\n\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} local run ${envName} <job-name> [options]\\n` +\n ` ${BIN_NAME} local run ${envName} -i`,\n );\n process.exit(1);\n }\n\n /**\n * Rewrite process.argv so runJob sees the job name as the first positional\n * argument, followed by any job-specific flags.\n */\n process.argv = [\n process.argv[0]!,\n process.argv[1]!,\n jobNameFromArgs,\n ...jobFlags,\n ];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n}\n\ninterface CloudDeployOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n}\n\nasync function handleCloudDeploy(options: CloudDeployOptions): Promise<void> {\n const { config, envConfig } = options;\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n const serviceDirectory = process.cwd();\n\n const { imageUri } = await prepareImage({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n consola.success(\"Deploy complete\");\n}\n\ninterface CloudRunOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n isAsync: boolean;\n tasks?: number;\n parallelism?: number;\n}\n\nasync function handleCloudRun(options: CloudRunOptions): Promise<void> {\n const {\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n } = options;\n\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n /** Override parallelism from CLI flag */\n if (parallelism !== undefined) {\n cloud.resources = { ...cloud.resources, parallelism };\n }\n\n const serviceDirectory = process.cwd();\n\n /** Determine job name and argv */\n let jobArgv: string[];\n\n if (isInteractive) {\n const result = await resolveInteractiveJob(jobsDirectory);\n jobArgv = result.jobArgv;\n } else {\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} cloud run <env> <job-name> [options]\\n` +\n ` ${BIN_NAME} cloud run <env> -i`,\n );\n process.exit(1);\n }\n jobArgv = [jobNameFromArgs, ...jobFlags];\n }\n\n /** Build and push image if changed */\n const { imageUri } = await deployIfChanged({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n\n /** Derive per-script Cloud Run Job name */\n const jobScript = jobArgv[0]!;\n const jobResourceName = deriveJobResourceName(jobScript);\n const region = cloud.region ?? \"us-central1\";\n\n /** Ensure per-script Cloud Run Job exists with current image */\n await createOrUpdateJob({\n cloud,\n envConfig,\n jobName: jobResourceName,\n imageUri,\n region,\n project: envConfig.project,\n });\n\n /** Execute the per-script Cloud Run Job */\n await execute({\n jobResourceName,\n region,\n project: envConfig.project,\n jobArgv,\n async: isAsync,\n tasks,\n });\n}\n\n/**\n * Interactively select a job and prompt for arguments.\n * Returns the job name and the complete jobArgv array.\n */\nasync function resolveInteractiveJob(\n jobsDirectory: string,\n): Promise<{ jobName: string; jobArgv: string[] }> {\n const jobName = await selectJob(jobsDirectory);\n\n consola.info(`Selected job: ${jobName}`);\n\n /**\n * Set console-friendly env vars before importing the module, since\n * importing may initialize a structured logger like pino.\n */\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n const parts = jobName.split(\"/\");\n const fileName = parts.pop() ?? \"\";\n const subDirectories = parts;\n const fileLocation = path.join(jobsDirectory, ...subDirectories);\n const modulePath = path.resolve(fileLocation, `${fileName}.mjs`);\n\n let schema: ZodObject<ZodRawShape> | undefined;\n try {\n const moduleObject = (await import(modulePath)) as Record<string, unknown>;\n const fn = moduleObject.default as JobFunction | undefined;\n schema = fn?.__metadata?.schema;\n } catch {\n /** Module might not exist yet or have errors - proceed without schema */\n }\n\n /** Prompt for arguments if schema exists */\n let args: Record<string, unknown> = {};\n if (schema && Object.keys(schema.shape).length > 0) {\n consola.info(\"Enter arguments for the job:\");\n args = await promptForArgs(schema);\n }\n\n const jobArgv = [jobName];\n if (Object.keys(args).length > 0) {\n jobArgv.push(\"--args\", JSON.stringify(args));\n }\n\n return { jobName, jobArgv };\n}\n\n/**\n * Run the build command to compile workspace dependencies.\n * Shows a spinner and hides output unless the build fails.\n */\nfunction runBuild(command: string): void {\n consola.start(\"Building jobs source code...\");\n\n try {\n execSync(command, {\n stdio: \"pipe\",\n encoding: \"utf-8\",\n });\n consola.success(\"Build complete\");\n } catch (error) {\n consola.fail(\"Build failed\");\n\n /** Show the build output on failure */\n if (error && typeof error === \"object\" && \"stdout\" in error) {\n const stdout = (error as { stdout?: string }).stdout;\n if (stdout) {\n consola.log(stdout);\n }\n }\n if (error && typeof error === \"object\" && \"stderr\" in error) {\n const stderr = (error as { stderr?: string }).stderr;\n if (stderr) {\n consola.log(stderr);\n }\n }\n\n process.exit(1);\n }\n}\n\nawait main();\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { execSync } from \"node:child_process\";\nimport path from \"node:path\";\nimport process from \"node:process\";\nimport { consola } from \"consola\";\nimport type { ZodObject, ZodRawShape } from \"zod\";\nimport type { RunnerConfig } from \"./config\";\nimport {\n createOrUpdateJob,\n deployIfChanged,\n prepareImage,\n} from \"./cloud/deploy\";\nimport { execute } from \"./cloud/execute\";\nimport { deriveJobResourceName } from \"./cloud/job-name\";\nimport { discoverJobs } from \"./discover-jobs\";\nimport { promptForArgs, selectJob } from \"./interactive\";\nimport { runJob } from \"./run-job\";\nimport { getSecrets } from \"./secrets\";\nimport { parseEnvFiles } from \"./env-file\";\nimport type { JobFunction } from \"./types\";\n\nconst BIN_NAME = \"job\";\nconst CONFIG_FILE = \"job-runner.config.ts\";\nconst DEFAULT_BUILD_COMMAND = \"turbo build\";\nconst DEFAULT_JOBS_DIRECTORY = \"dist/jobs\";\n\nconst USAGE = `Usage: ${BIN_NAME} local run <env> <job-name> [options]\n ${BIN_NAME} cloud run <env> <job-name> [options]\n ${BIN_NAME} cloud deploy <env>\n ${BIN_NAME} --list\n\nCloud run options:\n --tasks <n> Number of parallel tasks for this execution\n --parallelism <n> Max concurrent tasks (sets job resource default)`;\n\nfunction validateInteger(value: number, flagName: string): number {\n if (Number.isNaN(value) || !Number.isInteger(value) || value < 0) {\n consola.error(\n `Invalid value for ${flagName}: expected a non-negative integer`,\n );\n process.exit(1);\n }\n return value;\n}\n\n/**\n * Extract a numeric flag value from args.\n * Supports both `--flag N` and `--flag=N` syntax.\n * Returns undefined if the flag is not present.\n */\nfunction extractNumberFlag(\n args: string[],\n flagName: string,\n): number | undefined {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n\n if (arg === flagName) {\n const next = args[i + 1];\n if (next === undefined || next.startsWith(\"-\")) {\n consola.error(`${flagName} requires a value`);\n process.exit(1);\n }\n return validateInteger(Number(next), flagName);\n }\n\n if (arg.startsWith(`${flagName}=`)) {\n return validateInteger(Number(arg.slice(flagName.length + 1)), flagName);\n }\n }\n\n return undefined;\n}\n\nasync function main(): Promise<void> {\n const args = process.argv.slice(2);\n\n /** Extract flags from anywhere in args */\n const noBuild = args.includes(\"--no-build\");\n const isInteractive = args.includes(\"--interactive\") || args.includes(\"-i\");\n const isAsync = args.includes(\"--async\");\n\n /** Determine mode early so cloud-only flags are only parsed in cloud mode */\n const mode = args.find((arg) => !arg.startsWith(\"-\"));\n const isCloudMode = mode === \"cloud\";\n\n const tasks = isCloudMode ? extractNumberFlag(args, \"--tasks\") : undefined;\n if (tasks === 0) {\n consola.error(\"--tasks must be at least 1\");\n process.exit(1);\n }\n const parallelism = isCloudMode\n ? extractNumberFlag(args, \"--parallelism\")\n : undefined;\n\n const configPath = path.resolve(process.cwd(), CONFIG_FILE);\n\n /** Load config directly (config should only depend on gcp-job-runner) */\n let config: RunnerConfig;\n try {\n const module = (await import(configPath)) as { default: RunnerConfig };\n config = module.default;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n consola.error(\n `Failed to load runner config from ${configPath}\\n${message}`,\n );\n process.exit(1);\n }\n\n /** Resolve jobs directory with default */\n const jobsDirectory =\n config.jobsDirectory ?? path.resolve(process.cwd(), DEFAULT_JOBS_DIRECTORY);\n\n const envNames = Object.keys(config.environments);\n\n /** Handle --list: discover and print all available jobs */\n if (args.includes(\"--list\")) {\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n const jobs = await discoverJobs(jobsDirectory);\n if (jobs.length === 0) {\n consola.info(\"No jobs found.\");\n } else {\n consola.info(\"Available jobs:\");\n for (const job of jobs) {\n consola.log(` ${job.name}`);\n }\n }\n return;\n }\n\n /**\n * Parse positional arguments. In cloud mode, skip values consumed by\n * numeric flags like `--tasks 5` so that `5` is not treated as a positional.\n */\n const consumedIndices = new Set<number>();\n if (isCloudMode) {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n if (\n (arg === \"--tasks\" || arg === \"--parallelism\") &&\n i + 1 < args.length\n ) {\n consumedIndices.add(i + 1);\n }\n }\n }\n\n const positionals = args.filter(\n (arg, index) => !arg.startsWith(\"-\") && !consumedIndices.has(index),\n );\n\n if (mode !== \"local\" && mode !== \"cloud\") {\n consola.error(\n `Unknown or missing mode \"${mode ?? \"\"}\".\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const action = positionals[1];\n if (mode === \"cloud\" && action !== \"run\" && action !== \"deploy\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for cloud mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n if (mode === \"local\" && action !== \"run\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for local mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n\n const envName = positionals[2];\n if (!envName) {\n consola.error(\n `No environment specified.\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n if (!envNames.includes(envName)) {\n consola.error(\n `Unknown environment \"${envName}\".\\n\\n` +\n `Available environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const envConfig = config.environments[envName]!;\n\n /**\n * Remaining positionals after env become the job name/path. For example\n * `job cloud run stag test/countdown` -> positionals[3] = \"test/countdown\".\n */\n const jobNameFromArgs = positionals[3];\n\n /**\n * Collect job flags: everything after `<env>` in the original args that\n * isn't a consumed positional or a known global flag.\n */\n const consumedPositionals = new Set(\n [mode, action, envName, jobNameFromArgs].filter(Boolean),\n );\n const globalFlags = new Set([\n \"--no-build\",\n \"--interactive\",\n \"-i\",\n \"--async\",\n \"--list\",\n ]);\n\n /** --tasks and --parallelism are cloud-only; don't strip them in local mode */\n if (mode === \"cloud\") {\n globalFlags.add(\"--tasks\");\n globalFlags.add(\"--parallelism\");\n }\n\n const envIndex = args.indexOf(envName);\n const jobFlags = args.slice(envIndex + 1).filter((arg, index, arr) => {\n if (consumedPositionals.has(arg)) return false;\n if (globalFlags.has(arg)) return false;\n if (mode === \"cloud\") {\n /** Filter out `--flag=value` forms of number flags */\n if (arg.startsWith(\"--tasks=\") || arg.startsWith(\"--parallelism=\"))\n return false;\n /** Filter out values that follow --tasks or --parallelism */\n const previous = arr[index - 1];\n if (previous === \"--tasks\" || previous === \"--parallelism\") return false;\n }\n return true;\n });\n\n /** Build unless skipped */\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n if (mode === \"local\") {\n await handleLocalRun({\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n });\n } else if (action === \"deploy\") {\n await handleCloudDeploy({ config, envConfig });\n } else {\n await handleCloudRun({\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n });\n }\n}\n\ninterface LocalRunOptions {\n config: RunnerConfig;\n envName: string;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n}\n\nasync function handleLocalRun(options: LocalRunOptions): Promise<void> {\n const {\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n } = options;\n\n /** Set environment variables for local execution */\n process.env.NODE_ENV ??= \"development\";\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n /** Load envFile variables (don't overwrite existing process.env values) */\n const envFileVars = parseEnvFiles(envConfig.envFile);\n for (const [key, value] of Object.entries(envFileVars)) {\n process.env[key] ??= value;\n }\n\n /** Explicit env values override envFile values */\n if (envConfig.env) {\n for (const [key, value] of Object.entries(envConfig.env)) {\n process.env[key] = value;\n }\n }\n\n /** Project always takes highest precedence */\n process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;\n\n /**\n * Resolve local exportsPath values against the service directory so jobs\n * can use consistent relative config (e.g. \"./exports\") from any cwd.\n * `gs://` URIs pass through unchanged.\n */\n if (envConfig.exportsPath) {\n process.env.JOB_EXPORTS_PATH = envConfig.exportsPath.startsWith(\"gs://\")\n ? envConfig.exportsPath\n : path.resolve(process.cwd(), envConfig.exportsPath);\n }\n\n if (envConfig.secrets && envConfig.secrets.length > 0) {\n const secrets = await getSecrets(envConfig.secrets);\n for (const [key, value] of Object.entries(secrets)) {\n process.env[key] = value;\n }\n }\n\n if (isInteractive) {\n const { jobArgv } = await resolveInteractiveJob(jobsDirectory);\n\n process.argv = [process.argv[0]!, process.argv[1]!, ...jobArgv];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n return;\n }\n\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} local run ${envName} <job-name> [options]\\n` +\n ` ${BIN_NAME} local run ${envName} -i`,\n );\n process.exit(1);\n }\n\n /**\n * Rewrite process.argv so runJob sees the job name as the first positional\n * argument, followed by any job-specific flags.\n */\n process.argv = [\n process.argv[0]!,\n process.argv[1]!,\n jobNameFromArgs,\n ...jobFlags,\n ];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n}\n\ninterface CloudDeployOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n}\n\nasync function handleCloudDeploy(options: CloudDeployOptions): Promise<void> {\n const { config, envConfig } = options;\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n assertCloudExportsPath(envConfig);\n\n const serviceDirectory = process.cwd();\n\n const { imageUri } = await prepareImage({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n consola.success(\"Deploy complete\");\n}\n\n/**\n * Reject local exportsPath values for cloud commands — they would silently\n * write to a container filesystem that is discarded on task exit.\n */\nfunction assertCloudExportsPath(\n envConfig: RunnerConfig[\"environments\"][string],\n): void {\n if (envConfig.exportsPath && !envConfig.exportsPath.startsWith(\"gs://\")) {\n consola.error(\n `exportsPath \"${envConfig.exportsPath}\" is a local path but this is a cloud command.\\n` +\n \"Use a gs:// URI for cloud environments (e.g. gs://my-bucket/exports).\",\n );\n process.exit(1);\n }\n}\n\ninterface CloudRunOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n isAsync: boolean;\n tasks?: number;\n parallelism?: number;\n}\n\nasync function handleCloudRun(options: CloudRunOptions): Promise<void> {\n const {\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n } = options;\n\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n assertCloudExportsPath(envConfig);\n\n /** Override parallelism from CLI flag */\n if (parallelism !== undefined) {\n cloud.resources = { ...cloud.resources, parallelism };\n }\n\n const serviceDirectory = process.cwd();\n\n /** Determine job name and argv */\n let jobArgv: string[];\n\n if (isInteractive) {\n const result = await resolveInteractiveJob(jobsDirectory);\n jobArgv = result.jobArgv;\n } else {\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} cloud run <env> <job-name> [options]\\n` +\n ` ${BIN_NAME} cloud run <env> -i`,\n );\n process.exit(1);\n }\n jobArgv = [jobNameFromArgs, ...jobFlags];\n }\n\n /** Build and push image if changed */\n const { imageUri } = await deployIfChanged({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n\n /** Derive per-script Cloud Run Job name */\n const jobScript = jobArgv[0]!;\n const jobResourceName = deriveJobResourceName(jobScript);\n const region = cloud.region ?? \"us-central1\";\n\n /** Ensure per-script Cloud Run Job exists with current image */\n await createOrUpdateJob({\n cloud,\n envConfig,\n jobName: jobResourceName,\n imageUri,\n region,\n project: envConfig.project,\n });\n\n /** Execute the per-script Cloud Run Job */\n await execute({\n jobResourceName,\n region,\n project: envConfig.project,\n jobArgv,\n async: isAsync,\n tasks,\n });\n}\n\n/**\n * Interactively select a job and prompt for arguments.\n * Returns the job name and the complete jobArgv array.\n */\nasync function resolveInteractiveJob(\n jobsDirectory: string,\n): Promise<{ jobName: string; jobArgv: string[] }> {\n const jobName = await selectJob(jobsDirectory);\n\n consola.info(`Selected job: ${jobName}`);\n\n /**\n * Set console-friendly env vars before importing the module, since\n * importing may initialize a structured logger like pino.\n */\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n const parts = jobName.split(\"/\");\n const fileName = parts.pop() ?? \"\";\n const subDirectories = parts;\n const fileLocation = path.join(jobsDirectory, ...subDirectories);\n const modulePath = path.resolve(fileLocation, `${fileName}.mjs`);\n\n let schema: ZodObject<ZodRawShape> | undefined;\n try {\n const moduleObject = (await import(modulePath)) as Record<string, unknown>;\n const fn = moduleObject.default as JobFunction | undefined;\n schema = fn?.__metadata?.schema;\n } catch {\n /** Module might not exist yet or have errors - proceed without schema */\n }\n\n /** Prompt for arguments if schema exists */\n let args: Record<string, unknown> = {};\n if (schema && Object.keys(schema.shape).length > 0) {\n consola.info(\"Enter arguments for the job:\");\n args = await promptForArgs(schema);\n }\n\n const jobArgv = [jobName];\n if (Object.keys(args).length > 0) {\n jobArgv.push(\"--args\", JSON.stringify(args));\n }\n\n return { jobName, jobArgv };\n}\n\n/**\n * Run the build command to compile workspace dependencies.\n * Shows a spinner and hides output unless the build fails.\n */\nfunction runBuild(command: string): void {\n consola.start(\"Building jobs source code...\");\n\n try {\n execSync(command, {\n stdio: \"pipe\",\n encoding: \"utf-8\",\n });\n consola.success(\"Build complete\");\n } catch (error) {\n consola.fail(\"Build failed\");\n\n /** Show the build output on failure */\n if (error && typeof error === \"object\" && \"stdout\" in error) {\n const stdout = (error as { stdout?: string }).stdout;\n if (stdout) {\n consola.log(stdout);\n }\n }\n if (error && typeof error === \"object\" && \"stderr\" in error) {\n const stderr = (error as { stderr?: string }).stderr;\n if (stderr) {\n consola.log(stderr);\n }\n }\n\n process.exit(1);\n }\n}\n\nawait main();\n"],"mappings":"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"}
@@ -211,6 +211,11 @@ async function createOrUpdateJob(options) {
211
211
  ...envConfig.env,
212
212
  GOOGLE_CLOUD_PROJECT: project
213
213
  };
214
+ /**
215
+ * Forward the exports destination to the container. Local paths are
216
+ * rejected earlier by assertCloudExportsPath() in the CLI handlers.
217
+ */
218
+ if (envConfig.exportsPath) envVars.JOB_EXPORTS_PATH = envConfig.exportsPath;
214
219
  const envVarsString = Object.entries(envVars).map(([key, value]) => `${key}=${value}`).join(",");
215
220
  const secretsString = (envConfig.secrets ?? []).map((name) => `${name}=${name}:latest`).join(",");
216
221
  if (existingJob) {
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.mjs","names":[],"sources":["../../src/cloud/deploy.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync, unlinkSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { consola } from \"consola\";\nimport type { CloudConfig, RunnerEnvOptions } from \"../config\";\nimport { parseEnvFiles } from \"../env-file\";\nimport { generateDockerfile } from \"./dockerfile\";\nimport {\n checkGcloudAvailable,\n isDockerInstalled,\n isDockerDaemonRunning,\n startDockerDaemon,\n waitForDockerDaemon,\n gcloudExecCapture,\n gcloudJson,\n shellExecCapture,\n} from \"./gcloud\";\nimport { hashDirectory } from \"./hash\";\n\nexport interface DeployOptions {\n /** Cloud configuration from the runner config */\n cloud: CloudConfig;\n /** Environment configuration (project, env vars, secrets) */\n envConfig: RunnerEnvOptions;\n /** Working directory (service root where isolate.config.json lives) */\n serviceDirectory: string;\n}\n\nexport interface DeployResult {\n /** Full image URI including tag */\n imageUri: string;\n /** Whether a new image was built */\n imageBuilt: boolean;\n}\n\n/**\n * Filter out noisy gcloud hints from captured output.\n * Removes \"To execute this job\" and \"Updates are available\" blocks.\n */\nfunction filterGcloudOutput(output: string): string {\n return output\n .replace(/To execute this job.*?gcloud run jobs execute \\S+\\n?/gs, \"\")\n .replace(/Updates are available.*?\\$ gcloud components update\\n?/gs, \"\")\n .trim();\n}\n\nconst DEFAULT_REGION = \"us-central1\";\nconst DEFAULT_ARTIFACT_REGISTRY = \"cloud-run\";\nconst DEFAULT_ISOLATE_PATH = \"isolate\";\nconst GENERATED_DOCKERFILE = \"Dockerfile\";\nconst REGISTRY = \"docker.pkg.dev\";\n\ninterface IsolateConfig {\n targetPackagePath?: string;\n}\n\n/**\n * Resolve the isolate output directory path.\n * Reads isolate.config.json if present, otherwise uses default \"./isolate\".\n */\nfunction resolveIsolateDirectory(serviceDirectory: string): string {\n const configPath = path.join(serviceDirectory, \"isolate.config.json\");\n\n if (existsSync(configPath)) {\n try {\n const configContent = readFileSync(configPath, \"utf-8\");\n const config = JSON.parse(configContent) as IsolateConfig;\n if (config.targetPackagePath) {\n return path.join(serviceDirectory, config.targetPackagePath);\n }\n } catch {\n /** Ignore parse errors, use default */\n }\n }\n\n return path.join(serviceDirectory, DEFAULT_ISOLATE_PATH);\n}\n\nexport interface PrepareResult {\n imageUri: string;\n imageBuilt: boolean;\n region: string;\n project: string;\n}\n\n/**\n * Shared preparation logic: isolate, hash, check image, build if needed.\n * Used by both deploy() and deployIfChanged().\n */\nexport async function prepareImage(\n options: DeployOptions,\n): Promise<PrepareResult> {\n const { cloud, envConfig, serviceDirectory } = options;\n const region = cloud.region ?? DEFAULT_REGION;\n const artifactRegistry = cloud.artifactRegistry ?? DEFAULT_ARTIFACT_REGISTRY;\n const project = envConfig.project;\n let buildLocal = cloud.buildLocal !== false;\n\n checkGcloudAvailable();\n\n if (buildLocal) {\n if (!isDockerInstalled()) {\n consola.warn(\n \"Docker is not installed, falling back to Cloud Build. \" +\n \"Install Docker for faster local builds: https://docs.docker.com/get-docker/\",\n );\n buildLocal = false;\n } else if (!isDockerDaemonRunning()) {\n if (!process.stdin.isTTY) {\n /** Non-interactive environment (CI) — fall back automatically */\n consola.warn(\n \"Docker daemon is not running, falling back to Cloud Build.\",\n );\n buildLocal = false;\n } else {\n const choice = await consola.prompt(\n \"Docker is installed but the daemon is not running.\",\n {\n type: \"select\",\n options: [\n {\n label: \"Start Docker\",\n value: \"start\",\n hint: \"attempt to start the daemon\",\n },\n {\n label: \"Use Cloud Build\",\n value: \"cloud-build\",\n hint: \"build remotely instead\",\n },\n ],\n },\n );\n\n if (typeof choice === \"symbol\") {\n process.exit(0);\n }\n\n if (choice === \"start\") {\n const started = startDockerDaemon();\n\n if (!started) {\n consola.warn(\n \"Could not start Docker automatically, falling back to Cloud Build.\",\n );\n buildLocal = false;\n } else {\n const ready = await waitForDockerDaemon();\n\n if (!ready) {\n consola.warn(\n \"Docker daemon did not become ready in time, falling back to Cloud Build.\",\n );\n buildLocal = false;\n }\n }\n } else {\n buildLocal = false;\n }\n }\n }\n }\n\n /** Step 1: Run isolate to bundle workspace dependencies */\n const isolateDirectory = resolveIsolateDirectory(serviceDirectory);\n\n consola.start(\"Isolating package...\");\n\n try {\n const { isolate: runIsolate } = await import(\"isolate-package\");\n\n const configPath = path.join(serviceDirectory, \"isolate.config.json\");\n const fileConfig = existsSync(configPath)\n ? JSON.parse(readFileSync(configPath, \"utf-8\"))\n : {};\n\n await runIsolate({\n ...fileConfig,\n includeDevDependencies: true,\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n consola.error(`Failed to isolate package: ${message}`);\n process.exit(1);\n }\n\n consola.success(\"Package isolated\");\n\n /** Step 2: Hash the isolate directory */\n const tag = await hashDirectory(isolateDirectory);\n consola.info(`Content hash: ${tag}`);\n\n /** Step 3: Check if image already exists */\n const imageUri = `${region}-${REGISTRY}/${project}/${artifactRegistry}/${cloud.name}:${tag}`;\n\n const imageExists = checkImageExists(imageUri, project);\n let imageBuilt = false;\n\n if (imageExists) {\n consola.success(`Image already exists: ${cloud.name}:${tag}`);\n } else if (buildLocal) {\n /** Step 4a: Generate Dockerfile and build locally with Docker */\n consola.start(\"Building image locally with Docker...\");\n\n const dockerfilePath = path.join(serviceDirectory, GENERATED_DOCKERFILE);\n writeFileSync(dockerfilePath, generateDockerfile());\n\n try {\n const buildResult = shellExecCapture(\n `docker build --platform linux/amd64 -t ${imageUri} .`,\n { cwd: serviceDirectory },\n );\n\n if (!buildResult.success) {\n consola.error(\"Docker build failed. Output:\\n\" + buildResult.output);\n process.exit(1);\n }\n\n consola.success(`Image built: ${cloud.name}:${tag}`);\n\n /** Configure Docker authentication for Artifact Registry */\n gcloudExecCapture([\n \"auth\",\n \"configure-docker\",\n `${region}-${REGISTRY}`,\n \"--quiet\",\n ]);\n\n consola.start(\"Pushing image to Artifact Registry...\");\n\n const pushResult = shellExecCapture(`docker push ${imageUri}`);\n\n if (!pushResult.success) {\n consola.error(\"Docker push failed. Output:\\n\" + pushResult.output);\n process.exit(1);\n }\n\n consola.success(`Image pushed: ${cloud.name}:${tag}`);\n imageBuilt = true;\n } finally {\n /** Clean up generated Dockerfile */\n try {\n unlinkSync(dockerfilePath);\n } catch {\n /** Ignore cleanup errors */\n }\n }\n } else {\n /** Step 4b: Generate Dockerfile and build with Cloud Build */\n consola.start(\"Building image with Cloud Build...\");\n\n const dockerfilePath = path.join(serviceDirectory, GENERATED_DOCKERFILE);\n writeFileSync(dockerfilePath, generateDockerfile());\n\n try {\n const buildResult = gcloudExecCapture(\n [\n \"builds\",\n \"submit\",\n \"--project\",\n project,\n \"--region\",\n region,\n `--tag=${imageUri}`,\n \".\",\n ],\n { cwd: serviceDirectory },\n );\n\n /** Extract and show the Cloud Build logs URL if available */\n const logsUrlMatch = buildResult.output.match(\n /Logs are available at \\[(.+?)]/,\n );\n if (logsUrlMatch) {\n consola.info(`Cloud Build logs: ${logsUrlMatch[1]}`);\n }\n\n if (!buildResult.success) {\n consola.error(\"Cloud Build failed. Output:\\n\" + buildResult.output);\n process.exit(1);\n }\n\n consola.success(`Image built: ${cloud.name}:${tag}`);\n imageBuilt = true;\n } finally {\n /** Clean up generated Dockerfile */\n try {\n unlinkSync(dockerfilePath);\n } catch {\n /** Ignore cleanup errors */\n }\n }\n }\n\n return { imageUri, imageBuilt, region, project };\n}\n\n/**\n * Build and push a Cloud Run Job image.\n *\n * This is image-only: it does not create or update Cloud Run Job resources.\n * Use createOrUpdateJob() separately to manage job resources.\n */\nexport async function deploy(options: DeployOptions): Promise<DeployResult> {\n const { imageUri, imageBuilt } = await prepareImage(options);\n return { imageUri, imageBuilt };\n}\n\n/**\n * Build and push a Cloud Run Job image only if it has changed.\n *\n * This is image-only: it does not create or update Cloud Run Job resources.\n * Use createOrUpdateJob() separately to manage job resources.\n */\nexport async function deployIfChanged(\n options: DeployOptions,\n): Promise<DeployResult> {\n const { imageUri, imageBuilt } = await prepareImage(options);\n\n if (!imageBuilt) {\n consola.info(\"No changes detected, skipping image build\");\n }\n\n return { imageUri, imageBuilt };\n}\n\nexport interface CreateOrUpdateJobOptions {\n cloud: CloudConfig;\n envConfig: RunnerEnvOptions;\n /** The Cloud Run Job resource name (e.g., \"admin-create-user\") */\n jobName: string;\n imageUri: string;\n region: string;\n project: string;\n}\n\n/**\n * Create or update a Cloud Run Job resource.\n * Returns true if the job was newly created, false if updated.\n */\nexport async function createOrUpdateJob(\n options: CreateOrUpdateJobOptions,\n): Promise<boolean> {\n const { cloud, envConfig, jobName, imageUri, region, project } = options;\n const memory = cloud.resources?.memory ?? \"512Mi\";\n const cpu = cloud.resources?.cpu ?? \"1\";\n const timeout = cloud.resources?.timeout ?? 86400;\n\n /** Check if job already exists */\n const existingJob = gcloudJson(\n [\n \"run\",\n \"jobs\",\n \"describe\",\n jobName,\n \"--project\",\n project,\n \"--region\",\n region,\n ],\n { ignoreErrors: true },\n );\n\n /** Build environment variables (envFile < env < project) */\n const envFileVars = parseEnvFiles(envConfig.envFile);\n const envVars: Record<string, string> = {\n ...envFileVars,\n ...envConfig.env,\n GOOGLE_CLOUD_PROJECT: project,\n };\n\n const envVarsString = Object.entries(envVars)\n .map(([key, value]) => `${key}=${value}`)\n .join(\",\");\n\n /** Build secret references */\n const secretNames = envConfig.secrets ?? [];\n const secretsString = secretNames\n .map((name) => `${name}=${name}:latest`)\n .join(\",\");\n\n if (existingJob) {\n consola.start(\"Updating Cloud Run Job...\");\n\n const updateArgs = [\n \"run\",\n \"jobs\",\n \"update\",\n jobName,\n \"--project\",\n project,\n \"--region\",\n region,\n `--image=${imageUri}`,\n `--set-env-vars=${envVarsString}`,\n `--memory=${memory}`,\n `--cpu=${cpu}`,\n `--task-timeout=${timeout}s`,\n \"--max-retries=0\",\n ];\n\n /**\n * Always pass --parallelism on update so that removing it from config\n * resets the deployed value. Default 0 means no concurrency limit.\n */\n updateArgs.push(`--parallelism=${cloud.resources?.parallelism ?? 0}`);\n\n if (secretsString) {\n updateArgs.push(`--set-secrets=${secretsString}`);\n }\n\n if (cloud.serviceAccount) {\n updateArgs.push(`--service-account=${cloud.serviceAccount}`);\n }\n\n if (cloud.network) {\n updateArgs.push(`--network=${cloud.network.name}`);\n if (cloud.network.subnet) {\n updateArgs.push(`--subnet=${cloud.network.subnet}`);\n }\n updateArgs.push(\n `--vpc-egress=${cloud.network.egress ?? \"private-ranges-only\"}`,\n );\n }\n\n const result = gcloudExecCapture(updateArgs);\n\n if (!result.success) {\n consola.error(\"Failed to update Cloud Run Job\");\n process.exit(1);\n }\n\n const filtered = filterGcloudOutput(result.stderr);\n if (filtered) {\n process.stderr.write(filtered + \"\\n\");\n }\n\n consola.success(`Cloud Run Job updated: ${jobName}`);\n return false;\n }\n\n consola.start(\"Creating Cloud Run Job...\");\n\n const createArgs = [\n \"run\",\n \"jobs\",\n \"create\",\n jobName,\n \"--project\",\n project,\n \"--region\",\n region,\n `--image=${imageUri}`,\n `--set-env-vars=${envVarsString}`,\n `--memory=${memory}`,\n `--cpu=${cpu}`,\n `--task-timeout=${timeout}s`,\n \"--max-retries=0\",\n ];\n\n if (cloud.resources?.parallelism !== undefined) {\n createArgs.push(`--parallelism=${cloud.resources.parallelism}`);\n }\n\n if (secretsString) {\n createArgs.push(`--set-secrets=${secretsString}`);\n }\n\n if (cloud.serviceAccount) {\n createArgs.push(`--service-account=${cloud.serviceAccount}`);\n }\n\n if (cloud.network) {\n createArgs.push(`--network=${cloud.network.name}`);\n if (cloud.network.subnet) {\n createArgs.push(`--subnet=${cloud.network.subnet}`);\n }\n createArgs.push(\n `--vpc-egress=${cloud.network.egress ?? \"private-ranges-only\"}`,\n );\n }\n\n const result = gcloudExecCapture(createArgs);\n\n if (!result.success) {\n consola.error(\"Failed to create Cloud Run Job\");\n process.exit(1);\n }\n\n const filtered = filterGcloudOutput(result.stderr);\n if (filtered) {\n process.stderr.write(filtered + \"\\n\");\n }\n\n consola.success(`Cloud Run Job created: ${jobName}`);\n return true;\n}\n\n/**\n * Check if a Docker image exists in Artifact Registry.\n */\nfunction checkImageExists(imageUri: string, project: string): boolean {\n const result = gcloudJson(\n [\n \"artifacts\",\n \"docker\",\n \"images\",\n \"describe\",\n imageUri,\n \"--project\",\n project,\n ],\n { ignoreErrors: true },\n );\n\n return result !== undefined;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"deploy.mjs","names":[],"sources":["../../src/cloud/deploy.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync, unlinkSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { consola } from \"consola\";\nimport type { CloudConfig, RunnerEnvOptions } from \"../config\";\nimport { parseEnvFiles } from \"../env-file\";\nimport { generateDockerfile } from \"./dockerfile\";\nimport {\n checkGcloudAvailable,\n isDockerInstalled,\n isDockerDaemonRunning,\n startDockerDaemon,\n waitForDockerDaemon,\n gcloudExecCapture,\n gcloudJson,\n shellExecCapture,\n} from \"./gcloud\";\nimport { hashDirectory } from \"./hash\";\n\nexport interface DeployOptions {\n /** Cloud configuration from the runner config */\n cloud: CloudConfig;\n /** Environment configuration (project, env vars, secrets) */\n envConfig: RunnerEnvOptions;\n /** Working directory (service root where isolate.config.json lives) */\n serviceDirectory: string;\n}\n\nexport interface DeployResult {\n /** Full image URI including tag */\n imageUri: string;\n /** Whether a new image was built */\n imageBuilt: boolean;\n}\n\n/**\n * Filter out noisy gcloud hints from captured output.\n * Removes \"To execute this job\" and \"Updates are available\" blocks.\n */\nfunction filterGcloudOutput(output: string): string {\n return output\n .replace(/To execute this job.*?gcloud run jobs execute \\S+\\n?/gs, \"\")\n .replace(/Updates are available.*?\\$ gcloud components update\\n?/gs, \"\")\n .trim();\n}\n\nconst DEFAULT_REGION = \"us-central1\";\nconst DEFAULT_ARTIFACT_REGISTRY = \"cloud-run\";\nconst DEFAULT_ISOLATE_PATH = \"isolate\";\nconst GENERATED_DOCKERFILE = \"Dockerfile\";\nconst REGISTRY = \"docker.pkg.dev\";\n\ninterface IsolateConfig {\n targetPackagePath?: string;\n}\n\n/**\n * Resolve the isolate output directory path.\n * Reads isolate.config.json if present, otherwise uses default \"./isolate\".\n */\nfunction resolveIsolateDirectory(serviceDirectory: string): string {\n const configPath = path.join(serviceDirectory, \"isolate.config.json\");\n\n if (existsSync(configPath)) {\n try {\n const configContent = readFileSync(configPath, \"utf-8\");\n const config = JSON.parse(configContent) as IsolateConfig;\n if (config.targetPackagePath) {\n return path.join(serviceDirectory, config.targetPackagePath);\n }\n } catch {\n /** Ignore parse errors, use default */\n }\n }\n\n return path.join(serviceDirectory, DEFAULT_ISOLATE_PATH);\n}\n\nexport interface PrepareResult {\n imageUri: string;\n imageBuilt: boolean;\n region: string;\n project: string;\n}\n\n/**\n * Shared preparation logic: isolate, hash, check image, build if needed.\n * Used by both deploy() and deployIfChanged().\n */\nexport async function prepareImage(\n options: DeployOptions,\n): Promise<PrepareResult> {\n const { cloud, envConfig, serviceDirectory } = options;\n const region = cloud.region ?? DEFAULT_REGION;\n const artifactRegistry = cloud.artifactRegistry ?? DEFAULT_ARTIFACT_REGISTRY;\n const project = envConfig.project;\n let buildLocal = cloud.buildLocal !== false;\n\n checkGcloudAvailable();\n\n if (buildLocal) {\n if (!isDockerInstalled()) {\n consola.warn(\n \"Docker is not installed, falling back to Cloud Build. \" +\n \"Install Docker for faster local builds: https://docs.docker.com/get-docker/\",\n );\n buildLocal = false;\n } else if (!isDockerDaemonRunning()) {\n if (!process.stdin.isTTY) {\n /** Non-interactive environment (CI) — fall back automatically */\n consola.warn(\n \"Docker daemon is not running, falling back to Cloud Build.\",\n );\n buildLocal = false;\n } else {\n const choice = await consola.prompt(\n \"Docker is installed but the daemon is not running.\",\n {\n type: \"select\",\n options: [\n {\n label: \"Start Docker\",\n value: \"start\",\n hint: \"attempt to start the daemon\",\n },\n {\n label: \"Use Cloud Build\",\n value: \"cloud-build\",\n hint: \"build remotely instead\",\n },\n ],\n },\n );\n\n if (typeof choice === \"symbol\") {\n process.exit(0);\n }\n\n if (choice === \"start\") {\n const started = startDockerDaemon();\n\n if (!started) {\n consola.warn(\n \"Could not start Docker automatically, falling back to Cloud Build.\",\n );\n buildLocal = false;\n } else {\n const ready = await waitForDockerDaemon();\n\n if (!ready) {\n consola.warn(\n \"Docker daemon did not become ready in time, falling back to Cloud Build.\",\n );\n buildLocal = false;\n }\n }\n } else {\n buildLocal = false;\n }\n }\n }\n }\n\n /** Step 1: Run isolate to bundle workspace dependencies */\n const isolateDirectory = resolveIsolateDirectory(serviceDirectory);\n\n consola.start(\"Isolating package...\");\n\n try {\n const { isolate: runIsolate } = await import(\"isolate-package\");\n\n const configPath = path.join(serviceDirectory, \"isolate.config.json\");\n const fileConfig = existsSync(configPath)\n ? JSON.parse(readFileSync(configPath, \"utf-8\"))\n : {};\n\n await runIsolate({\n ...fileConfig,\n includeDevDependencies: true,\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n consola.error(`Failed to isolate package: ${message}`);\n process.exit(1);\n }\n\n consola.success(\"Package isolated\");\n\n /** Step 2: Hash the isolate directory */\n const tag = await hashDirectory(isolateDirectory);\n consola.info(`Content hash: ${tag}`);\n\n /** Step 3: Check if image already exists */\n const imageUri = `${region}-${REGISTRY}/${project}/${artifactRegistry}/${cloud.name}:${tag}`;\n\n const imageExists = checkImageExists(imageUri, project);\n let imageBuilt = false;\n\n if (imageExists) {\n consola.success(`Image already exists: ${cloud.name}:${tag}`);\n } else if (buildLocal) {\n /** Step 4a: Generate Dockerfile and build locally with Docker */\n consola.start(\"Building image locally with Docker...\");\n\n const dockerfilePath = path.join(serviceDirectory, GENERATED_DOCKERFILE);\n writeFileSync(dockerfilePath, generateDockerfile());\n\n try {\n const buildResult = shellExecCapture(\n `docker build --platform linux/amd64 -t ${imageUri} .`,\n { cwd: serviceDirectory },\n );\n\n if (!buildResult.success) {\n consola.error(\"Docker build failed. Output:\\n\" + buildResult.output);\n process.exit(1);\n }\n\n consola.success(`Image built: ${cloud.name}:${tag}`);\n\n /** Configure Docker authentication for Artifact Registry */\n gcloudExecCapture([\n \"auth\",\n \"configure-docker\",\n `${region}-${REGISTRY}`,\n \"--quiet\",\n ]);\n\n consola.start(\"Pushing image to Artifact Registry...\");\n\n const pushResult = shellExecCapture(`docker push ${imageUri}`);\n\n if (!pushResult.success) {\n consola.error(\"Docker push failed. Output:\\n\" + pushResult.output);\n process.exit(1);\n }\n\n consola.success(`Image pushed: ${cloud.name}:${tag}`);\n imageBuilt = true;\n } finally {\n /** Clean up generated Dockerfile */\n try {\n unlinkSync(dockerfilePath);\n } catch {\n /** Ignore cleanup errors */\n }\n }\n } else {\n /** Step 4b: Generate Dockerfile and build with Cloud Build */\n consola.start(\"Building image with Cloud Build...\");\n\n const dockerfilePath = path.join(serviceDirectory, GENERATED_DOCKERFILE);\n writeFileSync(dockerfilePath, generateDockerfile());\n\n try {\n const buildResult = gcloudExecCapture(\n [\n \"builds\",\n \"submit\",\n \"--project\",\n project,\n \"--region\",\n region,\n `--tag=${imageUri}`,\n \".\",\n ],\n { cwd: serviceDirectory },\n );\n\n /** Extract and show the Cloud Build logs URL if available */\n const logsUrlMatch = buildResult.output.match(\n /Logs are available at \\[(.+?)]/,\n );\n if (logsUrlMatch) {\n consola.info(`Cloud Build logs: ${logsUrlMatch[1]}`);\n }\n\n if (!buildResult.success) {\n consola.error(\"Cloud Build failed. Output:\\n\" + buildResult.output);\n process.exit(1);\n }\n\n consola.success(`Image built: ${cloud.name}:${tag}`);\n imageBuilt = true;\n } finally {\n /** Clean up generated Dockerfile */\n try {\n unlinkSync(dockerfilePath);\n } catch {\n /** Ignore cleanup errors */\n }\n }\n }\n\n return { imageUri, imageBuilt, region, project };\n}\n\n/**\n * Build and push a Cloud Run Job image.\n *\n * This is image-only: it does not create or update Cloud Run Job resources.\n * Use createOrUpdateJob() separately to manage job resources.\n */\nexport async function deploy(options: DeployOptions): Promise<DeployResult> {\n const { imageUri, imageBuilt } = await prepareImage(options);\n return { imageUri, imageBuilt };\n}\n\n/**\n * Build and push a Cloud Run Job image only if it has changed.\n *\n * This is image-only: it does not create or update Cloud Run Job resources.\n * Use createOrUpdateJob() separately to manage job resources.\n */\nexport async function deployIfChanged(\n options: DeployOptions,\n): Promise<DeployResult> {\n const { imageUri, imageBuilt } = await prepareImage(options);\n\n if (!imageBuilt) {\n consola.info(\"No changes detected, skipping image build\");\n }\n\n return { imageUri, imageBuilt };\n}\n\nexport interface CreateOrUpdateJobOptions {\n cloud: CloudConfig;\n envConfig: RunnerEnvOptions;\n /** The Cloud Run Job resource name (e.g., \"admin-create-user\") */\n jobName: string;\n imageUri: string;\n region: string;\n project: string;\n}\n\n/**\n * Create or update a Cloud Run Job resource.\n * Returns true if the job was newly created, false if updated.\n */\nexport async function createOrUpdateJob(\n options: CreateOrUpdateJobOptions,\n): Promise<boolean> {\n const { cloud, envConfig, jobName, imageUri, region, project } = options;\n const memory = cloud.resources?.memory ?? \"512Mi\";\n const cpu = cloud.resources?.cpu ?? \"1\";\n const timeout = cloud.resources?.timeout ?? 86400;\n\n /** Check if job already exists */\n const existingJob = gcloudJson(\n [\n \"run\",\n \"jobs\",\n \"describe\",\n jobName,\n \"--project\",\n project,\n \"--region\",\n region,\n ],\n { ignoreErrors: true },\n );\n\n /** Build environment variables (envFile < env < project) */\n const envFileVars = parseEnvFiles(envConfig.envFile);\n const envVars: Record<string, string> = {\n ...envFileVars,\n ...envConfig.env,\n GOOGLE_CLOUD_PROJECT: project,\n };\n\n /**\n * Forward the exports destination to the container. Local paths are\n * rejected earlier by assertCloudExportsPath() in the CLI handlers.\n */\n if (envConfig.exportsPath) {\n envVars.JOB_EXPORTS_PATH = envConfig.exportsPath;\n }\n\n const envVarsString = Object.entries(envVars)\n .map(([key, value]) => `${key}=${value}`)\n .join(\",\");\n\n /** Build secret references */\n const secretNames = envConfig.secrets ?? [];\n const secretsString = secretNames\n .map((name) => `${name}=${name}:latest`)\n .join(\",\");\n\n if (existingJob) {\n consola.start(\"Updating Cloud Run Job...\");\n\n const updateArgs = [\n \"run\",\n \"jobs\",\n \"update\",\n jobName,\n \"--project\",\n project,\n \"--region\",\n region,\n `--image=${imageUri}`,\n `--set-env-vars=${envVarsString}`,\n `--memory=${memory}`,\n `--cpu=${cpu}`,\n `--task-timeout=${timeout}s`,\n \"--max-retries=0\",\n ];\n\n /**\n * Always pass --parallelism on update so that removing it from config\n * resets the deployed value. Default 0 means no concurrency limit.\n */\n updateArgs.push(`--parallelism=${cloud.resources?.parallelism ?? 0}`);\n\n if (secretsString) {\n updateArgs.push(`--set-secrets=${secretsString}`);\n }\n\n if (cloud.serviceAccount) {\n updateArgs.push(`--service-account=${cloud.serviceAccount}`);\n }\n\n if (cloud.network) {\n updateArgs.push(`--network=${cloud.network.name}`);\n if (cloud.network.subnet) {\n updateArgs.push(`--subnet=${cloud.network.subnet}`);\n }\n updateArgs.push(\n `--vpc-egress=${cloud.network.egress ?? \"private-ranges-only\"}`,\n );\n }\n\n const result = gcloudExecCapture(updateArgs);\n\n if (!result.success) {\n consola.error(\"Failed to update Cloud Run Job\");\n process.exit(1);\n }\n\n const filtered = filterGcloudOutput(result.stderr);\n if (filtered) {\n process.stderr.write(filtered + \"\\n\");\n }\n\n consola.success(`Cloud Run Job updated: ${jobName}`);\n return false;\n }\n\n consola.start(\"Creating Cloud Run Job...\");\n\n const createArgs = [\n \"run\",\n \"jobs\",\n \"create\",\n jobName,\n \"--project\",\n project,\n \"--region\",\n region,\n `--image=${imageUri}`,\n `--set-env-vars=${envVarsString}`,\n `--memory=${memory}`,\n `--cpu=${cpu}`,\n `--task-timeout=${timeout}s`,\n \"--max-retries=0\",\n ];\n\n if (cloud.resources?.parallelism !== undefined) {\n createArgs.push(`--parallelism=${cloud.resources.parallelism}`);\n }\n\n if (secretsString) {\n createArgs.push(`--set-secrets=${secretsString}`);\n }\n\n if (cloud.serviceAccount) {\n createArgs.push(`--service-account=${cloud.serviceAccount}`);\n }\n\n if (cloud.network) {\n createArgs.push(`--network=${cloud.network.name}`);\n if (cloud.network.subnet) {\n createArgs.push(`--subnet=${cloud.network.subnet}`);\n }\n createArgs.push(\n `--vpc-egress=${cloud.network.egress ?? \"private-ranges-only\"}`,\n );\n }\n\n const result = gcloudExecCapture(createArgs);\n\n if (!result.success) {\n consola.error(\"Failed to create Cloud Run Job\");\n process.exit(1);\n }\n\n const filtered = filterGcloudOutput(result.stderr);\n if (filtered) {\n process.stderr.write(filtered + \"\\n\");\n }\n\n consola.success(`Cloud Run Job created: ${jobName}`);\n return true;\n}\n\n/**\n * Check if a Docker image exists in Artifact Registry.\n */\nfunction checkImageExists(imageUri: string, project: string): boolean {\n const result = gcloudJson(\n [\n \"artifacts\",\n \"docker\",\n \"images\",\n \"describe\",\n imageUri,\n \"--project\",\n project,\n ],\n { ignoreErrors: true },\n );\n\n return result !== undefined;\n}\n"],"mappings":";;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,QAAwB;AAClD,QAAO,OACJ,QAAQ,0DAA0D,GAAG,CACrE,QAAQ,4DAA4D,GAAG,CACvE,MAAM;;AAGX,MAAM,iBAAiB;AACvB,MAAM,4BAA4B;AAClC,MAAM,uBAAuB;AAC7B,MAAM,uBAAuB;AAC7B,MAAM,WAAW;;;;;AAUjB,SAAS,wBAAwB,kBAAkC;CACjE,MAAM,aAAa,KAAK,KAAK,kBAAkB,sBAAsB;AAErE,KAAI,WAAW,WAAW,CACxB,KAAI;EACF,MAAM,gBAAgB,aAAa,YAAY,QAAQ;EACvD,MAAM,SAAS,KAAK,MAAM,cAAc;AACxC,MAAI,OAAO,kBACT,QAAO,KAAK,KAAK,kBAAkB,OAAO,kBAAkB;SAExD;AAKV,QAAO,KAAK,KAAK,kBAAkB,qBAAqB;;;;;;AAc1D,eAAsB,aACpB,SACwB;CACxB,MAAM,EAAE,OAAO,WAAW,qBAAqB;CAC/C,MAAM,SAAS,MAAM,UAAU;CAC/B,MAAM,mBAAmB,MAAM,oBAAoB;CACnD,MAAM,UAAU,UAAU;CAC1B,IAAI,aAAa,MAAM,eAAe;AAEtC,uBAAsB;AAEtB,KAAI,YACF;MAAI,CAAC,mBAAmB,EAAE;AACxB,WAAQ,KACN,oIAED;AACD,gBAAa;aACJ,CAAC,uBAAuB,CACjC,KAAI,CAAC,QAAQ,MAAM,OAAO;;AAExB,WAAQ,KACN,6DACD;AACD,gBAAa;SACR;GACL,MAAM,SAAS,MAAM,QAAQ,OAC3B,sDACA;IACE,MAAM;IACN,SAAS,CACP;KACE,OAAO;KACP,OAAO;KACP,MAAM;KACP,EACD;KACE,OAAO;KACP,OAAO;KACP,MAAM;KACP,CACF;IACF,CACF;AAED,OAAI,OAAO,WAAW,SACpB,SAAQ,KAAK,EAAE;AAGjB,OAAI,WAAW,SAGb;QAAI,CAFY,mBAAmB,EAErB;AACZ,aAAQ,KACN,qEACD;AACD,kBAAa;eAIT,CAFU,MAAM,qBAAqB,EAE7B;AACV,aAAQ,KACN,2EACD;AACD,kBAAa;;SAIjB,cAAa;;;;CAOrB,MAAM,mBAAmB,wBAAwB,iBAAiB;AAElE,SAAQ,MAAM,uBAAuB;AAErC,KAAI;EACF,MAAM,EAAE,SAAS,eAAe,MAAM,OAAO;EAE7C,MAAM,aAAa,KAAK,KAAK,kBAAkB,sBAAsB;AAKrE,QAAM,WAAW;GACf,GALiB,WAAW,WAAW,GACrC,KAAK,MAAM,aAAa,YAAY,QAAQ,CAAC,GAC7C,EAAE;GAIJ,wBAAwB;GACzB,CAAC;UACK,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;AACtE,UAAQ,MAAM,8BAA8B,UAAU;AACtD,UAAQ,KAAK,EAAE;;AAGjB,SAAQ,QAAQ,mBAAmB;;CAGnC,MAAM,MAAM,MAAM,cAAc,iBAAiB;AACjD,SAAQ,KAAK,iBAAiB,MAAM;;CAGpC,MAAM,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,GAAG,iBAAiB,GAAG,MAAM,KAAK,GAAG;CAEvF,MAAM,cAAc,iBAAiB,UAAU,QAAQ;CACvD,IAAI,aAAa;AAEjB,KAAI,YACF,SAAQ,QAAQ,yBAAyB,MAAM,KAAK,GAAG,MAAM;UACpD,YAAY;;AAErB,UAAQ,MAAM,wCAAwC;EAEtD,MAAM,iBAAiB,KAAK,KAAK,kBAAkB,qBAAqB;AACxE,gBAAc,gBAAgB,oBAAoB,CAAC;AAEnD,MAAI;GACF,MAAM,cAAc,iBAClB,0CAA0C,SAAS,KACnD,EAAE,KAAK,kBAAkB,CAC1B;AAED,OAAI,CAAC,YAAY,SAAS;AACxB,YAAQ,MAAM,mCAAmC,YAAY,OAAO;AACpE,YAAQ,KAAK,EAAE;;AAGjB,WAAQ,QAAQ,gBAAgB,MAAM,KAAK,GAAG,MAAM;;AAGpD,qBAAkB;IAChB;IACA;IACA,GAAG,OAAO,GAAG;IACb;IACD,CAAC;AAEF,WAAQ,MAAM,wCAAwC;GAEtD,MAAM,aAAa,iBAAiB,eAAe,WAAW;AAE9D,OAAI,CAAC,WAAW,SAAS;AACvB,YAAQ,MAAM,kCAAkC,WAAW,OAAO;AAClE,YAAQ,KAAK,EAAE;;AAGjB,WAAQ,QAAQ,iBAAiB,MAAM,KAAK,GAAG,MAAM;AACrD,gBAAa;YACL;;AAER,OAAI;AACF,eAAW,eAAe;WACpB;;QAIL;;AAEL,UAAQ,MAAM,qCAAqC;EAEnD,MAAM,iBAAiB,KAAK,KAAK,kBAAkB,qBAAqB;AACxE,gBAAc,gBAAgB,oBAAoB,CAAC;AAEnD,MAAI;GACF,MAAM,cAAc,kBAClB;IACE;IACA;IACA;IACA;IACA;IACA;IACA,SAAS;IACT;IACD,EACD,EAAE,KAAK,kBAAkB,CAC1B;;GAGD,MAAM,eAAe,YAAY,OAAO,MACtC,iCACD;AACD,OAAI,aACF,SAAQ,KAAK,qBAAqB,aAAa,KAAK;AAGtD,OAAI,CAAC,YAAY,SAAS;AACxB,YAAQ,MAAM,kCAAkC,YAAY,OAAO;AACnE,YAAQ,KAAK,EAAE;;AAGjB,WAAQ,QAAQ,gBAAgB,MAAM,KAAK,GAAG,MAAM;AACpD,gBAAa;YACL;;AAER,OAAI;AACF,eAAW,eAAe;WACpB;;;AAMZ,QAAO;EAAE;EAAU;EAAY;EAAQ;EAAS;;;;;;;;AAoBlD,eAAsB,gBACpB,SACuB;CACvB,MAAM,EAAE,UAAU,eAAe,MAAM,aAAa,QAAQ;AAE5D,KAAI,CAAC,WACH,SAAQ,KAAK,4CAA4C;AAG3D,QAAO;EAAE;EAAU;EAAY;;;;;;AAiBjC,eAAsB,kBACpB,SACkB;CAClB,MAAM,EAAE,OAAO,WAAW,SAAS,UAAU,QAAQ,YAAY;CACjE,MAAM,SAAS,MAAM,WAAW,UAAU;CAC1C,MAAM,MAAM,MAAM,WAAW,OAAO;CACpC,MAAM,UAAU,MAAM,WAAW,WAAW;;CAG5C,MAAM,cAAc,WAClB;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD,EACD,EAAE,cAAc,MAAM,CACvB;CAID,MAAM,UAAkC;EACtC,GAFkB,cAAc,UAAU,QAAQ;EAGlD,GAAG,UAAU;EACb,sBAAsB;EACvB;;;;;AAMD,KAAI,UAAU,YACZ,SAAQ,mBAAmB,UAAU;CAGvC,MAAM,gBAAgB,OAAO,QAAQ,QAAQ,CAC1C,KAAK,CAAC,KAAK,WAAW,GAAG,IAAI,GAAG,QAAQ,CACxC,KAAK,IAAI;CAIZ,MAAM,iBADc,UAAU,WAAW,EAAE,EAExC,KAAK,SAAS,GAAG,KAAK,GAAG,KAAK,SAAS,CACvC,KAAK,IAAI;AAEZ,KAAI,aAAa;AACf,UAAQ,MAAM,4BAA4B;EAE1C,MAAM,aAAa;GACjB;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA,WAAW;GACX,kBAAkB;GAClB,YAAY;GACZ,SAAS;GACT,kBAAkB,QAAQ;GAC1B;GACD;;;;;AAMD,aAAW,KAAK,iBAAiB,MAAM,WAAW,eAAe,IAAI;AAErE,MAAI,cACF,YAAW,KAAK,iBAAiB,gBAAgB;AAGnD,MAAI,MAAM,eACR,YAAW,KAAK,qBAAqB,MAAM,iBAAiB;AAG9D,MAAI,MAAM,SAAS;AACjB,cAAW,KAAK,aAAa,MAAM,QAAQ,OAAO;AAClD,OAAI,MAAM,QAAQ,OAChB,YAAW,KAAK,YAAY,MAAM,QAAQ,SAAS;AAErD,cAAW,KACT,gBAAgB,MAAM,QAAQ,UAAU,wBACzC;;EAGH,MAAM,SAAS,kBAAkB,WAAW;AAE5C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAQ,MAAM,iCAAiC;AAC/C,WAAQ,KAAK,EAAE;;EAGjB,MAAM,WAAW,mBAAmB,OAAO,OAAO;AAClD,MAAI,SACF,SAAQ,OAAO,MAAM,WAAW,KAAK;AAGvC,UAAQ,QAAQ,0BAA0B,UAAU;AACpD,SAAO;;AAGT,SAAQ,MAAM,4BAA4B;CAE1C,MAAM,aAAa;EACjB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,kBAAkB;EAClB,YAAY;EACZ,SAAS;EACT,kBAAkB,QAAQ;EAC1B;EACD;AAED,KAAI,MAAM,WAAW,gBAAgB,OACnC,YAAW,KAAK,iBAAiB,MAAM,UAAU,cAAc;AAGjE,KAAI,cACF,YAAW,KAAK,iBAAiB,gBAAgB;AAGnD,KAAI,MAAM,eACR,YAAW,KAAK,qBAAqB,MAAM,iBAAiB;AAG9D,KAAI,MAAM,SAAS;AACjB,aAAW,KAAK,aAAa,MAAM,QAAQ,OAAO;AAClD,MAAI,MAAM,QAAQ,OAChB,YAAW,KAAK,YAAY,MAAM,QAAQ,SAAS;AAErD,aAAW,KACT,gBAAgB,MAAM,QAAQ,UAAU,wBACzC;;CAGH,MAAM,SAAS,kBAAkB,WAAW;AAE5C,KAAI,CAAC,OAAO,SAAS;AACnB,UAAQ,MAAM,iCAAiC;AAC/C,UAAQ,KAAK,EAAE;;CAGjB,MAAM,WAAW,mBAAmB,OAAO,OAAO;AAClD,KAAI,SACF,SAAQ,OAAO,MAAM,WAAW,KAAK;AAGvC,SAAQ,QAAQ,0BAA0B,UAAU;AACpD,QAAO;;;;;AAMT,SAAS,iBAAiB,UAAkB,SAA0B;AAcpE,QAbe,WACb;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACD,EACD,EAAE,cAAc,MAAM,CACvB,KAEiB"}
package/dist/config.d.mts CHANGED
@@ -13,6 +13,17 @@ interface RunnerEnvOptions {
13
13
  env?: Record<string, string>;
14
14
  /** Secret names to load from GCP Secret Manager */
15
15
  secrets?: string[];
16
+ /**
17
+ * Destination for artifacts written via `getExportsWriter()`.
18
+ *
19
+ * Either a local path (resolved relative to the service directory) or a
20
+ * `gs://bucket[/prefix]` URI. Typically set per environment — e.g. a local
21
+ * directory for development and a bucket for cloud deployments.
22
+ *
23
+ * When unset, `getExportsWriter()` throws. Local paths are only applied when
24
+ * running locally; cloud deployments require a `gs://` URI.
25
+ */
26
+ exportsPath?: string;
16
27
  }
17
28
  /** Container resource limits for a Cloud Run Job */
18
29
  interface CloudResources {
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/** Environment configuration for a specific deployment target */\nexport interface RunnerEnvOptions {\n /** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */\n project: string;\n /**\n * Path(s) to .env files to load, resolved relative to the service directory.\n * Variables from these files have lower precedence than explicit `env` values.\n * When multiple files are specified, earlier files take precedence over later ones.\n */\n envFile?: string | string[];\n /** Additional environment variables to set before the job runs */\n env?: Record<string, string>;\n /** Secret names to load from GCP Secret Manager */\n secrets?: string[];\n}\n\n/** Container resource limits for a Cloud Run Job */\nexport interface CloudResources {\n /** Memory limit (e.g., \"512Mi\", \"1Gi\"). Default: \"512Mi\" */\n memory?: string;\n /** CPU limit (e.g., \"1\", \"2\"). Default: \"1\" */\n cpu?: string;\n /** Job timeout in seconds. Default: 86400 (24 hours) */\n timeout?: number;\n /** Maximum number of tasks that can run in parallel. Default: unset (no limit) */\n parallelism?: number;\n}\n\n/** Direct VPC egress configuration for private network access (e.g., Redis) */\nexport interface CloudNetworkConfig {\n /** VPC network name (e.g., \"default\") */\n name: string;\n /** VPC subnet name (e.g., \"default\") */\n subnet?: string;\n /** VPC egress mode. Default: \"private-ranges-only\" */\n egress?: \"all-traffic\" | \"private-ranges-only\";\n}\n\n/** Configuration for Cloud Run Jobs execution */\nexport interface CloudConfig {\n /** Cloud Run Job name (e.g., \"loads-predictions-jobs\") */\n name: string;\n /** GCP region. Default: \"us-central1\" */\n region?: string;\n /** Artifact Registry repository name. Default: \"cloud-run\" */\n artifactRegistry?: string;\n /** Container resource limits */\n resources?: CloudResources;\n /** Service account email for the Cloud Run Job */\n serviceAccount?: string;\n /**\n * Build Docker images locally instead of using Cloud Build.\n * Requires Docker to be installed and running. Default: true.\n */\n buildLocal?: boolean;\n /** Direct VPC egress configuration for private network access */\n network?: CloudNetworkConfig;\n}\n\n/** Full runner configuration provided by each service */\nexport interface RunnerConfig {\n /**\n * Absolute path to the directory containing job scripts.\n * Default: `dist/jobs` relative to cwd.\n */\n jobsDirectory?: string;\n /** Optional initialization function called before the job runs (skipped for --help) */\n initialize?: () => void | Promise<void>;\n /** Optional custom logger (defaults to console) */\n logger?: {\n info: (message: string) => void;\n error: (message: string) => void;\n };\n /** Named environments (e.g., stag, prod) */\n environments: Record<string, RunnerEnvOptions>;\n /** Cloud Run Jobs configuration (required for `job cloud run/deploy` commands) */\n cloud?: CloudConfig;\n /**\n * Command to build workspace dependencies before running jobs.\n * Set to `false` to skip the build step entirely.\n * Default: \"turbo build\"\n */\n buildCommand?: string | false;\n}\n\n/** Identity function for type-safe runner config definition */\nexport function defineRunnerConfig(config: RunnerConfig): RunnerConfig {\n return config;\n}\n\n/** Identity function for type-safe environment definition */\nexport function defineRunnerEnv(options: RunnerEnvOptions): RunnerEnvOptions {\n return options;\n}\n"],"mappings":";;AAsFA,SAAgB,mBAAmB,QAAoC;AACrE,QAAO;;;AAIT,SAAgB,gBAAgB,SAA6C;AAC3E,QAAO"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/** Environment configuration for a specific deployment target */\nexport interface RunnerEnvOptions {\n /** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */\n project: string;\n /**\n * Path(s) to .env files to load, resolved relative to the service directory.\n * Variables from these files have lower precedence than explicit `env` values.\n * When multiple files are specified, earlier files take precedence over later ones.\n */\n envFile?: string | string[];\n /** Additional environment variables to set before the job runs */\n env?: Record<string, string>;\n /** Secret names to load from GCP Secret Manager */\n secrets?: string[];\n /**\n * Destination for artifacts written via `getExportsWriter()`.\n *\n * Either a local path (resolved relative to the service directory) or a\n * `gs://bucket[/prefix]` URI. Typically set per environment — e.g. a local\n * directory for development and a bucket for cloud deployments.\n *\n * When unset, `getExportsWriter()` throws. Local paths are only applied when\n * running locally; cloud deployments require a `gs://` URI.\n */\n exportsPath?: string;\n}\n\n/** Container resource limits for a Cloud Run Job */\nexport interface CloudResources {\n /** Memory limit (e.g., \"512Mi\", \"1Gi\"). Default: \"512Mi\" */\n memory?: string;\n /** CPU limit (e.g., \"1\", \"2\"). Default: \"1\" */\n cpu?: string;\n /** Job timeout in seconds. Default: 86400 (24 hours) */\n timeout?: number;\n /** Maximum number of tasks that can run in parallel. Default: unset (no limit) */\n parallelism?: number;\n}\n\n/** Direct VPC egress configuration for private network access (e.g., Redis) */\nexport interface CloudNetworkConfig {\n /** VPC network name (e.g., \"default\") */\n name: string;\n /** VPC subnet name (e.g., \"default\") */\n subnet?: string;\n /** VPC egress mode. Default: \"private-ranges-only\" */\n egress?: \"all-traffic\" | \"private-ranges-only\";\n}\n\n/** Configuration for Cloud Run Jobs execution */\nexport interface CloudConfig {\n /** Cloud Run Job name (e.g., \"loads-predictions-jobs\") */\n name: string;\n /** GCP region. Default: \"us-central1\" */\n region?: string;\n /** Artifact Registry repository name. Default: \"cloud-run\" */\n artifactRegistry?: string;\n /** Container resource limits */\n resources?: CloudResources;\n /** Service account email for the Cloud Run Job */\n serviceAccount?: string;\n /**\n * Build Docker images locally instead of using Cloud Build.\n * Requires Docker to be installed and running. Default: true.\n */\n buildLocal?: boolean;\n /** Direct VPC egress configuration for private network access */\n network?: CloudNetworkConfig;\n}\n\n/** Full runner configuration provided by each service */\nexport interface RunnerConfig {\n /**\n * Absolute path to the directory containing job scripts.\n * Default: `dist/jobs` relative to cwd.\n */\n jobsDirectory?: string;\n /** Optional initialization function called before the job runs (skipped for --help) */\n initialize?: () => void | Promise<void>;\n /** Optional custom logger (defaults to console) */\n logger?: {\n info: (message: string) => void;\n error: (message: string) => void;\n };\n /** Named environments (e.g., stag, prod) */\n environments: Record<string, RunnerEnvOptions>;\n /** Cloud Run Jobs configuration (required for `job cloud run/deploy` commands) */\n cloud?: CloudConfig;\n /**\n * Command to build workspace dependencies before running jobs.\n * Set to `false` to skip the build step entirely.\n * Default: \"turbo build\"\n */\n buildCommand?: string | false;\n}\n\n/** Identity function for type-safe runner config definition */\nexport function defineRunnerConfig(config: RunnerConfig): RunnerConfig {\n return config;\n}\n\n/** Identity function for type-safe environment definition */\nexport function defineRunnerEnv(options: RunnerEnvOptions): RunnerEnvOptions {\n return options;\n}\n"],"mappings":";;AAiGA,SAAgB,mBAAmB,QAAoC;AACrE,QAAO;;;AAIT,SAAgB,gBAAgB,SAA6C;AAC3E,QAAO"}
@@ -0,0 +1,29 @@
1
+ //#region src/exports.d.ts
2
+ /**
3
+ * Writer for job artifacts, produced by `getExportsWriter()`. A single writer
4
+ * instance targets a destination configured via the `exportsPath` runner
5
+ * option (local directory or `gs://` URI). All methods return the resolved
6
+ * absolute path or `gs://` URI of the written artifact.
7
+ */
8
+ interface ExportsWriter {
9
+ /**
10
+ * Write a value as pretty-printed JSON (2-space indent, trailing newline).
11
+ * The `.json` extension is added if missing.
12
+ */
13
+ writeJson(relativePath: string, data: unknown): Promise<string>;
14
+ /** Write a UTF-8 string (e.g. CSV, SVG, plain text). */
15
+ writeText(relativePath: string, content: string): Promise<string>;
16
+ /** Write a binary buffer. */
17
+ writeBuffer(relativePath: string, content: Buffer | Uint8Array): Promise<string>;
18
+ }
19
+ /**
20
+ * Return a writer that persists artifacts to the destination configured via
21
+ * the `exportsPath` runner option. Local paths are used for local execution;
22
+ * `gs://bucket[/prefix]` URIs are used for Cloud Run deployments.
23
+ *
24
+ * Throws when no destination is configured.
25
+ */
26
+ declare function getExportsWriter(): ExportsWriter;
27
+ //#endregion
28
+ export { ExportsWriter, getExportsWriter };
29
+ //# sourceMappingURL=exports.d.mts.map
@@ -0,0 +1,138 @@
1
+ import { consola } from "consola";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ //#region src/exports.ts
6
+ const GCS_PREFIX = "gs://";
7
+ /**
8
+ * Return a writer that persists artifacts to the destination configured via
9
+ * the `exportsPath` runner option. Local paths are used for local execution;
10
+ * `gs://bucket[/prefix]` URIs are used for Cloud Run deployments.
11
+ *
12
+ * Throws when no destination is configured.
13
+ */
14
+ function getExportsWriter() {
15
+ const destination = process.env.JOB_EXPORTS_PATH;
16
+ if (!destination) throw new Error("No exports destination configured.\nSet `exportsPath` on the current environment in your job-runner.config.ts, or set JOB_EXPORTS_PATH directly in the environment.");
17
+ if (destination.startsWith(GCS_PREFIX)) return createGcsWriter(destination);
18
+ return createLocalWriter(destination);
19
+ }
20
+ function createLocalWriter(basePath) {
21
+ const resolvedBase = path.resolve(basePath);
22
+ /** Takes an already-sanitized relative path and writes the content. */
23
+ async function write(safeRelative, content) {
24
+ const fullPath = path.join(resolvedBase, safeRelative);
25
+ await mkdir(path.dirname(fullPath), { recursive: true });
26
+ await writeFile(fullPath, content);
27
+ consola.info(`Export written: ${fullPath}`);
28
+ return fullPath;
29
+ }
30
+ return {
31
+ async writeJson(relativePath, data) {
32
+ return write(ensureExtension(sanitizeRelativePath(relativePath), ".json"), formatJson(data));
33
+ },
34
+ async writeText(relativePath, content) {
35
+ return write(sanitizeRelativePath(relativePath), content);
36
+ },
37
+ async writeBuffer(relativePath, content) {
38
+ return write(sanitizeRelativePath(relativePath), content);
39
+ }
40
+ };
41
+ }
42
+ function createGcsWriter(uri) {
43
+ const target = parseGcsUri(uri);
44
+ /** Takes an already-sanitized relative path and uploads the content. */
45
+ async function write(safeRelative, content, contentType) {
46
+ const objectName = joinGcsPath(target.prefix, safeRelative);
47
+ const fullUri = `${GCS_PREFIX}${target.bucket}/${objectName}`;
48
+ const file = (await getStorageClient()).bucket(target.bucket).file(objectName);
49
+ const body = typeof content === "string" || Buffer.isBuffer(content) ? content : Buffer.from(content);
50
+ await file.save(body, {
51
+ contentType,
52
+ resumable: false
53
+ });
54
+ consola.info(`Export written: ${fullUri}`);
55
+ return fullUri;
56
+ }
57
+ return {
58
+ async writeJson(relativePath, data) {
59
+ return write(ensureExtension(sanitizeRelativePath(relativePath), ".json"), formatJson(data), "application/json");
60
+ },
61
+ async writeText(relativePath, content) {
62
+ const safe = sanitizeRelativePath(relativePath);
63
+ return write(safe, content, contentTypeFor(safe));
64
+ },
65
+ async writeBuffer(relativePath, content) {
66
+ return write(sanitizeRelativePath(relativePath), content, "application/octet-stream");
67
+ }
68
+ };
69
+ }
70
+ /**
71
+ * Lazy Storage client singleton. The `@google-cloud/storage` module is only
72
+ * loaded when a `gs://` destination is actually used for a write.
73
+ */
74
+ let storageClient = null;
75
+ async function getStorageClient() {
76
+ if (storageClient) return storageClient;
77
+ const { Storage: StorageCtor } = await import("@google-cloud/storage");
78
+ storageClient = new StorageCtor();
79
+ return storageClient;
80
+ }
81
+ function parseGcsUri(uri) {
82
+ const withoutScheme = uri.slice(5);
83
+ const slashIndex = withoutScheme.indexOf("/");
84
+ if (slashIndex === -1) {
85
+ if (!withoutScheme) throw new Error(`Invalid GCS URI: "${uri}" (missing bucket name)`);
86
+ return {
87
+ bucket: withoutScheme,
88
+ prefix: ""
89
+ };
90
+ }
91
+ const bucket = withoutScheme.slice(0, slashIndex);
92
+ const prefix = withoutScheme.slice(slashIndex + 1).replace(/\/+$/, "");
93
+ if (!bucket) throw new Error(`Invalid GCS URI: "${uri}" (missing bucket name)`);
94
+ return {
95
+ bucket,
96
+ prefix
97
+ };
98
+ }
99
+ function joinGcsPath(prefix, relative) {
100
+ const normalizedRelative = relative.replace(/^\/+/, "");
101
+ return prefix ? `${prefix}/${normalizedRelative}` : normalizedRelative;
102
+ }
103
+ function sanitizeRelativePath(relativePath) {
104
+ if (!relativePath || relativePath.trim() === "") throw new Error("Export path is empty");
105
+ if (path.isAbsolute(relativePath) || relativePath.startsWith("/")) throw new Error(`Export path must be relative, got "${relativePath}". Absolute paths are not allowed.`);
106
+ const normalized = path.posix.normalize(relativePath.replace(/\\/g, "/"));
107
+ /**
108
+ * Reject `..` only as a distinct path segment, not as a prefix. A filename
109
+ * like "..hidden" is a legitimate dotfile variant, while ".." / "../x" /
110
+ * "a/../../x" all produce a `..` segment after normalization.
111
+ */
112
+ if (normalized.split("/").includes("..")) throw new Error(`Export path must not traverse upward, got "${relativePath}".`);
113
+ return normalized;
114
+ }
115
+ function ensureExtension(relativePath, extension) {
116
+ return relativePath.endsWith(extension) ? relativePath : `${relativePath}${extension}`;
117
+ }
118
+ function formatJson(data) {
119
+ return `${JSON.stringify(data, null, 2)}\n`;
120
+ }
121
+ const TEXT_CONTENT_TYPES = {
122
+ ".csv": "text/csv; charset=utf-8",
123
+ ".json": "application/json",
124
+ ".svg": "image/svg+xml",
125
+ ".txt": "text/plain; charset=utf-8",
126
+ ".html": "text/html; charset=utf-8",
127
+ ".xml": "application/xml",
128
+ ".yaml": "application/yaml",
129
+ ".yml": "application/yaml",
130
+ ".md": "text/markdown; charset=utf-8"
131
+ };
132
+ function contentTypeFor(relativePath) {
133
+ return TEXT_CONTENT_TYPES[path.posix.extname(relativePath).toLowerCase()] ?? "text/plain; charset=utf-8";
134
+ }
135
+
136
+ //#endregion
137
+ export { getExportsWriter };
138
+ //# sourceMappingURL=exports.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exports.mjs","names":[],"sources":["../src/exports.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { consola } from \"consola\";\nimport type { Storage } from \"@google-cloud/storage\";\n\n/**\n * Writer for job artifacts, produced by `getExportsWriter()`. A single writer\n * instance targets a destination configured via the `exportsPath` runner\n * option (local directory or `gs://` URI). All methods return the resolved\n * absolute path or `gs://` URI of the written artifact.\n */\nexport interface ExportsWriter {\n /**\n * Write a value as pretty-printed JSON (2-space indent, trailing newline).\n * The `.json` extension is added if missing.\n */\n writeJson(relativePath: string, data: unknown): Promise<string>;\n /** Write a UTF-8 string (e.g. CSV, SVG, plain text). */\n writeText(relativePath: string, content: string): Promise<string>;\n /** Write a binary buffer. */\n writeBuffer(\n relativePath: string,\n content: Buffer | Uint8Array,\n ): Promise<string>;\n}\n\nconst GCS_PREFIX = \"gs://\";\n\n/**\n * Return a writer that persists artifacts to the destination configured via\n * the `exportsPath` runner option. Local paths are used for local execution;\n * `gs://bucket[/prefix]` URIs are used for Cloud Run deployments.\n *\n * Throws when no destination is configured.\n */\nexport function getExportsWriter(): ExportsWriter {\n const destination = process.env.JOB_EXPORTS_PATH;\n\n if (!destination) {\n throw new Error(\n \"No exports destination configured.\\n\" +\n \"Set `exportsPath` on the current environment in your job-runner.config.ts, \" +\n \"or set JOB_EXPORTS_PATH directly in the environment.\",\n );\n }\n\n if (destination.startsWith(GCS_PREFIX)) {\n return createGcsWriter(destination);\n }\n\n return createLocalWriter(destination);\n}\n\nfunction createLocalWriter(basePath: string): ExportsWriter {\n const resolvedBase = path.resolve(basePath);\n\n /** Takes an already-sanitized relative path and writes the content. */\n async function write(\n safeRelative: string,\n content: string | Uint8Array,\n ): Promise<string> {\n const fullPath = path.join(resolvedBase, safeRelative);\n\n await mkdir(path.dirname(fullPath), { recursive: true });\n await writeFile(fullPath, content);\n\n consola.info(`Export written: ${fullPath}`);\n return fullPath;\n }\n\n return {\n async writeJson(relativePath, data) {\n /** Sanitize before appending the extension so \"\" doesn't become \".json\". */\n const withExtension = ensureExtension(\n sanitizeRelativePath(relativePath),\n \".json\",\n );\n return write(withExtension, formatJson(data));\n },\n async writeText(relativePath, content) {\n return write(sanitizeRelativePath(relativePath), content);\n },\n async writeBuffer(relativePath, content) {\n return write(sanitizeRelativePath(relativePath), content);\n },\n };\n}\n\ninterface GcsTarget {\n bucket: string;\n prefix: string;\n}\n\nfunction createGcsWriter(uri: string): ExportsWriter {\n const target = parseGcsUri(uri);\n\n /** Takes an already-sanitized relative path and uploads the content. */\n async function write(\n safeRelative: string,\n content: string | Buffer | Uint8Array,\n contentType: string,\n ): Promise<string> {\n const objectName = joinGcsPath(target.prefix, safeRelative);\n const fullUri = `${GCS_PREFIX}${target.bucket}/${objectName}`;\n\n const storage = await getStorageClient();\n const file = storage.bucket(target.bucket).file(objectName);\n\n const body =\n typeof content === \"string\" || Buffer.isBuffer(content)\n ? content\n : Buffer.from(content);\n\n await file.save(body, {\n contentType,\n resumable: false,\n });\n\n consola.info(`Export written: ${fullUri}`);\n return fullUri;\n }\n\n return {\n async writeJson(relativePath, data) {\n /** Sanitize before appending the extension so \"\" doesn't become \".json\". */\n const withExtension = ensureExtension(\n sanitizeRelativePath(relativePath),\n \".json\",\n );\n return write(withExtension, formatJson(data), \"application/json\");\n },\n async writeText(relativePath, content) {\n const safe = sanitizeRelativePath(relativePath);\n return write(safe, content, contentTypeFor(safe));\n },\n async writeBuffer(relativePath, content) {\n return write(\n sanitizeRelativePath(relativePath),\n content,\n \"application/octet-stream\",\n );\n },\n };\n}\n\n/**\n * Lazy Storage client singleton. The `@google-cloud/storage` module is only\n * loaded when a `gs://` destination is actually used for a write.\n */\nlet storageClient: Storage | null = null;\nasync function getStorageClient(): Promise<Storage> {\n if (storageClient) return storageClient;\n const { Storage: StorageCtor } = await import(\"@google-cloud/storage\");\n storageClient = new StorageCtor();\n return storageClient;\n}\n\nfunction parseGcsUri(uri: string): GcsTarget {\n const withoutScheme = uri.slice(GCS_PREFIX.length);\n const slashIndex = withoutScheme.indexOf(\"/\");\n\n if (slashIndex === -1) {\n if (!withoutScheme) {\n throw new Error(`Invalid GCS URI: \"${uri}\" (missing bucket name)`);\n }\n return { bucket: withoutScheme, prefix: \"\" };\n }\n\n const bucket = withoutScheme.slice(0, slashIndex);\n const prefix = withoutScheme.slice(slashIndex + 1).replace(/\\/+$/, \"\");\n\n if (!bucket) {\n throw new Error(`Invalid GCS URI: \"${uri}\" (missing bucket name)`);\n }\n\n return { bucket, prefix };\n}\n\nfunction joinGcsPath(prefix: string, relative: string): string {\n const normalizedRelative = relative.replace(/^\\/+/, \"\");\n return prefix ? `${prefix}/${normalizedRelative}` : normalizedRelative;\n}\n\nfunction sanitizeRelativePath(relativePath: string): string {\n if (!relativePath || relativePath.trim() === \"\") {\n throw new Error(\"Export path is empty\");\n }\n\n if (path.isAbsolute(relativePath) || relativePath.startsWith(\"/\")) {\n throw new Error(\n `Export path must be relative, got \"${relativePath}\". ` +\n \"Absolute paths are not allowed.\",\n );\n }\n\n const normalized = path.posix.normalize(relativePath.replace(/\\\\/g, \"/\"));\n\n /**\n * Reject `..` only as a distinct path segment, not as a prefix. A filename\n * like \"..hidden\" is a legitimate dotfile variant, while \"..\" / \"../x\" /\n * \"a/../../x\" all produce a `..` segment after normalization.\n */\n if (normalized.split(\"/\").includes(\"..\")) {\n throw new Error(\n `Export path must not traverse upward, got \"${relativePath}\".`,\n );\n }\n\n return normalized;\n}\n\nfunction ensureExtension(relativePath: string, extension: string): string {\n return relativePath.endsWith(extension)\n ? relativePath\n : `${relativePath}${extension}`;\n}\n\nfunction formatJson(data: unknown): string {\n return `${JSON.stringify(data, null, 2)}\\n`;\n}\n\nconst TEXT_CONTENT_TYPES: Record<string, string> = {\n \".csv\": \"text/csv; charset=utf-8\",\n \".json\": \"application/json\",\n \".svg\": \"image/svg+xml\",\n \".txt\": \"text/plain; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\",\n \".xml\": \"application/xml\",\n \".yaml\": \"application/yaml\",\n \".yml\": \"application/yaml\",\n \".md\": \"text/markdown; charset=utf-8\",\n};\n\nfunction contentTypeFor(relativePath: string): string {\n const ext = path.posix.extname(relativePath).toLowerCase();\n return TEXT_CONTENT_TYPES[ext] ?? \"text/plain; charset=utf-8\";\n}\n"],"mappings":"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"}
package/dist/index.d.mts CHANGED
@@ -2,7 +2,8 @@ import { CloudConfig, CloudNetworkConfig, CloudResources, RunnerConfig, RunnerEn
2
2
  import { FlagAliases, JobFunction, JobInfo, JobMetadata, JobOptions, RunJobOptions } from "./types.mjs";
3
3
  import { defineJob } from "./define-job.mjs";
4
4
  import { discoverJobs } from "./discover-jobs.mjs";
5
+ import { ExportsWriter, getExportsWriter } from "./exports.mjs";
5
6
  import { FieldInfo, extractFieldInfo, formatZodError, generateSchemaHelp, schemaToParseArgsOptions } from "./help.mjs";
6
7
  import { TaskContext, getTaskContext } from "./task-context.mjs";
7
8
  import { runJob } from "./run-job.mjs";
8
- export { type CloudConfig, type CloudNetworkConfig, type CloudResources, type FieldInfo, type FlagAliases, type JobFunction, type JobInfo, type JobMetadata, type JobOptions, type RunJobOptions, type RunnerConfig, type RunnerEnvOptions, type TaskContext, defineJob, defineRunnerConfig, defineRunnerEnv, discoverJobs, extractFieldInfo, formatZodError, generateSchemaHelp, getTaskContext, runJob, schemaToParseArgsOptions };
9
+ export { type CloudConfig, type CloudNetworkConfig, type CloudResources, type ExportsWriter, type FieldInfo, type FlagAliases, type JobFunction, type JobInfo, type JobMetadata, type JobOptions, type RunJobOptions, type RunnerConfig, type RunnerEnvOptions, type TaskContext, defineJob, defineRunnerConfig, defineRunnerEnv, discoverJobs, extractFieldInfo, formatZodError, generateSchemaHelp, getExportsWriter, getTaskContext, runJob, schemaToParseArgsOptions };
package/dist/index.mjs CHANGED
@@ -2,7 +2,8 @@ import { defineRunnerConfig, defineRunnerEnv } from "./config.mjs";
2
2
  import { extractFieldInfo, formatZodError, generateSchemaHelp, schemaToParseArgsOptions } from "./help.mjs";
3
3
  import { defineJob } from "./define-job.mjs";
4
4
  import { discoverJobs } from "./discover-jobs.mjs";
5
+ import { getExportsWriter } from "./exports.mjs";
5
6
  import { getTaskContext } from "./task-context.mjs";
6
7
  import { runJob } from "./run-job.mjs";
7
8
 
8
- export { defineJob, defineRunnerConfig, defineRunnerEnv, discoverJobs, extractFieldInfo, formatZodError, generateSchemaHelp, getTaskContext, runJob, schemaToParseArgsOptions };
9
+ export { defineJob, defineRunnerConfig, defineRunnerEnv, discoverJobs, extractFieldInfo, formatZodError, generateSchemaHelp, getExportsWriter, getTaskContext, runJob, schemaToParseArgsOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp-job-runner",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Run schema-driven Cloud Run jobs seamlessly in any environment",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,6 +42,7 @@
42
42
  "dependencies": {
43
43
  "@google-cloud/logging": "^11.2.0",
44
44
  "@google-cloud/secret-manager": "^6.1.1",
45
+ "@google-cloud/storage": "^7.19.0",
45
46
  "consola": "^3.4.0",
46
47
  "execa": "^9.6.1",
47
48
  "isolate-package": "1.28.2",
package/src/cli.ts CHANGED
@@ -315,6 +315,17 @@ async function handleLocalRun(options: LocalRunOptions): Promise<void> {
315
315
  /** Project always takes highest precedence */
316
316
  process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
317
317
 
318
+ /**
319
+ * Resolve local exportsPath values against the service directory so jobs
320
+ * can use consistent relative config (e.g. "./exports") from any cwd.
321
+ * `gs://` URIs pass through unchanged.
322
+ */
323
+ if (envConfig.exportsPath) {
324
+ process.env.JOB_EXPORTS_PATH = envConfig.exportsPath.startsWith("gs://")
325
+ ? envConfig.exportsPath
326
+ : path.resolve(process.cwd(), envConfig.exportsPath);
327
+ }
328
+
318
329
  if (envConfig.secrets && envConfig.secrets.length > 0) {
319
330
  const secrets = await getSecrets(envConfig.secrets);
320
331
  for (const [key, value] of Object.entries(secrets)) {
@@ -385,6 +396,8 @@ async function handleCloudDeploy(options: CloudDeployOptions): Promise<void> {
385
396
  process.exit(1);
386
397
  }
387
398
 
399
+ assertCloudExportsPath(envConfig);
400
+
388
401
  const serviceDirectory = process.cwd();
389
402
 
390
403
  const { imageUri } = await prepareImage({
@@ -397,6 +410,22 @@ async function handleCloudDeploy(options: CloudDeployOptions): Promise<void> {
397
410
  consola.success("Deploy complete");
398
411
  }
399
412
 
413
+ /**
414
+ * Reject local exportsPath values for cloud commands — they would silently
415
+ * write to a container filesystem that is discarded on task exit.
416
+ */
417
+ function assertCloudExportsPath(
418
+ envConfig: RunnerConfig["environments"][string],
419
+ ): void {
420
+ if (envConfig.exportsPath && !envConfig.exportsPath.startsWith("gs://")) {
421
+ consola.error(
422
+ `exportsPath "${envConfig.exportsPath}" is a local path but this is a cloud command.\n` +
423
+ "Use a gs:// URI for cloud environments (e.g. gs://my-bucket/exports).",
424
+ );
425
+ process.exit(1);
426
+ }
427
+ }
428
+
400
429
  interface CloudRunOptions {
401
430
  config: RunnerConfig;
402
431
  envConfig: RunnerConfig["environments"][string];
@@ -432,6 +461,8 @@ async function handleCloudRun(options: CloudRunOptions): Promise<void> {
432
461
  process.exit(1);
433
462
  }
434
463
 
464
+ assertCloudExportsPath(envConfig);
465
+
435
466
  /** Override parallelism from CLI flag */
436
467
  if (parallelism !== undefined) {
437
468
  cloud.resources = { ...cloud.resources, parallelism };
@@ -368,6 +368,14 @@ export async function createOrUpdateJob(
368
368
  GOOGLE_CLOUD_PROJECT: project,
369
369
  };
370
370
 
371
+ /**
372
+ * Forward the exports destination to the container. Local paths are
373
+ * rejected earlier by assertCloudExportsPath() in the CLI handlers.
374
+ */
375
+ if (envConfig.exportsPath) {
376
+ envVars.JOB_EXPORTS_PATH = envConfig.exportsPath;
377
+ }
378
+
371
379
  const envVarsString = Object.entries(envVars)
372
380
  .map(([key, value]) => `${key}=${value}`)
373
381
  .join(",");
package/src/config.ts CHANGED
@@ -12,6 +12,17 @@ export interface RunnerEnvOptions {
12
12
  env?: Record<string, string>;
13
13
  /** Secret names to load from GCP Secret Manager */
14
14
  secrets?: string[];
15
+ /**
16
+ * Destination for artifacts written via `getExportsWriter()`.
17
+ *
18
+ * Either a local path (resolved relative to the service directory) or a
19
+ * `gs://bucket[/prefix]` URI. Typically set per environment — e.g. a local
20
+ * directory for development and a bucket for cloud deployments.
21
+ *
22
+ * When unset, `getExportsWriter()` throws. Local paths are only applied when
23
+ * running locally; cloud deployments require a `gs://` URI.
24
+ */
25
+ exportsPath?: string;
15
26
  }
16
27
 
17
28
  /** Container resource limits for a Cloud Run Job */
@@ -0,0 +1,76 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { defineJob } from "./define-job";
6
+ import { getExportsWriter } from "./exports";
7
+
8
+ /**
9
+ * Exercises the full public-API path a job author would use: `defineJob`
10
+ * composes the handler, the runner sets `JOB_EXPORTS_PATH` (simulated here),
11
+ * and the handler writes files via `getExportsWriter`. Catches regressions
12
+ * where the two pieces are individually correct but don't compose.
13
+ */
14
+ describe("exports integration", () => {
15
+ let tempDir: string;
16
+ const originalExportsPath = process.env.JOB_EXPORTS_PATH;
17
+
18
+ beforeEach(() => {
19
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "exports-integration-"));
20
+ process.env.JOB_EXPORTS_PATH = tempDir;
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(tempDir, { recursive: true, force: true });
25
+ if (originalExportsPath === undefined) {
26
+ delete process.env.JOB_EXPORTS_PATH;
27
+ } else {
28
+ process.env.JOB_EXPORTS_PATH = originalExportsPath;
29
+ }
30
+ });
31
+
32
+ it("writes artifacts from inside a defineJob handler", async () => {
33
+ const job = defineJob({
34
+ handler: async () => {
35
+ const exports = getExportsWriter();
36
+ await exports.writeJson("report.json", { count: 42 });
37
+ await exports.writeText("report.csv", "id,count\n1,42\n");
38
+ },
39
+ });
40
+
41
+ await job([], "report");
42
+
43
+ expect(readFileSync(path.join(tempDir, "report.json"), "utf-8")).toBe(
44
+ '{\n "count": 42\n}\n',
45
+ );
46
+ expect(readFileSync(path.join(tempDir, "report.csv"), "utf-8")).toBe(
47
+ "id,count\n1,42\n",
48
+ );
49
+ });
50
+
51
+ it("surfaces config errors to the handler caller", async () => {
52
+ delete process.env.JOB_EXPORTS_PATH;
53
+
54
+ const job = defineJob({
55
+ handler: async () => {
56
+ getExportsWriter();
57
+ },
58
+ });
59
+
60
+ await expect(job([], "missing-config")).rejects.toThrow(/exportsPath/);
61
+ });
62
+
63
+ it("writes to nested directories declared in the relative path", async () => {
64
+ const job = defineJob({
65
+ handler: async () => {
66
+ const exports = getExportsWriter();
67
+ await exports.writeJson("db/airlines/UA.json", { iata: "UA" });
68
+ },
69
+ });
70
+
71
+ await job([], "nested");
72
+
73
+ const fullPath = path.join(tempDir, "db", "airlines", "UA.json");
74
+ expect(readFileSync(fullPath, "utf-8")).toContain('"iata": "UA"');
75
+ });
76
+ });
@@ -0,0 +1,240 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { consola } from "consola";
6
+ import { getExportsWriter } from "./exports";
7
+
8
+ describe("getExportsWriter", () => {
9
+ let tempDir: string;
10
+ const originalExportsPath = process.env.JOB_EXPORTS_PATH;
11
+
12
+ beforeEach(() => {
13
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "exports-test-"));
14
+ process.env.JOB_EXPORTS_PATH = tempDir;
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ if (originalExportsPath === undefined) {
20
+ delete process.env.JOB_EXPORTS_PATH;
21
+ } else {
22
+ process.env.JOB_EXPORTS_PATH = originalExportsPath;
23
+ }
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ describe("configuration", () => {
28
+ it("throws when JOB_EXPORTS_PATH is unset", () => {
29
+ delete process.env.JOB_EXPORTS_PATH;
30
+ expect(() => getExportsWriter()).toThrowError(/exportsPath/);
31
+ });
32
+
33
+ it("throws when JOB_EXPORTS_PATH is empty", () => {
34
+ process.env.JOB_EXPORTS_PATH = "";
35
+ expect(() => getExportsWriter()).toThrowError(/exportsPath/);
36
+ });
37
+ });
38
+
39
+ describe("local writer", () => {
40
+ it("writes pretty-printed JSON with trailing newline", async () => {
41
+ const writer = getExportsWriter();
42
+ const fullPath = await writer.writeJson("data.json", {
43
+ a: 1,
44
+ b: [2, 3],
45
+ });
46
+
47
+ expect(fullPath).toBe(path.join(tempDir, "data.json"));
48
+ expect(readFileSync(fullPath, "utf-8")).toBe(
49
+ '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n',
50
+ );
51
+ });
52
+
53
+ it("adds .json extension when missing", async () => {
54
+ const writer = getExportsWriter();
55
+ const fullPath = await writer.writeJson("report", { ok: true });
56
+
57
+ expect(fullPath).toBe(path.join(tempDir, "report.json"));
58
+ expect(readFileSync(fullPath, "utf-8")).toBe('{\n "ok": true\n}\n');
59
+ });
60
+
61
+ it("does not double-add .json extension", async () => {
62
+ const writer = getExportsWriter();
63
+ const fullPath = await writer.writeJson("report.json", { ok: true });
64
+ expect(fullPath).toBe(path.join(tempDir, "report.json"));
65
+ });
66
+
67
+ it("writes text content unchanged", async () => {
68
+ const writer = getExportsWriter();
69
+ const csv = "id,name\n1,Alice\n2,Bob\n";
70
+ const fullPath = await writer.writeText("users.csv", csv);
71
+
72
+ expect(fullPath).toBe(path.join(tempDir, "users.csv"));
73
+ expect(readFileSync(fullPath, "utf-8")).toBe(csv);
74
+ });
75
+
76
+ it("writes binary buffers unchanged", async () => {
77
+ const writer = getExportsWriter();
78
+ const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
79
+ const fullPath = await writer.writeBuffer("icon.png", buffer);
80
+
81
+ expect(fullPath).toBe(path.join(tempDir, "icon.png"));
82
+ const written = readFileSync(fullPath);
83
+ expect(written.equals(buffer)).toBe(true);
84
+ });
85
+
86
+ it("creates nested directories as needed", async () => {
87
+ const writer = getExportsWriter();
88
+ const fullPath = await writer.writeJson("db/airlines/UA.json", {
89
+ code: "UA",
90
+ });
91
+
92
+ expect(fullPath).toBe(path.join(tempDir, "db", "airlines", "UA.json"));
93
+ expect(readFileSync(fullPath, "utf-8")).toContain('"code": "UA"');
94
+ });
95
+
96
+ it("logs the written path", async () => {
97
+ const infoSpy = vi.spyOn(consola, "info").mockImplementation(() => {});
98
+ const writer = getExportsWriter();
99
+ await writer.writeText("a.txt", "hi");
100
+ expect(infoSpy).toHaveBeenCalledWith(
101
+ expect.stringContaining(path.join(tempDir, "a.txt")),
102
+ );
103
+ });
104
+
105
+ it("rejects absolute paths", async () => {
106
+ const writer = getExportsWriter();
107
+ await expect(writer.writeText("/etc/passwd", "pwned")).rejects.toThrow(
108
+ /Absolute paths are not allowed/,
109
+ );
110
+ });
111
+
112
+ it("rejects parent-directory traversal", async () => {
113
+ const writer = getExportsWriter();
114
+ await expect(writer.writeText("../escape.txt", "pwned")).rejects.toThrow(
115
+ /must not traverse upward/,
116
+ );
117
+ await expect(
118
+ writer.writeText("a/../../escape.txt", "pwned"),
119
+ ).rejects.toThrow(/must not traverse upward/);
120
+ await expect(writer.writeText("..", "pwned")).rejects.toThrow(
121
+ /must not traverse upward/,
122
+ );
123
+ });
124
+
125
+ it("allows filenames that start with two dots", async () => {
126
+ const writer = getExportsWriter();
127
+ const fullPath = await writer.writeText("..hidden", "ok");
128
+ expect(fullPath).toBe(path.join(tempDir, "..hidden"));
129
+ expect(readFileSync(fullPath, "utf-8")).toBe("ok");
130
+ });
131
+
132
+ it("rejects empty paths", async () => {
133
+ const writer = getExportsWriter();
134
+ await expect(writer.writeText("", "x")).rejects.toThrow(/empty/);
135
+ await expect(writer.writeBuffer("", Buffer.from("x"))).rejects.toThrow(
136
+ /empty/,
137
+ );
138
+ await expect(writer.writeJson("", { ok: true })).rejects.toThrow(/empty/);
139
+ });
140
+
141
+ it("resolves relative base path to absolute", async () => {
142
+ process.env.JOB_EXPORTS_PATH = tempDir;
143
+ const writer = getExportsWriter();
144
+ const fullPath = await writer.writeText("x.txt", "y");
145
+ expect(path.isAbsolute(fullPath)).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe("gcs writer", () => {
150
+ const saveMock = vi.fn().mockResolvedValue(undefined);
151
+ const fileMock = vi.fn(() => ({ save: saveMock }));
152
+ const bucketMock = vi.fn(() => ({ file: fileMock }));
153
+
154
+ beforeEach(() => {
155
+ saveMock.mockClear();
156
+ fileMock.mockClear();
157
+ bucketMock.mockClear();
158
+ vi.doMock("@google-cloud/storage", () => ({
159
+ Storage: class {
160
+ bucket = bucketMock;
161
+ },
162
+ }));
163
+ });
164
+
165
+ afterEach(() => {
166
+ vi.doUnmock("@google-cloud/storage");
167
+ vi.resetModules();
168
+ });
169
+
170
+ it("routes to the configured bucket and prefix for JSON", async () => {
171
+ process.env.JOB_EXPORTS_PATH = "gs://my-bucket/jobs";
172
+ vi.resetModules();
173
+ const { getExportsWriter: freshWriter } = await import("./exports");
174
+ const writer = freshWriter();
175
+
176
+ const uri = await writer.writeJson("data/flights.json", { count: 5 });
177
+
178
+ expect(uri).toBe("gs://my-bucket/jobs/data/flights.json");
179
+ expect(bucketMock).toHaveBeenCalledWith("my-bucket");
180
+ expect(fileMock).toHaveBeenCalledWith("jobs/data/flights.json");
181
+ expect(saveMock).toHaveBeenCalledWith(
182
+ '{\n "count": 5\n}\n',
183
+ expect.objectContaining({
184
+ contentType: "application/json",
185
+ resumable: false,
186
+ }),
187
+ );
188
+ });
189
+
190
+ it("handles bucket-only URIs (no prefix)", async () => {
191
+ process.env.JOB_EXPORTS_PATH = "gs://my-bucket";
192
+ vi.resetModules();
193
+ const { getExportsWriter: freshWriter } = await import("./exports");
194
+ const writer = freshWriter();
195
+
196
+ const uri = await writer.writeText("report.csv", "a,b\n1,2\n");
197
+
198
+ expect(uri).toBe("gs://my-bucket/report.csv");
199
+ expect(fileMock).toHaveBeenCalledWith("report.csv");
200
+ expect(saveMock).toHaveBeenCalledWith(
201
+ "a,b\n1,2\n",
202
+ expect.objectContaining({ contentType: "text/csv; charset=utf-8" }),
203
+ );
204
+ });
205
+
206
+ it("passes buffers through as binary", async () => {
207
+ process.env.JOB_EXPORTS_PATH = "gs://my-bucket/artifacts";
208
+ vi.resetModules();
209
+ const { getExportsWriter: freshWriter } = await import("./exports");
210
+ const writer = freshWriter();
211
+
212
+ const buffer = Buffer.from([1, 2, 3]);
213
+ const uri = await writer.writeBuffer("blob.bin", buffer);
214
+
215
+ expect(uri).toBe("gs://my-bucket/artifacts/blob.bin");
216
+ expect(saveMock).toHaveBeenCalledWith(
217
+ buffer,
218
+ expect.objectContaining({ contentType: "application/octet-stream" }),
219
+ );
220
+ });
221
+
222
+ it("throws on invalid URI without bucket", async () => {
223
+ process.env.JOB_EXPORTS_PATH = "gs://";
224
+ vi.resetModules();
225
+ const { getExportsWriter: freshWriter } = await import("./exports");
226
+ expect(() => freshWriter()).toThrowError(/missing bucket name/);
227
+ });
228
+
229
+ it("rejects traversal in GCS paths too", async () => {
230
+ process.env.JOB_EXPORTS_PATH = "gs://my-bucket/jobs";
231
+ vi.resetModules();
232
+ const { getExportsWriter: freshWriter } = await import("./exports");
233
+ const writer = freshWriter();
234
+
235
+ await expect(writer.writeText("../secret.txt", "no")).rejects.toThrow(
236
+ /must not traverse upward/,
237
+ );
238
+ });
239
+ });
240
+ });
package/src/exports.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { consola } from "consola";
4
+ import type { Storage } from "@google-cloud/storage";
5
+
6
+ /**
7
+ * Writer for job artifacts, produced by `getExportsWriter()`. A single writer
8
+ * instance targets a destination configured via the `exportsPath` runner
9
+ * option (local directory or `gs://` URI). All methods return the resolved
10
+ * absolute path or `gs://` URI of the written artifact.
11
+ */
12
+ export interface ExportsWriter {
13
+ /**
14
+ * Write a value as pretty-printed JSON (2-space indent, trailing newline).
15
+ * The `.json` extension is added if missing.
16
+ */
17
+ writeJson(relativePath: string, data: unknown): Promise<string>;
18
+ /** Write a UTF-8 string (e.g. CSV, SVG, plain text). */
19
+ writeText(relativePath: string, content: string): Promise<string>;
20
+ /** Write a binary buffer. */
21
+ writeBuffer(
22
+ relativePath: string,
23
+ content: Buffer | Uint8Array,
24
+ ): Promise<string>;
25
+ }
26
+
27
+ const GCS_PREFIX = "gs://";
28
+
29
+ /**
30
+ * Return a writer that persists artifacts to the destination configured via
31
+ * the `exportsPath` runner option. Local paths are used for local execution;
32
+ * `gs://bucket[/prefix]` URIs are used for Cloud Run deployments.
33
+ *
34
+ * Throws when no destination is configured.
35
+ */
36
+ export function getExportsWriter(): ExportsWriter {
37
+ const destination = process.env.JOB_EXPORTS_PATH;
38
+
39
+ if (!destination) {
40
+ throw new Error(
41
+ "No exports destination configured.\n" +
42
+ "Set `exportsPath` on the current environment in your job-runner.config.ts, " +
43
+ "or set JOB_EXPORTS_PATH directly in the environment.",
44
+ );
45
+ }
46
+
47
+ if (destination.startsWith(GCS_PREFIX)) {
48
+ return createGcsWriter(destination);
49
+ }
50
+
51
+ return createLocalWriter(destination);
52
+ }
53
+
54
+ function createLocalWriter(basePath: string): ExportsWriter {
55
+ const resolvedBase = path.resolve(basePath);
56
+
57
+ /** Takes an already-sanitized relative path and writes the content. */
58
+ async function write(
59
+ safeRelative: string,
60
+ content: string | Uint8Array,
61
+ ): Promise<string> {
62
+ const fullPath = path.join(resolvedBase, safeRelative);
63
+
64
+ await mkdir(path.dirname(fullPath), { recursive: true });
65
+ await writeFile(fullPath, content);
66
+
67
+ consola.info(`Export written: ${fullPath}`);
68
+ return fullPath;
69
+ }
70
+
71
+ return {
72
+ async writeJson(relativePath, data) {
73
+ /** Sanitize before appending the extension so "" doesn't become ".json". */
74
+ const withExtension = ensureExtension(
75
+ sanitizeRelativePath(relativePath),
76
+ ".json",
77
+ );
78
+ return write(withExtension, formatJson(data));
79
+ },
80
+ async writeText(relativePath, content) {
81
+ return write(sanitizeRelativePath(relativePath), content);
82
+ },
83
+ async writeBuffer(relativePath, content) {
84
+ return write(sanitizeRelativePath(relativePath), content);
85
+ },
86
+ };
87
+ }
88
+
89
+ interface GcsTarget {
90
+ bucket: string;
91
+ prefix: string;
92
+ }
93
+
94
+ function createGcsWriter(uri: string): ExportsWriter {
95
+ const target = parseGcsUri(uri);
96
+
97
+ /** Takes an already-sanitized relative path and uploads the content. */
98
+ async function write(
99
+ safeRelative: string,
100
+ content: string | Buffer | Uint8Array,
101
+ contentType: string,
102
+ ): Promise<string> {
103
+ const objectName = joinGcsPath(target.prefix, safeRelative);
104
+ const fullUri = `${GCS_PREFIX}${target.bucket}/${objectName}`;
105
+
106
+ const storage = await getStorageClient();
107
+ const file = storage.bucket(target.bucket).file(objectName);
108
+
109
+ const body =
110
+ typeof content === "string" || Buffer.isBuffer(content)
111
+ ? content
112
+ : Buffer.from(content);
113
+
114
+ await file.save(body, {
115
+ contentType,
116
+ resumable: false,
117
+ });
118
+
119
+ consola.info(`Export written: ${fullUri}`);
120
+ return fullUri;
121
+ }
122
+
123
+ return {
124
+ async writeJson(relativePath, data) {
125
+ /** Sanitize before appending the extension so "" doesn't become ".json". */
126
+ const withExtension = ensureExtension(
127
+ sanitizeRelativePath(relativePath),
128
+ ".json",
129
+ );
130
+ return write(withExtension, formatJson(data), "application/json");
131
+ },
132
+ async writeText(relativePath, content) {
133
+ const safe = sanitizeRelativePath(relativePath);
134
+ return write(safe, content, contentTypeFor(safe));
135
+ },
136
+ async writeBuffer(relativePath, content) {
137
+ return write(
138
+ sanitizeRelativePath(relativePath),
139
+ content,
140
+ "application/octet-stream",
141
+ );
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Lazy Storage client singleton. The `@google-cloud/storage` module is only
148
+ * loaded when a `gs://` destination is actually used for a write.
149
+ */
150
+ let storageClient: Storage | null = null;
151
+ async function getStorageClient(): Promise<Storage> {
152
+ if (storageClient) return storageClient;
153
+ const { Storage: StorageCtor } = await import("@google-cloud/storage");
154
+ storageClient = new StorageCtor();
155
+ return storageClient;
156
+ }
157
+
158
+ function parseGcsUri(uri: string): GcsTarget {
159
+ const withoutScheme = uri.slice(GCS_PREFIX.length);
160
+ const slashIndex = withoutScheme.indexOf("/");
161
+
162
+ if (slashIndex === -1) {
163
+ if (!withoutScheme) {
164
+ throw new Error(`Invalid GCS URI: "${uri}" (missing bucket name)`);
165
+ }
166
+ return { bucket: withoutScheme, prefix: "" };
167
+ }
168
+
169
+ const bucket = withoutScheme.slice(0, slashIndex);
170
+ const prefix = withoutScheme.slice(slashIndex + 1).replace(/\/+$/, "");
171
+
172
+ if (!bucket) {
173
+ throw new Error(`Invalid GCS URI: "${uri}" (missing bucket name)`);
174
+ }
175
+
176
+ return { bucket, prefix };
177
+ }
178
+
179
+ function joinGcsPath(prefix: string, relative: string): string {
180
+ const normalizedRelative = relative.replace(/^\/+/, "");
181
+ return prefix ? `${prefix}/${normalizedRelative}` : normalizedRelative;
182
+ }
183
+
184
+ function sanitizeRelativePath(relativePath: string): string {
185
+ if (!relativePath || relativePath.trim() === "") {
186
+ throw new Error("Export path is empty");
187
+ }
188
+
189
+ if (path.isAbsolute(relativePath) || relativePath.startsWith("/")) {
190
+ throw new Error(
191
+ `Export path must be relative, got "${relativePath}". ` +
192
+ "Absolute paths are not allowed.",
193
+ );
194
+ }
195
+
196
+ const normalized = path.posix.normalize(relativePath.replace(/\\/g, "/"));
197
+
198
+ /**
199
+ * Reject `..` only as a distinct path segment, not as a prefix. A filename
200
+ * like "..hidden" is a legitimate dotfile variant, while ".." / "../x" /
201
+ * "a/../../x" all produce a `..` segment after normalization.
202
+ */
203
+ if (normalized.split("/").includes("..")) {
204
+ throw new Error(
205
+ `Export path must not traverse upward, got "${relativePath}".`,
206
+ );
207
+ }
208
+
209
+ return normalized;
210
+ }
211
+
212
+ function ensureExtension(relativePath: string, extension: string): string {
213
+ return relativePath.endsWith(extension)
214
+ ? relativePath
215
+ : `${relativePath}${extension}`;
216
+ }
217
+
218
+ function formatJson(data: unknown): string {
219
+ return `${JSON.stringify(data, null, 2)}\n`;
220
+ }
221
+
222
+ const TEXT_CONTENT_TYPES: Record<string, string> = {
223
+ ".csv": "text/csv; charset=utf-8",
224
+ ".json": "application/json",
225
+ ".svg": "image/svg+xml",
226
+ ".txt": "text/plain; charset=utf-8",
227
+ ".html": "text/html; charset=utf-8",
228
+ ".xml": "application/xml",
229
+ ".yaml": "application/yaml",
230
+ ".yml": "application/yaml",
231
+ ".md": "text/markdown; charset=utf-8",
232
+ };
233
+
234
+ function contentTypeFor(relativePath: string): string {
235
+ const ext = path.posix.extname(relativePath).toLowerCase();
236
+ return TEXT_CONTENT_TYPES[ext] ?? "text/plain; charset=utf-8";
237
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { defineRunnerConfig, defineRunnerEnv } from "./config";
2
2
  export { defineJob } from "./define-job";
3
3
  export { discoverJobs } from "./discover-jobs";
4
+ export { getExportsWriter } from "./exports";
5
+ export type { ExportsWriter } from "./exports";
4
6
  export {
5
7
  extractFieldInfo,
6
8
  formatZodError,