@nexusbloom/cli 0.9.3 → 0.9.5
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 +27 -0
- package/package.json +1 -1
- package/src/abbr.js +1 -7
- package/src/commands.js +8 -12
- package/src/index.js +5 -20
- package/src/list.js +3 -6
- package/src/run.js +4 -4
- package/src/tools.js +8 -22
- package/src/values.js +121 -0
- package/src/wizard.js +135 -10
package/README.md
CHANGED
|
@@ -209,6 +209,33 @@ $ nxb pipe --build
|
|
|
209
209
|
frequency-analyzer.total_words integer 2
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
+
When you wire a **collection** (an array or object) into a field, the builder
|
|
213
|
+
offers the whole transform catalogue rather than only flagging a type clash —
|
|
214
|
+
`len()` on an array, `join(",")`, `keys()`, `pick(field)` and the rest, listed
|
|
215
|
+
with what each one does. You can chain them, and they are applied in the order
|
|
216
|
+
you chose:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
frequency-analyzer → count integer
|
|
220
|
+
Apply a transform to count? yes
|
|
221
|
+
Transform › len
|
|
222
|
+
len length of a string, array or object
|
|
223
|
+
frequency-analyzer count=$character_frequency|len()
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
This produces the same `$path|transform()` syntax you can write by hand. Two
|
|
227
|
+
constraints come from the parser, and the builder refuses bad input up front
|
|
228
|
+
rather than emitting a spec that will not run:
|
|
229
|
+
|
|
230
|
+
- **No spaces anywhere in a connection.** `$items|join(", ")` is rejected — use
|
|
231
|
+
`join(",")`. The parser cannot distinguish a space inside a quoted argument
|
|
232
|
+
from a separator, so it refuses all of them.
|
|
233
|
+
- **`|` cannot appear in an argument**, because it separates pipeline steps.
|
|
234
|
+
|
|
235
|
+
A scalar type clash keeps its existing one-tap suggestion (`apply int()?`),
|
|
236
|
+
since that is a fix rather than a choice. Run `nxb pipe --transforms` for the
|
|
237
|
+
full catalogue with examples.
|
|
238
|
+
|
|
212
239
|
### Saved pipelines
|
|
213
240
|
|
|
214
241
|
```bash
|
package/package.json
CHANGED
package/src/abbr.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* abbr.js — `nxb abbr` command.
|
|
3
3
|
*
|
|
4
|
-
* Paginated abbreviation view.
|
|
5
|
-
*
|
|
6
|
-
* nxb abbr — interactive pagination (20/page)
|
|
7
|
-
* nxb abbr ip — filter by query (matches slug, abbr, or name)
|
|
8
|
-
* nxb abbr -s ip — same, explicit flag
|
|
9
|
-
* nxb abbr --json — raw JSON
|
|
10
|
-
* nxb abbr --no-interactive— single dump
|
|
4
|
+
* Paginated abbreviation view. Flags are declared in index.js.
|
|
11
5
|
*/
|
|
12
6
|
|
|
13
7
|
import chalk from "chalk";
|
package/src/commands.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
runPipeline,
|
|
24
24
|
} from "./pipe.js";
|
|
25
25
|
import { inferType, schemaTypes, typeLabel } from "./types.js";
|
|
26
|
-
import { MISSING, getPath, preview, transformNames, parsePath, formatPath } from "./values.js";
|
|
26
|
+
import { MISSING, getPath, preview, transformNames, parsePath, formatPath, TRANSFORM_GROUPS } from "./values.js";
|
|
27
27
|
import { buildWizard } from "./wizard.js";
|
|
28
28
|
|
|
29
29
|
/** Shared manifest/tool lookup used by the plan, the runner and the wizard. */
|
|
@@ -150,17 +150,13 @@ export function listTransforms(jsonMode) {
|
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
152
|
console.log(chalk.bold("\n Connection transforms\n"));
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
["json / unjson", "serialise / parse"],
|
|
161
|
-
["default(v)", "supply a value when the path is missing"],
|
|
162
|
-
];
|
|
163
|
-
for (const [a, b] of rows) console.log(` ${chalk.cyan(a.padEnd(42))} ${chalk.dim(b)}`);
|
|
153
|
+
// Rendered from the shared catalogue so the list and the wizard's picker can
|
|
154
|
+
// never describe the same transform differently. The column is sized to the
|
|
155
|
+
// longest heading, which is wider than a fixed pad and left the docs ragged.
|
|
156
|
+
const width = Math.max(...TRANSFORM_GROUPS.map((g) => g.label.length)) + 2;
|
|
157
|
+
for (const group of TRANSFORM_GROUPS) {
|
|
158
|
+
console.log(` ${chalk.cyan(group.label.padEnd(width))} ${chalk.dim(group.doc)}`);
|
|
159
|
+
}
|
|
164
160
|
console.log(chalk.dim("\n Use: key=$path|transform (no spaces around |)\n"));
|
|
165
161
|
return { ok: true };
|
|
166
162
|
}
|
package/src/index.js
CHANGED
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* NexusBloom CLI (nxb)
|
|
4
|
+
* NexusBloom CLI (nxb) — command wiring.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* nxb run <slug> [args...] — Execute a tool (remote API)
|
|
11
|
-
* nxb run <slug> --input '{}' — Execute with inline JSON input
|
|
12
|
-
* nxb run <slug> --file input.json — Execute with file input
|
|
13
|
-
* nxb run <slug> --local — Execute coreLogic locally (cached)
|
|
14
|
-
* nxb run — Interactive: select tool → pick inputs → auto-build command
|
|
15
|
-
* nxb run -s <query> — Filter the tool picker (non-TTY: print the list)
|
|
16
|
-
* nxb info <slug> — Show tool schema
|
|
17
|
-
* nxb abbr [-s <query>] — Show tool abbreviations (paginated)
|
|
18
|
-
* nxb transform <slug> — Pipe stdin through a tool
|
|
19
|
-
* nxb config set <key> [value] — Set config (interactive if value omitted)
|
|
20
|
-
* nxb config get [key] — Get config (secrets redacted)
|
|
21
|
-
* nxb cache clear — Clear local tool cache
|
|
22
|
-
* nxb cache list — List cached tools
|
|
23
|
-
* nxb cache path — Print where config/cache live
|
|
24
|
-
* nxb json --template <slug> — Create JSON input for tools
|
|
6
|
+
* Commands are declared with commander below, and `--help` is generated from
|
|
7
|
+
* those declarations. A hand-maintained usage list in this header was always
|
|
8
|
+
* going to drift from the code, so there isn't one; README.md carries the
|
|
9
|
+
* task-oriented examples.
|
|
25
10
|
*/
|
|
26
11
|
|
|
27
12
|
import { program } from "commander";
|
package/src/list.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* list.js — `nxb list` command.
|
|
3
3
|
*
|
|
4
|
-
* Paginated tool listing. Long lists are always paired with a search option
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* nxb list -s ssl — filter + paginate
|
|
8
|
-
* nxb list --json — raw JSON
|
|
9
|
-
* nxb list --no-interactive — single dump
|
|
4
|
+
* Paginated tool listing. Long lists are always paired with a search option;
|
|
5
|
+
* `--json` and the other flags are declared in index.js, which is also where
|
|
6
|
+
* `--help` reads them from.
|
|
10
7
|
*/
|
|
11
8
|
|
|
12
9
|
import chalk from "chalk";
|
package/src/run.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* run.js — `nxb run` command.
|
|
3
3
|
*
|
|
4
4
|
* Two modes:
|
|
5
|
-
* 1. Direct:
|
|
6
|
-
* 2. Interactive:
|
|
5
|
+
* 1. Direct: nxb run <slug> [key=value ...] [--input json] [--file path] [--local]
|
|
6
|
+
* 2. Interactive: nxb run → select tool → pick inputs → auto-build & execute
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* The interactive path is only entered when no slug and no input were supplied,
|
|
9
|
+
* otherwise a scripted call would block on a prompt it never sees.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import fs from "fs";
|
package/src/tools.js
CHANGED
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tools.js —
|
|
2
|
+
* tools.js — Tool catalogue resolution and config access.
|
|
3
3
|
*
|
|
4
4
|
* Resolution order for the *tool list*:
|
|
5
|
-
* 1. Cached snapshot
|
|
6
|
-
* successful fetch so
|
|
7
|
-
* 2. Live API
|
|
5
|
+
* 1. Cached snapshot (`$XDG_CACHE_HOME/nexusbloom/tools-list.json`) — written
|
|
6
|
+
* on every successful fetch, so `list` / `abbr` stay fast and work offline
|
|
7
|
+
* 2. Live API (`/api/tools`)
|
|
8
8
|
* 3. Supabase fallback (`/rest/v1/tools` + `/rest/v1/community_tools`)
|
|
9
|
-
* 4. Local filesystem scan
|
|
9
|
+
* 4. Local filesystem scan — see `LOCAL_TOOL_DIRS` for the search roots
|
|
10
10
|
*
|
|
11
|
-
* Resolution order for a *single tool's source* (for `--local` execution)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Exports:
|
|
15
|
-
* loadConfig / saveConfig / configGet / configSet / redactedConfig
|
|
16
|
-
* fetchTools() — resolves list (cache-first on network failure)
|
|
17
|
-
* refreshTools() — force a fresh fetch + persist to cache
|
|
18
|
-
* fetchToolSource(slug) — { manifest, coreLogicSource }
|
|
19
|
-
* filterTools / rankTools — shared search used by list / search / abbr / run
|
|
20
|
-
* isValidSlug / normalizeSlug
|
|
21
|
-
* scanLocalTools()
|
|
22
|
-
* findLocalTool(slug)
|
|
23
|
-
* generateAbbreviations(slugs)
|
|
24
|
-
* resolveToolSlug(slug, tools?)
|
|
25
|
-
* getSupabaseKey / getSupabaseUrl / supabaseHeaders
|
|
26
|
-
* API_BASE
|
|
11
|
+
* Resolution order for a *single tool's source* (for `--local` execution) is
|
|
12
|
+
* the mirror image: local filesystem, then cache, then API/Supabase. Local wins
|
|
13
|
+
* because a developer running `--local` is iterating on that file right now.
|
|
27
14
|
*/
|
|
28
15
|
|
|
29
16
|
import fs from "fs";
|
|
@@ -446,7 +433,6 @@ export async function fetchToolSource(rawSlug) {
|
|
|
446
433
|
const source = fs.readFileSync(localPath, "utf-8");
|
|
447
434
|
const manifest = extractManifest(source, { trusted: true });
|
|
448
435
|
if (manifest) {
|
|
449
|
-
// Refresh cache from local source
|
|
450
436
|
fs.writeFileSync(cacheFile, JSON.stringify({ manifest, coreLogicSource: source, cachedAt: new Date().toISOString() }), "utf-8");
|
|
451
437
|
return { manifest, coreLogicSource: source, source: "local" };
|
|
452
438
|
}
|
package/src/values.js
CHANGED
|
@@ -223,6 +223,127 @@ export function transformNames() {
|
|
|
223
223
|
return Object.keys(TRANSFORMS).sort();
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* One-line description per transform, for the wizard's picker.
|
|
228
|
+
*
|
|
229
|
+
* These live beside the implementations rather than in the UI so that adding a
|
|
230
|
+
* transform and describing it are the same edit — a picker entry with no
|
|
231
|
+
* description reads as a mystery, and a description with no entry is worse.
|
|
232
|
+
*/
|
|
233
|
+
export const TRANSFORM_DOCS = {
|
|
234
|
+
len: "length of a string, array or object",
|
|
235
|
+
count: "same as len — size of an array or string",
|
|
236
|
+
words: "number of whitespace-separated words",
|
|
237
|
+
lines: "number of lines",
|
|
238
|
+
chars: "number of characters",
|
|
239
|
+
first: "first element of an array",
|
|
240
|
+
last: "last element of an array",
|
|
241
|
+
get: "element at an index",
|
|
242
|
+
slice: "a range of an array or string",
|
|
243
|
+
join: "array to a string, with a separator",
|
|
244
|
+
keys: "an object's keys, as an array",
|
|
245
|
+
values: "an object's values, as an array",
|
|
246
|
+
reverse: "reverse an array",
|
|
247
|
+
sort: "sort an array, optionally by field",
|
|
248
|
+
upper: "UPPERCASE",
|
|
249
|
+
lower: "lowercase",
|
|
250
|
+
trim: "strip surrounding whitespace",
|
|
251
|
+
replace: "replace every occurrence of a substring",
|
|
252
|
+
pad: "pad the start to a width",
|
|
253
|
+
split: "string to an array on a separator",
|
|
254
|
+
str: "to string",
|
|
255
|
+
int: "to integer",
|
|
256
|
+
float: "to number",
|
|
257
|
+
num: "to number",
|
|
258
|
+
bool: "to true/false",
|
|
259
|
+
json: "serialise to a JSON string",
|
|
260
|
+
unjson: "parse a JSON string",
|
|
261
|
+
pick: "one field out of an object",
|
|
262
|
+
default: "value to use when the path is missing",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The catalogue grouped by purpose, for `nxb pipe --transforms`.
|
|
267
|
+
*
|
|
268
|
+
* `label` is the heading as a user reads it — arg hints and all — so it is
|
|
269
|
+
* written out rather than derived from `TRANSFORM_ARITY` (which would render
|
|
270
|
+
* `get()` where the heading wants `get(i)`).
|
|
271
|
+
*/
|
|
272
|
+
export const TRANSFORM_GROUPS = [
|
|
273
|
+
{ label: "len / count / words / lines / chars", doc: "size of a string, array or object",
|
|
274
|
+
names: ["len", "count", "words", "lines", "chars"] },
|
|
275
|
+
{ label: "first / last / get(i) / slice(a,b)", doc: "reduce an array to a single value",
|
|
276
|
+
names: ["first", "last", "get", "slice"] },
|
|
277
|
+
{ label: "join(sep) / split(sep) / reverse / sort(key)", doc: "reshape collections",
|
|
278
|
+
names: ["join", "split", "reverse", "sort"] },
|
|
279
|
+
{ label: "keys / values / pick(field)", doc: "reach into an object",
|
|
280
|
+
names: ["keys", "values", "pick"] },
|
|
281
|
+
{ label: "upper / lower / trim / replace(a,b) / pad(n)", doc: "edit text",
|
|
282
|
+
names: ["upper", "lower", "trim", "replace", "pad"] },
|
|
283
|
+
{ label: "int() / float() / num / str / bool()", doc: "change type — required for casts",
|
|
284
|
+
names: ["int", "float", "num", "str", "bool"] },
|
|
285
|
+
{ label: "json / unjson", doc: "serialise / parse",
|
|
286
|
+
names: ["json", "unjson"] },
|
|
287
|
+
{ label: "default(v)", doc: "supply a value when the path is missing",
|
|
288
|
+
names: ["default"] },
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* How many arguments a transform takes, excluding the value itself.
|
|
293
|
+
* @returns {number} 0 for transforms callable with no argument
|
|
294
|
+
*/
|
|
295
|
+
export function transformArgCount(name) {
|
|
296
|
+
const arity = TRANSFORM_ARITY[name];
|
|
297
|
+
return arity === undefined ? 0 : Math.max(0, arity - 1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Render one literal as it must appear inside a pipeline spec.
|
|
302
|
+
*
|
|
303
|
+
* Bare words are left alone (`hello`, `2024-01-01`); anything that could be
|
|
304
|
+
* misread — a space, a comma, a quote — is JSON-quoted so `parseValueExpr`'s
|
|
305
|
+
* quote-aware splitter sees it as one argument.
|
|
306
|
+
*/
|
|
307
|
+
export function formatArg(value) {
|
|
308
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
309
|
+
if (value === null || value === undefined) return "";
|
|
310
|
+
const s = String(value);
|
|
311
|
+
if (s === "") return '""';
|
|
312
|
+
if (/^[\w.@/+-]+$/.test(s)) return s;
|
|
313
|
+
return JSON.stringify(s);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Render a transform call: `len()`, `get(0)`, `join(",")`.
|
|
318
|
+
*
|
|
319
|
+
* Always emits parens, even with no arguments, so the result reads as a call
|
|
320
|
+
* and round-trips through `parseTransform` unchanged.
|
|
321
|
+
*/
|
|
322
|
+
export function formatTransformCall(name, args = []) {
|
|
323
|
+
return `|${name}(${(args || []).map(formatArg).join(",")})`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Reject an argument the parser cannot accept.
|
|
328
|
+
*
|
|
329
|
+
* `parseValueExpr` rejects whitespace anywhere in an expression — it cannot
|
|
330
|
+
* tell a separator from a space inside a quoted argument, so `$x|join(", ")` is
|
|
331
|
+
* refused. Better to say so at the prompt than to emit a spec that fails to
|
|
332
|
+
* parse after the pipeline is already built.
|
|
333
|
+
*
|
|
334
|
+
* @returns {string|null} an error message, or null when the arg is usable
|
|
335
|
+
*/
|
|
336
|
+
export function validateTransformArg(raw) {
|
|
337
|
+
const s = String(raw ?? "");
|
|
338
|
+
if (/\s/.test(s)) {
|
|
339
|
+
return "arguments cannot contain spaces — the pipeline syntax has none. Try a symbol like \"-\" or \"%20\".";
|
|
340
|
+
}
|
|
341
|
+
if (s.includes("|")) {
|
|
342
|
+
return "arguments cannot contain \"|\" — it separates steps in a pipeline.";
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
226
347
|
/**
|
|
227
348
|
* Apply a chain of transforms to a value.
|
|
228
349
|
* @param {any} value
|
package/src/wizard.js
CHANGED
|
@@ -33,7 +33,18 @@ import { execInSandbox } from "./sandbox.js";
|
|
|
33
33
|
import { configGet } from "./tools.js";
|
|
34
34
|
import { runRemote } from "./run.js";
|
|
35
35
|
import { inferType, schemaTypes, typeLabel, compatibility } from "./types.js";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
formatPath,
|
|
38
|
+
parsePath,
|
|
39
|
+
preview,
|
|
40
|
+
transformNames,
|
|
41
|
+
transformArgCount,
|
|
42
|
+
formatTransformCall,
|
|
43
|
+
validateTransformArg,
|
|
44
|
+
TRANSFORM_DOCS,
|
|
45
|
+
TRANSFORMS,
|
|
46
|
+
MISSING,
|
|
47
|
+
} from "./values.js";
|
|
37
48
|
import { parseValueExpr, resolveAccessors } from "./pipe.js";
|
|
38
49
|
import { isInteractive } from "./ui.js";
|
|
39
50
|
import { renderHeader } from "./ui.js";
|
|
@@ -203,20 +214,134 @@ async function wireFields(manifest, prevSlug, prevFields, P = defaultPrompts) {
|
|
|
203
214
|
const source = prevFields.find((f) => formatPath(f.path) === choice.path);
|
|
204
215
|
const from = source ? inferType(source.value) : "unknown";
|
|
205
216
|
const compat = targetTypes.length ? compatibility(from, targetTypes) : { level: "ok" };
|
|
206
|
-
|
|
217
|
+
// Offer transforms where one can do something: a type clash makes one
|
|
218
|
+
// necessary, and a collection is what `len`/`first`/`join`/`keys` exist for.
|
|
219
|
+
// Asking after every wire would be a pointless extra question on the common
|
|
220
|
+
// scalar-to-scalar case, where no transform applies anyway.
|
|
221
|
+
const worthTransforming = compat.level === "cast" || from === "array" || from === "object";
|
|
222
|
+
const chain = worthTransforming
|
|
223
|
+
? await promptTransformChain({ field, targetTypes, compat, from, P })
|
|
224
|
+
: "";
|
|
225
|
+
|
|
226
|
+
assignments.push({ kind: "wire", text: `${field}=$${choice.path}${chain}` });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return assignments;
|
|
230
|
+
}
|
|
207
231
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Ask which transforms to apply to a wired value.
|
|
234
|
+
*
|
|
235
|
+
* Two things are happening here, in order. First a *required* cast — if the
|
|
236
|
+
* source type cannot widen into the field's type, the suggestion is offered
|
|
237
|
+
* directly, because without it the pipeline cannot run. Then the whole
|
|
238
|
+
* catalogue: any field can be reshaped, not just the ones with a type clash,
|
|
239
|
+
* since "how many items came back" is a question `len()` answers and no type
|
|
240
|
+
* mismatch hints at.
|
|
241
|
+
*
|
|
242
|
+
* The returned string is appended to `$path`, so it already carries its own
|
|
243
|
+
* leading `|`.
|
|
244
|
+
*/
|
|
245
|
+
async function promptTransformChain({ field, targetTypes, compat, from, P }) {
|
|
246
|
+
// A collection feeding a scalar field is reported as a cast, but `first()` is
|
|
247
|
+
// a poor guess there — the usual intent is to measure or reshape it (`len`,
|
|
248
|
+
// `join`, `pick`). So collections go straight to the catalogue, which offers
|
|
249
|
+
// `first` anyway. A genuine scalar clash keeps its original single question:
|
|
250
|
+
// one tap to fix a type error beats making everyone walk a picker for it.
|
|
251
|
+
if (from === "array" || from === "object") {
|
|
252
|
+
let chain = "";
|
|
253
|
+
for (;;) {
|
|
254
|
+
const more = await P.confirm({
|
|
255
|
+
message: chain ? `Add another transform to ${field}?` : `Apply a transform to ${field}?`,
|
|
256
|
+
default: false,
|
|
212
257
|
});
|
|
213
|
-
if (
|
|
258
|
+
if (!more) break;
|
|
259
|
+
const built = await promptOneTransform(P);
|
|
260
|
+
if (!built) break;
|
|
261
|
+
chain += built;
|
|
214
262
|
}
|
|
263
|
+
return chain;
|
|
264
|
+
}
|
|
215
265
|
|
|
216
|
-
|
|
266
|
+
if (compat.level === "cast" && compat.suggestion) {
|
|
267
|
+
const use = await P.confirm({
|
|
268
|
+
message: `${field} expects ${typeLabel(targetTypes)} — apply ${chalk.cyan(compat.suggestion)}?`,
|
|
269
|
+
default: true,
|
|
270
|
+
});
|
|
271
|
+
if (use) return await buildSuggestedTransform(compat.suggestion, P);
|
|
217
272
|
}
|
|
218
273
|
|
|
219
|
-
return
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Turn a `compatibility()` suggestion into a real transform call.
|
|
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.
|
|
286
|
+
*/
|
|
287
|
+
async function buildSuggestedTransform(suggestion, P) {
|
|
288
|
+
const m = String(suggestion).match(/^([a-z_][a-z0-9_]*)(?:\((.*)\))?$/i);
|
|
289
|
+
if (!m) return "";
|
|
290
|
+
const name = m[1];
|
|
291
|
+
if (!Object.hasOwn(TRANSFORMS, name)) return "";
|
|
292
|
+
const needsArg = Boolean((m[2] || "").trim()) || transformArgCount(name) > 0;
|
|
293
|
+
|
|
294
|
+
if (!needsArg) return formatTransformCall(name);
|
|
295
|
+
|
|
296
|
+
const args = [];
|
|
297
|
+
for (let i = 0; i < Math.max(1, transformArgCount(name)); i++) {
|
|
298
|
+
args.push(
|
|
299
|
+
await P.input({
|
|
300
|
+
message: `${name}() argument ${i + 1}`,
|
|
301
|
+
validate: (v) => validateTransformArg(v),
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return formatTransformCall(name, args);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Pick one transform from the catalogue and ask for whatever it needs.
|
|
310
|
+
* @returns {Promise<string>} e.g. `|len()` or `|get(0)`
|
|
311
|
+
*/
|
|
312
|
+
async function promptOneTransform(P) {
|
|
313
|
+
const names = transformNames();
|
|
314
|
+
const name = await P.select({
|
|
315
|
+
message: "Transform",
|
|
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);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return formatTransformCall(name, args);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Rank transforms by name and description, so "array" finds `join`. */
|
|
339
|
+
function rankTransforms(term) {
|
|
340
|
+
if (!term) return [];
|
|
341
|
+
const t = String(term).toLowerCase();
|
|
342
|
+
return transformNames().filter(
|
|
343
|
+
(n) => n.includes(t) || (TRANSFORM_DOCS[n] || "").toLowerCase().includes(t)
|
|
344
|
+
);
|
|
220
345
|
}
|
|
221
346
|
|
|
222
347
|
function formatLiteral(value) {
|
|
@@ -391,4 +516,4 @@ function literalInput(assignments, upstream) {
|
|
|
391
516
|
return input;
|
|
392
517
|
}
|
|
393
518
|
|
|
394
|
-
export { transformNames, parsePath };
|
|
519
|
+
export { transformNames, parsePath, rankTransforms };
|