@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 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. See
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.0",
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(tool.slug, literalInput(assignments));
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
- /** Build the input object for the trial run from literal assignments. */
332
- function literalInput(assignments) {
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
- if (raw.startsWith("$")) continue; // wired fields are not needed for a first run
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.