@nexusbloom/cli 0.3.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -34
- package/TESTING.md +125 -0
- package/package.json +10 -3
- package/src/abbr.js +27 -20
- package/src/commands.js +253 -0
- package/src/compile.js +69 -0
- package/src/diagnostics.js +41 -0
- package/src/index.js +330 -280
- package/src/jsonish.js +213 -0
- package/src/list.js +29 -23
- package/src/paths.js +42 -16
- package/src/pipe.js +709 -0
- package/src/pipelines.js +76 -0
- package/src/run.js +203 -100
- package/src/runner.js +89 -0
- package/src/sandbox.js +167 -0
- package/src/tools.js +177 -26
- package/src/types.js +178 -0
- package/src/ui.js +20 -0
- package/src/values.js +260 -0
- package/src/wizard.js +349 -0
package/README.md
CHANGED
|
@@ -11,23 +11,33 @@ npm install -g @nexusbloom/cli
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
# List available tools (interactive pagination,
|
|
14
|
+
# List available tools (interactive pagination, 20 per page)
|
|
15
15
|
nxb list
|
|
16
16
|
|
|
17
|
-
# Search with pagination
|
|
17
|
+
# Search with pagination — every long list has a search option
|
|
18
18
|
nxb list -s ssl
|
|
19
|
+
nxb search "ssl"
|
|
20
|
+
nxb abbr -s ssl
|
|
21
|
+
nxb cache list -s json
|
|
22
|
+
|
|
23
|
+
# Bare `nxb run` shows the tool list (and prompts to search when large)
|
|
24
|
+
nxb run
|
|
25
|
+
nxb run -s gradient
|
|
26
|
+
|
|
27
|
+
# Build a pipeline interactively, wiring against real tool output
|
|
28
|
+
nxb pipe --build
|
|
29
|
+
|
|
30
|
+
# Check whether a tool's declared output_schema matches what it returns
|
|
31
|
+
nxb doctor data-generator
|
|
32
|
+
|
|
33
|
+
# Exits non-zero on drift, so it works as a CI gate
|
|
34
|
+
nxb doctor data-generator || exit 1
|
|
19
35
|
|
|
20
36
|
# Non-interactive mode (single page dump)
|
|
21
37
|
nxb list --no-interactive
|
|
22
38
|
|
|
23
39
|
# JSON output (all commands)
|
|
24
40
|
nxb --json list
|
|
25
|
-
|
|
26
|
-
# Show tool abbreviations (paginated)
|
|
27
|
-
nxb abbr
|
|
28
|
-
|
|
29
|
-
# Search tools
|
|
30
|
-
nxb search "ssl"
|
|
31
41
|
```
|
|
32
42
|
|
|
33
43
|
## Running Tools
|
|
@@ -48,6 +58,9 @@ nxb run ssl-certificate-checker domain=example.com --local
|
|
|
48
58
|
# Interactive runner (select tool → pick inputs → auto-build command)
|
|
49
59
|
nxb run
|
|
50
60
|
|
|
61
|
+
# Narrow the picker first
|
|
62
|
+
nxb run -s gradient
|
|
63
|
+
|
|
51
64
|
# Pipe stdin through a tool
|
|
52
65
|
echo '{"domain":"example.com"}' | nxb transform ssl-certificate-checker
|
|
53
66
|
```
|
|
@@ -61,53 +74,175 @@ nxb info ssl-certificate-checker
|
|
|
61
74
|
# Show abbreviations (useful for quick tool references)
|
|
62
75
|
nxb abbr
|
|
63
76
|
nxb abbr ip # Filter abbreviations by query
|
|
77
|
+
nxb abbr -s ip # Same, explicit flag
|
|
64
78
|
|
|
65
79
|
# Interactive pagination for lists
|
|
66
80
|
nxb list # Browse pages with prev / next / done
|
|
67
|
-
nxb list -s validate
|
|
81
|
+
nxb list -s validate # Filtered + paginated
|
|
82
|
+
nxb list --refresh # Bypass cache, re-fetch the catalogue
|
|
83
|
+
nxb list --offline # Cache only, no network
|
|
68
84
|
|
|
69
|
-
# Search (full-text across slugs, names, descriptions, tags)
|
|
85
|
+
# Search (full-text across slugs, names, descriptions, categories, tags)
|
|
70
86
|
nxb search "validate"
|
|
71
87
|
```
|
|
72
88
|
|
|
89
|
+
Search matches case-insensitively across slug, name, description, category and
|
|
90
|
+
tags. Multi-word queries narrow the result set — `nxb search "json format"`
|
|
91
|
+
matches tools mentioning **both** words.
|
|
92
|
+
|
|
73
93
|
## Configuration
|
|
74
94
|
|
|
75
95
|
```bash
|
|
76
|
-
# Set config values
|
|
77
|
-
nxb config set key YOUR_KEY
|
|
78
|
-
nxb config set
|
|
96
|
+
# Set config values (secrets are stored but never echoed back)
|
|
97
|
+
nxb config set key YOUR_KEY
|
|
98
|
+
nxb config set supabaseKey YOUR_SUPABASE_ANON_KEY
|
|
99
|
+
nxb config set telemetry false
|
|
100
|
+
|
|
101
|
+
# Remove a stored value (e.g. rotate away an old API key)
|
|
102
|
+
nxb config unset key
|
|
79
103
|
|
|
80
|
-
# Get config values
|
|
81
|
-
nxb config get
|
|
82
|
-
nxb config get
|
|
104
|
+
# Get config values — secret values are always redacted
|
|
105
|
+
nxb config get
|
|
106
|
+
nxb config get key
|
|
83
107
|
|
|
84
108
|
# Environment variables (alternative to config)
|
|
85
109
|
# NEXUSBLOOM_API_KEY — API key (can also use `--key`)
|
|
86
110
|
# NEXUSBLOOM_API_URL — API base URL (default: https://nexusbloom.dev)
|
|
87
111
|
# NEXUSBLOOM_SUPABASE_URL — Supabase URL (for fallback fetching)
|
|
88
|
-
# NEXUSBLOOM_SUPABASE_ANON_KEY — Supabase anon key (
|
|
112
|
+
# NEXUSBLOOM_SUPABASE_ANON_KEY — Supabase anon key (enables the fallback fetcher)
|
|
113
|
+
# NEXUSBLOOM_TELEMETRY — set to `false` to disable the run counter
|
|
114
|
+
# NXB_NO_INTERACTIVE — force non-interactive output
|
|
89
115
|
```
|
|
90
116
|
|
|
91
|
-
|
|
117
|
+
No credentials are bundled with the CLI. The Supabase fallback stays disabled
|
|
118
|
+
until you supply your own key.
|
|
119
|
+
|
|
120
|
+
## Pipelines
|
|
121
|
+
|
|
122
|
+
Chain tools by piping one tool's output into the next tool's input.
|
|
92
123
|
|
|
93
124
|
```bash
|
|
94
|
-
#
|
|
95
|
-
nxb
|
|
125
|
+
# Frequency -> how many rows to generate -> encode the first one
|
|
126
|
+
nxb pipe 'frequency-analyzer text=@in | data-generator count=$total_words format=json | base64-tool text=$data|first.id|str action=encode'
|
|
127
|
+
|
|
128
|
+
# Read the pipeline's input from stdin
|
|
129
|
+
echo "some text to analyse" | nxb pipe 'frequency-analyzer text=@in | base64-tool text=$total_words|str action=encode'
|
|
130
|
+
|
|
131
|
+
# Check the wiring and types without running anything
|
|
132
|
+
nxb pipe --dry-run 'frequency-analyzer text=@in | data-generator count=$word_frequency format=json'
|
|
96
133
|
|
|
97
|
-
#
|
|
98
|
-
nxb
|
|
134
|
+
# List the available transforms
|
|
135
|
+
nxb pipe --transforms
|
|
99
136
|
```
|
|
100
137
|
|
|
101
|
-
|
|
138
|
+
### Connections
|
|
139
|
+
|
|
140
|
+
| Form | Meaning |
|
|
141
|
+
|------|---------|
|
|
142
|
+
| `key=value` | literal |
|
|
143
|
+
| `key=@in` | the pipeline's standard input |
|
|
144
|
+
| `key=$field` | a field from the previous step |
|
|
145
|
+
| `key=$` | the whole previous output |
|
|
146
|
+
| `key=$data[0].email` | array index + nested field |
|
|
147
|
+
| `key=$text|len` | apply a transform |
|
|
148
|
+
| `key=$max|int(0)` | transform with an argument |
|
|
149
|
+
| `key=$data|first.email` | transform, then keep going into the result |
|
|
150
|
+
| `key=$missing|default(7)` | fall back when the path is absent |
|
|
151
|
+
|
|
152
|
+
A step separator is written with surrounding whitespace (` | `); a `|` inside a
|
|
153
|
+
`key=$...` token is always a transform.
|
|
154
|
+
|
|
155
|
+
### Type safety
|
|
156
|
+
|
|
157
|
+
Conversions that cannot lose information happen silently. Conversions that can
|
|
158
|
+
lose information require an explicit transform:
|
|
159
|
+
|
|
160
|
+
| From → To | Behaviour |
|
|
161
|
+
|-----------|-----------|
|
|
162
|
+
| `integer → number` | automatic (widening) |
|
|
163
|
+
| `number → integer` | requires `\|int()` |
|
|
164
|
+
| `integer/number/boolean → string` | automatic |
|
|
165
|
+
| `string → integer/number/boolean` | requires a cast |
|
|
166
|
+
| `array → scalar` | requires `\|first()`, `\|last()`, `\|get(i)` |
|
|
167
|
+
| `* → enum` | validated against the allowed values |
|
|
168
|
+
|
|
169
|
+
The wiring is checked **before** anything runs, so a broken pipe is reported
|
|
170
|
+
before any side effects:
|
|
102
171
|
|
|
103
172
|
```bash
|
|
104
|
-
|
|
105
|
-
|
|
173
|
+
$ nxb pipe 'frequency-analyzer text=hi | data-generator count=$word_frequency format=json'
|
|
174
|
+
✗ step 2: frequency-analyzer.word_frequency is array but data-generator.count
|
|
175
|
+
wants integer — add |first()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Everything is re-checked against real values at runtime, because declared
|
|
179
|
+
schemas can be wrong. `nxb doctor` finds that drift ahead of time:
|
|
106
180
|
|
|
107
|
-
|
|
108
|
-
nxb
|
|
181
|
+
```bash
|
|
182
|
+
$ nxb doctor some-tool
|
|
183
|
+
! undeclared output fields: summary, meta
|
|
184
|
+
✗ score: declared integer, actually returns string
|
|
109
185
|
```
|
|
110
186
|
|
|
187
|
+
### Interactive builder
|
|
188
|
+
|
|
189
|
+
`nxb pipe --build` walks you through building a pipeline. It runs each tool as
|
|
190
|
+
you go and lists **what it actually returned** — including fields its schema
|
|
191
|
+
never mentioned — before asking what to send onward. A browser node builder can
|
|
192
|
+
only trust `output_schema`; this can execute the tool and look.
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
$ nxb pipe --build
|
|
196
|
+
Step 1
|
|
197
|
+
Tool › frequency-analyzer
|
|
198
|
+
text hello world
|
|
199
|
+
mode words
|
|
200
|
+
✓ ran frequency-analyzer · 12ms
|
|
201
|
+
Output
|
|
202
|
+
total_words integer 2
|
|
203
|
+
character_frequency array [9]
|
|
204
|
+
Send this output into another tool? yes
|
|
205
|
+
Step 2
|
|
206
|
+
frequency-analyzer → count integer
|
|
207
|
+
✔ data_generator... array → integer needs a transform
|
|
208
|
+
· set by hand
|
|
209
|
+
frequency-analyzer.total_words integer 2
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Saved pipelines
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
nxb pipeline list
|
|
216
|
+
nxb pipeline run my-pipeline
|
|
217
|
+
nxb pipeline delete my-pipeline
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Pipelines are JSON files in `~/.config/nexusbloom/pipelines/` — plain text, so
|
|
221
|
+
they diff, sync and live in git. No database, no migrations.
|
|
222
|
+
|
|
223
|
+
## Local Tool Cache
|
|
224
|
+
|
|
225
|
+
Tool catalogues and downloaded tool source are cached on disk so `list`,
|
|
226
|
+
`abbr` and `--local` runs work offline and stay fast.
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
nxb cache path # show exactly where config + cache live
|
|
230
|
+
nxb cache list # list cached tools (-s to filter)
|
|
231
|
+
nxb cache clear # delete everything, including the legacy cache location
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Locations follow the XDG spec:
|
|
235
|
+
|
|
236
|
+
| What | Where |
|
|
237
|
+
|------|-------|
|
|
238
|
+
| Config (contains your API key) | `$XDG_CONFIG_HOME/nexusbloom/config.json` — mode `0600`, dir `0700` |
|
|
239
|
+
| Tool catalogue | `$XDG_CACHE_HOME/nexusbloom/tools-list.json` |
|
|
240
|
+
| Tool source | `$XDG_CACHE_HOME/nexusbloom/tools/<slug>.json` |
|
|
241
|
+
|
|
242
|
+
Earlier versions cached inside the installed package directory, which meant the
|
|
243
|
+
cache was wiped by every `npm update -g` and could fail on root-owned global
|
|
244
|
+
installs. `nxb cache clear` removes that leftover directory too.
|
|
245
|
+
|
|
111
246
|
## JSON Input Generation
|
|
112
247
|
|
|
113
248
|
```bash
|
|
@@ -126,23 +261,98 @@ nxb json --template data-generator -s count=3 | nxb run data-generator --input @
|
|
|
126
261
|
|
|
127
262
|
## Options
|
|
128
263
|
|
|
129
|
-
| Flag | Description |
|
|
130
|
-
|
|
131
|
-
| `--json` | Output results in JSON
|
|
132
|
-
|
|
|
264
|
+
| Flag | Commands | Description |
|
|
265
|
+
|------|----------|-------------|
|
|
266
|
+
| `--json` | all | Output results in JSON (global — place before the subcommand) |
|
|
267
|
+
| `-s, --search <query>` | `list`, `run`, `abbr` | Filter a list by name, slug, category or tags |
|
|
268
|
+
| `--no-interactive` | `list`, `search`, `abbr` | Disable interactive pagination |
|
|
269
|
+
| `-r, --refresh` | `list` | Bypass the catalogue cache and re-fetch |
|
|
270
|
+
| `--offline` | `list` | Use only cached data, never the network |
|
|
271
|
+
| `-k, --key <key>` | `run`, `transform` | API key (or `NEXUSBLOOM_API_KEY`) |
|
|
272
|
+
| `-i, --input <json>` | `run` | Inline JSON input, or `@-` for stdin |
|
|
273
|
+
| `-f, --file <path>` | `run` | Read input from a JSON file |
|
|
274
|
+
| `--local` | `run`, `transform` | Execute `coreLogic` on this machine |
|
|
275
|
+
| `--dry-run` | `pipe`, `pipeline run` | Check wiring and types without running |
|
|
276
|
+
| `--remote` | `pipe`, `pipeline run` | Run every step against the API |
|
|
277
|
+
| `--transforms` | `pipe` | List the available connection transforms |
|
|
278
|
+
| `--build` | `pipe` | Build a pipeline interactively |
|
|
279
|
+
| `-t, --template <slug>` | `json` | Generate a JSON template for a tool |
|
|
280
|
+
| `-i, --interactive` | `json` | Prompt for each field |
|
|
281
|
+
| `-s, --set <key=value>` | `json` | Set specific field values |
|
|
282
|
+
| `-a, --add <json>` | `json` | Merge additional JSON into the output |
|
|
283
|
+
|
|
284
|
+
Subcommands: `config list|set|get|unset`, `cache path|clear`,
|
|
285
|
+
`pipeline list|run|delete`.
|
|
286
|
+
|
|
287
|
+
Exit codes are meaningful for scripting: `0` success, `1` anything the CLI
|
|
288
|
+
diagnosed — unknown tool, broken wiring, type mismatch, empty catalogue, or an
|
|
289
|
+
unreachable API. `--json` puts machine-readable results on stdout and keeps
|
|
290
|
+
progress, warnings and errors on stderr, so `nxb --json list | jq` is always
|
|
291
|
+
safe.
|
|
133
292
|
|
|
134
293
|
## Interactive Mode Features
|
|
135
294
|
|
|
136
|
-
- **Paginated browsing**: `nxb list` and `nxb abbr` show
|
|
295
|
+
- **Paginated browsing**: `nxb list` and `nxb abbr` show 20 items per page with prev/next navigation
|
|
296
|
+
- **Search-first**: any list long enough to page through offers `-s/--search`
|
|
137
297
|
- **Interactive runner**: `nxb run` without a slug opens a tool selector, then prompts for each input field, then builds and executes the command
|
|
138
298
|
- **Abbreviation resolution**: Tool slugs can be typed using auto-generated abbreviations (e.g. `scc` for `ssl-certificate-checker`)
|
|
299
|
+
- **Script-safe**: when stdout/stdin is piped (or `CI` is set), prompts are never opened. `nxb run` prints a searchable tool list and exits instead of hanging
|
|
139
300
|
|
|
140
301
|
## Local Execution
|
|
141
302
|
|
|
142
|
-
The `--local` flag fetches the tool's v2 source code from the NexusBloom API,
|
|
303
|
+
The `--local` flag fetches the tool's v2 source code from the NexusBloom API,
|
|
304
|
+
caches it locally, and executes the `coreLogic()` function directly on your
|
|
305
|
+
machine. This is useful for:
|
|
143
306
|
|
|
144
307
|
- Offline use (after initial fetch)
|
|
145
308
|
- Faster repeated execution (cached)
|
|
146
309
|
- Privacy-sensitive inputs (data stays on your machine)
|
|
147
310
|
|
|
148
|
-
|
|
311
|
+
****This executes JavaScript downloaded from the network.** Tool code runs in a
|
|
312
|
+
disposable child process with a hard `SIGKILL` deadline, so a tool that loops
|
|
313
|
+
forever, allocates without bound, or calls `process.exit()` cannot take the CLI
|
|
314
|
+
with it. Run `nxb cache clear` to remove downloaded source and stop trusting it.
|
|
315
|
+
|
|
316
|
+
Note that this is isolation, not a security sandbox — the child runs with your
|
|
317
|
+
user's permissions. Prefer remote execution for tools you do not trust.
|
|
318
|
+
|
|
319
|
+
A tool's `coreLogic` must be **self-contained**: local execution strips module
|
|
320
|
+
syntax and evaluates the function in isolation, so `import` statements are
|
|
321
|
+
removed rather than resolved. Relative imports like `import { helper } from
|
|
322
|
+
"./util"` will fail at runtime. `export async function coreLogic`,
|
|
323
|
+
`export const coreLogic = async () => {}` and plain `function coreLogic` are all
|
|
324
|
+
supported.
|
|
325
|
+
|
|
326
|
+
## Development
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
npm test # 500+ tests, no network required
|
|
330
|
+
npm run test:coverage # with a coverage report
|
|
331
|
+
npm run test:watch # re-run on change
|
|
332
|
+
```
|
|
333
|
+
|
|
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.
|
|
337
|
+
|
|
338
|
+
## Security Notes
|
|
339
|
+
|
|
340
|
+
- No API keys or Supabase keys are shipped inside the package.
|
|
341
|
+
- Manifest text fetched from the network is **parsed, never evaluated**; the
|
|
342
|
+
literal parser rejects anything that is not plain data.
|
|
343
|
+
- Tool code runs in a child process with a hard timeout, a memory cap, and
|
|
344
|
+
captured stdio, so it cannot hang, OOM or corrupt the CLI's output.
|
|
345
|
+
- Pipeline slugs are validated before being used as cache filenames.
|
|
346
|
+
- `config.json` is written with mode `0600`; secret values are redacted by
|
|
347
|
+
`nxb config get` and never echoed by `nxb config set` or `config unset`.
|
|
348
|
+
- `nxb config unset` exists so a rotated API key can actually be removed.
|
|
349
|
+
- `nxb transform` writes the payload to stdout exactly once and nothing else, so
|
|
350
|
+
`nxb transform <tool> | jq` is safe.
|
|
351
|
+
- Tool slugs are validated against a strict pattern before being used as cache
|
|
352
|
+
filenames or query strings, which also blocks PostgREST filter injection.
|
|
353
|
+
- Run telemetry sends only the tool slug, is bounded by a 1.5s abort timeout, and
|
|
354
|
+
is skipped entirely when disabled.
|
|
355
|
+
- Test isolation is itself guarded: the suite cannot read your real config or
|
|
356
|
+
spend your API key, and canary tests fail if a secret is ever printed.
|
|
357
|
+
- The run counter sends only the tool slug. Disable it with
|
|
358
|
+
`nxb config set telemetry false`.
|
package/TESTING.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexusbloom/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "NexusBloom CLI — run tools and workflows from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src/",
|
|
11
11
|
"package.json",
|
|
12
|
-
"README.md"
|
|
12
|
+
"README.md",
|
|
13
|
+
"TESTING.md"
|
|
13
14
|
],
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"access": "public"
|
|
@@ -22,5 +23,11 @@
|
|
|
22
23
|
"chalk": "^5.3.0",
|
|
23
24
|
"commander": "^12.0.0",
|
|
24
25
|
"qrcode": "^1.5.4"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test --import ./test/setup.mjs test/*.test.js",
|
|
29
|
+
"test:coverage": "node --test --experimental-test-coverage --import ./test/setup.mjs test/*.test.js",
|
|
30
|
+
"test:watch": "node --test --watch --import ./test/setup.mjs test/*.test.js",
|
|
31
|
+
"lint": "node --check src/index.js"
|
|
25
32
|
}
|
|
26
|
-
}
|
|
33
|
+
}
|
package/src/abbr.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* abbr.js — `nxb abbr` command.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Paginated abbreviation view.
|
|
5
5
|
*
|
|
6
|
-
* nxb abbr
|
|
7
|
-
* nxb abbr ip
|
|
8
|
-
* nxb abbr
|
|
9
|
-
* nxb abbr --
|
|
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
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import chalk from "chalk";
|
|
13
|
-
import { fetchTools, generateAbbreviations } from "./tools.js";
|
|
14
|
+
import { fetchTools, generateAbbreviations, filterTools } from "./tools.js";
|
|
14
15
|
import { paginate, paginateInteractive, PAGE_SIZE } from "./ui.js";
|
|
16
|
+
import { diagnoseEmptyCatalogue } from "./diagnostics.js";
|
|
15
17
|
|
|
16
18
|
function renderAbbrPage(result, abbrMap) {
|
|
17
19
|
if (result.total === 0) {
|
|
@@ -30,22 +32,27 @@ function renderAbbrPage(result, abbrMap) {
|
|
|
30
32
|
|
|
31
33
|
export async function abbrAction(opts = {}) {
|
|
32
34
|
const { query, json: jsonMode, interactive = true, force = false, offline = false } = opts;
|
|
35
|
+
const search = query || opts.search;
|
|
33
36
|
|
|
34
37
|
const tools = await fetchTools({ force, offline });
|
|
38
|
+
if (!tools || tools.length === 0) {
|
|
39
|
+
return diagnoseEmptyCatalogue({ offline, jsonMode, source: "catalogue" });
|
|
40
|
+
}
|
|
35
41
|
const abbrMap = generateAbbreviations(tools.map((t) => t.slug));
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
// Match slug / name / description / category, plus the abbreviation itself —
|
|
44
|
+
// the abbreviation is what you actually type, so it has to be searchable.
|
|
45
|
+
const filtered = search
|
|
46
|
+
? (() => {
|
|
47
|
+
const byFields = filterTools(tools, search);
|
|
48
|
+
const seen = new Set(byFields.map((t) => t.slug));
|
|
49
|
+
const q = search.toLowerCase();
|
|
50
|
+
const byAbbr = tools.filter(
|
|
51
|
+
(t) => !seen.has(t.slug) && (abbrMap[t.slug] || "").toLowerCase().includes(q)
|
|
52
|
+
);
|
|
53
|
+
return [...byFields, ...byAbbr];
|
|
54
|
+
})()
|
|
55
|
+
: tools;
|
|
49
56
|
|
|
50
57
|
if (jsonMode) {
|
|
51
58
|
const output = filtered.reduce((acc, t) => { acc[t.slug] = abbrMap[t.slug]; return acc; }, {});
|
|
@@ -55,7 +62,7 @@ export async function abbrAction(opts = {}) {
|
|
|
55
62
|
|
|
56
63
|
if (!interactive) {
|
|
57
64
|
if (filtered.length === 0) {
|
|
58
|
-
console.log(chalk.yellow(` No abbreviations${
|
|
65
|
+
console.log(chalk.yellow(` No abbreviations${search ? ` matching "${search}"` : ""}.`));
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
61
68
|
const result = paginate(filtered, filtered.length, 1);
|
|
@@ -73,7 +80,7 @@ export async function abbrAction(opts = {}) {
|
|
|
73
80
|
console.log(chalk.bold(`\n Tool Abbreviations (${result.total} · page ${result.page}/${result.totalPages})\n`));
|
|
74
81
|
renderAbbrPage(result, abbrMap);
|
|
75
82
|
},
|
|
76
|
-
{}
|
|
83
|
+
{ interactive }
|
|
77
84
|
);
|
|
78
85
|
}
|
|
79
86
|
|