@nexusbloom/cli 0.9.5 → 0.9.7
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 +45 -12
- package/package.json +1 -1
- package/src/pipe.js +140 -27
- package/src/values.js +354 -5
- package/src/wizard.js +161 -119
package/README.md
CHANGED
|
@@ -209,18 +209,20 @@ $ nxb pipe --build
|
|
|
209
209
|
frequency-analyzer.total_words integer 2
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
`
|
|
215
|
-
|
|
216
|
-
you chose:
|
|
212
|
+
Every wired field offers the whole transform catalogue, whatever its type, and
|
|
213
|
+
nothing is applied on your behalf — a type clash is flagged on the choice list
|
|
214
|
+
but no transform is chosen for you. So `chars()` on a string and `len()` on an
|
|
215
|
+
array are both one step away, and you can chain as many as you like:
|
|
217
216
|
|
|
218
217
|
```bash
|
|
219
218
|
frequency-analyzer → count integer
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
count: transform (optional)
|
|
220
|
+
· none — use the value as it came
|
|
221
|
+
len length of a string, array or object
|
|
222
|
+
chars number of characters
|
|
223
|
+
join array to a string, with a separator
|
|
224
|
+
Add another transform to count? yes
|
|
225
|
+
frequency-analyzer count=$character_frequency|len()|int()
|
|
224
226
|
```
|
|
225
227
|
|
|
226
228
|
This produces the same `$path|transform()` syntax you can write by hand. Two
|
|
@@ -232,9 +234,40 @@ rather than emitting a spec that will not run:
|
|
|
232
234
|
from a separator, so it refuses all of them.
|
|
233
235
|
- **`|` cannot appear in an argument**, because it separates pipeline steps.
|
|
234
236
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
Declining the list leaves the value as it came. If that produces a type error,
|
|
238
|
+
`nxb doctor <slug>` and the plan-time check in `--dry-run` name the broken wire
|
|
239
|
+
rather than the pipeline failing mid-run. Run `nxb pipe --transforms` for the
|
|
240
|
+
same catalogue as a plain list.
|
|
241
|
+
|
|
242
|
+
### Reusing an earlier step's output
|
|
243
|
+
|
|
244
|
+
A bare `$path` always reads the **previous** step. When a pipeline needs two
|
|
245
|
+
values carried forward, add `as=<name>` to the step that produces the one you
|
|
246
|
+
want to keep, then address it by that name from anywhere later:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
$ nxb pipe 'password-generator length=8 as=pw \
|
|
250
|
+
| lorem-generator count=$pw.password|chars()|str() # use its length here \
|
|
251
|
+
| base64-tool text=$pw.password action=encode # encode the password \
|
|
252
|
+
| base64-tool text=$result action=decode # …and decode it again \
|
|
253
|
+
| echo-tool text=$pw.password' # …and use the original
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Without a label that pipeline is impossible: the only way to reach the first
|
|
257
|
+
step's password at the end is to run it a second time.
|
|
258
|
+
|
|
259
|
+
- A label is read from the first path segment, so `$pw.password` and
|
|
260
|
+
`$pw` (the whole output) both work. Transforms chain as usual.
|
|
261
|
+
- Labels are unique across a pipeline; a repeat is an error rather than a
|
|
262
|
+
silent last-one-wins.
|
|
263
|
+
- `as=` only counts as a label in **last** position, so a tool with a real
|
|
264
|
+
input field named `as` is unaffected.
|
|
265
|
+
- Plan-time type checking resolves labels too, so `--dry-run` still catches a
|
|
266
|
+
bad cast on a far connection instead of failing mid-run.
|
|
267
|
+
|
|
268
|
+
The interactive builder offers this: after a step runs it asks whether to keep
|
|
269
|
+
the output, and a kept output then appears in the field picker for every later
|
|
270
|
+
step alongside the previous one.
|
|
238
271
|
|
|
239
272
|
### Saved pipelines
|
|
240
273
|
|
package/package.json
CHANGED
package/src/pipe.js
CHANGED
|
@@ -322,6 +322,24 @@ export function parsePipeline(spec) {
|
|
|
322
322
|
throw new Error(`Invalid tool slug "${slug}"`);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
// A trailing `as=<label>` names this step's output so later steps can read
|
|
326
|
+
// it instead of only the immediately previous step. It is recognised only in
|
|
327
|
+
// last position, so a tool with a real field called `as` keeps working.
|
|
328
|
+
let label = null;
|
|
329
|
+
const last = tokens[tokens.length - 1];
|
|
330
|
+
if (last !== undefined && /^as=([a-zA-Z_][a-zA-Z0-9_]*)$/.test(last)) {
|
|
331
|
+
label = last.slice(3);
|
|
332
|
+
tokens.pop();
|
|
333
|
+
} else if (last !== undefined && /^as=/.test(last)) {
|
|
334
|
+
// Trailing `as=` that is not a valid identifier is far more likely a typo
|
|
335
|
+
// than a field the user meant to set, and silently becoming `as=<value>`
|
|
336
|
+
// would produce a confusing "no such field" much later.
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Invalid label "${last.slice(3)}" — a label must start with a letter or _ and ` +
|
|
339
|
+
`contain only letters, digits and _ (e.g. as=pw).`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
325
343
|
const inputs = [];
|
|
326
344
|
for (const token of tokens.slice(1)) {
|
|
327
345
|
const eq = token.indexOf("=");
|
|
@@ -335,13 +353,55 @@ export function parsePipeline(spec) {
|
|
|
335
353
|
const exprText = expr.kind === "literal" ? "" : `$${expr.headPath ?? ""}`;
|
|
336
354
|
inputs.push({ field, expr, exprText });
|
|
337
355
|
}
|
|
338
|
-
steps.push({ slug, inputs, raw: spec });
|
|
356
|
+
steps.push({ slug, inputs, raw: spec, label });
|
|
339
357
|
}
|
|
340
358
|
|
|
341
359
|
if (!steps.length) throw new Error("Empty pipeline.");
|
|
360
|
+
|
|
361
|
+
const seen = new Map();
|
|
362
|
+
for (const step of steps) {
|
|
363
|
+
if (!step.label) continue;
|
|
364
|
+
const first = seen.get(step.label);
|
|
365
|
+
if (first !== undefined) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`Duplicate label "${step.label}" on steps ${first + 1} and ${step.slug} — ` +
|
|
368
|
+
`labels name one step's output, so they must be unique.`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
seen.set(step.label, steps.indexOf(step));
|
|
372
|
+
}
|
|
373
|
+
|
|
342
374
|
return steps;
|
|
343
375
|
}
|
|
344
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Resolve a connection's head path against the labelled outputs in scope.
|
|
379
|
+
*
|
|
380
|
+
* `$x` keeps its meaning — the previous step. `$name.field` reads `field` from
|
|
381
|
+
* whichever step was labelled `as=name`, at any distance, which is what lets a
|
|
382
|
+
* pipeline use an early value again much later.
|
|
383
|
+
*
|
|
384
|
+
* @returns {{label: string|null, upstream: any, accessors: Array}|null} null when
|
|
385
|
+
* the head does not name a label, meaning the caller should use the old
|
|
386
|
+
* previous-step behaviour.
|
|
387
|
+
*/
|
|
388
|
+
export function scopeToLabel(accessors, scope) {
|
|
389
|
+
if (!accessors.length || accessors[0].op !== "path") return null;
|
|
390
|
+
const segments = parsePath(accessors[0].path);
|
|
391
|
+
const head = segments[0];
|
|
392
|
+
if (typeof head !== "string" || !scope.has(head)) return null;
|
|
393
|
+
|
|
394
|
+
const rest = segments.slice(1);
|
|
395
|
+
return {
|
|
396
|
+
label: head,
|
|
397
|
+
upstream: scope.get(head),
|
|
398
|
+
accessors: [
|
|
399
|
+
{ op: "path", path: rest.length ? formatPath(rest) : "$" },
|
|
400
|
+
...accessors.slice(1),
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
345
405
|
// ─── Plan ────────────────────────────────────────────────────────────────────
|
|
346
406
|
|
|
347
407
|
/**
|
|
@@ -355,6 +415,8 @@ export async function buildPlan(steps, getManifest) {
|
|
|
355
415
|
const planned = [];
|
|
356
416
|
const issues = [];
|
|
357
417
|
const produced = new Map(); // slug -> output field names it declares
|
|
418
|
+
const labelTypes = new Map(); // `as=name` -> that step's output schema
|
|
419
|
+
const labelSlug = new Map(); // `as=name` -> the slug that produced it
|
|
358
420
|
|
|
359
421
|
for (let i = 0; i < steps.length; i++) {
|
|
360
422
|
const step = steps[i];
|
|
@@ -378,6 +440,10 @@ export async function buildPlan(steps, getManifest) {
|
|
|
378
440
|
const outProps = info.manifest.output_schema?.properties || {};
|
|
379
441
|
entry.outputs = Object.keys(outProps);
|
|
380
442
|
produced.set(step.slug, outProps);
|
|
443
|
+
if (step.label) {
|
|
444
|
+
labelTypes.set(step.label, outProps);
|
|
445
|
+
labelSlug.set(step.label, step.slug);
|
|
446
|
+
}
|
|
381
447
|
|
|
382
448
|
for (const input of step.inputs) {
|
|
383
449
|
const prop = props[input.field];
|
|
@@ -399,37 +465,55 @@ export async function buildPlan(steps, getManifest) {
|
|
|
399
465
|
}
|
|
400
466
|
|
|
401
467
|
if (input.expr.kind === "connection") {
|
|
468
|
+
// Where does this connection read from? An `as=` label if the head names
|
|
469
|
+
// one, otherwise the immediately previous step as before.
|
|
470
|
+
const headSegments = parsePath(input.expr.headPath);
|
|
471
|
+
const head = headSegments[0];
|
|
472
|
+
const usesLabel = typeof head === "string" && labelTypes.has(head);
|
|
402
473
|
const prev = i > 0 ? steps[i - 1] : null;
|
|
403
|
-
|
|
474
|
+
const sourceSlug = usesLabel ? labelSlug.get(head) : prev?.slug;
|
|
475
|
+
const sourceOut = usesLabel
|
|
476
|
+
? labelTypes.get(head) || {}
|
|
477
|
+
: (produced.get(prev?.slug) || {});
|
|
478
|
+
// The segments after the label are the real path inside that output.
|
|
479
|
+
const restSegments = usesLabel ? headSegments.slice(1) : headSegments;
|
|
480
|
+
|
|
481
|
+
if (!usesLabel && !prev) {
|
|
404
482
|
issues.push({ step: i, level: "error", message: `Nothing to read $${input.expr.headPath} from — it is the first step.` });
|
|
405
483
|
field.verdict = "error";
|
|
406
484
|
} else {
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
const prevDeclaresAny = Object.keys(prevOut).length > 0;
|
|
412
|
-
const known = headSegments.length === 0 || Boolean(prevProp) || !prevDeclaresAny;
|
|
485
|
+
const leaf = restSegments[restSegments.length - 1];
|
|
486
|
+
const sourceProp = restSegments.length === 1 ? sourceOut[leaf] : undefined;
|
|
487
|
+
const declaresAny = Object.keys(sourceOut).length > 0;
|
|
488
|
+
const known = restSegments.length === 0 || Boolean(sourceProp) || !declaresAny;
|
|
413
489
|
|
|
414
490
|
field.source = {
|
|
415
|
-
slug:
|
|
416
|
-
|
|
417
|
-
|
|
491
|
+
slug: sourceSlug,
|
|
492
|
+
label: usesLabel ? head : null,
|
|
493
|
+
path: usesLabel ? formatPath(restSegments) : input.expr.headPath,
|
|
494
|
+
pathLabel: formatPath(restSegments),
|
|
418
495
|
};
|
|
419
|
-
field.sourceTypes = schemaTypes(
|
|
496
|
+
field.sourceTypes = schemaTypes(sourceProp);
|
|
420
497
|
field.sourceKnown = known;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
498
|
+
const srcName = usesLabel
|
|
499
|
+
? `${head}.${formatPath(restSegments) || "(root)"}`
|
|
500
|
+
: `${sourceSlug}.${input.expr.headPath || "(root)"}`;
|
|
501
|
+
|
|
502
|
+
// Walk the accessors to work out the type that actually arrives. The
|
|
503
|
+
// label segment is not part of the data path, so it is dropped first.
|
|
504
|
+
const simAccessors = usesLabel
|
|
505
|
+
? [{ op: "path", path: formatPath(restSegments) }, ...input.expr.accessors.slice(1)]
|
|
506
|
+
: input.expr.accessors;
|
|
507
|
+
field.resultTypes = simulateTypes(simAccessors, field.sourceTypes);
|
|
424
508
|
// A path used *after* a transform cannot be typed from the schema.
|
|
425
|
-
if (!known ||
|
|
509
|
+
if (!known || restSegments.length > 1) field.staticallyUnknown = true;
|
|
426
510
|
|
|
427
511
|
if (!known) {
|
|
428
512
|
field.verdict = "unknown-source";
|
|
429
513
|
issues.push({
|
|
430
514
|
step: i,
|
|
431
515
|
level: "warn",
|
|
432
|
-
message: `"${
|
|
516
|
+
message: `"${sourceSlug}" does not declare output "${formatPath(restSegments)}" — checked at runtime.`,
|
|
433
517
|
});
|
|
434
518
|
} else if (field.targetTypes.length && field.resultTypes.length) {
|
|
435
519
|
const compat = compatibility(field.resultTypes[0], field.targetTypes);
|
|
@@ -439,18 +523,18 @@ export async function buildPlan(steps, getManifest) {
|
|
|
439
523
|
issues.push({
|
|
440
524
|
step: i,
|
|
441
525
|
level: "error",
|
|
442
|
-
message: `${
|
|
526
|
+
message: `${srcName} is ${typeLabel(field.resultTypes)} but ${step.slug}.${input.field} wants ${typeLabel(field.targetTypes)} — add |${compat.suggestion}`,
|
|
443
527
|
});
|
|
444
528
|
} else if (compat.level === "lossy") {
|
|
445
529
|
field.verdict = "lossy";
|
|
446
530
|
issues.push({
|
|
447
531
|
step: i,
|
|
448
532
|
level: "warn",
|
|
449
|
-
message: `${
|
|
533
|
+
message: `${srcName} → ${step.slug}.${input.field} may lose precision (${compat.note}).`,
|
|
450
534
|
});
|
|
451
535
|
} else if (compat.level === "invalid") {
|
|
452
536
|
field.verdict = "error";
|
|
453
|
-
issues.push({ step: i, level: "error", message: `${
|
|
537
|
+
issues.push({ step: i, level: "error", message: `${srcName} → ${step.slug}.${input.field}: ${compat.note}.` });
|
|
454
538
|
} else {
|
|
455
539
|
field.verdict = "ok";
|
|
456
540
|
}
|
|
@@ -538,6 +622,8 @@ export async function runPipeline({
|
|
|
538
622
|
}) {
|
|
539
623
|
const outputs = new Array(steps.length).fill(undefined);
|
|
540
624
|
const logs = [];
|
|
625
|
+
// Labelled step outputs, readable from any later step.
|
|
626
|
+
const scope = new Map();
|
|
541
627
|
|
|
542
628
|
for (let i = 0; i < steps.length; i++) {
|
|
543
629
|
const step = steps[i];
|
|
@@ -563,13 +649,24 @@ export async function runPipeline({
|
|
|
563
649
|
continue;
|
|
564
650
|
}
|
|
565
651
|
|
|
566
|
-
|
|
652
|
+
// An explicit `as=` label wins over the implicit previous step.
|
|
653
|
+
const labelled = scopeToLabel(expr.accessors, scope);
|
|
654
|
+
const upstream = labelled ? labelled.upstream : outputs[i - 1];
|
|
567
655
|
if (upstream === undefined) {
|
|
568
|
-
throw new PipeError(
|
|
656
|
+
throw new PipeError(
|
|
657
|
+
labelled
|
|
658
|
+
? `Step ${i + 1}: ${step.slug} reads $${expr.headPath}, but no step produced "${labelled.label}".`
|
|
659
|
+
: i === 0
|
|
660
|
+
? `Step 1: ${step.slug} reads $${expr.headPath}, but there is no earlier step. ` +
|
|
661
|
+
`A connection reads the previous step, or a step you labelled with as=<name>.`
|
|
662
|
+
: `Step ${i + 1}: ${step.slug} reads $${expr.headPath} but step ${i} has not run.`
|
|
663
|
+
);
|
|
569
664
|
}
|
|
570
665
|
|
|
571
|
-
let value = resolveAccessors(upstream, expr.accessors);
|
|
572
|
-
const fromLabel =
|
|
666
|
+
let value = resolveAccessors(upstream, labelled ? labelled.accessors : expr.accessors);
|
|
667
|
+
const fromLabel = labelled
|
|
668
|
+
? `${labelled.label}.${formatPath(parsePath(expr.headPath).slice(1)) || "(root)"}`
|
|
669
|
+
: `${field.source?.slug ?? `step ${i}`}.${expr.headPath || "(root)"}`;
|
|
573
670
|
|
|
574
671
|
// A connection that resolves to nothing is always an error: silently
|
|
575
672
|
// sending `undefined` hides the broken wire behind whatever the tool
|
|
@@ -618,6 +715,7 @@ export async function runPipeline({
|
|
|
618
715
|
|
|
619
716
|
const value = result?.ok === true ? result.result : result;
|
|
620
717
|
outputs[i] = value;
|
|
718
|
+
if (step.label) scope.set(step.label, value);
|
|
621
719
|
onStep({ phase: "done", index: i, step, input, output: value, durationMs: result?.durationMs });
|
|
622
720
|
}
|
|
623
721
|
|
|
@@ -645,7 +743,11 @@ export function renderPlan(plan) {
|
|
|
645
743
|
lines.push(chalk.red(` ✗ ${step.slug} — not found`));
|
|
646
744
|
continue;
|
|
647
745
|
}
|
|
648
|
-
lines.push(
|
|
746
|
+
lines.push(
|
|
747
|
+
step.label
|
|
748
|
+
? chalk.bold(` ${step.slug}`) + chalk.magenta(` as=${step.label}`)
|
|
749
|
+
: chalk.bold(` ${step.slug}`)
|
|
750
|
+
);
|
|
649
751
|
|
|
650
752
|
for (const f of step.fields) {
|
|
651
753
|
const target = chalk.cyan(f.field);
|
|
@@ -656,11 +758,22 @@ export function renderPlan(plan) {
|
|
|
656
758
|
} else if (f.expr.kind === "stdin") {
|
|
657
759
|
lines.push(` ${target} = ${chalk.magenta("@in")} ${chalk.gray(`(${targetType || "any"})`)}`);
|
|
658
760
|
} else {
|
|
659
|
-
const
|
|
761
|
+
const accessors = f.expr.accessors || [];
|
|
762
|
+
// A labelled source puts the label *and* the rest of the path in the head
|
|
763
|
+
// accessor, so it is displayed the way it was written rather than with the
|
|
764
|
+
// producing slug glued in front.
|
|
765
|
+
const labelled = Boolean(f.source?.label);
|
|
766
|
+
const rest = accessors.slice(labelled ? 1 : 0);
|
|
767
|
+
const chain = rest
|
|
660
768
|
.map((a) => (a.op === "transform" ? `|${a.name}` : a.path))
|
|
661
769
|
.join("");
|
|
770
|
+
const head = labelled
|
|
771
|
+
? `${f.source.label}${f.source.path === "$" ? "" : `.${f.source.path}`}`
|
|
772
|
+
: `${f.source?.slug ?? "?"}`;
|
|
662
773
|
const arrow = chalk.gray("←");
|
|
663
|
-
const src = chalk.dim(
|
|
774
|
+
const src = chalk.dim(
|
|
775
|
+
`${head}${labelled ? chain : chain.startsWith(".") || chain.startsWith("|") ? chain : `.${chain}`}`
|
|
776
|
+
);
|
|
664
777
|
const got = f.resultTypes?.length ? typeLabel(f.resultTypes) : "runtime";
|
|
665
778
|
const mark =
|
|
666
779
|
f.verdict === "needs-cast" || f.verdict === "error" ? chalk.red("✗")
|
package/src/values.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/** Distinguishes "the field is absent" from "the field holds undefined". */
|
|
16
|
+
import { createHash, randomUUID } from "crypto";
|
|
17
|
+
|
|
16
18
|
export const MISSING = Symbol("missing");
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -96,6 +98,40 @@ export function formatPath(segments) {
|
|
|
96
98
|
return out;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
// ─── Helpers for the computed transforms ─────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compare loosely enough that `eq(200)` matches a JSON number but `eq("200")`
|
|
105
|
+
* also matches the string a tool happened to return, while still refusing to
|
|
106
|
+
* claim `[1]` equals `[1]`.
|
|
107
|
+
*/
|
|
108
|
+
function coerceLike(other, sample) {
|
|
109
|
+
if (typeof other === "string" && typeof sample === "number") {
|
|
110
|
+
const n = Number(other);
|
|
111
|
+
return isNaN(n) ? other : n;
|
|
112
|
+
}
|
|
113
|
+
return other;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Equality that forgives a number arriving as text, without extending that
|
|
118
|
+
* forgiveness to containers. Comparing by string would make two separate
|
|
119
|
+
* `[1]`s equal and two `{a:1}`s equal, which is never what "are these the same"
|
|
120
|
+
* should answer.
|
|
121
|
+
*/
|
|
122
|
+
function looseEqual(a, b) {
|
|
123
|
+
if (a === b) return true;
|
|
124
|
+
const bothPrimitives =
|
|
125
|
+
(a === null || typeof a !== "object") && (b === null || typeof b !== "object");
|
|
126
|
+
if (!bothPrimitives) return false;
|
|
127
|
+
return String(a) === String(b);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stable hash of a stringified value, for change detection and cache keys. */
|
|
131
|
+
function digest(value, algorithm) {
|
|
132
|
+
return createHash(algorithm).update(toStr(value), "utf8").digest("hex");
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
// ─── Coercion helpers ────────────────────────────────────────────────────────
|
|
100
136
|
|
|
101
137
|
const toStr = (v) => {
|
|
@@ -204,6 +240,212 @@ export const TRANSFORMS = {
|
|
|
204
240
|
return r;
|
|
205
241
|
},
|
|
206
242
|
default: (v, fallback) => (v === MISSING || v === undefined || v === null || v === "" ? fallback : v),
|
|
243
|
+
|
|
244
|
+
// — arithmetic —
|
|
245
|
+
//
|
|
246
|
+
// The tools in a catalogue rarely line up numerically: one returns a count, the
|
|
247
|
+
// next wants a word count. These fill the gap without a dedicated tool, which is
|
|
248
|
+
// the difference between "add a tool" and "compose what exists".
|
|
249
|
+
//
|
|
250
|
+
// A wrong input type returns the value untouched rather than throwing or
|
|
251
|
+
// coercing, so a transform never silently invents a number.
|
|
252
|
+
add: (v, n) => (typeof v === "number" && isFinite(v) ? v + Number(n) : v),
|
|
253
|
+
sub: (v, n) => (typeof v === "number" && isFinite(v) ? v - Number(n) : v),
|
|
254
|
+
mul: (v, n) => (typeof v === "number" && isFinite(v) ? v * Number(n) : v),
|
|
255
|
+
div: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v / Number(n) : v),
|
|
256
|
+
mod: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v % Number(n) : v),
|
|
257
|
+
inc: (v) => (typeof v === "number" && isFinite(v) ? v + 1 : v),
|
|
258
|
+
dec: (v) => (typeof v === "number" && isFinite(v) ? v - 1 : v),
|
|
259
|
+
neg: (v) => (typeof v === "number" && isFinite(v) ? -v : v),
|
|
260
|
+
abs: (v) => (typeof v === "number" && isFinite(v) ? Math.abs(v) : v),
|
|
261
|
+
pow: (v, n) => (typeof v === "number" && isFinite(v) ? v ** Number(n) : v),
|
|
262
|
+
round: (v, p = 0) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(Number(p))) : v),
|
|
263
|
+
floor: (v) => (typeof v === "number" && isFinite(v) ? Math.floor(v) : v),
|
|
264
|
+
ceil: (v) => (typeof v === "number" && isFinite(v) ? Math.ceil(v) : v),
|
|
265
|
+
clamp: (v, lo, hi) =>
|
|
266
|
+
typeof v === "number" && isFinite(v)
|
|
267
|
+
? Math.min(Math.max(v, Number(lo)), Number(hi))
|
|
268
|
+
: v,
|
|
269
|
+
|
|
270
|
+
// Aggregate — the whole input array collapses to one number.
|
|
271
|
+
sum: (v) => (Array.isArray(v) ? v.filter((n) => typeof n === "number").reduce((a, b) => a + b, 0) : v),
|
|
272
|
+
avg: (v) => {
|
|
273
|
+
if (!Array.isArray(v)) return v;
|
|
274
|
+
const nums = v.filter((n) => typeof n === "number");
|
|
275
|
+
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : MISSING;
|
|
276
|
+
},
|
|
277
|
+
min: (v) => (Array.isArray(v) && v.length ? Math.min(...v.filter((n) => typeof n === "number")) : v),
|
|
278
|
+
max: (v) => (Array.isArray(v) && v.length ? Math.max(...v.filter((n) => typeof n === "number")) : v),
|
|
279
|
+
// Sum one field across an array of objects — the common "total of the column".
|
|
280
|
+
sumBy: (v, key) => {
|
|
281
|
+
if (!Array.isArray(v)) return v;
|
|
282
|
+
return v
|
|
283
|
+
.map((r) => getPath(r, key))
|
|
284
|
+
.filter((n) => typeof n === "number")
|
|
285
|
+
.reduce((a, b) => a + b, 0);
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
// — comparison —
|
|
289
|
+
//
|
|
290
|
+
// These return real booleans, so they are the input to `then()` and to any tool
|
|
291
|
+
// taking a boolean. A non-matching value is `false`, never MISSING.
|
|
292
|
+
eq: (v, other) => looseEqual(v, other),
|
|
293
|
+
ne: (v, other) => !looseEqual(v, other),
|
|
294
|
+
gt: (v, n) => typeof v === "number" && isFinite(v) && v > Number(n),
|
|
295
|
+
gte: (v, n) => typeof v === "number" && isFinite(v) && v >= Number(n),
|
|
296
|
+
lt: (v, n) => typeof v === "number" && isFinite(v) && v < Number(n),
|
|
297
|
+
lte: (v, n) => typeof v === "number" && isFinite(v) && v <= Number(n),
|
|
298
|
+
between: (v, lo, hi) => typeof v === "number" && isFinite(v) && v >= Number(lo) && v <= Number(hi),
|
|
299
|
+
includes: (v, needle) =>
|
|
300
|
+
typeof v === "string"
|
|
301
|
+
? v.includes(needle)
|
|
302
|
+
: Array.isArray(v)
|
|
303
|
+
? v.some((x) => x === needle)
|
|
304
|
+
: false,
|
|
305
|
+
startsWith: (v, s) => typeof v === "string" && v.startsWith(s),
|
|
306
|
+
endsWith: (v, s) => typeof v === "string" && v.endsWith(s),
|
|
307
|
+
// Regex source only — a pattern with spaces or flags cannot be expressed, and a
|
|
308
|
+
// bad pattern is the user's typo rather than something to guess at.
|
|
309
|
+
matches: (v, re) => {
|
|
310
|
+
if (typeof v !== "string" || !re) return false;
|
|
311
|
+
try {
|
|
312
|
+
return new RegExp(String(re)).test(v);
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
isEmpty: (v) =>
|
|
318
|
+
v === MISSING || v === undefined || v === null || v === "" ||
|
|
319
|
+
(Array.isArray(v) && v.length === 0) ||
|
|
320
|
+
(typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0),
|
|
321
|
+
|
|
322
|
+
// — branching —
|
|
323
|
+
//
|
|
324
|
+
// `then(cond, whenTrue, whenFalse)` is what a linear pipeline was missing: pick
|
|
325
|
+
// between two values on a condition, without a step to do it. `coalesce` is the
|
|
326
|
+
// safe default chain that does not need a condition at all.
|
|
327
|
+
then: (v, cond, whenTrue, whenFalse) => {
|
|
328
|
+
const test = typeof cond === "boolean" ? cond : v === true;
|
|
329
|
+
return test ? whenTrue : whenFalse === undefined ? MISSING : whenFalse;
|
|
330
|
+
},
|
|
331
|
+
coalesce: (v, ...fallbacks) => {
|
|
332
|
+
if (v !== MISSING && v !== undefined && v !== null && v !== "") return v;
|
|
333
|
+
for (const f of fallbacks) {
|
|
334
|
+
if (f !== MISSING && f !== undefined && f !== null && f !== "") return f;
|
|
335
|
+
}
|
|
336
|
+
return MISSING;
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
// — arrays —
|
|
340
|
+
//
|
|
341
|
+
// `pluck` is the one that unlocks the most: it turns an array of records into a
|
|
342
|
+
// flat array of one field, which everything above (`len`, `sort`, `sumBy`,
|
|
343
|
+
// `join`) can then work on.
|
|
344
|
+
pluck: (v, key) =>
|
|
345
|
+
Array.isArray(v) ? v.map((row) => getPath(row, key)) : v,
|
|
346
|
+
unique: (v) => (Array.isArray(v) ? [...new Set(v)] : v),
|
|
347
|
+
compact: (v) =>
|
|
348
|
+
Array.isArray(v) ? v.filter((x) => x !== undefined && x !== null && x !== "") : v,
|
|
349
|
+
flatten: (v) => (Array.isArray(v) ? v.flat(Infinity) : v),
|
|
350
|
+
chunk: (v, n) => {
|
|
351
|
+
if (!Array.isArray(v)) return v;
|
|
352
|
+
const size = Math.max(1, Number(n) || 1);
|
|
353
|
+
const out = [];
|
|
354
|
+
for (let i = 0; i < v.length; i += size) out.push(v.slice(i, i + size));
|
|
355
|
+
return out;
|
|
356
|
+
},
|
|
357
|
+
without: (v, needle) => (Array.isArray(v) ? v.filter((x) => x !== needle) : v),
|
|
358
|
+
// Keep array elements whose field passes a comparison: where("status","eq",200).
|
|
359
|
+
where: (v, key, op, operand) => {
|
|
360
|
+
if (!Array.isArray(v)) return v;
|
|
361
|
+
const test = (row) => {
|
|
362
|
+
const got = getPath(row, key);
|
|
363
|
+
switch (op) {
|
|
364
|
+
case "eq": return looseEqual(got, operand);
|
|
365
|
+
case "ne": return !looseEqual(got, operand);
|
|
366
|
+
case "gt": return typeof got === "number" && got > Number(operand);
|
|
367
|
+
case "gte": return typeof got === "number" && got >= Number(operand);
|
|
368
|
+
case "lt": return typeof got === "number" && got < Number(operand);
|
|
369
|
+
case "lte": return typeof got === "number" && got <= Number(operand);
|
|
370
|
+
case "contains": return String(got ?? "").includes(String(operand));
|
|
371
|
+
case "exists": return got !== MISSING && got !== undefined && got !== null;
|
|
372
|
+
default: return false;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
return v.filter(test);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// — naming and shape —
|
|
379
|
+
title: (v) => toStr(v).replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
380
|
+
camel: (v) => toStr(v).replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")).replace(/^(.)/, (c) => c.toLowerCase()),
|
|
381
|
+
snake: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase(),
|
|
382
|
+
kebab: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(),
|
|
383
|
+
slugify: (v) =>
|
|
384
|
+
toStr(v)
|
|
385
|
+
.toLowerCase()
|
|
386
|
+
.normalize("NFKD")
|
|
387
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
388
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
389
|
+
.replace(/^-+|-+$/g, ""),
|
|
390
|
+
truncate: (v, n, ellipsis = "…") => {
|
|
391
|
+
const s = toStr(v);
|
|
392
|
+
const max = Math.max(0, Number(n) || 0);
|
|
393
|
+
if (s.length <= max) return s;
|
|
394
|
+
// Prefer a word boundary, but only if one is close enough that truncating to
|
|
395
|
+
// it does not throw away most of what was asked for.
|
|
396
|
+
const hard = s.slice(0, max);
|
|
397
|
+
const space = hard.lastIndexOf(" ");
|
|
398
|
+
const cut = space > max * 0.6 ? hard.slice(0, space).replace(/\s+$/, "") : hard;
|
|
399
|
+
return cut + ellipsis;
|
|
400
|
+
},
|
|
401
|
+
repeat: (v, n) => toStr(v).repeat(Math.max(0, Number(n) || 0)),
|
|
402
|
+
// Show the first and last few characters, e.g. mask(4) → "jTI…Nj".
|
|
403
|
+
mask: (v, n = 3) => {
|
|
404
|
+
const s = toStr(v);
|
|
405
|
+
const keep = Math.max(0, Number(n) || 0);
|
|
406
|
+
if (s.length <= keep * 2) return s;
|
|
407
|
+
return `${s.slice(0, keep)}${"…".repeat(1)}${s.slice(-keep)}`;
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
// — encoding and identity —
|
|
411
|
+
//
|
|
412
|
+
// Hashing here is for change detection and cache keys, never for passwords.
|
|
413
|
+
md5: (v) => digest(v, "md5"),
|
|
414
|
+
sha1: (v) => digest(v, "sha1"),
|
|
415
|
+
sha256: (v) => digest(v, "sha256"),
|
|
416
|
+
base64: (v) => Buffer.from(toStr(v), "utf8").toString("base64"),
|
|
417
|
+
unbase64: (v) => {
|
|
418
|
+
const text = toStr(v).trim();
|
|
419
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text)) return MISSING;
|
|
420
|
+
try {
|
|
421
|
+
const bytes = Buffer.from(text, "base64");
|
|
422
|
+
// Buffer.from silently drops anything outside the alphabet, so the only
|
|
423
|
+
// way to notice the input was never base64 is to re-encode and compare.
|
|
424
|
+
// Garbage bytes are worse than an honest MISSING.
|
|
425
|
+
const reencoded = bytes.toString("base64").replace(/=+$/, "");
|
|
426
|
+
if (reencoded !== text.replace(/=+$/, "")) return MISSING;
|
|
427
|
+
return bytes.toString("utf8");
|
|
428
|
+
} catch {
|
|
429
|
+
return MISSING;
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
urlEncode: (v) => encodeURIComponent(toStr(v)),
|
|
433
|
+
urlDecode: (v) => {
|
|
434
|
+
try {
|
|
435
|
+
return decodeURIComponent(toStr(v));
|
|
436
|
+
} catch {
|
|
437
|
+
return MISSING;
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
uuid: () => randomUUID(),
|
|
441
|
+
|
|
442
|
+
// — introspection —
|
|
443
|
+
type: (v) => (v === MISSING ? "missing" : Array.isArray(v) ? "array" : v === null ? "null" : typeof v),
|
|
444
|
+
isArray: (v) => Array.isArray(v),
|
|
445
|
+
isString: (v) => typeof v === "string",
|
|
446
|
+
isNumber: (v) => typeof v === "number" && isFinite(v),
|
|
447
|
+
isObject: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
448
|
+
isBool: (v) => typeof v === "boolean",
|
|
207
449
|
};
|
|
208
450
|
|
|
209
451
|
/** Transforms that are no-ops on their argument order, used for plan hints. */
|
|
@@ -217,6 +459,20 @@ export const TRANSFORM_ARITY = {
|
|
|
217
459
|
sort: 2,
|
|
218
460
|
pick: 2,
|
|
219
461
|
default: 2,
|
|
462
|
+
|
|
463
|
+
add: 2, sub: 2, mul: 2, div: 2, mod: 2, pow: 2,
|
|
464
|
+
round: 2, clamp: 3, sumBy: 2,
|
|
465
|
+
eq: 2, ne: 2, gt: 2, gte: 2, lt: 2, lte: 2,
|
|
466
|
+
between: 3, includes: 2, startsWith: 2, endsWith: 2, matches: 2,
|
|
467
|
+
then: 4,
|
|
468
|
+
// Arity is how many arguments the *builder* asks for, which is one less than
|
|
469
|
+
// the argument count of the hand-written form for a variadic transform.
|
|
470
|
+
// coalesce takes any number of fallbacks; prompting for a fixed count would
|
|
471
|
+
// understate it, so the builder asks for one and a written pipeline may list
|
|
472
|
+
// as many as it likes.
|
|
473
|
+
coalesce: 2,
|
|
474
|
+
pluck: 2, chunk: 2, without: 2, where: 4,
|
|
475
|
+
truncate: 3, repeat: 2, mask: 2,
|
|
220
476
|
};
|
|
221
477
|
|
|
222
478
|
export function transformNames() {
|
|
@@ -260,6 +516,71 @@ export const TRANSFORM_DOCS = {
|
|
|
260
516
|
unjson: "parse a JSON string",
|
|
261
517
|
pick: "one field out of an object",
|
|
262
518
|
default: "value to use when the path is missing",
|
|
519
|
+
// The catalogue is grouped by purpose; `nxb pipe --transforms` and the
|
|
520
|
+
// builder's picker both read from here, so a transform and its description
|
|
521
|
+
// are always edited together.
|
|
522
|
+
add: "add a number",
|
|
523
|
+
sub: "subtract a number",
|
|
524
|
+
mul: "multiply by a number",
|
|
525
|
+
div: "divide by a number",
|
|
526
|
+
mod: "remainder after division",
|
|
527
|
+
pow: "raise to a power",
|
|
528
|
+
inc: "add one",
|
|
529
|
+
dec: "subtract one",
|
|
530
|
+
neg: "negate",
|
|
531
|
+
abs: "absolute value",
|
|
532
|
+
round: "round to N decimal places",
|
|
533
|
+
floor: "round down to an integer",
|
|
534
|
+
ceil: "round up to an integer",
|
|
535
|
+
clamp: "keep a number between a low and a high",
|
|
536
|
+
sum: "add up every number in an array",
|
|
537
|
+
avg: "average of every number in an array",
|
|
538
|
+
min: "smallest number in an array",
|
|
539
|
+
max: "largest number in an array",
|
|
540
|
+
sumBy: "add up one field across an array of objects",
|
|
541
|
+
eq: "is it equal to a value",
|
|
542
|
+
ne: "is it different from a value",
|
|
543
|
+
gt: "is it greater than a number",
|
|
544
|
+
gte: "is it at least a number",
|
|
545
|
+
lt: "is it less than a number",
|
|
546
|
+
lte: "is it at most a number",
|
|
547
|
+
between: "is a number within a low and a high",
|
|
548
|
+
includes: "does a string or array contain a value",
|
|
549
|
+
startsWith: "does a string start with text",
|
|
550
|
+
endsWith: "does a string end with text",
|
|
551
|
+
matches: "does a string match a regular expression",
|
|
552
|
+
isEmpty: "is the value empty — blank, [], or {}",
|
|
553
|
+
then: "choose between two values on a condition",
|
|
554
|
+
coalesce: "first value that is not empty",
|
|
555
|
+
pluck: "pull one field out of every object in an array",
|
|
556
|
+
unique: "remove duplicates from an array",
|
|
557
|
+
compact: "drop empty values from an array",
|
|
558
|
+
flatten: "flatten a nested array",
|
|
559
|
+
chunk: "split an array into fixed-size groups",
|
|
560
|
+
without: "remove every occurrence of a value",
|
|
561
|
+
where: "keep array items whose field passes a test",
|
|
562
|
+
title: "Title Case",
|
|
563
|
+
camel: "camelCase",
|
|
564
|
+
snake: "snake_case",
|
|
565
|
+
kebab: "kebab-case",
|
|
566
|
+
slugify: "url-safe slug",
|
|
567
|
+
truncate: "shorten to N characters",
|
|
568
|
+
repeat: "repeat a string N times",
|
|
569
|
+
mask: "hide the middle, e.g. jTI…Nj",
|
|
570
|
+
md5: "MD5 hex digest",
|
|
571
|
+
sha1: "SHA-1 hex digest",
|
|
572
|
+
sha256: "SHA-256 hex digest",
|
|
573
|
+
base64: "Base64-encode text",
|
|
574
|
+
unbase64: "decode Base64 text",
|
|
575
|
+
urlEncode: "percent-encode for a URL",
|
|
576
|
+
urlDecode: "decode percent-encoding",
|
|
577
|
+
uuid: "a random UUID",
|
|
578
|
+
type: "the value's type as text",
|
|
579
|
+
isArray: "is it an array",
|
|
580
|
+
isString: "is it text",
|
|
581
|
+
isNumber: "is it a number",
|
|
582
|
+
isObject: "is it an object",
|
|
583
|
+
isBool: "is it true or false",
|
|
263
584
|
};
|
|
264
585
|
|
|
265
586
|
/**
|
|
@@ -284,8 +605,32 @@ export const TRANSFORM_GROUPS = [
|
|
|
284
605
|
names: ["int", "float", "num", "str", "bool"] },
|
|
285
606
|
{ label: "json / unjson", doc: "serialise / parse",
|
|
286
607
|
names: ["json", "unjson"] },
|
|
287
|
-
{ label: "default(v)", doc: "supply a value when
|
|
288
|
-
names: ["default"] },
|
|
608
|
+
{ label: "default(v) / coalesce(a,b,…) / then(cond,a,b)", doc: "supply a value when one is missing, or choose on a condition",
|
|
609
|
+
names: ["default", "coalesce", "then"] },
|
|
610
|
+
{ label: "eq / ne / gt / gte / lt / lte / between(a,b)", doc: "compare — the result is true or false",
|
|
611
|
+
names: ["eq", "ne", "gt", "gte", "lt", "lte", "between"] },
|
|
612
|
+
{ label: "includes / startsWith / endsWith / matches(re) / isEmpty", doc: "test text and arrays",
|
|
613
|
+
names: ["includes", "startsWith", "endsWith", "matches", "isEmpty"] },
|
|
614
|
+
{ label: "pluck(field) / where(field,op,value)", doc: "select out of an array of records",
|
|
615
|
+
names: ["pluck", "where"] },
|
|
616
|
+
{ label: "unique / compact / flatten / chunk(n) / without(v)", doc: "reshape an array",
|
|
617
|
+
names: ["unique", "compact", "flatten", "chunk", "without"] },
|
|
618
|
+
{ label: "add(n) / sub(n) / mul(n) / div(n) / mod(n) / pow(n)", doc: "arithmetic on a number",
|
|
619
|
+
names: ["add", "sub", "mul", "div", "mod", "pow"] },
|
|
620
|
+
{ label: "inc / dec / neg / abs / round(p) / floor / ceil / clamp(lo,hi)", doc: "adjust a number",
|
|
621
|
+
names: ["inc", "dec", "neg", "abs", "round", "floor", "ceil", "clamp"] },
|
|
622
|
+
{ label: "sum / avg / min / max / sumBy(field)", doc: "collapse an array to one number",
|
|
623
|
+
names: ["sum", "avg", "min", "max", "sumBy"] },
|
|
624
|
+
{ label: "title / camel / snake / kebab / slugify", doc: "change a string's shape",
|
|
625
|
+
names: ["title", "camel", "snake", "kebab", "slugify"] },
|
|
626
|
+
{ label: "truncate(n) / repeat(n) / mask(n)", doc: "shorten, repeat or hide part of a string",
|
|
627
|
+
names: ["truncate", "repeat", "mask"] },
|
|
628
|
+
{ label: "md5 / sha1 / sha256 / base64 / unbase64", doc: "digest or encode — for fingerprints, not secrets",
|
|
629
|
+
names: ["md5", "sha1", "sha256", "base64", "unbase64"] },
|
|
630
|
+
{ label: "urlEncode / urlDecode / uuid", doc: "URL and identity helpers",
|
|
631
|
+
names: ["urlEncode", "urlDecode", "uuid"] },
|
|
632
|
+
{ label: "type / isArray / isString / isNumber / isObject / isBool", doc: "what is this value",
|
|
633
|
+
names: ["type", "isArray", "isString", "isNumber", "isObject", "isBool"] },
|
|
289
634
|
];
|
|
290
635
|
|
|
291
636
|
/**
|
|
@@ -350,14 +695,18 @@ export function validateTransformArg(raw) {
|
|
|
350
695
|
* @param {Array<{name: string, args: any[]}>} chain
|
|
351
696
|
* @returns {any|typeof MISSING}
|
|
352
697
|
*/
|
|
698
|
+
/** Transforms permitted to replace a missing value with a fallback. */
|
|
699
|
+
const FALLBACK_TRANSFORMS = new Set(["default", "coalesce"]);
|
|
700
|
+
|
|
353
701
|
export function applyTransforms(value, chain) {
|
|
354
702
|
let cur = value;
|
|
355
703
|
for (const step of chain) {
|
|
356
704
|
const fn = TRANSFORMS[step.name];
|
|
357
705
|
if (!fn) throw new Error(`Unknown transform "${step.name}". Run \`nxb pipe --transforms\`.`);
|
|
358
|
-
// A missing value stays missing unless the transform
|
|
359
|
-
//
|
|
360
|
-
|
|
706
|
+
// A missing value stays missing unless the transform is explicitly allowed to
|
|
707
|
+
// supply a fallback — `default(v)` and `coalesce(…)`. Anything else would
|
|
708
|
+
// be inventing a value the tool never produced.
|
|
709
|
+
if (cur === MISSING && !FALLBACK_TRANSFORMS.has(step.name)) return MISSING;
|
|
361
710
|
// A step may legitimately take no argument; treat a missing `args` as
|
|
362
711
|
// empty rather than failing to iterate undefined.
|
|
363
712
|
cur = fn(cur, ...(step.args || []));
|
package/src/wizard.js
CHANGED
|
@@ -42,10 +42,9 @@ import {
|
|
|
42
42
|
formatTransformCall,
|
|
43
43
|
validateTransformArg,
|
|
44
44
|
TRANSFORM_DOCS,
|
|
45
|
-
TRANSFORMS,
|
|
46
45
|
MISSING,
|
|
47
46
|
} from "./values.js";
|
|
48
|
-
import { parseValueExpr, resolveAccessors } from "./pipe.js";
|
|
47
|
+
import { parseValueExpr, resolveAccessors, scopeToLabel } from "./pipe.js";
|
|
49
48
|
import { isInteractive } from "./ui.js";
|
|
50
49
|
import { renderHeader } from "./ui.js";
|
|
51
50
|
|
|
@@ -165,7 +164,7 @@ async function promptLiteral(field, prop, P = defaultPrompts) {
|
|
|
165
164
|
* Ask how each input field of the next tool is supplied: wired from the
|
|
166
165
|
* previous output, or a literal.
|
|
167
166
|
*/
|
|
168
|
-
async function wireFields(manifest,
|
|
167
|
+
async function wireFields(manifest, sources, P = defaultPrompts) {
|
|
169
168
|
const props = manifest.input_schema?.properties || {};
|
|
170
169
|
const required = new Set(manifest.input_schema?.required || []);
|
|
171
170
|
const assignments = [];
|
|
@@ -179,25 +178,30 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
|
|
|
179
178
|
name: chalk.gray("· set by hand"),
|
|
180
179
|
value: { kind: "literal" },
|
|
181
180
|
},
|
|
182
|
-
...
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
181
|
+
...sources.flatMap((src) =>
|
|
182
|
+
src.fields.map((f) => {
|
|
183
|
+
const path = formatPath(f.path);
|
|
184
|
+
// A kept output is addressed by its label, the previous step by path.
|
|
185
|
+
const wire = src.label ? `${src.label}.${path}` : path;
|
|
186
|
+
const from = inferType(f.value);
|
|
187
|
+
const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
|
|
188
|
+
const flag =
|
|
189
|
+
compat.level === "cast" ? chalk.red(" · needs a transform")
|
|
190
|
+
: compat.level === "lossy" ? chalk.yellow(" · lossy")
|
|
191
|
+
: compat.level === "invalid" ? chalk.red(" · type mismatch")
|
|
192
|
+
: "";
|
|
193
|
+
const tag = src.label ? chalk.magenta(` ${src.label}·`) : "";
|
|
194
|
+
return {
|
|
195
|
+
name: `${chalk.cyan(wire.padEnd(24))} ${chalk.gray(from.padEnd(8))} ${chalk.dim(preview(f.value, 24))}${tag}${flag}`,
|
|
196
|
+
value: { kind: "wire", path: wire, label: src.label || null },
|
|
197
|
+
};
|
|
198
|
+
})
|
|
199
|
+
),
|
|
196
200
|
...(canSkip ? [{ name: chalk.gray("· leave empty"), value: { kind: "skip" } }] : []),
|
|
197
201
|
];
|
|
198
202
|
|
|
199
203
|
const choice = await P.select({
|
|
200
|
-
message: `${
|
|
204
|
+
message: `${sources[0]?.label || sources[0]?.slug || "?"} → ${chalk.cyan(field)} ${chalk.gray(typeLabel(targetTypes))}${required.has(field) ? chalk.red(" *") : ""}`,
|
|
201
205
|
choices,
|
|
202
206
|
pageSize: 14,
|
|
203
207
|
});
|
|
@@ -210,18 +214,18 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
|
|
|
210
214
|
continue;
|
|
211
215
|
}
|
|
212
216
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
217
|
+
const chosen = sources
|
|
218
|
+
.flatMap((src) => src.fields.map((f) => ({ src, f })))
|
|
219
|
+
.find(({ src, f }) => {
|
|
220
|
+
const path = formatPath(f.path);
|
|
221
|
+
return src.label ? `${src.label}.${path}` === choice.path : path === choice.path;
|
|
222
|
+
});
|
|
223
|
+
const from = chosen ? inferType(chosen.f.value) : "unknown";
|
|
224
|
+
// Any wired value can be reshaped, whatever its type — `len()` on a string
|
|
225
|
+
// is as legitimate as on an array, and the wizard has no business deciding
|
|
226
|
+
// which transforms are worth offering. The type flag on the choice above is
|
|
227
|
+
// the only nudge: it marks a clash without applying anything.
|
|
228
|
+
const chain = await promptTransformChain(field, P);
|
|
225
229
|
|
|
226
230
|
assignments.push({ kind: "wire", text: `${field}=$${choice.path}${chain}` });
|
|
227
231
|
}
|
|
@@ -232,69 +236,71 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
|
|
|
232
236
|
/**
|
|
233
237
|
* Ask which transforms to apply to a wired value.
|
|
234
238
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
239
|
+
* One question, always, with the whole catalogue in it — no type-based
|
|
240
|
+
* narrowing and no suggestion picked on the user's behalf. "How long is this
|
|
241
|
+
* string" is answered by `chars()` and no type mismatch hints at it, so gating
|
|
242
|
+
* the list on whether a cast happens to be needed would hide transforms people
|
|
243
|
+
* reach for. Declining is the first choice, so the common case costs one
|
|
244
|
+
* keystroke.
|
|
241
245
|
*
|
|
242
|
-
* The returned string is appended to `$path`, so it
|
|
243
|
-
* leading `|`.
|
|
246
|
+
* The returned string is appended to `$path`, so it carries its own `|`.
|
|
244
247
|
*/
|
|
245
|
-
async function promptTransformChain(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const more = await P.confirm({
|
|
255
|
-
message: chain ? `Add another transform to ${field}?` : `Apply a transform to ${field}?`,
|
|
256
|
-
default: false,
|
|
257
|
-
});
|
|
258
|
-
if (!more) break;
|
|
259
|
-
const built = await promptOneTransform(P);
|
|
260
|
-
if (!built) break;
|
|
261
|
-
chain += built;
|
|
262
|
-
}
|
|
263
|
-
return chain;
|
|
264
|
-
}
|
|
248
|
+
async function promptTransformChain(field, P) {
|
|
249
|
+
const picked = await P.search({
|
|
250
|
+
message: `${field}: transform${chalk.gray(" (optional)")}`,
|
|
251
|
+
source: (term) => transformChoices(term, { withNone: true }),
|
|
252
|
+
pageSize: 15,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// `null` is the "use it as it came" row.
|
|
256
|
+
if (!picked) return "";
|
|
265
257
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
let chain = await buildTransform(picked, P);
|
|
259
|
+
for (;;) {
|
|
260
|
+
const more = await P.confirm({
|
|
261
|
+
message: `Add another transform to ${field}?`,
|
|
262
|
+
default: false,
|
|
263
|
+
});
|
|
264
|
+
if (!more) break;
|
|
265
|
+
const next = await P.search({
|
|
266
|
+
message: `${field}: another transform`,
|
|
267
|
+
source: (term) => transformChoices(term, { withNone: true }),
|
|
268
|
+
pageSize: 15,
|
|
270
269
|
});
|
|
271
|
-
if (
|
|
270
|
+
if (!next) break;
|
|
271
|
+
chain += await buildTransform(next, P);
|
|
272
272
|
}
|
|
273
|
-
|
|
274
|
-
return "";
|
|
273
|
+
return chain;
|
|
275
274
|
}
|
|
276
275
|
|
|
277
276
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* Suggestions arrive as `int()`, `first()` — but also `pick(field)`, which
|
|
281
|
-
* names an argument it cannot fill in. Anything inside the parens is a
|
|
282
|
-
* placeholder rather than a value, so it prompts. `default()` is the awkward
|
|
283
|
-
* case: the suggestion shows no argument even though the transform needs one,
|
|
284
|
-
* so the arity is topped up from `TRANSFORM_ARITY` instead of emitting
|
|
285
|
-
* `|default()`, which `parseTransform` rejects.
|
|
277
|
+
* Rows for the searchable transform picker. `withNone` keeps "use as-is" pinned
|
|
278
|
+
* to the top so it survives a search term that matches no transform.
|
|
286
279
|
*/
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
280
|
+
function transformChoices(term, { withNone = false } = {}) {
|
|
281
|
+
const skip = withNone
|
|
282
|
+
? [{ value: null, name: chalk.gray("· none — use the value as it came") }]
|
|
283
|
+
: [];
|
|
284
|
+
const hits = term ? rankTransforms(term) : transformNames();
|
|
285
|
+
return [
|
|
286
|
+
...skip,
|
|
287
|
+
// No cap: this is a searchable list, so it paginates and scrolls. Truncating
|
|
288
|
+
// here silently hid every transform past the cut-off, which is unreachable
|
|
289
|
+
// by typing the name you can see.
|
|
290
|
+
...hits.map((n) => ({
|
|
291
|
+
value: n,
|
|
292
|
+
name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
|
|
293
|
+
})),
|
|
294
|
+
];
|
|
295
|
+
}
|
|
295
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Build one transform call, prompting for whatever arguments it needs.
|
|
299
|
+
* @returns {Promise<string>} e.g. `|len()` or `|get(0)`
|
|
300
|
+
*/
|
|
301
|
+
async function buildTransform(name, P) {
|
|
296
302
|
const args = [];
|
|
297
|
-
for (let i = 0; i <
|
|
303
|
+
for (let i = 0; i < transformArgCount(name); i++) {
|
|
298
304
|
args.push(
|
|
299
305
|
await P.input({
|
|
300
306
|
message: `${name}() argument ${i + 1}`,
|
|
@@ -305,34 +311,16 @@ async function buildSuggestedTransform(suggestion, P) {
|
|
|
305
311
|
return formatTransformCall(name, args);
|
|
306
312
|
}
|
|
307
313
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
source: async (term) => {
|
|
317
|
-
const hits = rankTransforms(term);
|
|
318
|
-
return (term ? hits : names).slice(0, 50).map((n) => ({
|
|
319
|
-
value: n,
|
|
320
|
-
name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
|
|
321
|
-
}));
|
|
322
|
-
},
|
|
323
|
-
pageSize: 15,
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
let args = [];
|
|
327
|
-
for (let i = 0; i < transformArgCount(name); i++) {
|
|
328
|
-
const answer = await P.input({
|
|
329
|
-
message: `${name}() argument ${i + 1}`,
|
|
330
|
-
validate: (v) => validateTransformArg(v),
|
|
331
|
-
});
|
|
332
|
-
args.push(answer);
|
|
314
|
+
/** Suggest a label for a step: short, derived from the slug, not already taken. */
|
|
315
|
+
function defaultLabel(slug, taken) {
|
|
316
|
+
const base = String(slug).replace(/[^a-zA-Z0-9_]/g, "").slice(0, 4) || "out";
|
|
317
|
+
const safe = /^[a-zA-Z_]/.test(base) ? base : `_${base}`;
|
|
318
|
+
if (!taken.has(safe)) return safe;
|
|
319
|
+
for (let n = 2; n < 100; n++) {
|
|
320
|
+
const next = `${safe}${n}`;
|
|
321
|
+
if (!taken.has(next)) return next;
|
|
333
322
|
}
|
|
334
|
-
|
|
335
|
-
return formatTransformCall(name, args);
|
|
323
|
+
return safe;
|
|
336
324
|
}
|
|
337
325
|
|
|
338
326
|
/** Rank transforms by name and description, so "array" finds `join`. */
|
|
@@ -349,6 +337,10 @@ function formatLiteral(value) {
|
|
|
349
337
|
return JSON.stringify(value);
|
|
350
338
|
}
|
|
351
339
|
if (typeof value === "string" && value === "") return '""';
|
|
340
|
+
// An object or array would otherwise land in the spec as "[object Object]",
|
|
341
|
+
// which is valid-looking and parses as neither JSON nor the intended value.
|
|
342
|
+
if (value !== null && typeof value === "object") return JSON.stringify(value);
|
|
343
|
+
if (value === null || value === undefined) return "null";
|
|
352
344
|
return String(value);
|
|
353
345
|
}
|
|
354
346
|
|
|
@@ -379,6 +371,11 @@ export async function buildWizard(opts = {}) {
|
|
|
379
371
|
|
|
380
372
|
const steps = [];
|
|
381
373
|
const outputs = [];
|
|
374
|
+
// Outputs the user chose to keep, readable from any later step. `scope` holds
|
|
375
|
+
// the raw output so it can be handed straight to `scopeToLabel`; `kept` keeps
|
|
376
|
+
// the metadata the picker needs to describe it.
|
|
377
|
+
const scope = new Map();
|
|
378
|
+
const kept = [];
|
|
382
379
|
|
|
383
380
|
for (;;) {
|
|
384
381
|
renderHeader(
|
|
@@ -396,11 +393,16 @@ export async function buildWizard(opts = {}) {
|
|
|
396
393
|
continue;
|
|
397
394
|
}
|
|
398
395
|
|
|
399
|
-
// Wire from the previous step
|
|
396
|
+
// Wire from the previous step and from anything kept earlier.
|
|
400
397
|
let assignments = [];
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
398
|
+
const sources = [
|
|
399
|
+
...outputs.slice().reverse().map((o) => ({ slug: o.slug, label: null, fields: o.fields })),
|
|
400
|
+
...kept.filter(
|
|
401
|
+
(k) => !outputs.some((o) => o.slug === k.slug && o.value === k.value)
|
|
402
|
+
),
|
|
403
|
+
];
|
|
404
|
+
if (sources.length) {
|
|
405
|
+
assignments = await wireFields(manifest, sources, P);
|
|
404
406
|
} else {
|
|
405
407
|
// First step: ask for each field by hand.
|
|
406
408
|
const props = manifest.input_schema?.properties || {};
|
|
@@ -411,13 +413,13 @@ export async function buildWizard(opts = {}) {
|
|
|
411
413
|
}
|
|
412
414
|
|
|
413
415
|
const spec = `${tool.slug} ${assignments.map((a) => a.text).join(" ")}`.trim();
|
|
414
|
-
steps.push({ slug: tool.slug, spec, assignments });
|
|
416
|
+
steps.push({ slug: tool.slug, spec, assignments, label: null });
|
|
415
417
|
|
|
416
418
|
console.log(chalk.gray(`\n Running ${spec}\n`));
|
|
417
419
|
const started = Date.now();
|
|
418
420
|
const result = await execute(
|
|
419
421
|
tool.slug,
|
|
420
|
-
literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined)
|
|
422
|
+
literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined, scope)
|
|
421
423
|
);
|
|
422
424
|
|
|
423
425
|
if (!result.ok) {
|
|
@@ -438,6 +440,37 @@ export async function buildWizard(opts = {}) {
|
|
|
438
440
|
|
|
439
441
|
outputs.push({ slug: tool.slug, value: result.result, fields });
|
|
440
442
|
|
|
443
|
+
// Keeping an output lets a later step read it again, which is the only way to
|
|
444
|
+
// carry two values forward without re-running the step that produced them.
|
|
445
|
+
if (fields.length) {
|
|
446
|
+
const keep = await P.confirm({
|
|
447
|
+
message: `Keep ${tool.slug}'s output for later steps?`,
|
|
448
|
+
default: false,
|
|
449
|
+
});
|
|
450
|
+
if (keep) {
|
|
451
|
+
const label = await P.input({
|
|
452
|
+
message: " Call it",
|
|
453
|
+
default: defaultLabel(tool.slug, scope),
|
|
454
|
+
});
|
|
455
|
+
const name = String(label).trim();
|
|
456
|
+
if (name) {
|
|
457
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
458
|
+
console.log(chalk.yellow(` "${name}" is not a usable label — ignored.`));
|
|
459
|
+
} else if (scope.has(name)) {
|
|
460
|
+
console.log(chalk.yellow(` "${name}" is already used — ignored.`));
|
|
461
|
+
} else {
|
|
462
|
+
const last = steps[steps.length - 1];
|
|
463
|
+
if (last) {
|
|
464
|
+
last.label = name;
|
|
465
|
+
last.spec = `${last.spec} as=${name}`;
|
|
466
|
+
}
|
|
467
|
+
scope.set(name, result.result);
|
|
468
|
+
kept.push({ slug: tool.slug, label: name, fields, value: result.result });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
441
474
|
if (fields.length === 0) break;
|
|
442
475
|
const more = await P.confirm({ message: "Send this output into another tool?", default: steps.length < 2 });
|
|
443
476
|
if (!more) break;
|
|
@@ -479,7 +512,7 @@ export async function buildWizard(opts = {}) {
|
|
|
479
512
|
* A reference that resolves to nothing is omitted rather than passed as
|
|
480
513
|
* `undefined`, so schema validation reports the field by name.
|
|
481
514
|
*/
|
|
482
|
-
function literalInput(assignments, upstream) {
|
|
515
|
+
function literalInput(assignments, upstream, scope) {
|
|
483
516
|
const input = {};
|
|
484
517
|
for (const a of assignments) {
|
|
485
518
|
const eq = a.text.indexOf("=");
|
|
@@ -488,7 +521,6 @@ function literalInput(assignments, upstream) {
|
|
|
488
521
|
const raw = a.text.slice(eq + 1);
|
|
489
522
|
|
|
490
523
|
if (raw.startsWith("$")) {
|
|
491
|
-
if (upstream === undefined) continue;
|
|
492
524
|
let expr;
|
|
493
525
|
try {
|
|
494
526
|
expr = parseValueExpr(raw);
|
|
@@ -496,9 +528,19 @@ function literalInput(assignments, upstream) {
|
|
|
496
528
|
continue;
|
|
497
529
|
}
|
|
498
530
|
if (expr.kind !== "connection" || !expr.accessors) continue;
|
|
531
|
+
// A leading segment naming a kept output wins over the previous step,
|
|
532
|
+
// matching how the engine resolves it.
|
|
533
|
+
let source = upstream;
|
|
534
|
+
let accessors = expr.accessors;
|
|
535
|
+
const labelled = scopeToLabel(expr.accessors, scope);
|
|
536
|
+
if (labelled) {
|
|
537
|
+
source = labelled.upstream;
|
|
538
|
+
accessors = labelled.accessors;
|
|
539
|
+
}
|
|
540
|
+
if (source === undefined) continue;
|
|
499
541
|
let value;
|
|
500
542
|
try {
|
|
501
|
-
value = resolveAccessors(
|
|
543
|
+
value = resolveAccessors(source, accessors);
|
|
502
544
|
} catch {
|
|
503
545
|
continue;
|
|
504
546
|
}
|
|
@@ -516,4 +558,4 @@ function literalInput(assignments, upstream) {
|
|
|
516
558
|
return input;
|
|
517
559
|
}
|
|
518
560
|
|
|
519
|
-
export { transformNames, parsePath, rankTransforms };
|
|
561
|
+
export { transformNames, parsePath, rankTransforms, defaultLabel };
|