hardening and bugfixing prior to stable release
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user