Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/cli-invocation-loudness.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 36 additions & 2 deletions packages/cli/bin/run-dev.js
Original file line number Diff line number Diff line change
@@ -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);
});
47 changes: 45 additions & 2 deletions packages/cli/bin/run.js
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 27 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
}
212 changes: 212 additions & 0 deletions packages/cli/src/utils/invocation.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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 <file>` 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 <dir>`, 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);
});
});
Loading
Loading