gcp-job-runner 1.7.0 → 1.8.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 +7 -5
- package/dist/cli.mjs.map +1 -1
- package/dist/cloud/deploy.mjs +1 -1
- package/dist/cloud/hash.mjs +1 -1
- package/dist/config.d.mts +18 -4
- package/dist/config.mjs +14 -1
- package/dist/config.mjs.map +1 -1
- package/dist/discover-jobs.mjs +1 -1
- package/dist/env-file.mjs +1 -1
- package/dist/exports.mjs +2 -2
- package/dist/exports.mjs.map +1 -1
- package/dist/interactive.mjs +1 -1
- package/dist/run-job.mjs +1 -1
- package/package.json +1 -1
- package/src/cli.ts +11 -8
- package/src/config.test.ts +66 -0
- package/src/config.ts +36 -4
- package/src/exports.ts +3 -2
package/dist/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { resolveLocalExportsPath } from "./config.mjs";
|
|
2
3
|
import { discoverJobs } from "./discover-jobs.mjs";
|
|
3
4
|
import { promptForArgs, selectJob } from "./interactive.mjs";
|
|
4
5
|
import { runJob } from "./run-job.mjs";
|
|
@@ -7,8 +8,8 @@ import { createOrUpdateJob, deployIfChanged, prepareImage } from "./cloud/deploy
|
|
|
7
8
|
import { execute } from "./cloud/execute.mjs";
|
|
8
9
|
import { deriveJobResourceName } from "./cloud/job-name.mjs";
|
|
9
10
|
import { getSecrets } from "./secrets/loader.mjs";
|
|
10
|
-
import { consola } from "consola";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { consola } from "consola";
|
|
12
13
|
import process from "node:process";
|
|
13
14
|
import { execSync } from "node:child_process";
|
|
14
15
|
|
|
@@ -204,11 +205,12 @@ async function handleLocalRun(options) {
|
|
|
204
205
|
/** Project always takes highest precedence */
|
|
205
206
|
process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
|
|
206
207
|
/**
|
|
207
|
-
*
|
|
208
|
-
* can use
|
|
209
|
-
*
|
|
208
|
+
* `localExportsPath` wins over the environment's `exportsPath` for local
|
|
209
|
+
* runs so developers can use one destination regardless of whether they
|
|
210
|
+
* point at stag or prod data.
|
|
210
211
|
*/
|
|
211
|
-
|
|
212
|
+
const localExportsPath = resolveLocalExportsPath(config, envConfig, process.cwd());
|
|
213
|
+
if (localExportsPath !== void 0) process.env.JOB_EXPORTS_PATH = localExportsPath;
|
|
212
214
|
if (envConfig.secrets && envConfig.secrets.length > 0) {
|
|
213
215
|
const secrets = await getSecrets(envConfig.secrets);
|
|
214
216
|
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 { parseEnvFiles } from \"./env-file\";\nimport type { JobFunction } from \"./types\";\n\nconst BIN_NAME = \"job\";\nconst CONFIG_FILE = \"job-runner.config.ts\";\nconst DEFAULT_BUILD_COMMAND = \"turbo build\";\nconst DEFAULT_JOBS_DIRECTORY = \"dist/jobs\";\n\nconst USAGE = `Usage: ${BIN_NAME} local run <env> <job-name> [options]\n ${BIN_NAME} cloud run <env> <job-name> [options]\n ${BIN_NAME} cloud deploy <env>\n ${BIN_NAME} --list\n\nCloud run options:\n --tasks <n> Number of parallel tasks for this execution\n --parallelism <n> Max concurrent tasks (sets job resource default)`;\n\nfunction validateInteger(value: number, flagName: string): number {\n if (Number.isNaN(value) || !Number.isInteger(value) || value < 0) {\n consola.error(\n `Invalid value for ${flagName}: expected a non-negative integer`,\n );\n process.exit(1);\n }\n return value;\n}\n\n/**\n * Extract a numeric flag value from args.\n * Supports both `--flag N` and `--flag=N` syntax.\n * Returns undefined if the flag is not present.\n */\nfunction extractNumberFlag(\n args: string[],\n flagName: string,\n): number | undefined {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n\n if (arg === flagName) {\n const next = args[i + 1];\n if (next === undefined || next.startsWith(\"-\")) {\n consola.error(`${flagName} requires a value`);\n process.exit(1);\n }\n return validateInteger(Number(next), flagName);\n }\n\n if (arg.startsWith(`${flagName}=`)) {\n return validateInteger(Number(arg.slice(flagName.length + 1)), flagName);\n }\n }\n\n return undefined;\n}\n\nasync function main(): Promise<void> {\n const args = process.argv.slice(2);\n\n /** Extract flags from anywhere in args */\n const noBuild = args.includes(\"--no-build\");\n const isInteractive = args.includes(\"--interactive\") || args.includes(\"-i\");\n const isAsync = args.includes(\"--async\");\n\n /** Determine mode early so cloud-only flags are only parsed in cloud mode */\n const mode = args.find((arg) => !arg.startsWith(\"-\"));\n const isCloudMode = mode === \"cloud\";\n\n const tasks = isCloudMode ? extractNumberFlag(args, \"--tasks\") : undefined;\n if (tasks === 0) {\n consola.error(\"--tasks must be at least 1\");\n process.exit(1);\n }\n const parallelism = isCloudMode\n ? extractNumberFlag(args, \"--parallelism\")\n : undefined;\n\n const configPath = path.resolve(process.cwd(), CONFIG_FILE);\n\n /** Load config directly (config should only depend on gcp-job-runner) */\n let config: RunnerConfig;\n try {\n const module = (await import(configPath)) as { default: RunnerConfig };\n config = module.default;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n consola.error(\n `Failed to load runner config from ${configPath}\\n${message}`,\n );\n process.exit(1);\n }\n\n /** Resolve jobs directory with default */\n const jobsDirectory =\n config.jobsDirectory ?? path.resolve(process.cwd(), DEFAULT_JOBS_DIRECTORY);\n\n const envNames = Object.keys(config.environments);\n\n /** Handle --list: discover and print all available jobs */\n if (args.includes(\"--list\")) {\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n const jobs = await discoverJobs(jobsDirectory);\n if (jobs.length === 0) {\n consola.info(\"No jobs found.\");\n } else {\n consola.info(\"Available jobs:\");\n for (const job of jobs) {\n consola.log(` ${job.name}`);\n }\n }\n return;\n }\n\n /**\n * Parse positional arguments. In cloud mode, skip values consumed by\n * numeric flags like `--tasks 5` so that `5` is not treated as a positional.\n */\n const consumedIndices = new Set<number>();\n if (isCloudMode) {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n if (\n (arg === \"--tasks\" || arg === \"--parallelism\") &&\n i + 1 < args.length\n ) {\n consumedIndices.add(i + 1);\n }\n }\n }\n\n const positionals = args.filter(\n (arg, index) => !arg.startsWith(\"-\") && !consumedIndices.has(index),\n );\n\n if (mode !== \"local\" && mode !== \"cloud\") {\n consola.error(\n `Unknown or missing mode \"${mode ?? \"\"}\".\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const action = positionals[1];\n if (mode === \"cloud\" && action !== \"run\" && action !== \"deploy\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for cloud mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n if (mode === \"local\" && action !== \"run\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for local mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n\n const envName = positionals[2];\n if (!envName) {\n consola.error(\n `No environment specified.\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n if (!envNames.includes(envName)) {\n consola.error(\n `Unknown environment \"${envName}\".\\n\\n` +\n `Available environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const envConfig = config.environments[envName]!;\n\n /**\n * Remaining positionals after env become the job name/path. For example\n * `job cloud run stag test/countdown` -> positionals[3] = \"test/countdown\".\n */\n const jobNameFromArgs = positionals[3];\n\n /**\n * Collect job flags: everything after `<env>` in the original args that\n * isn't a consumed positional or a known global flag.\n */\n const consumedPositionals = new Set(\n [mode, action, envName, jobNameFromArgs].filter(Boolean),\n );\n const globalFlags = new Set([\n \"--no-build\",\n \"--interactive\",\n \"-i\",\n \"--async\",\n \"--list\",\n ]);\n\n /** --tasks and --parallelism are cloud-only; don't strip them in local mode */\n if (mode === \"cloud\") {\n globalFlags.add(\"--tasks\");\n globalFlags.add(\"--parallelism\");\n }\n\n const envIndex = args.indexOf(envName);\n const jobFlags = args.slice(envIndex + 1).filter((arg, index, arr) => {\n if (consumedPositionals.has(arg)) return false;\n if (globalFlags.has(arg)) return false;\n if (mode === \"cloud\") {\n /** Filter out `--flag=value` forms of number flags */\n if (arg.startsWith(\"--tasks=\") || arg.startsWith(\"--parallelism=\"))\n return false;\n /** Filter out values that follow --tasks or --parallelism */\n const previous = arr[index - 1];\n if (previous === \"--tasks\" || previous === \"--parallelism\") return false;\n }\n return true;\n });\n\n /** Build unless skipped */\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n if (mode === \"local\") {\n await handleLocalRun({\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n });\n } else if (action === \"deploy\") {\n await handleCloudDeploy({ config, envConfig });\n } else {\n await handleCloudRun({\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n });\n }\n}\n\ninterface LocalRunOptions {\n config: RunnerConfig;\n envName: string;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n}\n\nasync function handleLocalRun(options: LocalRunOptions): Promise<void> {\n const {\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n } = options;\n\n /** Set environment variables for local execution */\n process.env.NODE_ENV ??= \"development\";\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n /** Load envFile variables (don't overwrite existing process.env values) */\n const envFileVars = parseEnvFiles(envConfig.envFile);\n for (const [key, value] of Object.entries(envFileVars)) {\n process.env[key] ??= value;\n }\n\n /** Explicit env values override envFile values */\n if (envConfig.env) {\n for (const [key, value] of Object.entries(envConfig.env)) {\n process.env[key] = value;\n }\n }\n\n /** Project always takes highest precedence */\n process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;\n\n /**\n * Resolve local exportsPath values against the service directory so jobs\n * can use consistent relative config (e.g. \"./exports\") from any cwd.\n * `gs://` URIs pass through unchanged.\n */\n if (envConfig.exportsPath) {\n process.env.JOB_EXPORTS_PATH = envConfig.exportsPath.startsWith(\"gs://\")\n ? envConfig.exportsPath\n : path.resolve(process.cwd(), envConfig.exportsPath);\n }\n\n if (envConfig.secrets && envConfig.secrets.length > 0) {\n const secrets = await getSecrets(envConfig.secrets);\n for (const [key, value] of Object.entries(secrets)) {\n process.env[key] = value;\n }\n }\n\n if (isInteractive) {\n const { jobArgv } = await resolveInteractiveJob(jobsDirectory);\n\n process.argv = [process.argv[0]!, process.argv[1]!, ...jobArgv];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n return;\n }\n\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} local run ${envName} <job-name> [options]\\n` +\n ` ${BIN_NAME} local run ${envName} -i`,\n );\n process.exit(1);\n }\n\n /**\n * Rewrite process.argv so runJob sees the job name as the first positional\n * argument, followed by any job-specific flags.\n */\n process.argv = [\n process.argv[0]!,\n process.argv[1]!,\n jobNameFromArgs,\n ...jobFlags,\n ];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n}\n\ninterface CloudDeployOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n}\n\nasync function handleCloudDeploy(options: CloudDeployOptions): Promise<void> {\n const { config, envConfig } = options;\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n assertCloudExportsPath(envConfig);\n\n const serviceDirectory = process.cwd();\n\n const { imageUri } = await prepareImage({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n consola.success(\"Deploy complete\");\n}\n\n/**\n * Reject local exportsPath values for cloud commands — they would silently\n * write to a container filesystem that is discarded on task exit.\n */\nfunction assertCloudExportsPath(\n envConfig: RunnerConfig[\"environments\"][string],\n): void {\n if (envConfig.exportsPath && !envConfig.exportsPath.startsWith(\"gs://\")) {\n consola.error(\n `exportsPath \"${envConfig.exportsPath}\" is a local path but this is a cloud command.\\n` +\n \"Use a gs:// URI for cloud environments (e.g. gs://my-bucket/exports).\",\n );\n process.exit(1);\n }\n}\n\ninterface CloudRunOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n isAsync: boolean;\n tasks?: number;\n parallelism?: number;\n}\n\nasync function handleCloudRun(options: CloudRunOptions): Promise<void> {\n const {\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n } = options;\n\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n assertCloudExportsPath(envConfig);\n\n /** Override parallelism from CLI flag */\n if (parallelism !== undefined) {\n cloud.resources = { ...cloud.resources, parallelism };\n }\n\n const serviceDirectory = process.cwd();\n\n /** Determine job name and argv */\n let jobArgv: string[];\n\n if (isInteractive) {\n const result = await resolveInteractiveJob(jobsDirectory);\n jobArgv = result.jobArgv;\n } else {\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} cloud run <env> <job-name> [options]\\n` +\n ` ${BIN_NAME} cloud run <env> -i`,\n );\n process.exit(1);\n }\n jobArgv = [jobNameFromArgs, ...jobFlags];\n }\n\n /** Build and push image if changed */\n const { imageUri } = await deployIfChanged({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n\n /** Derive per-script Cloud Run Job name */\n const jobScript = jobArgv[0]!;\n const jobResourceName = deriveJobResourceName(jobScript);\n const region = cloud.region ?? \"us-central1\";\n\n /** Ensure per-script Cloud Run Job exists with current image */\n await createOrUpdateJob({\n cloud,\n envConfig,\n jobName: jobResourceName,\n imageUri,\n region,\n project: envConfig.project,\n });\n\n /** Execute the per-script Cloud Run Job */\n await execute({\n jobResourceName,\n region,\n project: envConfig.project,\n jobArgv,\n async: isAsync,\n tasks,\n });\n}\n\n/**\n * Interactively select a job and prompt for arguments.\n * Returns the job name and the complete jobArgv array.\n */\nasync function resolveInteractiveJob(\n jobsDirectory: string,\n): Promise<{ jobName: string; jobArgv: string[] }> {\n const jobName = await selectJob(jobsDirectory);\n\n consola.info(`Selected job: ${jobName}`);\n\n /**\n * Set console-friendly env vars before importing the module, since\n * importing may initialize a structured logger like pino.\n */\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n const parts = jobName.split(\"/\");\n const fileName = parts.pop() ?? \"\";\n const subDirectories = parts;\n const fileLocation = path.join(jobsDirectory, ...subDirectories);\n const modulePath = path.resolve(fileLocation, `${fileName}.mjs`);\n\n let schema: ZodObject<ZodRawShape> | undefined;\n try {\n const moduleObject = (await import(modulePath)) as Record<string, unknown>;\n const fn = moduleObject.default as JobFunction | undefined;\n schema = fn?.__metadata?.schema;\n } catch {\n /** Module might not exist yet or have errors - proceed without schema */\n }\n\n /** Prompt for arguments if schema exists */\n let args: Record<string, unknown> = {};\n if (schema && Object.keys(schema.shape).length > 0) {\n consola.info(\"Enter arguments for the job:\");\n args = await promptForArgs(schema);\n }\n\n const jobArgv = [jobName];\n if (Object.keys(args).length > 0) {\n jobArgv.push(\"--args\", JSON.stringify(args));\n }\n\n return { jobName, jobArgv };\n}\n\n/**\n * Run the build command to compile workspace dependencies.\n * Shows a spinner and hides output unless the build fails.\n */\nfunction runBuild(command: string): void {\n consola.start(\"Building jobs source code...\");\n\n try {\n execSync(command, {\n stdio: \"pipe\",\n encoding: \"utf-8\",\n });\n consola.success(\"Build complete\");\n } catch (error) {\n consola.fail(\"Build failed\");\n\n /** Show the build output on failure */\n if (error && typeof error === \"object\" && \"stdout\" in error) {\n const stdout = (error as { stdout?: string }).stdout;\n if (stdout) {\n consola.log(stdout);\n }\n }\n if (error && typeof error === \"object\" && \"stderr\" in error) {\n const stderr = (error as { stderr?: string }).stderr;\n if (stderr) {\n consola.log(stderr);\n }\n }\n\n process.exit(1);\n }\n}\n\nawait main();\n"],"mappings":"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"}
|
|
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 { resolveLocalExportsPath, type RunnerConfig } from \"./config\";\nimport {\n createOrUpdateJob,\n deployIfChanged,\n prepareImage,\n} from \"./cloud/deploy\";\nimport { execute } from \"./cloud/execute\";\nimport { deriveJobResourceName } from \"./cloud/job-name\";\nimport { discoverJobs } from \"./discover-jobs\";\nimport { promptForArgs, selectJob } from \"./interactive\";\nimport { runJob } from \"./run-job\";\nimport { getSecrets } from \"./secrets\";\nimport { parseEnvFiles } from \"./env-file\";\nimport type { JobFunction } from \"./types\";\n\nconst BIN_NAME = \"job\";\nconst CONFIG_FILE = \"job-runner.config.ts\";\nconst DEFAULT_BUILD_COMMAND = \"turbo build\";\nconst DEFAULT_JOBS_DIRECTORY = \"dist/jobs\";\n\nconst USAGE = `Usage: ${BIN_NAME} local run <env> <job-name> [options]\n ${BIN_NAME} cloud run <env> <job-name> [options]\n ${BIN_NAME} cloud deploy <env>\n ${BIN_NAME} --list\n\nCloud run options:\n --tasks <n> Number of parallel tasks for this execution\n --parallelism <n> Max concurrent tasks (sets job resource default)`;\n\nfunction validateInteger(value: number, flagName: string): number {\n if (Number.isNaN(value) || !Number.isInteger(value) || value < 0) {\n consola.error(\n `Invalid value for ${flagName}: expected a non-negative integer`,\n );\n process.exit(1);\n }\n return value;\n}\n\n/**\n * Extract a numeric flag value from args.\n * Supports both `--flag N` and `--flag=N` syntax.\n * Returns undefined if the flag is not present.\n */\nfunction extractNumberFlag(\n args: string[],\n flagName: string,\n): number | undefined {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n\n if (arg === flagName) {\n const next = args[i + 1];\n if (next === undefined || next.startsWith(\"-\")) {\n consola.error(`${flagName} requires a value`);\n process.exit(1);\n }\n return validateInteger(Number(next), flagName);\n }\n\n if (arg.startsWith(`${flagName}=`)) {\n return validateInteger(Number(arg.slice(flagName.length + 1)), flagName);\n }\n }\n\n return undefined;\n}\n\nasync function main(): Promise<void> {\n const args = process.argv.slice(2);\n\n /** Extract flags from anywhere in args */\n const noBuild = args.includes(\"--no-build\");\n const isInteractive = args.includes(\"--interactive\") || args.includes(\"-i\");\n const isAsync = args.includes(\"--async\");\n\n /** Determine mode early so cloud-only flags are only parsed in cloud mode */\n const mode = args.find((arg) => !arg.startsWith(\"-\"));\n const isCloudMode = mode === \"cloud\";\n\n const tasks = isCloudMode ? extractNumberFlag(args, \"--tasks\") : undefined;\n if (tasks === 0) {\n consola.error(\"--tasks must be at least 1\");\n process.exit(1);\n }\n const parallelism = isCloudMode\n ? extractNumberFlag(args, \"--parallelism\")\n : undefined;\n\n const configPath = path.resolve(process.cwd(), CONFIG_FILE);\n\n /** Load config directly (config should only depend on gcp-job-runner) */\n let config: RunnerConfig;\n try {\n const module = (await import(configPath)) as { default: RunnerConfig };\n config = module.default;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n consola.error(\n `Failed to load runner config from ${configPath}\\n${message}`,\n );\n process.exit(1);\n }\n\n /** Resolve jobs directory with default */\n const jobsDirectory =\n config.jobsDirectory ?? path.resolve(process.cwd(), DEFAULT_JOBS_DIRECTORY);\n\n const envNames = Object.keys(config.environments);\n\n /** Handle --list: discover and print all available jobs */\n if (args.includes(\"--list\")) {\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n const jobs = await discoverJobs(jobsDirectory);\n if (jobs.length === 0) {\n consola.info(\"No jobs found.\");\n } else {\n consola.info(\"Available jobs:\");\n for (const job of jobs) {\n consola.log(` ${job.name}`);\n }\n }\n return;\n }\n\n /**\n * Parse positional arguments. In cloud mode, skip values consumed by\n * numeric flags like `--tasks 5` so that `5` is not treated as a positional.\n */\n const consumedIndices = new Set<number>();\n if (isCloudMode) {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!;\n if (\n (arg === \"--tasks\" || arg === \"--parallelism\") &&\n i + 1 < args.length\n ) {\n consumedIndices.add(i + 1);\n }\n }\n }\n\n const positionals = args.filter(\n (arg, index) => !arg.startsWith(\"-\") && !consumedIndices.has(index),\n );\n\n if (mode !== \"local\" && mode !== \"cloud\") {\n consola.error(\n `Unknown or missing mode \"${mode ?? \"\"}\".\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const action = positionals[1];\n if (mode === \"cloud\" && action !== \"run\" && action !== \"deploy\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for cloud mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n if (mode === \"local\" && action !== \"run\") {\n consola.error(\n `Unknown or missing action \"${action ?? \"\"}\" for local mode.\\n\\n` + USAGE,\n );\n process.exit(1);\n }\n\n const envName = positionals[2];\n if (!envName) {\n consola.error(\n `No environment specified.\\n\\n` +\n `${USAGE}\\n\\n` +\n `Environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n if (!envNames.includes(envName)) {\n consola.error(\n `Unknown environment \"${envName}\".\\n\\n` +\n `Available environments: ${envNames.join(\", \")}`,\n );\n process.exit(1);\n }\n\n const envConfig = config.environments[envName]!;\n\n /**\n * Remaining positionals after env become the job name/path. For example\n * `job cloud run stag test/countdown` -> positionals[3] = \"test/countdown\".\n */\n const jobNameFromArgs = positionals[3];\n\n /**\n * Collect job flags: everything after `<env>` in the original args that\n * isn't a consumed positional or a known global flag.\n */\n const consumedPositionals = new Set(\n [mode, action, envName, jobNameFromArgs].filter(Boolean),\n );\n const globalFlags = new Set([\n \"--no-build\",\n \"--interactive\",\n \"-i\",\n \"--async\",\n \"--list\",\n ]);\n\n /** --tasks and --parallelism are cloud-only; don't strip them in local mode */\n if (mode === \"cloud\") {\n globalFlags.add(\"--tasks\");\n globalFlags.add(\"--parallelism\");\n }\n\n const envIndex = args.indexOf(envName);\n const jobFlags = args.slice(envIndex + 1).filter((arg, index, arr) => {\n if (consumedPositionals.has(arg)) return false;\n if (globalFlags.has(arg)) return false;\n if (mode === \"cloud\") {\n /** Filter out `--flag=value` forms of number flags */\n if (arg.startsWith(\"--tasks=\") || arg.startsWith(\"--parallelism=\"))\n return false;\n /** Filter out values that follow --tasks or --parallelism */\n const previous = arr[index - 1];\n if (previous === \"--tasks\" || previous === \"--parallelism\") return false;\n }\n return true;\n });\n\n /** Build unless skipped */\n if (!noBuild && config.buildCommand !== false) {\n const buildCommand = config.buildCommand ?? DEFAULT_BUILD_COMMAND;\n runBuild(buildCommand);\n }\n\n if (mode === \"local\") {\n await handleLocalRun({\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n });\n } else if (action === \"deploy\") {\n await handleCloudDeploy({ config, envConfig });\n } else {\n await handleCloudRun({\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n });\n }\n}\n\ninterface LocalRunOptions {\n config: RunnerConfig;\n envName: string;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n}\n\nasync function handleLocalRun(options: LocalRunOptions): Promise<void> {\n const {\n config,\n envName,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n } = options;\n\n /** Set environment variables for local execution */\n process.env.NODE_ENV ??= \"development\";\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n /** Load envFile variables (don't overwrite existing process.env values) */\n const envFileVars = parseEnvFiles(envConfig.envFile);\n for (const [key, value] of Object.entries(envFileVars)) {\n process.env[key] ??= value;\n }\n\n /** Explicit env values override envFile values */\n if (envConfig.env) {\n for (const [key, value] of Object.entries(envConfig.env)) {\n process.env[key] = value;\n }\n }\n\n /** Project always takes highest precedence */\n process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;\n\n /**\n * `localExportsPath` wins over the environment's `exportsPath` for local\n * runs so developers can use one destination regardless of whether they\n * point at stag or prod data.\n */\n const localExportsPath = resolveLocalExportsPath(\n config,\n envConfig,\n process.cwd(),\n );\n if (localExportsPath !== undefined) {\n process.env.JOB_EXPORTS_PATH = localExportsPath;\n }\n\n if (envConfig.secrets && envConfig.secrets.length > 0) {\n const secrets = await getSecrets(envConfig.secrets);\n for (const [key, value] of Object.entries(secrets)) {\n process.env[key] = value;\n }\n }\n\n if (isInteractive) {\n const { jobArgv } = await resolveInteractiveJob(jobsDirectory);\n\n process.argv = [process.argv[0]!, process.argv[1]!, ...jobArgv];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n return;\n }\n\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} local run ${envName} <job-name> [options]\\n` +\n ` ${BIN_NAME} local run ${envName} -i`,\n );\n process.exit(1);\n }\n\n /**\n * Rewrite process.argv so runJob sees the job name as the first positional\n * argument, followed by any job-specific flags.\n */\n process.argv = [\n process.argv[0]!,\n process.argv[1]!,\n jobNameFromArgs,\n ...jobFlags,\n ];\n\n const commandPrefix = `${BIN_NAME} local run ${envName}`;\n\n await runJob({\n jobsDirectory,\n initialize: config.initialize,\n logger: config.logger,\n commandPrefix,\n });\n}\n\ninterface CloudDeployOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n}\n\nasync function handleCloudDeploy(options: CloudDeployOptions): Promise<void> {\n const { config, envConfig } = options;\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n assertCloudExportsPath(envConfig);\n\n const serviceDirectory = process.cwd();\n\n const { imageUri } = await prepareImage({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n consola.success(\"Deploy complete\");\n}\n\n/**\n * Reject local exportsPath values for cloud commands — they would silently\n * write to a container filesystem that is discarded on task exit.\n */\nfunction assertCloudExportsPath(\n envConfig: RunnerConfig[\"environments\"][string],\n): void {\n if (envConfig.exportsPath && !envConfig.exportsPath.startsWith(\"gs://\")) {\n consola.error(\n `exportsPath \"${envConfig.exportsPath}\" is a local path but this is a cloud command.\\n` +\n \"Use a gs:// URI for cloud environments (e.g. gs://my-bucket/exports).\",\n );\n process.exit(1);\n }\n}\n\ninterface CloudRunOptions {\n config: RunnerConfig;\n envConfig: RunnerConfig[\"environments\"][string];\n jobsDirectory: string;\n jobNameFromArgs: string | undefined;\n jobFlags: string[];\n isInteractive: boolean;\n isAsync: boolean;\n tasks?: number;\n parallelism?: number;\n}\n\nasync function handleCloudRun(options: CloudRunOptions): Promise<void> {\n const {\n config,\n envConfig,\n jobsDirectory,\n jobNameFromArgs,\n jobFlags,\n isInteractive,\n isAsync,\n tasks,\n parallelism,\n } = options;\n\n const cloud = config.cloud;\n\n if (!cloud) {\n consola.error(\n \"No cloud configuration found in runner config.\\n\" +\n \"Add a `cloud` section to your job-runner.config.ts\",\n );\n process.exit(1);\n }\n\n assertCloudExportsPath(envConfig);\n\n /** Override parallelism from CLI flag */\n if (parallelism !== undefined) {\n cloud.resources = { ...cloud.resources, parallelism };\n }\n\n const serviceDirectory = process.cwd();\n\n /** Determine job name and argv */\n let jobArgv: string[];\n\n if (isInteractive) {\n const result = await resolveInteractiveJob(jobsDirectory);\n jobArgv = result.jobArgv;\n } else {\n if (!jobNameFromArgs) {\n consola.error(\n `No job name specified.\\n\\n` +\n `Usage: ${BIN_NAME} cloud run <env> <job-name> [options]\\n` +\n ` ${BIN_NAME} cloud run <env> -i`,\n );\n process.exit(1);\n }\n jobArgv = [jobNameFromArgs, ...jobFlags];\n }\n\n /** Build and push image if changed */\n const { imageUri } = await deployIfChanged({\n cloud,\n envConfig,\n serviceDirectory,\n });\n\n consola.info(`Image: ${imageUri}`);\n\n /** Derive per-script Cloud Run Job name */\n const jobScript = jobArgv[0]!;\n const jobResourceName = deriveJobResourceName(jobScript);\n const region = cloud.region ?? \"us-central1\";\n\n /** Ensure per-script Cloud Run Job exists with current image */\n await createOrUpdateJob({\n cloud,\n envConfig,\n jobName: jobResourceName,\n imageUri,\n region,\n project: envConfig.project,\n });\n\n /** Execute the per-script Cloud Run Job */\n await execute({\n jobResourceName,\n region,\n project: envConfig.project,\n jobArgv,\n async: isAsync,\n tasks,\n });\n}\n\n/**\n * Interactively select a job and prompt for arguments.\n * Returns the job name and the complete jobArgv array.\n */\nasync function resolveInteractiveJob(\n jobsDirectory: string,\n): Promise<{ jobName: string; jobArgv: string[] }> {\n const jobName = await selectJob(jobsDirectory);\n\n consola.info(`Selected job: ${jobName}`);\n\n /**\n * Set console-friendly env vars before importing the module, since\n * importing may initialize a structured logger like pino.\n */\n process.env.USE_CONSOLE_LOG ??= \"true\";\n process.env.LOG_COLORIZE ??= \"true\";\n\n const parts = jobName.split(\"/\");\n const fileName = parts.pop() ?? \"\";\n const subDirectories = parts;\n const fileLocation = path.join(jobsDirectory, ...subDirectories);\n const modulePath = path.resolve(fileLocation, `${fileName}.mjs`);\n\n let schema: ZodObject<ZodRawShape> | undefined;\n try {\n const moduleObject = (await import(modulePath)) as Record<string, unknown>;\n const fn = moduleObject.default as JobFunction | undefined;\n schema = fn?.__metadata?.schema;\n } catch {\n /** Module might not exist yet or have errors - proceed without schema */\n }\n\n /** Prompt for arguments if schema exists */\n let args: Record<string, unknown> = {};\n if (schema && Object.keys(schema.shape).length > 0) {\n consola.info(\"Enter arguments for the job:\");\n args = await promptForArgs(schema);\n }\n\n const jobArgv = [jobName];\n if (Object.keys(args).length > 0) {\n jobArgv.push(\"--args\", JSON.stringify(args));\n }\n\n return { jobName, jobArgv };\n}\n\n/**\n * Run the build command to compile workspace dependencies.\n * Shows a spinner and hides output unless the build fails.\n */\nfunction runBuild(command: string): void {\n consola.start(\"Building jobs source code...\");\n\n try {\n execSync(command, {\n stdio: \"pipe\",\n encoding: \"utf-8\",\n });\n consola.success(\"Build complete\");\n } catch (error) {\n consola.fail(\"Build failed\");\n\n /** Show the build output on failure */\n if (error && typeof error === \"object\" && \"stdout\" in error) {\n const stdout = (error as { stdout?: string }).stdout;\n if (stdout) {\n consola.log(stdout);\n }\n }\n if (error && typeof error === \"object\" && \"stderr\" in error) {\n const stderr = (error as { stderr?: string }).stderr;\n if (stderr) {\n consola.log(stderr);\n }\n }\n\n process.exit(1);\n }\n}\n\nawait main();\n"],"mappings":"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"}
|
package/dist/cloud/deploy.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { parseEnvFiles } from "../env-file.mjs";
|
|
|
2
2
|
import { generateDockerfile } from "./dockerfile.mjs";
|
|
3
3
|
import { checkGcloudAvailable, gcloudExecCapture, gcloudJson, isDockerDaemonRunning, isDockerInstalled, shellExecCapture, startDockerDaemon, waitForDockerDaemon } from "./gcloud.mjs";
|
|
4
4
|
import { hashDirectory } from "./hash.mjs";
|
|
5
|
-
import { consola } from "consola";
|
|
6
5
|
import path from "node:path";
|
|
6
|
+
import { consola } from "consola";
|
|
7
7
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
|
|
9
9
|
//#region src/cloud/deploy.ts
|
package/dist/cloud/hash.mjs
CHANGED
package/dist/config.d.mts
CHANGED
|
@@ -17,11 +17,14 @@ interface RunnerEnvOptions {
|
|
|
17
17
|
* Destination for artifacts written via `getExportsWriter()`.
|
|
18
18
|
*
|
|
19
19
|
* Either a local path (resolved relative to the service directory) or a
|
|
20
|
-
* `gs://bucket[/prefix]` URI.
|
|
21
|
-
*
|
|
20
|
+
* `gs://bucket[/prefix]` URI. For most setups this is a `gs://` URI — one
|
|
21
|
+
* per environment — paired with a top-level `localExportsPath` that
|
|
22
|
+
* catches every local run regardless of which environment is selected.
|
|
22
23
|
*
|
|
23
|
-
* When unset, `getExportsWriter()` throws
|
|
24
|
-
*
|
|
24
|
+
* When unset, `getExportsWriter()` throws (unless `localExportsPath` is
|
|
25
|
+
* set and the run is local). Cloud deployments require a `gs://` URI.
|
|
26
|
+
*
|
|
27
|
+
* @see RunnerConfig.localExportsPath
|
|
25
28
|
*/
|
|
26
29
|
exportsPath?: string;
|
|
27
30
|
}
|
|
@@ -81,6 +84,17 @@ interface RunnerConfig {
|
|
|
81
84
|
};
|
|
82
85
|
/** Named environments (e.g., stag, prod) */
|
|
83
86
|
environments: Record<string, RunnerEnvOptions>;
|
|
87
|
+
/**
|
|
88
|
+
* Destination used by `getExportsWriter()` for every local run, regardless
|
|
89
|
+
* of which environment is selected. Resolved relative to the service
|
|
90
|
+
* directory; `gs://bucket[/prefix]` URIs are also accepted.
|
|
91
|
+
*
|
|
92
|
+
* When set, local runs ignore the environment's `exportsPath`. When unset,
|
|
93
|
+
* local runs fall back to the environment's `exportsPath`.
|
|
94
|
+
*
|
|
95
|
+
* Cloud runs always use the environment's `exportsPath`.
|
|
96
|
+
*/
|
|
97
|
+
localExportsPath?: string;
|
|
84
98
|
/** Cloud Run Jobs configuration (required for `job cloud run/deploy` commands) */
|
|
85
99
|
cloud?: CloudConfig;
|
|
86
100
|
/**
|
package/dist/config.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
//#region src/config.ts
|
|
2
4
|
/** Identity function for type-safe runner config definition */
|
|
3
5
|
function defineRunnerConfig(config) {
|
|
@@ -7,7 +9,18 @@ function defineRunnerConfig(config) {
|
|
|
7
9
|
function defineRunnerEnv(options) {
|
|
8
10
|
return options;
|
|
9
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the exports destination for a local run. Prefers the top-level
|
|
14
|
+
* `localExportsPath` over the environment's `exportsPath`. Local paths are
|
|
15
|
+
* resolved against the service directory; `gs://` URIs pass through
|
|
16
|
+
* unchanged.
|
|
17
|
+
*/
|
|
18
|
+
function resolveLocalExportsPath(config, envConfig, serviceDirectory) {
|
|
19
|
+
const raw = config.localExportsPath ?? envConfig.exportsPath;
|
|
20
|
+
if (!raw) return void 0;
|
|
21
|
+
return raw.startsWith("gs://") ? raw : path.resolve(serviceDirectory, raw);
|
|
22
|
+
}
|
|
10
23
|
|
|
11
24
|
//#endregion
|
|
12
|
-
export { defineRunnerConfig, defineRunnerEnv };
|
|
25
|
+
export { defineRunnerConfig, defineRunnerEnv, resolveLocalExportsPath };
|
|
13
26
|
//# sourceMappingURL=config.mjs.map
|
package/dist/config.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/** Environment configuration for a specific deployment target */\nexport interface RunnerEnvOptions {\n /** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */\n project: string;\n /**\n * Path(s) to .env files to load, resolved relative to the service directory.\n * Variables from these files have lower precedence than explicit `env` values.\n * When multiple files are specified, earlier files take precedence over later ones.\n */\n envFile?: string | string[];\n /** Additional environment variables to set before the job runs */\n env?: Record<string, string>;\n /** Secret names to load from GCP Secret Manager */\n secrets?: string[];\n /**\n * Destination for artifacts written via `getExportsWriter()`.\n *\n * Either a local path (resolved relative to the service directory) or a\n * `gs://bucket[/prefix]` URI.
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import path from \"node:path\";\n\n/** Environment configuration for a specific deployment target */\nexport interface RunnerEnvOptions {\n /** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */\n project: string;\n /**\n * Path(s) to .env files to load, resolved relative to the service directory.\n * Variables from these files have lower precedence than explicit `env` values.\n * When multiple files are specified, earlier files take precedence over later ones.\n */\n envFile?: string | string[];\n /** Additional environment variables to set before the job runs */\n env?: Record<string, string>;\n /** Secret names to load from GCP Secret Manager */\n secrets?: string[];\n /**\n * Destination for artifacts written via `getExportsWriter()`.\n *\n * Either a local path (resolved relative to the service directory) or a\n * `gs://bucket[/prefix]` URI. For most setups this is a `gs://` URI — one\n * per environment — paired with a top-level `localExportsPath` that\n * catches every local run regardless of which environment is selected.\n *\n * When unset, `getExportsWriter()` throws (unless `localExportsPath` is\n * set and the run is local). Cloud deployments require a `gs://` URI.\n *\n * @see RunnerConfig.localExportsPath\n */\n exportsPath?: string;\n}\n\n/** Container resource limits for a Cloud Run Job */\nexport interface CloudResources {\n /** Memory limit (e.g., \"512Mi\", \"1Gi\"). Default: \"512Mi\" */\n memory?: string;\n /** CPU limit (e.g., \"1\", \"2\"). Default: \"1\" */\n cpu?: string;\n /** Job timeout in seconds. Default: 86400 (24 hours) */\n timeout?: number;\n /** Maximum number of tasks that can run in parallel. Default: unset (no limit) */\n parallelism?: number;\n}\n\n/** Direct VPC egress configuration for private network access (e.g., Redis) */\nexport interface CloudNetworkConfig {\n /** VPC network name (e.g., \"default\") */\n name: string;\n /** VPC subnet name (e.g., \"default\") */\n subnet?: string;\n /** VPC egress mode. Default: \"private-ranges-only\" */\n egress?: \"all-traffic\" | \"private-ranges-only\";\n}\n\n/** Configuration for Cloud Run Jobs execution */\nexport interface CloudConfig {\n /** Cloud Run Job name (e.g., \"loads-predictions-jobs\") */\n name: string;\n /** GCP region. Default: \"us-central1\" */\n region?: string;\n /** Artifact Registry repository name. Default: \"cloud-run\" */\n artifactRegistry?: string;\n /** Container resource limits */\n resources?: CloudResources;\n /** Service account email for the Cloud Run Job */\n serviceAccount?: string;\n /**\n * Build Docker images locally instead of using Cloud Build.\n * Requires Docker to be installed and running. Default: true.\n */\n buildLocal?: boolean;\n /** Direct VPC egress configuration for private network access */\n network?: CloudNetworkConfig;\n}\n\n/** Full runner configuration provided by each service */\nexport interface RunnerConfig {\n /**\n * Absolute path to the directory containing job scripts.\n * Default: `dist/jobs` relative to cwd.\n */\n jobsDirectory?: string;\n /** Optional initialization function called before the job runs (skipped for --help) */\n initialize?: () => void | Promise<void>;\n /** Optional custom logger (defaults to console) */\n logger?: {\n info: (message: string) => void;\n error: (message: string) => void;\n };\n /** Named environments (e.g., stag, prod) */\n environments: Record<string, RunnerEnvOptions>;\n /**\n * Destination used by `getExportsWriter()` for every local run, regardless\n * of which environment is selected. Resolved relative to the service\n * directory; `gs://bucket[/prefix]` URIs are also accepted.\n *\n * When set, local runs ignore the environment's `exportsPath`. When unset,\n * local runs fall back to the environment's `exportsPath`.\n *\n * Cloud runs always use the environment's `exportsPath`.\n */\n localExportsPath?: string;\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\n/**\n * Resolve the exports destination for a local run. Prefers the top-level\n * `localExportsPath` over the environment's `exportsPath`. Local paths are\n * resolved against the service directory; `gs://` URIs pass through\n * unchanged.\n */\nexport function resolveLocalExportsPath(\n config: Pick<RunnerConfig, \"localExportsPath\">,\n envConfig: Pick<RunnerEnvOptions, \"exportsPath\">,\n serviceDirectory: string,\n): string | undefined {\n const raw = config.localExportsPath ?? envConfig.exportsPath;\n if (!raw) return undefined;\n return raw.startsWith(\"gs://\") ? raw : path.resolve(serviceDirectory, raw);\n}\n"],"mappings":";;;;AAiHA,SAAgB,mBAAmB,QAAoC;AACrE,QAAO;;;AAIT,SAAgB,gBAAgB,SAA6C;AAC3E,QAAO;;;;;;;;AAST,SAAgB,wBACd,QACA,WACA,kBACoB;CACpB,MAAM,MAAM,OAAO,oBAAoB,UAAU;AACjD,KAAI,CAAC,IAAK,QAAO;AACjB,QAAO,IAAI,WAAW,QAAQ,GAAG,MAAM,KAAK,QAAQ,kBAAkB,IAAI"}
|
package/dist/discover-jobs.mjs
CHANGED
package/dist/env-file.mjs
CHANGED
package/dist/exports.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { consola } from "consola";
|
|
2
3
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
//#region src/exports.ts
|
|
6
6
|
const GCS_PREFIX = "gs://";
|
|
@@ -13,7 +13,7 @@ const GCS_PREFIX = "gs://";
|
|
|
13
13
|
*/
|
|
14
14
|
function getExportsWriter() {
|
|
15
15
|
const destination = process.env.JOB_EXPORTS_PATH;
|
|
16
|
-
if (!destination) throw new Error("No exports destination configured.\nSet `
|
|
16
|
+
if (!destination) throw new Error("No exports destination configured.\nSet `localExportsPath` or the current environment's `exportsPath` in your job-runner.config.ts, or set JOB_EXPORTS_PATH directly in the environment.");
|
|
17
17
|
if (destination.startsWith(GCS_PREFIX)) return createGcsWriter(destination);
|
|
18
18
|
return createLocalWriter(destination);
|
|
19
19
|
}
|
package/dist/exports.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exports.mjs","names":[],"sources":["../src/exports.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { consola } from \"consola\";\nimport type { Storage } from \"@google-cloud/storage\";\n\n/**\n * Writer for job artifacts, produced by `getExportsWriter()`. A single writer\n * instance targets a destination configured via the `exportsPath` runner\n * option (local directory or `gs://` URI). All methods return the resolved\n * absolute path or `gs://` URI of the written artifact.\n */\nexport interface ExportsWriter {\n /**\n * Write a value as pretty-printed JSON (2-space indent, trailing newline).\n * The `.json` extension is added if missing.\n */\n writeJson(relativePath: string, data: unknown): Promise<string>;\n /** Write a UTF-8 string (e.g. CSV, SVG, plain text). */\n writeText(relativePath: string, content: string): Promise<string>;\n /** Write a binary buffer. */\n writeBuffer(\n relativePath: string,\n content: Buffer | Uint8Array,\n ): Promise<string>;\n}\n\nconst GCS_PREFIX = \"gs://\";\n\n/**\n * Return a writer that persists artifacts to the destination configured via\n * the `exportsPath` runner option. Local paths are used for local execution;\n * `gs://bucket[/prefix]` URIs are used for Cloud Run deployments.\n *\n * Throws when no destination is configured.\n */\nexport function getExportsWriter(): ExportsWriter {\n const destination = process.env.JOB_EXPORTS_PATH;\n\n if (!destination) {\n throw new Error(\n \"No exports destination configured.\\n\" +\n \"Set `exportsPath` on the current environment in your job-runner.config.ts, \" +\n \"or set JOB_EXPORTS_PATH directly in the environment.\",\n );\n }\n\n if (destination.startsWith(GCS_PREFIX)) {\n return createGcsWriter(destination);\n }\n\n return createLocalWriter(destination);\n}\n\nfunction createLocalWriter(basePath: string): ExportsWriter {\n const resolvedBase = path.resolve(basePath);\n\n /** Takes an already-sanitized relative path and writes the content. */\n async function write(\n safeRelative: string,\n content: string | Uint8Array,\n ): Promise<string> {\n const fullPath = path.join(resolvedBase, safeRelative);\n\n await mkdir(path.dirname(fullPath), { recursive: true });\n await writeFile(fullPath, content);\n\n consola.info(`Export written: ${fullPath}`);\n return fullPath;\n }\n\n return {\n async writeJson(relativePath, data) {\n /** Sanitize before appending the extension so \"\" doesn't become \".json\". */\n const withExtension = ensureExtension(\n sanitizeRelativePath(relativePath),\n \".json\",\n );\n return write(withExtension, formatJson(data));\n },\n async writeText(relativePath, content) {\n return write(sanitizeRelativePath(relativePath), content);\n },\n async writeBuffer(relativePath, content) {\n return write(sanitizeRelativePath(relativePath), content);\n },\n };\n}\n\ninterface GcsTarget {\n bucket: string;\n prefix: string;\n}\n\nfunction createGcsWriter(uri: string): ExportsWriter {\n const target = parseGcsUri(uri);\n\n /** Takes an already-sanitized relative path and uploads the content. */\n async function write(\n safeRelative: string,\n content: string | Buffer | Uint8Array,\n contentType: string,\n ): Promise<string> {\n const objectName = joinGcsPath(target.prefix, safeRelative);\n const fullUri = `${GCS_PREFIX}${target.bucket}/${objectName}`;\n\n const storage = await getStorageClient();\n const file = storage.bucket(target.bucket).file(objectName);\n\n const body =\n typeof content === \"string\" || Buffer.isBuffer(content)\n ? content\n : Buffer.from(content);\n\n await file.save(body, {\n contentType,\n resumable: false,\n });\n\n consola.info(`Export written: ${fullUri}`);\n return fullUri;\n }\n\n return {\n async writeJson(relativePath, data) {\n /** Sanitize before appending the extension so \"\" doesn't become \".json\". */\n const withExtension = ensureExtension(\n sanitizeRelativePath(relativePath),\n \".json\",\n );\n return write(withExtension, formatJson(data), \"application/json\");\n },\n async writeText(relativePath, content) {\n const safe = sanitizeRelativePath(relativePath);\n return write(safe, content, contentTypeFor(safe));\n },\n async writeBuffer(relativePath, content) {\n return write(\n sanitizeRelativePath(relativePath),\n content,\n \"application/octet-stream\",\n );\n },\n };\n}\n\n/**\n * Lazy Storage client singleton. The `@google-cloud/storage` module is only\n * loaded when a `gs://` destination is actually used for a write.\n */\nlet storageClient: Storage | null = null;\nasync function getStorageClient(): Promise<Storage> {\n if (storageClient) return storageClient;\n const { Storage: StorageCtor } = await import(\"@google-cloud/storage\");\n storageClient = new StorageCtor();\n return storageClient;\n}\n\nfunction parseGcsUri(uri: string): GcsTarget {\n const withoutScheme = uri.slice(GCS_PREFIX.length);\n const slashIndex = withoutScheme.indexOf(\"/\");\n\n if (slashIndex === -1) {\n if (!withoutScheme) {\n throw new Error(`Invalid GCS URI: \"${uri}\" (missing bucket name)`);\n }\n return { bucket: withoutScheme, prefix: \"\" };\n }\n\n const bucket = withoutScheme.slice(0, slashIndex);\n const prefix = withoutScheme.slice(slashIndex + 1).replace(/\\/+$/, \"\");\n\n if (!bucket) {\n throw new Error(`Invalid GCS URI: \"${uri}\" (missing bucket name)`);\n }\n\n return { bucket, prefix };\n}\n\nfunction joinGcsPath(prefix: string, relative: string): string {\n const normalizedRelative = relative.replace(/^\\/+/, \"\");\n return prefix ? `${prefix}/${normalizedRelative}` : normalizedRelative;\n}\n\nfunction sanitizeRelativePath(relativePath: string): string {\n if (!relativePath || relativePath.trim() === \"\") {\n throw new Error(\"Export path is empty\");\n }\n\n if (path.isAbsolute(relativePath) || relativePath.startsWith(\"/\")) {\n throw new Error(\n `Export path must be relative, got \"${relativePath}\". ` +\n \"Absolute paths are not allowed.\",\n );\n }\n\n const normalized = path.posix.normalize(relativePath.replace(/\\\\/g, \"/\"));\n\n /**\n * Reject `..` only as a distinct path segment, not as a prefix. A filename\n * like \"..hidden\" is a legitimate dotfile variant, while \"..\" / \"../x\" /\n * \"a/../../x\" all produce a `..` segment after normalization.\n */\n if (normalized.split(\"/\").includes(\"..\")) {\n throw new Error(\n `Export path must not traverse upward, got \"${relativePath}\".`,\n );\n }\n\n return normalized;\n}\n\nfunction ensureExtension(relativePath: string, extension: string): string {\n return relativePath.endsWith(extension)\n ? relativePath\n : `${relativePath}${extension}`;\n}\n\nfunction formatJson(data: unknown): string {\n return `${JSON.stringify(data, null, 2)}\\n`;\n}\n\nconst TEXT_CONTENT_TYPES: Record<string, string> = {\n \".csv\": \"text/csv; charset=utf-8\",\n \".json\": \"application/json\",\n \".svg\": \"image/svg+xml\",\n \".txt\": \"text/plain; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\",\n \".xml\": \"application/xml\",\n \".yaml\": \"application/yaml\",\n \".yml\": \"application/yaml\",\n \".md\": \"text/markdown; charset=utf-8\",\n};\n\nfunction contentTypeFor(relativePath: string): string {\n const ext = path.posix.extname(relativePath).toLowerCase();\n return TEXT_CONTENT_TYPES[ext] ?? \"text/plain; charset=utf-8\";\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"exports.mjs","names":[],"sources":["../src/exports.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { consola } from \"consola\";\nimport type { Storage } from \"@google-cloud/storage\";\n\n/**\n * Writer for job artifacts, produced by `getExportsWriter()`. A single writer\n * instance targets a destination configured via the `exportsPath` runner\n * option (local directory or `gs://` URI). All methods return the resolved\n * absolute path or `gs://` URI of the written artifact.\n */\nexport interface ExportsWriter {\n /**\n * Write a value as pretty-printed JSON (2-space indent, trailing newline).\n * The `.json` extension is added if missing.\n */\n writeJson(relativePath: string, data: unknown): Promise<string>;\n /** Write a UTF-8 string (e.g. CSV, SVG, plain text). */\n writeText(relativePath: string, content: string): Promise<string>;\n /** Write a binary buffer. */\n writeBuffer(\n relativePath: string,\n content: Buffer | Uint8Array,\n ): Promise<string>;\n}\n\nconst GCS_PREFIX = \"gs://\";\n\n/**\n * Return a writer that persists artifacts to the destination configured via\n * the `exportsPath` runner option. Local paths are used for local execution;\n * `gs://bucket[/prefix]` URIs are used for Cloud Run deployments.\n *\n * Throws when no destination is configured.\n */\nexport function getExportsWriter(): ExportsWriter {\n const destination = process.env.JOB_EXPORTS_PATH;\n\n if (!destination) {\n throw new Error(\n \"No exports destination configured.\\n\" +\n \"Set `localExportsPath` or the current environment's `exportsPath` \" +\n \"in your job-runner.config.ts, or set JOB_EXPORTS_PATH directly in \" +\n \"the environment.\",\n );\n }\n\n if (destination.startsWith(GCS_PREFIX)) {\n return createGcsWriter(destination);\n }\n\n return createLocalWriter(destination);\n}\n\nfunction createLocalWriter(basePath: string): ExportsWriter {\n const resolvedBase = path.resolve(basePath);\n\n /** Takes an already-sanitized relative path and writes the content. */\n async function write(\n safeRelative: string,\n content: string | Uint8Array,\n ): Promise<string> {\n const fullPath = path.join(resolvedBase, safeRelative);\n\n await mkdir(path.dirname(fullPath), { recursive: true });\n await writeFile(fullPath, content);\n\n consola.info(`Export written: ${fullPath}`);\n return fullPath;\n }\n\n return {\n async writeJson(relativePath, data) {\n /** Sanitize before appending the extension so \"\" doesn't become \".json\". */\n const withExtension = ensureExtension(\n sanitizeRelativePath(relativePath),\n \".json\",\n );\n return write(withExtension, formatJson(data));\n },\n async writeText(relativePath, content) {\n return write(sanitizeRelativePath(relativePath), content);\n },\n async writeBuffer(relativePath, content) {\n return write(sanitizeRelativePath(relativePath), content);\n },\n };\n}\n\ninterface GcsTarget {\n bucket: string;\n prefix: string;\n}\n\nfunction createGcsWriter(uri: string): ExportsWriter {\n const target = parseGcsUri(uri);\n\n /** Takes an already-sanitized relative path and uploads the content. */\n async function write(\n safeRelative: string,\n content: string | Buffer | Uint8Array,\n contentType: string,\n ): Promise<string> {\n const objectName = joinGcsPath(target.prefix, safeRelative);\n const fullUri = `${GCS_PREFIX}${target.bucket}/${objectName}`;\n\n const storage = await getStorageClient();\n const file = storage.bucket(target.bucket).file(objectName);\n\n const body =\n typeof content === \"string\" || Buffer.isBuffer(content)\n ? content\n : Buffer.from(content);\n\n await file.save(body, {\n contentType,\n resumable: false,\n });\n\n consola.info(`Export written: ${fullUri}`);\n return fullUri;\n }\n\n return {\n async writeJson(relativePath, data) {\n /** Sanitize before appending the extension so \"\" doesn't become \".json\". */\n const withExtension = ensureExtension(\n sanitizeRelativePath(relativePath),\n \".json\",\n );\n return write(withExtension, formatJson(data), \"application/json\");\n },\n async writeText(relativePath, content) {\n const safe = sanitizeRelativePath(relativePath);\n return write(safe, content, contentTypeFor(safe));\n },\n async writeBuffer(relativePath, content) {\n return write(\n sanitizeRelativePath(relativePath),\n content,\n \"application/octet-stream\",\n );\n },\n };\n}\n\n/**\n * Lazy Storage client singleton. The `@google-cloud/storage` module is only\n * loaded when a `gs://` destination is actually used for a write.\n */\nlet storageClient: Storage | null = null;\nasync function getStorageClient(): Promise<Storage> {\n if (storageClient) return storageClient;\n const { Storage: StorageCtor } = await import(\"@google-cloud/storage\");\n storageClient = new StorageCtor();\n return storageClient;\n}\n\nfunction parseGcsUri(uri: string): GcsTarget {\n const withoutScheme = uri.slice(GCS_PREFIX.length);\n const slashIndex = withoutScheme.indexOf(\"/\");\n\n if (slashIndex === -1) {\n if (!withoutScheme) {\n throw new Error(`Invalid GCS URI: \"${uri}\" (missing bucket name)`);\n }\n return { bucket: withoutScheme, prefix: \"\" };\n }\n\n const bucket = withoutScheme.slice(0, slashIndex);\n const prefix = withoutScheme.slice(slashIndex + 1).replace(/\\/+$/, \"\");\n\n if (!bucket) {\n throw new Error(`Invalid GCS URI: \"${uri}\" (missing bucket name)`);\n }\n\n return { bucket, prefix };\n}\n\nfunction joinGcsPath(prefix: string, relative: string): string {\n const normalizedRelative = relative.replace(/^\\/+/, \"\");\n return prefix ? `${prefix}/${normalizedRelative}` : normalizedRelative;\n}\n\nfunction sanitizeRelativePath(relativePath: string): string {\n if (!relativePath || relativePath.trim() === \"\") {\n throw new Error(\"Export path is empty\");\n }\n\n if (path.isAbsolute(relativePath) || relativePath.startsWith(\"/\")) {\n throw new Error(\n `Export path must be relative, got \"${relativePath}\". ` +\n \"Absolute paths are not allowed.\",\n );\n }\n\n const normalized = path.posix.normalize(relativePath.replace(/\\\\/g, \"/\"));\n\n /**\n * Reject `..` only as a distinct path segment, not as a prefix. A filename\n * like \"..hidden\" is a legitimate dotfile variant, while \"..\" / \"../x\" /\n * \"a/../../x\" all produce a `..` segment after normalization.\n */\n if (normalized.split(\"/\").includes(\"..\")) {\n throw new Error(\n `Export path must not traverse upward, got \"${relativePath}\".`,\n );\n }\n\n return normalized;\n}\n\nfunction ensureExtension(relativePath: string, extension: string): string {\n return relativePath.endsWith(extension)\n ? relativePath\n : `${relativePath}${extension}`;\n}\n\nfunction formatJson(data: unknown): string {\n return `${JSON.stringify(data, null, 2)}\\n`;\n}\n\nconst TEXT_CONTENT_TYPES: Record<string, string> = {\n \".csv\": \"text/csv; charset=utf-8\",\n \".json\": \"application/json\",\n \".svg\": \"image/svg+xml\",\n \".txt\": \"text/plain; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\",\n \".xml\": \"application/xml\",\n \".yaml\": \"application/yaml\",\n \".yml\": \"application/yaml\",\n \".md\": \"text/markdown; charset=utf-8\",\n};\n\nfunction contentTypeFor(relativePath: string): string {\n const ext = path.posix.extname(relativePath).toLowerCase();\n return TEXT_CONTENT_TYPES[ext] ?? \"text/plain; charset=utf-8\";\n}\n"],"mappings":"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"}
|
package/dist/interactive.mjs
CHANGED
package/dist/run-job.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { discoverJobs } from "./discover-jobs.mjs";
|
|
2
2
|
import { formatDuration } from "./format.mjs";
|
|
3
3
|
import { promptForArgs, selectJob } from "./interactive.mjs";
|
|
4
|
-
import { consola } from "consola";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { consola } from "consola";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import process from "node:process";
|
|
8
8
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { consola } from "consola";
|
|
7
7
|
import type { ZodObject, ZodRawShape } from "zod";
|
|
8
|
-
import type
|
|
8
|
+
import { resolveLocalExportsPath, type RunnerConfig } from "./config";
|
|
9
9
|
import {
|
|
10
10
|
createOrUpdateJob,
|
|
11
11
|
deployIfChanged,
|
|
@@ -316,14 +316,17 @@ async function handleLocalRun(options: LocalRunOptions): Promise<void> {
|
|
|
316
316
|
process.env.GOOGLE_CLOUD_PROJECT = envConfig.project;
|
|
317
317
|
|
|
318
318
|
/**
|
|
319
|
-
*
|
|
320
|
-
* can use
|
|
321
|
-
*
|
|
319
|
+
* `localExportsPath` wins over the environment's `exportsPath` for local
|
|
320
|
+
* runs so developers can use one destination regardless of whether they
|
|
321
|
+
* point at stag or prod data.
|
|
322
322
|
*/
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
const localExportsPath = resolveLocalExportsPath(
|
|
324
|
+
config,
|
|
325
|
+
envConfig,
|
|
326
|
+
process.cwd(),
|
|
327
|
+
);
|
|
328
|
+
if (localExportsPath !== undefined) {
|
|
329
|
+
process.env.JOB_EXPORTS_PATH = localExportsPath;
|
|
327
330
|
}
|
|
328
331
|
|
|
329
332
|
if (envConfig.secrets && envConfig.secrets.length > 0) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { resolveLocalExportsPath } from "./config";
|
|
4
|
+
|
|
5
|
+
const SERVICE_DIR = "/srv/my-service";
|
|
6
|
+
|
|
7
|
+
describe("resolveLocalExportsPath", () => {
|
|
8
|
+
it("returns undefined when neither localExportsPath nor exportsPath is set", () => {
|
|
9
|
+
expect(resolveLocalExportsPath({}, {}, SERVICE_DIR)).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("prefers localExportsPath over env.exportsPath", () => {
|
|
13
|
+
const result = resolveLocalExportsPath(
|
|
14
|
+
{ localExportsPath: "./exports" },
|
|
15
|
+
{ exportsPath: "gs://stag-bucket" },
|
|
16
|
+
SERVICE_DIR,
|
|
17
|
+
);
|
|
18
|
+
expect(result).toBe(path.resolve(SERVICE_DIR, "./exports"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to env.exportsPath when localExportsPath is unset", () => {
|
|
22
|
+
const result = resolveLocalExportsPath(
|
|
23
|
+
{},
|
|
24
|
+
{ exportsPath: "./exports" },
|
|
25
|
+
SERVICE_DIR,
|
|
26
|
+
);
|
|
27
|
+
expect(result).toBe(path.resolve(SERVICE_DIR, "./exports"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resolves relative paths against the service directory", () => {
|
|
31
|
+
const result = resolveLocalExportsPath(
|
|
32
|
+
{ localExportsPath: "../../shared-exports" },
|
|
33
|
+
{},
|
|
34
|
+
SERVICE_DIR,
|
|
35
|
+
);
|
|
36
|
+
expect(result).toBe(path.resolve(SERVICE_DIR, "../../shared-exports"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("passes gs:// URIs through unchanged from localExportsPath", () => {
|
|
40
|
+
const result = resolveLocalExportsPath(
|
|
41
|
+
{ localExportsPath: "gs://dev-bucket/prefix" },
|
|
42
|
+
{ exportsPath: "gs://stag-bucket" },
|
|
43
|
+
SERVICE_DIR,
|
|
44
|
+
);
|
|
45
|
+
expect(result).toBe("gs://dev-bucket/prefix");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("passes gs:// URIs through unchanged from env.exportsPath fallback", () => {
|
|
49
|
+
const result = resolveLocalExportsPath(
|
|
50
|
+
{},
|
|
51
|
+
{ exportsPath: "gs://stag-bucket/sub" },
|
|
52
|
+
SERVICE_DIR,
|
|
53
|
+
);
|
|
54
|
+
expect(result).toBe("gs://stag-bucket/sub");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("preserves existing behaviour when only env.exportsPath is a local path", () => {
|
|
58
|
+
/** Regression guard for the 1.7.0 behaviour */
|
|
59
|
+
const result = resolveLocalExportsPath(
|
|
60
|
+
{},
|
|
61
|
+
{ exportsPath: "./exports" },
|
|
62
|
+
SERVICE_DIR,
|
|
63
|
+
);
|
|
64
|
+
expect(result).toBe(path.resolve(SERVICE_DIR, "./exports"));
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
/** Environment configuration for a specific deployment target */
|
|
2
4
|
export interface RunnerEnvOptions {
|
|
3
5
|
/** GCP project ID — sets GOOGLE_CLOUD_PROJECT automatically */
|
|
@@ -16,11 +18,14 @@ export interface RunnerEnvOptions {
|
|
|
16
18
|
* Destination for artifacts written via `getExportsWriter()`.
|
|
17
19
|
*
|
|
18
20
|
* Either a local path (resolved relative to the service directory) or a
|
|
19
|
-
* `gs://bucket[/prefix]` URI.
|
|
20
|
-
*
|
|
21
|
+
* `gs://bucket[/prefix]` URI. For most setups this is a `gs://` URI — one
|
|
22
|
+
* per environment — paired with a top-level `localExportsPath` that
|
|
23
|
+
* catches every local run regardless of which environment is selected.
|
|
24
|
+
*
|
|
25
|
+
* When unset, `getExportsWriter()` throws (unless `localExportsPath` is
|
|
26
|
+
* set and the run is local). Cloud deployments require a `gs://` URI.
|
|
21
27
|
*
|
|
22
|
-
*
|
|
23
|
-
* running locally; cloud deployments require a `gs://` URI.
|
|
28
|
+
* @see RunnerConfig.localExportsPath
|
|
24
29
|
*/
|
|
25
30
|
exportsPath?: string;
|
|
26
31
|
}
|
|
@@ -84,6 +89,17 @@ export interface RunnerConfig {
|
|
|
84
89
|
};
|
|
85
90
|
/** Named environments (e.g., stag, prod) */
|
|
86
91
|
environments: Record<string, RunnerEnvOptions>;
|
|
92
|
+
/**
|
|
93
|
+
* Destination used by `getExportsWriter()` for every local run, regardless
|
|
94
|
+
* of which environment is selected. Resolved relative to the service
|
|
95
|
+
* directory; `gs://bucket[/prefix]` URIs are also accepted.
|
|
96
|
+
*
|
|
97
|
+
* When set, local runs ignore the environment's `exportsPath`. When unset,
|
|
98
|
+
* local runs fall back to the environment's `exportsPath`.
|
|
99
|
+
*
|
|
100
|
+
* Cloud runs always use the environment's `exportsPath`.
|
|
101
|
+
*/
|
|
102
|
+
localExportsPath?: string;
|
|
87
103
|
/** Cloud Run Jobs configuration (required for `job cloud run/deploy` commands) */
|
|
88
104
|
cloud?: CloudConfig;
|
|
89
105
|
/**
|
|
@@ -103,3 +119,19 @@ export function defineRunnerConfig(config: RunnerConfig): RunnerConfig {
|
|
|
103
119
|
export function defineRunnerEnv(options: RunnerEnvOptions): RunnerEnvOptions {
|
|
104
120
|
return options;
|
|
105
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the exports destination for a local run. Prefers the top-level
|
|
125
|
+
* `localExportsPath` over the environment's `exportsPath`. Local paths are
|
|
126
|
+
* resolved against the service directory; `gs://` URIs pass through
|
|
127
|
+
* unchanged.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveLocalExportsPath(
|
|
130
|
+
config: Pick<RunnerConfig, "localExportsPath">,
|
|
131
|
+
envConfig: Pick<RunnerEnvOptions, "exportsPath">,
|
|
132
|
+
serviceDirectory: string,
|
|
133
|
+
): string | undefined {
|
|
134
|
+
const raw = config.localExportsPath ?? envConfig.exportsPath;
|
|
135
|
+
if (!raw) return undefined;
|
|
136
|
+
return raw.startsWith("gs://") ? raw : path.resolve(serviceDirectory, raw);
|
|
137
|
+
}
|
package/src/exports.ts
CHANGED
|
@@ -39,8 +39,9 @@ export function getExportsWriter(): ExportsWriter {
|
|
|
39
39
|
if (!destination) {
|
|
40
40
|
throw new Error(
|
|
41
41
|
"No exports destination configured.\n" +
|
|
42
|
-
"Set `
|
|
43
|
-
"or set JOB_EXPORTS_PATH directly in
|
|
42
|
+
"Set `localExportsPath` or the current environment's `exportsPath` " +
|
|
43
|
+
"in your job-runner.config.ts, or set JOB_EXPORTS_PATH directly in " +
|
|
44
|
+
"the environment.",
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
47
|
|