[chore] commit id display on version
Publish snapshot / snapshot (push) Successful in 1m26s

This commit is contained in:
Benjamin Diedrichsen
2026-07-29 13:00:10 +02:00
parent ea08e76a2f
commit 1ba1c2a32a
17 changed files with 589 additions and 56 deletions
+33 -9
View File
@@ -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);
}
+9
View File
@@ -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 {
+22 -2
View File
@@ -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(
+132
View File
@@ -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);
};
}
+4 -2
View File
@@ -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'],