Skip to content

Commit 41e84c0

Browse files
fix(cli): the os dev watcher prints why a recompile was refused (#23867)
Fixes #23820 Clause-②: no ## What changed `os dev`'s watch-recompile loop (`compileAndPing` in `packages/cli/src/commands/dev.ts`) now captures the `compile` child's stdout as well as its stderr. On a non-zero exit it prints both under the `✗ compile failed` line: the same lines `pnpm build` prints, with no `--verbose` needed. Nothing else changes: - A successful recompile prints exactly what it printed before. The compile's progress output stays hidden on success. - `--verbose` still passes both streams straight through, so a failure prints nothing twice. - A failed compile still writes no artifact and still triggers no restart. ## Measured: why the refusal never appeared Setup: a fresh `os init my-app -t app --no-install` scaffold (namespace `my_app`), made with the CLI built from this branch. Dependencies are `18.0.0-next.2` from npm. An object named `task` is wired into `src/objects`. - `os compile` exits 2. It writes 10 lines (517 bytes) to stdout and 0 bytes to stderr, so the refusal is on stdout. `pnpm build` prints the same 10 lines and exits 2. - `os dev --fresh -p RANDOM` boots, the server answers, and the object is saved. **Before** (base `1eac7ad751`): ``` ↻ recompiling (2 files)... ✗ compile failed (3782ms) ``` - **After** (this branch, same scaffold, same save): ``` ↻ recompiling (2 files)... ✗ compile failed (4012ms) ◆ Compile ──────────────────────────────────────── → Loading configuration... ✗ defineStack namespace-prefix validation failed (1 issue): ✗ Object 'task' is missing the package namespace prefix. Rename it to 'my_app_task' (namespace = 'my_app'). Every object.name must be `${manifest.namespace}_${shortName}`. This is the only supported writing style — the platform does not provide ns() helpers or factory wrappers. ``` Both runs were torn down by their own PID, and the port had no listener afterwards. ## Where the fix lives: the watcher (A), not the producer (B) - **`compile` writes to stdout on purpose.** It renders every text-face refusal through `printError`, which writes to stdout: 12 call sites in `compile.ts`, and the printer is used by 61 command files. `packages/cli/src/utils/format.ts` states this as the convention. `printErrorToStderr` exists only for shared helpers that cannot see `--json`, and "a command that has already decided it is rendering the text face should keep writing to stdout". - **Moving refusals to stderr (B) breaks more than it fixes.** It goes against that convention, either for `compile` alone or for every command. It also changes the streams every other caller sees: `os dev`'s boot compile, `os start` and `os environments bind` spawn `compile` with inherited streams. The e2e suites that spawn it would see it too. - **The watcher is the only caller that threw stdout away**, so the fix is a change at that one site (A). ## What is printed - **The whole output, not only the refusal block.** The watcher prints the compile's stdout, then its stderr. It drops leading blank lines and trailing whitespace and filters nothing else. - On the card's case, 4 of the 10 lines are the compile's header and progress: the blank line, `◆ Compile`, the rule and `→ Loading configuration...`. The other 6 lines are the refusal, which has two paragraphs. The noise is small, and the last progress line also tells you which step refused. - Picking "the refusal" out would mean `dev.ts` parsing `compile`'s glyphs. That is a second reader of another command's prose, and it would silently drop output whenever that prose changed. So it is not done. - **stdout comes before stderr.** They arrive through two separate pipes, so their interleaving cannot be recovered. stdout goes first because that is where the refusal is. - **`maxBuffer` is now 64 MiB.** spawnSync's default is 1 MiB, and a child whose pipe fills the buffer is killed. With stdout now piped, a large but successful compile would have turned into a failed one. For scale, the successful compile on the scaffold writes 736 bytes. ## Surface - **Within the claim's surface.** The diff touches `dev.ts` `compileAndPing` only (its `spawnSync` options and its failure line), adds one pin and one changeset. - **Not touched.** `compile.ts` and every refusal producer. No other region of `dev.ts`: the `resolveDevDatabase` and database-directory `mkdirSync` regions belong to #23737 (seat 1's PR #23851), and this PR leaves them alone. ## Tests **Pin.** `packages/cli/src/commands/dev-watch-compile-refusal.pin.test.ts`, unit tier: it spawns a fixture, never the CLI entry, and boots no kernel. How it is built: - **Real code under test.** The pin drives the real `startWatchRecompile` and `compileAndPing`. It fakes only chokidar, plus a stub for `./serve.js`: the watch loop never reads `serve.ts`, and loading it costs the whole runtime graph. - **Fixture scripts stand in for `compile`.** `binPath` points at them, so the refusal text belongs to the fixture and no CLI prose is pinned. - **Inheritance is observed.** Each fixture records the identity (device and inode) of its fd 1 and fd 2. So `--verbose` inheritance is checked against the test process's own streams, not read off the spawn options. What it pins: 1. A refusal written to stdout only (exit 2) lands on the failure line, and the whole stdout appears in order. No restart is requested. 2. With output on stdout and stderr (exit 1), both appear, stdout first. 3. CONTROL: a successful recompile prints exactly two lines, as before. The fixture's progress output does not appear, and `onRebuildLanded` fires with the label. 4. CONTROL, `--verbose`: the child's fd 1 and fd 2 are the test process's own, and the failure line repeats nothing. **Ablation**, on `4f4f529e9` through `scripts/ablation-replace.mjs`. The test imports `./dev.js` from `src`, so there is no dist leg. - **Mutation.** stdio reverts to `['ignore', 'ignore', 'pipe']`, the pre-fix capture. The anchor count went 1 to 0, and the blob changed from `4ada8ac0d61a` to `b74790b2dffb`. - **Result.** Cases 1 and 2 went red: `expected ' ✗ compile failed (46ms)' to contain 'FIXTURE-REFUSAL: …'`, the card's symptom exactly. Cases 3 and 4 stayed green. - **Restore.** Blob equal to HEAD, and `git diff HEAD` empty. **Local runs.** All at `4f4f529e9` unless noted, through `scripts/pm/os-verify-lock.sh`. The box is shared, so timings are shared-box seconds. - `pnpm --filter @objectstack/cli run typecheck`: exit 0. That covers `tsc --noEmit` and `check:test-typecheck`. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` over the pin and the seven unit files that import or read `dev.ts` (`child-env-source-loader.pin`, `artifact-child-env.pin`, `dev-mcp-connect-hint-origin`, `port-contract-single-source`, `console-route-ledger.conformance`, `strip-arg-separator`, `test/commands`): exit 0, 8 files, 143 tests passed. - The pin alone: 4 of 4 passed. The file's wall time on this shared box went from 30.7 s to 13.8 s once `serve.js` was stubbed. Import accounts for 12 s of that and the tests for 1.5 s. - `pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2`: exit 0, at `1aa612afc`. That was the merge of `origin/main`, and `dev.ts` has not changed since. - `pnpm exec eslint --no-inline-config` on both changed `.ts` files: exit 0. The repo-wide lint run is CI's `Lint & Repo Gates`. **Gate families.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 64. Reconciled with `--ran` (every line carries its exit code): 64 accounted, 62 run with exit 0. Two are NOT MEASURED, each exiting 3 with `PREREQUISITE NOT MET`. Both need packages outside the `@objectstack/cli` closure built: - `check:dual-build-cjs-loads` lists `studio`, `client-react`, `connector-slack` and others with no `dist/`. - `check:i18n-coverage` needs `examples/app-showcase`'s `connector-slack` built. CI's `Lint & Repo Gates` runs both. The four artifact-roster gates whose roster sits under a changed path all exited 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. So did `check:nul-bytes`. **Declared to CI, not run locally.** - The `integration` tier. The diff touches no integration-tier file and no spawn entry (`bin/`, `test/helpers/serve-process.ts`). `test/dev-no-watch.pin.test.ts` imports `dev.ts` and is integration-tier. - `test:repo`. ## Acceptance notes - **Signals and spawn errors are still not explained.** A compile killed by a signal (for example the OOM killer) still prints `✗ compile failed` with whatever output it produced, and does not name the signal. `r.error` (a spawn error) is not printed either. Both were already true and are not reproduced here: noted, not filed. - **The objectos quickstart workaround can be removed after release.** The quickstart's line "If it prints only `✗ compile failed`, run `pnpm build` to see why" is obsolete once this ships in an `@objectstack/cli` release. That is the objectos repo's edit to make. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a35f7a6 commit 41e84c0

3 files changed

Lines changed: 318 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev` (and `pnpm dev` on a scaffold) prints why a watched save was refused, not only `✗ compile failed`
6+
7+
Clause-②: no
8+
9+
When a saved file makes the compile refuse, the watcher now prints the compile's output under its failure line: the same lines `pnpm build` prints on that tree, without `--verbose`.
10+
11+
- **Before.** On a fresh `os init my-app -t app` scaffold (namespace `my_app`), saving an object named `task` printed `↻ recompiling (2 files)...` and then a bare `✗ compile failed (3782ms)`. The reason never appeared. The compile writes its refusals to stdout, and the watcher discarded stdout and printed stderr only.
12+
- **After.** The failure line is followed by the compile's stdout, then its stderr. For that save it shows `✗ Object 'task' is missing the package namespace prefix. Rename it to 'my_app_task' (namespace = 'my_app').` and the lines around it.
13+
14+
What does not change: a successful recompile prints exactly what it printed before, and the compile's progress output stays hidden on success. `--verbose` still passes the compile's output straight through, so on a failure nothing is printed twice. A failed compile still writes no artifact and still triggers no restart.
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#23820) — when a watched save makes `compile` refuse, `os dev`'s watcher
5+
* prints WHY, without `--verbose`.
6+
*
7+
* Measured before the fix on a fresh `os init my-app -t app` scaffold (namespace
8+
* `my_app`), saving an object named `task`: the refused compile exited 2 with
9+
* 10 lines on stdout — the refusal among them — and 0 bytes on stderr, while the
10+
* watcher spawned it with stdout ignored and printed stderr only:
11+
*
12+
* ```
13+
* ↻ recompiling (2 files)...
14+
* ✗ compile failed (3782ms)
15+
* ```
16+
*
17+
* `compile` writes its text face, refusals included, to stdout (`printError`;
18+
* `utils/format.ts` states that convention for a command rendering its own text
19+
* face), so the fix is on the watcher's side: it captures both streams and
20+
* prints them on a non-zero exit.
21+
*
22+
* ## The instrument
23+
*
24+
* The REAL `compileAndPing` runs: `startWatchRecompile` is driven with only
25+
* chokidar faked (a watcher whose handlers the test fires), so the spawn, its
26+
* stdio and the printed lines are the shipped code's. `binPath` names a fixture
27+
* script in place of the CLI entry, so each case controls exactly what "the
28+
* compile" writes and to which stream — the refusal lives in the fixture, and
29+
* ⛔ no sentence of the CLI's own refusal prose is pinned here.
30+
*
31+
* Every fixture records `fstat` of its fd 1 and fd 2 before writing anything.
32+
* That is how the `--verbose` control is OBSERVED rather than read off the
33+
* spawn options: an inherited stream is the test process's own (same device and
34+
* inode), a captured one is a fresh pipe.
35+
*
36+
* A spawned fixture blocks this process until it exits, and `compileAndPing`
37+
* prints synchronously after `spawnSync` returns — so once the fixture's record
38+
* exists on disk, every line this recompile prints has been printed.
39+
*/
40+
41+
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
42+
import { existsSync, fstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
43+
import { tmpdir } from 'node:os';
44+
import { join } from 'node:path';
45+
import { stripVTControlCharacters } from 'node:util';
46+
47+
type Handler = (filePath: string) => void;
48+
interface FakeWatcher {
49+
handlers: Map<string, Handler>;
50+
on(event: string, cb: Handler): FakeWatcher;
51+
close(): Promise<void>;
52+
}
53+
54+
const fake = vi.hoisted(() => ({ watchers: [] as FakeWatcher[] }));
55+
56+
vi.mock('chokidar', () => ({
57+
default: {
58+
watch: () => {
59+
const w: FakeWatcher = {
60+
handlers: new Map(),
61+
on(event, cb) {
62+
w.handlers.set(event, cb);
63+
return w;
64+
},
65+
close: async () => {},
66+
};
67+
fake.watchers.push(w);
68+
return w;
69+
},
70+
},
71+
}));
72+
73+
// `dev.ts` borrows one function from `serve.ts` (`resolveAuthBaseUrl`, read by
74+
// the MCP connect hint, never by the watch loop), and `serve.ts` imports the
75+
// whole runtime graph. Stubbed here so this file pays for the code it tests,
76+
// not for a server boot path it never reaches.
77+
vi.mock('./serve.js', () => ({ resolveAuthBaseUrl: () => undefined }));
78+
79+
import Dev from './dev.js';
80+
81+
interface WatchOpts {
82+
cwd: string;
83+
configPath: string;
84+
artifactPath: string;
85+
binPath: string;
86+
verbose?: boolean;
87+
autoRestart: boolean;
88+
onRebuildLanded?: (label: string) => void;
89+
}
90+
91+
// The private method is the unit under test; its body never reads `this`.
92+
const startWatchRecompile = (opts: WatchOpts): void =>
93+
(Dev.prototype as unknown as { startWatchRecompile(o: WatchOpts): void }).startWatchRecompile(opts);
94+
95+
/** What a refused compile writes, in the shape the measured one did: header, progress, refusal. */
96+
const REFUSAL = "FIXTURE-REFUSAL: Object 'task' is missing the package namespace prefix (fixture text)";
97+
const REFUSED_STDOUT = [
98+
'',
99+
'◆ Compile',
100+
'─'.repeat(40),
101+
' → Loading configuration...',
102+
'',
103+
` ✗ ${REFUSAL}`,
104+
'',
105+
'FIXTURE-REFUSAL-TAIL: a second paragraph of the same refusal',
106+
'',
107+
].join('\n');
108+
const STDERR_LINE = ' ⚠ FIXTURE-STDERR: a line the compile wrote to stderr';
109+
const PROGRESS = ' → FIXTURE-PROGRESS: Writing artifact...';
110+
111+
/**
112+
* A stand-in for `bin/run.js`, invoked as `node <fixture> compile --output <artifact>`.
113+
* It writes `<artifact>.stdio.json` first (fd 1 / fd 2 identity), then performs `mode`.
114+
*/
115+
function fixtureSource(mode: 'refuse-stdout' | 'refuse-both' | 'succeed'): string {
116+
return `
117+
import { fstatSync, writeFileSync, mkdirSync } from 'node:fs';
118+
import { dirname } from 'node:path';
119+
const out = process.argv[process.argv.indexOf('--output') + 1];
120+
mkdirSync(dirname(out), { recursive: true });
121+
const id = (fd) => { const s = fstatSync(fd); return { dev: s.dev, ino: s.ino }; };
122+
writeFileSync(out + '.stdio.json', JSON.stringify({ argv: process.argv.slice(2), fd1: id(1), fd2: id(2) }));
123+
const mode = ${JSON.stringify(mode)};
124+
if (mode === 'succeed') {
125+
process.stdout.write(${JSON.stringify(PROGRESS + '\n')});
126+
writeFileSync(out, '{}');
127+
process.exit(0);
128+
}
129+
process.stdout.write(${JSON.stringify(REFUSED_STDOUT)});
130+
if (mode === 'refuse-both') {
131+
process.stderr.write(${JSON.stringify(STDERR_LINE + '\n')});
132+
process.exit(1);
133+
}
134+
process.exit(2);
135+
`;
136+
}
137+
138+
let root: string;
139+
const fixtures: Record<string, string> = {};
140+
let caseNo = 0;
141+
let sigintBaseline: unknown[] = [];
142+
let sigtermBaseline: unknown[] = [];
143+
144+
beforeAll(() => {
145+
root = mkdtempSync(join(tmpdir(), 'os-dev-watch-refusal-'));
146+
for (const mode of ['refuse-stdout', 'refuse-both', 'succeed'] as const) {
147+
fixtures[mode] = join(root, `${mode}.mjs`);
148+
writeFileSync(fixtures[mode], fixtureSource(mode));
149+
}
150+
sigintBaseline = process.listeners('SIGINT');
151+
sigtermBaseline = process.listeners('SIGTERM');
152+
});
153+
154+
afterEach(() => {
155+
vi.restoreAllMocks();
156+
// `startWatchRecompile` registers SIGINT/SIGTERM handlers to close its watcher.
157+
for (const l of process.listeners('SIGINT')) {
158+
if (!sigintBaseline.includes(l)) process.removeListener('SIGINT', l);
159+
}
160+
for (const l of process.listeners('SIGTERM')) {
161+
if (!sigtermBaseline.includes(l)) process.removeListener('SIGTERM', l);
162+
}
163+
});
164+
165+
afterAll(() => {
166+
rmSync(root, { recursive: true, force: true });
167+
});
168+
169+
interface StdioRecord {
170+
argv: string[];
171+
fd1: { dev: number; ino: number };
172+
fd2: { dev: number; ino: number };
173+
}
174+
175+
/** One save, one recompile: everything the watcher printed for it, plus the fixture's record. */
176+
async function recompileOnce(
177+
mode: keyof typeof fixtures,
178+
{ verbose = false }: { verbose?: boolean } = {},
179+
): Promise<{ printed: string[]; landed: string[]; stdio: StdioRecord }> {
180+
const cwd = join(root, `case-${++caseNo}`);
181+
mkdirSync(join(cwd, 'src', 'objects'), { recursive: true });
182+
const configPath = join(cwd, 'objectstack.config.ts');
183+
writeFileSync(configPath, 'export default {};\n');
184+
const artifactPath = join(cwd, 'dist', 'objectstack.json');
185+
186+
const printed: string[] = [];
187+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
188+
printed.push(stripVTControlCharacters(args.map(String).join(' ')));
189+
});
190+
const landed: string[] = [];
191+
192+
const before = fake.watchers.length;
193+
startWatchRecompile({
194+
cwd,
195+
configPath,
196+
artifactPath,
197+
binPath: fixtures[mode],
198+
verbose,
199+
autoRestart: true,
200+
onRebuildLanded: (label) => landed.push(label),
201+
});
202+
await vi.waitFor(() => expect(fake.watchers[before]?.handlers.has('change')).toBe(true));
203+
fake.watchers[before].handlers.get('change')!(join(cwd, 'src', 'objects', 'task.object.ts'));
204+
205+
const record = `${artifactPath}.stdio.json`;
206+
await vi.waitFor(() => expect(existsSync(record), `fixture ${mode} never ran`).toBe(true), {
207+
timeout: 20_000,
208+
interval: 50,
209+
});
210+
return { printed, landed, stdio: JSON.parse(readFileSync(record, 'utf8')) as StdioRecord };
211+
}
212+
213+
const own = (fd: number) => {
214+
const s = fstatSync(fd);
215+
return { dev: s.dev, ino: s.ino };
216+
};
217+
218+
/** The fixture's stdout as the watcher relays it: leading blank lines and trailing whitespace dropped. */
219+
const relayed = (text: string) => text.replace(/^\s*\n/, '').trimEnd();
220+
221+
describe('os dev watcher: a refused recompile shows the refusal (#23820)', () => {
222+
it('relays a refusal the compile wrote to STDOUT, on the failure line, without --verbose', async () => {
223+
const { printed, landed, stdio } = await recompileOnce('refuse-stdout');
224+
const told = printed.join('\n---\n');
225+
226+
// The fixture really was invoked the way the CLI entry is.
227+
expect(stdio.argv.slice(0, 2)).toEqual(['compile', '--output']);
228+
// Captured, not inherited: the child's stdout was a pipe of its own.
229+
expect(stdio.fd1, told).not.toEqual(own(1));
230+
231+
// Two lines for one recompile — "recompiling", then the failure line — and
232+
// the failure line carries the compile's whole stdout, in order.
233+
expect(printed, told).toHaveLength(2);
234+
expect(printed[1], told).toContain(REFUSAL);
235+
expect(printed[1].endsWith(relayed(REFUSED_STDOUT)), told).toBe(true);
236+
// A refused compile lands nothing: no restart is requested.
237+
expect(landed).toEqual([]);
238+
});
239+
240+
it('relays stderr too — stdout first, then stderr — on a non-zero exit', async () => {
241+
const { printed, landed } = await recompileOnce('refuse-both');
242+
const told = printed.join('\n---\n');
243+
244+
expect(printed, told).toHaveLength(2);
245+
const failure = printed[1];
246+
expect(failure, told).toContain(REFUSAL);
247+
expect(failure, told).toContain(STDERR_LINE.trim());
248+
expect(failure.indexOf(REFUSAL), told).toBeLessThan(failure.indexOf(STDERR_LINE.trim()));
249+
expect(landed).toEqual([]);
250+
});
251+
252+
it('CONTROL: a successful recompile prints exactly what it printed before — the compile output stays discarded', async () => {
253+
const { printed, landed } = await recompileOnce('succeed');
254+
const told = printed.join('\n---\n');
255+
256+
// "recompiling" and the success line, nothing else; the compile's own
257+
// progress output is not printed on success.
258+
expect(printed, told).toHaveLength(2);
259+
expect(told).not.toContain('FIXTURE-PROGRESS');
260+
expect(landed).toEqual(['src/objects/task.object.ts']);
261+
});
262+
263+
it('CONTROL: --verbose still inherits both streams, and the failure line does not repeat what is already on screen', async () => {
264+
const { printed, landed, stdio } = await recompileOnce('refuse-both', { verbose: true });
265+
const told = printed.join('\n---\n');
266+
267+
// Inherited: the child's fd 1 and fd 2 ARE this process's.
268+
expect(stdio.fd1, told).toEqual(own(1));
269+
expect(stdio.fd2, told).toEqual(own(2));
270+
271+
expect(printed, told).toHaveLength(2);
272+
expect(printed[1], told).not.toContain(REFUSAL);
273+
expect(printed[1], told).not.toContain('FIXTURE-STDERR');
274+
expect(landed).toEqual([]);
275+
});
276+
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -997,15 +997,40 @@ export default class Dev extends Command {
997997
: `${changed.length} files`;
998998
console.log(chalk.dim(`\n ↻ recompiling (${label})...`));
999999
const t0 = Date.now();
1000+
// BOTH streams are captured, and both are printed when the compile
1001+
// fails (#23820). `compile` renders its text face — refusals included — on
1002+
// STDOUT through `printError`, the convention `utils/format.ts` states
1003+
// for a command rendering its own text face; only the shared helpers
1004+
// that cannot see `--json` use stderr. Capturing stderr alone dropped
1005+
// the one message that says what to fix: a refused save printed a bare
1006+
// `✗ compile failed`, while `pnpm build` on the same tree printed the
1007+
// refusal. What is shown is the compile's whole output, unfiltered —
1008+
// the lines `pnpm build` prints, in the same words — because picking
1009+
// "the refusal" out of it would mean parsing another command's text
1010+
// face here. A successful compile's output is still discarded, so that
1011+
// path prints exactly what it did. `--verbose` keeps inheriting both
1012+
// streams, so the output is already on screen and nothing is repeated.
1013+
// `maxBuffer` is raised because a pipe that fills spawnSync's 1 MiB
1014+
// default kills the child: a large but successful compile would turn
1015+
// into a failed one.
10001016
const r = spawnSync(
10011017
process.execPath,
10021018
[opts.binPath, 'compile', '--output', opts.artifactPath],
1003-
{ stdio: opts.verbose ? 'inherit' : ['ignore', 'ignore', 'pipe'], env: process.env },
1019+
{
1020+
stdio: opts.verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'],
1021+
env: process.env,
1022+
maxBuffer: 64 * 1024 * 1024,
1023+
},
10041024
);
10051025
const dt = Date.now() - t0;
10061026
if (r.status !== 0) {
1007-
const stderr = r.stderr?.toString().trim();
1008-
console.log(chalk.red(` ✗ compile failed (${dt}ms)${stderr ? '\n' + stderr : ''}`));
1027+
// stdout first, then stderr: the two pipes are read separately, so
1028+
// their interleaving is not recoverable.
1029+
const output = [r.stdout, r.stderr]
1030+
.map((b) => b?.toString().replace(/^\s*\n/, '').trimEnd())
1031+
.filter(Boolean)
1032+
.join('\n');
1033+
console.log(chalk.red(` ✗ compile failed (${dt}ms)${output ? '\n' + output : ''}`));
10091034
// A failed compile writes no artifact — the running server still
10101035
// matches dist/objectstack.json, so no restart and no warning.
10111036
} else {

0 commit comments

Comments
 (0)