Skip to content

Commit 4bfeb9e

Browse files
dmealingclaude
andcommitted
fix(cli): a failed D1 introspection named a warning as its cause
`wrangler d1 execute --json` writes its error to STDOUT and warnings to stderr. The runner read stderr alone, so an estate whose wrangler.toml carries an `unsafe` field was told its production schema gate failed because "unsafe fields are experimental and may change or break at any time" — a WARNING, reported as the cause of a failure — while the real cause, "In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN…", was discarded unread. `wranglerFailureReason` picks the reason: wrangler's structured error first ({error:{text}}, {error}, and the [{success:false,error}] execute envelope), then stderr, then raw stdout, then the process message. Stderr stays in the chain — a genuine failure that never reaches stdout must not be swallowed to fix the opposite mistake. Verified end to end against the live D1 that produced the wrong message. Found by running an adopter estate's own verify:prod-schema against production for the first time: the one declared gate no estate pass had exercised, wrong on its first run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent d2fe169 commit 4bfeb9e

3 files changed

Lines changed: 134 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — a failed D1 introspection named a WARNING as its cause
11+
12+
`meta verify --db` / `meta migrate` against a live D1 shell out to
13+
`wrangler d1 execute … --json`, and **`--json` puts wrangler's error on STDOUT** while
14+
stderr carries only warnings. The runner reported stderr alone, so an estate whose
15+
`wrangler.toml` uses an `unsafe` field was told its production schema gate failed
16+
because *"`unsafe` fields are experimental and may change or break at any time"* — a
17+
warning, printed as the cause of a failure, with the real one (`In a non-interactive
18+
environment, it's necessary to set a CLOUDFLARE_API_TOKEN…`) discarded unread.
19+
20+
The reason is now chosen by `wranglerFailureReason`: wrangler's structured error
21+
(`{error:{text}}`, `{error}`, or the `[{success:false,error}]` execute envelope) first,
22+
then stderr, then raw stdout, then the process's own message. Stderr is still consulted —
23+
a genuine wrangler failure that never reaches stdout must not be swallowed to fix the
24+
opposite mistake.
25+
26+
Found by running an adopter estate's own `verify:prod-schema` script against production
27+
for the first time. It is the one declared gate no estate pass had ever exercised, and it
28+
was wrong on its first run.
29+
30+
1031
### `meta gen --baseline=adopt` — the first run a pre-manifest project can actually perform
1132

1233
The no-manifest refusal LED with *"ONE-TIME FIX: commit

server/typescript/packages/cli/src/lib/wrangler.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,56 @@ export function buildWranglerExecuteArgs(opts: WranglerExecuteOptions): string[]
2424
}
2525

2626
/**
27-
* Run wrangler with the given args; return stdout. Stderr is included in the
28-
* error message when wrangler exits non-zero. `cwd` is the directory wrangler
27+
* The reason to report when wrangler exits non-zero.
28+
*
29+
* **`--json` puts wrangler's error on STDOUT, and warnings on stderr.** This function
30+
* exists because the runner used to report stderr alone, so a live D1 gate failing for
31+
* want of a `CLOUDFLARE_API_TOKEN` told the adopter its cause was
32+
* `▲ [WARNING] … "unsafe" fields are experimental` — a warning, named as the reason a
33+
* schema gate failed, with the real cause discarded. Found by running an estate's own
34+
* `verify:prod-schema` against production for the first time.
35+
*
36+
* Order: the structured error wrangler wrote, then stderr, then raw stdout, then the
37+
* process's own message. Stderr is still consulted — a genuine wrangler failure that
38+
* never reaches stdout must not be swallowed to fix the opposite mistake.
39+
*/
40+
export function wranglerFailureReason(
41+
stdout: string,
42+
stderr: string,
43+
fallback: string,
44+
): string {
45+
const structured = structuredWranglerError(stdout);
46+
if (structured !== undefined) return structured;
47+
const err = stderr.trim();
48+
if (err.length > 0) return err;
49+
const out = stdout.trim();
50+
if (out.length > 0) return out;
51+
return fallback;
52+
}
53+
54+
/** wrangler's two `--json` error shapes: `{error:{text}}` / `{error}` and the
55+
* `[{success:false,error}]` execute envelope. Anything else reads as absent. */
56+
function structuredWranglerError(stdout: string): string | undefined {
57+
let parsed: unknown;
58+
try {
59+
parsed = JSON.parse(stdout);
60+
} catch {
61+
return undefined;
62+
}
63+
const node = Array.isArray(parsed) ? parsed[0] : parsed;
64+
if (node === null || typeof node !== "object") return undefined;
65+
const { error } = node as { error?: unknown };
66+
if (typeof error === "string" && error.trim().length > 0) return error.trim();
67+
if (error !== null && typeof error === "object") {
68+
const { text } = error as { text?: unknown };
69+
if (typeof text === "string" && text.trim().length > 0) return text.trim();
70+
}
71+
return undefined;
72+
}
73+
74+
/**
75+
* Run wrangler with the given args; return stdout. The failure REASON is chosen by
76+
* `wranglerFailureReason` when wrangler exits non-zero. `cwd` is the directory wrangler
2977
* runs in (defaults to process.cwd() — caller should pass the project root).
3078
*/
3179
export type WranglerRunner = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>;
@@ -39,8 +87,8 @@ export const defaultWranglerRunner: WranglerRunner = async (args, cwd) => {
3987
if (e.code === "ENOENT") {
4088
throw new Error(`wrangler not found on PATH; install it: 'npm i -D wrangler'`);
4189
}
42-
const stderr = e.stderr ?? "";
43-
throw new Error(`wrangler ${args.join(" ")} failed: ${stderr || e.message}`);
90+
const reason = wranglerFailureReason(e.stdout ?? "", e.stderr ?? "", e.message);
91+
throw new Error(`wrangler ${args.join(" ")} failed: ${reason}`);
4492
}
4593
};
4694

server/typescript/packages/cli/test/unit/wrangler.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe } from "bun:test";
2-
import { buildWranglerExecuteArgs } from "../../src/lib/wrangler.js";
2+
import { buildWranglerExecuteArgs, wranglerFailureReason } from "../../src/lib/wrangler.js";
33

44
describe("buildWranglerExecuteArgs", () => {
55
test("local execution with command", () => {
@@ -39,3 +39,63 @@ describe("buildWranglerExecuteArgs", () => {
3939
});
4040
});
4141

42+
43+
// ── the failure REASON: wrangler --json puts its error on STDOUT ──────────────
44+
//
45+
// Found by running an estate's own `verify:prod-schema` gate against a live D1 for
46+
// the first time. It failed, and the reason MetaObjects printed was:
47+
//
48+
// failed to introspect D1 binding 'DB': wrangler d1 execute … failed:
49+
// ▲ [WARNING] Processing wrangler.toml configuration:
50+
// - "unsafe" fields are experimental and may change or break at any time.
51+
//
52+
// A WARNING, reported as the cause of a failure. The actual cause was on stdout,
53+
// where `--json` puts it: "In a non-interactive environment, it's necessary to set a
54+
// CLOUDFLARE_API_TOKEN environment variable". The runner read stderr and discarded
55+
// stdout, so every adopter whose wrangler.toml warns about anything gets that warning
56+
// named as the reason their schema gate failed.
57+
describe("wranglerFailureReason", () => {
58+
const TOKEN_ERROR =
59+
"In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable";
60+
61+
test("prefers the structured error wrangler --json writes to stdout", () => {
62+
const reason = wranglerFailureReason(
63+
JSON.stringify({ error: { text: TOKEN_ERROR } }),
64+
'▲ [WARNING] Processing wrangler.toml configuration:\n - "unsafe" fields are experimental.\n',
65+
"Command failed with exit code 1",
66+
);
67+
expect(reason).toContain("CLOUDFLARE_API_TOKEN");
68+
// The warning must not be presented as the cause.
69+
expect(reason).not.toContain("experimental");
70+
});
71+
72+
test("reads the array envelope shape too", () => {
73+
const reason = wranglerFailureReason(
74+
JSON.stringify([{ success: false, error: "no such table: Member" }]),
75+
"",
76+
"exit 1",
77+
);
78+
expect(reason).toBe("no such table: Member");
79+
});
80+
81+
test("falls back to stderr when stdout carries no structured error", () => {
82+
const reason = wranglerFailureReason("", "Authentication error [code: 10000]\n", "exit 1");
83+
expect(reason).toBe("Authentication error [code: 10000]");
84+
});
85+
86+
test("falls back to the process message when both streams are empty", () => {
87+
expect(wranglerFailureReason("", "", "Command failed with exit code 1"))
88+
.toBe("Command failed with exit code 1");
89+
});
90+
91+
test("unparseable stdout does not swallow stderr", () => {
92+
const reason = wranglerFailureReason("not json at all", "real stderr cause\n", "exit 1");
93+
expect(reason).toBe("real stderr cause");
94+
});
95+
96+
test("keeps stdout when it is the only thing that said anything", () => {
97+
// No structured error, no stderr — the raw stdout is still better than "exit 1".
98+
const reason = wranglerFailureReason("something went wrong upstream", "", "exit 1");
99+
expect(reason).toContain("something went wrong upstream");
100+
});
101+
});

0 commit comments

Comments
 (0)