This commit is contained in:
@@ -123,7 +123,10 @@ jobs:
|
|||||||
# `g` prefix keeps the identifier a valid semver one even when the
|
# `g` prefix keeps the identifier a valid semver one even when the
|
||||||
# abbreviated sha happens to be all digits.
|
# abbreviated sha happens to be all digits.
|
||||||
version="${base}-main.${{ github.run_number }}.g${short_sha}"
|
version="${base}-main.${{ github.run_number }}.g${short_sha}"
|
||||||
(cd "$dir" && npm pkg set "version=${version}")
|
# `buildInfo.commit` is what `nopy --version` annotates itself with.
|
||||||
|
# An unknown top-level key is ignored by npm and package.json is
|
||||||
|
# always in the tarball, so it ships without any `files` change.
|
||||||
|
(cd "$dir" && npm pkg set "version=${version}" "buildInfo.commit=${short_sha}")
|
||||||
done
|
done
|
||||||
|
|
||||||
# Pass 2: publish.
|
# Pass 2: publish.
|
||||||
|
|||||||
@@ -165,6 +165,20 @@ jobs:
|
|||||||
# Explicit, so the publish steps can skip lifecycle scripts entirely.
|
# Explicit, so the publish steps can skip lifecycle scripts entirely.
|
||||||
run: pnpm run build
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Stamp the commit into the manifest
|
||||||
|
# What `nopy --version` annotates itself with. The version is untouched:
|
||||||
|
# this only adds a `buildInfo.commit` key, which npm ignores and which
|
||||||
|
# ships regardless of `files` because package.json is always packed.
|
||||||
|
# Before the pack below, so the artefact under test is the one publish
|
||||||
|
# ships. The tree is left dirty, which is why both publish steps pass
|
||||||
|
# --no-git-checks — they already did, for the detached HEAD.
|
||||||
|
env:
|
||||||
|
DIR: ${{ steps.target.outputs.dir }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
short_sha=$(git rev-parse --short=7 HEAD)
|
||||||
|
(cd "$DIR" && npm pkg set "buildInfo.commit=${short_sha}")
|
||||||
|
|
||||||
- name: Verify the packed manifests
|
- name: Verify the packed manifests
|
||||||
# Packages link to each other with `workspace:*`, which npm cannot
|
# Packages link to each other with `workspace:*`, which npm cannot
|
||||||
# install. Proves on the tarball that pack rewrote it.
|
# install. Proves on the tarball that pack rewrote it.
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ publish order matters — see *Releasing*.
|
|||||||
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
|
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
|
||||||
`node_modules`. There is no `cubes/` directory at the repo root any more.
|
`node_modules`. There is no `cubes/` directory at the repo root any more.
|
||||||
|
|
||||||
|
## Documenting
|
||||||
|
|
||||||
|
Be modest. Size the write-up to the change: most work needs none, and a small
|
||||||
|
module never earns a section in `docs/API.md`. Where a reason is genuinely
|
||||||
|
non-obvious, one comment next to the code beats three paragraphs in a document
|
||||||
|
nobody re-reads. Document the surprising, not the obvious.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -97,14 +104,19 @@ One pass per invocation, `nopy.main.ts` orchestrating:
|
|||||||
if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily
|
if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily
|
||||||
inside the action, so `--help`/`--version` work outside a project.
|
inside the action, so `--help`/`--version` work outside a project.
|
||||||
2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef`
|
2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef`
|
||||||
into a package root plus the directories its `nopy.cubes` field declares.
|
into a package root plus its cube directories. The location is a **convention**:
|
||||||
|
`<root>/cubes`, so a bundle needs no nopy-specific `package.json` field at all.
|
||||||
|
`nopy.cubes` survives only as an override, for the bundle whose cubes are
|
||||||
|
elsewhere (`dist/cubes` after a build, say) — absent means the default, but
|
||||||
|
present-and-malformed is an error rather than a fall back, since saying
|
||||||
|
something that does not parse is not the same as saying nothing.
|
||||||
Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`,
|
Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`,
|
||||||
deliberately bypassing the `exports` map: a bundle ships directories and has
|
deliberately bypassing the `exports` map: a bundle ships directories and has
|
||||||
no entry point to declare. `existsSync` also follows the symlink pnpm plants
|
no entry point to declare. `existsSync` also follows the symlink pnpm plants
|
||||||
at `node_modules/<name>`, which a `readdir` scan skips outright (it reports
|
at `node_modules/<name>`, which a `readdir` scan skips outright (it reports
|
||||||
`isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable
|
`isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable
|
||||||
manifest, a missing `nopy.cubes`, a directory that does not exist, and an
|
manifest, no cube directory found, and an entry pointing outside the package
|
||||||
entry pointing outside the package root are all errors, never silent skips.
|
root are all errors, never silent skips.
|
||||||
Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes
|
Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes
|
||||||
arrays of primitives and these are objects.
|
arrays of primitives and these are objects.
|
||||||
3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the
|
3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the
|
||||||
@@ -295,6 +307,18 @@ numerically highest version on npmjs, so install with an explicit `@latest`.
|
|||||||
|
|
||||||
So: bump `packages/<pkg>/package.json`, land it on `main`, then tag that commit.
|
So: bump `packages/<pkg>/package.json`, land it on `main`, then tag that commit.
|
||||||
|
|
||||||
|
Both workflows also stamp `buildInfo.commit` (the 7-char sha) into the manifest
|
||||||
|
with the same `npm pkg set`, never committed either — the snapshot loop stamps
|
||||||
|
every package, and the release step stamps whichever one the tag named. Both
|
||||||
|
CLIs append it to `--version` in parentheses — `0.5.0 (ab12cd7)` — and print the
|
||||||
|
bare version when the field is absent, which is every run from source. The
|
||||||
|
version string itself is untouched: `nopy.cli.ts` and `keyman.cli.ts` decorate
|
||||||
|
only the string they print, while `updateNotice()` and `selfUpdate()` keep
|
||||||
|
reading the raw `version`, so channel derivation never sees the annotation. An
|
||||||
|
unknown top-level key is ignored by npm and `package.json` is always packed, so
|
||||||
|
nothing in `files` had to change. The two CLIs are kept in step here for the
|
||||||
|
same reason their update modules are duplicated rather than shared.
|
||||||
|
|
||||||
Three things the `workspace:*` links added, all of them non-obvious:
|
Three things the `workspace:*` links added, all of them non-obvious:
|
||||||
|
|
||||||
- **`pnpm publish`, never `npm publish`.** `link-workspace-packages` is unset and
|
- **`pnpm publish`, never `npm publish`.** `link-workspace-packages` is unset and
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ Then name it in `.nopyrc.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`nopy` resolves the package from the directory of the config file that named it,
|
`nopy` resolves the package from the directory of the config file that named it
|
||||||
reads `nopy.cubes` out of its `package.json`, and scans those directories exactly
|
and scans its `cubes/` directory exactly as it scans a `cubeDirs` entry. Nothing
|
||||||
as it scans a `cubeDirs` entry. Nothing has to be linked or copied.
|
has to be linked or copied.
|
||||||
|
|
||||||
## What is in it
|
## What is in it
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ import { keyman } from './keyman.main.js';
|
|||||||
import type { Channel } from './keyman.update.js';
|
import type { Channel } from './keyman.update.js';
|
||||||
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
|
import { formatCommand, selfUpdate, updateNotice } from './keyman.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;
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
@@ -24,7 +34,7 @@ if (args.includes('--print-config')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.includes('--version') || args.includes('-V')) {
|
if (args.includes('--version') || args.includes('-V')) {
|
||||||
console.log(version);
|
console.log(versionLabel);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,12 +302,12 @@ All three are unioned and scanned the same way. A directory is a cube when it ho
|
|||||||
|
|
||||||
#### Cube packages
|
#### 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
|
```json
|
||||||
{
|
{
|
||||||
"name": "@bitsquare/cubes-core",
|
"name": "@acme/cubes-web",
|
||||||
"nopy": { "cubes": ["./cubes"] }
|
"nopy": { "cubes": ["./dist/cubes"] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ pnpm add -D @bitsquare/cubes-core
|
|||||||
{ "cubePackages": ["@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
|
#### Ids are claimed globally
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 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 `secrets` entry naming a key that is not in the schema;
|
||||||
- a package in `cubePackages` that is not installed, cannot be read, or declares
|
- a package in `cubePackages` that is not installed, cannot be read, has neither a
|
||||||
no `nopy.cubes`;
|
`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.
|
- 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
|
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 {
|
interface CubePackage {
|
||||||
name: string; // the name it was requested under
|
name: string; // the name it was requested under
|
||||||
root: string; // absolute path to the package root
|
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
|
const { selectedCubes } = await CubeSelection(cubes); // string[] of ids
|
||||||
```
|
```
|
||||||
|
|
||||||
Multi-select with fuzzy filtering on the rendered label. A cancelled prompt
|
Multi-select with fuzzy filtering on the rendered label. A prompt dismissed with
|
||||||
returns an empty array rather than throwing.
|
Escape returns an empty array rather than throwing.
|
||||||
|
|
||||||
### `HostSelection(hosts)`
|
### `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
|
`Cube.getDefaults()`, against `{}` — and prompt input is type-coerced, which is
|
||||||
not the same thing.
|
not the same thing.
|
||||||
- **Nothing checks bundle/CLI compatibility.** A cube package declares no
|
- **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`
|
- **`self-update` reports an empty channel as unreachable.** `latest === null`
|
||||||
means either the request failed *or* the registry answered normally and the
|
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
|
dist-tag simply has no version — the second is exactly what a Gitea package
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ The rest of this document is for writing one.
|
|||||||
|
|
||||||
## What a bundle is
|
## What a bundle is
|
||||||
|
|
||||||
An ordinary npm package that ships cube directories and points at them from its
|
An ordinary npm package that ships its cubes in a `cubes/` directory. There is no
|
||||||
own `package.json`. There is no build step, no plugin API and no entry point —
|
build step, no plugin API and no entry point — nopy reads the directory off disk
|
||||||
nopy reads the directories off disk and imports each `manifest.mjs` directly.
|
and imports each `manifest.mjs` directly.
|
||||||
|
|
||||||
```
|
```
|
||||||
@acme/cubes-web
|
@acme/cubes-web
|
||||||
├── package.json nopy.cubes → ["./cubes"]
|
├── package.json no nopy block needed
|
||||||
├── README.md
|
├── README.md
|
||||||
└── cubes/
|
└── cubes/
|
||||||
├── nginx/
|
├── nginx/
|
||||||
@@ -50,7 +50,6 @@ special-cased.
|
|||||||
"name": "@acme/cubes-web",
|
"name": "@acme/cubes-web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"nopy": { "cubes": ["./cubes"] },
|
|
||||||
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
|
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
|
||||||
"publishConfig": { "access": "public" },
|
"publishConfig": { "access": "public" },
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -60,10 +59,24 @@ special-cased.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
|
**Nothing declares the cubes.** `cubes/` at the package root is the convention,
|
||||||
relative to the package root, each scanned recursively for cubes. Several
|
scanned recursively, and a bundle that follows it needs no nopy-specific field at
|
||||||
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
|
all. Naming the package in `cubePackages` is already the statement that cubes are
|
||||||
must stay inside the package — a path escaping the root is refused, not resolved.
|
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
|
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
|
||||||
loads (the extension carries the day), but anything it imports relatively will
|
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:
|
Some things worth deciding once:
|
||||||
|
|
||||||
- **Version the bundle independently of nopy.** There is no compatibility check
|
- **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.
|
the nopy version you test against in your README.
|
||||||
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
|
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
|
||||||
and breaks any manifest listing it as a dependency, including manifests in
|
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 |
|
| 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' 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. |
|
| `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`. |
|
||||||
| `'./cubes' does not exist in …` | 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. |
|
| `'…' 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. |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ A cube bundle is an npm package with a `nopy` field:
|
|||||||
"name": "@acme/cubes-net",
|
"name": "@acme/cubes-net",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"nopy": { "cubes": ["./cubes"] },
|
|
||||||
"files": ["cubes", "README.md", "LICENSE"],
|
"files": ["cubes", "README.md", "LICENSE"],
|
||||||
"keywords": ["nopy", "nopy-cubes", "pyinfra"],
|
"keywords": ["nopy", "nopy-cubes", "pyinfra"],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -154,10 +153,16 @@ A cube bundle is an npm package with a `nopy` field:
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- `nopy.cubes` — directories relative to the package root, scanned exactly like
|
- **Cube location is a convention, `<root>/cubes`.** *(Amended after Phase 5;
|
||||||
`cubeDirs` entries. Required; a package listed in `cubePackages` without a
|
originally `nopy.cubes` was a required field.)* The field bought nothing a
|
||||||
`nopy` field is an error, not a silent skip. Listing it means the user expects
|
convention does not: it is not a discovery marker — naming the package in
|
||||||
cubes from it.
|
`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
|
- Both dependencies are **regular dependencies, not peers**, and both are
|
||||||
load-bearing: a manifest imports `Manifest` from `@bitsquare/nopy-cube` and `z`
|
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
|
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 not found on any candidate path
|
||||||
- `package.json` unparseable
|
- `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
|
- a `nopy.cubes` entry escapes the package root, or does not exist
|
||||||
|
|
||||||
### Wiring
|
### Wiring
|
||||||
@@ -625,7 +631,8 @@ runner alike.
|
|||||||
- resolves through a symlinked package directory (mimicking pnpm)
|
- resolves through a symlinked package directory (mimicking pnpm)
|
||||||
- resolves from the declaring config's directory, not `cwd`
|
- resolves from the declaring config's directory, not `cwd`
|
||||||
- missing package → error naming the spec
|
- 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
|
- `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
|
- last-wins dedupe when parent and child config both name a package
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ import { createRequire } from 'node:module';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { CubePackageRef } from '../nopy.config.js';
|
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. */
|
/** An installed cube package, located and validated. */
|
||||||
export interface CubePackage {
|
export interface CubePackage {
|
||||||
/** The name it was requested under. */
|
/** The name it was requested under. */
|
||||||
name: string;
|
name: string;
|
||||||
/** Absolute path to the package root. */
|
/** Absolute path to the package root. */
|
||||||
root: string;
|
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[];
|
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
|
* 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
|
* `cubePackages` is a statement that cubes are expected from it, and errors
|
||||||
@@ -75,27 +86,40 @@ export function resolveCubePackages(refs: CubePackageRef[]): {
|
|||||||
continue;
|
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 declared = manifest.nopy?.cubes;
|
||||||
|
const defaulted = declared === undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!Array.isArray(declared) ||
|
!defaulted &&
|
||||||
declared.length === 0 ||
|
(!Array.isArray(declared) ||
|
||||||
!declared.every((entry) => typeof entry === 'string')
|
declared.length === 0 ||
|
||||||
|
!declared.every((entry) => typeof entry === 'string'))
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`Cube package '${ref.spec}' declares no cubes. ` +
|
`Cube package '${ref.spec}': "nopy": { "cubes": … } in ${root}/package.json ` +
|
||||||
`Expected "nopy": { "cubes": ["./cubes"] } in ${root}/package.json.`
|
`must be a non-empty array of strings. Omit it to use the default, ./cubes.`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirs: string[] = [];
|
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);
|
const dir = path.resolve(root, entry);
|
||||||
|
|
||||||
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
||||||
errors.push(`Cube package '${ref.spec}': '${entry}' points outside the package.`);
|
errors.push(`Cube package '${ref.spec}': '${entry}' points outside the package.`);
|
||||||
} else if (!fs.existsSync(dir)) {
|
} 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 {
|
} else {
|
||||||
dirs.push(dir);
|
dirs.push(dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ export {
|
|||||||
outputExecutionPlan,
|
outputExecutionPlan,
|
||||||
summarizeResults,
|
summarizeResults,
|
||||||
} from './nopy.executor.js';
|
} 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';
|
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||||
// History management
|
// History management
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { loadConfig } from './nopy.config.js';
|
import { loadConfig } from './nopy.config.js';
|
||||||
|
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
|
||||||
import {
|
import {
|
||||||
clearHistory,
|
clearHistory,
|
||||||
formatHistoryList,
|
formatHistoryList,
|
||||||
@@ -19,7 +20,17 @@ import { nopy } from './nopy.main.js';
|
|||||||
import type { Channel } from './nopy.update.js';
|
import type { Channel } from './nopy.update.js';
|
||||||
import { formatCommand, selfUpdate, updateNotice } 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
|
* 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();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('nopy')
|
.name('nopy')
|
||||||
.version(version)
|
.version(versionLabel)
|
||||||
.description('A CLI tool for pyinfra script management and execution.')
|
.description('A CLI tool for pyinfra script management and execution.')
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
'after',
|
'after',
|
||||||
@@ -124,6 +139,11 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) {
|
if (options.json) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
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' };
|
if (useAuthKey) return { authMethod: 'ssh-key' };
|
||||||
const answers = await inquirer.prompt([
|
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',
|
name: 'authMethod',
|
||||||
message: 'Select authentication method:',
|
message: 'Select authentication method:',
|
||||||
choices: ['ssh-key', 'password'],
|
choices: ['ssh-key', 'password'],
|
||||||
@@ -118,7 +120,7 @@ export async function PasswordSelection(username: string): Promise<string> {
|
|||||||
export async function HostSelection(hosts: string[]): Promise<string> {
|
export async function HostSelection(hosts: string[]): Promise<string> {
|
||||||
const selectedHost = await inquirer.prompt([
|
const selectedHost = await inquirer.prompt([
|
||||||
{
|
{
|
||||||
type: 'list',
|
type: 'select',
|
||||||
name: 'host',
|
name: 'host',
|
||||||
message: 'Select host from inventory',
|
message: 'Select host from inventory',
|
||||||
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
||||||
|
|||||||
@@ -108,19 +108,48 @@ describe('resolveCubePackages', () => {
|
|||||||
expect(errors[0]).toMatch(/cannot read/);
|
expect(errors[0]).toMatch(/cannot read/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a package that declares no cubes', () => {
|
it('falls back to cubes/ when the package declares nothing', () => {
|
||||||
install(tmpDir, 'plain', {});
|
// 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, 'empty', { nopy: { cubes: [] } });
|
||||||
install(tmpDir, 'wrong-type', { nopy: { cubes: 'cubes' } });
|
install(tmpDir, 'wrong-type', { nopy: { cubes: 'cubes' } });
|
||||||
install(tmpDir, 'not-strings', { nopy: { cubes: [1] } });
|
install(tmpDir, 'not-strings', { nopy: { cubes: [1] } });
|
||||||
|
|
||||||
const { packages, errors } = resolveCubePackages(
|
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(packages).toEqual([]);
|
||||||
expect(errors).toHaveLength(4);
|
expect(errors).toHaveLength(3);
|
||||||
for (const error of errors) expect(error).toMatch(/declares no cubes/);
|
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', () => {
|
it('reports a cube directory that does not exist', () => {
|
||||||
|
|||||||
@@ -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 = () => {};
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
describe('VariableAssignment', () => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
port: z.number().default(8080).describe('Listen port'),
|
port: z.number().default(8080).describe('Listen port'),
|
||||||
|
|||||||
Reference in New Issue
Block a user