gcp-job-runner 1.0.0-2 → 1.0.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,3 +1,61 @@
1
- # code-conventions
1
+ # gcp-job-runner
2
2
 
3
- Formalize and sync code-conventions across repositories
3
+ Define jobs with Zod schemas, run them locally or on Cloud Run.
4
+
5
+ ## Quick Look
6
+
7
+ ```typescript
8
+ import { z } from "zod";
9
+ import { defineJob } from "gcp-job-runner";
10
+
11
+ export default defineJob({
12
+ description: "Count down and exit",
13
+ schema: z.object({
14
+ seconds: z.number().default(10).describe("Number of seconds to count down"),
15
+ }),
16
+ handler: async ({ seconds }) => {
17
+ for (let i = seconds; i > 0; i--) {
18
+ console.log(`${i}...`);
19
+ await new Promise((resolve) => setTimeout(resolve, 1000));
20
+ }
21
+ console.log("Done!");
22
+ },
23
+ });
24
+ ```
25
+
26
+ Run it locally:
27
+
28
+ ```bash
29
+ job local run stag countdown --seconds 5
30
+ ```
31
+
32
+ Run it on Cloud Run:
33
+
34
+ ```bash
35
+ job cloud run stag countdown --seconds 5
36
+ ```
37
+
38
+ Same code, same arguments, same secrets. The cloud command automatically builds a Docker image, pushes it to Artifact Registry, and streams logs back to your terminal. Images are cached by content hash — only source code changes trigger a rebuild.
39
+
40
+ ## Features
41
+
42
+ - **Zod validation** — arguments are validated before your handler runs, with auto-generated `--help` for every job
43
+ - **Interactive mode** — browse jobs and fill in arguments interactively with `--interactive`
44
+ - **Cloud Run deployment** — no Terraform or manual GCP config needed, just `job cloud run`
45
+ - **Smart caching** — a single Docker image contains all jobs; running different jobs or different arguments doesn't rebuild
46
+ - **GCP Secret Manager** — secrets are loaded transparently for both local and cloud execution
47
+ - **Multi-environment** — configure staging, production, etc. and switch with a single argument
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ npm install gcp-job-runner
53
+ ```
54
+
55
+ ## Documentation
56
+
57
+ Full documentation is available at [0x80.github.io/gcp-job-runner](https://0x80.github.io/gcp-job-runner/).
58
+
59
+ ## License
60
+
61
+ MIT
@@ -173,14 +173,11 @@ function getArrayElementType(schema) {
173
173
  */
174
174
  function getBaseType(schema) {
175
175
  let def = schema._def;
176
- /** Unwrap ZodEffects (refinements, transforms) */
177
- while (getType(def) === "ZodEffects" && def.schema) def = def.schema._def;
178
- /** Unwrap optional */
179
- if (getType(def) === "optional" && def.innerType) def = def.innerType._def;
180
- /** Unwrap default */
181
- if (getType(def) === "default" && def.innerType) def = def.innerType._def;
182
- /** Unwrap any remaining effects */
183
- while (getType(def) === "ZodEffects" && def.schema) def = def.schema._def;
176
+ /** Unwrap optional/default/effects in any order until we reach a base type */
177
+ while ((getType(def) === "optional" || getType(def) === "default" || getType(def) === "ZodEffects") && (def.innerType || def.schema)) {
178
+ const inner = def.innerType ?? def.schema;
179
+ if (inner) def = inner._def;
180
+ }
184
181
  switch (getType(def)) {
185
182
  case "array":
186
183
  case "ZodArray": return "array";
@@ -1 +1 @@
1
- {"version":3,"file":"define-job.mjs","names":[],"sources":["../src/define-job.ts"],"sourcesContent":["import { parseArgs } from \"node:util\";\nimport { consola } from \"consola\";\nimport type { ZodObject, ZodRawShape, ZodType } from \"zod\";\nimport { z } from \"zod\";\nimport {\n formatZodError,\n generateFullHelp,\n schemaToParseArgsOptions,\n} from \"./help\";\nimport type { FlagAliases, JobFunction, JobOptions } from \"./types\";\n\n/** Internal Zod definition type for introspection */\ninterface ZodDef {\n type?: string;\n typeName?: string;\n schema?: ZodType;\n innerType?: ZodType;\n element?: ZodType;\n}\n\n/**\n * Create a job with Zod schema validation.\n *\n * Returns a function with signature (argv, jobName) => Promise<void> that is\n * called by runJob(). The function:\n * - Parses flags from argv using Node's built-in `parseArgs`\n * - Handles --help (prints help and returns)\n * - Validates args against the Zod schema (strict mode, rejects unknown)\n * - Calls the handler with validated, typed args\n *\n * @example\n * ```typescript\n * const ArgsSchema = z.object({\n * name: z.string().describe(\"Your name\"),\n * verbose: z.boolean().optional().default(false),\n * });\n *\n * export default defineJob({\n * schema: ArgsSchema,\n * handler: async (args) => {\n * console.log(`Hello, ${args.name}!`);\n * },\n * description: \"Greet someone\",\n * });\n * ```\n */\nexport function defineJob<T extends ZodRawShape = ZodRawShape>(\n options: JobOptions<T>,\n): JobFunction {\n const schema = options.schema ?? (z.object({}) as unknown as ZodObject<T>);\n const { handler } = options;\n\n const fn: JobFunction = async (\n argv: string[],\n jobName: string,\n commandPrefix?: string,\n ): Promise<void> => {\n /** Parse args from argv */\n const parsed = parseArgv(argv, schema, options.aliases);\n\n const helpOptions = {\n ...options,\n name: jobName,\n commandPrefix: commandPrefix ?? options.commandPrefix,\n };\n\n /** Handle --help */\n if (parsed.help === true) {\n const helpText = generateFullHelp(schema, helpOptions);\n consola.box(helpText);\n return;\n }\n\n /** Validate with strict mode (reject unknown fields) */\n const strictSchema = schema.strict();\n const result = strictSchema.safeParse(parsed);\n\n if (!result.success) {\n const errorText = formatZodError(result.error);\n const helpText = generateFullHelp(schema, helpOptions);\n consola.error(`${errorText}\\n\\n${helpText}`);\n process.exit(1);\n }\n\n await handler(result.data);\n };\n\n /** Attach metadata for discovery and interactive mode */\n fn.__metadata = {\n description: options.description,\n schema,\n };\n\n return fn;\n}\n\n/**\n * Convert kebab-case to camelCase.\n */\nfunction toCamelCase(str: string): string {\n return str.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());\n}\n\n/**\n * Parse argv into a merged args object using Node's built-in `parseArgs`.\n * Supports both --args JSON format and individual flags.\n * Coerces values to match the schema types.\n */\nfunction parseArgv<T extends ZodRawShape>(\n argv: string[],\n schema: ZodObject<T>,\n aliases?: FlagAliases,\n): Record<string, unknown> {\n /** Extract --args JSON if present */\n let jsonArgs: Record<string, unknown> = {};\n let filteredArgv = argv;\n const argsIndex = argv.findIndex((arg) => arg === \"--args\" || arg === \"-a\");\n\n if (argsIndex !== -1) {\n const argsValue = argv[argsIndex + 1];\n if (argsValue?.trim().startsWith(\"{\")) {\n try {\n const parsed = JSON.parse(argsValue) as unknown;\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n !Array.isArray(parsed)\n ) {\n jsonArgs = parsed as Record<string, unknown>;\n }\n } catch {\n /** Ignore JSON parse errors */\n }\n }\n /** Remove --args/-a and its value from argv before passing to parseArgs */\n filteredArgv = [...argv.slice(0, argsIndex), ...argv.slice(argsIndex + 2)];\n }\n\n /** Build parseArgs options from schema and aliases */\n const options = schemaToParseArgsOptions(schema, aliases);\n\n /** Add built-in help option */\n options.help = { type: \"boolean\", short: \"h\" };\n\n /** Parse with strict: false to allow unknown flags (Zod handles rejection) */\n const { values } = parseArgs({\n args: filteredArgv,\n options,\n strict: false,\n allowPositionals: true,\n });\n\n /** Build flag args, converting kebab-case to camelCase and resolving aliases */\n const flagArgs: Record<string, unknown> = {};\n\n for (const [key, value] of Object.entries(values)) {\n if (value === undefined) continue;\n const camelKey = toCamelCase(key);\n const resolvedKey = aliases?.[camelKey] ?? camelKey;\n flagArgs[resolvedKey] = value;\n }\n\n /** Merge: flags take precedence over JSON args */\n const merged = { ...jsonArgs, ...flagArgs };\n\n /** Coerce values to match schema types */\n return coerceToSchema(merged, schema);\n}\n\n/**\n * Coerce parsed values to match schema types.\n * - Converts values to the expected type (string, number, boolean)\n * - Wraps single values in arrays if the schema expects an array\n */\nfunction coerceToSchema<T extends ZodRawShape>(\n values: Record<string, unknown>,\n schema: ZodObject<T>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = { ...values };\n const shape = schema.shape;\n\n for (const [key, value] of Object.entries(result)) {\n const fieldSchema = shape[key] as ZodType | undefined;\n if (!fieldSchema || value === undefined) continue;\n\n const baseType = getBaseType(fieldSchema);\n\n if (baseType === \"array\") {\n const elementType = getArrayElementType(fieldSchema);\n if (Array.isArray(value)) {\n result[key] = value.map((v) => coerceValue(v, elementType));\n } else {\n result[key] = [coerceValue(value, elementType)];\n }\n } else if (baseType !== null) {\n result[key] = coerceValue(value, baseType);\n }\n }\n\n return result;\n}\n\n/**\n * Coerce a single value to the expected type.\n */\nfunction coerceValue(value: unknown, targetType: string | null): unknown {\n if (value === undefined || value === null || targetType === null)\n return value;\n\n switch (targetType) {\n case \"string\":\n return `${value as string | number | boolean}`;\n case \"number\":\n return typeof value === \"number\" ? value : Number(value);\n case \"boolean\":\n if (typeof value === \"boolean\") return value;\n if (value === \"true\") return true;\n if (value === \"false\") return false;\n return Boolean(value);\n default:\n return value;\n }\n}\n\n/**\n * Get the element type of an array schema.\n */\nfunction getArrayElementType(schema: ZodType): string | null {\n let def = schema._def as ZodDef;\n\n /** Unwrap optional/default/effects to get to the array */\n while (\n (getType(def) === \"optional\" ||\n getType(def) === \"default\" ||\n getType(def) === \"ZodEffects\") &&\n (def.innerType || def.schema)\n ) {\n const inner = def.innerType ?? def.schema;\n if (inner) {\n def = inner._def as ZodDef;\n }\n }\n\n /** Get the element schema from the array */\n const typeName = getType(def);\n if ((typeName === \"array\" || typeName === \"ZodArray\") && def.element) {\n return getBaseType(def.element);\n }\n\n return null;\n}\n\n/**\n * Get the base type of a Zod schema, unwrapping optional/default/effects\n * wrappers. Returns null if the type cannot be confidently determined.\n */\nfunction getBaseType(schema: ZodType): string | null {\n let def = schema._def as ZodDef;\n\n /** Unwrap ZodEffects (refinements, transforms) */\n while (getType(def) === \"ZodEffects\" && def.schema) {\n def = def.schema._def as ZodDef;\n }\n\n /** Unwrap optional */\n if (getType(def) === \"optional\" && def.innerType) {\n def = def.innerType._def as ZodDef;\n }\n\n /** Unwrap default */\n if (getType(def) === \"default\" && def.innerType) {\n def = def.innerType._def as ZodDef;\n }\n\n /** Unwrap any remaining effects */\n while (getType(def) === \"ZodEffects\" && def.schema) {\n def = def.schema._def as ZodDef;\n }\n\n const typeName = getType(def);\n switch (typeName) {\n case \"array\":\n case \"ZodArray\":\n return \"array\";\n case \"number\":\n case \"ZodNumber\":\n case \"int\":\n case \"ZodInt\":\n return \"number\";\n case \"boolean\":\n case \"ZodBoolean\":\n return \"boolean\";\n case \"string\":\n case \"ZodString\":\n return \"string\";\n default:\n return null;\n }\n}\n\n/**\n * Get type string from a Zod definition, handling both v3 and v4 formats.\n */\nfunction getType(def: ZodDef): string | undefined {\n return def.type ?? def.typeName;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"define-job.mjs","names":[],"sources":["../src/define-job.ts"],"sourcesContent":["import { parseArgs } from \"node:util\";\nimport { consola } from \"consola\";\nimport type { ZodObject, ZodRawShape, ZodType } from \"zod\";\nimport { z } from \"zod\";\nimport {\n formatZodError,\n generateFullHelp,\n schemaToParseArgsOptions,\n} from \"./help\";\nimport type { FlagAliases, JobFunction, JobOptions } from \"./types\";\n\n/** Internal Zod definition type for introspection */\ninterface ZodDef {\n type?: string;\n typeName?: string;\n schema?: ZodType;\n innerType?: ZodType;\n element?: ZodType;\n}\n\n/**\n * Create a job with Zod schema validation.\n *\n * Returns a function with signature (argv, jobName) => Promise<void> that is\n * called by runJob(). The function:\n * - Parses flags from argv using Node's built-in `parseArgs`\n * - Handles --help (prints help and returns)\n * - Validates args against the Zod schema (strict mode, rejects unknown)\n * - Calls the handler with validated, typed args\n *\n * @example\n * ```typescript\n * const ArgsSchema = z.object({\n * name: z.string().describe(\"Your name\"),\n * verbose: z.boolean().optional().default(false),\n * });\n *\n * export default defineJob({\n * schema: ArgsSchema,\n * handler: async (args) => {\n * console.log(`Hello, ${args.name}!`);\n * },\n * description: \"Greet someone\",\n * });\n * ```\n */\nexport function defineJob<T extends ZodRawShape = ZodRawShape>(\n options: JobOptions<T>,\n): JobFunction {\n const schema = options.schema ?? (z.object({}) as unknown as ZodObject<T>);\n const { handler } = options;\n\n const fn: JobFunction = async (\n argv: string[],\n jobName: string,\n commandPrefix?: string,\n ): Promise<void> => {\n /** Parse args from argv */\n const parsed = parseArgv(argv, schema, options.aliases);\n\n const helpOptions = {\n ...options,\n name: jobName,\n commandPrefix: commandPrefix ?? options.commandPrefix,\n };\n\n /** Handle --help */\n if (parsed.help === true) {\n const helpText = generateFullHelp(schema, helpOptions);\n consola.box(helpText);\n return;\n }\n\n /** Validate with strict mode (reject unknown fields) */\n const strictSchema = schema.strict();\n const result = strictSchema.safeParse(parsed);\n\n if (!result.success) {\n const errorText = formatZodError(result.error);\n const helpText = generateFullHelp(schema, helpOptions);\n consola.error(`${errorText}\\n\\n${helpText}`);\n process.exit(1);\n }\n\n await handler(result.data);\n };\n\n /** Attach metadata for discovery and interactive mode */\n fn.__metadata = {\n description: options.description,\n schema,\n };\n\n return fn;\n}\n\n/**\n * Convert kebab-case to camelCase.\n */\nfunction toCamelCase(str: string): string {\n return str.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());\n}\n\n/**\n * Parse argv into a merged args object using Node's built-in `parseArgs`.\n * Supports both --args JSON format and individual flags.\n * Coerces values to match the schema types.\n */\nfunction parseArgv<T extends ZodRawShape>(\n argv: string[],\n schema: ZodObject<T>,\n aliases?: FlagAliases,\n): Record<string, unknown> {\n /** Extract --args JSON if present */\n let jsonArgs: Record<string, unknown> = {};\n let filteredArgv = argv;\n const argsIndex = argv.findIndex((arg) => arg === \"--args\" || arg === \"-a\");\n\n if (argsIndex !== -1) {\n const argsValue = argv[argsIndex + 1];\n if (argsValue?.trim().startsWith(\"{\")) {\n try {\n const parsed = JSON.parse(argsValue) as unknown;\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n !Array.isArray(parsed)\n ) {\n jsonArgs = parsed as Record<string, unknown>;\n }\n } catch {\n /** Ignore JSON parse errors */\n }\n }\n /** Remove --args/-a and its value from argv before passing to parseArgs */\n filteredArgv = [...argv.slice(0, argsIndex), ...argv.slice(argsIndex + 2)];\n }\n\n /** Build parseArgs options from schema and aliases */\n const options = schemaToParseArgsOptions(schema, aliases);\n\n /** Add built-in help option */\n options.help = { type: \"boolean\", short: \"h\" };\n\n /** Parse with strict: false to allow unknown flags (Zod handles rejection) */\n const { values } = parseArgs({\n args: filteredArgv,\n options,\n strict: false,\n allowPositionals: true,\n });\n\n /** Build flag args, converting kebab-case to camelCase and resolving aliases */\n const flagArgs: Record<string, unknown> = {};\n\n for (const [key, value] of Object.entries(values)) {\n if (value === undefined) continue;\n const camelKey = toCamelCase(key);\n const resolvedKey = aliases?.[camelKey] ?? camelKey;\n flagArgs[resolvedKey] = value;\n }\n\n /** Merge: flags take precedence over JSON args */\n const merged = { ...jsonArgs, ...flagArgs };\n\n /** Coerce values to match schema types */\n return coerceToSchema(merged, schema);\n}\n\n/**\n * Coerce parsed values to match schema types.\n * - Converts values to the expected type (string, number, boolean)\n * - Wraps single values in arrays if the schema expects an array\n */\nfunction coerceToSchema<T extends ZodRawShape>(\n values: Record<string, unknown>,\n schema: ZodObject<T>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = { ...values };\n const shape = schema.shape;\n\n for (const [key, value] of Object.entries(result)) {\n const fieldSchema = shape[key] as ZodType | undefined;\n if (!fieldSchema || value === undefined) continue;\n\n const baseType = getBaseType(fieldSchema);\n\n if (baseType === \"array\") {\n const elementType = getArrayElementType(fieldSchema);\n if (Array.isArray(value)) {\n result[key] = value.map((v) => coerceValue(v, elementType));\n } else {\n result[key] = [coerceValue(value, elementType)];\n }\n } else if (baseType !== null) {\n result[key] = coerceValue(value, baseType);\n }\n }\n\n return result;\n}\n\n/**\n * Coerce a single value to the expected type.\n */\nfunction coerceValue(value: unknown, targetType: string | null): unknown {\n if (value === undefined || value === null || targetType === null)\n return value;\n\n switch (targetType) {\n case \"string\":\n return `${value as string | number | boolean}`;\n case \"number\":\n return typeof value === \"number\" ? value : Number(value);\n case \"boolean\":\n if (typeof value === \"boolean\") return value;\n if (value === \"true\") return true;\n if (value === \"false\") return false;\n return Boolean(value);\n default:\n return value;\n }\n}\n\n/**\n * Get the element type of an array schema.\n */\nfunction getArrayElementType(schema: ZodType): string | null {\n let def = schema._def as ZodDef;\n\n /** Unwrap optional/default/effects to get to the array */\n while (\n (getType(def) === \"optional\" ||\n getType(def) === \"default\" ||\n getType(def) === \"ZodEffects\") &&\n (def.innerType || def.schema)\n ) {\n const inner = def.innerType ?? def.schema;\n if (inner) {\n def = inner._def as ZodDef;\n }\n }\n\n /** Get the element schema from the array */\n const typeName = getType(def);\n if ((typeName === \"array\" || typeName === \"ZodArray\") && def.element) {\n return getBaseType(def.element);\n }\n\n return null;\n}\n\n/**\n * Get the base type of a Zod schema, unwrapping optional/default/effects\n * wrappers. Returns null if the type cannot be confidently determined.\n */\nfunction getBaseType(schema: ZodType): string | null {\n let def = schema._def as ZodDef;\n\n /** Unwrap optional/default/effects in any order until we reach a base type */\n while (\n (getType(def) === \"optional\" ||\n getType(def) === \"default\" ||\n getType(def) === \"ZodEffects\") &&\n (def.innerType || def.schema)\n ) {\n const inner = def.innerType ?? def.schema;\n if (inner) {\n def = inner._def as ZodDef;\n }\n }\n\n const typeName = getType(def);\n switch (typeName) {\n case \"array\":\n case \"ZodArray\":\n return \"array\";\n case \"number\":\n case \"ZodNumber\":\n case \"int\":\n case \"ZodInt\":\n return \"number\";\n case \"boolean\":\n case \"ZodBoolean\":\n return \"boolean\";\n case \"string\":\n case \"ZodString\":\n return \"string\";\n default:\n return null;\n }\n}\n\n/**\n * Get type string from a Zod definition, handling both v3 and v4 formats.\n */\nfunction getType(def: ZodDef): string | undefined {\n return def.type ?? def.typeName;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8CA,SAAgB,UACd,SACa;CACb,MAAM,SAAS,QAAQ,UAAW,EAAE,OAAO,EAAE,CAAC;CAC9C,MAAM,EAAE,YAAY;CAEpB,MAAM,KAAkB,OACtB,MACA,SACA,kBACkB;;EAElB,MAAM,SAAS,UAAU,MAAM,QAAQ,QAAQ,QAAQ;EAEvD,MAAM,cAAc;GAClB,GAAG;GACH,MAAM;GACN,eAAe,iBAAiB,QAAQ;GACzC;;AAGD,MAAI,OAAO,SAAS,MAAM;GACxB,MAAM,WAAW,iBAAiB,QAAQ,YAAY;AACtD,WAAQ,IAAI,SAAS;AACrB;;EAKF,MAAM,SADe,OAAO,QAAQ,CACR,UAAU,OAAO;AAE7C,MAAI,CAAC,OAAO,SAAS;GACnB,MAAM,YAAY,eAAe,OAAO,MAAM;GAC9C,MAAM,WAAW,iBAAiB,QAAQ,YAAY;AACtD,WAAQ,MAAM,GAAG,UAAU,MAAM,WAAW;AAC5C,WAAQ,KAAK,EAAE;;AAGjB,QAAM,QAAQ,OAAO,KAAK;;;AAI5B,IAAG,aAAa;EACd,aAAa,QAAQ;EACrB;EACD;AAED,QAAO;;;;;AAMT,SAAS,YAAY,KAAqB;AACxC,QAAO,IAAI,QAAQ,cAAc,GAAG,WAAmB,OAAO,aAAa,CAAC;;;;;;;AAQ9E,SAAS,UACP,MACA,QACA,SACyB;;CAEzB,IAAI,WAAoC,EAAE;CAC1C,IAAI,eAAe;CACnB,MAAM,YAAY,KAAK,WAAW,QAAQ,QAAQ,YAAY,QAAQ,KAAK;AAE3E,KAAI,cAAc,IAAI;EACpB,MAAM,YAAY,KAAK,YAAY;AACnC,MAAI,WAAW,MAAM,CAAC,WAAW,IAAI,CACnC,KAAI;GACF,MAAM,SAAS,KAAK,MAAM,UAAU;AACpC,OACE,OAAO,WAAW,YAClB,WAAW,QACX,CAAC,MAAM,QAAQ,OAAO,CAEtB,YAAW;UAEP;;AAKV,iBAAe,CAAC,GAAG,KAAK,MAAM,GAAG,UAAU,EAAE,GAAG,KAAK,MAAM,YAAY,EAAE,CAAC;;;CAI5E,MAAM,UAAU,yBAAyB,QAAQ,QAAQ;;AAGzD,SAAQ,OAAO;EAAE,MAAM;EAAW,OAAO;EAAK;;CAG9C,MAAM,EAAE,WAAW,UAAU;EAC3B,MAAM;EACN;EACA,QAAQ;EACR,kBAAkB;EACnB,CAAC;;CAGF,MAAM,WAAoC,EAAE;AAE5C,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,EAAE;AACjD,MAAI,UAAU,OAAW;EACzB,MAAM,WAAW,YAAY,IAAI;EACjC,MAAM,cAAc,UAAU,aAAa;AAC3C,WAAS,eAAe;;;AAO1B,QAAO,eAHQ;EAAE,GAAG;EAAU,GAAG;EAAU,EAGb,OAAO;;;;;;;AAQvC,SAAS,eACP,QACA,QACyB;CACzB,MAAM,SAAkC,EAAE,GAAG,QAAQ;CACrD,MAAM,QAAQ,OAAO;AAErB,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,EAAE;EACjD,MAAM,cAAc,MAAM;AAC1B,MAAI,CAAC,eAAe,UAAU,OAAW;EAEzC,MAAM,WAAW,YAAY,YAAY;AAEzC,MAAI,aAAa,SAAS;GACxB,MAAM,cAAc,oBAAoB,YAAY;AACpD,OAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,OAAO,MAAM,KAAK,MAAM,YAAY,GAAG,YAAY,CAAC;OAE3D,QAAO,OAAO,CAAC,YAAY,OAAO,YAAY,CAAC;aAExC,aAAa,KACtB,QAAO,OAAO,YAAY,OAAO,SAAS;;AAI9C,QAAO;;;;;AAMT,SAAS,YAAY,OAAgB,YAAoC;AACvE,KAAI,UAAU,UAAa,UAAU,QAAQ,eAAe,KAC1D,QAAO;AAET,SAAQ,YAAR;EACE,KAAK,SACH,QAAO,GAAG;EACZ,KAAK,SACH,QAAO,OAAO,UAAU,WAAW,QAAQ,OAAO,MAAM;EAC1D,KAAK;AACH,OAAI,OAAO,UAAU,UAAW,QAAO;AACvC,OAAI,UAAU,OAAQ,QAAO;AAC7B,OAAI,UAAU,QAAS,QAAO;AAC9B,UAAO,QAAQ,MAAM;EACvB,QACE,QAAO;;;;;;AAOb,SAAS,oBAAoB,QAAgC;CAC3D,IAAI,MAAM,OAAO;;AAGjB,SACG,QAAQ,IAAI,KAAK,cAChB,QAAQ,IAAI,KAAK,aACjB,QAAQ,IAAI,KAAK,kBAClB,IAAI,aAAa,IAAI,SACtB;EACA,MAAM,QAAQ,IAAI,aAAa,IAAI;AACnC,MAAI,MACF,OAAM,MAAM;;;CAKhB,MAAM,WAAW,QAAQ,IAAI;AAC7B,MAAK,aAAa,WAAW,aAAa,eAAe,IAAI,QAC3D,QAAO,YAAY,IAAI,QAAQ;AAGjC,QAAO;;;;;;AAOT,SAAS,YAAY,QAAgC;CACnD,IAAI,MAAM,OAAO;;AAGjB,SACG,QAAQ,IAAI,KAAK,cAChB,QAAQ,IAAI,KAAK,aACjB,QAAQ,IAAI,KAAK,kBAClB,IAAI,aAAa,IAAI,SACtB;EACA,MAAM,QAAQ,IAAI,aAAa,IAAI;AACnC,MAAI,MACF,OAAM,MAAM;;AAKhB,SADiB,QAAQ,IAAI,EAC7B;EACE,KAAK;EACL,KAAK,WACH,QAAO;EACT,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,SACH,QAAO;EACT,KAAK;EACL,KAAK,aACH,QAAO;EACT,KAAK;EACL,KAAK,YACH,QAAO;EACT,QACE,QAAO;;;;;;AAOb,SAAS,QAAQ,KAAiC;AAChD,QAAO,IAAI,QAAQ,IAAI"}
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "gcp-job-runner",
3
- "version": "1.0.0-2",
3
+ "version": "1.0.0",
4
4
  "description": "Trivially run the same jobs locally or in the cloud",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/0x80/gcp-job-runner"
9
+ },
6
10
  "bin": {
7
11
  "job": "dist/cli.mjs"
8
12
  },
@@ -19,12 +23,28 @@
19
23
  "./run-cloud": "./dist/run-cloud.mjs",
20
24
  "./package.json": "./package.json"
21
25
  },
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "dev": "tsdown --watch",
29
+ "check-types": "tsc --noEmit",
30
+ "check-lint": "oxlint -c .oxlintrc.json --import-plugin",
31
+ "format": "oxfmt",
32
+ "check-format": "oxfmt --check",
33
+ "test": "vitest",
34
+ "test:integration": "vitest run --config vitest.integration.config.ts",
35
+ "coverage": "vitest run --coverage",
36
+ "clean": "del-cli dist *.tsbuildinfo",
37
+ "docs:dev": "vitepress dev docs",
38
+ "docs:build": "vitepress build docs",
39
+ "docs:preview": "vitepress preview docs",
40
+ "prepare": "husky && tsdown"
41
+ },
22
42
  "dependencies": {
23
43
  "@google-cloud/logging": "^11.2.0",
24
44
  "@google-cloud/secret-manager": "^6.1.1",
25
45
  "consola": "^3.4.0",
26
46
  "execa": "^9.6.1",
27
- "isolate-package": "1.27.0-8",
47
+ "isolate-package": "1.27.0",
28
48
  "zod": "^4.3.6"
29
49
  },
30
50
  "devDependencies": {
@@ -37,6 +57,7 @@
37
57
  "oxlint": "^1.47.0",
38
58
  "tsdown": "^0.20.1",
39
59
  "typescript": "^5.9.3",
60
+ "vitepress": "^1.6.4",
40
61
  "vitest": "^4.0.18"
41
62
  },
42
63
  "lint-staged": {
@@ -51,15 +72,5 @@
51
72
  "engines": {
52
73
  "node": ">=22.0.0"
53
74
  },
54
- "scripts": {
55
- "build": "tsdown",
56
- "dev": "tsdown --watch",
57
- "check-types": "tsc --noEmit",
58
- "check-lint": "oxlint -c .oxlintrc.json --import-plugin",
59
- "format": "oxfmt",
60
- "check-format": "oxfmt --check",
61
- "test": "vitest",
62
- "coverage": "vitest run --coverage",
63
- "clean": "del-cli dist *.tsbuildinfo"
64
- }
65
- }
75
+ "packageManager": "pnpm@10.22.0"
76
+ }
@@ -0,0 +1,199 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { execa } from "execa";
7
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
8
+
9
+ const projectRoot = path.resolve(import.meta.dirname, "../..");
10
+
11
+ /**
12
+ * Build a fixture package.json for gcp-job-runner by deriving it from the real
13
+ * one. Strips isolate-package (build-time only), devDependencies, and scripts.
14
+ */
15
+ function buildFixturePackageJson(): Record<string, unknown> {
16
+ const real = JSON.parse(
17
+ readFileSync(path.join(projectRoot, "package.json"), "utf-8"),
18
+ ) as Record<string, unknown>;
19
+
20
+ const dependencies = { ...(real.dependencies as Record<string, string>) };
21
+ delete dependencies["isolate-package"];
22
+
23
+ return {
24
+ name: real.name,
25
+ version: real.version,
26
+ type: real.type,
27
+ files: real.files,
28
+ exports: real.exports,
29
+ dependencies,
30
+ };
31
+ }
32
+
33
+ describe("isolation pipeline", () => {
34
+ let workspaceRoot: string;
35
+ let serviceDirectory: string;
36
+ let isolateDirectory: string;
37
+
38
+ beforeAll(async () => {
39
+ /** Preconditions */
40
+ const { exitCode } = await execa("pnpm", ["--version"], {
41
+ reject: false,
42
+ });
43
+ expect(exitCode, "pnpm must be available").toBe(0);
44
+ expect(
45
+ existsSync(path.join(projectRoot, "dist")),
46
+ "dist/ must exist — run pnpm build first",
47
+ ).toBe(true);
48
+
49
+ /** Create temp pnpm workspace */
50
+ workspaceRoot = path.join(
51
+ tmpdir(),
52
+ `isolation-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
53
+ );
54
+
55
+ const runnerPackageDirectory = path.join(
56
+ workspaceRoot,
57
+ "packages/gcp-job-runner",
58
+ );
59
+ serviceDirectory = path.join(workspaceRoot, "packages/test-service");
60
+ isolateDirectory = path.join(serviceDirectory, "isolate");
61
+
62
+ await mkdir(runnerPackageDirectory, { recursive: true });
63
+ await mkdir(path.join(serviceDirectory, "dist/jobs"), { recursive: true });
64
+
65
+ /** Write workspace root config */
66
+ await writeFile(
67
+ path.join(workspaceRoot, "pnpm-workspace.yaml"),
68
+ 'packages:\n - "packages/*"\n',
69
+ );
70
+ await writeFile(
71
+ path.join(workspaceRoot, "package.json"),
72
+ JSON.stringify({ name: "test-workspace", private: true }, null, 2),
73
+ );
74
+
75
+ /** Write gcp-job-runner fixture package */
76
+ await writeFile(
77
+ path.join(runnerPackageDirectory, "package.json"),
78
+ JSON.stringify(buildFixturePackageJson(), null, 2),
79
+ );
80
+ await cp(
81
+ path.join(projectRoot, "dist"),
82
+ path.join(runnerPackageDirectory, "dist"),
83
+ { recursive: true },
84
+ );
85
+
86
+ /** Write test-service package */
87
+ await writeFile(
88
+ path.join(serviceDirectory, "package.json"),
89
+ JSON.stringify(
90
+ {
91
+ name: "test-service",
92
+ version: "0.0.0",
93
+ private: true,
94
+ type: "module",
95
+ files: ["dist"],
96
+ dependencies: {
97
+ "gcp-job-runner": "workspace:*",
98
+ },
99
+ },
100
+ null,
101
+ 2,
102
+ ),
103
+ );
104
+
105
+ /** Write a minimal hello job */
106
+ await writeFile(
107
+ path.join(serviceDirectory, "dist/jobs/hello.mjs"),
108
+ [
109
+ 'import { defineJob } from "gcp-job-runner";',
110
+ "export default defineJob({",
111
+ " handler: async () => {",
112
+ ' console.log("hello from isolated job");',
113
+ " },",
114
+ "});",
115
+ ].join("\n"),
116
+ );
117
+
118
+ /** Write isolate config for the test service */
119
+ await writeFile(
120
+ path.join(serviceDirectory, "isolate.config.json"),
121
+ JSON.stringify(
122
+ {
123
+ buildDirName: "dist",
124
+ includeDevDependencies: true,
125
+ },
126
+ null,
127
+ 2,
128
+ ),
129
+ );
130
+
131
+ /** Install dependencies in the workspace */
132
+ await execa("pnpm", ["install"], { cwd: workspaceRoot });
133
+
134
+ /** Run isolation so all tests can assert on the output */
135
+ const { isolate } = await import("isolate-package");
136
+
137
+ const originalCwd = process.cwd();
138
+ try {
139
+ process.chdir(serviceDirectory);
140
+ await isolate({ buildDirName: "dist", includeDevDependencies: true });
141
+ } finally {
142
+ process.chdir(originalCwd);
143
+ }
144
+ }, 120_000);
145
+
146
+ afterAll(async () => {
147
+ if (workspaceRoot) {
148
+ await rm(workspaceRoot, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ it("isolation produces valid output", () => {
153
+ /** Verify isolate output structure */
154
+ expect(existsSync(path.join(isolateDirectory, "package.json"))).toBe(true);
155
+ expect(existsSync(path.join(isolateDirectory, "pnpm-lock.yaml"))).toBe(
156
+ true,
157
+ );
158
+ expect(existsSync(path.join(isolateDirectory, "pnpm-workspace.yaml"))).toBe(
159
+ true,
160
+ );
161
+
162
+ /** Build output should be copied */
163
+ expect(existsSync(path.join(isolateDirectory, "dist/jobs/hello.mjs"))).toBe(
164
+ true,
165
+ );
166
+
167
+ /** Workspace dependency should be packed */
168
+ expect(
169
+ existsSync(path.join(isolateDirectory, "packages/gcp-job-runner")),
170
+ ).toBe(true);
171
+ });
172
+
173
+ it("pnpm install --frozen-lockfile succeeds in isolate output", async () => {
174
+ const result = await execa("pnpm", ["install", "--frozen-lockfile"], {
175
+ cwd: isolateDirectory,
176
+ reject: false,
177
+ });
178
+
179
+ expect(result.exitCode, `pnpm install failed:\n${result.stderr}`).toBe(0);
180
+ }, 60_000);
181
+
182
+ it("job executes from isolated environment", async () => {
183
+ const result = await execa(
184
+ "node",
185
+ ["--input-type=module", "-e", "import 'gcp-job-runner/run-cloud'"],
186
+ {
187
+ cwd: isolateDirectory,
188
+ env: {
189
+ ...process.env,
190
+ JOB_ARGV: JSON.stringify(["hello"]),
191
+ },
192
+ reject: false,
193
+ },
194
+ );
195
+
196
+ expect(result.exitCode, `Job execution failed:\n${result.stderr}`).toBe(0);
197
+ expect(result.stdout).toContain("hello from isolated job");
198
+ }, 30_000);
199
+ });
@@ -210,6 +210,19 @@ describe("defineJob", () => {
210
210
  expect(handler).toHaveBeenCalledWith({ name: "test", verbose: true });
211
211
  });
212
212
 
213
+ it("coerces numbers through optional().default() wrappers", async () => {
214
+ const handler = vi.fn();
215
+ const job = defineJob({
216
+ schema: z.object({
217
+ limit: z.number().optional().default(10),
218
+ }),
219
+ handler,
220
+ });
221
+
222
+ await job(["--limit", "42"], "test-job");
223
+ expect(handler).toHaveBeenCalledWith({ limit: 42 });
224
+ });
225
+
213
226
  it("converts kebab-case flags to camelCase", async () => {
214
227
  const handler = vi.fn();
215
228
  const job = defineJob({
package/src/define-job.ts CHANGED
@@ -257,24 +257,17 @@ function getArrayElementType(schema: ZodType): string | null {
257
257
  function getBaseType(schema: ZodType): string | null {
258
258
  let def = schema._def as ZodDef;
259
259
 
260
- /** Unwrap ZodEffects (refinements, transforms) */
261
- while (getType(def) === "ZodEffects" && def.schema) {
262
- def = def.schema._def as ZodDef;
263
- }
264
-
265
- /** Unwrap optional */
266
- if (getType(def) === "optional" && def.innerType) {
267
- def = def.innerType._def as ZodDef;
268
- }
269
-
270
- /** Unwrap default */
271
- if (getType(def) === "default" && def.innerType) {
272
- def = def.innerType._def as ZodDef;
273
- }
274
-
275
- /** Unwrap any remaining effects */
276
- while (getType(def) === "ZodEffects" && def.schema) {
277
- def = def.schema._def as ZodDef;
260
+ /** Unwrap optional/default/effects in any order until we reach a base type */
261
+ while (
262
+ (getType(def) === "optional" ||
263
+ getType(def) === "default" ||
264
+ getType(def) === "ZodEffects") &&
265
+ (def.innerType || def.schema)
266
+ ) {
267
+ const inner = def.innerType ?? def.schema;
268
+ if (inner) {
269
+ def = inner._def as ZodDef;
270
+ }
278
271
  }
279
272
 
280
273
  const typeName = getType(def);