diff --git a/.changeset/cli-invocation-loudness.md b/.changeset/cli-invocation-loudness.md new file mode 100644 index 0000000000..9d303eb061 --- /dev/null +++ b/.changeset/cli-invocation-loudness.md @@ -0,0 +1,15 @@ +--- +"@objectstack/cli": patch +--- + +Two ways to invoke the CLI wrong used to present as a crashed boot. Both now say what they are. + +`node packages/cli/dist/index.js` — the package `main`, which is a re-export barrel — ran to completion, printed nothing and exited 0. Backgrounded, that is indistinguishable from a server that came up and died. It now writes two lines to stderr, the first saying that running this file starts nothing and the second naming `bin/run.js` as the CLI entry point, and exits 1. + +A rejected invocation such as `objectstack dev --no-ui` answered with oclif's error line followed by a full usage dump, and in a background log the dump is what the eye lands on. One line now goes to stderr ahead of it: + +``` +objectstack: INVOCATION ERROR — Nonexistent flag: --no-ui. The command never ran: nothing was started and nothing is listening. Invoked as: objectstack dev --no-ui +``` + +No flag surface changed: `dev` still rejects `--no-ui` (only `serve` declares `ui` with `allowNo`). What changed is what the CLI says when it rejects an invocation. diff --git a/packages/cli/bin/run-dev.js b/packages/cli/bin/run-dev.js index 774534ce47..033f8998e4 100644 --- a/packages/cli/bin/run-dev.js +++ b/packages/cli/bin/run-dev.js @@ -1,5 +1,39 @@ #!/usr/bin/env tsx -import { execute } from '@oclif/core'; +// The SOURCE entry point — same CLI, run from `src/` through tsx, used by this +// repo's gates and e2e suites so they do not depend on `packages/cli/dist` +// having been built. Not published (`files` does not name `bin/`, and only the +// `bin` target itself is packed automatically). +// +// The body is `execute({ development: true })` from @oclif/core 4.13.3 inlined, +// for the reason `bin/run.js` states: `execute` hands the error straight to +// `handle()`, which prints a usage dump, and #10111 needs one unmistakable line +// to land on stderr before it. `NODE_ENV` and `settings.debug` are what +// `development: true` sets — they are set here so this shim keeps behaving +// exactly as it did. +import { flush, handle, run, settings } from '@oclif/core'; -await execute({ type: 'esm', development: true, dir: import.meta.url }); +/** See `bin/run.js` — the same lazy import, against `src/` instead of `dist/`. */ +async function announceInvocationFailure(error) { + try { + const { invocationFailureLine } = await import('../src/utils/invocation.ts'); + const line = invocationFailureLine(error, process.argv.slice(2)); + if (line) process.stderr.write(`${line}\n`); + } catch { + // Stay quiet rather than replacing oclif's report with an error about the + // reporter itself. + } +} + +process.env.NODE_ENV = 'development'; +settings.debug = true; + +await run(process.argv.slice(2), import.meta.url) + .then(async (result) => { + flush(); + return result; + }) + .catch(async (error) => { + await announceInvocationFailure(error); + return handle(error); + }); diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 7a6c197a5f..3f35139b58 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -1,5 +1,48 @@ #!/usr/bin/env node -import { execute } from '@oclif/core'; +// The CLI entry point — `bin.objectstack` / `bin.os` in package.json, and the +// only file under `bin/` npm packs (it ships because it is the `bin` target; +// `files` never names the directory — see scripts/check-published-files.mjs). +// +// It used to be `await execute({ type: 'esm', dir: import.meta.url })`. What is +// inlined below IS `execute()` from @oclif/core 4.13.3, verbatim apart from the +// one added line, because `execute` swallows the error into `handle()` and +// there is no hook between the two. `handle()` writes the parse error and then +// a full usage dump; #10111 needs one unmistakable line to reach stderr FIRST, +// so a backgrounded runner that skims its log reads "the command never ran" +// instead of concluding that a server booted and died. +// +// ⛔ Nothing here changes which arguments the CLI accepts. `os dev --no-ui` is +// still rejected — it is only rejected legibly. +import { flush, handle, run } from '@oclif/core'; -await execute({ type: 'esm', dir: import.meta.url }); +/** + * Print the one-line invocation verdict, if this failure is one. + * + * Imported lazily, and deliberately: a static import of `../dist/` would make + * an UNBUILT tree fail with `Cannot find module …/dist/utils/invocation.js` + * instead of oclif's "command not found", which is the signature + * `scripts/cli-build-prerequisite.mjs` classifies for every gate that shells + * out to this CLI. The failure path is also the only path that needs it, so the + * cost stays off every successful run. + */ +async function announceInvocationFailure(error) { + try { + const { invocationFailureLine } = await import('../dist/utils/invocation.js'); + const line = invocationFailureLine(error, process.argv.slice(2)); + if (line) process.stderr.write(`${line}\n`); + } catch { + // Unbuilt or half-built tree. Stay quiet rather than replacing oclif's + // report with a module-resolution error about the reporter itself. + } +} + +await run(process.argv.slice(2), import.meta.url) + .then(async (result) => { + flush(); + return result; + }) + .catch(async (error) => { + await announceInvocationFailure(error); + return handle(error); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0d68437078..c1f56dd83a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { fileURLToPath } from 'node:url'; + +import { isProcessEntry, moduleEntryMisuseLines } from './utils/invocation.js'; + // ─── oclif Command Classes ────────────────────────────────────────── // Each command is auto-discovered by oclif from `src/commands/`. // These re-exports provide programmatic access for testing and integration. @@ -43,3 +47,26 @@ export { default as CloudWhoamiCommand } from './commands/cloud/whoami.js'; // ─── Package topic subcommands ────────────────────────────────────── export { default as PackagePublishCommand } from './commands/package/publish.js'; export { default as PackageInstallCommand } from './commands/package/install.js'; + +// ─── Entry-point guard (#10111) ───────────────────────────────────── +// This file is the package `main`. It is a LIBRARY entry — a barrel of +// re-exports with no side effects — so `node packages/cli/dist/index.js` used +// to run it to completion, print nothing and exit 0. Backgrounded, that is +// indistinguishable from a server that booted and died, and it sent the one +// measured reader off to debug the application instead of the invocation +// (#10087). The CLI entry point is `bin/run.js`, and now the barrel says so. +// +// The predicate lives in `./utils/invocation.js` with the reason it is not the +// usual one-line `argv[1] === import.meta.url`: every spelling of that in this +// repo goes silently inert through a symlink (#10086), which is this exact +// defect. `process.exitCode` rather than `process.exit()` because nothing runs +// after this and the write to a piped stderr must be allowed to drain — the +// truncation trap `utils/format.ts` documents for `--json` payloads. +if (isProcessEntry(process.argv[1], import.meta.url)) { + const lines = moduleEntryMisuseLines( + fileURLToPath(import.meta.url), + fileURLToPath(new URL('../bin/run.js', import.meta.url)), + ); + for (const line of lines) process.stderr.write(`${line}\n`); + process.exitCode = 1; +} diff --git a/packages/cli/src/utils/invocation.test.ts b/packages/cli/src/utils/invocation.test.ts new file mode 100644 index 0000000000..67bb63ffe3 --- /dev/null +++ b/packages/cli/src/utils/invocation.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The judgments behind #10111's two loud failures, unit-tested where a spawn + * cannot reach them. + * + * `test/invocation-loudness.e2e.test.ts` is the end-to-end half — it proves the + * lines actually reach a shell's stderr in the right ORDER, with the right exit + * status. What is pinned here instead is the part that is invisible from + * outside: which invocations the entry predicate calls "this process was + * pointed at me", and which errors count as an invocation error at all. + * + * The symlink and directory legs are the reason this file exists. #10086 + * measured ~8 spellings of the same entry guard across `scripts/`, all of them + * blind to symlinks, and every one of them makes its script silently inert — + * exit 0, no output. That is the exact defect #10111 removes, so a guard here + * with the same hole would have been the bug wearing the fix's clothes. Ablate + * `realOrSelf` out of `isProcessEntry` and the symlink and directory cases turn + * red; nothing else in this file moves. + */ + +import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Flags, Parser } from '@oclif/core'; + +import { CLI_NAME } from './format.js'; +import { + INVOCATION_PREFIX, + invocationFailureLine, + isInvocationError, + isProcessEntry, + moduleEntryMisuseLines, +} from './invocation.js'; + +/** + * A real `NonExistentFlagsError`, thrown by the REAL parser through its public + * entry point — the same error `os dev --no-ui` produces. Hand-rolling a + * look-alike would test the look-alike: `@oclif/core` does not export + * `CLIParseError`, so the structural predicate is only worth anything if it is + * checked against what oclif actually throws. + */ +async function realNonExistentFlagError(): Promise { + try { + await Parser.parse(['--no-ui'], { + flags: { ui: Flags.boolean({ description: 'as `dev` declares it — no allowNo' }) }, + strict: true, + }); + } catch (error) { + return error; + } + throw new Error('the parser accepted --no-ui: this fixture no longer reproduces the measured failure'); +} + +function fixtureDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-invocation-')); + return dir; +} + +describe('[#10111] isProcessEntry', () => { + it('is false when there is no entry argument (node --eval, the REPL)', () => { + expect(isProcessEntry(undefined, pathToFileURL(join(tmpdir(), 'anything.js')).href)).toBe(false); + expect(isProcessEntry('', pathToFileURL(join(tmpdir(), 'anything.js')).href)).toBe(false); + }); + + it('is true for the plain `node ` invocation', () => { + const dir = fixtureDir(); + try { + const entry = join(dir, 'entry.js'); + writeFileSync(entry, ''); + expect(isProcessEntry(entry, pathToFileURL(entry).href)).toBe(true); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('is true through a SYMLINK — the leg every spelling in #10086 gets wrong', () => { + const dir = fixtureDir(); + try { + const entry = join(dir, 'entry.js'); + const link = join(dir, 'link.js'); + writeFileSync(entry, ''); + symlinkSync(entry, link); + // `import.meta.url` names the REAL file (node resolves symlinks for the + // module graph); `process.argv[1]` stays as the caller typed it. Comparing + // only those two answers false here, and a guard that answers false goes + // silently inert. + expect(isProcessEntry(link, pathToFileURL(entry).href)).toBe(true); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('is true for `node `, where the entry argument names the index it resolved to', () => { + const dir = fixtureDir(); + try { + const pkg = join(dir, 'dist'); + mkdirSync(pkg); + const entry = join(pkg, 'index.js'); + writeFileSync(entry, ''); + expect(isProcessEntry(pkg, pathToFileURL(entry).href)).toBe(true); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('is false for an unrelated entry — an ordinary `import` must not be aborted', () => { + const dir = fixtureDir(); + try { + const entry = join(dir, 'entry.js'); + const other = join(dir, 'some-other-tool.js'); + writeFileSync(entry, ''); + writeFileSync(other, ''); + expect(isProcessEntry(other, pathToFileURL(entry).href)).toBe(false); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it('is false for a DIFFERENT file with the same basename', () => { + // The other half of #10086's finding: two scripts there match on basename, + // which fires on import as readily as it goes inert. + const dir = fixtureDir(); + try { + const here = join(dir, 'a'); + const there = join(dir, 'b'); + mkdirSync(here); + mkdirSync(there); + writeFileSync(join(here, 'index.js'), ''); + writeFileSync(join(there, 'index.js'), ''); + expect(isProcessEntry(join(there, 'index.js'), pathToFileURL(join(here, 'index.js')).href)).toBe(false); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); +}); + +describe('[#10111] moduleEntryMisuseLines', () => { + const [first, second] = moduleEntryMisuseLines('/w/packages/cli/dist/index.js', '/w/packages/cli/bin/run.js'); + + it('leads with the prefix a runner log can be grepped for', () => { + expect(first.startsWith(`${INVOCATION_PREFIX}: `)).toBe(true); + expect(second.startsWith(`${INVOCATION_PREFIX}: `)).toBe(true); + }); + + it('says on line one that running this file started nothing', () => { + expect(first).toContain('/w/packages/cli/dist/index.js'); + expect(first).toContain('starts nothing'); + }); + + it('names the real entry point — the question the reader is holding', () => { + expect(second).toContain('/w/packages/cli/bin/run.js'); + }); + + it('keeps each line on one line', () => { + expect(first).not.toContain('\n'); + expect(second).not.toContain('\n'); + }); +}); + +describe('[#10111] isInvocationError', () => { + it('recognises what the real oclif parser throws for an unknown flag', async () => { + expect(isInvocationError(await realNonExistentFlagError())).toBe(true); + }); + + it('does not claim an ordinary runtime failure', () => { + expect(isInvocationError(new Error('ECONNREFUSED 127.0.0.1:3000'))).toBe(false); + expect(isInvocationError(undefined)).toBe(false); + expect(isInvocationError('a string')).toBe(false); + expect(isInvocationError({ parse: {} })).toBe(false); + }); +}); + +describe('[#10111] invocationFailureLine', () => { + it('is ONE line naming the rejected flag and the fact that nothing ran', async () => { + const line = invocationFailureLine(await realNonExistentFlagError(), ['dev', '--no-ui']); + expect(line).toBeDefined(); + expect(line).not.toContain('\n'); + expect(line!.startsWith(`${INVOCATION_PREFIX}: INVOCATION ERROR — `)).toBe(true); + expect(line).toContain('Nonexistent flag: --no-ui'); + expect(line).toContain('The command never ran'); + expect(line).toContain('nothing is listening'); + expect(line).toContain(`Invoked as: ${INVOCATION_PREFIX} dev --no-ui`); + }); + + it('drops oclif’s `See more help with --help` tail, which is the second line of the message', async () => { + const error = await realNonExistentFlagError(); + expect(String((error as Error).message)).toContain('See more help with --help'); + expect(invocationFailureLine(error, ['dev', '--no-ui'])).not.toContain('See more help'); + }); + + it('returns undefined for a runtime failure, leaving oclif’s reporting untouched', () => { + expect(invocationFailureLine(new Error('boom'), ['serve'])).toBeUndefined(); + }); + + it('caps the echoed invocation so one long argument cannot wrap the line', async () => { + const line = invocationFailureLine(await realNonExistentFlagError(), ['dev', `--app=${'x'.repeat(400)}`]); + expect(line!.length).toBeLessThan(320); + expect(line).toContain('...'); + }); +}); + +describe('[#10111] the prefix', () => { + it('is the CLI name `format.ts` declares — kept in sync by this test, not an import', () => { + // `invocation.ts` imports nothing but node builtins on purpose: it is + // reached from the bin shims' failure path, and pulling `format.ts` in + // would drag chalk, zod and @objectstack/spec along with it. + expect(INVOCATION_PREFIX).toBe(CLI_NAME); + }); +}); diff --git a/packages/cli/src/utils/invocation.ts b/packages/cli/src/utils/invocation.ts new file mode 100644 index 0000000000..5d2c8ff923 --- /dev/null +++ b/packages/cli/src/utils/invocation.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two ways to invoke this CLI wrong that used to present as a CRASHED BOOT + * (#10111, carved from the #10087 finding), and the shapes that make each one + * read as what it actually is. + * + * Both were measured while a checklist runner booted the showcase app, and both + * cost the same thing: the reader went and debugged the APPLICATION, because + * nothing in what they saw said the failure happened before anything started. + * + * 1. `node packages/cli/dist/index.js` — the package `main`, which is a + * re-export barrel — ran to completion, printed nothing, and exited 0. + * Backgrounded, that is indistinguishable from a server that came up and + * died: the process is gone, nothing is listening, and every instinct for + * "did it start?" (exit code, stderr) answers yes. + * 2. `objectstack dev --no-ui` fails the oclif parse (`dev` has a `--ui` flag + * but, unlike `serve`, no `allowNo`), and oclif answers with the error line + * followed by a full usage dump. In a background log the dump is what the + * eye lands on, and the one sentence that matters scrolls past unread. + * + * ⛔ Fixing (2) by teaching `dev` to accept `--no-ui` is deliberately NOT what + * this module does: that widens the public CLI flag surface and is a product + * decision, not a diagnosability one. Nothing here adds or removes an accepted + * input — it only changes what the CLI SAYS when it rejects one. + * + * ## Why this file imports nothing but `node:` builtins + * + * `bin/run.js` and `bin/run-dev.js` reach it from the failure path, where the + * budget is one small module and no side effects. Pulling `./format.js` for + * {@link CLI_NAME} would drag chalk, zod and `@objectstack/spec` into a shim + * whose whole job is to print one line and get out of the way, so the prefix is + * spelled locally and `invocation.cli-name-parity.test.ts` fails if the two + * spellings ever disagree. + */ + +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * The prefix every line here starts with — the same name as `format.ts`'s + * `CLI_NAME`, kept in sync by a test rather than by an import (see the module + * docstring). It leads the line so that a `grep objectstack:` over a runner log + * finds the sentence, which is the only reading a backgrounded failure gets. + */ +export const INVOCATION_PREFIX = 'objectstack'; + +/** `realpathSync`, degrading to the input for a path that does not exist. */ +function realOrSelf(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * Is this module the process ENTRY POINT — the thing `node ` was pointed + * at — rather than a module someone imported? + * + * ⚠️ The obvious spelling of this predicate is the bug it guards against. + * #10086 measured the `invokedDirectly` guard across `scripts/` in ~8 spellings, + * all of them some form of `resolve(argv[1]) === fileURLToPath(import.meta.url)`, + * and EVERY one of them answers **false** when the script is reached through a + * symlink — because node resolves symlinks for the module graph but leaves + * `process.argv[1]` exactly as the caller typed it. A guard used the usual way + * ("only run when invoked directly") then makes its script silently inert: exit + * 0, no output. That is precisely the defect this module exists to remove, so + * reproducing it here would have been the same bug wearing the fix's clothes. + * + * Two things follow, and both are load-bearing: + * + * • the symlink leg — compare the `realpathSync` of both sides, which is the + * shape #10086 recommends and PR #10084 pinned with a real symlink fixture; + * • the DIRECTORY leg — `node ` resolves the entry to `/index.js` + * for the entry argument only, so `argv[1]` can name the directory whose + * index this module is. Same failure class, same silent 0. + * + * A false NEGATIVE here is the silent no-op. A false POSITIVE would abort a + * legitimate `import '@objectstack/cli'`, so the comparison stays exact — no + * basename matching, which #10086 also found in the wild and which fires for + * any entry script that happens to share a filename. + * + * @param entryArg `process.argv[1]` — undefined under `node --eval` / the REPL + * @param selfUrl the caller's `import.meta.url` + */ +export function isProcessEntry(entryArg: string | undefined, selfUrl: string): boolean { + if (!entryArg) return false; + + let self: string; + try { + self = resolve(fileURLToPath(selfUrl)); + } catch { + return false; + } + + const entry = resolve(entryArg); + // `node ` — the entry argument, and only it, still gets directory + // resolution. `index.ts` is here for the `tsx bin/run-dev.js` / source-run + // path, where this same module is reached before any build. + const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')]; + if (candidates.includes(self)) return true; + + const realSelf = realOrSelf(self); + return candidates.some((candidate) => realOrSelf(candidate) === realSelf); +} + +/** + * What the barrel says when it was run instead of imported. + * + * Line one is the whole fix: it has to be legible on its own, because the run + * that hits this is backgrounded and nobody reads line two. Line two names the + * real entry point, which is the question the reader is actually holding. + * + * @param selfPath the file that was run, absolute + * @param binPath `packages/cli/bin/run.js`, absolute + */ +export function moduleEntryMisuseLines(selfPath: string, binPath: string): [string, string] { + return [ + `${INVOCATION_PREFIX}: NOT A CLI ENTRY POINT — ${selfPath} only re-exports the command classes, so running it starts nothing and exits.`, + `${INVOCATION_PREFIX}: the CLI entry point is ${binPath} (installed as \`objectstack\` / \`os\`) — e.g. \`node ${binPath} dev\`.`, + ]; +} + +/** + * Is this one of oclif's ARGUMENT errors — a failure that happened while + * parsing the invocation, before the command ran at all? + * + * Structural rather than `instanceof`: `@oclif/core` does not export + * `CLIParseError` (`lib/parser/errors.js` is not in the package's `exports` + * map), and reaching into an unexported path to type-test would be a worse + * coupling than reading two own properties its constructor always sets. The + * corpus this is checked against is the REAL parser's output — + * `invocation.test.ts` throws the errors through `Parser.parse`, the public + * entry point, instead of hand-rolling look-alikes. + */ +export function isInvocationError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const own = Object.prototype.hasOwnProperty; + // Both are class fields on `CLIParseError`; `parse` alone would also match a + // plain object that happens to carry parsed input. + return own.call(error, 'parse') && own.call(error, 'showHelp'); +} + +/** The first line of a message, with oclif's `See more help with --help` tail dropped. */ +function reasonOf(error: unknown): string { + const message = (error as { message?: unknown } | null)?.message; + const first = String(typeof message === 'string' ? message : '').split('\n')[0]?.trim() ?? ''; + return first || 'invalid arguments'; +} + +/** The invocation as typed, capped so one long argument cannot push the line into a wrap. */ +function invocationOf(argv: readonly string[]): string { + const joined = [INVOCATION_PREFIX, ...argv].join(' '); + return joined.length > 120 ? `${joined.slice(0, 117)}...` : joined; +} + +/** + * The ONE line that goes to stderr ahead of oclif's own error-plus-usage dump + * when the CLI rejected the invocation. + * + * Three properties, each of them the reason a usage dump was not enough: + * + * • It is FIRST. `handle()` writes the error and then the usage sections, and + * when stderr is a pipe those writes are asynchronous and `process.exit` + * can tear the process down mid-drain — so the earliest bytes are the ones + * that survive a truncated log as well as an unread one. + * • It is ONE line. A backgrounded runner's log is skimmed, not read. + * • It says the command never ran. That is the correct attribution the + * #10087 finding is about: without it the reader sees a dead process and no + * listener, concludes the server booted and died, and goes off to debug an + * application that was never started. + * + * @returns the line, or `undefined` when the error is not an invocation error — + * a genuine runtime failure keeps oclif's reporting exactly as it was. + */ +export function invocationFailureLine(error: unknown, argv: readonly string[]): string | undefined { + if (!isInvocationError(error)) return undefined; + return `${INVOCATION_PREFIX}: INVOCATION ERROR — ${reasonOf(error)}. The command never ran: nothing was started and nothing is listening. Invoked as: ${invocationOf(argv)}`; +} diff --git a/packages/cli/test/invocation-loudness.e2e.test.ts b/packages/cli/test/invocation-loudness.e2e.test.ts new file mode 100644 index 0000000000..48f3fb3c8e --- /dev/null +++ b/packages/cli/test/invocation-loudness.e2e.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#10111, carved from #10087) — what a SHELL sees when the CLI is invoked + * wrongly, which is the only thing either failure was ever judged by. + * + * Measured on `main` before this change, in a checklist run that was booting + * the showcase app: + * + * ``` + * $ node packages/cli/dist/index.js + * $ echo $? + * 0 # ...and not one byte on stdout or stderr + * + * $ objectstack dev --no-ui + * Error: Nonexistent flag: --no-ui + * See more help with --help + * # followed by the full USAGE/FLAGS/ARGUMENTS dump + * ``` + * + * Backgrounded — which is how a runner boots a server — both read as a server + * that came up and died: the process is gone, nothing is listening, and the one + * sentence that would have said otherwise either does not exist or has scrolled + * past. The measured cost was a boot cycle spent debugging the APPLICATION. + * + * So the assertions here are about the shell's view, not the module's: + * + * • a real child process, because `process.exitCode` inside a vitest worker + * is not an exit status — the number a runner reads only exists once node + * has exited (the reason `qa-empty-glob-exit-code.e2e.test.ts` spawns too); + * • the FIRST line of stderr, because a backgrounded log is skimmed. "It is + * somewhere in the output" is the property the usage dump already had; + * • spawned through `bin/run-dev.js` + tsx, so the suite does not depend on + * `packages/cli/dist` having been built — `@objectstack/cli#test` depends on + * `^build` only, so this package's own `dist/` may legitimately be absent. + * + * ⛔ Not asserted, because it is out of scope and stays that way: that `dev` + * ACCEPTS `--no-ui`. It does not, and this change does not add it. `serve` + * declares its `ui` flag with `allowNo: true` and `dev` does not — reconciling + * those two is a flag-surface decision, not a diagnosability one. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const BIN = resolve(HERE, '../bin/run.js'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const BARREL = resolve(HERE, '../src/index.ts'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold start, with every command module loaded; ~2-10 s when healthy. */ +const RUN_TIMEOUT_MS = 180_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runTsx(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + args, + { cwd, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1' } }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; null/undefined means the child + // was signalled — a different failure, never reported as 0. + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +const firstLine = (text: string): string => text.split('\n')[0] ?? ''; + +let dir: string; +let linkDir: string; +let barrel: Run; +let barrelViaSymlink: Run; +let unknownFlag: Run; +let version: Run; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-invocation-e2e-')); + linkDir = mkdtempSync(join(tmpdir(), 'os-invocation-link-')); + const link = join(linkDir, 'index.ts'); + symlinkSync(BARREL, link); + + // Sequential on purpose: four cold tsx starts, each loading every command + // module, in a container several agents share. + barrel = await runTsx([BARREL], dir); + barrelViaSymlink = await runTsx([link], dir); + unknownFlag = await runTsx([CLI, 'dev', '--no-ui'], dir); + version = await runTsx([CLI, '--version'], dir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + rmSync(linkDir, { recursive: true, force: true }); +}); + +describe('[#10111] the package `main` run as if it were the CLI', () => { + it('no longer exits 0 with zero output — the measured defect, inverted', () => { + expect(barrel.code).not.toBe(0); + expect(barrel.stderr.trim()).not.toBe(''); + }); + + it('exits 1', () => { + expect(barrel.code).toBe(1); + }); + + it('leads with one line saying this file starts nothing', () => { + expect(firstLine(barrel.stderr)).toContain('objectstack: NOT A CLI ENTRY POINT'); + expect(firstLine(barrel.stderr)).toContain('starts nothing'); + }); + + it('names the real entry point', () => { + expect(barrel.stderr).toContain(BIN); + }); + + it('says it on stderr, leaving stdout clean for the pipelines that read it', () => { + expect(barrel.stdout).toBe(''); + }); + + it('fails the SAME way through a symlink — the #10086 hole, closed', () => { + // Every `invokedDirectly` spelling in `scripts/` answers false here, which + // turns the guard back into the silent exit-0 no-op it exists to remove. + expect(barrelViaSymlink.code).toBe(1); + expect(firstLine(barrelViaSymlink.stderr)).toContain('objectstack: NOT A CLI ENTRY POINT'); + }); +}); + +describe('[#10111] an unknown flag on `dev`', () => { + it('still fails — no flag surface was widened', () => { + expect(unknownFlag.code).not.toBe(0); + expect(unknownFlag.stderr).toContain('Nonexistent flag: --no-ui'); + }); + + it('puts ONE unmistakable line FIRST on stderr, ahead of the usage dump', () => { + const line = firstLine(unknownFlag.stderr); + expect(line).toContain('objectstack: INVOCATION ERROR'); + expect(line).toContain('Nonexistent flag: --no-ui'); + expect(line).toContain('The command never ran'); + }); + + it('attributes the failure to the invocation, not to a server that died', () => { + expect(firstLine(unknownFlag.stderr)).toContain('nothing is listening'); + expect(firstLine(unknownFlag.stderr)).toContain('Invoked as: objectstack dev --no-ui'); + }); + + it('keeps stdout empty', () => { + expect(unknownFlag.stdout).toBe(''); + }); +}); + +describe('[#10111] the success path the shims kept', () => { + it('`--version` still exits 0 with the version on stdout and nothing added to stderr', () => { + // The shims now inline what `execute()` did instead of calling it, so the + // path that does NOT fail has to be pinned too. + expect(version.code).toBe(0); + expect(version.stdout).toContain('@objectstack/cli'); + expect(version.stderr).not.toContain('INVOCATION ERROR'); + }); +});