This commit is contained in:
@@ -8,13 +8,23 @@ import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import type { CubePackageRef } from '../nopy.config.js';
|
||||
|
||||
/**
|
||||
* Where a bundle's cubes live when its `package.json` does not say otherwise.
|
||||
*
|
||||
* Convention over configuration: shipping `cubes/` at the package root needs no
|
||||
* `nopy` block at all. `nopy.cubes` remains as an override for the bundle whose
|
||||
* cubes sit somewhere else — one compiled from TypeScript into `dist/cubes`,
|
||||
* say — so the escape hatch survives without every author paying for it.
|
||||
*/
|
||||
const DEFAULT_CUBE_DIRS = ['./cubes'];
|
||||
|
||||
/** An installed cube package, located and validated. */
|
||||
export interface CubePackage {
|
||||
/** The name it was requested under. */
|
||||
name: string;
|
||||
/** Absolute path to the package root. */
|
||||
root: string;
|
||||
/** Absolute paths to its cube directories, from `nopy.cubes`. */
|
||||
/** Absolute paths to its cube directories, from `nopy.cubes` or the default. */
|
||||
dirs: string[];
|
||||
}
|
||||
|
||||
@@ -40,7 +50,8 @@ function findPackageRoot(ref: CubePackageRef): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every named package to its cube directories.
|
||||
* Resolves every named package to its cube directories — {@link DEFAULT_CUBE_DIRS}
|
||||
* unless its `package.json` overrides them with `nopy.cubes`.
|
||||
*
|
||||
* Anything wrong is an error rather than a silent skip: naming a package in
|
||||
* `cubePackages` is a statement that cubes are expected from it, and errors
|
||||
@@ -75,27 +86,40 @@ export function resolveCubePackages(refs: CubePackageRef[]): {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absent is the ordinary case and means the convention. Present-but-wrong
|
||||
// is a different thing entirely — the author meant to say something and it
|
||||
// did not parse — so it stays an error rather than falling back silently.
|
||||
const declared = manifest.nopy?.cubes;
|
||||
const defaulted = declared === undefined;
|
||||
|
||||
if (
|
||||
!Array.isArray(declared) ||
|
||||
declared.length === 0 ||
|
||||
!declared.every((entry) => typeof entry === 'string')
|
||||
!defaulted &&
|
||||
(!Array.isArray(declared) ||
|
||||
declared.length === 0 ||
|
||||
!declared.every((entry) => typeof entry === 'string'))
|
||||
) {
|
||||
errors.push(
|
||||
`Cube package '${ref.spec}' declares no cubes. ` +
|
||||
`Expected "nopy": { "cubes": ["./cubes"] } in ${root}/package.json.`
|
||||
`Cube package '${ref.spec}': "nopy": { "cubes": … } in ${root}/package.json ` +
|
||||
`must be a non-empty array of strings. Omit it to use the default, ./cubes.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
for (const entry of declared as string[]) {
|
||||
for (const entry of defaulted ? DEFAULT_CUBE_DIRS : (declared as string[])) {
|
||||
const dir = path.resolve(root, entry);
|
||||
|
||||
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
||||
errors.push(`Cube package '${ref.spec}': '${entry}' points outside the package.`);
|
||||
} else if (!fs.existsSync(dir)) {
|
||||
errors.push(`Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`);
|
||||
// Naming the entry would be misleading when nobody wrote one; say what
|
||||
// was looked for and what would change it instead.
|
||||
errors.push(
|
||||
defaulted
|
||||
? `Cube package '${ref.spec}' has no cubes/ directory in ${root}, and its ` +
|
||||
`package.json declares no "nopy": { "cubes": [...] } pointing elsewhere.`
|
||||
: `Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`
|
||||
);
|
||||
} else {
|
||||
dirs.push(dir);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ export {
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
// Graceful exit
|
||||
export {
|
||||
CANCELLED_EXIT_CODE,
|
||||
exitWithFarewell,
|
||||
FAREWELL,
|
||||
installGracefulExit,
|
||||
isCancellation,
|
||||
restoreTerminal,
|
||||
} from './nopy.exit.js';
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
// History management
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from './nopy.config.js';
|
||||
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
|
||||
import {
|
||||
clearHistory,
|
||||
formatHistoryList,
|
||||
@@ -19,7 +20,17 @@ import { nopy } from './nopy.main.js';
|
||||
import type { Channel } from './nopy.update.js';
|
||||
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
|
||||
|
||||
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
|
||||
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
|
||||
version: string;
|
||||
buildInfo?: { commit?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* What `--version` prints. `version` itself stays untouched everywhere else —
|
||||
* the commit is an annotation, stamped into `package.json` on the runner by the
|
||||
* publish workflows and absent when running from source.
|
||||
*/
|
||||
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
|
||||
|
||||
/**
|
||||
* Prints the update hint to stderr, so it never lands in `--json` output or in
|
||||
@@ -32,11 +43,15 @@ async function printUpdateNotice(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Before anything can open a prompt: a cancelled TUI leaves through
|
||||
// nopy.exit, not through node's default unhandled-rejection trace.
|
||||
installGracefulExit();
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('nopy')
|
||||
.version(version)
|
||||
.version(versionLabel)
|
||||
.description('A CLI tool for pyinfra script management and execution.')
|
||||
.addHelpText(
|
||||
'after',
|
||||
@@ -124,6 +139,11 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
// A prompt the user backed out of is not a failed run: inquirer rejects
|
||||
// cleanly, so unlike the enquirer case this arrives here rather than at
|
||||
// the process-level handler.
|
||||
if (isCancellation(error)) exitWithFarewell();
|
||||
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* What happens when the user walks out of the TUI instead of finishing it.
|
||||
* @module nopy.exit
|
||||
*/
|
||||
|
||||
/** Parting words. Printed whenever a run ends because the user asked it to. */
|
||||
export const FAREWELL = 'Bye Bye Honeypie';
|
||||
|
||||
/** Conventional exit code for "terminated by SIGINT" — 128 + 2. */
|
||||
export const CANCELLED_EXIT_CODE = 130;
|
||||
|
||||
/** ETX: the byte a raw-mode terminal delivers for Ctrl-C. */
|
||||
const ETX = '\x03';
|
||||
|
||||
/** Undoes `ansi.cursor.hide()`, which every enquirer prompt writes on start. */
|
||||
const SHOW_CURSOR = '\x1B[?25h';
|
||||
|
||||
/**
|
||||
* Error names the two prompt libraries use for "the user called it off".
|
||||
*
|
||||
* `ExitPromptError` is what `@inquirer/core` rejects with on Ctrl-C;
|
||||
* `CancelPromptError` is the same thing reached from outside the prompt.
|
||||
*/
|
||||
const CANCEL_ERROR_NAMES = new Set(['ExitPromptError', 'CancelPromptError']);
|
||||
|
||||
/**
|
||||
* Whether a thrown value is the user cancelling rather than something failing.
|
||||
*
|
||||
* Three shapes, one per way out of a prompt:
|
||||
*
|
||||
* - `ERR_USE_AFTER_CLOSE` — enquirer's teardown exploding. Ctrl-C in raw mode
|
||||
* reaches *both* node's readline, which closes the interface because it has
|
||||
* no `SIGINT` listener, and enquirer's own keypress queue, which then cancels
|
||||
* the prompt and calls `rl.pause()` on the interface node has already closed.
|
||||
* Node >= 22 throws there rather than ignoring it. The throw happens inside
|
||||
* `Prompt.close()`, i.e. *before* `emit('cancel')`, so `prompt.run()` never
|
||||
* settles and the `try/catch` around it in `nopy.prompts` never runs — the
|
||||
* rejection surfaces with nothing awaiting it, which is why this has to be
|
||||
* caught at the process level.
|
||||
* - `ExitPromptError` — inquirer, which does reject cleanly and whose rejection
|
||||
* travels up the normal call chain.
|
||||
* - a bare `''` or an ETX byte — enquirer rejecting a cancelled prompt with the
|
||||
* keypress that cancelled it, on the runs where the teardown does not throw.
|
||||
*/
|
||||
export function isCancellation(error: unknown): boolean {
|
||||
if (error === '' || error === ETX) return true;
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
|
||||
const { name, code } = error as { name?: unknown; code?: unknown };
|
||||
return (
|
||||
code === 'ERR_USE_AFTER_CLOSE' || (typeof name === 'string' && CANCEL_ERROR_NAMES.has(name))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the terminal back the way it was found.
|
||||
*
|
||||
* A prompt owns the terminal while it runs: stdin is in raw mode and the cursor
|
||||
* is hidden. Exiting from under it leaves the shell with no cursor and no echo,
|
||||
* so this runs on every abnormal exit, cancelled or crashed. Best-effort by
|
||||
* design — a destroyed stdin throws on `setRawMode`, and a failure to tidy up
|
||||
* must not replace the message explaining why we are leaving.
|
||||
*/
|
||||
export function restoreTerminal(): void {
|
||||
try {
|
||||
if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
|
||||
if (process.stdout.isTTY) process.stdout.write(SHOW_CURSOR);
|
||||
} catch {
|
||||
// Nothing useful to do about a terminal that will not be restored.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `process.exit` rather than letting the loop drain, because the prompt that
|
||||
* was cancelled is still holding stdin — after the teardown above threw, its
|
||||
* promise is pending forever and nothing else will end the process.
|
||||
*/
|
||||
export function exitWithFarewell(code: number = CANCELLED_EXIT_CODE): never {
|
||||
restoreTerminal();
|
||||
process.stderr.write(`\n${FAREWELL}\n`);
|
||||
return process.exit(code) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a genuine crash, having first handed the terminal back.
|
||||
*
|
||||
* Deliberately as loud as node's own default — the stack, not a summary. The
|
||||
* only thing being taken over is *when* it prints, so that {@link
|
||||
* restoreTerminal} gets to run first.
|
||||
*/
|
||||
function reportFatal(error: unknown): never {
|
||||
restoreTerminal();
|
||||
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
||||
return process.exit(1) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the process-level handlers that turn a Ctrl-C into {@link FAREWELL}.
|
||||
*
|
||||
* Two entry points, because Ctrl-C arrives differently depending on who owns
|
||||
* the terminal. During a prompt, stdin is in raw mode: the process gets no
|
||||
* `SIGINT` at all, the keypress goes to the prompt library, and the failure
|
||||
* comes back as an unhandled rejection. Everywhere else — cube loading, a
|
||||
* pyinfra run — the signal arrives normally.
|
||||
*
|
||||
* Returns a disposer, which the CLI ignores and the tests do not.
|
||||
*/
|
||||
export function installGracefulExit(): () => void {
|
||||
const onSignal = () => exitWithFarewell();
|
||||
const onFatal = (reason: unknown) => {
|
||||
if (isCancellation(reason)) {
|
||||
exitWithFarewell();
|
||||
return;
|
||||
}
|
||||
reportFatal(reason);
|
||||
};
|
||||
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('uncaughtException', onFatal);
|
||||
process.on('unhandledRejection', onFatal);
|
||||
|
||||
return () => {
|
||||
process.off('SIGINT', onSignal);
|
||||
process.off('uncaughtException', onFatal);
|
||||
process.off('unhandledRejection', onFatal);
|
||||
};
|
||||
}
|
||||
@@ -83,7 +83,9 @@ export async function AuthSelection(useAuthKey?: boolean): Promise<{
|
||||
if (useAuthKey) return { authMethod: 'ssh-key' };
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
// `select`, not `list`: inquirer 14 dropped the legacy name and rejects
|
||||
// an unknown type outright.
|
||||
type: 'select',
|
||||
name: 'authMethod',
|
||||
message: 'Select authentication method:',
|
||||
choices: ['ssh-key', 'password'],
|
||||
@@ -118,7 +120,7 @@ export async function PasswordSelection(username: string): Promise<string> {
|
||||
export async function HostSelection(hosts: string[]): Promise<string> {
|
||||
const selectedHost = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
type: 'select',
|
||||
name: 'host',
|
||||
message: 'Select host from inventory',
|
||||
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
||||
|
||||
Reference in New Issue
Block a user