gcp-job-runner 1.4.1 → 1.6.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/dist/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { discoverJobs } from "./discover-jobs.mjs";
3
3
  import { promptForArgs, selectJob } from "./interactive.mjs";
4
4
  import { runJob } from "./run-job.mjs";
5
+ import { parseEnvFiles } from "./env-file.mjs";
5
6
  import { createOrUpdateJob, deployIfChanged, prepareImage } from "./cloud/deploy.mjs";
6
7
  import { execute } from "./cloud/execute.mjs";
7
8
  import { deriveJobResourceName } from "./cloud/job-name.mjs";
@@ -193,10 +194,15 @@ async function handleLocalRun(options) {
193
194
  const { config, envName, envConfig, jobsDirectory, jobNameFromArgs, jobFlags, isInteractive } = options;
194
195
  /** Set environment variables for local execution */
195
196
  process.env.NODE_ENV ??= "development";
196
- process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
197
197
  process.env.USE_CONSOLE_LOG ??= "true";
198
198
  process.env.LOG_COLORIZE ??= "true";
199
+ /** Load envFile variables (don't overwrite existing process.env values) */
200
+ const envFileVars = parseEnvFiles(envConfig.envFile);
201
+ for (const [key, value] of Object.entries(envFileVars)) process.env[key] ??= value;
202
+ /** Explicit env values override envFile values */
199
203
  if (envConfig.env) for (const [key, value] of Object.entries(envConfig.env)) process.env[key] = value;
204
+ /** Project always takes highest precedence */
205
+ process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
200
206
  if (envConfig.secrets && envConfig.secrets.length > 0) {
201
207
  const secrets = await getSecrets(envConfig.secrets);
202
208
  for (const [key, value] of Object.entries(secrets)) process.env[key] = value;
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 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.GOOGLE_CLOUD_PROJECT = envConfig.project;\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n if (envConfig.env) {\n for (const [key, value] of Object.entries(envConfig.env)) {\n process.env[key] = value;\n }\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 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 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,5 +1,6 @@
1
+ import { parseEnvFiles } from "../env-file.mjs";
1
2
  import { generateDockerfile } from "./dockerfile.mjs";
2
- import { checkGcloudAvailable, gcloudExecCapture, gcloudJson, isDockerAvailable, shellExecCapture } from "./gcloud.mjs";
3
+ import { checkGcloudAvailable, gcloudExecCapture, gcloudJson, isDockerDaemonRunning, isDockerInstalled, shellExecCapture, startDockerDaemon, waitForDockerDaemon } from "./gcloud.mjs";
3
4
  import { hashDirectory } from "./hash.mjs";
4
5
  import { consola } from "consola";
5
6
  import path from "node:path";
@@ -42,9 +43,38 @@ async function prepareImage(options) {
42
43
  const project = envConfig.project;
43
44
  let buildLocal = cloud.buildLocal !== false;
44
45
  checkGcloudAvailable();
45
- if (buildLocal && !isDockerAvailable()) {
46
- consola.warn("Docker is not available, falling back to Cloud Build. Install Docker for faster local builds: https://docs.docker.com/get-docker/");
47
- buildLocal = false;
46
+ if (buildLocal) {
47
+ if (!isDockerInstalled()) {
48
+ consola.warn("Docker is not installed, falling back to Cloud Build. Install Docker for faster local builds: https://docs.docker.com/get-docker/");
49
+ buildLocal = false;
50
+ } else if (!isDockerDaemonRunning()) if (!process.stdin.isTTY) {
51
+ /** Non-interactive environment (CI) — fall back automatically */
52
+ consola.warn("Docker daemon is not running, falling back to Cloud Build.");
53
+ buildLocal = false;
54
+ } else {
55
+ const choice = await consola.prompt("Docker is installed but the daemon is not running.", {
56
+ type: "select",
57
+ options: [{
58
+ label: "Start Docker",
59
+ value: "start",
60
+ hint: "attempt to start the daemon"
61
+ }, {
62
+ label: "Use Cloud Build",
63
+ value: "cloud-build",
64
+ hint: "build remotely instead"
65
+ }]
66
+ });
67
+ if (typeof choice === "symbol") process.exit(0);
68
+ if (choice === "start") {
69
+ if (!startDockerDaemon()) {
70
+ consola.warn("Could not start Docker automatically, falling back to Cloud Build.");
71
+ buildLocal = false;
72
+ } else if (!await waitForDockerDaemon()) {
73
+ consola.warn("Docker daemon did not become ready in time, falling back to Cloud Build.");
74
+ buildLocal = false;
75
+ }
76
+ } else buildLocal = false;
77
+ }
48
78
  }
49
79
  /** Step 1: Run isolate to bundle workspace dependencies */
50
80
  const isolateDirectory = resolveIsolateDirectory(serviceDirectory);
@@ -176,10 +206,10 @@ async function createOrUpdateJob(options) {
176
206
  "--region",
177
207
  region
178
208
  ], { ignoreErrors: true });
179
- /** Build environment variables */
180
209
  const envVars = {
181
- GOOGLE_CLOUD_PROJECT: project,
182
- ...envConfig.env
210
+ ...parseEnvFiles(envConfig.envFile),
211
+ ...envConfig.env,
212
+ GOOGLE_CLOUD_PROJECT: project
183
213
  };
184
214
  const envVarsString = Object.entries(envVars).map(([key, value]) => `${key}=${value}`).join(",");
185
215
  const secretsString = (envConfig.secrets ?? []).map((name) => `${name}=${name}:latest`).join(",");
@@ -208,6 +238,11 @@ async function createOrUpdateJob(options) {
208
238
  updateArgs.push(`--parallelism=${cloud.resources?.parallelism ?? 0}`);
209
239
  if (secretsString) updateArgs.push(`--set-secrets=${secretsString}`);
210
240
  if (cloud.serviceAccount) updateArgs.push(`--service-account=${cloud.serviceAccount}`);
241
+ if (cloud.network) {
242
+ updateArgs.push(`--network=${cloud.network.name}`);
243
+ if (cloud.network.subnet) updateArgs.push(`--subnet=${cloud.network.subnet}`);
244
+ updateArgs.push(`--vpc-egress=${cloud.network.egress ?? "private-ranges-only"}`);
245
+ }
211
246
  const result = gcloudExecCapture(updateArgs);
212
247
  if (!result.success) {
213
248
  consola.error("Failed to update Cloud Run Job");
@@ -238,6 +273,11 @@ async function createOrUpdateJob(options) {
238
273
  if (cloud.resources?.parallelism !== void 0) createArgs.push(`--parallelism=${cloud.resources.parallelism}`);
239
274
  if (secretsString) createArgs.push(`--set-secrets=${secretsString}`);
240
275
  if (cloud.serviceAccount) createArgs.push(`--service-account=${cloud.serviceAccount}`);
276
+ if (cloud.network) {
277
+ createArgs.push(`--network=${cloud.network.name}`);
278
+ if (cloud.network.subnet) createArgs.push(`--subnet=${cloud.network.subnet}`);
279
+ createArgs.push(`--vpc-egress=${cloud.network.egress ?? "private-ranges-only"}`);
280
+ }
241
281
  const result = gcloudExecCapture(createArgs);
242
282
  if (!result.success) {
243
283
  consola.error("Failed to create Cloud Run Job");
@@ -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 { generateDockerfile } from \"./dockerfile\";\nimport {\n checkGcloudAvailable,\n isDockerAvailable,\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 && !isDockerAvailable()) {\n consola.warn(\n \"Docker is not available, falling back to Cloud Build. \" +\n \"Install Docker for faster local builds: https://docs.docker.com/get-docker/\",\n );\n buildLocal = false;\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 */\n const envVars: Record<string, string> = {\n GOOGLE_CLOUD_PROJECT: project,\n ...envConfig.env,\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 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 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 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,5 +1,5 @@
1
1
  import { consola } from "consola";
2
- import { execaCommandSync, execaSync } from "execa";
2
+ import { execa, execaCommandSync, execaSync } from "execa";
3
3
 
4
4
  //#region src/cloud/gcloud.ts
5
5
  /**
@@ -75,9 +75,9 @@ function checkGcloudAvailable() {
75
75
  }
76
76
  }
77
77
  /**
78
- * Check if Docker CLI is available.
78
+ * Check if the Docker CLI binary is installed.
79
79
  */
80
- function isDockerAvailable() {
80
+ function isDockerInstalled() {
81
81
  try {
82
82
  execaSync("docker", ["--version"]);
83
83
  return true;
@@ -85,7 +85,62 @@ function isDockerAvailable() {
85
85
  return false;
86
86
  }
87
87
  }
88
+ /**
89
+ * Check if the Docker daemon is running by executing `docker info`.
90
+ */
91
+ function isDockerDaemonRunning() {
92
+ try {
93
+ execaSync("docker", ["info"], { stdio: "pipe" });
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * Attempt to start the Docker daemon.
101
+ * - macOS: opens the Docker Desktop application
102
+ * - Linux: starts the docker systemd service
103
+ * - Other platforms: unsupported, returns false
104
+ */
105
+ function startDockerDaemon() {
106
+ try {
107
+ if (process.platform === "darwin") {
108
+ execaSync("open", ["-a", "Docker"]);
109
+ return true;
110
+ }
111
+ if (process.platform === "linux") {
112
+ execaSync("systemctl", ["start", "docker"]);
113
+ return true;
114
+ }
115
+ return false;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+ /**
121
+ * Poll `docker info` until the daemon is responsive or the timeout is reached.
122
+ * Shows a spinner while waiting.
123
+ *
124
+ * @param timeoutMs - Maximum time to wait in milliseconds (default: 30000)
125
+ * @param intervalMs - Polling interval in milliseconds (default: 2000)
126
+ * @returns true if the daemon became available, false on timeout
127
+ */
128
+ async function waitForDockerDaemon(timeoutMs = 3e4, intervalMs = 2e3) {
129
+ if (isDockerDaemonRunning()) return true;
130
+ consola.start("Waiting for Docker daemon to start...");
131
+ const deadline = Date.now() + timeoutMs;
132
+ while (Date.now() < deadline) {
133
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
134
+ try {
135
+ await execa("docker", ["info"], { stdio: "pipe" });
136
+ consola.success("Docker daemon is running");
137
+ return true;
138
+ } catch {}
139
+ }
140
+ consola.fail("Docker daemon did not start in time");
141
+ return false;
142
+ }
88
143
 
89
144
  //#endregion
90
- export { checkGcloudAvailable, gcloudExecCapture, gcloudJson, isDockerAvailable, shellExecCapture };
145
+ export { checkGcloudAvailable, gcloudExecCapture, gcloudJson, isDockerDaemonRunning, isDockerInstalled, shellExecCapture, startDockerDaemon, waitForDockerDaemon };
91
146
  //# sourceMappingURL=gcloud.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"gcloud.mjs","names":[],"sources":["../../src/cloud/gcloud.ts"],"sourcesContent":["import { execaCommandSync, execaSync } from \"execa\";\nimport { consola } from \"consola\";\n\nexport interface CapturedExecResult {\n success: boolean;\n output: string;\n stderr: string;\n}\n\n/**\n * Execute a gcloud command and return the parsed JSON output.\n * Throws on non-zero exit code unless `ignoreErrors` is set.\n */\nexport function gcloudJson<T = unknown>(\n args: string[],\n options?: { ignoreErrors?: boolean },\n): T | undefined {\n try {\n const result = execaSync(\"gcloud\", [...args, \"--format=json\"]);\n return JSON.parse(result.stdout) as T;\n } catch (error) {\n if (options?.ignoreErrors) {\n return undefined;\n }\n throw error;\n }\n}\n\n/**\n * Execute a gcloud command with stdio inherited (shows output in terminal).\n * Returns whether the command succeeded.\n */\nexport function gcloudExec(\n args: string[],\n options?: { cwd?: string },\n): boolean {\n try {\n execaSync(\"gcloud\", args, {\n stdio: \"inherit\",\n cwd: options?.cwd,\n reject: true,\n });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Execute a shell command with stdio inherited.\n * Uses shell execution for commands that need shell features (like pnpm).\n */\nexport function shellExec(\n command: string,\n options?: { cwd?: string },\n): boolean {\n try {\n execaCommandSync(command, {\n stdio: \"inherit\",\n cwd: options?.cwd,\n reject: true,\n });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Execute a gcloud command and capture all output instead of streaming it.\n * Returns success status and captured stdout/stderr for the caller to handle.\n */\nexport function gcloudExecCapture(\n args: string[],\n options?: { cwd?: string },\n): CapturedExecResult {\n try {\n const result = execaSync(\"gcloud\", args, {\n cwd: options?.cwd,\n reject: true,\n });\n return { success: true, output: result.stdout, stderr: result.stderr };\n } catch (error) {\n const stderr = (error as { stderr?: string }).stderr ?? \"\";\n const stdout = (error as { stdout?: string }).stdout ?? \"\";\n return {\n success: false,\n output: [stderr, stdout].filter(Boolean).join(\"\\n\"),\n stderr,\n };\n }\n}\n\n/**\n * Execute a shell command and capture all output instead of streaming it.\n * Returns success status and captured output for the caller to handle.\n */\nexport function shellExecCapture(\n command: string,\n options?: { cwd?: string },\n): CapturedExecResult {\n try {\n const result = execaCommandSync(command, {\n cwd: options?.cwd,\n reject: true,\n });\n return { success: true, output: result.stdout, stderr: result.stderr };\n } catch (error) {\n const stderr = (error as { stderr?: string }).stderr ?? \"\";\n const stdout = (error as { stdout?: string }).stdout ?? \"\";\n return {\n success: false,\n output: [stderr, stdout].filter(Boolean).join(\"\\n\"),\n stderr,\n };\n }\n}\n\n/**\n * Check if gcloud CLI is available and authenticated.\n */\nexport function checkGcloudAvailable(): void {\n try {\n execaSync(\"gcloud\", [\"--version\"]);\n } catch {\n consola.error(\n \"gcloud CLI is not installed or not in PATH.\\n\" +\n \"Install it from: https://cloud.google.com/sdk/docs/install\",\n );\n process.exit(1);\n }\n}\n\n/**\n * Check if Docker CLI is available.\n */\nexport function isDockerAvailable(): boolean {\n try {\n execaSync(\"docker\", [\"--version\"]);\n return true;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;;AAaA,SAAgB,WACd,MACA,SACe;AACf,KAAI;EACF,MAAM,SAAS,UAAU,UAAU,CAAC,GAAG,MAAM,gBAAgB,CAAC;AAC9D,SAAO,KAAK,MAAM,OAAO,OAAO;UACzB,OAAO;AACd,MAAI,SAAS,aACX;AAEF,QAAM;;;;;;;AAgDV,SAAgB,kBACd,MACA,SACoB;AACpB,KAAI;EACF,MAAM,SAAS,UAAU,UAAU,MAAM;GACvC,KAAK,SAAS;GACd,QAAQ;GACT,CAAC;AACF,SAAO;GAAE,SAAS;GAAM,QAAQ,OAAO;GAAQ,QAAQ,OAAO;GAAQ;UAC/D,OAAO;EACd,MAAM,SAAU,MAA8B,UAAU;AAExD,SAAO;GACL,SAAS;GACT,QAAQ,CAAC,QAHK,MAA8B,UAAU,GAG9B,CAAC,OAAO,QAAQ,CAAC,KAAK,KAAK;GACnD;GACD;;;;;;;AAQL,SAAgB,iBACd,SACA,SACoB;AACpB,KAAI;EACF,MAAM,SAAS,iBAAiB,SAAS;GACvC,KAAK,SAAS;GACd,QAAQ;GACT,CAAC;AACF,SAAO;GAAE,SAAS;GAAM,QAAQ,OAAO;GAAQ,QAAQ,OAAO;GAAQ;UAC/D,OAAO;EACd,MAAM,SAAU,MAA8B,UAAU;AAExD,SAAO;GACL,SAAS;GACT,QAAQ,CAAC,QAHK,MAA8B,UAAU,GAG9B,CAAC,OAAO,QAAQ,CAAC,KAAK,KAAK;GACnD;GACD;;;;;;AAOL,SAAgB,uBAA6B;AAC3C,KAAI;AACF,YAAU,UAAU,CAAC,YAAY,CAAC;SAC5B;AACN,UAAQ,MACN,0GAED;AACD,UAAQ,KAAK,EAAE;;;;;;AAOnB,SAAgB,oBAA6B;AAC3C,KAAI;AACF,YAAU,UAAU,CAAC,YAAY,CAAC;AAClC,SAAO;SACD;AACN,SAAO"}
1
+ {"version":3,"file":"gcloud.mjs","names":[],"sources":["../../src/cloud/gcloud.ts"],"sourcesContent":["import { execaCommandSync, execaSync } from \"execa\";\nimport { execa } from \"execa\";\nimport { consola } from \"consola\";\n\nexport interface CapturedExecResult {\n success: boolean;\n output: string;\n stderr: string;\n}\n\n/**\n * Execute a gcloud command and return the parsed JSON output.\n * Throws on non-zero exit code unless `ignoreErrors` is set.\n */\nexport function gcloudJson<T = unknown>(\n args: string[],\n options?: { ignoreErrors?: boolean },\n): T | undefined {\n try {\n const result = execaSync(\"gcloud\", [...args, \"--format=json\"]);\n return JSON.parse(result.stdout) as T;\n } catch (error) {\n if (options?.ignoreErrors) {\n return undefined;\n }\n throw error;\n }\n}\n\n/**\n * Execute a gcloud command with stdio inherited (shows output in terminal).\n * Returns whether the command succeeded.\n */\nexport function gcloudExec(\n args: string[],\n options?: { cwd?: string },\n): boolean {\n try {\n execaSync(\"gcloud\", args, {\n stdio: \"inherit\",\n cwd: options?.cwd,\n reject: true,\n });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Execute a shell command with stdio inherited.\n * Uses shell execution for commands that need shell features (like pnpm).\n */\nexport function shellExec(\n command: string,\n options?: { cwd?: string },\n): boolean {\n try {\n execaCommandSync(command, {\n stdio: \"inherit\",\n cwd: options?.cwd,\n reject: true,\n });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Execute a gcloud command and capture all output instead of streaming it.\n * Returns success status and captured stdout/stderr for the caller to handle.\n */\nexport function gcloudExecCapture(\n args: string[],\n options?: { cwd?: string },\n): CapturedExecResult {\n try {\n const result = execaSync(\"gcloud\", args, {\n cwd: options?.cwd,\n reject: true,\n });\n return { success: true, output: result.stdout, stderr: result.stderr };\n } catch (error) {\n const stderr = (error as { stderr?: string }).stderr ?? \"\";\n const stdout = (error as { stdout?: string }).stdout ?? \"\";\n return {\n success: false,\n output: [stderr, stdout].filter(Boolean).join(\"\\n\"),\n stderr,\n };\n }\n}\n\n/**\n * Execute a shell command and capture all output instead of streaming it.\n * Returns success status and captured output for the caller to handle.\n */\nexport function shellExecCapture(\n command: string,\n options?: { cwd?: string },\n): CapturedExecResult {\n try {\n const result = execaCommandSync(command, {\n cwd: options?.cwd,\n reject: true,\n });\n return { success: true, output: result.stdout, stderr: result.stderr };\n } catch (error) {\n const stderr = (error as { stderr?: string }).stderr ?? \"\";\n const stdout = (error as { stdout?: string }).stdout ?? \"\";\n return {\n success: false,\n output: [stderr, stdout].filter(Boolean).join(\"\\n\"),\n stderr,\n };\n }\n}\n\n/**\n * Check if gcloud CLI is available and authenticated.\n */\nexport function checkGcloudAvailable(): void {\n try {\n execaSync(\"gcloud\", [\"--version\"]);\n } catch {\n consola.error(\n \"gcloud CLI is not installed or not in PATH.\\n\" +\n \"Install it from: https://cloud.google.com/sdk/docs/install\",\n );\n process.exit(1);\n }\n}\n\n/**\n * Check if the Docker CLI binary is installed.\n */\nexport function isDockerInstalled(): boolean {\n try {\n execaSync(\"docker\", [\"--version\"]);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Check if the Docker daemon is running by executing `docker info`.\n */\nexport function isDockerDaemonRunning(): boolean {\n try {\n execaSync(\"docker\", [\"info\"], { stdio: \"pipe\" });\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Attempt to start the Docker daemon.\n * - macOS: opens the Docker Desktop application\n * - Linux: starts the docker systemd service\n * - Other platforms: unsupported, returns false\n */\nexport function startDockerDaemon(): boolean {\n try {\n if (process.platform === \"darwin\") {\n execaSync(\"open\", [\"-a\", \"Docker\"]);\n return true;\n }\n\n if (process.platform === \"linux\") {\n execaSync(\"systemctl\", [\"start\", \"docker\"]);\n return true;\n }\n\n return false;\n } catch {\n return false;\n }\n}\n\n/**\n * Poll `docker info` until the daemon is responsive or the timeout is reached.\n * Shows a spinner while waiting.\n *\n * @param timeoutMs - Maximum time to wait in milliseconds (default: 30000)\n * @param intervalMs - Polling interval in milliseconds (default: 2000)\n * @returns true if the daemon became available, false on timeout\n */\nexport async function waitForDockerDaemon(\n timeoutMs = 30_000,\n intervalMs = 2_000,\n): Promise<boolean> {\n if (isDockerDaemonRunning()) return true;\n\n consola.start(\"Waiting for Docker daemon to start...\");\n\n const deadline = Date.now() + timeoutMs;\n\n while (Date.now() < deadline) {\n await new Promise((resolve) => setTimeout(resolve, intervalMs));\n\n try {\n await execa(\"docker\", [\"info\"], { stdio: \"pipe\" });\n consola.success(\"Docker daemon is running\");\n return true;\n } catch {\n /** Daemon not ready yet */\n }\n }\n\n consola.fail(\"Docker daemon did not start in time\");\n return false;\n}\n"],"mappings":";;;;;;;;AAcA,SAAgB,WACd,MACA,SACe;AACf,KAAI;EACF,MAAM,SAAS,UAAU,UAAU,CAAC,GAAG,MAAM,gBAAgB,CAAC;AAC9D,SAAO,KAAK,MAAM,OAAO,OAAO;UACzB,OAAO;AACd,MAAI,SAAS,aACX;AAEF,QAAM;;;;;;;AAgDV,SAAgB,kBACd,MACA,SACoB;AACpB,KAAI;EACF,MAAM,SAAS,UAAU,UAAU,MAAM;GACvC,KAAK,SAAS;GACd,QAAQ;GACT,CAAC;AACF,SAAO;GAAE,SAAS;GAAM,QAAQ,OAAO;GAAQ,QAAQ,OAAO;GAAQ;UAC/D,OAAO;EACd,MAAM,SAAU,MAA8B,UAAU;AAExD,SAAO;GACL,SAAS;GACT,QAAQ,CAAC,QAHK,MAA8B,UAAU,GAG9B,CAAC,OAAO,QAAQ,CAAC,KAAK,KAAK;GACnD;GACD;;;;;;;AAQL,SAAgB,iBACd,SACA,SACoB;AACpB,KAAI;EACF,MAAM,SAAS,iBAAiB,SAAS;GACvC,KAAK,SAAS;GACd,QAAQ;GACT,CAAC;AACF,SAAO;GAAE,SAAS;GAAM,QAAQ,OAAO;GAAQ,QAAQ,OAAO;GAAQ;UAC/D,OAAO;EACd,MAAM,SAAU,MAA8B,UAAU;AAExD,SAAO;GACL,SAAS;GACT,QAAQ,CAAC,QAHK,MAA8B,UAAU,GAG9B,CAAC,OAAO,QAAQ,CAAC,KAAK,KAAK;GACnD;GACD;;;;;;AAOL,SAAgB,uBAA6B;AAC3C,KAAI;AACF,YAAU,UAAU,CAAC,YAAY,CAAC;SAC5B;AACN,UAAQ,MACN,0GAED;AACD,UAAQ,KAAK,EAAE;;;;;;AAOnB,SAAgB,oBAA6B;AAC3C,KAAI;AACF,YAAU,UAAU,CAAC,YAAY,CAAC;AAClC,SAAO;SACD;AACN,SAAO;;;;;;AAOX,SAAgB,wBAAiC;AAC/C,KAAI;AACF,YAAU,UAAU,CAAC,OAAO,EAAE,EAAE,OAAO,QAAQ,CAAC;AAChD,SAAO;SACD;AACN,SAAO;;;;;;;;;AAUX,SAAgB,oBAA6B;AAC3C,KAAI;AACF,MAAI,QAAQ,aAAa,UAAU;AACjC,aAAU,QAAQ,CAAC,MAAM,SAAS,CAAC;AACnC,UAAO;;AAGT,MAAI,QAAQ,aAAa,SAAS;AAChC,aAAU,aAAa,CAAC,SAAS,SAAS,CAAC;AAC3C,UAAO;;AAGT,SAAO;SACD;AACN,SAAO;;;;;;;;;;;AAYX,eAAsB,oBACpB,YAAY,KACZ,aAAa,KACK;AAClB,KAAI,uBAAuB,CAAE,QAAO;AAEpC,SAAQ,MAAM,wCAAwC;CAEtD,MAAM,WAAW,KAAK,KAAK,GAAG;AAE9B,QAAO,KAAK,KAAK,GAAG,UAAU;AAC5B,QAAM,IAAI,SAAS,YAAY,WAAW,SAAS,WAAW,CAAC;AAE/D,MAAI;AACF,SAAM,MAAM,UAAU,CAAC,OAAO,EAAE,EAAE,OAAO,QAAQ,CAAC;AAClD,WAAQ,QAAQ,2BAA2B;AAC3C,UAAO;UACD;;AAKV,SAAQ,KAAK,sCAAsC;AACnD,QAAO"}
package/dist/config.d.mts CHANGED
@@ -3,6 +3,12 @@
3
3
  interface RunnerEnvOptions {
4
4
  /** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */
5
5
  project: string;
6
+ /**
7
+ * Path(s) to .env files to load, resolved relative to the service directory.
8
+ * Variables from these files have lower precedence than explicit `env` values.
9
+ * When multiple files are specified, earlier files take precedence over later ones.
10
+ */
11
+ envFile?: string | string[];
6
12
  /** Additional environment variables to set before the job runs */
7
13
  env?: Record<string, string>;
8
14
  /** Secret names to load from GCP Secret Manager */
@@ -19,6 +25,15 @@ interface CloudResources {
19
25
  /** Maximum number of tasks that can run in parallel. Default: unset (no limit) */
20
26
  parallelism?: number;
21
27
  }
28
+ /** Direct VPC egress configuration for private network access (e.g., Redis) */
29
+ interface CloudNetworkConfig {
30
+ /** VPC network name (e.g., "default") */
31
+ name: string;
32
+ /** VPC subnet name (e.g., "default") */
33
+ subnet?: string;
34
+ /** VPC egress mode. Default: "private-ranges-only" */
35
+ egress?: "all-traffic" | "private-ranges-only";
36
+ }
22
37
  /** Configuration for Cloud Run Jobs execution */
23
38
  interface CloudConfig {
24
39
  /** Cloud Run Job name (e.g., "loads-predictions-jobs") */
@@ -36,6 +51,8 @@ interface CloudConfig {
36
51
  * Requires Docker to be installed and running. Default: true.
37
52
  */
38
53
  buildLocal?: boolean;
54
+ /** Direct VPC egress configuration for private network access */
55
+ network?: CloudNetworkConfig;
39
56
  }
40
57
  /** Full runner configuration provided by each service */
41
58
  interface RunnerConfig {
@@ -67,5 +84,5 @@ declare function defineRunnerConfig(config: RunnerConfig): RunnerConfig;
67
84
  /** Identity function for type-safe environment definition */
68
85
  declare function defineRunnerEnv(options: RunnerEnvOptions): RunnerEnvOptions;
69
86
  //#endregion
70
- export { CloudConfig, CloudResources, RunnerConfig, RunnerEnvOptions, defineRunnerConfig, defineRunnerEnv };
87
+ export { CloudConfig, CloudNetworkConfig, CloudResources, RunnerConfig, RunnerEnvOptions, defineRunnerConfig, defineRunnerEnv };
71
88
  //# sourceMappingURL=config.d.mts.map
@@ -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 /** 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/** 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}\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":";;AAoEA,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\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"}
@@ -0,0 +1,28 @@
1
+ import { parseEnv } from "node:util";
2
+ import path from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+
5
+ //#region src/env-file.ts
6
+ /**
7
+ * Parse one or more .env files and return the merged key-value pairs.
8
+ *
9
+ * Files are processed in order. Earlier files take precedence over later ones
10
+ * (first-wins), matching the dotenv convention.
11
+ */
12
+ function parseEnvFiles(envFile, cwd) {
13
+ if (!envFile) return {};
14
+ const files = Array.isArray(envFile) ? envFile : [envFile];
15
+ const baseDir = cwd ?? process.cwd();
16
+ const result = {};
17
+ for (const file of files) {
18
+ const filePath = path.resolve(baseDir, file);
19
+ if (!existsSync(filePath)) throw new Error(`Environment file not found: ${file}\nResolved path: ${filePath}`);
20
+ const parsed = parseEnv(readFileSync(filePath, "utf-8"));
21
+ for (const [key, value] of Object.entries(parsed)) if (value !== void 0 && !Object.hasOwn(result, key)) result[key] = value;
22
+ }
23
+ return result;
24
+ }
25
+
26
+ //#endregion
27
+ export { parseEnvFiles };
28
+ //# sourceMappingURL=env-file.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env-file.mjs","names":[],"sources":["../src/env-file.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { parseEnv } from \"node:util\";\n\n/**\n * Parse one or more .env files and return the merged key-value pairs.\n *\n * Files are processed in order. Earlier files take precedence over later ones\n * (first-wins), matching the dotenv convention.\n */\nexport function parseEnvFiles(\n envFile: string | string[] | undefined,\n cwd?: string,\n): Record<string, string> {\n if (!envFile) return {};\n\n const files = Array.isArray(envFile) ? envFile : [envFile];\n const baseDir = cwd ?? process.cwd();\n const result: Record<string, string> = {};\n\n for (const file of files) {\n const filePath = path.resolve(baseDir, file);\n\n if (!existsSync(filePath)) {\n throw new Error(\n `Environment file not found: ${file}\\nResolved path: ${filePath}`,\n );\n }\n\n const content = readFileSync(filePath, \"utf-8\");\n const parsed = parseEnv(content);\n\n for (const [key, value] of Object.entries(parsed)) {\n if (value !== undefined && !Object.hasOwn(result, key)) {\n result[key] = value;\n }\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;AAUA,SAAgB,cACd,SACA,KACwB;AACxB,KAAI,CAAC,QAAS,QAAO,EAAE;CAEvB,MAAM,QAAQ,MAAM,QAAQ,QAAQ,GAAG,UAAU,CAAC,QAAQ;CAC1D,MAAM,UAAU,OAAO,QAAQ,KAAK;CACpC,MAAM,SAAiC,EAAE;AAEzC,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,WAAW,KAAK,QAAQ,SAAS,KAAK;AAE5C,MAAI,CAAC,WAAW,SAAS,CACvB,OAAM,IAAI,MACR,+BAA+B,KAAK,mBAAmB,WACxD;EAIH,MAAM,SAAS,SADC,aAAa,UAAU,QAAQ,CACf;AAEhC,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,CAC/C,KAAI,UAAU,UAAa,CAAC,OAAO,OAAO,QAAQ,IAAI,CACpD,QAAO,OAAO;;AAKpB,QAAO"}
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
- import { CloudConfig, CloudResources, RunnerConfig, RunnerEnvOptions, defineRunnerConfig, defineRunnerEnv } from "./config.mjs";
1
+ import { CloudConfig, CloudNetworkConfig, CloudResources, RunnerConfig, RunnerEnvOptions, defineRunnerConfig, defineRunnerEnv } from "./config.mjs";
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
5
  import { FieldInfo, extractFieldInfo, formatZodError, generateSchemaHelp, schemaToParseArgsOptions } from "./help.mjs";
6
6
  import { TaskContext, getTaskContext } from "./task-context.mjs";
7
7
  import { runJob } from "./run-job.mjs";
8
- export { type CloudConfig, 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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp-job-runner",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Run schema-driven Cloud Run jobs seamlessly in any environment",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,19 +44,19 @@
44
44
  "@google-cloud/secret-manager": "^6.1.1",
45
45
  "consola": "^3.4.0",
46
46
  "execa": "^9.6.1",
47
- "isolate-package": "1.27.0",
47
+ "isolate-package": "1.28.2",
48
48
  "zod": "^4.3.6"
49
49
  },
50
50
  "devDependencies": {
51
- "@codecompose/typescript-config": "2.3.0",
52
- "@types/node": "^25.0.10",
51
+ "@codecompose/typescript-config": "3.0.0",
52
+ "@types/node": "^25.3.0",
53
53
  "del-cli": "^7.0.0",
54
54
  "husky": "^9.1.7",
55
55
  "lint-staged": "^16.2.7",
56
- "oxfmt": "^0.32.0",
57
- "oxlint": "^1.47.0",
56
+ "oxfmt": "^0.34.0",
57
+ "oxlint": "^1.49.0",
58
58
  "tsdown": "^0.20.1",
59
- "typescript": "^5.9.3",
59
+ "typescript": "6.0.0-beta",
60
60
  "vitepress": "^1.6.4",
61
61
  "vitest": "^4.0.18"
62
62
  },
@@ -70,7 +70,7 @@
70
70
  ]
71
71
  },
72
72
  "engines": {
73
- "node": ">=22.0.0"
73
+ "node": ">=22.6.0"
74
74
  },
75
75
  "packageManager": "pnpm@10.22.0"
76
76
  }
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import { discoverJobs } from "./discover-jobs";
17
17
  import { promptForArgs, selectJob } from "./interactive";
18
18
  import { runJob } from "./run-job";
19
19
  import { getSecrets } from "./secrets";
20
+ import { parseEnvFiles } from "./env-file";
20
21
  import type { JobFunction } from "./types";
21
22
 
22
23
  const BIN_NAME = "job";
@@ -295,16 +296,25 @@ async function handleLocalRun(options: LocalRunOptions): Promise<void> {
295
296
 
296
297
  /** Set environment variables for local execution */
297
298
  process.env.NODE_ENV ??= "development";
298
- process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
299
299
  process.env.USE_CONSOLE_LOG ??= "true";
300
300
  process.env.LOG_COLORIZE ??= "true";
301
301
 
302
+ /** Load envFile variables (don't overwrite existing process.env values) */
303
+ const envFileVars = parseEnvFiles(envConfig.envFile);
304
+ for (const [key, value] of Object.entries(envFileVars)) {
305
+ process.env[key] ??= value;
306
+ }
307
+
308
+ /** Explicit env values override envFile values */
302
309
  if (envConfig.env) {
303
310
  for (const [key, value] of Object.entries(envConfig.env)) {
304
311
  process.env[key] = value;
305
312
  }
306
313
  }
307
314
 
315
+ /** Project always takes highest precedence */
316
+ process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
317
+
308
318
  if (envConfig.secrets && envConfig.secrets.length > 0) {
309
319
  const secrets = await getSecrets(envConfig.secrets);
310
320
  for (const [key, value] of Object.entries(secrets)) {
@@ -2,10 +2,14 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { consola } from "consola";
4
4
  import type { CloudConfig, RunnerEnvOptions } from "../config";
5
+ import { parseEnvFiles } from "../env-file";
5
6
  import { generateDockerfile } from "./dockerfile";
6
7
  import {
7
8
  checkGcloudAvailable,
8
- isDockerAvailable,
9
+ isDockerInstalled,
10
+ isDockerDaemonRunning,
11
+ startDockerDaemon,
12
+ waitForDockerDaemon,
9
13
  gcloudExecCapture,
10
14
  gcloudJson,
11
15
  shellExecCapture,
@@ -93,12 +97,67 @@ export async function prepareImage(
93
97
 
94
98
  checkGcloudAvailable();
95
99
 
96
- if (buildLocal && !isDockerAvailable()) {
97
- consola.warn(
98
- "Docker is not available, falling back to Cloud Build. " +
99
- "Install Docker for faster local builds: https://docs.docker.com/get-docker/",
100
- );
101
- buildLocal = false;
100
+ if (buildLocal) {
101
+ if (!isDockerInstalled()) {
102
+ consola.warn(
103
+ "Docker is not installed, falling back to Cloud Build. " +
104
+ "Install Docker for faster local builds: https://docs.docker.com/get-docker/",
105
+ );
106
+ buildLocal = false;
107
+ } else if (!isDockerDaemonRunning()) {
108
+ if (!process.stdin.isTTY) {
109
+ /** Non-interactive environment (CI) — fall back automatically */
110
+ consola.warn(
111
+ "Docker daemon is not running, falling back to Cloud Build.",
112
+ );
113
+ buildLocal = false;
114
+ } else {
115
+ const choice = await consola.prompt(
116
+ "Docker is installed but the daemon is not running.",
117
+ {
118
+ type: "select",
119
+ options: [
120
+ {
121
+ label: "Start Docker",
122
+ value: "start",
123
+ hint: "attempt to start the daemon",
124
+ },
125
+ {
126
+ label: "Use Cloud Build",
127
+ value: "cloud-build",
128
+ hint: "build remotely instead",
129
+ },
130
+ ],
131
+ },
132
+ );
133
+
134
+ if (typeof choice === "symbol") {
135
+ process.exit(0);
136
+ }
137
+
138
+ if (choice === "start") {
139
+ const started = startDockerDaemon();
140
+
141
+ if (!started) {
142
+ consola.warn(
143
+ "Could not start Docker automatically, falling back to Cloud Build.",
144
+ );
145
+ buildLocal = false;
146
+ } else {
147
+ const ready = await waitForDockerDaemon();
148
+
149
+ if (!ready) {
150
+ consola.warn(
151
+ "Docker daemon did not become ready in time, falling back to Cloud Build.",
152
+ );
153
+ buildLocal = false;
154
+ }
155
+ }
156
+ } else {
157
+ buildLocal = false;
158
+ }
159
+ }
160
+ }
102
161
  }
103
162
 
104
163
  /** Step 1: Run isolate to bundle workspace dependencies */
@@ -301,10 +360,12 @@ export async function createOrUpdateJob(
301
360
  { ignoreErrors: true },
302
361
  );
303
362
 
304
- /** Build environment variables */
363
+ /** Build environment variables (envFile < env < project) */
364
+ const envFileVars = parseEnvFiles(envConfig.envFile);
305
365
  const envVars: Record<string, string> = {
306
- GOOGLE_CLOUD_PROJECT: project,
366
+ ...envFileVars,
307
367
  ...envConfig.env,
368
+ GOOGLE_CLOUD_PROJECT: project,
308
369
  };
309
370
 
310
371
  const envVarsString = Object.entries(envVars)
@@ -351,6 +412,16 @@ export async function createOrUpdateJob(
351
412
  updateArgs.push(`--service-account=${cloud.serviceAccount}`);
352
413
  }
353
414
 
415
+ if (cloud.network) {
416
+ updateArgs.push(`--network=${cloud.network.name}`);
417
+ if (cloud.network.subnet) {
418
+ updateArgs.push(`--subnet=${cloud.network.subnet}`);
419
+ }
420
+ updateArgs.push(
421
+ `--vpc-egress=${cloud.network.egress ?? "private-ranges-only"}`,
422
+ );
423
+ }
424
+
354
425
  const result = gcloudExecCapture(updateArgs);
355
426
 
356
427
  if (!result.success) {
@@ -398,6 +469,16 @@ export async function createOrUpdateJob(
398
469
  createArgs.push(`--service-account=${cloud.serviceAccount}`);
399
470
  }
400
471
 
472
+ if (cloud.network) {
473
+ createArgs.push(`--network=${cloud.network.name}`);
474
+ if (cloud.network.subnet) {
475
+ createArgs.push(`--subnet=${cloud.network.subnet}`);
476
+ }
477
+ createArgs.push(
478
+ `--vpc-egress=${cloud.network.egress ?? "private-ranges-only"}`,
479
+ );
480
+ }
481
+
401
482
  const result = gcloudExecCapture(createArgs);
402
483
 
403
484
  if (!result.success) {
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it, vi, beforeEach, type Mock } from "vitest";
2
+ import { consola } from "consola";
3
+
4
+ /**
5
+ * Mock all external dependencies so we can test the Docker detection
6
+ * and fallback branching in prepareImage() without Docker or gcloud.
7
+ */
8
+ vi.mock("consola", () => ({
9
+ consola: {
10
+ warn: vi.fn(),
11
+ start: vi.fn(),
12
+ success: vi.fn(),
13
+ info: vi.fn(),
14
+ error: vi.fn(),
15
+ prompt: vi.fn(),
16
+ },
17
+ }));
18
+
19
+ vi.mock("./gcloud", () => ({
20
+ checkGcloudAvailable: vi.fn(),
21
+ isDockerInstalled: vi.fn(() => true),
22
+ isDockerDaemonRunning: vi.fn(() => true),
23
+ startDockerDaemon: vi.fn(() => true),
24
+ waitForDockerDaemon: vi.fn(async () => true),
25
+ gcloudJson: vi.fn(),
26
+ gcloudExecCapture: vi.fn(() => ({ success: true, output: "", stderr: "" })),
27
+ shellExecCapture: vi.fn(() => ({ success: true, output: "", stderr: "" })),
28
+ }));
29
+
30
+ vi.mock("isolate-package", () => ({
31
+ isolate: vi.fn(async () => {}),
32
+ }));
33
+
34
+ vi.mock("./dockerfile", () => ({
35
+ generateDockerfile: vi.fn(() => "FROM node:22"),
36
+ }));
37
+
38
+ vi.mock("./hash", () => ({
39
+ hashDirectory: vi.fn(async () => "abc123"),
40
+ }));
41
+
42
+ vi.mock("node:fs", async () => {
43
+ const actual = await vi.importActual("node:fs");
44
+ return {
45
+ ...actual,
46
+ existsSync: vi.fn(() => false),
47
+ readFileSync: vi.fn(() => "{}"),
48
+ writeFileSync: vi.fn(),
49
+ unlinkSync: vi.fn(),
50
+ };
51
+ });
52
+
53
+ import {
54
+ isDockerInstalled,
55
+ isDockerDaemonRunning,
56
+ startDockerDaemon,
57
+ waitForDockerDaemon,
58
+ gcloudJson,
59
+ } from "./gcloud";
60
+ import { prepareImage, type DeployOptions } from "./deploy";
61
+
62
+ const defaultOptions: DeployOptions = {
63
+ cloud: { name: "test-job", buildLocal: true },
64
+ envConfig: { project: "test-project" },
65
+ serviceDirectory: "/tmp/test-service",
66
+ };
67
+
68
+ /** Stub gcloudJson to report no existing image */
69
+ function stubNoExistingImage() {
70
+ (gcloudJson as Mock).mockReturnValue(undefined);
71
+ }
72
+
73
+ describe("prepareImage Docker fallback", () => {
74
+ beforeEach(() => {
75
+ vi.restoreAllMocks();
76
+ /** Defaults: Docker installed, daemon running, no existing image */
77
+ (isDockerInstalled as Mock).mockReturnValue(true);
78
+ (isDockerDaemonRunning as Mock).mockReturnValue(true);
79
+ (startDockerDaemon as Mock).mockReturnValue(true);
80
+ (waitForDockerDaemon as Mock).mockResolvedValue(true);
81
+ stubNoExistingImage();
82
+ });
83
+
84
+ it("falls back to Cloud Build with warning when Docker is not installed", async () => {
85
+ (isDockerInstalled as Mock).mockReturnValue(false);
86
+
87
+ await prepareImage(defaultOptions);
88
+
89
+ expect(consola.warn).toHaveBeenCalledWith(
90
+ expect.stringContaining("Docker is not installed"),
91
+ );
92
+ expect(isDockerDaemonRunning).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("builds locally when Docker is installed and daemon is running", async () => {
96
+ await prepareImage(defaultOptions);
97
+
98
+ expect(consola.warn).not.toHaveBeenCalled();
99
+ expect(consola.prompt).not.toHaveBeenCalled();
100
+ });
101
+
102
+ describe("daemon not running, non-interactive (no TTY)", () => {
103
+ beforeEach(() => {
104
+ (isDockerDaemonRunning as Mock).mockReturnValue(false);
105
+ Object.defineProperty(process.stdin, "isTTY", {
106
+ value: false,
107
+ configurable: true,
108
+ });
109
+ });
110
+
111
+ it("falls back to Cloud Build without prompting", async () => {
112
+ await prepareImage(defaultOptions);
113
+
114
+ expect(consola.prompt).not.toHaveBeenCalled();
115
+ expect(consola.warn).toHaveBeenCalledWith(
116
+ expect.stringContaining("falling back to Cloud Build"),
117
+ );
118
+ });
119
+ });
120
+
121
+ describe("daemon not running, interactive (TTY)", () => {
122
+ beforeEach(() => {
123
+ (isDockerDaemonRunning as Mock).mockReturnValue(false);
124
+ Object.defineProperty(process.stdin, "isTTY", {
125
+ value: true,
126
+ configurable: true,
127
+ });
128
+ });
129
+
130
+ it("prompts the user when daemon is not running", async () => {
131
+ (consola.prompt as Mock).mockResolvedValue("cloud-build");
132
+
133
+ await prepareImage(defaultOptions);
134
+
135
+ expect(consola.prompt).toHaveBeenCalledWith(
136
+ expect.stringContaining("daemon is not running"),
137
+ expect.objectContaining({ type: "select" }),
138
+ );
139
+ });
140
+
141
+ it("falls back to Cloud Build when user chooses cloud-build", async () => {
142
+ (consola.prompt as Mock).mockResolvedValue("cloud-build");
143
+
144
+ await prepareImage(defaultOptions);
145
+
146
+ expect(startDockerDaemon).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("starts Docker and waits when user chooses start", async () => {
150
+ (consola.prompt as Mock).mockResolvedValue("start");
151
+
152
+ await prepareImage(defaultOptions);
153
+
154
+ expect(startDockerDaemon).toHaveBeenCalled();
155
+ expect(waitForDockerDaemon).toHaveBeenCalled();
156
+ });
157
+
158
+ it("falls back to Cloud Build when Docker fails to start", async () => {
159
+ (consola.prompt as Mock).mockResolvedValue("start");
160
+ (startDockerDaemon as Mock).mockReturnValue(false);
161
+
162
+ await prepareImage(defaultOptions);
163
+
164
+ expect(consola.warn).toHaveBeenCalledWith(
165
+ expect.stringContaining("Could not start Docker automatically"),
166
+ );
167
+ expect(waitForDockerDaemon).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it("falls back to Cloud Build when daemon does not become ready in time", async () => {
171
+ (consola.prompt as Mock).mockResolvedValue("start");
172
+ (waitForDockerDaemon as Mock).mockResolvedValue(false);
173
+
174
+ await prepareImage(defaultOptions);
175
+
176
+ expect(consola.warn).toHaveBeenCalledWith(
177
+ expect.stringContaining("did not become ready in time"),
178
+ );
179
+ });
180
+
181
+ it("exits when user cancels the prompt", async () => {
182
+ (consola.prompt as Mock).mockResolvedValue(Symbol("cancel"));
183
+
184
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
185
+ throw new Error("process.exit");
186
+ });
187
+
188
+ await expect(prepareImage(defaultOptions)).rejects.toThrow(
189
+ "process.exit",
190
+ );
191
+
192
+ expect(exitSpy).toHaveBeenCalledWith(0);
193
+ });
194
+ });
195
+ });
@@ -1,4 +1,5 @@
1
1
  import { execaCommandSync, execaSync } from "execa";
2
+ import { execa } from "execa";
2
3
  import { consola } from "consola";
3
4
 
4
5
  export interface CapturedExecResult {
@@ -132,9 +133,9 @@ export function checkGcloudAvailable(): void {
132
133
  }
133
134
 
134
135
  /**
135
- * Check if Docker CLI is available.
136
+ * Check if the Docker CLI binary is installed.
136
137
  */
137
- export function isDockerAvailable(): boolean {
138
+ export function isDockerInstalled(): boolean {
138
139
  try {
139
140
  execaSync("docker", ["--version"]);
140
141
  return true;
@@ -142,3 +143,73 @@ export function isDockerAvailable(): boolean {
142
143
  return false;
143
144
  }
144
145
  }
146
+
147
+ /**
148
+ * Check if the Docker daemon is running by executing `docker info`.
149
+ */
150
+ export function isDockerDaemonRunning(): boolean {
151
+ try {
152
+ execaSync("docker", ["info"], { stdio: "pipe" });
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Attempt to start the Docker daemon.
161
+ * - macOS: opens the Docker Desktop application
162
+ * - Linux: starts the docker systemd service
163
+ * - Other platforms: unsupported, returns false
164
+ */
165
+ export function startDockerDaemon(): boolean {
166
+ try {
167
+ if (process.platform === "darwin") {
168
+ execaSync("open", ["-a", "Docker"]);
169
+ return true;
170
+ }
171
+
172
+ if (process.platform === "linux") {
173
+ execaSync("systemctl", ["start", "docker"]);
174
+ return true;
175
+ }
176
+
177
+ return false;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Poll `docker info` until the daemon is responsive or the timeout is reached.
185
+ * Shows a spinner while waiting.
186
+ *
187
+ * @param timeoutMs - Maximum time to wait in milliseconds (default: 30000)
188
+ * @param intervalMs - Polling interval in milliseconds (default: 2000)
189
+ * @returns true if the daemon became available, false on timeout
190
+ */
191
+ export async function waitForDockerDaemon(
192
+ timeoutMs = 30_000,
193
+ intervalMs = 2_000,
194
+ ): Promise<boolean> {
195
+ if (isDockerDaemonRunning()) return true;
196
+
197
+ consola.start("Waiting for Docker daemon to start...");
198
+
199
+ const deadline = Date.now() + timeoutMs;
200
+
201
+ while (Date.now() < deadline) {
202
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
203
+
204
+ try {
205
+ await execa("docker", ["info"], { stdio: "pipe" });
206
+ consola.success("Docker daemon is running");
207
+ return true;
208
+ } catch {
209
+ /** Daemon not ready yet */
210
+ }
211
+ }
212
+
213
+ consola.fail("Docker daemon did not start in time");
214
+ return false;
215
+ }
package/src/config.ts CHANGED
@@ -2,6 +2,12 @@
2
2
  export interface RunnerEnvOptions {
3
3
  /** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */
4
4
  project: string;
5
+ /**
6
+ * Path(s) to .env files to load, resolved relative to the service directory.
7
+ * Variables from these files have lower precedence than explicit `env` values.
8
+ * When multiple files are specified, earlier files take precedence over later ones.
9
+ */
10
+ envFile?: string | string[];
5
11
  /** Additional environment variables to set before the job runs */
6
12
  env?: Record<string, string>;
7
13
  /** Secret names to load from GCP Secret Manager */
@@ -20,6 +26,16 @@ export interface CloudResources {
20
26
  parallelism?: number;
21
27
  }
22
28
 
29
+ /** Direct VPC egress configuration for private network access (e.g., Redis) */
30
+ export interface CloudNetworkConfig {
31
+ /** VPC network name (e.g., "default") */
32
+ name: string;
33
+ /** VPC subnet name (e.g., "default") */
34
+ subnet?: string;
35
+ /** VPC egress mode. Default: "private-ranges-only" */
36
+ egress?: "all-traffic" | "private-ranges-only";
37
+ }
38
+
23
39
  /** Configuration for Cloud Run Jobs execution */
24
40
  export interface CloudConfig {
25
41
  /** Cloud Run Job name (e.g., "loads-predictions-jobs") */
@@ -37,6 +53,8 @@ export interface CloudConfig {
37
53
  * Requires Docker to be installed and running. Default: true.
38
54
  */
39
55
  buildLocal?: boolean;
56
+ /** Direct VPC egress configuration for private network access */
57
+ network?: CloudNetworkConfig;
40
58
  }
41
59
 
42
60
  /** Full runner configuration provided by each service */
@@ -0,0 +1,86 @@
1
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { parseEnvFiles } from "./env-file";
6
+
7
+ describe("parseEnvFiles", () => {
8
+ let tempDir: string;
9
+
10
+ beforeAll(() => {
11
+ tempDir = mkdtempSync(path.join(os.tmpdir(), "env-file-test-"));
12
+
13
+ writeFileSync(
14
+ path.join(tempDir, ".env.stag"),
15
+ [
16
+ "# Staging config",
17
+ "API_URL=https://api.staging.example.com",
18
+ "DB_HOST=staging-db",
19
+ "SHARED=from-stag",
20
+ "",
21
+ "EMPTY_VALUE=",
22
+ ].join("\n"),
23
+ );
24
+
25
+ writeFileSync(
26
+ path.join(tempDir, ".env.prod"),
27
+ [
28
+ "API_URL=https://api.example.com",
29
+ "DB_HOST=prod-db",
30
+ "SHARED=from-prod",
31
+ ].join("\n"),
32
+ );
33
+
34
+ writeFileSync(
35
+ path.join(tempDir, ".env.local"),
36
+ ["SHARED=from-local", "LOCAL_ONLY=secret"].join("\n"),
37
+ );
38
+ });
39
+
40
+ afterAll(() => {
41
+ rmSync(tempDir, { recursive: true });
42
+ });
43
+
44
+ it("returns empty object when envFile is undefined", () => {
45
+ expect(parseEnvFiles(undefined)).toEqual({});
46
+ });
47
+
48
+ it("parses a single env file", () => {
49
+ const result = parseEnvFiles(".env.stag", tempDir);
50
+
51
+ expect(result).toEqual({
52
+ API_URL: "https://api.staging.example.com",
53
+ DB_HOST: "staging-db",
54
+ SHARED: "from-stag",
55
+ EMPTY_VALUE: "",
56
+ });
57
+ });
58
+
59
+ it("applies first-wins precedence across multiple files", () => {
60
+ const result = parseEnvFiles([".env.local", ".env.stag"], tempDir);
61
+
62
+ expect(result.SHARED).toBe("from-local");
63
+ expect(result.LOCAL_ONLY).toBe("secret");
64
+ expect(result.API_URL).toBe("https://api.staging.example.com");
65
+ });
66
+
67
+ it("throws when a file does not exist", () => {
68
+ expect(() => parseEnvFiles(".env.missing", tempDir)).toThrowError(
69
+ /Environment file not found: .env.missing/,
70
+ );
71
+ });
72
+
73
+ it("handles comments and blank lines", () => {
74
+ const result = parseEnvFiles(".env.stag", tempDir);
75
+
76
+ expect(Object.keys(result)).not.toContain("#");
77
+ expect(result.API_URL).toBe("https://api.staging.example.com");
78
+ });
79
+
80
+ it("accepts a single string instead of an array", () => {
81
+ const result = parseEnvFiles(".env.prod", tempDir);
82
+
83
+ expect(result.API_URL).toBe("https://api.example.com");
84
+ expect(result.DB_HOST).toBe("prod-db");
85
+ });
86
+ });
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseEnv } from "node:util";
4
+
5
+ /**
6
+ * Parse one or more .env files and return the merged key-value pairs.
7
+ *
8
+ * Files are processed in order. Earlier files take precedence over later ones
9
+ * (first-wins), matching the dotenv convention.
10
+ */
11
+ export function parseEnvFiles(
12
+ envFile: string | string[] | undefined,
13
+ cwd?: string,
14
+ ): Record<string, string> {
15
+ if (!envFile) return {};
16
+
17
+ const files = Array.isArray(envFile) ? envFile : [envFile];
18
+ const baseDir = cwd ?? process.cwd();
19
+ const result: Record<string, string> = {};
20
+
21
+ for (const file of files) {
22
+ const filePath = path.resolve(baseDir, file);
23
+
24
+ if (!existsSync(filePath)) {
25
+ throw new Error(
26
+ `Environment file not found: ${file}\nResolved path: ${filePath}`,
27
+ );
28
+ }
29
+
30
+ const content = readFileSync(filePath, "utf-8");
31
+ const parsed = parseEnv(content);
32
+
33
+ for (const [key, value] of Object.entries(parsed)) {
34
+ if (value !== undefined && !Object.hasOwn(result, key)) {
35
+ result[key] = value;
36
+ }
37
+ }
38
+ }
39
+
40
+ return result;
41
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export { runJob } from "./run-job";
13
13
  export type { TaskContext } from "./task-context";
14
14
  export type {
15
15
  CloudConfig,
16
+ CloudNetworkConfig,
16
17
  CloudResources,
17
18
  RunnerConfig,
18
19
  RunnerEnvOptions,