35 KiB
Field-report implementation plan
Turns the findings of the wild-run field report into work. Ordered by severity,
then by whether a fix unblocks a later one. Every phase is independently
shippable and ends at the existing gate (lint:ci → typecheck →
test:coverage).
Findings the field run confirmed that DOCS-AUDIT.md already tracks keep their
audit number, so the two documents stay in step: closing an item here closes it
there.
Contents
- 0. Retractions — two findings were harness artefacts
- 1. The secret leak —
envbroadcasts a credential in the clear - 2. Remove
--json— audit §1.2, closed by deletion - 3. Replay and session correctness — audit §2.4, §2.5, §4.5
- 4. The first five minutes
- 5. Cube defects — audit §5.3
- 6. Documentation sweep
- 7. Harness fix and acceptance run
0. Retractions
Two findings in the field report were caused by the PTY driver I used to script
the TUI, not by nopy. The driver called pty.fork() and never issued
TIOCSWINSZ, so the child saw a 0×0 terminal.
enquirer's utils.height (lib/utils.js:80-86) computes a sane fallback and
then throws it away:
let rows = (stream && stream.rows) ? stream.rows : fallback; // fallback = 25
if (stream && typeof stream.getWindowSize === 'function') {
rows = stream.getWindowSize()[1]; // ← unconditional
}
A TTY always has getWindowSize, so height becomes 0, and
ArrayPrompt.limit (lib/types/array.js:604) returns Math.min(limit, 0).
visible is then empty for every array prompt.
Re-run with a 50×200 window, both work correctly:
| Field report | Actual |
|---|---|
| §3.6 multi-field forms never render their fields | All four fields render, accept input, and submit: RESULT {"USER":"X","PASSWORD":"changeme","GROUPS":"","PUBKEY":""} |
| §3.14 cube filter says "No matching choices" while matching fine | Filter renders correctly, highlights the matched substring, and returns ["user:add"] |
What survives, and it is worth fixing. nopy has no defence against a
terminal that reports a degenerate size: the form silently submits {}, and the
run proceeds with every variable absent. That is §4.4
and §4.5. Field
report §3.7 (a required key dropped from the command) was reached through the
0×0 form, but the hole it exposed is real and independent: nothing on the
interactive path checks that a cube's required variables were actually filled.
Everything else in the field report stands.
1. The secret leak
Highest severity: following the documentation as written prints a credential in plaintext, and the workaround it is prescribed for does not work either.
1.1 A declared secret must never be broadcast to cubes that do not declare it
What happens. Variables.bucket() (nopy.common.ts:193-203) seeds every
key of config env onto every cube as an env-origin assignment, and
isSecret (nopy.common.ts:135-137) is keyed per cube. So with PASSWORD under
env, a dry run prints:
Step 1: apt:essentials … --data "PASSWORD=wildpass123" ← unmasked
Step 2: user:add … --data "PASSWORD=********" ← masked
Step 3: runtime:nodevm … --data "PASSWORD=wildpass123" ← unmasked
Why the obvious fix is wrong. "Seed env only onto cubes whose schema
declares the key" breaks a shipped cube: ssh/keyman/deploy.py:28 reads
host.data.get('KEY_DIR'), a key its manifest does not declare and that exists
only in .nopyrc.json env (packages/nopy/.nopyrc.json:5). Broadcast is
load-bearing.
Fix. Narrow the rule to secrets only — broadcast stays, secrets stop travelling:
-
In
nopy.main.ts, afterloadCubes(), collect the union of every loaded manifest'ssecretsand hand it toVariables:const declaredSecrets = new Set(Object.values(cubes).flatMap((c) => c.secrets)); const variables = new Variables(config.env, declaredSecrets);Deterministic and ordering-free: it is computed before the first
resolveCube, so it does not depend on which cube resolves first. -
In
bucket(), skip seeding anenvkey that is indeclaredSecretsunless the cube itself declares that key in its schema.Variablesneeds the cube's schema keys for this — adddeclareSchema(cube, keys), called fromBuildContext.resolveCubeimmediately afterdeclareSecrets(cubes/dependencies.ts:121), before any assignment creates the bucket. -
Mark globally, mask globally: a key in
declaredSecretsisredactedon whichever cube it does land on, even if that cube's own manifest forgot to list it. Cheap defence against a manifest that declaresPASSWORDinschemaand omits it fromsecrets. -
New optional
.nopyrc.jsonkey, for anenvsecret no manifest declares (an API token a hook uses, say):{ "secrets": ["DEPLOY_TOKEN"], "env": { "DEPLOY_TOKEN": "…" } }Unions into
declaredSecrets. Validate it innopy.config.tsalongside the other properties.
Verify. New test in tests/common.test.ts: env carrying a key that cube A
declares secret and cube B does not → B's get() does not contain the key; A's
does and is redacted. New test in tests/executor.test.ts: the printed plan for
B contains no occurrence of the value.
1.2 env must satisfy the --use-defaults gap check
What happens. fillSessionGaps (cubes/dependencies.ts:79-89) builds
gaps as missingRequired ∪ cube.secrets — unconditionally including every
secret, regardless of whether anything supplied a value. Under -D it throws,
and the message tells you to do the thing you have already done:
Error: Cube "user:add" cannot be replayed with --use-defaults: PASSWORD would
have to be entered. … set the values under "env" in .nopyrc.json.
The value was read — without -D the prompt came pre-filled from env.
Fix. Under useDefaults, a gap is satisfied when something outside the
session supplied it deliberately:
const unsatisfied = gaps.filter((key) => {
const origin = this.variables.of(cube.id, key)?.origin;
return origin !== 'env' && origin !== 'param';
});
if (unsatisfied.length > 0) throw new Error(…);
default is deliberately not accepted for a secret. On a replay the
recorded value is gone by design, so falling through to a manifest default would
deploy a different credential than the run being replayed — silently. The
message says so, instead of repeating advice that already failed:
Cube "user:add" cannot be replayed with --use-defaults: PASSWORD is a secret and secrets are never recorded in a session. Set it under "env" in .nopyrc.json (a schema .default() is not accepted for a secret), pass it from a dependency, or replay without --use-defaults.
Verify. tests/cubes.dependencies.test.ts: -D replay with the secret under
env succeeds and the value reaches the deploy call; with only a schema
.default() it throws and the message names the key. Both are new cases.
1.3 Documentation
README.md:291 currently prescribes exactly the leak. After 1.1 and 1.2 the
advice becomes true; add one sentence under Secrets stating the new rule — a
declared secret in env reaches only the cubes that declare it — so the
interaction between the two features is written down once, in the place a reader
of either lands.
2. Remove --json
Audit §1.2, independently confirmed: nopy install -R --json > j.out →
json.load() raises; stdout carries seven ANSI-coloured log lines and no JSON.
Closed by deletion, not by implementation.
Why removal is the right call and not just the cheap one. executeDeployCalls
runs pyinfra through execa with inherited stdio (nopy.executor.ts:117), so
during a real run nopy does not own its own stdout — pyinfra does, and writes an
unbounded amount to it. A JSON blob appended after that is not machine-readable
by any definition a caller could rely on; making it so means capturing pyinfra's
output and giving up live progress, which is a real feature traded for a
speculative one. That is the same root cause as the documented gap that
ExecutionResult.stdout is never populated. The CI case the flag was for is
already covered: --print-only for the plan and the exit code for the verdict
(nopy.cli.ts:138-140 exits 1 on any failure). Nothing can depend on the current
behaviour, because there is no current behaviour.
2.1 The flag, from the install command
nopy.cli.ts:91— drop the.option('-j, --json', …)line.:133— dropjsonOutput: options.jsonfrom thenopy()call.:147-160— theif (options.json)error branch collapses to the singleconsole.error. This is the same statement §4.3 rewrites, so whichever phase lands first does both; the other just reads it.
2.2 jsonOutput, from the library
NopyOptions.jsonOutput(nopy.main.ts:110) and its destructure (:141). This is a breaking change to an exported interface —docs/API.md:273documents it. It is a0.xminor bump, and an unknown property is a type error rather than a silent behaviour change, so a consumer finds out at compile time.:148— the banner guard becomesif (!replaySession && !loadSessionPath).:158— the JSON error dump goes;log.erroron:156-157already reported the same errors.:227— theonProgresscallback loses its guard and always logs.outputExecutionPlan(calls, asJson?)(nopy.executor.ts:148-158) — drop the parameter and the dead JSON branch. Exported and documented (docs/API.md:588); nothing insrc/passes the second argument, only a test does.
2.3 stdout hygiene — the one fix that survives, and now matters more
With --json gone, --print-only is the machine-readable surface, so it has to
be clean. Two writers currently pollute it, and jsonOutput was the only thing
holding either back:
configureLogtape's console sink usesconsole.log(nopy.main.ts:34), againstREADME.md:409, which promises stderr. Switch toconsole.error.printActiveConfigends inconsole.log(nopy.main.ts:95) and is suppressed today only byjsonOutputand by replay. Same switch.
Write the rule down once, in the README: stdout carries the deploy commands and
pyinfra's own output; everything nopy says about itself goes to stderr. That is
a promise a test can hold to, which the old --json claim never was.
Ripple worth knowing before starting: tests/main.test.ts spies on console.log
(logSpy) throughout, so moving logtape to console.error means moving those
spies. Mechanical, but it touches most of the file.
2.4 Documentation — most of the work
| File | Change |
|---|---|
packages/nopy/README.md:537-544 |
Delete the JSON output (for CI/CD) block. Replace with the CI recipe that works: --print-only for the plan, exit code 1 for the verdict, --continue-on-error when you want every failure in one run. |
packages/nopy/README.md:409 |
The stderr promise stays and is now load-bearing; reword its reason from --json to --print-only and piped stdout. |
docs/API.md:273 |
Remove the jsonOutput row from the NopyOptions table. |
docs/API.md:588-596 |
outputExecutionPlan(calls, asJson?) → outputExecutionPlan(calls); the note that --dry-run --json prints the text plan goes with it. |
docs/API.md:1034 |
Reword the stderr note the same way as README.md:409. |
docs/API.md:1170-1173 |
Known gaps: the --json entry disappears — that is the point. ExecutionResult.stdout is never populated stays, and gains the reason (stdio is inherited), since that is now the honest answer to "how do I capture output?". |
docs/CUBE-PACKAGES.md:317 |
Future-work line proposes surfacing a cube's source "in the interactive picker and in --json output"; drop the second half. |
nopy.exit.ts:77 |
Comment cites --json and --print-only as the reason for the exit discipline; leave the discipline, drop the --json half. |
DOCS-AUDIT.md:85 |
Mark §1.2 closed by removal and say so in one line — a reader of that document should not go looking for the fix. Also touch its back-references at :446 and :854. |
2.5 nopy history --json is a different flag — keep it
nopy.cli.ts:169-177 is a second, unrelated -j, --json, on the history
command, and it works: JSON.stringify(listHistory()). It was never part of
audit §1.2 — the field run used it successfully. Keep it. Nothing else writes to
stdout during history, so it has none of the problem above, it is three lines,
and it is how a script finds the id to pass to -H.
If the intent is that nopy has no JSON surface at all, removing it is
nopy.cli.ts:169 plus :173-177, and README.md:541 and :573. Flagging it
rather than deciding it: this one is a working feature, so deleting it is a
different kind of change from deleting one that never worked.
2.6 Tests
Delete, rather than adapt — they assert behaviour that no longer exists:
tests/main.test.ts:162-168— emits the errors as JSON when jsonOutput is settests/main.test.ts:224-227— is suppressed for JSON output (the siblingreplaySession/loadSessionsuppression cases stay and still cover:148)tests/main.test.ts:366-373— stays silent on progress when jsonOutput is settests/executor.test.ts:129— theoutputExecutionPlan(calls, true)case
Add one that holds the new rule: run with printOnly and assert console.log
received the command block and nothing else — banner and progress lines on
console.error. Deleting a covered branch moves coverage up, not down, so the
gate is not at risk here.
3. Replay and session correctness
3.1 --save-session no-ops on a replay (audit §4.5)
nopy.main.ts:199 guards with !workflow.isReplay, so
nopy install -R -s out.json exits 0 and writes nothing. Drop the guard: the
resolved cube set is exactly what the user asked to capture, and a replay's
session is no less valid than a fresh run's.
3.2 A --load-session replay is not recorded
nopy.main.ts:203 excludes every replay from history. For -R and -H that is
right and documented (README.md:494) — repeating must not push the original
out of the list. For -l it is wrong: the run is not already in history, so
after deploying from a session file nopy history says "No sessions in
history" and -R has nothing to repeat. That is what happened in the field run.
Record -l runs; keep -R/-H non-recording. WorkflowResult needs to
distinguish them — replace the boolean isReplay with
replaySource: 'file' | 'history' | undefined, or add a second flag. Then fix
README.md:496-500, whose explicit "a run is not recorded when" list omits
replays entirely and so contradicts :494.
3.3 The written session does not match the documented format (audit §2.4)
Documented (README.md:224-254, docs/SESSION_FORMAT.md) versus written:
| Field | Documented | Written |
|---|---|---|
version |
"1.0.0" |
absent |
name |
"My Deployment Session" |
absent |
timestamp |
ISO 8601 | absent |
auth.method |
"ssh-key" |
"ssh" |
auth.username |
"root" |
absent |
This is what you consult in order to hand-write a session, which is what the field run had to do.
Implement rather than delete — all three fields are cheap and two are useful:
createSession(nopy.session.ts:183-197) stampsversion: '1.0.0'andtimestamp: new Date().toISOString(), and derives a defaultnamethe waygenerateEntryNamealready does for history (nopy.history.ts:84-102).loadSession(:130-158) keeps accepting sessions without them — every existing file and every hand-written one must stay loadable. Warn on aversionit does not know; do not fail.auth.method: 'ssh'is real, not a bug:runInteractiveWorkflow:64-67uses it for@vagrant/and@docker/hosts, where the connector owns authentication. It is simply undocumented. Document the third value and when it appears.
3.4 listSessions does not match the documented filename (audit §2.5)
Docs say .nopysession.json; listSessions (nopy.session.ts:166-175) filters
for .session.json / .session.mjs, which wild.nopysession.json does not
match. Widen the filter to .nopysession.json / .nopysession.mjs and keep the
old suffixes.
4. The first five minutes
The four roughest edges a new user meets all sit before anything that works well.
4.1 The documented install command 404s
packages/nopy-cubes-core/README.md:9, packages/nopy/README.md:317 and :344
all open with:
pnpm add -D @bitsquare/nopy-cubes-core
[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/@bitsquare%2Fnopy-cubes-core: Not Found
The bundle has never been published to npmjs, and an untagged Gitea install
resolves to nothing because Gitea publishes no latest tag. What rescued the
field run was pnpm's own error listing main: 0.5.0-main.17.gda84523.
Replace both snippets with the form that works, and say why:
pnpm add -D @bitsquare/nopy-cubes-core@main \
--@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
nopy-cubes-core's README does not mention Gitea at all; nopy's mentions it only
in a Channels section framed around installing the CLI. Both need the tag
requirement stated where the install command is, not two sections away. Revisit
when release.yml first ships the bundle to npmjs — the guard in that workflow
blocks the first nopy release until it does.
4.2 pyinfra is an unstated prerequisite
Nothing in the README says pyinfra must be installed separately and on PATH;
nopy.executor.ts:117 spawns it directly. The field run only worked because it
happened to be there. Add a Requirements block next to the install command:
Node ≥ 22, pyinfra on PATH (pipx install pyinfra), plus whatever the chosen
connector needs (vagrant, docker). Optionally probe for it once at startup
and fail with one line instead of a spawn error.
4.3 Routine errors print a raw Node stack trace
nopy.cli.ts:159 passes the error object as a third argument:
console.error('Error:', error instanceof Error ? error.message : error, error);
so the message prints, then the whole error prints again with frames into
dist/. Running outside a project — the most likely first-run mistake — yields:
Error: No .nopyrc.json found. Create one in your project directory or any parent directory.
at loadConfig (…/dist/nopy.config.js:187:15)
at Command.<anonymous> (…/dist/nopy.cli.js:74:24)
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
Drop the third argument; print the stack only under NOPY_DEBUG. Adopt keyman's
shape (keyman.cli.ts is the error boundary that turns a UsageError into one
line) so the two CLIs stay in step: a NopyUsageError for the errors that are
the user's to fix — no config, no cubes, missing required variable, unknown
session — and a stack for everything else.
4.4 Survive a terminal that reports no size
Per §0: with stdout.rows === 0, every enquirer array prompt
renders "No matching choices", the form submits {}, and nopy deploys with every
variable defaulted. Reachable outside a test harness — some CI pseudo-terminals,
script -q, and editor terminals during startup all report 0 rows.
Passing an explicit limit does not help (measured): enquirer clamps it with
Math.min(limit, this.height). But height itself has an escape hatch one line
above the bug — prompt.js:396:
get height() { return this.options.rows || utils.height(this.stdout, 25); }
options.rows short-circuits the broken function entirely, so the fix is to pass
a floored size rather than to fake a stdout:
const MIN_ROWS = 24, MIN_COLS = 80;
const terminalSize = (out = process.stdout) => ({
rows: Math.max(out.rows || 0, MIN_ROWS),
columns: Math.max(out.columns || 0, MIN_COLS),
});
Measured, 2×2:
| PTY | without | with |
|---|---|---|
| 0×0 | RESULT {} |
RESULT {"USER":"X","PASSWORD":"changeme","GROUPS":"","PUBKEY":""} |
| 50×200 | full result | full result (rows passes through as 50) |
Apply to both enquirer call sites — CubeSelection (nopy.prompts.ts:61-68) and
VariableAssignment (:238-243) — and derive pageSize (:55-56) from the same
helper, where process.stdout.rows || 24 already fails for 0 only to be clamped
away again.
(An earlier draft of this section proposed a Proxy over process.stdout
reporting the floor. It works — also measured — but it fakes a stream object to
reach a value the prompt will take directly. options.rows is the same fix
without the impersonation.)
enquirer 2.4.1 is the last release (2023) and this is its bug. Worth a comment at the call site so nobody "simplifies" the sizes away later.
4.5 Never deploy a cube with a missing required variable
Field report §3.7. README.md:99 guarantees "Every key defined in the manifest
schema is guaranteed to be present on host.data", and the interactive path
does not enforce it: resolveCube calls VariableAssignment
(cubes/dependencies.ts:138) and goes straight to buildDeployCall.
assertVariablesComplete exists and runs only under useDefaults (:136).
buildDeployCall then emits --data for whatever variables exist
(:186-189), so a key nothing ever assigned is absent from the command
entirely and the deploy script reads None.
Two ways in, both real: a form that submits nothing (§4.4), and a form the user
cancels — VariableAssignment's catch {} (nopy.prompts.ts:252-254) swallows
cancellation and returns as though it succeeded.
- Call the completeness check on the interactive path too, with a message that
fits:
Cube "user:add" is missing PUBKEY. It has no default value and nothing supplied one.(The replay path already does this atcubes/dependencies.ts:94-100.) - Distinguish cancel from error in
VariableAssignmentand route a cancel throughnopy.exit.tslike the other prompts, instead of continuing with a half-filled cube.
Verify. tests/cubes.dependencies.test.ts: a cube with a required
no-default key, with the form stubbed to return {} → resolution throws and
names the key. This test fails today.
4.6 self-update prints a command that cannot work
From a project with no scope mapping in .npmrc:
Channel: main
Registry: https://registry.npmjs.org/
Available: unknown
Would run: npm install --global @bitsquare/nopy@main
main snapshots exist only on Gitea, and buildSelfUpdateCommand
(nopy.update.ts:378-381) deliberately omits the registry flag when the registry
is npmjs — correct in general, wrong for this combination. The channel is
derived from the running version, so nopy already knows the command is
unrunnable.
Detect channel === 'main' && registry === NPMJS_REGISTRY in the CLI action and
refuse with a line that fixes it:
You are running a main snapshot, which is published to Gitea only, but @bitsquare resolves to npmjs. Re-run with --registry <url>, or set it once: npm config set @bitsquare:registry <url>
Verify. tests/update.test.ts already covers buildSelfUpdateCommand's
registry logic; add the combination case.
5. Cube defects
5.1 GLOBAL_PACKAGES is accepted and then ignored
runtime/nodevm/deploy.py:9 reads GLOBAL_PACKAGES off host.data and never
uses it; :49 hardcodes the list. The field run passed
GLOBAL_PACKAGES=npm-check-updates, watched it appear in the plan and on the
command line, and found it absent from npm ls -g.
"npm install -g pm2 yarn local-web-server node-gyp inquirer execa @dotenvx/dotenvx"
Fix both halves so behaviour does not change for anyone who never set the variable — use the parameter in the deploy, and make the manifest default the list that is hardcoded today:
f"npm install -g {GLOBAL_PACKAGES}"
GLOBAL_PACKAGES: z.string()
.describe('Space-separated list of global npm packages to install')
.default('pm2 yarn local-web-server node-gyp inquirer execa @dotenvx/dotenvx'),
The current default (npm-check-updates) is not what the cube installs, so
today's default is wrong in both directions.
5.2 runtime/nodevm/README.md describes a different cube (audit §5.3)
| README says | Manifest / deploy.py |
|---|---|
"This cube currently has no configurable parameters" (:44) |
VERSION, USER, ALIAS, GLOBAL_PACKAGES, and SHELL after §5.3 |
"official NodeSource setup script" (:22) |
nvm — deploy.py:34-37 |
"Installs the latest LTS version" (:23) |
whatever VERSION says, default v22.20.0 |
"npm@11.1.0" in the global list (:33) |
not installed |
"Node.js is installed system-wide" (:80) |
per-user under ~/.nvm for USER |
Rewrite against the manifest. It is the only file that would tell a reader
VERSION or USER exist. runtime/docker/README.md makes the same
"no configurable parameters" claim with a DISTRO field — same fix, same commit.
5.3 runtime:nodevm has an undeclared shell dependency — add a SHELL parameter
dependencies: () => [], but deploy.py runs omf install nvm (:35) and sets
_shell_executable='/usr/bin/fish' (:43) — it needs fish and Oh My Fish
already installed for USER. In the field run it worked only because user:add
ran first and installs both. Declaring user:add as a dependency would be wrong:
it would create a user that is usually meant to already exist.
Fix. Make the shell a parameter — SHELL: 'fish' | 'bash' — so the cube can
be standalone, as its manifest already claims, without taking fish away from
anyone using it today.
SHELL: z
.enum(['fish', 'bash'])
.describe('Login shell to install through. fish needs Oh My Fish; bash needs nothing')
.default('fish'),
Default stays fish, deliberately. nvm wires itself into whichever shell
installed it, so switching the default would leave an existing user — whose login
shell user:add set to fish — with node installed and invisible. Additive
change; the escape hatch for a fresh host is one variable.
Three places differ, and only three:
fish |
bash |
|
|---|---|---|
_shell_executable |
/usr/bin/fish |
/bin/bash |
| Loading nvm | omf install nvm — the plugin defines nvm as a fish function that every login shell loads |
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" |
Making npm reachable |
the plugin activates the default alias on load |
nvm use <ALIAS> first |
The bash arm has one non-obvious constraint: every entry in commands is its
own shell, so sourcing nvm.sh and using nvm have to be a single entry.
Sourcing cannot be skipped either — nvm's installer appends to ~/.bashrc, and
Ubuntu's ~/.bashrc returns at line 1 for a non-interactive shell, so the hook
never runs under su -c. fish has no equivalent problem, which is presumably why
it was chosen.
That same constraint makes set -gx NVM_DIR $HOME/.nvm (:38) dead today —
its own shell, exported, exits. Drop it; the fish plugin sets NVM_DIR itself.
Guard. With SHELL: 'fish' on a host without fish, fail early and legibly
rather than inside omf:
if SHELL == 'fish' and not host.get_fact(Which, 'fish'):
raise DeployError(
f'runtime:nodevm: SHELL is "fish" but fish is not installed for {USER}. '
'Run user:add first, or set SHELL=bash.'
)
Deliberately checks the binary only. Oh My Fish is a set of fish functions with
no binary to probe and no fixed path, so a check for it would be guesswork; if
fish is present and omf is not, omf install nvm fails with its own clear
message. Half a guard that is certain beats a whole one that is not.
While in the file — deploy.py:1-6 imports npm and python and never uses
them, and assigns hasNode = host.get_fact(Which, 'node'), also unused. The
Which import stops being dead the moment the guard lands.
Verify. No test harness reaches a cube deploy script, so this is acceptance,
not unit: §7.3 runs runtime:nodevm with
SHELL=bash on a fresh VM where user:add has not run, and confirms
node -v and the GLOBAL_PACKAGES list for USER. That is the case the cube has
never survived.
5.4 VERSION accepts null and would install None
z.nullable(z.string()).default('v22.20.0'), and deploy.py:36 interpolates it
straight into nvm install {VERSION}. Either drop nullable, or handle None
as "latest LTS" — which is what the README claims the cube does anyway.
5.5 user/add/README.md — trim, do not rewrite
Every claim it makes was verified true in the field run, and its notes on why
PUBKEY has no default and why the generated password was removed are the best
documentation in the repo. Its last ~50 lines are generic Fish keybinding tips
(Ctrl+L → clear the terminal) unrelated to the cube. Move them somewhere they
belong or delete them; leave the rest alone.
6. Documentation sweep
Small, mechanical, no code.
| File | Change |
|---|---|
docs/VAGRANT.md |
Never states the @vagrant/<name> host syntax — the field run inferred it from an unrelated @docker/ example. Add "hosts": ["@vagrant/nopytestvm"] and one line tying the VM name to it. Add vagrant destroy -f for cleanup. |
README.md (root) |
Lists a cubes/ directory at the repo root that no longer exists (:10); describes typecheck as tsc --build --noEmit (:31), which TS rejects outright for a project with references. |
packages/nopy/README.md |
Top-level --help lists only -V/-h, then the Examples block uses -R, -n, -P, -l, -s — all of which live on install. Either add a "these are install options" line to the help text (nopy.cli.ts:56-76) or promote the common ones. |
packages/nopy/README.md |
The host picker offers docker, vagrant, @vagrant/…, custom; the first two appear in no document. Add the two connector shortcuts and what they prompt for. |
docs/SESSION_FORMAT.md |
Uses .session.json throughout while the README uses .nopysession.json. Pick one — .nopysession.json — and align both, together with §3.4. |
Also worth stating once, somewhere prominent: the accuracy failures cluster on
one seam. Everything a human reads on screen matched the docs; everything
machine-facing had drifted — --json, the session format, -s on replay, -l
and history, nodevm's parameters, the install command. That is not random rot,
it is the interactive surface being maintained by daily use while the scripting
surface was documented from intent. Phases 2 and 3 are the correction, in the two
ways available: --json was documented from intent and never built, so it goes;
the rest was built and then drifted, so it gets fixed. Keeping it corrected means
what remains of the scripting surface — --print-only, sessions, history —
needs tests that assert on stdout, not prose.
7. Harness fix and acceptance run
7.1 Fix the PTY driver before it lies again: drive.py and expect.py must
issue TIOCSWINSZ after pty.fork().
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
Worth keeping the drivers — they are the only way to test the TUI end to end —
so they belong in the repo under scripts/, not in a temp folder.
7.2 Add a 0-rows regression test that exercises §4.4 directly: spawn the CLI
under a 0×0 PTY and assert the form still yields values. It has to be a real
child process for the same reason cubes.resolve-hook.test.ts does — inside a
vitest worker there is no TTY to misreport.
7.3 Acceptance: re-run the field scenario from an empty directory —
vagrant up, install the bundle with the §4.1
command, deploy user:add and runtime:nodevm interactively (not from a
hand-written session), then check:
npm ls -gcontains whatGLOBAL_PACKAGESasked for (§5.1)- on a second, fresh VM where
user:addhas not run,runtime:nodevmwithSHELL=bashinstalls node and the global packages; withSHELL=fishit fails in one line naming the missing shell rather than insideomf(§5.3) nopy install -P 2>/dev/nullprints the deploy commands and nothing else — no banner, no progress lines, no update hint (§2.3)nopy install --jsonis rejected as an unknown option (§2.1)nopy historylists the-lrun (§3.2)- a dry run with a secret under
envprints********on every cube (§1.1) nopyin an unconfigured directory prints one line (§4.3)
What the run found
Every check above passed. One deviation and three findings.
Deviation. The bundle was installed from pnpm pack tarballs of the three
packages rather than from Gitea, because the cube fixes this plan makes are not
in any published snapshot and publishing one means pushing to main. The install
still goes through cubePackages → node_modules → <root>/cubes, which is the
part §4.1 is about; what it does not exercise is the registry and dist-tag half
of the documented command.
Two VMs, as specified: the first got user:add then runtime:nodevm with
SHELL=fish, the second (destroyed and recreated, no fish, no user:add) got
runtime:nodevm alone under both shells. Driven through scripts/expect.py, so
the interactive path is what was exercised.
Findings, all recorded in DOCS-AUDIT.md:
- §6.8 —
--print-onlywas recorded in history where--dry-runwas not, so a-Ppass displaced the last real deployment at the head of what-Rrepeats. Fixed. - §6.9 — picking
user:addandruntime:nodevmtogether resolves nodevm first. The first write-up blamed the ordering and was wrong: emission is already post-order overdependencies(), so a declared edge wins over list order whichever way round the two were listed, and a test now pins that. What list order decides is where a cube with no edge lands — andruntime:nodevmdeclares none, deliberately, becauseuser:addcreates a user. §5.3'sDeployErroris the guard for that pair; the acceptance run used two invocations. - §6.10 —
runtime:nodevminstalled apt packages without refreshing the index, which only surfaced once §5.3 let the cube run on a box whereapt:essentialshad not. Fixed.
Suggested order
- Phase 1 — the leak. Security, and the fix is contained.
- Phase 4.3–4.5 — the error boundary, the terminal proxy, and the completeness check. Small, and they stop a silently wrong deployment.
- Phase 2 — remove
--json. Mostly deletion, and it settles what the scripting surface is before phase 6 documents it. - Phase 6 + 4.1 + 4.2 — documentation. No code, immediate payoff for the next new user.
- Phase 5 — cubes. Independent of everything above; ships with the bundle, not the CLI.
- Phase 3 — session and replay. Largest surface, lowest severity.
- Phase 7 — harness and acceptance, last, so it exercises all of it.