Skip to content

Commit a4015ab

Browse files
fix(cli): a flag given without its value (os dev -d) opens with the INVOCATION ERROR line (#29004)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSSJD6C4GLhhpJrw1FTNZZn)_ Fixes #28931 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`os dev -d`、`os serve --port`、`os migrate meta --from` 这类「旗标没给值」的调用,现在和未知旗标一样,stderr 第一行先打出 `objectstack: INVOCATION ERROR — Flag --database expects a value. The command never ran: nothing was started and nothing is listening.`。同类的另外三种解析失败也一并补上:带选项列表的旗标没给值(`--log-level`)、单值旗标给了两次(`-p 1 -p 2`)、旗标自带解析拒收的值(`--eval-min abc`)。 - 为什么改:原来这几种只有 oclif 一行 `Error: Flag --database expects a value`,退出码 2。后台跑时看起来像服务起来又挂了,读的人会去排查一个从没启动过的应用。 - 风险与回滚:只改 stderr 首行;接受/拒收的输入、退出码 2、oclif 自己的后续行都不变。命令解析成功后在运行中失败的错误(`this.error()`)不加横幅,有正反两个测试钉住。回滚即还原本 PR。 - 先看:`packages/cli/src/utils/invocation.ts` 的 `isInvocationError`(新增按抛出位置识别:错误自身栈里有 oclif 解析器模块的帧);`src/utils/invocation.test.ts` 新增的用例。 - 两分钟验证:在任一应用目录跑 `node packages/cli/bin/run.js dev -d`,预期退出码 2,stderr 第一行是 `objectstack: INVOCATION ERROR — Flag --database expects a value. ...`,下面仍是 oclif 原来那一行。 - 需要你做的:无,席位落地。 ## What changes - **`isInvocationError()` gains a second reading: the throw site.** oclif's parser throws a plain `CLIError` (no `parse` / `showHelp` own fields) for a flag given without its value, a single-valued flag given twice and a bare option-list flag, and passes a flag's own `parse` failure (`Flags.integer` on `abc`) through. Those errors carry nothing a runtime failure does not: a command that parsed and then called `this.error()` throws the same class, with the same own fields (`stack,message,code,oclif,skipOclifErrorHandling,suggestions`) and the same `oclif.exit` of 2 (measured, and pinned in the unit file). So the order's hypothesised readings (class / own fields / `oclif.exit`) cannot separate them. What differs is where the error was thrown: the predicate reads the error's own `stack` for a frame inside `@oclif/core/lib/parser/` (either path separator). A command body never runs with that module on the stack, because `this.parse()` has returned before it does. No English message text is matched. - **The `CLIParseError` reading is unchanged** and still covers an unknown flag, a refused option value, a missing required flag, a missing required arg and an unexpected arg (all five measured printing the banner on `main` already). - **Degrades towards silence.** A stack it cannot read (stack-trace limit 0, a moved parser module) answers false, which is the behaviour before this PR, never a banner on a runtime failure. A moved module is caught by the unit file, which throws every case through the real `Parser.parse`. - **Unchanged:** which inputs are accepted or refused, the exit code (2), and oclif's own lines after the banner. No new flag. ## Tests - **Unit, `packages/cli/src/utils/invocation.test.ts`:** recognised through the real parser: missing value (`-d`), bare option-list flag, flag given twice, `Flags.integer` refusal; missing required flag, missing required arg and unexpected arg kept recognised. Not claimed: a real oclif `Command` that parsed and then called `this.error()` (asserted to share class, own fields and `oclif.exit: 2` with the parse-time error), and a bare `Errors.error()`. `invocationFailureLine` names `Flag --database expects a value` for `dev -d` and returns `undefined` for the runtime command. - **Spawn, `packages/cli/test/invocation-loudness.e2e.test.ts` (nightly e2e tier, already spawning `dev --no-ui`):** one more spawn, `dev -d`: exit 2, oclif's own line kept, the INVOCATION ERROR line first, stdout empty. <details><summary>Measured on main (base 135054e), built bin/run.js, from examples/app-todo</summary> | invocation | exit | banner on main | banner on head | |---|---|---|---| | `dev --no-compile -p PORT -d` | 2 | no (`Error: Flag --database expects a value`) | yes | | `serve --port` | 2 | no | yes | | `migrate meta --from` | 2 | no | yes | | `dev --no-compile --log-level` | 2 | no (`expects one of these values`) | yes | | `dev --no-compile -p A -p B` | 2 | no (`can only be specified once`) | yes | | `lint --eval-min abc` | 2 | no (`Parsing --eval-min` / `Expected an integer`) | yes | | `plugin keygen` (missing required flag) | 2 | yes | yes | | `plugin uninstall` (missing required arg) | 2 | yes | yes | | `plugin uninstall a b` (unexpected arg) | 2 | yes | yes | | `serve --artifact x` (nonexistent flag, control) | 2 | yes | yes | | `dev --no-compile -p abc` (runtime refusal) | 1 | no | no | | `plugin uninstall no-such-plugin` (runtime) | 1 | no | no | Error shapes (real `Parser.parse` and a real `Command`): every parse-time plain error and the runtime `this.error()` are `CLIError`, own fields `stack,message,code,oclif,skipOclifErrorHandling,suggestions`, `oclif {"exit":2}`, `code undefined`. Parse-time stacks carry `@oclif/core/lib/parser/parse.js` frames; the runtime stack carries `lib/errors/error.js` and `lib/command.js` only. </details> <details><summary>Red on base, green on head, ablations</summary> - Red (base source, new tests): unit `5 failed | 21 passed (26)`, the four parse-time recognitions and the `dev -d` line; the negatives pass on base. Spawn `1 failed | 13 passed (14)`, "puts the INVOCATION ERROR line FIRST on stderr". - Green on head `45c1b2f0`: unit `26 passed (26)`; spawn `14 passed (14)`. - Ablation 1 (`scripts/ablation-replace.mjs`: the throw-site reading forced false): unit `5 failed | 21 passed`, spawn `1 failed | 13 passed`, the same cases as red-first. Restored: blob `78901ca1` == HEAD, `git diff HEAD` empty. - Ablation 2, the negative pin (every `Error` claimed, the naive class/exit reading): unit `5 failed | 21 passed`, exactly the five runtime-not-claimed cases. Restored: blob == HEAD, `git diff HEAD` empty. - Both test files import `src/` (the spawn runs `bin/run-dev.js` under tsx), so no `dist/` leg; the head probe table above ran the rebuilt `bin/run.js`. </details> ## Acceptance notes - The banner's reason is the first line of oclif's message, so for a flag-parse refusal it reads `INVOCATION ERROR — Parsing --eval-min.` and for a missing required flag (unchanged from `main`) `INVOCATION ERROR — The following error occurred:.`; the actual reason is on oclif's lines right below. Left as is: `reasonOf()` is shared by every class and the order keeps this change to recognition. - One more cold spawn in `invocation-loudness.e2e.test.ts` (five instead of four); the file is in the nightly e2e tier, not the PR queue. ## Verification All at head `45c1b2f0`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 61 derived families. `--ran` reconciliation exit 0: **60 run, exit 0; 1 NOT MEASURED** (`check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET: needs every package's `dist/`; CI measures it). `check:i18n-coverage` first exited 3 (examples' plugin `dist/` absent), exit 0 after building the closure it names. `check-plugin-teardown-shape --self-test` ran after `git fetch --depth=1` of its pinned fixture commit. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: `368 passed (368)` files, `5205 passed (5205)` tests (lock VERDICT command-exit 0). The integration tier is CI's; the one spawn file touched is nightly-tier and was run alone (`14 passed`). - `pnpm --filter @objectstack/cli typecheck` (incl. `check:test-typecheck`): exit 0. - eslint `--no-inline-config` over `packages/cli`: exit 0. - CI check-runs: not waited on at hand-back; the seat reads them. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pvjo67wc1GwKuRXFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f67bbec commit a4015ab

4 files changed

Lines changed: 257 additions & 13 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): a flag given without its value (`os dev -d`) now opens with the INVOCATION ERROR line
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`os dev -d`, `os serve --port` and `os migrate meta --from` used to exit 2 with only oclif's `Error: Flag --NAME expects a value` line. Backgrounded, that reads like a server that booted and died. The CLI now writes its one-line `objectstack: INVOCATION ERROR — … The command never ran: nothing was started and nothing is listening.` first, as it already did for an unknown flag or a refused value.
11+
12+
The same line now also leads three other parse failures that oclif raises the same way: a flag with an option list given without its value (`os dev --log-level`), a single-valued flag given twice (`os dev -p 1 -p 2`), and a value a flag's own parser refused (`os lint --eval-min abc`).
13+
14+
Nothing else changes: the same inputs are accepted and refused, the exit code is still 2, and oclif's own lines follow unchanged. A command that parsed and then failed while running gets no banner.
15+
16+
The banner's reason now carries oclif's whole message on one line instead of only its first line, so `os plugin keygen` reads `The following error occurred: Missing required flag out` and `os lint --eval-min abc` reads `Parsing --eval-min; Expected an integer but received: abc` where both used to name only the header.

‎packages/cli/src/utils/invocation.test.ts‎

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
2424
import { tmpdir } from 'node:os';
2525
import { join } from 'node:path';
2626
import { pathToFileURL } from 'node:url';
27-
import { Flags, Parser } from '@oclif/core';
27+
import { Args, Command, Errors, Flags, Parser } from '@oclif/core';
2828

2929
import { CLI_NAME } from './format.js';
3030
import {
@@ -54,6 +54,50 @@ async function realNonExistentFlagError(): Promise<unknown> {
5454
throw new Error('the parser accepted --no-ui: this fixture no longer reproduces the measured failure');
5555
}
5656

57+
/**
58+
* Whatever the REAL parser throws for `argv` against `input` — the shared shape
59+
* of the parse-time cases below. Every one of them is thrown through
60+
* `Parser.parse`, the public entry point, for the same reason as
61+
* {@link realNonExistentFlagError}: the predicate is only worth anything against
62+
* what oclif actually throws.
63+
*/
64+
async function realParseFailure(argv: string[], input: Parameters<typeof Parser.parse>[1]): Promise<unknown> {
65+
try {
66+
await Parser.parse(argv, { strict: true, ...input });
67+
} catch (error) {
68+
return error;
69+
}
70+
throw new Error(`the parser accepted ${JSON.stringify(argv)}: this fixture no longer reproduces a parse failure`);
71+
}
72+
73+
/**
74+
* A command that PARSED fine and then failed in its own body with
75+
* `this.error()` — the commonest runtime failure in this CLI. oclif gives it
76+
* the same class, the same own fields and the same exit 2 as the parse-time
77+
* `CLIError`s, which is why the predicate cannot read any of those.
78+
*/
79+
async function realRuntimeCommandError(): Promise<unknown> {
80+
class RanAndFailed extends Command {
81+
static override flags = { database: Flags.string({ char: 'd' }) };
82+
async run(): Promise<void> {
83+
await this.parse(RanAndFailed);
84+
this.error('objectstack.config.ts not found');
85+
}
86+
}
87+
// A bare oclif root: loading this package's own config would import every
88+
// command module, ~5 s for one fixture.
89+
const root = fixtureDir();
90+
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '0.0.0', oclif: {} }));
91+
try {
92+
await RanAndFailed.run(['-d', 'file.db'], { root });
93+
} catch (error) {
94+
return error;
95+
} finally {
96+
rmSync(root, { force: true, recursive: true });
97+
}
98+
throw new Error('the command did not fail: this fixture no longer reproduces a runtime failure');
99+
}
100+
57101
function fixtureDir(): string {
58102
const dir = mkdtempSync(join(tmpdir(), 'os-invocation-'));
59103
return dir;
@@ -165,6 +209,65 @@ describe('[#10111] isInvocationError', () => {
165209
expect(isInvocationError(await realNonExistentFlagError())).toBe(true);
166210
});
167211

212+
// The parse failures oclif raises as a PLAIN `CLIError` from inside its
213+
// parser — no `parse` / `showHelp` fields — measured on `os dev -d`,
214+
// `os serve --port`, `os migrate meta --from`, `os dev --log-level`,
215+
// `os dev -p 1 -p 2` and `os lint --eval-min abc`: each exited 2 with no banner.
216+
it('recognises a flag given without its value (`os dev -d`)', async () => {
217+
const error = await realParseFailure(['-d'], { flags: { database: Flags.string({ char: 'd' }) } });
218+
expect(String((error as Error).message)).toBe('Flag --database expects a value');
219+
expect(isInvocationError(error)).toBe(true);
220+
});
221+
222+
it('recognises a flag with an option list given without its value (`os dev --log-level`)', async () => {
223+
const error = await realParseFailure(['--log-level'], {
224+
flags: { 'log-level': Flags.string({ options: ['debug', 'info'] }) },
225+
});
226+
expect(isInvocationError(error)).toBe(true);
227+
});
228+
229+
it('recognises a single-valued flag given twice (`os dev -p 1 -p 2`)', async () => {
230+
const error = await realParseFailure(['-p', '1', '-p', '2'], { flags: { port: Flags.string({ char: 'p' }) } });
231+
expect(isInvocationError(error)).toBe(true);
232+
});
233+
234+
it('recognises a value the flag’s own parser refused (`os lint --eval-min abc`)', async () => {
235+
const error = await realParseFailure(['--eval-min', 'abc'], { flags: { 'eval-min': Flags.integer() } });
236+
expect(isInvocationError(error)).toBe(true);
237+
});
238+
239+
// The other classes the parser raises at the same place already carry the
240+
// `CLIParseError` fields and printed the banner before; pinned so the
241+
// reading keeps covering them.
242+
it('recognises a missing required flag, a missing required arg and an unexpected arg', async () => {
243+
const required = await realParseFailure([], { flags: { out: Flags.string({ required: true }) } });
244+
const missingArg = await realParseFailure([], { args: { id: Args.string({ required: true }) } });
245+
const extraArg = await realParseFailure(['a', 'b'], { args: { id: Args.string({ required: true }) } });
246+
expect(isInvocationError(required)).toBe(true);
247+
expect(isInvocationError(missingArg)).toBe(true);
248+
expect(isInvocationError(extraArg)).toBe(true);
249+
});
250+
251+
it('does not claim a command that parsed and then failed — same class, fields and exit 2 as a parse failure', async () => {
252+
const runtime = await realRuntimeCommandError();
253+
const parseTime = await realParseFailure(['-d'], { flags: { database: Flags.string({ char: 'd' }) } });
254+
// The measured reason the predicate cannot read the error's class or fields.
255+
expect((runtime as Error).constructor).toBe((parseTime as Error).constructor);
256+
expect(Object.getOwnPropertyNames(runtime).sort()).toEqual(Object.getOwnPropertyNames(parseTime).sort());
257+
expect((runtime as { oclif?: unknown }).oclif).toEqual({ exit: 2 });
258+
expect(isInvocationError(runtime)).toBe(false);
259+
});
260+
261+
it('does not claim a bare `Errors.error()` thrown outside the parser', () => {
262+
let thrown: unknown;
263+
try {
264+
Errors.error('ECONNREFUSED 127.0.0.1:3000');
265+
} catch (error) {
266+
thrown = error;
267+
}
268+
expect(isInvocationError(thrown)).toBe(false);
269+
});
270+
168271
it('does not claim an ordinary runtime failure', () => {
169272
expect(isInvocationError(new Error('ECONNREFUSED 127.0.0.1:3000'))).toBe(false);
170273
expect(isInvocationError(undefined)).toBe(false);
@@ -185,6 +288,52 @@ describe('[#10111] invocationFailureLine', () => {
185288
expect(line).toContain(`Invoked as: ${INVOCATION_PREFIX} dev --no-ui`);
186289
});
187290

291+
it('names the flag that is missing its value (`os dev -d`)', async () => {
292+
const error = await realParseFailure(['-d'], { flags: { database: Flags.string({ char: 'd' }) } });
293+
const line = invocationFailureLine(error, ['dev', '-d']);
294+
expect(line).toBeDefined();
295+
expect(line!.startsWith(`${INVOCATION_PREFIX}: INVOCATION ERROR — Flag --database expects a value. `)).toBe(true);
296+
expect(line).toContain(`Invoked as: ${INVOCATION_PREFIX} dev -d`);
297+
});
298+
299+
// oclif spreads these reasons over several lines under a generic header; the
300+
// banner used to keep only the header ("The following error occurred:.",
301+
// "Parsing --eval-min.") and so named no reason at all.
302+
it('names the missing required flag, not only oclif’s header (`os plugin keygen`)', async () => {
303+
const error = await realParseFailure([], { flags: { out: Flags.string({ char: 'o', required: true }) } });
304+
expect(String((error as Error).message).split('\n')[0]).toBe('The following error occurred:');
305+
const line = invocationFailureLine(error, ['plugin', 'keygen']);
306+
expect(line).toContain('INVOCATION ERROR — The following error occurred: Missing required flag out. The command never ran');
307+
});
308+
309+
it('names the value a flag’s parser refused, not only `Parsing --X` (`os lint --eval-min abc`)', async () => {
310+
const error = await realParseFailure(['--eval-min', 'abc'], { flags: { 'eval-min': Flags.integer() } });
311+
const line = invocationFailureLine(error, ['lint', '--eval-min', 'abc']);
312+
expect(line).toContain('INVOCATION ERROR — Parsing --eval-min; Expected an integer but received: abc. The command never ran');
313+
});
314+
315+
it('keeps a joined reason on one line, free of colour codes and of the help tail', async () => {
316+
const error = await realParseFailure([], { args: { id: Args.string({ description: 'The installed plugin id', required: true }) } });
317+
const line = invocationFailureLine(error, ['plugin', 'uninstall']);
318+
expect(line).toContain('INVOCATION ERROR — Missing 1 required arg: id The installed plugin id. The command never ran');
319+
expect(line).not.toContain('\n');
320+
expect(line).not.toContain('See more help');
321+
const dimmed = Object.assign(new Error('The following error occurred:\n \u001b[2mMissing required flag out\u001b[22m'), { parse: {}, showHelp: false });
322+
expect(invocationFailureLine(dimmed, ['plugin', 'keygen'])).toContain('occurred: Missing required flag out. The command');
323+
expect(invocationFailureLine(dimmed, ['plugin', 'keygen'])).not.toContain('\u001b');
324+
});
325+
326+
it('caps a long joined reason so the line cannot run on', () => {
327+
const long = Object.assign(new Error(`Missing 9 required args:\n${'an argument with a long description\n'.repeat(20)}`), { parse: {}, showHelp: true });
328+
const line = invocationFailureLine(long, ['plugin', 'uninstall']);
329+
expect(line).toContain('.... The command never ran');
330+
expect(line!.length).toBeLessThan(450);
331+
});
332+
333+
it('returns undefined for a command that parsed and then failed', async () => {
334+
expect(invocationFailureLine(await realRuntimeCommandError(), ['dev', '-d', 'file.db'])).toBeUndefined();
335+
});
336+
188337
it('drops oclif’s `See more help with --help` tail, which is the second line of the message', async () => {
189338
const error = await realNonExistentFlagError();
190339
expect(String((error as Error).message)).toContain('See more help with --help');

‎packages/cli/src/utils/invocation.ts‎

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,31 +133,88 @@ export function moduleEntryMisuseLines(selfPath: string, binPath: string): [stri
133133
];
134134
}
135135

136+
/**
137+
* A stack frame inside `@oclif/core`'s parser module (`lib/parser/`), on
138+
* either path separator.
139+
*/
140+
const OCLIF_PARSER_FRAME = /[\\/]@oclif[\\/]core[\\/]lib[\\/]parser[\\/]/;
141+
136142
/**
137143
* Is this one of oclif's ARGUMENT errors — a failure that happened while
138144
* parsing the invocation, before the command ran at all?
139145
*
140-
* Structural rather than `instanceof`: `@oclif/core` does not export
141-
* `CLIParseError` (`lib/parser/errors.js` is not in the package's `exports`
142-
* map), and reaching into an unexported path to type-test would be a worse
143-
* coupling than reading two own properties its constructor always sets. The
144-
* corpus this is checked against is the REAL parser's output —
145-
* `invocation.test.ts` throws the errors through `Parser.parse`, the public
146-
* entry point, instead of hand-rolling look-alikes.
146+
* Two readings, both structural, neither reading the English message:
147+
*
148+
* 1. `CLIParseError`'s own fields. Structural rather than `instanceof`:
149+
* `@oclif/core` does not export `CLIParseError` (`lib/parser/errors.js` is
150+
* not in the package's `exports` map), and reaching into an unexported
151+
* path to type-test would be a worse coupling than reading two own
152+
* properties its constructor always sets. This covers an unknown flag, a
153+
* refused option value, a missing required flag or arg and an unexpected
154+
* arg.
155+
* 2. The THROW SITE. The parser also throws a plain `CLIError` — a flag given
156+
* without its value (`os dev -d`), a single-valued flag given twice, a
157+
* flag with an option list given bare — and passes a flag's own `parse`
158+
* failure (`Flags.integer` on `abc`) through. Those carry nothing a
159+
* runtime failure does not: a command that parsed and then called
160+
* `this.error()` throws the same class, with the same own fields and the
161+
* same `oclif.exit` of 2. What differs is where it was thrown, so the
162+
* error's own stack is read for a frame inside oclif's parser module. A
163+
* command body never runs with that module on the stack — `this.parse()`
164+
* has returned before it does.
165+
*
166+
* Reading (2) degrades towards silence: a stack it cannot read (a stack-trace
167+
* limit of 0, a moved parser module) answers false, which is the behaviour
168+
* before it existed, never a banner on a runtime failure. The moved-module
169+
* case is caught by `invocation.test.ts`, which throws every case through the
170+
* REAL parser's public entry point (`Parser.parse`) instead of hand-rolling
171+
* look-alikes, and pins a real command's runtime `this.error()` as not
172+
* claimed.
147173
*/
148174
export function isInvocationError(error: unknown): boolean {
149175
if (!error || typeof error !== 'object') return false;
150176
const own = Object.prototype.hasOwnProperty;
151177
// Both are class fields on `CLIParseError`; `parse` alone would also match a
152178
// plain object that happens to carry parsed input.
153-
return own.call(error, 'parse') && own.call(error, 'showHelp');
179+
if (own.call(error, 'parse') && own.call(error, 'showHelp')) return true;
180+
if (!(error instanceof Error)) return false;
181+
const stack = error.stack;
182+
return typeof stack === 'string' && OCLIF_PARSER_FRAME.test(stack);
154183
}
155184

156-
/** The first line of a message, with oclif's `See more help with --help` tail dropped. */
185+
/** oclif's closing line on every parse error, which says nothing about this one. */
186+
const HELP_TAIL = 'See more help with --help';
187+
188+
/** SGR colour codes: oclif dims the reasons it lists under a header when stderr is a terminal. */
189+
const ANSI_SGR = /\u001b\[[0-9;]*m/g;
190+
191+
/** Longest reason the banner echoes, so a long argument list cannot push the line into a wrap. */
192+
const REASON_MAX = 200;
193+
194+
/**
195+
* oclif's message as ONE line, with its `See more help with --help` tail and
196+
* colour codes dropped.
197+
*
198+
* Several parse errors put a generic header on line one and the reason under
199+
* it: `The following error occurred:` over `Missing required flag out`,
200+
* `Parsing --eval-min` over `Expected an integer but received: abc`, `Missing
201+
* 1 required arg:` over the arg list. Keeping line one alone left a banner that
202+
* named no reason, so every line is kept, whitespace runs collapsed: a line
203+
* after a header ending in `:` follows it after a space, any other after `; `.
204+
*/
157205
function reasonOf(error: unknown): string {
158206
const message = (error as { message?: unknown } | null)?.message;
159-
const first = String(typeof message === 'string' ? message : '').split('\n')[0]?.trim() ?? '';
160-
return first || 'invalid arguments';
207+
const lines = String(typeof message === 'string' ? message : '')
208+
.replace(ANSI_SGR, '')
209+
.split('\n')
210+
.map((line) => line.replace(/\s+/g, ' ').trim())
211+
.filter((line) => line !== '' && line !== HELP_TAIL);
212+
let reason = '';
213+
for (const line of lines) {
214+
reason = reason === '' ? line : `${reason}${reason.endsWith(':') ? ' ' : '; '}${line}`;
215+
}
216+
if (reason === '') return 'invalid arguments';
217+
return reason.length > REASON_MAX ? `${reason.slice(0, REASON_MAX - 3)}...` : reason;
161218
}
162219

163220
/** The invocation as typed, capped so one long argument cannot push the line into a wrap. */

‎packages/cli/test/invocation-loudness.e2e.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ let linkDir: string;
9393
let barrel: Run;
9494
let barrelViaSymlink: Run;
9595
let unknownFlag: Run;
96+
let missingValue: Run;
9697
let version: Run;
9798

9899
beforeAll(async () => {
@@ -101,11 +102,12 @@ beforeAll(async () => {
101102
const link = join(linkDir, 'index.ts');
102103
symlinkSync(BARREL, link);
103104

104-
// Sequential on purpose: four cold tsx starts, each loading every command
105+
// Sequential on purpose: five cold tsx starts, each loading every command
105106
// module, in a container several agents share.
106107
barrel = await runTsx([BARREL], dir);
107108
barrelViaSymlink = await runTsx([link], dir);
108109
unknownFlag = await runTsx([CLI, 'dev', '--no-ui'], dir);
110+
missingValue = await runTsx([CLI, 'dev', '-d'], dir);
109111
version = await runTsx([CLI, '--version'], dir);
110112
}, RUN_TIMEOUT_MS);
111113

@@ -168,6 +170,26 @@ describe('[#10111] an unknown flag on `dev`', () => {
168170
});
169171
});
170172

173+
describe('[#28931] a flag given without its value on `dev`', () => {
174+
// oclif throws this one as a plain `CLIError` from inside its parser, not a
175+
// `CLIParseError`, so it used to exit 2 with oclif's one line and no banner.
176+
it('keeps exit 2 and oclif’s own line', () => {
177+
expect(missingValue.code).toBe(2);
178+
expect(missingValue.stderr).toContain('Error: Flag --database expects a value');
179+
});
180+
181+
it('puts the INVOCATION ERROR line FIRST on stderr', () => {
182+
const line = firstLine(missingValue.stderr);
183+
expect(line).toContain('objectstack: INVOCATION ERROR — Flag --database expects a value');
184+
expect(line).toContain('The command never ran');
185+
expect(line).toContain('Invoked as: objectstack dev -d');
186+
});
187+
188+
it('keeps stdout empty', () => {
189+
expect(missingValue.stdout).toBe('');
190+
});
191+
});
192+
171193
describe('[#10111] the success path the shims kept', () => {
172194
it('`--version` still exits 0 with the version on stdout and nothing added to stderr', () => {
173195
// The shims now inline what `execute()` did instead of calling it, so the

0 commit comments

Comments
 (0)