[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
+4 -4
View File
@@ -302,12 +302,12 @@ All three are unioned and scanned the same way. A directory is a cube when it ho
#### Cube packages
A cube package is an ordinary npm package that ships cube directories and points at them from its own `package.json`:
A cube package is an ordinary npm package that ships its cubes in a `cubes/` directory at its root. That is the whole contract — no nopy-specific `package.json` field is required. A bundle whose cubes live elsewhere (compiled into `dist/cubes`, say) overrides the location:
```json
{
"name": "@bitsquare/cubes-core",
"nopy": { "cubes": ["./cubes"] }
"name": "@acme/cubes-web",
"nopy": { "cubes": ["./dist/cubes"] }
}
```
@@ -321,7 +321,7 @@ pnpm add -D @bitsquare/cubes-core
{ "cubePackages": ["@bitsquare/cubes-core"] }
```
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it declares no `nopy.cubes`, or an entry points at a directory that does not exist or lies outside the package.
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it has neither a `cubes/` directory nor a `nopy.cubes` override, its `nopy.cubes` is malformed, or an entry points at a directory that does not exist or lies outside the package.
#### Ids are claimed globally
+7 -6
View File
@@ -325,8 +325,9 @@ mirror the path, and they are claimed **globally** — across `cubeDirs`,
- a duplicate id (the message names every claimant and how each got into the run);
- a manifest that throws on import, exports a non-object, or has no `name`;
- a `secrets` entry naming a key that is not in the schema;
- a package in `cubePackages` that is not installed, cannot be read, or declares
no `nopy.cubes`;
- a package in `cubePackages` that is not installed, cannot be read, has neither a
`cubes/` directory nor a `nopy.cubes` override, or whose `nopy.cubes` is not a
non-empty array of strings;
- a `nopy.cubes` entry that does not exist or points outside its package root.
Any of them aborts the run (`nopy.main.ts` returns before the workflow). None is
@@ -380,7 +381,7 @@ const { packages, errors } = resolveCubePackages(config.cubePackages);
interface CubePackage {
name: string; // the name it was requested under
root: string; // absolute path to the package root
dirs: string[]; // absolute paths, from the package's `nopy.cubes` field
dirs: string[]; // absolute paths: `<root>/cubes`, or the package's `nopy.cubes`
}
```
@@ -892,8 +893,8 @@ Nothing feeds the result into the built command — see
const { selectedCubes } = await CubeSelection(cubes); // string[] of ids
```
Multi-select with fuzzy filtering on the rendered label. A cancelled prompt
returns an empty array rather than throwing.
Multi-select with fuzzy filtering on the rendered label. A prompt dismissed with
Escape returns an empty array rather than throwing.
### `HostSelection(hosts)`
@@ -1182,7 +1183,7 @@ Real behaviour that a reader would otherwise take on trust. Tracked in
`Cube.getDefaults()`, against `{}` — and prompt input is type-coerced, which is
not the same thing.
- **Nothing checks bundle/CLI compatibility.** A cube package declares no
supported nopy range and the loader reads whatever `nopy.cubes` points at.
supported nopy range and the loader scans whatever directories it finds.
- **`self-update` reports an empty channel as unreachable.** `latest === null`
means either the request failed *or* the registry answered normally and the
dist-tag simply has no version — the second is exactly what a Gitea package
+26 -12
View File
@@ -22,13 +22,13 @@ The rest of this document is for writing one.
## What a bundle is
An ordinary npm package that ships cube directories and points at them from its
own `package.json`. There is no build step, no plugin API and no entry point —
nopy reads the directories off disk and imports each `manifest.mjs` directly.
An ordinary npm package that ships its cubes in a `cubes/` directory. There is no
build step, no plugin API and no entry point — nopy reads the directory off disk
and imports each `manifest.mjs` directly.
```
@acme/cubes-web
├── package.json nopy.cubes → ["./cubes"]
├── package.json no nopy block needed
├── README.md
└── cubes/
├── nginx/
@@ -50,7 +50,6 @@ special-cased.
"name": "@acme/cubes-web",
"version": "1.0.0",
"type": "module",
"nopy": { "cubes": ["./cubes"] },
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
"publishConfig": { "access": "public" },
"dependencies": {
@@ -60,10 +59,24 @@ special-cased.
}
```
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
relative to the package root, each scanned recursively for cubes. Several
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
must stay inside the package — a path escaping the root is refused, not resolved.
**Nothing declares the cubes.** `cubes/` at the package root is the convention,
scanned recursively, and a bundle that follows it needs no nopy-specific field at
all. Naming the package in `cubePackages` is already the statement that cubes are
expected from it.
**`nopy.cubes`** overrides that, for the bundle whose cubes are somewhere else — a
package compiled from TypeScript sources into `dist/cubes`, say, or one shipping
two separate trees:
```json
"nopy": { "cubes": ["./dist/cubes", "./contrib"] }
```
It is an array of directories relative to the package root. Every entry must
exist and must stay inside the package — a path escaping the root is refused, not
resolved. Present-but-malformed (an empty array, a bare string, non-strings) is an
error rather than a fall back to the default: saying something that does not parse
is not the same as saying nothing.
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
loads (the extension carries the day), but anything it imports relatively will
@@ -222,7 +235,7 @@ Nothing bundle-specific: `npm publish` (or `pnpm publish`) with a version bump.
Some things worth deciding once:
- **Version the bundle independently of nopy.** There is no compatibility check
between the two — the loader reads whatever `nopy.cubes` points at. Document
between the two — the loader scans whatever directories it finds. Document
the nopy version you test against in your README.
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
and breaks any manifest listing it as a dependency, including manifests in
@@ -254,8 +267,9 @@ For how this repository releases its own packages, see
| Symptom | Cause |
| --- | --- |
| `Cube package 'X' is not installed (looked up from …)` | Not installed, or installed somewhere other than the config that named it. The path in the message is where the lookup started. |
| `Cube package 'X' declares no cubes` | Missing or malformed `nopy.cubes` in the package's `package.json`. It must be a non-empty array of strings. |
| `'./cubes' does not exist in` | The directory was not packed. Check `files` and `npm pack --dry-run`. |
| `Cube package 'X' has no cubes/ directory in …` | No `cubes/` at the package root and no `nopy.cubes` pointing elsewhere. Usually the directory was not packed — check `files` and `npm pack --dry-run`. |
| `"nopy": { "cubes": … } must be a non-empty array of strings` | The override is present but malformed. Fix it, or omit it entirely to use `./cubes`. |
| `'…' does not exist in …` | A `nopy.cubes` entry pointing at a directory the tarball does not contain. |
| `'…' points outside the package` | A `nopy.cubes` entry escaping the package root. Not allowed. |
| `Duplicate cube id 'X' from N sources:` | Two or more cubes claiming one id; the message lists each source. Rename one — there is no precedence rule to lean on. |
| `ERR_MODULE_NOT_FOUND` for `zod` or `@bitsquare/nopy-cube` | The bundle did not declare them as dependencies. The resolve-hook fallback covers loose local cubes, not published packages. |
+14 -7
View File
@@ -141,7 +141,6 @@ A cube bundle is an npm package with a `nopy` field:
"name": "@acme/cubes-net",
"version": "1.0.0",
"type": "module",
"nopy": { "cubes": ["./cubes"] },
"files": ["cubes", "README.md", "LICENSE"],
"keywords": ["nopy", "nopy-cubes", "pyinfra"],
"dependencies": {
@@ -154,10 +153,16 @@ A cube bundle is an npm package with a `nopy` field:
Rules:
- `nopy.cubes` — directories relative to the package root, scanned exactly like
`cubeDirs` entries. Required; a package listed in `cubePackages` without a
`nopy` field is an error, not a silent skip. Listing it means the user expects
cubes from it.
- **Cube location is a convention, `<root>/cubes`.** *(Amended after Phase 5;
originally `nopy.cubes` was a required field.)* The field bought nothing a
convention does not: it is not a discovery marker — naming the package in
`cubePackages` already is one — and it says nothing about whether the
directories were actually packed, which is the failure authors really hit.
`nopy.cubes` remains as an **override**, directories relative to the package
root, for the bundle whose cubes are elsewhere (`dist/cubes` after a build).
Absent means the default; present-and-malformed is an error rather than a fall
back. Finding no cube directory at all is still an error, not a silent skip:
listing a package means the user expects cubes from it.
- Both dependencies are **regular dependencies, not peers**, and both are
load-bearing: a manifest imports `Manifest` from `@bitsquare/nopy-cube` and `z`
from `zod`. `@bitsquare/nopy-cube` peer-depends on zod, so the bundle's copy is
@@ -255,7 +260,8 @@ Errors (each aborts the run, consistent with the existing `errors` contract):
- package not found on any candidate path
- `package.json` unparseable
- no `nopy.cubes`, or it is not a non-empty array of strings
- neither a `cubes/` directory nor a `nopy.cubes` override
- `nopy.cubes` present but not a non-empty array of strings
- a `nopy.cubes` entry escapes the package root, or does not exist
### Wiring
@@ -625,7 +631,8 @@ runner alike.
- resolves through a symlinked package directory (mimicking pnpm)
- resolves from the declaring config's directory, not `cwd`
- missing package → error naming the spec
- package without `nopy.cubes`error
- package without `nopy.cubes`falls back to `cubes/`
- package with neither → error; malformed `nopy.cubes` → error, no fall back
- `nopy.cubes` entry that does not exist, and one that escapes the root → errors
- last-wins dedupe when parent and child config both name a package
+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'],
+34 -5
View File
@@ -108,19 +108,48 @@ describe('resolveCubePackages', () => {
expect(errors[0]).toMatch(/cannot read/);
});
it('reports a package that declares no cubes', () => {
install(tmpDir, 'plain', {});
it('falls back to cubes/ when the package declares nothing', () => {
// The convention. A bundle that ships cubes/ at its root needs no `nopy`
// block at all, and one with an unrelated `nopy` block still gets it.
const plain = install(tmpDir, 'plain', {});
const other = install(tmpDir, 'other', { nopy: { somethingElse: true } });
const { packages, errors } = resolveCubePackages(
['plain', 'other'].map((spec) => ({ spec, from: tmpDir }))
);
expect(errors).toEqual([]);
expect(packages.map((pkg) => pkg.dirs)).toEqual([
[path.join(plain, 'cubes')],
[path.join(other, 'cubes')],
]);
});
it('reports a package with neither a declaration nor a cubes/ directory', () => {
install(tmpDir, 'bare', {}, []);
const { packages, errors } = resolveCubePackages([{ spec: 'bare', from: tmpDir }]);
expect(packages).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/has no cubes\/ directory/);
expect(errors[0]).toMatch(/declares no "nopy"/);
});
it('reports a malformed declaration instead of falling back to the default', () => {
// Each of these ships a usable cubes/ directory. Saying something that does
// not parse is not the same as saying nothing, so none of them resolve.
install(tmpDir, 'empty', { nopy: { cubes: [] } });
install(tmpDir, 'wrong-type', { nopy: { cubes: 'cubes' } });
install(tmpDir, 'not-strings', { nopy: { cubes: [1] } });
const { packages, errors } = resolveCubePackages(
['plain', 'empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
['empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
);
expect(packages).toEqual([]);
expect(errors).toHaveLength(4);
for (const error of errors) expect(error).toMatch(/declares no cubes/);
expect(errors).toHaveLength(3);
for (const error of errors) expect(error).toMatch(/must be a non-empty array of strings/);
});
it('reports a cube directory that does not exist', () => {
+218
View File
@@ -0,0 +1,218 @@
/**
* Tests for nopy.exit.
*
* The handlers are invoked by calling the listener `installGracefulExit`
* registered, not by `process.emit()`-ing the event: vitest listens for
* `unhandledRejection` and `uncaughtException` itself and would report a
* synthetic one as a failure of the test file.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CANCELLED_EXIT_CODE,
exitWithFarewell,
FAREWELL,
installGracefulExit,
isCancellation,
restoreTerminal,
} from '../src/nopy.exit.js';
let exit: ReturnType<typeof vi.spyOn>;
let stderr: ReturnType<typeof vi.spyOn>;
/** Pretends stdin/stdout are the terminal a prompt would have taken over. */
function fakeTerminal(opts: { isTTY: boolean; isRaw?: boolean }) {
const setRawMode = vi.fn();
const original = {
isTTY: process.stdin.isTTY,
isRaw: process.stdin.isRaw,
setRawMode: process.stdin.setRawMode,
outTTY: process.stdout.isTTY,
};
Object.defineProperty(process.stdin, 'isTTY', { value: opts.isTTY, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: opts.isRaw ?? false, configurable: true });
Object.defineProperty(process.stdin, 'setRawMode', { value: setRawMode, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: opts.isTTY, configurable: true });
const restore = () => {
Object.defineProperty(process.stdin, 'isTTY', { value: original.isTTY, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: original.isRaw, configurable: true });
Object.defineProperty(process.stdin, 'setRawMode', {
value: original.setRawMode,
configurable: true,
});
Object.defineProperty(process.stdout, 'isTTY', { value: original.outTTY, configurable: true });
};
return { setRawMode, restore };
}
/** The listener `installGracefulExit` most recently added for `event`. */
const lastListener = (event: string) =>
process.listeners(event as 'SIGINT').at(-1) as (reason?: unknown) => void;
beforeEach(() => {
exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('isCancellation', () => {
it('recognises enquirer tearing down a readline node already closed', () => {
const err = Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' });
expect(isCancellation(err)).toBe(true);
});
it('recognises the inquirer cancellations', () => {
const exitPrompt = Object.assign(new Error('User force closed the prompt'), {
name: 'ExitPromptError',
});
const cancelPrompt = Object.assign(new Error('Prompt was canceled'), {
name: 'CancelPromptError',
});
expect(isCancellation(exitPrompt)).toBe(true);
expect(isCancellation(cancelPrompt)).toBe(true);
});
it('recognises the bare values enquirer rejects a cancelled prompt with', () => {
expect(isCancellation('')).toBe(true);
expect(isCancellation('\x03')).toBe(true);
});
it('leaves a genuine failure alone', () => {
expect(isCancellation(new Error('pyinfra exited 1'))).toBe(false);
expect(isCancellation(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false);
expect(isCancellation('boom')).toBe(false);
expect(isCancellation(undefined)).toBe(false);
expect(isCancellation(null)).toBe(false);
expect(isCancellation(7)).toBe(false);
});
});
describe('restoreTerminal', () => {
it('leaves raw mode and shows the cursor again', () => {
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
restoreTerminal();
expect(terminal.setRawMode).toHaveBeenCalledWith(false);
expect(process.stdout.write).toHaveBeenCalledWith('\x1B[?25h');
terminal.restore();
});
it('touches nothing when the output is not a terminal', () => {
const terminal = fakeTerminal({ isTTY: false });
restoreTerminal();
expect(terminal.setRawMode).not.toHaveBeenCalled();
expect(process.stdout.write).not.toHaveBeenCalled();
terminal.restore();
});
it('survives a stdin that refuses to leave raw mode', () => {
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
terminal.setRawMode.mockImplementation(() => {
throw new Error('stdin destroyed');
});
expect(() => restoreTerminal()).not.toThrow();
terminal.restore();
});
});
describe('exitWithFarewell', () => {
it('says goodbye on stderr and exits 130', () => {
exitWithFarewell();
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
});
it('accepts a different exit code', () => {
exitWithFarewell(0);
expect(exit).toHaveBeenCalledWith(0);
});
});
describe('installGracefulExit', () => {
let dispose: () => void;
beforeEach(() => {
dispose = installGracefulExit();
});
afterEach(() => {
dispose();
});
it('says goodbye on SIGINT', () => {
lastListener('SIGINT')();
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
});
it('says goodbye for the rejection nothing is awaiting', () => {
// The shape of the real crash: enquirer's Ctrl-C teardown, which leaves
// `prompt.run()` pending forever, so no `catch` in nopy.prompts sees it.
lastListener('unhandledRejection')(
Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' })
);
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
});
it('still reports a real crash, loudly, and exits 1', () => {
const boom = new Error('everything is on fire');
lastListener('uncaughtException')(boom);
expect(stderr).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(boom.stack);
expect(exit).toHaveBeenCalledWith(1);
});
it('reports a thrown non-error too', () => {
lastListener('uncaughtException')('just a string');
expect(console.error).toHaveBeenCalledWith('just a string');
expect(exit).toHaveBeenCalledWith(1);
});
it('reports an error with no stack by its message', () => {
const stackless = new Error('no stack here');
stackless.stack = undefined;
lastListener('uncaughtException')(stackless);
expect(console.error).toHaveBeenCalledWith('no stack here');
});
it('hands the process back on dispose', () => {
const before = {
SIGINT: process.listenerCount('SIGINT'),
uncaughtException: process.listenerCount('uncaughtException'),
unhandledRejection: process.listenerCount('unhandledRejection'),
};
dispose();
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT - 1);
expect(process.listenerCount('uncaughtException')).toBe(before.uncaughtException - 1);
expect(process.listenerCount('unhandledRejection')).toBe(before.unhandledRejection - 1);
// The afterEach disposer runs a second time; make it a no-op.
dispose = () => {};
});
});
+26
View File
@@ -218,6 +218,32 @@ describe('HostSelection', () => {
});
});
describe('question types', () => {
it('declares only types the installed inquirer actually ships', async () => {
// Checked against the real module, not the mock: `list` was accepted for
// years and inquirer 14 dropped it, which took out host selection entirely
// — a failure no amount of mocked prompting can see.
const actual = await vi.importActual<typeof import('inquirer')>('inquirer');
const supported = Object.keys(actual.createPromptModule().prompts);
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
await HostSelection(['web-1']);
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
await AuthSelection(false);
inquirerPrompt.mockResolvedValue({ password: 'p' });
await PasswordSelection('deploy');
const declared = new Set(
inquirerPrompt.mock.calls.flatMap(([asked]: [Record<string, any>[]]) =>
asked.map((q) => q.type ?? 'input')
)
);
expect(declared.size).toBeGreaterThan(0);
expect(supported).toEqual(expect.arrayContaining([...declared]));
});
});
describe('VariableAssignment', () => {
const schema = z.object({
port: z.number().default(8080).describe('Listen port'),