hardening and bugfixing prior to stable release

This commit is contained in:
Benjamin Diedrichsen
2026-07-31 18:21:43 +02:00
parent ac7ea07e3c
commit 0aa0be5542
44 changed files with 3016 additions and 436 deletions
+58 -24
View File
@@ -7,6 +7,7 @@ import type { Cube, CubeVariables, HookContext } from '@bitsquare/nopy-cubes';
import { getLogger } from '@logtape/logtape';
import type { Variables } from '../nopy.common.js';
import type { NopyConfig } from '../nopy.config.js';
import { NopyUsageError } from '../nopy.errors.js';
import type { DeployCall } from '../nopy.executor.js';
import { VariableAssignment } from '../nopy.prompts.js';
import type { CubeSession, NopySession } from '../nopy.session.js';
@@ -45,21 +46,33 @@ export class BuildContext {
}
/**
* Fails a non-interactive run that cannot fill a required variable.
* Fails a run that cannot fill a required variable.
*
* Without this the cube would be deployed with the key simply absent from
* `--data`, and the deploy script would read `None` off `host.data`.
* `--data`, and the deploy script would read `None` off `host.data` — against
* the documented guarantee that every schema key reaches it.
*
* Runs on the interactive path too, not only under `--use-defaults`. A prompt
* is not proof of an answer: a terminal that misreports its size renders an
* empty form and submits `{}` without the user seeing a field, which is
* exactly how this was found.
*/
private assertVariablesComplete(cube: Cube): void {
const missing = this.missingRequired(cube);
if (missing.length === 0) return;
const [one, them] =
missing.length === 1 ? ['has no default value', 'it'] : ['have no default values', 'them'];
throw new Error(
`Cube "${cube.id}" cannot run with --use-defaults: ${missing.join(', ')} ${one}. ` +
`Set ${them} under "env" in .nopyrc.json, pass ${them} from a dependency, ` +
'or drop --use-defaults to be prompted.'
const list = missing.join(', ');
const them = missing.length === 1 ? 'it' : 'them';
const have = missing.length === 1 ? 'has no default value' : 'have no default values';
throw new NopyUsageError(
this.options.useDefaults
? `Cube "${cube.id}" cannot run with --use-defaults: ${list} ${have}. ` +
`Set ${them} under "env" in .nopyrc.json, pass ${them} from a dependency, ` +
'or drop --use-defaults to be prompted.'
: `Cube "${cube.id}" is missing ${list}. Nothing supplied ${them} — the form may have ` +
`been submitted empty. Re-run and fill ${them} in, or set ${them} under "env" ` +
'in .nopyrc.json.'
);
}
@@ -81,23 +94,39 @@ export class BuildContext {
if (gaps.length === 0) return;
if (this.options.useDefaults) {
throw new Error(
`Cube "${cube.id}" cannot be replayed with --use-defaults: ${gaps.join(', ')} ` +
'would have to be entered. Secrets are never recorded in a session. ' +
'Replay without --use-defaults, or set the values under "env" in .nopyrc.json.'
);
// A gap is only a gap if nothing outside the session filled it. `env` and
// `param` both say deliberately what the value is, which is exactly what
// the old message told the user to do — and then failed anyway.
//
// `default` is not accepted here. The session dropped the secret on
// purpose, so falling through to a manifest default would deploy a
// different credential than the run being replayed, without saying so.
const unsatisfied = gaps.filter((key) => {
const origin = this.variables.of(cube.id, key)?.origin;
return origin !== 'env' && origin !== 'param';
});
if (unsatisfied.length > 0) {
const them = unsatisfied.length === 1 ? 'it' : 'them';
const secret = unsatisfied.some((key) => cube.secrets.includes(key));
throw new NopyUsageError(
`Cube "${cube.id}" cannot be replayed with --use-defaults: ` +
`${unsatisfied.join(', ')} would have to be entered. ` +
(secret ? 'Secrets are never recorded in a session. ' : '') +
`Set ${them} under "env" in .nopyrc.json` +
(secret ? ' (a schema default is not accepted for a secret)' : '') +
`, pass ${them} from a dependency, or replay without --use-defaults.`
);
}
return;
}
log.debug('Filling session gaps', { cubeId: cube.id, gaps });
await VariableAssignment(cube, this.variables, { keys: gaps });
// A cancelled form leaves the run short of a value it cannot invent.
const stillMissing = this.missingRequired(cube);
if (stillMissing.length > 0) {
throw new Error(
`Cube "${cube.id}" is missing ${stillMissing.join(', ')} and cannot be deployed.`
);
}
// A form that resolved is not a form that was answered — same check, and
// the same reason for it, as the interactive path.
this.assertVariablesComplete(cube);
}
/**
@@ -110,15 +139,19 @@ export class BuildContext {
): Promise<void> {
const cube = this.allCubes[cubeId];
if (!cube) {
throw new Error(`Cube not found: ${cubeId}`);
throw new NopyUsageError(`Cube not found: ${cubeId}`);
}
log.debug('Resolving cube', { cubeId, host });
// 1. Declare secrets, then assign overrides and defaults. Declaring first
// means even the config `env` seeded on the cube's first assignment is
// already marked, so nothing reaches a session or a log unredacted.
// 1. Declare secrets and schema, then assign overrides and defaults. Both
// declarations have to come first: the cube's first assignment is what
// seeds the config `env` onto it, and by then it must already be known
// which of those keys are secret (so nothing reaches a session or a log
// unredacted) and which the cube actually declares (so a secret it does
// not declare is never seeded at all).
this.variables.declareSecrets(cubeId, cube.secrets);
this.variables.declareSchema(cubeId, cube.schemaKeys());
if (Object.keys(overrides).length > 0) {
this.variables.assign(cubeId, 'param', overrides);
}
@@ -136,6 +169,7 @@ export class BuildContext {
this.assertVariablesComplete(cube);
} else {
await VariableAssignment(cube, this.variables);
this.assertVariablesComplete(cube);
}
const currentVars = this.variables.get(cubeId);
+19 -24
View File
@@ -8,6 +8,7 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { loadConfig } from './nopy.config.js';
import { reportError } from './nopy.errors.js';
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
import {
clearHistory,
@@ -33,8 +34,8 @@ const { version, buildInfo } = createRequire(import.meta.url)('../package.json')
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
/**
* Prints the update hint to stderr, so it never lands in `--json` output or in
* a `--print-only` command list being piped somewhere.
* Prints the update hint to stderr, so it never lands in a `--print-only`
* command list being piped somewhere.
*/
async function printUpdateNotice(): Promise<void> {
const notice = await updateNotice({ currentVersion: version });
@@ -67,6 +68,9 @@ Examples:
$ nopy history List all saved sessions
$ nopy clear-history Clear session history
Every flag above belongs to 'install', the default command — 'nopy -R' is
'nopy install -R'. Run 'nopy install --help' for the full list.
Session Replay:
Sessions are automatically saved to history after each deployment.
Use 'nopy history' to see available sessions and their IDs.
@@ -88,16 +92,19 @@ program
.option('-n, --dry-run', 'Show execution plan without running')
.option('-P, --print-only', 'Print deploy commands and exit (no execution)')
.option('-c, --continue-on-error', 'Continue executing after failures')
.option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history')
.action(async (options) => {
await printUpdateNotice();
// Loaded lazily so that --help/--version work outside a configured project.
const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
try {
// Loaded lazily so that --help/--version work outside a configured
// project — and inside the try, so that "no .nopyrc.json here" is
// reported by `reportError` rather than escaping as an unhandled
// rejection and printing node's own stack. It is the likeliest first-run
// mistake there is.
const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
// Handle session replay
const loadSessionPath = options.loadSession;
let sessionToReplay: { session: import('./nopy.session.js').NopySession } | undefined;
@@ -109,7 +116,9 @@ program
process.exit(1);
}
sessionToReplay = lastEntry;
console.log(`Repeating: ${lastEntry.name}\n`);
// stderr, like everything nopy says about itself — `-R --print-only` has
// to leave stdout to the commands.
console.error(`Repeating: ${lastEntry.name}\n`);
} else if (options.history) {
const entry = getSessionById(options.history);
if (!entry) {
@@ -118,7 +127,7 @@ program
process.exit(1);
}
sessionToReplay = entry;
console.log(`Running: ${entry.name}\n`);
console.error(`Running: ${entry.name}\n`);
}
const result = await nopy({
@@ -130,7 +139,6 @@ program
dryRun: options.dryRun,
printOnly: options.printOnly,
continueOnError,
jsonOutput: options.json,
saveToHistory: options.history !== false && !options.dryRun,
});
@@ -144,20 +152,7 @@ program
// the process-level handler.
if (isCancellation(error)) exitWithFarewell();
if (options.json) {
console.log(
JSON.stringify(
{
success: false,
error: error instanceof Error ? error.message : String(error),
},
null,
2
)
);
} else {
console.error('Error:', error instanceof Error ? error.message : error, error);
}
reportError(error);
process.exit(1);
}
});
+62 -2
View File
@@ -114,8 +114,21 @@ export class Variable {
export class Variables {
private readonly store: Record<string, Record<string, Variable>> = {};
private readonly secrets: Record<string, Set<string>> = {};
private readonly schemas: Record<string, Set<string>> = {};
private readonly globalSecrets: Set<string>;
constructor(readonly env: TVariables = {}) {}
/**
* @param env - the `env` block of the merged config, seeded onto every cube
* @param globalSecrets - every key *any* manifest declares secret, plus the
* config's own `secrets` list. Known up front, before the first cube
* resolves, so it does not depend on resolution order.
*/
constructor(
readonly env: TVariables = {},
globalSecrets: Iterable<string> = []
) {
this.globalSecrets = new Set(globalSecrets);
}
/**
* Marks keys of one cube as holding secrets.
@@ -132,8 +145,30 @@ export class Variables {
}
}
/**
* Records which keys a cube's schema declares.
*
* Only {@link bucket} reads this, and only to decide whether a globally
* declared secret may be seeded from `env`. Call it before anything assigns to
* the cube — it deliberately does not create the bucket itself, because
* creating it is what seeds `env`.
*/
declareSchema(cube: string, keys: readonly string[]): void {
this.schemas[cube] ??= new Set<string>();
const declared = this.schemas[cube];
for (const key of keys) declared.add(key);
}
/**
* Whether a key is sensitive for a cube.
*
* True for a key the cube's own manifest declared, and also for one *another*
* manifest declared: a value that is a secret anywhere is a secret everywhere
* it lands. That covers the manifest that lists `PASSWORD` in `schema` and
* forgets it in `secrets`.
*/
isSecret(cube: string, name: string): boolean {
return this.secrets[cube]?.has(name) ?? false;
return (this.secrets[cube]?.has(name) ?? false) || this.globalSecrets.has(name);
}
/** Records values for one cube, all at the same origin. */
@@ -176,6 +211,24 @@ export class Variables {
return values;
}
/**
* The config's `env` block minus anything declared secret — what a session's
* own `env` records.
*
* A session copies `env` verbatim for reference, which quietly undid
* {@link persistable}: a credential declared in `.nopyrc.json` was kept out of
* every cube's `variables` and then written to the same file one key higher up,
* in plaintext, along with a copy in `.nopy.history.json`. Same rule as
* `persistable`, applied to the same file.
*/
persistableEnv(): TVariables {
const values: TVariables = {};
for (const [name, value] of Object.entries(this.env)) {
if (!this.globalSecrets.has(name)) values[name] = value;
}
return values;
}
private create(cube: string, name: string, first: Assignment): Variable {
const variable = new Variable(cube, name, first);
variable.redacted = this.isSecret(cube, name);
@@ -189,6 +242,12 @@ export class Variables {
* rather than a parallel bag merged in at read time. That is what lets it
* carry an origin, show up in the trace, and lose to a prompt by the same rule
* as everything else.
*
* One key is held back: a **secret**, on a cube whose schema does not mention
* it. Broadcasting is otherwise load-bearing — a cube may legitimately read a
* key off `host.data` that it never declared — but a credential does not
* belong on the command line of every unrelated cube in the run, where nothing
* masks it because that cube never declared it sensitive.
*/
private bucket(cube: string): Record<string, Variable> {
const existing = this.store[cube];
@@ -197,6 +256,7 @@ export class Variables {
const bucket: Record<string, Variable> = {};
this.store[cube] = bucket;
for (const [name, value] of Object.entries(this.env)) {
if (this.globalSecrets.has(name) && !this.schemas[cube]?.has(name)) continue;
bucket[name] = this.create(cube, name, { value, origin: 'env' });
}
return bucket;
+11 -2
View File
@@ -6,6 +6,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { TVariables } from './nopy.common.js';
import { NopyUsageError } from './nopy.errors.js';
/**
* Log verbosity levels for pyinfra output
@@ -102,6 +103,14 @@ export interface NopyConfig {
cubePackages: CubePackageRef[];
/** Global environment variables */
env: TVariables;
/**
* `env` keys to treat as sensitive even though no manifest says so.
*
* A manifest's own `secrets` list already covers the cubes that declare the
* key. This is for the value no cube declares at all — a token a hook reads,
* say — which would otherwise be broadcast and printed in the clear.
*/
secrets?: string[];
/** Logging configuration */
log?: LogConfig;
/** Session history configuration */
@@ -317,7 +326,7 @@ export function loadConfig(): NopyConfig {
const configPaths = findConfigFiles();
if (configPaths.length === 0) {
throw new Error(
throw new NopyUsageError(
`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`
);
}
@@ -334,7 +343,7 @@ export function loadConfig(): NopyConfig {
config = mergeConfigs(config, resolvedConfig);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to load config ${configPath}: ${message}`);
throw new NopyUsageError(`Failed to load config ${configPath}: ${message}`);
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* The errors that are the user's to fix.
* @module nopy.errors
*/
/**
* A run that failed for a reason the user can act on: no config file, a cube
* that does not exist, a required variable nothing supplied, a session file
* that will not load.
*
* The point is the *presentation*, not the control flow — nothing catches this
* to recover. A stack trace through `dist/` says nothing useful about a missing
* `.nopyrc.json`, and printing one invites the reader to look for a bug in nopy
* instead of a typo in their project. The CLI prints the message alone and keeps
* the stack behind `NOPY_DEBUG`.
*
* Mirrors keyman's `UsageError` deliberately: the two CLIs are kept in step on
* how they fail for the same reason their update modules are duplicated rather
* than shared.
*/
export class NopyUsageError extends Error {
constructor(message: string) {
super(message);
this.name = 'NopyUsageError';
}
}
/**
* Reports a failed run in as many lines as it deserves.
*
* A {@link NopyUsageError} prints as one line: it is something the reader can
* fix, and three frames into `dist/` say nothing about a missing `.nopyrc.json`
* except that it looks like a crash in nopy rather than a typo in the project.
* Everything else keeps its stack, because an unexpected failure is exactly when
* one is worth having. `NOPY_DEBUG` forces it for both.
*
* Lives here rather than in `nopy.cli.ts` because the CLI is excluded from
* coverage — it is argv wiring, and this is a decision.
*/
export function reportError(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
const stack = error instanceof Error ? error.stack : undefined;
const wanted = process.env.NOPY_DEBUG || !(error instanceof NopyUsageError);
if (wanted && stack) console.error(stack);
else if (!process.env.NOPY_DEBUG) console.error('Set NOPY_DEBUG=1 for the full stack trace.');
}
+1 -13
View File
@@ -143,20 +143,8 @@ async function executeCall(call: DeployCall): Promise<ExecutionResult> {
* Outputs the execution plan without running (dry run)
*
* @param calls - Array of deployment calls
* @param asJson - Output as JSON instead of text
*/
export function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void {
if (asJson) {
const plan = calls.map((call) => ({
cube: call.cube,
host: call.host,
command: maskCommand(call),
variables: maskVariables(call),
}));
console.log(JSON.stringify({ plan }, null, 2));
return;
}
export function outputExecutionPlan(calls: DeployCall[]): void {
console.log('\n=== Execution Plan (Dry Run) ===\n');
for (let i = 0; i < calls.length; i++) {
+1 -1
View File
@@ -74,7 +74,7 @@ export function restoreTerminal(): void {
* Says goodbye and leaves.
*
* The farewell goes to **stderr**, for the same reason the update hint does:
* `--json` and `--print-only` stay machine-readable no matter how the run ends.
* `--print-only` stays machine-readable no matter how the run ends.
*
* `process.exit` rather than letting the loop drain, because the prompt that
* was cancelled is still holding stdin — after the teardown above threw, its
+2 -31
View File
@@ -5,7 +5,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { NopySession } from './nopy.session.js';
import { describeSession, type NopySession } from './nopy.session.js';
/** Default number of sessions to keep in history */
export const DEFAULT_HISTORY_SIZE = 10;
@@ -72,35 +72,6 @@ export function saveHistory(history: SessionHistory): void {
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
}
/**
* Generates a history entry name from session data
*
* Format: "YYYY-MM-DD HH:mm - cube1, cube2, ..."
*
* @param session - The session to name
* @param timestamp - ISO timestamp
* @returns Human-readable name
*/
function generateEntryName(session: NopySession, timestamp: string): string {
const date = new Date(timestamp);
const dateStr = date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const cubeNames = session.cubes.map((c) => c.key).join(', ');
const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames;
const hosts = session.hosts?.join(', ') || 'no host';
const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts;
return `${dateStr} - ${truncatedCubes} → ${truncatedHosts}`;
}
/**
* Generates a unique ID for a history entry
*/
@@ -124,7 +95,7 @@ export function addToHistory(
const entry: HistoryEntry = {
id: generateEntryId(),
name: generateEntryName(session, timestamp),
name: describeSession(session, timestamp),
timestamp,
session,
};
+52 -18
View File
@@ -15,11 +15,16 @@ import {
summarizeResults,
} from './nopy.executor.js';
import { addToHistory, DEFAULT_HISTORY_SIZE } from './nopy.history.js';
import { type NopySession, saveSession } from './nopy.session.js';
import { describeSession, type NopySession, SESSION_VERSION, saveSession } from './nopy.session.js';
import { runWorkflow } from './nopy.workflow.js';
/**
* Configures the logtape logger for console output
* Configures the logtape logger for console output.
*
* **stderr**, deliberately. stdout carries the deploy commands and pyinfra's own
* output; everything nopy says about itself goes to stderr, so `--print-only`
* can be piped somewhere. The sink used to write to stdout and was held back
* only by `--json`, which never worked and is gone.
*/
function configureLogtape(): void {
configure({
@@ -31,7 +36,7 @@ function configureLogtape(): void {
if (typeof formatted === 'string') {
const msg = formatted.replace(/\r?\n$/, '');
const props = record.properties as Record<string, unknown>;
console.log(msg, ...Object.values(props));
console.error(msg, ...Object.values(props));
}
};
})(),
@@ -55,7 +60,7 @@ function configureLogtape(): void {
configureLogtape();
/**
* Prints the active configuration summary
* Prints the active configuration summary — to stderr, see {@link configureLogtape}.
*/
function printActiveConfig(
config: import('./nopy.config.js').NopyConfig,
@@ -92,7 +97,7 @@ function printActiveConfig(
}
lines.push('');
console.log(lines.join('\n'));
console.error(lines.join('\n'));
}
/**
@@ -107,7 +112,6 @@ export interface NopyOptions {
dryRun?: boolean;
printOnly?: boolean;
continueOnError?: boolean;
jsonOutput?: boolean;
saveToHistory?: boolean;
}
@@ -138,24 +142,30 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
dryRun = false,
printOnly = false,
continueOnError = false,
jsonOutput = false,
saveToHistory = true,
} = opts;
const log = getLogger(['nopy']);
const config = loadConfig();
if (!jsonOutput && !replaySession && !loadSessionPath) {
if (!replaySession && !loadSessionPath) {
printActiveConfig(config, { continueOnError });
}
const { cubes, errors } = await loadCubes();
const variables = new Variables(config.env);
// Every key any manifest calls a secret, plus the config's own list. Computed
// before the first cube resolves, so which cube happens to run first cannot
// change whether a credential is treated as one.
const declaredSecrets = new Set([
...Object.values(cubes).flatMap((cube) => cube.secrets),
...(config.secrets ?? []),
]);
const variables = new Variables(config.env, declaredSecrets);
if (errors.length > 0) {
log.error('Errors found during cube loading:');
for (const error of errors) log.error(error);
if (jsonOutput) console.log(JSON.stringify({ success: false, errors }, null, 2));
return undefined;
}
@@ -180,7 +190,7 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
},
{
useDefaults,
isSessionReplay: workflow.isReplay,
isSessionReplay: workflow.replaySource !== undefined,
}
);
@@ -190,17 +200,43 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
}
}
// The default name needs the resolved cube list, which does not exist until
// the build has run — so it is filled in here rather than in `createSession`,
// and only when nothing supplied one. `version` sits before the spread so that
// a replayed session keeps whatever its file declared; a hand-written session
// that declared none of the three gets all three.
const timestamp = workflow.session.timestamp ?? new Date().toISOString();
const sessionForSaving: NopySession = {
version: SESSION_VERSION,
...workflow.session,
timestamp,
cubes: context.cubeSessions,
env: config.env,
// Not `config.env` — a declared secret in there would be written to the
// session file in plaintext, one key above the `variables` it was carefully
// kept out of.
env: variables.persistableEnv(),
};
sessionForSaving.name ??= describeSession(sessionForSaving, timestamp);
if (saveSessionPath && !workflow.isReplay) {
// Saved on a replay too: the resolved cube set is exactly what was asked for,
// and a session written from a replay is no less valid than one written from a
// fresh run. The old `!isReplay` guard made `nopy install -R -s out.json` exit
// 0 having written nothing.
if (saveSessionPath) {
saveSession(sessionForSaving, saveSessionPath);
}
if (saveToHistory && !dryRun && !workflow.isReplay && context.deployCalls.length > 0) {
// A `-R`/`-H` replay is already in history and re-recording it would push the
// original out of the list. A `--load-session` run is not in history at all,
// so unless it is recorded here, `nopy history` reports nothing afterwards and
// `-R` has nothing to repeat.
const recordable = workflow.replaySource !== 'history';
// `--print-only` is excluded for the same reason `--dry-run` is: neither
// deployed anything, and history is what `-R` repeats. Recording a run that
// never happened made `nopy install -P` — the safe look-before-you-leap flag —
// silently displace the last real deployment at the head of the list.
if (saveToHistory && !dryRun && !printOnly && recordable && context.deployCalls.length > 0) {
const historySize = config.history?.maxSessions ?? DEFAULT_HISTORY_SIZE;
if (config.history?.autoSave !== false) {
addToHistory(sessionForSaving, historySize);
@@ -224,10 +260,8 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
dryRun,
continueOnError,
onProgress: (result, completed, total) => {
if (!jsonOutput) {
const status = result.success ? '✓' : '✗';
log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`);
}
const status = result.success ? '✓' : '✗';
log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`);
},
});
+58 -18
View File
@@ -17,6 +17,45 @@ interface CubeChoice {
message: string;
}
/** Floor for a terminal that reports a size no prompt could render into. */
const MIN_ROWS = 24;
const MIN_COLS = 80;
/**
* The window size to hand an enquirer prompt, never smaller than {@link MIN_ROWS}.
*
* Load-bearing, not cosmetic. enquirer derives how many choices are visible from
* its height, and `utils.height` (`lib/utils.js:80`) computes a sane fallback and
* then throws it away:
*
* ```js
* let rows = (stream && stream.rows) ? stream.rows : fallback;
* if (stream && typeof stream.getWindowSize === 'function') {
* rows = stream.getWindowSize()[1]; // unconditional
* }
* ```
*
* A TTY always has `getWindowSize`, so a terminal reporting 0 rows — some CI
* pseudo-terminals, `script -q`, an editor terminal mid-startup — yields
* `height: 0`, `Math.min(limit, 0)` choices, and a form that renders nothing and
* submits `{}`. Passing `rows` bypasses that: `prompt.js:396` reads
* `this.options.rows || utils.height(...)`, so the broken function never runs.
*
* Measured on a 0×0 pty: without this the four-field form returns `{}`; with it,
* every field. No effect on a terminal that reports its size honestly.
* enquirer 2.4.1 is its final release, so the bug is not going to be fixed
* upstream.
*/
function terminalSize(out: NodeJS.WriteStream = process.stdout): {
rows: number;
columns: number;
} {
return {
rows: Math.max(out.rows || 0, MIN_ROWS),
columns: Math.max(out.columns || 0, MIN_COLS),
};
}
/**
* Fuzzy-filters the cube list against what the user has typed so far.
*
@@ -52,8 +91,8 @@ export async function CubeSelection(
// Clear terminal and move cursor to top
process.stdout.write('\x1B[2J\x1B[0f');
const terminalHeight = process.stdout.rows || 24;
const pageSize = Math.max(10, terminalHeight - 5);
const size = terminalSize();
const pageSize = Math.max(10, size.rows - 5);
console.log('\n Cube Selection\n');
console.log(' Type to filter • Space to select • Enter to confirm\n');
@@ -65,14 +104,15 @@ export async function CubeSelection(
multiple: true,
choices: cubeChoices,
suggest: suggestCubes,
...size,
});
try {
return { selectedCubes: await prompt.run() };
} catch {
// User cancelled
return { selectedCubes: [] };
}
// Deliberately no catch. Swallowing a cancellation here used to return an
// empty selection, which is indistinguishable from "the user picked nothing"
// and let the run carry on to deploy zero cubes. Both ways out now travel:
// a cancellation to `isCancellation` at the CLI boundary, anything else as
// the failure it is.
return { selectedCubes: await prompt.run() };
}
export async function AuthSelection(useAuthKey?: boolean): Promise<{
@@ -239,17 +279,17 @@ export async function VariableAssignment<S extends AnyObjectSchema>(
name: 'variables',
message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`,
choices,
...terminalSize(),
});
try {
const result = await form.run();
const coercedResult: Record<string, any> = {};
for (const [key, value] of Object.entries(result)) {
const zodType = schema[key];
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
}
variables.assign(cube.id, 'prompt', coercedResult);
} catch {
// User cancelled
// Deliberately no catch — see `CubeSelection`. A cancelled form used to be
// swallowed here, leaving the cube short of values only the user could give
// and the run continuing as though the form had succeeded.
const result = await form.run();
const coercedResult: Record<string, any> = {};
for (const [key, value] of Object.entries(result)) {
const zodType = schema[key];
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
}
variables.assign(cube.id, 'prompt', coercedResult);
}
+85 -7
View File
@@ -6,6 +6,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { TVariables } from './nopy.common.js';
import { NopyUsageError } from './nopy.errors.js';
/**
* Primitive value types that can be stored in session variables
@@ -31,7 +32,13 @@ export interface CubeSession {
* Authentication configuration for a session
*/
export interface AuthSession {
/** Authentication method */
/**
* Authentication method.
*
* `ssh` is not a third kind of credential — it means the connector owns
* authentication and nopy supplies none. It is what an `@vagrant/` or
* `@docker/` host gets, and nothing prompts for it.
*/
method: 'ssh-key' | 'password' | 'ssh';
/** Username for authentication (password auth only) */
username?: string;
@@ -40,8 +47,20 @@ export interface AuthSession {
/**
* Complete session configuration
*
* Everything but `cubes` and `auth` is optional, because a hand-written session
* is a first-class one — the loader requires exactly what it cannot work without.
* `version`, `timestamp` and `name` are stamped on every session nopy writes and
* never demanded of one it reads.
*/
export interface NopySession {
/**
* Format version of the file. Absent on every session written before this was
* stamped, and on most hand-written ones.
*/
version?: string;
/** ISO 8601 time the session was created */
timestamp?: string;
/** Optional session name */
name?: string;
/** Array of cube configurations */
@@ -54,6 +73,40 @@ export interface NopySession {
env?: TVariables;
}
/**
* The format version stamped into every session nopy writes.
*
* There is one, and nothing yet reads it to decide anything — it exists so that
* a future change to the shape can tell an old file from a new one, which is
* impossible after the fact.
*/
export const SESSION_VERSION = '1.0.0';
/**
* A one-line description of a session: `YYYY-MM-DD HH:mm - cubes → hosts`.
*
* Shared with the history list, which is where the format comes from — the two
* name the same thing and there is no reason for them to disagree.
*/
export function describeSession(session: NopySession, timestamp: string): string {
const dateStr = new Date(timestamp).toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const cubeNames = session.cubes.map((c) => c.key).join(', ');
const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames;
const hosts = session.hosts?.join(', ') || 'no host';
const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts;
return `${dateStr} - ${truncatedCubes} → ${truncatedHosts}`;
}
/**
* Saves a session to a JSON file
*
@@ -129,7 +182,7 @@ function loadSessionFromJSON(filePath: string): NopySession {
*/
export async function loadSession(filePath: string): Promise<NopySession> {
if (!fs.existsSync(filePath)) {
throw new Error(`Session file not found: ${filePath}`);
throw new NopyUsageError(`Session file not found: ${filePath}`);
}
const ext = path.extname(filePath);
@@ -140,23 +193,44 @@ export async function loadSession(filePath: string): Promise<NopySession> {
} else if (ext === '.json') {
session = loadSessionFromJSON(filePath);
} else {
throw new Error(`Unsupported session file format: ${ext}. Use .json or .mjs`);
throw new NopyUsageError(`Unsupported session file format: ${ext}. Use .json or .mjs`);
}
// Validate required fields
if (!session.cubes || !Array.isArray(session.cubes)) {
throw new Error('Invalid session format: missing or invalid "cubes" field');
throw new NopyUsageError('Invalid session format: missing or invalid "cubes" field');
}
if (session.hosts && !Array.isArray(session.hosts)) {
throw new Error('Invalid session format: invalid "hosts" field');
throw new NopyUsageError('Invalid session format: invalid "hosts" field');
}
if (!session.auth) {
throw new Error('Invalid session format: missing "auth" field');
throw new NopyUsageError('Invalid session format: missing "auth" field');
}
// A version this build does not know is a warning, never a refusal: the file
// may well still load, and a session is often the only record of a deployment.
// A missing version says nothing at all — it predates the stamp.
if (session.version !== undefined && session.version !== SESSION_VERSION) {
console.error(
`Warning: session "${filePath}" declares version ${session.version}; ` +
`this build writes ${SESSION_VERSION}. Loading it anyway.`
);
}
return session;
}
/**
* Suffixes {@link listSessions} recognises.
*
* `.nopysession.*` is the documented name and the one the README's examples use;
* it was not matched at all, because `wild.nopysession.json` does not end in
* `.session.json` — the dot before `session` is part of the suffix. The shorter
* pair stays recognised: `saveSession` writes whatever path it is given, so
* files under the old name exist and there is no reason to stop finding them.
*/
const SESSION_SUFFIXES = ['.nopysession.json', '.nopysession.mjs', '.session.json', '.session.mjs'];
/**
* Lists all session files in a directory
*
@@ -170,7 +244,7 @@ export function listSessions(dirPath: string = process.cwd()): string[] {
const files = fs.readdirSync(dirPath);
return files
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
.filter((file) => SESSION_SUFFIXES.some((suffix) => file.endsWith(suffix)))
.map((file) => path.join(dirPath, file));
}
@@ -186,8 +260,12 @@ export function createSession(params: {
hosts: string[];
auth: AuthSession;
env?: TVariables;
/** Overrides the creation time; for tests, and for re-stamping a replay. */
timestamp?: string;
}): NopySession {
return {
version: SESSION_VERSION,
timestamp: params.timestamp ?? new Date().toISOString(),
name: params.name,
cubes: params.cubes,
hosts: params.hosts,
+15 -5
View File
@@ -35,8 +35,18 @@ export interface WorkflowResult {
username?: string;
/** Password if applicable */
password?: string;
/** Whether this is a session replay */
isReplay: boolean;
/**
* Where a replayed session came from, or `undefined` for a fresh interactive
* run.
*
* Was a boolean, which conflated two runs that need different treatment: a
* `-R`/`-H` replay is already in history and must not be recorded again, while
* a `--load-session` run is not in history at all — recording it is the only
* way `nopy history` and `-R` can see it afterwards. Everything that merely
* asks "am I replaying?" (reading values back off the session rather than
* prompting) takes `replaySource !== undefined`.
*/
replaySource?: 'file' | 'history';
}
/**
@@ -83,7 +93,7 @@ export async function runInteractiveWorkflow(
authMethod: authResult.authMethod,
username: authResult.username,
password: authResult.password,
isReplay: false,
replaySource: undefined,
};
}
@@ -141,7 +151,7 @@ export async function runReplayWorkflow(
authMethod,
username,
password,
isReplay: true,
replaySource: 'file',
};
}
@@ -196,7 +206,7 @@ export async function runSessionReplayWorkflow(
authMethod,
username,
password,
isReplay: true,
replaySource: 'history',
};
}