Skip to content

Commit 5895119

Browse files
fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command (#21522)
Fixes #21496 Clause-②: no This is the TEXT-face half of the exit-signal family. The JSON-face half landed as #21495 (`2ee8383f4e`); its card, #21434, is done. ## What was wrong `this.exit(1)` does not end the process. It throws oclif's exit signal (`code: 'EEXIT'`). When the call sits inside a `try`, that `try`'s own `catch` sees the signal first. In `os package install`, `os package publish` and `os plugin sign`, the `catch` reported whatever it caught, so the signal came back out as a second error line. The exit status was right every time; the extra line was the defect. Measured at the public door: the CLI run from source through `bin/run-dev.js`, from a scratch directory. | command | before (`f9a8eb889e`) | after (`2b562ed58c`) | |---|---|---| | `os package install ./does-not-exist.json` | `✗ Cannot read artifact: ENOENT …`, then `✗ EEXIT: 1`; exit 1 | `✗ Cannot read artifact: ENOENT …` only; exit 1 | | `os package publish ./does-not-exist.json --token t --server URL` | `✗ Cannot read artifact: ENOENT …`, then `✗ EEXIT: 1`; exit 1 | the first line only; exit 1 | | `os package publish ./artifact.json --token t --server STUB --icon-file ./icon.bmp` (a local stub answering the package registration with 200) | THREE lines: `✗ Cannot infer image type from '…icon.bmp'…`, then `✗ Cannot read --icon-file '…icon.bmp': EEXIT: 1`, then `✗ EEXIT: 1`; exit 1 | the first line only; exit 1 | All six runs wrote nothing to stderr. `os plugin sign` has one exit inside a `try`: the self-verification refusal. No real key reaches it at the public door. I signed with RSA and Ed25519 keys through the CLI, and with Ed25519, Ed448, RSA, RSA-PSS, EC and DSA keys through `node:crypto` directly; every signature verified against its own key. So that refusal is measured in-process, with `verifyPayload` replaced by a seam (below). Before the fix it printed `✗ Self-verification of the produced signature failed.` and then `✗ Self-verification error: EEXIT: 1`. After the fix it prints the first line only. The exit status is 1 both times. ## The fix The ruled idiom (#21434, `5957176280`): each affected `catch` opens with `if (isExitSignal(error)) throw error;`, the predicate in `src/utils/format.ts`. No second helper, and `format.ts` is not edited. - `packages/cli/src/commands/package/install.ts`: the outer `catch` (was `:248`). - `packages/cli/src/commands/package/publish.ts`: the icon step's `catch` (was `:667`) and the outer `catch` (was `:796`). - `packages/cli/src/commands/plugin/sign.ts`: the self-verification `catch` (was `:101`). ## Closing the class: the pin's population is now every command The pin's analyzer is shape-based. Before this PR its population was "declares a boolean `json` flag, or a flag whose `options` include `'json'`". Now there is no member predicate: every module under `src/commands`, the `src/` twin of oclif's `pattern` command table, is a member. A later command of any face enters by existing. A new assertion holds the population equal to the walk, so a filter that comes back goes red. **The widened population's red list, measured BEFORE the fix** (the widened pin run against the unfixed commands): **3 members**, exactly the three the card named. No further command was flagged. | member | `this.exit`-in-`try` sites | (site, swallowing catch) pairs | |---|---|---| | `os package install` | 6 (`:115`, `:123`, `:155`, `:161`, `:194`, `:210`) | 6, all in the catch at `:248` | | `os package publish` | 15 | 17: 15 in the catch at `:796`, plus `:649` and `:662` in the icon catch at `:667` as well | | `os plugin sign` | 1 (`:98`) | 1, the catch at `:101` | The site counts match the card's 6, 15 and 1. The widened population is 65 commands: the 46 JSON-capable ones from the first population, and 19 with a text face only. It holds 127 `this.exit`-in-`try` sites: 105 from before, and 22 in the three commands above. The floors move to 65 and 127. The first population's 46 is kept as a separate floor on the face labels. **Name (A4): renamed.** `git mv packages/cli/test/json-exit-signal.pin.test.ts packages/cli/test/exit-signal.pin.test.ts`. The population is no longer JSON-only, so the old name would have described a filter that no longer exists. Nothing in the tree referenced the old path (`git grep json-exit-signal` returned 0 hits). The header now describes the widened population. Its account of how a new command enters is rewritten: the module exists under `src/commands`, whatever faces it has. The face is still read off `static flags`, but only to label each case (`--json`, `--FLAG json` or `text`). ## Text-face pins A fourth `describe` drives the three commands in-process through oclif, with five refusal cases: - `package install`: an unreadable artifact (refused inside a nested `catch`), and a runtime with no install-local endpoint (refused in the `try` itself, behind a stubbed `fetch` answering 404); - `package publish`: an unreadable artifact, and an `--icon-file` whose type it cannot infer (behind a stubbed `fetch` answering the registration); - `plugin sign`: a failed self-verification (`verifyPayload` replaced by a seam; `signPayload` stays real). Each case asserts that the refusal is ONE `✗` line, about the path or URL the case chose; that `EEXIT` appears nowhere in the output; and that the exit status is 1. Message wording is not pinned. The cases stay in the unit tier: nothing is spawned, no kernel boots, and every file they read is written at module scope. **Reverse verification.** All three command files were restored to `f9a8eb889e` by `git restore --source`, under a trap that restores them on exit. On HEAD `2b562ed58c`, before the run, `isExitSignal` counted 2, 3 and 2 in the three files; after the restore it counted 0, 0 and 0, and each blob was compared against the `f9a8eb889e` blob to prove the restore landed. The pin went red as expected: **8 failed, 93 passed of 101**. Three were structural members and five were the driven cases, which printed 2, 2, 2, 3 and 2 error lines. The files were then restored to HEAD and proven by blob hash and an empty `git diff HEAD`. The pin imports the commands by relative `src/` path, so no `dist/` build was involved. ## Verification - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts`: **101 passed**. That is 15 fixtures, 3 population checks, 65 members, 13 JSON-face driven cases and 5 text-face driven cases. - Every unit test that drives an edited command, plus `plugin-publish-visibility`: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` over the pin, `package-install-storage-dir`, `package-publish-error-envelope`, `package-publish-manifest-id`, `package-publish-namespace`, `package-publish-visibility`, `plugin-sign`, `publish-active-environment-store` and `plugin-publish-visibility`: **9 files, 172 passed**. - The cli integration tier for the edited `package install`: `--project integration` over `package-install-local-boot-steps.integration.test.ts` and `package-install-local-handlers.integration.test.ts`, which spawn `os package install` against a live runtime: **2 files, 24 passed**. - `pnpm --filter @objectstack/cli run typecheck` (`tsc --noEmit` and `check:test-typecheck`, whose program includes `test/**`): exit 0. The test layer's ledger is unchanged (3 files, 28 errors, 6 pinned signatures). - Gates, run on HEAD `adcc2d77f2`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **66** commands, and all 66 exited 0. Four of them first answered `PREREQUISITE NOT MET` (exit 3) because the tree had no `dist/`: `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`. After `turbo run build --filter='!@objectstack/docs'` they ran again and exited 0. `dispatch-gates --ran`: `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN`. I also ran four roster gates whose roster sits in a directory this diff touches; all four exited 0 (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). The tool's own outside-the-list blocks are NOT MEASURED here and are left to CI: the 5 path-scheduled CI jobs, the 4 type-check lanes, the 6 workflow-valued families, the 11 wide-population families, and the other 50 artifact-roster families. - Lint, delivered as a proven narrowing rather than a whole-repo `pnpm lint`, on HEAD `adcc2d77f2`. I ran `eslint --no-inline-config --format json` (the `pnpm lint` binary and flags) over the five paths this diff adds or modifies. The checked population comes from eslint's own answer: the JSON has 5 entries. The 4 TypeScript files are linted with 0 errors and 0 warnings, and the changeset is reported "File ignored because no matching configuration was supplied". The only deleted path is the renamed pin. Narrowing to those files cannot change any other file's verdict. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no `projectService`, no typed rules, as its own header states and a grep confirms). Its plugins are inline AST rules, and the only files it reads at load are two baselines this diff does not touch. Each verdict therefore depends on the file's own text and the config alone. ## Acceptance notes - **`this.error(…)` inside a `try` is outside the analyzer, which is seeded with `exit` only.** Over the whole population on `f9a8eb889e`, three such calls sit inside a `try`. `compile.ts:1046` is in the same `catch` block as a `this.exit(1)` the pin already judges green. `init.ts:1359` and `init.ts:1388` sit under an outer `catch` that re-reports them. Measured at the public door: `os init demo -p npm` with an unreachable registry printed `✗ Project scaffolded, but dependency installation failed.`, then `✗ Dependency installation failed` from that `catch`, then `Error: Dependency installation failed` on stderr, and exited 2. That is the same family through a different signal. Widening the analyzer's seed would reshape it, so it is reported here and in the report, not fixed. The header's "does NOT cover" section states it. - **`os plugin sign` accepts a non-Ed25519 key and labels the result `ed25519:`.** With an RSA key it exits 0 and writes `ed25519:default:` followed by a 342-character signature; an Ed25519 key gives 86 characters. The contract in `packages/core/src/security/plugin-artifact-signature.ts` reads "This is the CANONICAL Ed25519 detached-signature contract". `signPayload` and `verifyPayload` both pass `null` as the algorithm and never check the key type. This is outside this card; it is reported, not fixed. - The public-door readings ran the CLI from source (`bin/run-dev.js`), not a built `dist/`. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad7c351 commit 5895119

5 files changed

Lines changed: 275 additions & 59 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os package install`, `os package publish` and `os plugin sign` print one error line per refusal (#21496)
6+
7+
Clause-②: no
8+
9+
`os package install ./does-not-exist.json` printed `✗ Cannot read artifact: ENOENT …` and then a second line, `✗ EEXIT: 1`. The exit status, 1, was right. The extra line came from the command's own `catch`: the `this.exit(1)` inside its `try` throws oclif's exit signal, and the `catch` reported the signal as an error.
10+
11+
The same `catch` sat in two more commands:
12+
13+
- **`os package publish`.** Every refusal it makes printed the extra `✗ EEXIT: 1` line. Examples are an unreadable artifact, an invalid manifest id, no cloud login, a failed package registration and a failed version publish. An `--icon-file` whose image type it cannot infer printed three error lines: the refusal, then `✗ Cannot read --icon-file '…': EEXIT: 1`, then `✗ EEXIT: 1`.
14+
- **`os plugin sign`.** A signature that failed its self-verification printed `✗ Self-verification error: EEXIT: 1` under the refusal.
15+
16+
Each refusal is now one error line, and every exit status is unchanged. A script that filtered out the `EEXIT` line can drop that filter.

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { readFile } from 'node:fs/promises';
2626
import { existsSync } from 'node:fs';
2727
import { resolve as resolvePath } from 'node:path';
2828
import { Args, Command, Flags } from '@oclif/core';
29-
import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js';
29+
import { printHeader, printKV, printSuccess, printError, printStep, isExitSignal } from '../../utils/format.js';
3030

3131
export default class PackageInstall extends Command {
3232
static override description =
@@ -246,6 +246,7 @@ export default class PackageInstall extends Command {
246246
console.log(` ${storageDir}`);
247247
}
248248
} catch (error) {
249+
if (isExitSignal(error)) throw error;
249250
printError((error as Error).message);
250251
this.exit(1);
251252
}

‎packages/cli/src/commands/package/publish.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { readFile } from 'node:fs/promises';
3232
import { resolve as resolvePath, basename, dirname, isAbsolute } from 'node:path';
3333
import { Args, Command, Flags } from '@oclif/core';
3434
import { PackageSchema } from '@objectstack/spec/marketplace';
35-
import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js';
35+
import { printHeader, printKV, printSuccess, printError, printStep, isExitSignal } from '../../utils/format.js';
3636
import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js';
3737
import { resolveCloudActiveEnvironmentId } from '../../utils/active-environment.js';
3838
import { readErrorMessage } from '../../utils/response-envelope.js';
@@ -665,6 +665,7 @@ export default class PackagePublish extends Command {
665665
const iconUrl = iconRes.body?.data?.icon_url ?? iconRes.body?.icon_url;
666666
if (iconUrl) printKV(' Icon URL', String(iconUrl));
667667
} catch (err: any) {
668+
if (isExitSignal(err)) throw err;
668669
printError(`Cannot read --icon-file '${iconPath}': ${err.message}`);
669670
this.exit(1);
670671
return;
@@ -794,6 +795,7 @@ export default class PackagePublish extends Command {
794795
for (const v of violations) console.log(` • ${v}`);
795796
}
796797
} catch (error) {
798+
if (isExitSignal(error)) throw error;
797799
printError((error as Error).message);
798800
this.exit(1);
799801
}

‎packages/cli/src/commands/plugin/sign.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { createPublicKey } from 'node:crypto';
1919
import { resolve as resolvePath } from 'node:path';
2020
import { Args, Command, Flags } from '@oclif/core';
2121
import { parseSignature, signPayload, verifyPayload } from '@objectstack/core';
22-
import { printError, printHeader, printKV, printStep, printSuccess } from '../../utils/format.js';
22+
import { isExitSignal, printError, printHeader, printKV, printStep, printSuccess } from '../../utils/format.js';
2323
import { OSPLUGIN_EXT } from '../../utils/osplugin.js';
2424

2525
export default class PluginSign extends Command {
@@ -99,6 +99,7 @@ export default class PluginSign extends Command {
9999
return;
100100
}
101101
} catch (err) {
102+
if (isExitSignal(err)) throw err;
102103
printError(`Self-verification error: ${(err as Error).message}`);
103104
this.exit(1);
104105
return;

0 commit comments

Comments
 (0)