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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(cli): name the missing build output instead of oclif's "command not found" by os-litant · Pull Request #13064 · objectstack-ai/objectstack · GitHub
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
36 changes: 36 additions & 0 deletions .changeset/run-dev-unbuilt-workspace-lead.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): name the missing build output instead of reporting "command not found" (#12964)

In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s
every command module while it builds its manifest, every one of them fails, and the run
ends on

```
Error: command i18n:extract:… not found
```

with exit 2 — while the command file is right there in `src/commands/`. A command whose
module will not load is indistinguishable, to `Config.runCommand`, from one that does not
exist, so the only cause the reader is handed is the one cause that is definitely not
true.

`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its
own gates and e2e suites, and not part of the published package — now collects oclif's
module-load warnings and, when that failure was caused by a package this repo builds,
prints the attribution and the single command that fixes it ahead of oclif's report:

```
objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to
LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'.
The unmet precondition is @objectstack/spec's build output, not the invocation.
objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec
```

Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the
module that already answers this question for the gates that shell out to the CLI, so
there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a
command that really is missing keeps oclif's reporting exactly as it was — the diagnosis
requires BOTH oclif's "not found" and a module-load failure naming a workspace package.
64 changes: 63 additions & 1 deletion packages/cli/bin/run-dev.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,15 +25,77 @@ async function announceInvocationFailure(error) {
}
}

/**
* Every module-load failure oclif reported while building its command table
* (#12964), in emission order. Filled by the listener attached below.
*
* It HAS to be collected as it happens. `findCommand` `import()`s every command
* module while `Config.load()` runs, warns on each one that will not load, and
* then throws a plain "command … not found" that carries none of it — so by the
* time the `.catch()` below holds the error, the only cause worth naming has
* already gone past. `warning.detail` is where oclif puts the failing specifier.
*/
const moduleLoadFailures = [];

/**
* The other reading of "command … not found": the command is there and its
* MODULE would not load, because a workspace package this repo builds has no
* usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole
* argument, including why the CLI's name is passed IN rather than imported
* there.
*
* Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`:
* a reporter that throws must never become the report.
*/
async function announceUnbuiltWorkspace(error) {
try {
const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
import('../src/utils/invocation.ts'),
]);
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
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)
const running = run(process.argv.slice(2), import.meta.url);

// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.
// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in
// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack
// plus `detail` under `settings.debug` — but it installs it ONLY when
// `process.listenerCount('warning') <= 1`, i.e. only node's own default is
// attached. A collector attached before `run()` makes that count 2, oclif
// silently declines to install, and every failing run through this shim quietly
// loses those blocks (measured on the #12964 repro: 1518 lines of report became
// 476, with nothing saying why).
//
// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its
// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`;
// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and
// `process.emitWarning` defers to `nextTick`, so a listener attached here is
// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e`
// asserts oclif's blocks are still there, so a future oclif that moves that call
// past an `await` fails a test instead of going quiet.
process.on('warning', (warning) => {
const detail = warning?.detail;
if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail);
});

await running
.then(async (result) => {
flush();
return result;
})
.catch(async (error) => {
await announceInvocationFailure(error);
await announceUnbuiltWorkspace(error);
return handle(error);
});
67 changes: 67 additions & 0 deletions packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec`
* and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts`
* needs and CI cannot otherwise have (#12964).
*
* Loaded with `node --import`, so it is in place before `@oclif/core` walks the
* command directory. It is a `resolve` hook and NOT a file operation on purpose:
* this repo is worked by several agents in one container at a time, and a test
* that renamed `packages/spec/dist` for a few seconds would break every other
* run in the box. Nothing here touches the disk.
*
* ## Why it re-points the specifier instead of throwing
*
* The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN
* sentence, so the sentence has to be node's. Two shapes were measured before
* this one was kept:
*
* - `{ url, shortCircuit: true }` at a non-existent URL skips
* `finalizeResolution`, so the failure surfaces from the LOAD step as
* `ENOENT: no such file or directory, open '…'`. That is not the corpus and
* the classifier correctly declines it — a green run that proves nothing.
* - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert
* against a string this file authored, which is the one thing a fixture for
* a text classifier must not do.
*
* Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real
* resolution against it, and node produces its real
* `Cannot find module '…' imported from …`.
*
* ## Why the path is spelled through `packages/cli/node_modules`
*
* That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm
* symlinks the workspace package in, and node only reports the pre-realpath
* spelling when resolution FAILS (a successful resolve reports
* `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The
* classifier keys on `node_modules/@objectstack/<pkg>` — deliberately, so it
* never diagnoses a third party — so a realpath spelling would classify as
* nothing and this fixture would silently stop simulating anything.
*/

import { registerHooks } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */
const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');

/**
* Where an unbuilt `@objectstack/spec` is looked for. The last segment is
* deliberately not a real one — `dist/` itself is present in a built checkout,
* and the whole point is a path that is missing.
*/
const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs');

/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */
const DENIED = '@objectstack/spec';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) {
return nextResolve(UNBUILT_TARGET, context);
}
return nextResolve(specifier, context);
},
});
160 changes: 160 additions & 0 deletions packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing
* run, whether "command … not found" is about a missing command at all.
*
* ```
* $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built
* …
* Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found
* $ echo $?
* 2
* ```
*
* The command file is right there in `src/commands/i18n/extract.ts`. oclif
* `import()`s every command module while it builds its manifest, all of them
* failed on a `@objectstack/spec` that had no `dist/`, and a command whose
* module will not load is indistinguishable to `Config.runCommand` from one
* that does not exist.
*
* ## Why this suite is spawned, and why it simulates
*
* The lead line is produced from a `process.on('warning')` collector installed
* around `run()` — state that exists only inside a real CLI process, so an
* in-process test cannot see it and `process.exit`-adjacent behaviour cannot be
* asserted from a vitest worker at all.
*
* And CI's checkout is BUILT. ⚠️ That is the trap this file is written against:
* an "unbuilt tree" test that runs in a built tree never enters the branch it
* claims to cover, prints nothing, asserts nothing failed, and reads green
* forever. So the unbuilt condition is MANUFACTURED for one child process
* (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches
* no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a
* single assertion:
*
* 1. hook on, real command id → the lead lines appear;
* 2. hook off, THE SAME command id → the command module loads and runs, so
* the run never reaches that branch at all;
* 3. hook off, a command that really is missing → oclif's "not found" stands
* exactly as it did, with nothing added.
*
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
* (2) and (3) without (1) are two zero readings. Together they say the branch is
* reachable, is not always taken, and is taken for the right reason.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href;

/**
* A REAL command id, so "not found" is a lie rather than the truth. Its
* argument names nothing: case 2 has to fail for its own reason (no config
* file) instead of doing work, and the point there is only WHICH failure.
*/
const REAL_COMMAND = ['i18n', 'extract', 'nope.ts'];

/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */
const RUN_TIMEOUT_MS = 180_000;

interface Run {
code: number;
stdout: string;
stderr: string;
}

/**
* `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()`
* strips the vitest-worker family and `NODE_PATH`, but not this one, so a
* control leg that said nothing would silently inherit whatever the runner was
* started with — and the control legs' whole job is to be un-simulated.
*/
function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => {
resolvePromise({
// `err.code` is the real exit status; `null`/undefined means the child
// was signalled — a failure of a different kind, 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),
});
});
});
}

/** The sentence this change exists to contradict. */
const LEAD = 'objectstack: NOT A MISSING COMMAND';
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';

let dir: string;
let unbuilt: Run;
let built: Run;
let genuinelyMissing: Run;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
built = await runCli(REAL_COMMAND, dir, undefined);
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
}, RUN_TIMEOUT_MS * 3);

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

describe('run-dev.js on a workspace package with no build output', () => {
it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => {
// The upstream line is deliberately NOT suppressed: nothing here changes
// which arguments the CLI accepts or how oclif reports, only what is said
// alongside. Asserting it also proves case 1 really reached that failure
// rather than dying earlier for some unrelated reason.
expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found');
expect(unbuilt.code).toBe(2);
});

it('names the real cause and the one command that fixes it', () => {
expect(unbuilt.stderr).toContain(LEAD);
expect(unbuilt.stderr).toContain('@objectstack/spec');
expect(unbuilt.stderr).toContain(FIX);
});

it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => {
// @oclif/core installs its `warning` listener only when
// `process.listenerCount('warning') <= 1`. A collector attached BEFORE
// `run()` makes that 2, oclif silently declines, and every failing run
// through this shim loses these blocks with nothing saying why (measured:
// 1518 lines of report became 476). `at Plugin.warn` is that listener's
// output, so this case reds if the attachment ever moves back.
expect(unbuilt.stderr).toContain('at Plugin.warn');
});
});

describe('the same probe, un-simulated (positive control)', () => {
it('takes the other branch entirely: the command module loads and runs', () => {
// Not "no lead line" alone — that is a zero reading. The command REACHED
// its own argument handling, which is only possible if its module loaded.
expect(`${built.stdout}${built.stderr}`).toContain('Config file not found');
expect(built.stderr).not.toContain('Error: command');
expect(built.stderr).not.toContain(LEAD);
expect(built.code).toBe(1);
});

it('leaves a command that really is missing exactly as it was', () => {
expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found');
expect(genuinelyMissing.stderr).not.toContain(LEAD);
expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:');
expect(genuinelyMissing.code).toBe(2);
});
});
Loading
Loading