gcp-job-runner 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # gcp-job-runner
2
2
 
3
- Run schema-driven Cloud Run jobs seamlessly in any environment.
3
+ Run jobs seamlessly on your local machine and on Cloud Run. Simple code, zero boilerplate.
4
4
 
5
5
  ## Quick Look
6
6
 
package/dist/cli.mjs CHANGED
@@ -19,13 +19,52 @@ const DEFAULT_JOBS_DIRECTORY = "dist/jobs";
19
19
  const USAGE = `Usage: ${BIN_NAME} local run <env> <job-name> [options]
20
20
  ${BIN_NAME} cloud run <env> <job-name> [options]
21
21
  ${BIN_NAME} cloud deploy <env>
22
- ${BIN_NAME} --list`;
22
+ ${BIN_NAME} --list
23
+
24
+ Cloud run options:
25
+ --tasks <n> Number of parallel tasks for this execution
26
+ --parallelism <n> Max concurrent tasks (sets job resource default)`;
27
+ function validateInteger(value, flagName) {
28
+ if (Number.isNaN(value) || !Number.isInteger(value) || value < 0) {
29
+ consola.error(`Invalid value for ${flagName}: expected a non-negative integer`);
30
+ process.exit(1);
31
+ }
32
+ return value;
33
+ }
34
+ /**
35
+ * Extract a numeric flag value from args.
36
+ * Supports both `--flag N` and `--flag=N` syntax.
37
+ * Returns undefined if the flag is not present.
38
+ */
39
+ function extractNumberFlag(args, flagName) {
40
+ for (let i = 0; i < args.length; i++) {
41
+ const arg = args[i];
42
+ if (arg === flagName) {
43
+ const next = args[i + 1];
44
+ if (next === void 0 || next.startsWith("-")) {
45
+ consola.error(`${flagName} requires a value`);
46
+ process.exit(1);
47
+ }
48
+ return validateInteger(Number(next), flagName);
49
+ }
50
+ if (arg.startsWith(`${flagName}=`)) return validateInteger(Number(arg.slice(flagName.length + 1)), flagName);
51
+ }
52
+ }
23
53
  async function main() {
24
54
  const args = process.argv.slice(2);
25
55
  /** Extract flags from anywhere in args */
26
56
  const noBuild = args.includes("--no-build");
27
57
  const isInteractive = args.includes("--interactive") || args.includes("-i");
28
58
  const isAsync = args.includes("--async");
59
+ /** Determine mode early so cloud-only flags are only parsed in cloud mode */
60
+ const mode = args.find((arg) => !arg.startsWith("-"));
61
+ const isCloudMode = mode === "cloud";
62
+ const tasks = isCloudMode ? extractNumberFlag(args, "--tasks") : void 0;
63
+ if (tasks === 0) {
64
+ consola.error("--tasks must be at least 1");
65
+ process.exit(1);
66
+ }
67
+ const parallelism = isCloudMode ? extractNumberFlag(args, "--parallelism") : void 0;
29
68
  const configPath = path.resolve(process.cwd(), CONFIG_FILE);
30
69
  /** Load config directly (config should only depend on gcp-job-runner) */
31
70
  let config;
@@ -50,9 +89,16 @@ async function main() {
50
89
  }
51
90
  return;
52
91
  }
53
- /** Parse positional arguments */
54
- const positionals = args.filter((arg) => !arg.startsWith("-"));
55
- const mode = positionals[0];
92
+ /**
93
+ * Parse positional arguments. In cloud mode, skip values consumed by
94
+ * numeric flags like `--tasks 5` so that `5` is not treated as a positional.
95
+ */
96
+ const consumedIndices = /* @__PURE__ */ new Set();
97
+ if (isCloudMode) for (let i = 0; i < args.length; i++) {
98
+ const arg = args[i];
99
+ if ((arg === "--tasks" || arg === "--parallelism") && i + 1 < args.length) consumedIndices.add(i + 1);
100
+ }
101
+ const positionals = args.filter((arg, index) => !arg.startsWith("-") && !consumedIndices.has(index));
56
102
  if (mode !== "local" && mode !== "cloud") {
57
103
  consola.error(`Unknown or missing mode "${mode ?? ""}".\n\n${USAGE}\n\nEnvironments: ${envNames.join(", ")}`);
58
104
  process.exit(1);
@@ -98,8 +144,24 @@ async function main() {
98
144
  "--async",
99
145
  "--list"
100
146
  ]);
147
+ /** --tasks and --parallelism are cloud-only; don't strip them in local mode */
148
+ if (mode === "cloud") {
149
+ globalFlags.add("--tasks");
150
+ globalFlags.add("--parallelism");
151
+ }
101
152
  const envIndex = args.indexOf(envName);
102
- const jobFlags = args.slice(envIndex + 1).filter((arg) => !consumedPositionals.has(arg) && !globalFlags.has(arg));
153
+ const jobFlags = args.slice(envIndex + 1).filter((arg, index, arr) => {
154
+ if (consumedPositionals.has(arg)) return false;
155
+ if (globalFlags.has(arg)) return false;
156
+ if (mode === "cloud") {
157
+ /** Filter out `--flag=value` forms of number flags */
158
+ if (arg.startsWith("--tasks=") || arg.startsWith("--parallelism=")) return false;
159
+ /** Filter out values that follow --tasks or --parallelism */
160
+ const previous = arr[index - 1];
161
+ if (previous === "--tasks" || previous === "--parallelism") return false;
162
+ }
163
+ return true;
164
+ });
103
165
  /** Build unless skipped */
104
166
  if (!noBuild && config.buildCommand !== false) runBuild(config.buildCommand ?? DEFAULT_BUILD_COMMAND);
105
167
  if (mode === "local") await handleLocalRun({
@@ -122,7 +184,9 @@ async function main() {
122
184
  jobNameFromArgs,
123
185
  jobFlags,
124
186
  isInteractive,
125
- isAsync
187
+ isAsync,
188
+ tasks,
189
+ parallelism
126
190
  });
127
191
  }
128
192
  async function handleLocalRun(options) {
@@ -191,12 +255,17 @@ async function handleCloudDeploy(options) {
191
255
  consola.success("Deploy complete");
192
256
  }
193
257
  async function handleCloudRun(options) {
194
- const { config, envConfig, jobsDirectory, jobNameFromArgs, jobFlags, isInteractive, isAsync } = options;
258
+ const { config, envConfig, jobsDirectory, jobNameFromArgs, jobFlags, isInteractive, isAsync, tasks, parallelism } = options;
195
259
  const cloud = config.cloud;
196
260
  if (!cloud) {
197
261
  consola.error("No cloud configuration found in runner config.\nAdd a `cloud` section to your job-runner.config.ts");
198
262
  process.exit(1);
199
263
  }
264
+ /** Override parallelism from CLI flag */
265
+ if (parallelism !== void 0) cloud.resources = {
266
+ ...cloud.resources,
267
+ parallelism
268
+ };
200
269
  const serviceDirectory = process.cwd();
201
270
  /** Determine job name and argv */
202
271
  let jobArgv;
@@ -234,7 +303,8 @@ async function handleCloudRun(options) {
234
303
  region,
235
304
  project: envConfig.project,
236
305
  jobArgv,
237
- async: isAsync
306
+ async: isAsync,
307
+ tasks
238
308
  });
239
309
  }
240
310
  /**
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\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 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 /** Parse positional arguments */\n const positionals = args.filter((arg) => !arg.startsWith(\"-\"));\n\n const mode = positionals[0];\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 const envIndex = args.indexOf(envName);\n const jobFlags = args\n .slice(envIndex + 1)\n .filter((arg) => !consumedPositionals.has(arg) && !globalFlags.has(arg));\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 });\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}\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 } = 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 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 });\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 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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"}
@@ -201,6 +201,11 @@ async function createOrUpdateJob(options) {
201
201
  `--task-timeout=${timeout}s`,
202
202
  "--max-retries=0"
203
203
  ];
204
+ /**
205
+ * Always pass --parallelism on update so that removing it from config
206
+ * resets the deployed value. Default 0 means no concurrency limit.
207
+ */
208
+ updateArgs.push(`--parallelism=${cloud.resources?.parallelism ?? 0}`);
204
209
  if (secretsString) updateArgs.push(`--set-secrets=${secretsString}`);
205
210
  if (cloud.serviceAccount) updateArgs.push(`--service-account=${cloud.serviceAccount}`);
206
211
  const result = gcloudExecCapture(updateArgs);
@@ -230,6 +235,7 @@ async function createOrUpdateJob(options) {
230
235
  `--task-timeout=${timeout}s`,
231
236
  "--max-retries=0"
232
237
  ];
238
+ if (cloud.resources?.parallelism !== void 0) createArgs.push(`--parallelism=${cloud.resources.parallelism}`);
233
239
  if (secretsString) createArgs.push(`--set-secrets=${secretsString}`);
234
240
  if (cloud.serviceAccount) createArgs.push(`--service-account=${cloud.serviceAccount}`);
235
241
  const result = gcloudExecCapture(createArgs);
@@ -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 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 (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 { 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,3 +1,4 @@
1
+ import { formatDuration } from "../format.mjs";
1
2
  import { gcloudJson } from "./gcloud.mjs";
2
3
  import { parseExecution } from "./types.mjs";
3
4
  import { pollExecution } from "./execution-poller.mjs";
@@ -8,13 +9,6 @@ import { consola } from "consola";
8
9
  const DEFAULT_REGION = "us-central1";
9
10
  /** Delay in ms to wait for log drain after execution completes */
10
11
  const LOG_DRAIN_DELAY = 3e3;
11
- function formatDuration(ms) {
12
- const totalSeconds = Math.round(ms / 1e3);
13
- const minutes = Math.floor(totalSeconds / 60);
14
- const seconds = totalSeconds % 60;
15
- if (minutes > 0) return `${minutes}m ${seconds}s`;
16
- return `${seconds}s`;
17
- }
18
12
  /**
19
13
  * Execute a Cloud Run Job.
20
14
  *
@@ -30,9 +24,10 @@ async function execute(options) {
30
24
  const region = options.region ?? DEFAULT_REGION;
31
25
  const jobArgvJson = JSON.stringify(jobArgv);
32
26
  const jobScript = jobArgv[0] ?? "unknown";
33
- consola.start(`Executing Cloud Run Job: ${jobResourceName} ${jobScript}${options.async ? " (async)" : ""}`);
27
+ const tasksSuffix = options.tasks ? ` (${options.tasks} ${options.tasks === 1 ? "task" : "tasks"})` : "";
28
+ consola.start(`Executing Cloud Run Job: ${jobResourceName} → ${jobScript}${tasksSuffix}${options.async ? " (async)" : ""}`);
34
29
  const executeStart = performance.now();
35
- const response = gcloudJson([
30
+ const args = [
36
31
  "run",
37
32
  "jobs",
38
33
  "execute",
@@ -43,7 +38,9 @@ async function execute(options) {
43
38
  region,
44
39
  `--update-env-vars=^||^JOB_ARGV=${jobArgvJson}`,
45
40
  "--async"
46
- ]);
41
+ ];
42
+ if (options.tasks) args.push(`--tasks=${options.tasks}`);
43
+ const response = gcloudJson(args);
47
44
  const execution = parseExecution(response, {
48
45
  project,
49
46
  region,
@@ -1 +1 @@
1
- {"version":3,"file":"execute.mjs","names":[],"sources":["../../src/cloud/execute.ts"],"sourcesContent":["import { consola } from \"consola\";\nimport { pollExecution } from \"./execution-poller\";\nimport { gcloudJson } from \"./gcloud\";\nimport { LogStreamer } from \"./log-streamer\";\nimport { parseExecution } from \"./types\";\n\ninterface ExecuteOptions {\n /** Cloud Run Job resource name (e.g., \"admin-create-user\") */\n jobResourceName: string;\n /** GCP region. Default: \"us-central1\" */\n region?: string;\n /** GCP project ID */\n project: string;\n /** Job arguments to pass via JOB_ARGV env var */\n jobArgv: string[];\n /** If true, don't wait for the job to complete */\n async?: boolean;\n}\n\nconst DEFAULT_REGION = \"us-central1\";\n\n/** Delay in ms to wait for log drain after execution completes */\nconst LOG_DRAIN_DELAY = 3000;\n\nfunction formatDuration(ms: number): string {\n const totalSeconds = Math.round(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n\n if (minutes > 0) {\n return `${minutes}m ${seconds}s`;\n }\n return `${seconds}s`;\n}\n\n/**\n * Execute a Cloud Run Job.\n *\n * Passes job arguments as a JSON-encoded string array in the `JOB_ARGV`\n * environment variable. Uses `^||^` as the key=value pair delimiter to\n * avoid conflicts with JSON commas.\n *\n * For non-async execution, streams Cloud Logging entries to the terminal\n * in real-time while polling for execution completion.\n */\nexport async function execute(options: ExecuteOptions): Promise<void> {\n const { jobResourceName, project, jobArgv } = options;\n const region = options.region ?? DEFAULT_REGION;\n\n const jobArgvJson = JSON.stringify(jobArgv);\n\n const jobScript = jobArgv[0] ?? \"unknown\";\n\n consola.start(\n `Executing Cloud Run Job: ${jobResourceName} → ${jobScript}${options.async ? \" (async)\" : \"\"}`,\n );\n\n const executeStart = performance.now();\n\n const args = [\n \"run\",\n \"jobs\",\n \"execute\",\n jobResourceName,\n \"--project\",\n project,\n \"--region\",\n region,\n `--update-env-vars=^||^JOB_ARGV=${jobArgvJson}`,\n \"--async\",\n ];\n\n const response = gcloudJson(args);\n\n const execution = parseExecution(response, {\n project,\n region,\n jobName: jobResourceName,\n });\n\n if (!execution) {\n consola.error(\"Failed to start Cloud Run Job execution\");\n consola.error(\"gcloud response:\", JSON.stringify(response, null, 2));\n process.exit(1);\n }\n\n const executionName = execution.name.split(\"/\").pop()!;\n const jobPageUrl = `https://console.cloud.google.com/run/jobs/execution/${region}/${executionName}?project=${project}`;\n\n if (options.async) {\n consola.success(`Cloud Run Job started: ${jobResourceName}`);\n consola.info(`Execution: ${executionName}`);\n consola.info(`Job page: ${jobPageUrl}`);\n return;\n }\n\n consola.info(`Execution: ${executionName}`);\n consola.info(`Job page: ${jobPageUrl}`);\n\n /** Start log streaming */\n const streamer = new LogStreamer({\n projectId: project,\n jobName: jobResourceName,\n executionName,\n });\n\n streamer.start();\n\n /** Handle SIGINT: stop streaming, inform user, exit */\n const handleSignal = () => {\n consola.log(\"\");\n consola.info(\"Stopping log stream...\");\n void streamer.stop().then(() => {\n consola.info(\"Execution continues in the cloud.\");\n consola.info(`Job page: ${jobPageUrl}`);\n process.exit(130);\n });\n };\n\n process.on(\"SIGINT\", handleSignal);\n\n try {\n /** Poll for execution completion */\n const result = await pollExecution({\n executionName,\n project,\n region,\n onStatusChange: (status) => consola.info(status),\n });\n\n /** Capture total time before log drain delay */\n const totalMs = performance.now() - executeStart;\n\n /** Wait for remaining logs to be ingested */\n await new Promise((resolve) => setTimeout(resolve, LOG_DRAIN_DELAY));\n\n await streamer.stop();\n const timingParts: string[] = [];\n\n if (result.startedAt !== undefined) {\n timingParts.push(\n `startup ${formatDuration(result.startedAt - executeStart)}`,\n );\n }\n\n const { startTime, completionTime } = result.execution;\n if (startTime && completionTime) {\n const jobMs =\n new Date(completionTime).getTime() - new Date(startTime).getTime();\n timingParts.push(`job ${formatDuration(jobMs)}`);\n }\n\n timingParts.push(`total ${formatDuration(totalMs)}`);\n consola.info(`Timing: ${timingParts.join(\", \")}`);\n\n if (result.succeeded) {\n consola.success(`Cloud Run Job completed: ${jobResourceName}`);\n process.exit(0);\n } else {\n consola.error(`Cloud Run Job failed: ${jobResourceName}`);\n\n const failedCondition = result.execution.conditions?.find(\n (c) => c.type === \"Completed\" && c.message,\n );\n if (failedCondition?.message) {\n consola.error(failedCondition.message);\n }\n\n process.exit(1);\n }\n } finally {\n process.removeListener(\"SIGINT\", handleSignal);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"execute.mjs","names":[],"sources":["../../src/cloud/execute.ts"],"sourcesContent":["import { consola } from \"consola\";\nimport { formatDuration } from \"../format\";\nimport { pollExecution } from \"./execution-poller\";\nimport { gcloudJson } from \"./gcloud\";\nimport { LogStreamer } from \"./log-streamer\";\nimport { parseExecution } from \"./types\";\n\ninterface ExecuteOptions {\n /** Cloud Run Job resource name (e.g., \"admin-create-user\") */\n jobResourceName: string;\n /** GCP region. Default: \"us-central1\" */\n region?: string;\n /** GCP project ID */\n project: string;\n /** Job arguments to pass via JOB_ARGV env var */\n jobArgv: string[];\n /** If true, don't wait for the job to complete */\n async?: boolean;\n /** Number of tasks to run in parallel. Overrides the job default for this execution. */\n tasks?: number;\n}\n\nconst DEFAULT_REGION = \"us-central1\";\n\n/** Delay in ms to wait for log drain after execution completes */\nconst LOG_DRAIN_DELAY = 3000;\n\n/**\n * Execute a Cloud Run Job.\n *\n * Passes job arguments as a JSON-encoded string array in the `JOB_ARGV`\n * environment variable. Uses `^||^` as the key=value pair delimiter to\n * avoid conflicts with JSON commas.\n *\n * For non-async execution, streams Cloud Logging entries to the terminal\n * in real-time while polling for execution completion.\n */\nexport async function execute(options: ExecuteOptions): Promise<void> {\n const { jobResourceName, project, jobArgv } = options;\n const region = options.region ?? DEFAULT_REGION;\n\n const jobArgvJson = JSON.stringify(jobArgv);\n\n const jobScript = jobArgv[0] ?? \"unknown\";\n\n const tasksSuffix = options.tasks\n ? ` (${options.tasks} ${options.tasks === 1 ? \"task\" : \"tasks\"})`\n : \"\";\n\n consola.start(\n `Executing Cloud Run Job: ${jobResourceName} → ${jobScript}${tasksSuffix}${options.async ? \" (async)\" : \"\"}`,\n );\n\n const executeStart = performance.now();\n\n const args = [\n \"run\",\n \"jobs\",\n \"execute\",\n jobResourceName,\n \"--project\",\n project,\n \"--region\",\n region,\n `--update-env-vars=^||^JOB_ARGV=${jobArgvJson}`,\n \"--async\",\n ];\n\n if (options.tasks) {\n args.push(`--tasks=${options.tasks}`);\n }\n\n const response = gcloudJson(args);\n\n const execution = parseExecution(response, {\n project,\n region,\n jobName: jobResourceName,\n });\n\n if (!execution) {\n consola.error(\"Failed to start Cloud Run Job execution\");\n consola.error(\"gcloud response:\", JSON.stringify(response, null, 2));\n process.exit(1);\n }\n\n const executionName = execution.name.split(\"/\").pop()!;\n const jobPageUrl = `https://console.cloud.google.com/run/jobs/execution/${region}/${executionName}?project=${project}`;\n\n if (options.async) {\n consola.success(`Cloud Run Job started: ${jobResourceName}`);\n consola.info(`Execution: ${executionName}`);\n consola.info(`Job page: ${jobPageUrl}`);\n return;\n }\n\n consola.info(`Execution: ${executionName}`);\n consola.info(`Job page: ${jobPageUrl}`);\n\n /** Start log streaming */\n const streamer = new LogStreamer({\n projectId: project,\n jobName: jobResourceName,\n executionName,\n });\n\n streamer.start();\n\n /** Handle SIGINT: stop streaming, inform user, exit */\n const handleSignal = () => {\n consola.log(\"\");\n consola.info(\"Stopping log stream...\");\n void streamer.stop().then(() => {\n consola.info(\"Execution continues in the cloud.\");\n consola.info(`Job page: ${jobPageUrl}`);\n process.exit(130);\n });\n };\n\n process.on(\"SIGINT\", handleSignal);\n\n try {\n /** Poll for execution completion */\n const result = await pollExecution({\n executionName,\n project,\n region,\n onStatusChange: (status) => consola.info(status),\n });\n\n /** Capture total time before log drain delay */\n const totalMs = performance.now() - executeStart;\n\n /** Wait for remaining logs to be ingested */\n await new Promise((resolve) => setTimeout(resolve, LOG_DRAIN_DELAY));\n\n await streamer.stop();\n const timingParts: string[] = [];\n\n if (result.startedAt !== undefined) {\n timingParts.push(\n `startup ${formatDuration(result.startedAt - executeStart)}`,\n );\n }\n\n const { startTime, completionTime } = result.execution;\n if (startTime && completionTime) {\n const jobMs =\n new Date(completionTime).getTime() - new Date(startTime).getTime();\n timingParts.push(`job ${formatDuration(jobMs)}`);\n }\n\n timingParts.push(`total ${formatDuration(totalMs)}`);\n consola.info(`Timing: ${timingParts.join(\", \")}`);\n\n if (result.succeeded) {\n consola.success(`Cloud Run Job completed: ${jobResourceName}`);\n process.exit(0);\n } else {\n consola.error(`Cloud Run Job failed: ${jobResourceName}`);\n\n const failedCondition = result.execution.conditions?.find(\n (c) => c.type === \"Completed\" && c.message,\n );\n if (failedCondition?.message) {\n consola.error(failedCondition.message);\n }\n\n process.exit(1);\n }\n } finally {\n process.removeListener(\"SIGINT\", handleSignal);\n }\n}\n"],"mappings":"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"}
@@ -47,12 +47,12 @@ function pollExecution(options) {
47
47
  onStatusChange?.("Running");
48
48
  } else if (!hasReportedStarting) {
49
49
  hasReportedStarting = true;
50
- onStatusChange?.("Container starting... (this can take about a minute)");
50
+ onStatusChange?.("Container starting... (this might take about 2 minutes)");
51
51
  }
52
52
  }
53
53
  if (execution.completionTime) {
54
54
  resolve({
55
- succeeded: (execution.succeededCount ?? 0) > 0,
55
+ succeeded: !((execution.failedCount ?? 0) > 0) && (execution.succeededCount ?? 0) > 0,
56
56
  execution,
57
57
  startedAt
58
58
  });
@@ -1 +1 @@
1
- {"version":3,"file":"execution-poller.mjs","names":[],"sources":["../../src/cloud/execution-poller.ts"],"sourcesContent":["import { consola } from \"consola\";\nimport { gcloudJson } from \"./gcloud\";\nimport type { GcloudExecution } from \"./types\";\nimport { parseExecution } from \"./types\";\n\ninterface PollOptions {\n /** Execution name (short name, e.g., \"my-job-abc123\") */\n executionName: string;\n /** GCP project ID */\n project: string;\n /** GCP region */\n region: string;\n /** Polling interval in milliseconds. Default: 5000 */\n interval?: number;\n /** Abort signal for cancellation */\n signal?: AbortSignal;\n /** Called when a status transition is detected */\n onStatusChange?: (status: string) => void;\n}\n\ninterface PollResult {\n succeeded: boolean;\n execution: GcloudExecution;\n /** Local timestamp (performance.now()) when polling first observed the container running */\n startedAt?: number;\n}\n\nconst DEFAULT_POLL_INTERVAL = 5000;\n\n/**\n * Poll a Cloud Run Job execution until it completes.\n * Returns whether the execution succeeded.\n */\nexport function pollExecution(options: PollOptions): Promise<PollResult> {\n const { executionName, project, region, signal, onStatusChange } = options;\n const interval = options.interval ?? DEFAULT_POLL_INTERVAL;\n\n let consecutiveErrors = 0;\n let hasStarted = false;\n let hasReportedStarting = false;\n let startedAt: number | undefined;\n\n return new Promise((resolve, reject) => {\n const check = () => {\n if (signal?.aborted) {\n reject(new DOMException(\"Polling aborted\", \"AbortError\"));\n return;\n }\n\n try {\n const response = gcloudJson([\n \"run\",\n \"jobs\",\n \"executions\",\n \"describe\",\n executionName,\n \"--project\",\n project,\n \"--region\",\n region,\n ]);\n\n consecutiveErrors = 0;\n\n const execution = parseExecution(response);\n\n if (!execution) {\n /** Retry on empty response */\n scheduleNext();\n return;\n }\n\n if (!hasStarted) {\n if (execution.startTime) {\n hasStarted = true;\n startedAt = performance.now();\n onStatusChange?.(\"Running\");\n } else if (!hasReportedStarting) {\n hasReportedStarting = true;\n onStatusChange?.(\n \"Container starting... (this can take about a minute)\",\n );\n }\n }\n\n if (execution.completionTime) {\n const succeeded = (execution.succeededCount ?? 0) > 0;\n resolve({ succeeded, execution, startedAt });\n return;\n }\n\n const completedCondition = execution.conditions?.find(\n (c) => c.type === \"Completed\",\n );\n\n if (completedCondition?.state === \"CONDITION_SUCCEEDED\") {\n resolve({ succeeded: true, execution, startedAt });\n return;\n }\n\n if (completedCondition?.state === \"CONDITION_FAILED\") {\n resolve({ succeeded: false, execution, startedAt });\n return;\n }\n\n scheduleNext();\n } catch (error) {\n consecutiveErrors++;\n\n if (consecutiveErrors <= 3) {\n consola.warn(\n `Failed to check execution status (attempt ${consecutiveErrors})`,\n error instanceof Error ? error.message : String(error),\n );\n }\n\n if (consecutiveErrors >= 10) {\n reject(\n new Error(\n `Polling failed after ${consecutiveErrors} consecutive errors`,\n ),\n );\n return;\n }\n\n scheduleNext();\n }\n };\n\n const scheduleNext = () => {\n setTimeout(check, interval);\n };\n\n /** Start first check after a short delay to give the execution time to register */\n setTimeout(check, interval);\n });\n}\n"],"mappings":";;;;;AA2BA,MAAM,wBAAwB;;;;;AAM9B,SAAgB,cAAc,SAA2C;CACvE,MAAM,EAAE,eAAe,SAAS,QAAQ,QAAQ,mBAAmB;CACnE,MAAM,WAAW,QAAQ,YAAY;CAErC,IAAI,oBAAoB;CACxB,IAAI,aAAa;CACjB,IAAI,sBAAsB;CAC1B,IAAI;AAEJ,QAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,cAAc;AAClB,OAAI,QAAQ,SAAS;AACnB,WAAO,IAAI,aAAa,mBAAmB,aAAa,CAAC;AACzD;;AAGF,OAAI;IACF,MAAM,WAAW,WAAW;KAC1B;KACA;KACA;KACA;KACA;KACA;KACA;KACA;KACA;KACD,CAAC;AAEF,wBAAoB;IAEpB,MAAM,YAAY,eAAe,SAAS;AAE1C,QAAI,CAAC,WAAW;;AAEd,mBAAc;AACd;;AAGF,QAAI,CAAC,YACH;SAAI,UAAU,WAAW;AACvB,mBAAa;AACb,kBAAY,YAAY,KAAK;AAC7B,uBAAiB,UAAU;gBAClB,CAAC,qBAAqB;AAC/B,4BAAsB;AACtB,uBACE,uDACD;;;AAIL,QAAI,UAAU,gBAAgB;AAE5B,aAAQ;MAAE,YADS,UAAU,kBAAkB,KAAK;MAC/B;MAAW;MAAW,CAAC;AAC5C;;IAGF,MAAM,qBAAqB,UAAU,YAAY,MAC9C,MAAM,EAAE,SAAS,YACnB;AAED,QAAI,oBAAoB,UAAU,uBAAuB;AACvD,aAAQ;MAAE,WAAW;MAAM;MAAW;MAAW,CAAC;AAClD;;AAGF,QAAI,oBAAoB,UAAU,oBAAoB;AACpD,aAAQ;MAAE,WAAW;MAAO;MAAW;MAAW,CAAC;AACnD;;AAGF,kBAAc;YACP,OAAO;AACd;AAEA,QAAI,qBAAqB,EACvB,SAAQ,KACN,6CAA6C,kBAAkB,IAC/D,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM,CACvD;AAGH,QAAI,qBAAqB,IAAI;AAC3B,4BACE,IAAI,MACF,wBAAwB,kBAAkB,qBAC3C,CACF;AACD;;AAGF,kBAAc;;;EAIlB,MAAM,qBAAqB;AACzB,cAAW,OAAO,SAAS;;;AAI7B,aAAW,OAAO,SAAS;GAC3B"}
1
+ {"version":3,"file":"execution-poller.mjs","names":[],"sources":["../../src/cloud/execution-poller.ts"],"sourcesContent":["import { consola } from \"consola\";\nimport { gcloudJson } from \"./gcloud\";\nimport type { GcloudExecution } from \"./types\";\nimport { parseExecution } from \"./types\";\n\ninterface PollOptions {\n /** Execution name (short name, e.g., \"my-job-abc123\") */\n executionName: string;\n /** GCP project ID */\n project: string;\n /** GCP region */\n region: string;\n /** Polling interval in milliseconds. Default: 5000 */\n interval?: number;\n /** Abort signal for cancellation */\n signal?: AbortSignal;\n /** Called when a status transition is detected */\n onStatusChange?: (status: string) => void;\n}\n\ninterface PollResult {\n succeeded: boolean;\n execution: GcloudExecution;\n /** Local timestamp (performance.now()) when polling first observed the container running */\n startedAt?: number;\n}\n\nconst DEFAULT_POLL_INTERVAL = 5000;\n\n/**\n * Poll a Cloud Run Job execution until it completes.\n * Returns whether the execution succeeded.\n */\nexport function pollExecution(options: PollOptions): Promise<PollResult> {\n const { executionName, project, region, signal, onStatusChange } = options;\n const interval = options.interval ?? DEFAULT_POLL_INTERVAL;\n\n let consecutiveErrors = 0;\n let hasStarted = false;\n let hasReportedStarting = false;\n let startedAt: number | undefined;\n\n return new Promise((resolve, reject) => {\n const check = () => {\n if (signal?.aborted) {\n reject(new DOMException(\"Polling aborted\", \"AbortError\"));\n return;\n }\n\n try {\n const response = gcloudJson([\n \"run\",\n \"jobs\",\n \"executions\",\n \"describe\",\n executionName,\n \"--project\",\n project,\n \"--region\",\n region,\n ]);\n\n consecutiveErrors = 0;\n\n const execution = parseExecution(response);\n\n if (!execution) {\n /** Retry on empty response */\n scheduleNext();\n return;\n }\n\n if (!hasStarted) {\n if (execution.startTime) {\n hasStarted = true;\n startedAt = performance.now();\n onStatusChange?.(\"Running\");\n } else if (!hasReportedStarting) {\n hasReportedStarting = true;\n onStatusChange?.(\n \"Container starting... (this might take about 2 minutes)\",\n );\n }\n }\n\n if (execution.completionTime) {\n const failed = (execution.failedCount ?? 0) > 0;\n const succeeded = !failed && (execution.succeededCount ?? 0) > 0;\n resolve({ succeeded, execution, startedAt });\n return;\n }\n\n const completedCondition = execution.conditions?.find(\n (c) => c.type === \"Completed\",\n );\n\n if (completedCondition?.state === \"CONDITION_SUCCEEDED\") {\n resolve({ succeeded: true, execution, startedAt });\n return;\n }\n\n if (completedCondition?.state === \"CONDITION_FAILED\") {\n resolve({ succeeded: false, execution, startedAt });\n return;\n }\n\n scheduleNext();\n } catch (error) {\n consecutiveErrors++;\n\n if (consecutiveErrors <= 3) {\n consola.warn(\n `Failed to check execution status (attempt ${consecutiveErrors})`,\n error instanceof Error ? error.message : String(error),\n );\n }\n\n if (consecutiveErrors >= 10) {\n reject(\n new Error(\n `Polling failed after ${consecutiveErrors} consecutive errors`,\n ),\n );\n return;\n }\n\n scheduleNext();\n }\n };\n\n const scheduleNext = () => {\n setTimeout(check, interval);\n };\n\n /** Start first check after a short delay to give the execution time to register */\n setTimeout(check, interval);\n });\n}\n"],"mappings":";;;;;AA2BA,MAAM,wBAAwB;;;;;AAM9B,SAAgB,cAAc,SAA2C;CACvE,MAAM,EAAE,eAAe,SAAS,QAAQ,QAAQ,mBAAmB;CACnE,MAAM,WAAW,QAAQ,YAAY;CAErC,IAAI,oBAAoB;CACxB,IAAI,aAAa;CACjB,IAAI,sBAAsB;CAC1B,IAAI;AAEJ,QAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,cAAc;AAClB,OAAI,QAAQ,SAAS;AACnB,WAAO,IAAI,aAAa,mBAAmB,aAAa,CAAC;AACzD;;AAGF,OAAI;IACF,MAAM,WAAW,WAAW;KAC1B;KACA;KACA;KACA;KACA;KACA;KACA;KACA;KACA;KACD,CAAC;AAEF,wBAAoB;IAEpB,MAAM,YAAY,eAAe,SAAS;AAE1C,QAAI,CAAC,WAAW;;AAEd,mBAAc;AACd;;AAGF,QAAI,CAAC,YACH;SAAI,UAAU,WAAW;AACvB,mBAAa;AACb,kBAAY,YAAY,KAAK;AAC7B,uBAAiB,UAAU;gBAClB,CAAC,qBAAqB;AAC/B,4BAAsB;AACtB,uBACE,0DACD;;;AAIL,QAAI,UAAU,gBAAgB;AAG5B,aAAQ;MAAE,WADQ,GADF,UAAU,eAAe,KAAK,OAChB,UAAU,kBAAkB,KAAK;MAC1C;MAAW;MAAW,CAAC;AAC5C;;IAGF,MAAM,qBAAqB,UAAU,YAAY,MAC9C,MAAM,EAAE,SAAS,YACnB;AAED,QAAI,oBAAoB,UAAU,uBAAuB;AACvD,aAAQ;MAAE,WAAW;MAAM;MAAW;MAAW,CAAC;AAClD;;AAGF,QAAI,oBAAoB,UAAU,oBAAoB;AACpD,aAAQ;MAAE,WAAW;MAAO;MAAW;MAAW,CAAC;AACnD;;AAGF,kBAAc;YACP,OAAO;AACd;AAEA,QAAI,qBAAqB,EACvB,SAAQ,KACN,6CAA6C,kBAAkB,IAC/D,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM,CACvD;AAGH,QAAI,qBAAqB,IAAI;AAC3B,4BACE,IAAI,MACF,wBAAwB,kBAAkB,qBAC3C,CACF;AACD;;AAGF,kBAAc;;;EAIlB,MAAM,qBAAqB;AACzB,cAAW,OAAO,SAAS;;;AAI7B,aAAW,OAAO,SAAS;GAC3B"}
@@ -6,13 +6,35 @@ import { Logging } from "@google-cloud/logging";
6
6
  * Streams Cloud Logging entries to the terminal for a specific Cloud Run Job
7
7
  * execution. Uses the Live Tail API for real-time log delivery.
8
8
  */
9
+ /** Delay in ms before attempting to reconnect after a stream error */
10
+ const RECONNECT_DELAY = 1e3;
11
+ /** Stop reconnecting after this many consecutive short-lived failures */
12
+ const MAX_RECONNECT_ATTEMPTS = 5;
13
+ /**
14
+ * If a stream survives longer than this, its failure is considered transient
15
+ * (e.g., the 1-hour DEADLINE_EXCEEDED) rather than permanent.
16
+ */
17
+ const TRANSIENT_FAILURE_THRESHOLD = 6e4;
9
18
  var LogStreamer = class {
10
19
  stream = null;
11
20
  options;
21
+ stopped = false;
22
+ reconnectTimer = null;
23
+ consecutiveFailures = 0;
24
+ connectedAt = 0;
12
25
  constructor(options) {
13
26
  this.options = options;
14
27
  }
15
28
  start() {
29
+ this.stopped = false;
30
+ this.consecutiveFailures = 0;
31
+ if (this.reconnectTimer) {
32
+ clearTimeout(this.reconnectTimer);
33
+ this.reconnectTimer = null;
34
+ }
35
+ this.connect();
36
+ }
37
+ connect() {
16
38
  const { projectId, jobName, executionName } = this.options;
17
39
  const logging = new Logging({ projectId });
18
40
  const filter = [
@@ -22,24 +44,49 @@ var LogStreamer = class {
22
44
  ].join(" AND ");
23
45
  try {
24
46
  this.stream = logging.tailEntries({ filter });
47
+ this.connectedAt = Date.now();
25
48
  this.stream.on("data", (response) => {
26
49
  const sorted = [...response.entries ?? []].sort(compareEntryTimestamps);
27
50
  for (const entry of sorted) printEntry(entry);
28
51
  });
29
52
  this.stream.on("error", (error) => {
30
53
  consola.warn(`Log stream error: ${error.message}`);
54
+ this.stream?.destroy();
31
55
  this.stream = null;
56
+ this.scheduleReconnect();
32
57
  });
33
58
  } catch (error) {
34
59
  const message = error instanceof Error ? error.message : String(error);
35
60
  consola.warn(`Failed to start log streaming: ${message}`);
61
+ this.scheduleReconnect();
36
62
  }
37
63
  }
64
+ scheduleReconnect() {
65
+ if (this.stopped || this.reconnectTimer) return;
66
+ if (Date.now() - this.connectedAt > TRANSIENT_FAILURE_THRESHOLD) this.consecutiveFailures = 0;
67
+ this.consecutiveFailures++;
68
+ if (this.consecutiveFailures > MAX_RECONNECT_ATTEMPTS) {
69
+ consola.warn("Log stream reconnect failed too many times, giving up");
70
+ return;
71
+ }
72
+ this.reconnectTimer = setTimeout(() => {
73
+ this.reconnectTimer = null;
74
+ if (!this.stopped) {
75
+ consola.info("Reconnecting log stream...");
76
+ this.connect();
77
+ }
78
+ }, RECONNECT_DELAY);
79
+ }
38
80
  /**
39
81
  * Stop the log stream. Returns a promise that resolves when the stream is
40
82
  * fully closed, with a safety timeout to prevent hanging.
41
83
  */
42
84
  stop() {
85
+ this.stopped = true;
86
+ if (this.reconnectTimer) {
87
+ clearTimeout(this.reconnectTimer);
88
+ this.reconnectTimer = null;
89
+ }
43
90
  return new Promise((resolve) => {
44
91
  if (!this.stream) {
45
92
  resolve();