clyops-jobs 0.9.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
@@ -236,6 +257,9 @@ function isPathValued(validation, type) {
236
257
  function isBareProgram(validation, value) {
237
258
  return validation === "file:executable" && value !== "" && !value.includes("/") && !(process.platform === "win32" && value.includes("\\"));
238
259
  }
260
+ function oneJsonValue(validation, many) {
261
+ return (validation === "json" || validation.startsWith("json:")) && !many;
262
+ }
239
263
  var TRUE = /^(true|1|yes|on)$/i;
240
264
  var FALSE = /^(false|0|no|off)$/i;
241
265
  function toArgv(schema, input, opts = {}) {
@@ -283,7 +307,7 @@ function toArgv(schema, input, opts = {}) {
283
307
  out.env[option.variableName] = text(found.value, isPathValued(option.validation, option.type), `--${option.name}`, option.validation);
284
308
  continue;
285
309
  }
286
- 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]) {
287
311
  out.argv.push(`--${option.name}`, text(item, isPathValued(option.validation, option.type), `--${option.name}`, option.validation));
288
312
  }
289
313
  }
@@ -309,7 +333,7 @@ function toArgv(schema, input, opts = {}) {
309
333
  positionals.push(text(gap.default, false));
310
334
  }
311
335
  skipped = [];
312
- 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];
313
337
  positionals.push(...values.map((v) => text(v, isPathValued(argument.validation), argument.name, argument.validation)));
314
338
  }
315
339
  }
@@ -600,22 +624,35 @@ function resolveFunctionConfig(scriptRoot, routeConfig, functionName) {
600
624
  const config = deepMerge(objectValue(objectValue(routeConfig.defaults)[key]), objectValue(routeConfig[key]));
601
625
  return { functionName, key, script: resolveScriptPath(scriptRoot, script), config, definition };
602
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
+ }
603
638
  function explicitPositionals(fn, context) {
604
639
  if (!Array.isArray(fn.definition.positionals)) return void 0;
605
- 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);
606
642
  }
607
643
  async function buildFunctionCommand(fn, opts, schema) {
608
644
  schema ??= await loadSchema(fn.script);
609
- 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 };
610
648
  const first = schema.arguments[0];
611
649
  if (opts.defaultPositional && first && !Array.isArray(fn.definition.positionals) && !inputKeys(first).some((k) => k in config)) {
612
650
  config[inputKeys(first)[0]] = opts.defaultPositional;
613
651
  }
614
- const context = opts.context;
615
652
  const { argv, unknown, controlled } = toArgv(schema, config, {
616
653
  base: opts.configRoot,
617
654
  controlled: opts.controlled ?? ["help"],
618
- render: context ? (s) => renderTemplateString(s, context) : void 0,
655
+ render: context ? (s) => renderTemplateString(s, context, templates) : void 0,
619
656
  positionals: explicitPositionals(fn, context),
620
657
  positionalsOrder: fn.definition.positionals_order
621
658
  });
@@ -632,9 +669,9 @@ function parseRoleJson(text) {
632
669
  return void 0;
633
670
  }
634
671
  }
635
- function streamPath(template, opts, base) {
672
+ function streamPath(template, opts, base, templates) {
636
673
  if (!template) return void 0;
637
- const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
674
+ const rendered = opts.context ? renderTemplateString(template, opts.context, templates) : template;
638
675
  return (0, import_node_path3.isAbsolute)(rendered) ? rendered : (0, import_node_path3.resolve)(base, rendered);
639
676
  }
640
677
  async function runScriptFunction(fn, opts) {
@@ -643,8 +680,8 @@ async function runScriptFunction(fn, opts) {
643
680
  const command = [file, ...redactArgv(schema, argv)];
644
681
  LOGGER.verbose("command: %s", shellQuote(command));
645
682
  const workDir = opts.context?.paths?.work_dir;
646
- const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
647
- 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));
648
685
  let progress;
649
686
  const roles = {
650
687
  progress: {
@@ -688,7 +725,7 @@ function buildResultRecord(p) {
688
725
  artifact_keys: p.artifactKeys,
689
726
  completed_at: p.now,
690
727
  config: p.fn.config,
691
- ...renderResultMap(p.fn.definition.result, p.context)
728
+ ...renderResultMap(p.fn.definition.result, p.context, functionTemplateOptions(p.fn))
692
729
  };
693
730
  }
694
731
 
@@ -734,8 +771,8 @@ function listFilesRecursive(dir, visited = /* @__PURE__ */ new Set()) {
734
771
  }
735
772
  return files;
736
773
  }
737
- function findFilesByArtifactPattern(pattern, context) {
738
- const rendered = renderTemplateString(pattern, context);
774
+ function findFilesByArtifactPattern(pattern, context, opts) {
775
+ const rendered = renderTemplateString(pattern, context, opts);
739
776
  const absolute = (0, import_node_path4.isAbsolute)(rendered) ? rendered : (0, import_node_path4.resolve)(context.paths.work_dir, rendered);
740
777
  if (!/[*?]/.test(absolute)) return isFilePath(absolute) ? [absolute] : [];
741
778
  const regex = globToRegex(absolute);
@@ -774,7 +811,7 @@ function copyConfiguredArtifacts(fn, context, opts = {}) {
774
811
  const seen = /* @__PURE__ */ new Set();
775
812
  const copied = [];
776
813
  for (const pattern of patterns) {
777
- for (const source of findFilesByArtifactPattern(pattern, context)) {
814
+ for (const source of findFilesByArtifactPattern(pattern, context, { strict: fn.definition.strict_params === true })) {
778
815
  if (seen.has(source)) continue;
779
816
  seen.add(source);
780
817
  const destination = (0, import_node_path4.join)(dir, (0, import_node_path4.basename)(source));
@@ -924,10 +961,12 @@ var JobQueue = class {
924
961
  deepMerge,
925
962
  ensureDir,
926
963
  findFilesByArtifactPattern,
964
+ functionTemplateOptions,
927
965
  globToRegex,
928
966
  isDirectoryPath,
929
967
  isEnabled,
930
968
  isFilePath,
969
+ isUnsetTemplate,
931
970
  listFilesRecursive,
932
971
  lookupTemplateValue,
933
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":"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"}
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.9.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.9.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
  }