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 +60 -2
- package/dist/define-job.mjs +5 -8
- package/dist/define-job.mjs.map +1 -1
- package/package.json +25 -14
- package/src/cloud/isolation-pipeline.integration.test.ts +199 -0
- package/src/define-job.test.ts +13 -0
- package/src/define-job.ts +11 -18
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# gcp-job-runner
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/define-job.mjs
CHANGED
|
@@ -173,14 +173,11 @@ function getArrayElementType(schema) {
|
|
|
173
173
|
*/
|
|
174
174
|
function getBaseType(schema) {
|
|
175
175
|
let def = schema._def;
|
|
176
|
-
/** Unwrap
|
|
177
|
-
while (getType(def) === "
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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";
|
package/dist/define-job.mjs.map
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,QAAO,QAAQ,IAAI,KAAK,gBAAgB,IAAI,OAC1C,OAAM,IAAI,OAAO;;AAInB,KAAI,QAAQ,IAAI,KAAK,cAAc,IAAI,UACrC,OAAM,IAAI,UAAU;;AAItB,KAAI,QAAQ,IAAI,KAAK,aAAa,IAAI,UACpC,OAAM,IAAI,UAAU;;AAItB,QAAO,QAAQ,IAAI,KAAK,gBAAgB,IAAI,OAC1C,OAAM,IAAI,OAAO;AAInB,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"}
|
|
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
|
|
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
|
|
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
|
-
"
|
|
55
|
-
|
|
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
|
+
});
|
package/src/define-job.test.ts
CHANGED
|
@@ -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
|
|
261
|
-
while (
|
|
262
|
-
def
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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);
|