clyops-jobs 0.8.0 → 0.10.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
@@ -35,7 +35,12 @@ and no `node_modules`. It includes clyops-tools. `require('clyops-jobs')` loads
35
35
  - `positionals` (optional) lists the tool's positional arguments explicitly; otherwise they're
36
36
  options keyed by argument name.
37
37
  - String values may use `${dot.path}` or `{{dot.path}}` templates against a context you build
38
- (`job`, `paths`, whatever the trigger knows).
38
+ (`job`, `paths`, whatever the trigger knows). An unknown path is an error, so a typo fails
39
+ loudly, except under `params` (the caller's fields): an option or positional that names a
40
+ missing or null `params` field is left unset, so the tool's default applies. That holds for
41
+ `${params.x}` alone and inside a longer string (`v-${params.x}`), so a value is never half
42
+ filled. Elsewhere (`result`, `stdin`, `stdout`, artifact patterns) a missing field renders as
43
+ `''`. `strict_params: true` on a function makes a missing `params` field an error too.
39
44
  - `artifacts`: glob patterns (`*`, `?`, `**`) relative to `paths.work_dir`, copied into
40
45
  `paths.artifacts_dir`, with an optional `required: true` or `min: N`.
41
46
  - `stdin`: a template naming a file fed to the tool's stdin (`"${media.path}"`; relative to the
package/clyops-jobs.cjs CHANGED
@@ -32,10 +32,12 @@ __export(index_exports, {
32
32
  deepMerge: () => deepMerge,
33
33
  ensureDir: () => ensureDir,
34
34
  findFilesByArtifactPattern: () => findFilesByArtifactPattern,
35
+ functionTemplateOptions: () => functionTemplateOptions,
35
36
  globToRegex: () => globToRegex,
36
37
  isDirectoryPath: () => isDirectoryPath,
37
38
  isEnabled: () => isEnabled,
38
39
  isFilePath: () => isFilePath,
40
+ isUnsetTemplate: () => isUnsetTemplate,
39
41
  listFilesRecursive: () => listFilesRecursive,
40
42
  lookupTemplateValue: () => lookupTemplateValue,
41
43
  newJobId: () => newJobId,
@@ -135,40 +137,59 @@ function resolveConfigPath(root, value) {
135
137
  var resolveScriptPath = resolveConfigPath;
136
138
 
137
139
  // packages/jobs/src/template.ts
138
- function lookupTemplateValue(context, expression) {
140
+ var OPTIONAL_ROOTS = ["params"];
141
+ var TEMPLATE = /\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
142
+ var MISSING = /* @__PURE__ */ Symbol("missing");
143
+ function resolvePath(context, expression) {
139
144
  let current = context;
140
145
  for (const part of String(expression).split(".").filter(Boolean)) {
141
- if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, part)) {
142
- throw new Error(`unknown template value: ${expression}`);
143
- }
146
+ if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, part)) return MISSING;
144
147
  current = current[part];
145
148
  }
149
+ return current;
150
+ }
151
+ function optionalRoot(expression, opts) {
152
+ return !opts?.strict && OPTIONAL_ROOTS.includes(String(expression).split(".").filter(Boolean)[0] ?? "");
153
+ }
154
+ function lookupTemplateValue(context, expression, opts) {
155
+ const current = resolvePath(context, expression);
156
+ if (current === MISSING) {
157
+ if (optionalRoot(expression, opts)) return "";
158
+ throw new Error(`unknown template value: ${expression}`);
159
+ }
146
160
  if (current === void 0 || current === null) return "";
147
161
  return typeof current === "object" ? JSON.stringify(current) : String(current);
148
162
  }
149
- function renderTemplateString(value, context) {
150
- return String(value).replace(
151
- /\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g,
152
- (_, dollar, brace) => lookupTemplateValue(context, dollar || brace)
153
- );
163
+ function isUnsetTemplate(value, context, opts) {
164
+ if (typeof value !== "string") return false;
165
+ for (const [, dollar, brace] of value.matchAll(TEMPLATE)) {
166
+ const expression = dollar || brace;
167
+ if (!optionalRoot(expression, opts)) continue;
168
+ const current = resolvePath(context, expression);
169
+ if (current === MISSING || current === void 0 || current === null) return true;
170
+ }
171
+ return false;
172
+ }
173
+ function renderTemplateString(value, context, opts) {
174
+ return String(value).replace(TEMPLATE, (_, dollar, brace) => lookupTemplateValue(context, dollar || brace, opts));
154
175
  }
155
- function renderConfigValue(value, context) {
176
+ function renderConfigValue(value, context, opts) {
156
177
  if (!context) return value;
157
- if (typeof value === "string") return renderTemplateString(value, context);
158
- if (Array.isArray(value)) return value.map((item) => renderConfigValue(item, context));
178
+ if (typeof value === "string") return renderTemplateString(value, context, opts);
179
+ if (Array.isArray(value)) return value.map((item) => renderConfigValue(item, context, opts));
159
180
  return value;
160
181
  }
161
- function renderResultValue(value, context) {
162
- if (typeof value === "string") return renderTemplateString(value, context);
163
- if (Array.isArray(value)) return value.map((item) => renderResultValue(item, context));
182
+ function renderResultValue(value, context, opts) {
183
+ if (typeof value === "string") return renderTemplateString(value, context, opts);
184
+ if (Array.isArray(value)) return value.map((item) => renderResultValue(item, context, opts));
164
185
  if (value && typeof value === "object") {
165
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context)]));
186
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context, opts)]));
166
187
  }
167
188
  return value;
168
189
  }
169
- function renderResultMap(resultMap, context) {
190
+ function renderResultMap(resultMap, context, opts) {
170
191
  if (!resultMap || typeof resultMap !== "object" || Array.isArray(resultMap)) return {};
171
- return renderResultValue(resultMap, context);
192
+ return renderResultValue(resultMap, context, opts);
172
193
  }
173
194
 
174
195
  // packages/tools/src/schema.ts
@@ -179,12 +200,13 @@ function stamp(file) {
179
200
  return `${s.mtimeMs}:${s.size}`;
180
201
  }
181
202
  var cache = /* @__PURE__ */ new Map();
203
+ var PROBE_TIMEOUT_MS = 3e4;
182
204
  function loadSchema(file, opts = {}) {
183
205
  const key = stamp(file);
184
206
  const hit = cache.get(file);
185
207
  if (hit && hit.stamp === key) return hit.schema;
186
208
  const schema = new Promise((resolve5, reject) => {
187
- (0, import_node_child_process.execFile)(file, ["--help-json-schema"], { cwd: opts.cwd, timeout: opts.timeoutMs ?? 5e3, maxBuffer: 16 << 20 }, (err, stdout) => {
209
+ (0, import_node_child_process.execFile)(file, ["--help-json-schema"], { cwd: opts.cwd, timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS, maxBuffer: 16 << 20 }, (err, stdout) => {
188
210
  if (err) return reject(new Error(`${file} does not provide --help-json-schema: ${err.message}`));
189
211
  const start2 = stdout.indexOf("{");
190
212
  const end = stdout.lastIndexOf("}");
@@ -200,7 +222,9 @@ function loadSchema(file, opts = {}) {
200
222
  }
201
223
  });
202
224
  });
203
- schema.catch(() => cache.delete(file));
225
+ schema.catch(() => {
226
+ if (cache.get(file)?.schema === schema) cache.delete(file);
227
+ });
204
228
  cache.set(file, { stamp: key, schema });
205
229
  return schema;
206
230
  }
@@ -233,6 +257,9 @@ function isPathValued(validation, type) {
233
257
  function isBareProgram(validation, value) {
234
258
  return validation === "file:executable" && value !== "" && !value.includes("/") && !(process.platform === "win32" && value.includes("\\"));
235
259
  }
260
+ function oneJsonValue(validation, many) {
261
+ return (validation === "json" || validation.startsWith("json:")) && !many;
262
+ }
236
263
  var TRUE = /^(true|1|yes|on)$/i;
237
264
  var FALSE = /^(false|0|no|off)$/i;
238
265
  function toArgv(schema, input, opts = {}) {
@@ -280,7 +307,7 @@ function toArgv(schema, input, opts = {}) {
280
307
  out.env[option.variableName] = text(found.value, isPathValued(option.validation, option.type), `--${option.name}`, option.validation);
281
308
  continue;
282
309
  }
283
- for (const item of Array.isArray(found.value) ? found.value : [found.value]) {
310
+ for (const item of Array.isArray(found.value) && !oneJsonValue(option.validation, option.isArray) ? found.value : [found.value]) {
284
311
  out.argv.push(`--${option.name}`, text(item, isPathValued(option.validation, option.type), `--${option.name}`, option.validation));
285
312
  }
286
313
  }
@@ -306,7 +333,7 @@ function toArgv(schema, input, opts = {}) {
306
333
  positionals.push(text(gap.default, false));
307
334
  }
308
335
  skipped = [];
309
- const values = Array.isArray(found.value) ? found.value : [found.value];
336
+ const values = Array.isArray(found.value) && !oneJsonValue(argument.validation, argument.isVariadic) ? found.value : [found.value];
310
337
  positionals.push(...values.map((v) => text(v, isPathValued(argument.validation), argument.name, argument.validation)));
311
338
  }
312
339
  }
@@ -597,22 +624,35 @@ function resolveFunctionConfig(scriptRoot, routeConfig, functionName) {
597
624
  const config = deepMerge(objectValue(objectValue(routeConfig.defaults)[key]), objectValue(routeConfig[key]));
598
625
  return { functionName, key, script: resolveScriptPath(scriptRoot, script), config, definition };
599
626
  }
627
+ function functionTemplateOptions(fn) {
628
+ return { strict: fn.definition.strict_params === true };
629
+ }
630
+ function withoutUnsetParams(config, context, opts) {
631
+ const out = {};
632
+ for (const [key, value] of Object.entries(config)) {
633
+ if (isUnsetTemplate(value, context, opts)) continue;
634
+ out[key] = Array.isArray(value) ? value.filter((item) => !isUnsetTemplate(item, context, opts)) : value;
635
+ }
636
+ return out;
637
+ }
600
638
  function explicitPositionals(fn, context) {
601
639
  if (!Array.isArray(fn.definition.positionals)) return void 0;
602
- return fn.definition.positionals.map((item) => String(renderConfigValue(item, context))).filter(Boolean);
640
+ const templates = functionTemplateOptions(fn);
641
+ return fn.definition.positionals.filter((item) => !context || !isUnsetTemplate(item, context, templates)).map((item) => String(renderConfigValue(item, context, templates))).filter(Boolean);
603
642
  }
604
643
  async function buildFunctionCommand(fn, opts, schema) {
605
644
  schema ??= await loadSchema(fn.script);
606
- const config = { ...fn.config };
645
+ const context = opts.context;
646
+ const templates = functionTemplateOptions(fn);
647
+ const config = context ? withoutUnsetParams(fn.config, context, templates) : { ...fn.config };
607
648
  const first = schema.arguments[0];
608
649
  if (opts.defaultPositional && first && !Array.isArray(fn.definition.positionals) && !inputKeys(first).some((k) => k in config)) {
609
650
  config[inputKeys(first)[0]] = opts.defaultPositional;
610
651
  }
611
- const context = opts.context;
612
652
  const { argv, unknown, controlled } = toArgv(schema, config, {
613
653
  base: opts.configRoot,
614
654
  controlled: opts.controlled ?? ["help"],
615
- render: context ? (s) => renderTemplateString(s, context) : void 0,
655
+ render: context ? (s) => renderTemplateString(s, context, templates) : void 0,
616
656
  positionals: explicitPositionals(fn, context),
617
657
  positionalsOrder: fn.definition.positionals_order
618
658
  });
@@ -629,9 +669,9 @@ function parseRoleJson(text) {
629
669
  return void 0;
630
670
  }
631
671
  }
632
- function streamPath(template, opts, base) {
672
+ function streamPath(template, opts, base, templates) {
633
673
  if (!template) return void 0;
634
- const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
674
+ const rendered = opts.context ? renderTemplateString(template, opts.context, templates) : template;
635
675
  return (0, import_node_path3.isAbsolute)(rendered) ? rendered : (0, import_node_path3.resolve)(base, rendered);
636
676
  }
637
677
  async function runScriptFunction(fn, opts) {
@@ -640,8 +680,8 @@ async function runScriptFunction(fn, opts) {
640
680
  const command = [file, ...redactArgv(schema, argv)];
641
681
  LOGGER.verbose("command: %s", shellQuote(command));
642
682
  const workDir = opts.context?.paths?.work_dir;
643
- const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
644
- const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
683
+ const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot, functionTemplateOptions(fn));
684
+ const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot, functionTemplateOptions(fn));
645
685
  let progress;
646
686
  const roles = {
647
687
  progress: {
@@ -685,7 +725,7 @@ function buildResultRecord(p) {
685
725
  artifact_keys: p.artifactKeys,
686
726
  completed_at: p.now,
687
727
  config: p.fn.config,
688
- ...renderResultMap(p.fn.definition.result, p.context)
728
+ ...renderResultMap(p.fn.definition.result, p.context, functionTemplateOptions(p.fn))
689
729
  };
690
730
  }
691
731
 
@@ -731,8 +771,8 @@ function listFilesRecursive(dir, visited = /* @__PURE__ */ new Set()) {
731
771
  }
732
772
  return files;
733
773
  }
734
- function findFilesByArtifactPattern(pattern, context) {
735
- const rendered = renderTemplateString(pattern, context);
774
+ function findFilesByArtifactPattern(pattern, context, opts) {
775
+ const rendered = renderTemplateString(pattern, context, opts);
736
776
  const absolute = (0, import_node_path4.isAbsolute)(rendered) ? rendered : (0, import_node_path4.resolve)(context.paths.work_dir, rendered);
737
777
  if (!/[*?]/.test(absolute)) return isFilePath(absolute) ? [absolute] : [];
738
778
  const regex = globToRegex(absolute);
@@ -771,7 +811,7 @@ function copyConfiguredArtifacts(fn, context, opts = {}) {
771
811
  const seen = /* @__PURE__ */ new Set();
772
812
  const copied = [];
773
813
  for (const pattern of patterns) {
774
- for (const source of findFilesByArtifactPattern(pattern, context)) {
814
+ for (const source of findFilesByArtifactPattern(pattern, context, { strict: fn.definition.strict_params === true })) {
775
815
  if (seen.has(source)) continue;
776
816
  seen.add(source);
777
817
  const destination = (0, import_node_path4.join)(dir, (0, import_node_path4.basename)(source));
@@ -921,10 +961,12 @@ var JobQueue = class {
921
961
  deepMerge,
922
962
  ensureDir,
923
963
  findFilesByArtifactPattern,
964
+ functionTemplateOptions,
924
965
  globToRegex,
925
966
  isDirectoryPath,
926
967
  isEnabled,
927
968
  isFilePath,
969
+ isUnsetTemplate,
928
970
  listFilesRecursive,
929
971
  lookupTemplateValue,
930
972
  newJobId,
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedFunction } from './functions.js';
2
- import { type TemplateContext } from './template.js';
2
+ import { type TemplateContext, type TemplateOptions } from './template.js';
3
3
  /** Compile an artifact glob into an anchored regular expression. */
4
4
  export declare function globToRegex(pattern: string): RegExp;
5
5
  /** Every file below `dir`, following symlinks without looping. */
@@ -9,7 +9,7 @@ export declare function findFilesByArtifactPattern(pattern: string, context: Tem
9
9
  paths: {
10
10
  work_dir: string;
11
11
  };
12
- }): string[];
12
+ }, opts?: TemplateOptions): string[];
13
13
  /** A function's `artifacts` as a list of include patterns, plus its `stdout` file. */
14
14
  export declare function artifactIncludePatterns(fn: ResolvedFunction): string[];
15
15
  /** How many artifacts a function must produce: `min`, or 1 for `required: true`. */
@@ -1 +1 @@
1
- {"version":3,"file":"artifacts.d.ts","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAK3E,oEAAoE;AACpE,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAiBnD;AAWD,kEAAkE;AAClE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,cAAoB,GAAG,MAAM,EAAE,CAarF;AAED,oFAAoF;AACpF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,MAAM,EAAE,CAQhI;AAED,sFAAsF;AACtF,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE,CAatE;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,CAMhE;AAED,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,gBAAgB,EACpB,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EACjF,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,cAAc,EAAE,CAwBlB"}
1
+ {"version":3,"file":"artifacts.d.ts","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAwB,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAKjG,oEAAoE;AACpE,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAiBnD;AAWD,kEAAkE;AAClE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,cAAoB,GAAG,MAAM,EAAE,CAarF;AAED,oFAAoF;AACpF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAQxJ;AAED,sFAAsF;AACtF,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE,CAatE;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,CAMhE;AAED,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,gBAAgB,EACpB,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EACjF,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,cAAc,EAAE,CAwBlB"}
package/dist/artifacts.js CHANGED
@@ -59,8 +59,8 @@ export function listFilesRecursive(dir, visited = new Set()) {
59
59
  return files;
60
60
  }
61
61
  /** Files matching a templated glob; relative patterns start at `paths.work_dir`. */
62
- export function findFilesByArtifactPattern(pattern, context) {
63
- const rendered = renderTemplateString(pattern, context);
62
+ export function findFilesByArtifactPattern(pattern, context, opts) {
63
+ const rendered = renderTemplateString(pattern, context, opts);
64
64
  const absolute = isAbsolute(rendered) ? rendered : resolve(context.paths.work_dir, rendered);
65
65
  if (!/[*?]/.test(absolute))
66
66
  return isFilePath(absolute) ? [absolute] : [];
@@ -118,7 +118,7 @@ export function copyConfiguredArtifacts(fn, context, opts = {}) {
118
118
  const seen = new Set();
119
119
  const copied = [];
120
120
  for (const pattern of patterns) {
121
- for (const source of findFilesByArtifactPattern(pattern, context)) {
121
+ for (const source of findFilesByArtifactPattern(pattern, context, { strict: fn.definition.strict_params === true })) {
122
122
  if (seen.has(source))
123
123
  continue;
124
124
  seen.add(source);
@@ -1,5 +1,5 @@
1
1
  import { type RunResult, type Schema } from 'clyops-tools';
2
- import { type TemplateContext } from './template.js';
2
+ import { type TemplateContext, type TemplateOptions } from './template.js';
3
3
  import { type Json } from './util.js';
4
4
  /**
5
5
  * Route diagnostics. `warn` and `verbose` take a printf-style format and
@@ -29,6 +29,8 @@ export interface FunctionDefinition {
29
29
  artifacts?: unknown;
30
30
  /** Task result or configured result templates, according to the containing interface. */
31
31
  result?: unknown;
32
+ /** A missing `params` field in a template is an error instead of unset (default false). */
33
+ strict_params?: boolean;
32
34
  [key: string]: unknown;
33
35
  }
34
36
  /** A configured function with resolved executable and merged defaults and overrides. */
@@ -63,6 +65,8 @@ export interface CommandOptions {
63
65
  */
64
66
  defaultPositional?: string;
65
67
  }
68
+ /** The template options a function's definition asks for. */
69
+ export declare function functionTemplateOptions(fn: ResolvedFunction): TemplateOptions;
66
70
  /** The full command line for a function: [script, ...argv]. */
67
71
  export declare function buildFunctionCommand(fn: ResolvedFunction, opts: CommandOptions, schema?: Schema): Promise<string[]>;
68
72
  /** The completed tool execution with a color-stripped stderr tail. */
@@ -1 +1 @@
1
- {"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"AASA,OAAO,EAAsG,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAI/J,OAAO,EAA4D,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,EAAwD,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAS5F;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,8EAA8E;IAAC,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;CAAE,GAAG,IAAI,CAE5N;AAED,2GAA2G;AAC3G,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,WAAW,CAAC,EAAE,OAAO,EAAE,CAAC;IACxB,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACrC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8FAA8F;IAC9F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,wFAAwF;AACxF,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,MAAM,EAAE,IAAI,CAAC;IACb,+FAA+F;IAC/F,UAAU,EAAE,kBAAkB,CAAC;CAChC;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAQ1H;AAED,kGAAkG;AAClG,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACjC,2GAA2G;IAC3G,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAYD,+DAA+D;AAC/D,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,gBAAgB,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAmBzH;AAED,sEAAsE;AACtE,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,6FAA6F;IAC7F,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAO/D;AASD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,gBAAgB,EACpB,IAAI,EAAE,cAAc,GAAG;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gGAAgG,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IACtH,uEAAuE,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAChG,8EAA8E,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAC5F,gFAAgF,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IACtG,4CAA4C,CAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChF,wFAAwF,CAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAChJ,+DAA+D,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChG,GACA,OAAO,CAAC,WAAW,CAAC,CAqCtB;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,CAAC,EAAE;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,IAAI,CAAC;IACd,EAAE,EAAE,gBAAgB,CAAC;IACrB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,wFAAwF;IACxF,OAAO,EAAE,eAAe,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;CACb,GAAG,IAAI,CAYP"}
1
+ {"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"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"}
package/dist/functions.js CHANGED
@@ -11,7 +11,7 @@ import { declaredRoles, inputKeys, loadSchema, redactArgv, start, shellQuote, ta
11
11
  import { createReadStream, createWriteStream } from 'node:fs';
12
12
  import { finished } from 'node:stream/promises';
13
13
  import { basename, dirname, isAbsolute, resolve } from 'node:path';
14
- import { renderConfigValue, renderResultMap, renderTemplateString } from './template.js';
14
+ import { isUnsetTemplate, renderConfigValue, renderResultMap, renderTemplateString } from './template.js';
15
15
  import { deepMerge, ensureDir, objectValue, resolveScriptPath } from './util.js';
16
16
  /** Where engine diagnostics go; a trigger installs its own with configureLogging. */
17
17
  const LOGGER = {
@@ -42,6 +42,24 @@ export function resolveFunctionConfig(scriptRoot, routeConfig, functionName) {
42
42
  const config = deepMerge(objectValue(objectValue(routeConfig.defaults)[key]), objectValue(routeConfig[key]));
43
43
  return { functionName, key, script: resolveScriptPath(scriptRoot, script), config, definition };
44
44
  }
45
+ /** The template options a function's definition asks for. */
46
+ export function functionTemplateOptions(fn) {
47
+ return { strict: fn.definition.strict_params === true };
48
+ }
49
+ /**
50
+ * The options with `params` templates that name a missing or null field left
51
+ * out, so the tool's own default applies: such an option is dropped, alone or
52
+ * inside a longer string, and so is such an item in an array.
53
+ */
54
+ function withoutUnsetParams(config, context, opts) {
55
+ const out = {};
56
+ for (const [key, value] of Object.entries(config)) {
57
+ if (isUnsetTemplate(value, context, opts))
58
+ continue;
59
+ out[key] = Array.isArray(value) ? value.filter((item) => !isUnsetTemplate(item, context, opts)) : value;
60
+ }
61
+ return out;
62
+ }
45
63
  /**
46
64
  * The positionals for a function: its `positionals` templates when set
47
65
  * (authoritative; `[]` for none), else none here and the schema's arguments
@@ -50,21 +68,27 @@ export function resolveFunctionConfig(scriptRoot, routeConfig, functionName) {
50
68
  function explicitPositionals(fn, context) {
51
69
  if (!Array.isArray(fn.definition.positionals))
52
70
  return undefined;
53
- return fn.definition.positionals.map((item) => String(renderConfigValue(item, context))).filter(Boolean);
71
+ const templates = functionTemplateOptions(fn);
72
+ return fn.definition.positionals
73
+ .filter((item) => !context || !isUnsetTemplate(item, context, templates))
74
+ .map((item) => String(renderConfigValue(item, context, templates)))
75
+ .filter(Boolean);
54
76
  }
55
77
  /** The full command line for a function: [script, ...argv]. */
56
78
  export async function buildFunctionCommand(fn, opts, schema) {
57
79
  schema ??= await loadSchema(fn.script);
58
- const config = { ...fn.config };
80
+ const context = opts.context;
81
+ const templates = functionTemplateOptions(fn);
82
+ // First, so an argument left unset still takes the default positional.
83
+ const config = context ? withoutUnsetParams(fn.config, context, templates) : { ...fn.config };
59
84
  const first = schema.arguments[0];
60
85
  if (opts.defaultPositional && first && !Array.isArray(fn.definition.positionals) && !inputKeys(first).some((k) => k in config)) {
61
86
  config[inputKeys(first)[0]] = opts.defaultPositional;
62
87
  }
63
- const context = opts.context;
64
88
  const { argv, unknown, controlled } = toArgv(schema, config, {
65
89
  base: opts.configRoot,
66
90
  controlled: opts.controlled ?? ['help'],
67
- render: context ? (s) => renderTemplateString(s, context) : undefined,
91
+ render: context ? (s) => renderTemplateString(s, context, templates) : undefined,
68
92
  positionals: explicitPositionals(fn, context),
69
93
  positionalsOrder: fn.definition.positionals_order,
70
94
  });
@@ -87,10 +111,10 @@ export function parseRoleJson(text) {
87
111
  }
88
112
  }
89
113
  /** A function's `stdin` or `stdout` template rendered, a relative one against `base`. */
90
- function streamPath(template, opts, base) {
114
+ function streamPath(template, opts, base, templates) {
91
115
  if (!template)
92
116
  return undefined;
93
- const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
117
+ const rendered = opts.context ? renderTemplateString(template, opts.context, templates) : template;
94
118
  return isAbsolute(rendered) ? rendered : resolve(base, rendered);
95
119
  }
96
120
  /**
@@ -105,8 +129,8 @@ export async function runScriptFunction(fn, opts) {
105
129
  LOGGER.verbose('command: %s', shellQuote(command));
106
130
  // stdin is input, like a path option; stdout is an artifact, found like one.
107
131
  const workDir = opts.context?.paths?.work_dir;
108
- const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
109
- const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
132
+ const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot, functionTemplateOptions(fn));
133
+ const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot, functionTemplateOptions(fn));
110
134
  // Wire the well-known roles the tool declares: progress onto the record, meta into the run.
111
135
  let progress;
112
136
  const roles = {
@@ -148,6 +172,6 @@ export function buildResultRecord(p) {
148
172
  artifact_keys: p.artifactKeys,
149
173
  completed_at: p.now,
150
174
  config: p.fn.config,
151
- ...renderResultMap(p.fn.definition.result, p.context),
175
+ ...renderResultMap(p.fn.definition.result, p.context, functionTemplateOptions(p.fn)),
152
176
  };
153
177
  }
@@ -2,16 +2,31 @@
2
2
  export type TemplateContext = {
3
3
  [key: string]: unknown;
4
4
  };
5
- /** A dot-path value from the context; unknown paths are errors, objects become JSON. */
6
- export declare function lookupTemplateValue(context: TemplateContext, expression: string): string;
5
+ /** How templates treat missing values. */
6
+ export interface TemplateOptions {
7
+ /** Every unknown path is an error, `params` too (default false: a missing `params` field renders `''`, and an option naming one is unset). */
8
+ strict?: boolean;
9
+ }
10
+ /**
11
+ * A dot-path value from the context; objects become JSON and null is `''`.
12
+ * An unknown path is an error, except a missing `params` field, which is `''`
13
+ * unless `opts.strict`.
14
+ */
15
+ export declare function lookupTemplateValue(context: TemplateContext, expression: string, opts?: TemplateOptions): string;
16
+ /**
17
+ * True when `value` names a `params` field that is missing or null, alone
18
+ * (`${params.x}`) or inside a longer string (`v-${params.x}`), so an option
19
+ * or positional set to it is left unset. Always false with `opts.strict`.
20
+ */
21
+ export declare function isUnsetTemplate(value: unknown, context: TemplateContext, opts?: TemplateOptions): boolean;
7
22
  /** Replace dotted-path ${expression} and {{expression}} templates using the context. */
8
- export declare function renderTemplateString(value: string, context: TemplateContext): string;
23
+ export declare function renderTemplateString(value: string, context: TemplateContext, opts?: TemplateOptions): string;
9
24
  /** Render strings and the strings in arrays; other scalars keep their type. */
10
- export declare function renderConfigValue(value: unknown, context?: TemplateContext | null): unknown;
25
+ export declare function renderConfigValue(value: unknown, context?: TemplateContext | null, opts?: TemplateOptions): unknown;
11
26
  /** Deep-render a `result` map value: strings, arrays and nested objects. */
12
- export declare function renderResultValue(value: unknown, context: TemplateContext): unknown;
27
+ export declare function renderResultValue(value: unknown, context: TemplateContext, opts?: TemplateOptions): unknown;
13
28
  /** Render a function's optional `result` map into extra result fields. */
14
- export declare function renderResultMap(resultMap: unknown, context: TemplateContext): {
29
+ export declare function renderResultMap(resultMap: unknown, context: TemplateContext, opts?: TemplateOptions): {
15
30
  [key: string]: unknown;
16
31
  };
17
32
  //# sourceMappingURL=template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/template.ts"],"names":[],"mappings":"AACA,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC;AAEzD,wFAAwF;AACxF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAUxF;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,MAAM,CAIpF;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAAG,OAAO,CAK3F;AAED,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAOnF;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAGxG"}
1
+ {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/template.ts"],"names":[],"mappings":"AACA,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC;AAEzD,0CAA0C;AAC1C,MAAM,WAAW,eAAe;IAC9B,8IAA8I;IAC9I,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAqBD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,MAAM,CAQhH;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CASzG;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,MAAM,CAE5G;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAKnH;AAED,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAO3G;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAGhI"}
package/dist/template.js CHANGED
@@ -1,44 +1,82 @@
1
- /** A dot-path value from the context; unknown paths are errors, objects become JSON. */
2
- export function lookupTemplateValue(context, expression) {
1
+ // Caller-supplied fields: one a request leaves out is unset, not a config error.
2
+ const OPTIONAL_ROOTS = ['params'];
3
+ const TEMPLATE = /\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
4
+ // The value at a dot path, or MISSING when the path is not there.
5
+ const MISSING = Symbol('missing');
6
+ function resolvePath(context, expression) {
3
7
  let current = context;
4
8
  for (const part of String(expression).split('.').filter(Boolean)) {
5
- if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, part)) {
6
- throw new Error(`unknown template value: ${expression}`);
7
- }
9
+ if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, part))
10
+ return MISSING;
8
11
  current = current[part];
9
12
  }
13
+ return current;
14
+ }
15
+ function optionalRoot(expression, opts) {
16
+ return !opts?.strict && OPTIONAL_ROOTS.includes(String(expression).split('.').filter(Boolean)[0] ?? '');
17
+ }
18
+ /**
19
+ * A dot-path value from the context; objects become JSON and null is `''`.
20
+ * An unknown path is an error, except a missing `params` field, which is `''`
21
+ * unless `opts.strict`.
22
+ */
23
+ export function lookupTemplateValue(context, expression, opts) {
24
+ const current = resolvePath(context, expression);
25
+ if (current === MISSING) {
26
+ if (optionalRoot(expression, opts))
27
+ return '';
28
+ throw new Error(`unknown template value: ${expression}`);
29
+ }
10
30
  if (current === undefined || current === null)
11
31
  return '';
12
32
  return typeof current === 'object' ? JSON.stringify(current) : String(current);
13
33
  }
34
+ /**
35
+ * True when `value` names a `params` field that is missing or null, alone
36
+ * (`${params.x}`) or inside a longer string (`v-${params.x}`), so an option
37
+ * or positional set to it is left unset. Always false with `opts.strict`.
38
+ */
39
+ export function isUnsetTemplate(value, context, opts) {
40
+ if (typeof value !== 'string')
41
+ return false;
42
+ for (const [, dollar, brace] of value.matchAll(TEMPLATE)) {
43
+ const expression = dollar || brace;
44
+ if (!optionalRoot(expression, opts))
45
+ continue;
46
+ const current = resolvePath(context, expression);
47
+ if (current === MISSING || current === undefined || current === null)
48
+ return true;
49
+ }
50
+ return false;
51
+ }
14
52
  /** Replace dotted-path ${expression} and {{expression}} templates using the context. */
15
- export function renderTemplateString(value, context) {
16
- return String(value).replace(/\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_, dollar, brace) => lookupTemplateValue(context, dollar || brace));
53
+ export function renderTemplateString(value, context, opts) {
54
+ return String(value).replace(TEMPLATE, (_, dollar, brace) => lookupTemplateValue(context, dollar || brace, opts));
17
55
  }
18
56
  /** Render strings and the strings in arrays; other scalars keep their type. */
19
- export function renderConfigValue(value, context) {
57
+ export function renderConfigValue(value, context, opts) {
20
58
  if (!context)
21
59
  return value;
22
60
  if (typeof value === 'string')
23
- return renderTemplateString(value, context);
61
+ return renderTemplateString(value, context, opts);
24
62
  if (Array.isArray(value))
25
- return value.map((item) => renderConfigValue(item, context));
63
+ return value.map((item) => renderConfigValue(item, context, opts));
26
64
  return value;
27
65
  }
28
66
  /** Deep-render a `result` map value: strings, arrays and nested objects. */
29
- export function renderResultValue(value, context) {
67
+ export function renderResultValue(value, context, opts) {
30
68
  if (typeof value === 'string')
31
- return renderTemplateString(value, context);
69
+ return renderTemplateString(value, context, opts);
32
70
  if (Array.isArray(value))
33
- return value.map((item) => renderResultValue(item, context));
71
+ return value.map((item) => renderResultValue(item, context, opts));
34
72
  if (value && typeof value === 'object') {
35
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context)]));
73
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context, opts)]));
36
74
  }
37
75
  return value;
38
76
  }
39
77
  /** Render a function's optional `result` map into extra result fields. */
40
- export function renderResultMap(resultMap, context) {
78
+ export function renderResultMap(resultMap, context, opts) {
41
79
  if (!resultMap || typeof resultMap !== 'object' || Array.isArray(resultMap))
42
80
  return {};
43
- return renderResultValue(resultMap, context);
81
+ return renderResultValue(resultMap, context, opts);
44
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clyops-jobs",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Run clyops tools as jobs: config-bound functions, templated invocation, artifacts, job records and a queue.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,12 +38,12 @@
38
38
  "typescript": "^5.6.0"
39
39
  },
40
40
  "dependencies": {
41
- "clyops-tools": "0.8.0"
41
+ "clyops-tools": "0.10.0"
42
42
  },
43
43
  "homepage": "https://clyops.io/node-tools.html",
44
44
  "scripts": {
45
45
  "build": "tsc -p tsconfig.json",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
- "test": "node --test test/*.test.mjs"
47
+ "test": "node --import ../../tools/test-leaks.mjs --test test/*.test.mjs"
48
48
  }
49
49
  }
package/src/artifacts.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join, normalize, parse, resolve } from 'node:path';
4
4
  import type { ResolvedFunction } from './functions.js';
5
- import { renderTemplateString, type TemplateContext } from './template.js';
5
+ import { renderTemplateString, type TemplateContext, type TemplateOptions } from './template.js';
6
6
  import { copyFileEnsuringDir, isDirectoryPath, isFilePath, statFollowingSymlinks, truthy } from './util.js';
7
7
 
8
8
  // A small glob subset as a regex: `*`, `?`, `**` and `**/` (which also matches no
@@ -53,8 +53,8 @@ export function listFilesRecursive(dir: string, visited = new Set<string>()): st
53
53
  }
54
54
 
55
55
  /** Files matching a templated glob; relative patterns start at `paths.work_dir`. */
56
- export function findFilesByArtifactPattern(pattern: string, context: TemplateContext & { paths: { work_dir: string } }): string[] {
57
- const rendered = renderTemplateString(pattern, context);
56
+ export function findFilesByArtifactPattern(pattern: string, context: TemplateContext & { paths: { work_dir: string } }, opts?: TemplateOptions): string[] {
57
+ const rendered = renderTemplateString(pattern, context, opts);
58
58
  const absolute = isAbsolute(rendered) ? rendered : resolve(context.paths.work_dir, rendered);
59
59
  if (!/[*?]/.test(absolute)) return isFilePath(absolute) ? [absolute] : [];
60
60
  const regex = globToRegex(absolute);
@@ -118,7 +118,7 @@ export function copyConfiguredArtifacts(
118
118
  const seen = new Set<string>();
119
119
  const copied: CopiedArtifact[] = [];
120
120
  for (const pattern of patterns) {
121
- for (const source of findFilesByArtifactPattern(pattern, context)) {
121
+ for (const source of findFilesByArtifactPattern(pattern, context, { strict: fn.definition.strict_params === true })) {
122
122
  if (seen.has(source)) continue;
123
123
  seen.add(source);
124
124
  const destination = join(dir, basename(source));
package/src/functions.ts CHANGED
@@ -11,7 +11,7 @@ import { declaredRoles, inputKeys, loadSchema, redactArgv, start, shellQuote, ta
11
11
  import { createReadStream, createWriteStream } from 'node:fs';
12
12
  import { finished } from 'node:stream/promises';
13
13
  import { basename, dirname, isAbsolute, resolve } from 'node:path';
14
- import { renderConfigValue, renderResultMap, renderTemplateString, type TemplateContext } from './template.js';
14
+ import { isUnsetTemplate, renderConfigValue, renderResultMap, renderTemplateString, type TemplateContext, type TemplateOptions } from './template.js';
15
15
  import { deepMerge, ensureDir, objectValue, resolveScriptPath, type Json } from './util.js';
16
16
 
17
17
  /** Where engine diagnostics go; a trigger installs its own with configureLogging. */
@@ -48,6 +48,8 @@ export interface FunctionDefinition {
48
48
  artifacts?: unknown;
49
49
  /** Task result or configured result templates, according to the containing interface. */
50
50
  result?: unknown;
51
+ /** A missing `params` field in a template is an error instead of unset (default false). */
52
+ strict_params?: boolean;
51
53
  [key: string]: unknown;
52
54
  }
53
55
 
@@ -94,6 +96,25 @@ export interface CommandOptions {
94
96
  defaultPositional?: string;
95
97
  }
96
98
 
99
+ /** The template options a function's definition asks for. */
100
+ export function functionTemplateOptions(fn: ResolvedFunction): TemplateOptions {
101
+ return { strict: fn.definition.strict_params === true };
102
+ }
103
+
104
+ /**
105
+ * The options with `params` templates that name a missing or null field left
106
+ * out, so the tool's own default applies: such an option is dropped, alone or
107
+ * inside a longer string, and so is such an item in an array.
108
+ */
109
+ function withoutUnsetParams(config: Json, context: TemplateContext, opts: TemplateOptions): Json {
110
+ const out: Json = {};
111
+ for (const [key, value] of Object.entries(config)) {
112
+ if (isUnsetTemplate(value, context, opts)) continue;
113
+ out[key] = Array.isArray(value) ? value.filter((item) => !isUnsetTemplate(item, context, opts)) : value;
114
+ }
115
+ return out;
116
+ }
117
+
97
118
  /**
98
119
  * The positionals for a function: its `positionals` templates when set
99
120
  * (authoritative; `[]` for none), else none here and the schema's arguments
@@ -101,22 +122,28 @@ export interface CommandOptions {
101
122
  */
102
123
  function explicitPositionals(fn: ResolvedFunction, context?: TemplateContext | null): string[] | undefined {
103
124
  if (!Array.isArray(fn.definition.positionals)) return undefined;
104
- return fn.definition.positionals.map((item) => String(renderConfigValue(item, context))).filter(Boolean);
125
+ const templates = functionTemplateOptions(fn);
126
+ return fn.definition.positionals
127
+ .filter((item) => !context || !isUnsetTemplate(item, context, templates))
128
+ .map((item) => String(renderConfigValue(item, context, templates)))
129
+ .filter(Boolean);
105
130
  }
106
131
 
107
132
  /** The full command line for a function: [script, ...argv]. */
108
133
  export async function buildFunctionCommand(fn: ResolvedFunction, opts: CommandOptions, schema?: Schema): Promise<string[]> {
109
134
  schema ??= await loadSchema(fn.script);
110
- const config: Json = { ...fn.config };
135
+ const context = opts.context;
136
+ const templates = functionTemplateOptions(fn);
137
+ // First, so an argument left unset still takes the default positional.
138
+ const config: Json = context ? withoutUnsetParams(fn.config, context, templates) : { ...fn.config };
111
139
  const first = schema.arguments[0];
112
140
  if (opts.defaultPositional && first && !Array.isArray(fn.definition.positionals) && !inputKeys(first).some((k) => k in config)) {
113
141
  config[inputKeys(first)[0]] = opts.defaultPositional;
114
142
  }
115
- const context = opts.context;
116
143
  const { argv, unknown, controlled } = toArgv(schema, config, {
117
144
  base: opts.configRoot,
118
145
  controlled: opts.controlled ?? ['help'],
119
- render: context ? (s) => renderTemplateString(s, context) : undefined,
146
+ render: context ? (s) => renderTemplateString(s, context, templates) : undefined,
120
147
  positionals: explicitPositionals(fn, context),
121
148
  positionalsOrder: fn.definition.positionals_order,
122
149
  });
@@ -147,9 +174,9 @@ export function parseRoleJson(text: string | undefined): unknown {
147
174
  }
148
175
 
149
176
  /** A function's `stdin` or `stdout` template rendered, a relative one against `base`. */
150
- function streamPath(template: string | undefined, opts: CommandOptions, base: string): string | undefined {
177
+ function streamPath(template: string | undefined, opts: CommandOptions, base: string, templates: TemplateOptions): string | undefined {
151
178
  if (!template) return undefined;
152
- const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
179
+ const rendered = opts.context ? renderTemplateString(template, opts.context, templates) : template;
153
180
  return isAbsolute(rendered) ? rendered : resolve(base, rendered);
154
181
  }
155
182
 
@@ -177,8 +204,8 @@ export async function runScriptFunction(
177
204
  LOGGER.verbose('command: %s', shellQuote(command));
178
205
  // stdin is input, like a path option; stdout is an artifact, found like one.
179
206
  const workDir = (opts.context?.paths as { work_dir?: string } | undefined)?.work_dir;
180
- const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
181
- const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
207
+ const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot, functionTemplateOptions(fn));
208
+ const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot, functionTemplateOptions(fn));
182
209
  // Wire the well-known roles the tool declares: progress onto the record, meta into the run.
183
210
  let progress: Record<string, unknown> | undefined;
184
211
  const roles: Record<string, RoleOption> = {
@@ -228,6 +255,6 @@ export function buildResultRecord(p: {
228
255
  artifact_keys: p.artifactKeys,
229
256
  completed_at: p.now,
230
257
  config: p.fn.config,
231
- ...renderResultMap(p.fn.definition.result, p.context),
258
+ ...renderResultMap(p.fn.definition.result, p.context, functionTemplateOptions(p.fn)),
232
259
  };
233
260
  }
package/src/template.ts CHANGED
@@ -2,46 +2,87 @@
2
2
  /** Values available to dotted-path template expressions. */
3
3
  export type TemplateContext = { [key: string]: unknown };
4
4
 
5
- /** A dot-path value from the context; unknown paths are errors, objects become JSON. */
6
- export function lookupTemplateValue(context: TemplateContext, expression: string): string {
5
+ /** How templates treat missing values. */
6
+ export interface TemplateOptions {
7
+ /** Every unknown path is an error, `params` too (default false: a missing `params` field renders `''`, and an option naming one is unset). */
8
+ strict?: boolean;
9
+ }
10
+
11
+ // Caller-supplied fields: one a request leaves out is unset, not a config error.
12
+ const OPTIONAL_ROOTS = ['params'];
13
+ const TEMPLATE = /\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
14
+
15
+ // The value at a dot path, or MISSING when the path is not there.
16
+ const MISSING = Symbol('missing');
17
+ function resolvePath(context: TemplateContext, expression: string): unknown {
7
18
  let current: unknown = context;
8
19
  for (const part of String(expression).split('.').filter(Boolean)) {
9
- if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, part)) {
10
- throw new Error(`unknown template value: ${expression}`);
11
- }
20
+ if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, part)) return MISSING;
12
21
  current = (current as TemplateContext)[part];
13
22
  }
23
+ return current;
24
+ }
25
+
26
+ function optionalRoot(expression: string, opts?: TemplateOptions): boolean {
27
+ return !opts?.strict && OPTIONAL_ROOTS.includes(String(expression).split('.').filter(Boolean)[0] ?? '');
28
+ }
29
+
30
+ /**
31
+ * A dot-path value from the context; objects become JSON and null is `''`.
32
+ * An unknown path is an error, except a missing `params` field, which is `''`
33
+ * unless `opts.strict`.
34
+ */
35
+ export function lookupTemplateValue(context: TemplateContext, expression: string, opts?: TemplateOptions): string {
36
+ const current = resolvePath(context, expression);
37
+ if (current === MISSING) {
38
+ if (optionalRoot(expression, opts)) return '';
39
+ throw new Error(`unknown template value: ${expression}`);
40
+ }
14
41
  if (current === undefined || current === null) return '';
15
42
  return typeof current === 'object' ? JSON.stringify(current) : String(current);
16
43
  }
17
44
 
45
+ /**
46
+ * True when `value` names a `params` field that is missing or null, alone
47
+ * (`${params.x}`) or inside a longer string (`v-${params.x}`), so an option
48
+ * or positional set to it is left unset. Always false with `opts.strict`.
49
+ */
50
+ export function isUnsetTemplate(value: unknown, context: TemplateContext, opts?: TemplateOptions): boolean {
51
+ if (typeof value !== 'string') return false;
52
+ for (const [, dollar, brace] of value.matchAll(TEMPLATE)) {
53
+ const expression = dollar || brace;
54
+ if (!optionalRoot(expression, opts)) continue;
55
+ const current = resolvePath(context, expression);
56
+ if (current === MISSING || current === undefined || current === null) return true;
57
+ }
58
+ return false;
59
+ }
60
+
18
61
  /** Replace dotted-path ${expression} and {{expression}} templates using the context. */
19
- export function renderTemplateString(value: string, context: TemplateContext): string {
20
- return String(value).replace(/\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_, dollar, brace) =>
21
- lookupTemplateValue(context, dollar || brace),
22
- );
62
+ export function renderTemplateString(value: string, context: TemplateContext, opts?: TemplateOptions): string {
63
+ return String(value).replace(TEMPLATE, (_, dollar, brace) => lookupTemplateValue(context, dollar || brace, opts));
23
64
  }
24
65
 
25
66
  /** Render strings and the strings in arrays; other scalars keep their type. */
26
- export function renderConfigValue(value: unknown, context?: TemplateContext | null): unknown {
67
+ export function renderConfigValue(value: unknown, context?: TemplateContext | null, opts?: TemplateOptions): unknown {
27
68
  if (!context) return value;
28
- if (typeof value === 'string') return renderTemplateString(value, context);
29
- if (Array.isArray(value)) return value.map((item) => renderConfigValue(item, context));
69
+ if (typeof value === 'string') return renderTemplateString(value, context, opts);
70
+ if (Array.isArray(value)) return value.map((item) => renderConfigValue(item, context, opts));
30
71
  return value;
31
72
  }
32
73
 
33
74
  /** Deep-render a `result` map value: strings, arrays and nested objects. */
34
- export function renderResultValue(value: unknown, context: TemplateContext): unknown {
35
- if (typeof value === 'string') return renderTemplateString(value, context);
36
- if (Array.isArray(value)) return value.map((item) => renderResultValue(item, context));
75
+ export function renderResultValue(value: unknown, context: TemplateContext, opts?: TemplateOptions): unknown {
76
+ if (typeof value === 'string') return renderTemplateString(value, context, opts);
77
+ if (Array.isArray(value)) return value.map((item) => renderResultValue(item, context, opts));
37
78
  if (value && typeof value === 'object') {
38
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context)]));
79
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context, opts)]));
39
80
  }
40
81
  return value;
41
82
  }
42
83
 
43
84
  /** Render a function's optional `result` map into extra result fields. */
44
- export function renderResultMap(resultMap: unknown, context: TemplateContext): { [key: string]: unknown } {
85
+ export function renderResultMap(resultMap: unknown, context: TemplateContext, opts?: TemplateOptions): { [key: string]: unknown } {
45
86
  if (!resultMap || typeof resultMap !== 'object' || Array.isArray(resultMap)) return {};
46
- return renderResultValue(resultMap, context) as { [key: string]: unknown };
87
+ return renderResultValue(resultMap, context, opts) as { [key: string]: unknown };
47
88
  }