@nexusbloom/cli 0.9.0 → 0.9.2
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 +1 -2
- package/package.json +2 -3
- package/src/pipe.js +1 -1
- package/src/wizard.js +45 -5
- package/TESTING.md +0 -125
package/README.md
CHANGED
|
@@ -332,8 +332,7 @@ npm run test:watch # re-run on change
|
|
|
332
332
|
```
|
|
333
333
|
|
|
334
334
|
The suite runs fully offline and is isolated from your real config, API key and
|
|
335
|
-
network — both properties are enforced by guard tests, not assumed.
|
|
336
|
-
[TESTING.md](./TESTING.md) for the harness and its gotchas.
|
|
335
|
+
network — both properties are enforced by guard tests, not assumed.
|
|
337
336
|
|
|
338
337
|
## Security Notes
|
|
339
338
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexusbloom/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "NexusBloom CLI — run tools and workflows from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src/",
|
|
11
11
|
"package.json",
|
|
12
|
-
"README.md"
|
|
13
|
-
"TESTING.md"
|
|
12
|
+
"README.md"
|
|
14
13
|
],
|
|
15
14
|
"publishConfig": {
|
|
16
15
|
"access": "public"
|
package/src/pipe.js
CHANGED
|
@@ -135,7 +135,7 @@ function tokenize(spec) {
|
|
|
135
135
|
* @returns {{kind:"stdin"|"connection"|"literal", headPath?: string,
|
|
136
136
|
* accessors?: Array, chain?: Array, literal?: any}}
|
|
137
137
|
*/
|
|
138
|
-
function parseValueExpr(expr) {
|
|
138
|
+
export function parseValueExpr(expr) {
|
|
139
139
|
const raw = String(expr ?? "");
|
|
140
140
|
|
|
141
141
|
if (raw === "@in" || raw === "@stdin") return { kind: "stdin" };
|
package/src/wizard.js
CHANGED
|
@@ -33,7 +33,8 @@ 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 { formatPath, parsePath, preview, transformNames } from "./values.js";
|
|
36
|
+
import { formatPath, parsePath, preview, transformNames, MISSING } from "./values.js";
|
|
37
|
+
import { parseValueExpr, resolveAccessors } from "./pipe.js";
|
|
37
38
|
import { isInteractive } from "./ui.js";
|
|
38
39
|
import { renderHeader } from "./ui.js";
|
|
39
40
|
|
|
@@ -283,7 +284,10 @@ export async function buildWizard(opts = {}) {
|
|
|
283
284
|
|
|
284
285
|
console.log(chalk.gray(`\n Running ${spec}\n`));
|
|
285
286
|
const started = Date.now();
|
|
286
|
-
const result = await execute(
|
|
287
|
+
const result = await execute(
|
|
288
|
+
tool.slug,
|
|
289
|
+
literalInput(assignments, outputs.length ? outputs[outputs.length - 1].value : undefined)
|
|
290
|
+
);
|
|
287
291
|
|
|
288
292
|
if (!result.ok) {
|
|
289
293
|
console.log(chalk.red(` ✗ ${result.error}`));
|
|
@@ -328,15 +332,51 @@ export async function buildWizard(opts = {}) {
|
|
|
328
332
|
return { ok: true, spec: pipeline };
|
|
329
333
|
}
|
|
330
334
|
|
|
331
|
-
/**
|
|
332
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Build the input object for a step's trial run.
|
|
337
|
+
*
|
|
338
|
+
* Wired fields (`$path`, `$path|transform`) are resolved against the previous
|
|
339
|
+
* step's real output, using the *same* parser and resolver the pipeline engine
|
|
340
|
+
* uses at run time. That matters twice over:
|
|
341
|
+
*
|
|
342
|
+
* - correctness: a step whose required input is wired previously ran with that
|
|
343
|
+
* input simply absent, so `password-generator → base64-tool` failed with
|
|
344
|
+
* "text must be a non-empty string" no matter what the user chose;
|
|
345
|
+
* - consistency: the trial the wizard shows is the run the replay command
|
|
346
|
+
* will later reproduce, because both go through `parseValueExpr` +
|
|
347
|
+
* `resolveAccessors` rather than a second, drifting implementation.
|
|
348
|
+
*
|
|
349
|
+
* A reference that resolves to nothing is omitted rather than passed as
|
|
350
|
+
* `undefined`, so schema validation reports the field by name.
|
|
351
|
+
*/
|
|
352
|
+
function literalInput(assignments, upstream) {
|
|
333
353
|
const input = {};
|
|
334
354
|
for (const a of assignments) {
|
|
335
355
|
const eq = a.text.indexOf("=");
|
|
336
356
|
if (eq === -1) continue;
|
|
337
357
|
const key = a.text.slice(0, eq);
|
|
338
358
|
const raw = a.text.slice(eq + 1);
|
|
339
|
-
|
|
359
|
+
|
|
360
|
+
if (raw.startsWith("$")) {
|
|
361
|
+
if (upstream === undefined) continue;
|
|
362
|
+
let expr;
|
|
363
|
+
try {
|
|
364
|
+
expr = parseValueExpr(raw);
|
|
365
|
+
} catch {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (expr.kind !== "connection" || !expr.accessors) continue;
|
|
369
|
+
let value;
|
|
370
|
+
try {
|
|
371
|
+
value = resolveAccessors(upstream, expr.accessors);
|
|
372
|
+
} catch {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (value === MISSING || value === undefined) continue;
|
|
376
|
+
input[key] = value;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
340
380
|
try {
|
|
341
381
|
input[key] = JSON.parse(raw);
|
|
342
382
|
} catch {
|
package/TESTING.md
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
# Testing the CLI
|
|
2
|
-
|
|
3
|
-
The test suite runs entirely offline and never touches a developer's real
|
|
4
|
-
config, credentials or network. Both of those properties are enforced, not
|
|
5
|
-
assumed — see [Leak guards](#leak-guards).
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm test # run everything
|
|
9
|
-
npm run test:coverage # run everything with a coverage report
|
|
10
|
-
npm run test:watch # re-run on change
|
|
11
|
-
node --test --import ./test/setup.mjs test/pipe.test.js # one file
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
## The isolation harness
|
|
15
|
-
|
|
16
|
-
Every test file is launched with `--import ./test/setup.mjs`. That module runs
|
|
17
|
-
**before any `src/` file is loaded**, which is the only moment it can work:
|
|
18
|
-
`paths.js` and `API_BASE` are evaluated at import time, so isolation set up
|
|
19
|
-
inside a test file would already be too late.
|
|
20
|
-
|
|
21
|
-
`test/setup.mjs` gives each test file:
|
|
22
|
-
|
|
23
|
-
| | |
|
|
24
|
-
|---|---|
|
|
25
|
-
| `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` | a fresh `mkdtemp` directory, so tests read and write a throwaway config instead of yours |
|
|
26
|
-
| `HOME` | masked, so any fallback path cannot reach your real home |
|
|
27
|
-
| `NEXUSBLOOM_API_URL` | `https://api.nxb.invalid` — `.invalid` is reserved by RFC 2606, so a request that escapes a stub fails instead of reaching production |
|
|
28
|
-
| `NEXUSBLOOM_API_KEY`, `NEXUSBLOOM_SUPABASE_ANON_KEY` | deleted, so a test can never spend your key |
|
|
29
|
-
| `NXB_NO_INTERACTIVE`, `CI` | set, so no prompt can block a run |
|
|
30
|
-
| `NEXUSBLOOM_TELEMETRY` | deleted, so no test can phone home |
|
|
31
|
-
| `process.stdin` | replaced with a double that is **not** a TTY but does emit `end` |
|
|
32
|
-
|
|
33
|
-
That last row is subtle and load-bearing. `readStdin()` resolves on `end`, or
|
|
34
|
-
immediately when stdin is a TTY. Under `node --test` stdin is a pipe that is
|
|
35
|
-
never closed, so `end` never arrives and any test reaching `readStdin()` waits
|
|
36
|
-
forever — which surfaces as an 800-second file timeout rather than a failing
|
|
37
|
-
assertion. The double has to satisfy three things at once: stay non-TTY so
|
|
38
|
-
`isInteractive()` is correctly false, deliver `end` to a listener that attaches
|
|
39
|
-
later, and hold no OS handle so the file can still exit. A test that wants piped
|
|
40
|
-
input uses `withStdin()`.
|
|
41
|
-
|
|
42
|
-
## Helpers
|
|
43
|
-
|
|
44
|
-
| helper | use |
|
|
45
|
-
|---|---|
|
|
46
|
-
| `helpers/net.js` | `resetNetwork()` · `route/json/text/status` · `calledWith` · `calls` |
|
|
47
|
-
| `helpers/output.js` | `captureOutput(fn)` · `captureBoth(fn)` → `{value, stdout, stderr}` |
|
|
48
|
-
| `helpers/cli.js` | `withExitCode(fn)` → `{value, exitCode}` |
|
|
49
|
-
| `helpers/stdin.js` | `withStdin(text, fn)` |
|
|
50
|
-
| `helpers/capture.js` | `captureIO` — string chunks only |
|
|
51
|
-
| `helpers/ui-double.mjs` | scripted stand-in for the interactive prompts |
|
|
52
|
-
| `helpers/ui-hook.mjs` | `module.register` hook that redirects only `run.js`'s `./ui.js` import |
|
|
53
|
-
|
|
54
|
-
## Gotchas
|
|
55
|
-
|
|
56
|
-
Each of these cost real debugging time. They are properties of the runner and
|
|
57
|
-
the harness, not opinions.
|
|
58
|
-
|
|
59
|
-
**`node --test` writes its own protocol to stdout as Buffers.** A capture helper
|
|
60
|
-
that decodes Buffers into the captured text corrupts it, and a
|
|
61
|
-
`JSON.parse(captured)` fails intermittently with `Unexpected token` depending on
|
|
62
|
-
timing. Record string chunks only and pass binary straight through. Both
|
|
63
|
-
`helpers/output.js` and `helpers/capture.js` do this.
|
|
64
|
-
|
|
65
|
-
**Do not capture stdout around anything that awaits a real timer or async gap.**
|
|
66
|
-
The runner's own reporting written during that window is swallowed, and the file
|
|
67
|
-
reports a wrong final test count with no error at all.
|
|
68
|
-
|
|
69
|
-
**Wrap anything that reports a failure in `withExitCode`.** The CLI signals
|
|
70
|
-
failure by setting `process.exitCode = 1` and returning, so the caller can keep
|
|
71
|
-
working. That is right for the CLI and wrong for a test: a perfectly correct test
|
|
72
|
-
of a failure path would make the whole file exit non-zero.
|
|
73
|
-
|
|
74
|
-
**`resetNetwork()` between cases, not just between tests.** Routes only append
|
|
75
|
-
and the first match wins, so registering two responses for one pattern leaves
|
|
76
|
-
the second as dead code and every assertion after it passes for the wrong reason.
|
|
77
|
-
|
|
78
|
-
**`await import("node:fs")` yields a namespace, not a live binding.** A stub
|
|
79
|
-
assigned to the *default* export is invisible to a named-export read from inside
|
|
80
|
-
the module. To exercise a path that checks the filesystem, change the
|
|
81
|
-
filesystem — e.g. `sandbox-missing.test.js` copies `sandbox.js` into a temp
|
|
82
|
-
directory with no `runner.js` beside it, which is a real broken install.
|
|
83
|
-
|
|
84
|
-
**Module-level state latches.** `runnerMissing` in `sandbox.js` stays true once
|
|
85
|
-
set, so that behaviour lives in its own test file; `node --test` gives each file
|
|
86
|
-
a separate process, which is the isolation it needs.
|
|
87
|
-
|
|
88
|
-
**Interactive code is tested by injection, not by a terminal.** Inquirer cannot
|
|
89
|
-
be driven headlessly and a pending prompt keeps the process alive forever. The
|
|
90
|
-
wizard takes a `prompts` bundle (`wizard-flow.test.js`); `run.js`'s interactive
|
|
91
|
-
half is reached through a `module.register` hook that swaps only its `./ui.js`
|
|
92
|
-
import (`run-interactive.test.js`). Both keep the real decision logic in `src`
|
|
93
|
-
and replace only the presentation.
|
|
94
|
-
|
|
95
|
-
**`index.js` is spawned, not imported.** It parses argv on import. Spawning it
|
|
96
|
-
is also the honest way to assert the two contracts users depend on: exit codes,
|
|
97
|
-
and the stdout/stderr split.
|
|
98
|
-
|
|
99
|
-
## Writing a test for a new module
|
|
100
|
-
|
|
101
|
-
1. `import` it normally at the top of the file. The `--import` hook has already
|
|
102
|
-
run, so isolation is in place.
|
|
103
|
-
2. Network: `beforeEach(() => resetNetwork())`, `after(() => restoreNetwork())`.
|
|
104
|
-
3. Failure paths: wrap in `withExitCode` and assert `exitCode`.
|
|
105
|
-
4. Anything reading stdin: `withStdin`, or just let the default resolve `""`.
|
|
106
|
-
5. Assert behaviour, not coverage. A test that exists only to execute a line is
|
|
107
|
-
a maintenance liability that fails for the wrong reasons.
|
|
108
|
-
|
|
109
|
-
## Leak guards
|
|
110
|
-
|
|
111
|
-
`test/isolation.test.js` is not an ordinary unit test file. Each test fails if a
|
|
112
|
-
future change lets the suite read the developer's real config, reuse a real API
|
|
113
|
-
key, or reach the real network:
|
|
114
|
-
|
|
115
|
-
- config and cache resolve inside the temp sandbox, never under the real home
|
|
116
|
-
- no inherited credentials or telemetry opt-in survive into the process
|
|
117
|
-
- the API base is unroutable, so an unstubbed request fails rather than escaping
|
|
118
|
-
- a canary secret never appears in captured stdout or stderr
|
|
119
|
-
- redacted config leaks neither the value nor a fragment of it
|
|
120
|
-
- `config.json` is `0600` inside a `0700` directory
|
|
121
|
-
- no source file contains a hardcoded credential
|
|
122
|
-
- no inlined JWT grants more than the `anon` role
|
|
123
|
-
|
|
124
|
-
If you add a code path that could print a secret, add a canary test for it in the
|
|
125
|
-
same change.
|