Skip to content

Commit 6ca1b2d

Browse files
committed
cli: add --json structured-output mode for init / add-chain / push / transfer-ownership
Adds a global --json flag (also activated by WL_NTT_JSON=1) that: - Redirects console.log / .info / .warn / .debug and direct process.stdout writes to stderr. - Emits exactly one { ok, command, data } envelope on stdout from the command's success path via emitResult(). Failure paths are intentionally unstructured: non-zero exit + stderr message is the contract. Wrappers (scripts, parent processes) read the trailing stdout line on exit code 0; treat anything else as failure. Bun's console implementation doesn't always route through process.stdout.write, so we override the console methods directly *and* hijack process.stdout.write for transitive deps.
1 parent 44239cf commit 6ca1b2d

8 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
beforeEach,
6+
afterEach,
7+
setDefaultTimeout,
8+
} from "bun:test";
9+
import fs from "fs";
10+
import os from "os";
11+
import path from "path";
12+
13+
const CLI = path.resolve(import.meta.dir, "../index.ts");
14+
const SUBPROCESS_TIMEOUT = 30_000;
15+
16+
setDefaultTimeout(SUBPROCESS_TIMEOUT);
17+
18+
async function runCli(
19+
args: string[],
20+
opts?: { cwd?: string; env?: Record<string, string> }
21+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
22+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
23+
stdout: "pipe",
24+
stderr: "pipe",
25+
cwd: opts?.cwd ?? process.cwd(),
26+
env: { ...process.env, ...(opts?.env ?? {}) },
27+
});
28+
const [stdout, stderr] = await Promise.all([
29+
new Response(proc.stdout).text(),
30+
new Response(proc.stderr).text(),
31+
]);
32+
const exitCode = await proc.exited;
33+
return { stdout, stderr, exitCode };
34+
}
35+
36+
describe("--json output mode", () => {
37+
let workDir: string;
38+
39+
beforeEach(() => {
40+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "ntt-json-test-"));
41+
});
42+
43+
afterEach(() => {
44+
if (workDir && fs.existsSync(workDir)) {
45+
fs.rmSync(workDir, { recursive: true, force: true });
46+
}
47+
});
48+
49+
it("ntt init --json emits a single JSON envelope on stdout", async () => {
50+
const { stdout, exitCode } = await runCli(["init", "Testnet", "--json"], {
51+
cwd: workDir,
52+
});
53+
expect(exitCode).toBe(0);
54+
// stdout should be exactly the JSON envelope (plus trailing newline).
55+
const lines = stdout.trim().split("\n").filter(Boolean);
56+
expect(lines.length).toBe(1);
57+
const parsed = JSON.parse(lines[0]!);
58+
expect(parsed.ok).toBe(true);
59+
expect(parsed.command).toBe("init");
60+
expect(parsed.data.network).toBe("Testnet");
61+
expect(parsed.data.path).toBe("deployment.json");
62+
});
63+
64+
it("WL_NTT_JSON=1 activates the same envelope without --json flag", async () => {
65+
const { stdout, exitCode } = await runCli(["init", "Mainnet"], {
66+
cwd: workDir,
67+
env: { WL_NTT_JSON: "1" },
68+
});
69+
expect(exitCode).toBe(0);
70+
const lines = stdout.trim().split("\n").filter(Boolean);
71+
expect(lines.length).toBe(1);
72+
const parsed = JSON.parse(lines[0]!);
73+
expect(parsed.ok).toBe(true);
74+
expect(parsed.command).toBe("init");
75+
expect(parsed.data.network).toBe("Mainnet");
76+
});
77+
78+
it("human mode is unchanged (no JSON envelope on stdout)", async () => {
79+
const { stdout, stderr, exitCode } = await runCli(["init", "Testnet"], {
80+
cwd: workDir,
81+
});
82+
expect(exitCode).toBe(0);
83+
// Human-readable messages on stdout, no JSON envelope.
84+
expect(stdout).toContain("deployment.json created");
85+
expect(stdout).not.toContain('"ok":true');
86+
// Should NOT have routed human output to stderr in human mode.
87+
expect(stderr).not.toContain("deployment.json created");
88+
});
89+
90+
it("--json routes human messages to stderr, keeping stdout clean", async () => {
91+
const { stdout, stderr, exitCode } = await runCli(
92+
["init", "Testnet", "--json"],
93+
{ cwd: workDir }
94+
);
95+
expect(exitCode).toBe(0);
96+
expect(stderr).toContain("deployment.json created");
97+
// stdout must be ONLY the JSON envelope.
98+
expect(stdout.trim()).toMatch(/^\{.*\}$/);
99+
});
100+
});

cli/src/commands/add-chain.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { Argv } from "yargs";
1414
import { colors } from "../colors.js";
1515
import { loadConfig, type Config } from "../deployments";
1616
import { enableBigBlocks } from "../evm/hyperliquid.js";
17+
import { emitResult } from "../output.js";
1718
import { promptYesNo } from "../prompts.js";
1819
import { promptSolanaMainnetOverridesIfNeeded } from "../overrides.js";
1920
import { validatePayerOption } from "../validation";
@@ -403,6 +404,16 @@ export function createAddChainCommand(
403404
console.log(
404405
`Added ${chain} to ${path} (instance ${instance.toBase58()} under program ${instanceOf})`
405406
);
407+
emitResult("add-chain", {
408+
path,
409+
chain,
410+
manager: deployedManager.address.toString(),
411+
instance: instance.toBase58(),
412+
decimals,
413+
mode,
414+
token,
415+
instanceOf,
416+
});
406417
return;
407418
}
408419

@@ -464,6 +475,15 @@ export function createAddChainCommand(
464475
);
465476
fs.writeFileSync(path, JSON.stringify(deployments, null, 2));
466477
console.log(`Added ${chain} to ${path}`);
478+
emitResult("add-chain", {
479+
path,
480+
chain,
481+
manager: deployedManager.address.toString(),
482+
instance: solanaInstance,
483+
decimals,
484+
mode,
485+
token,
486+
});
467487
},
468488
};
469489
}

cli/src/commands/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "fs";
22
import { isNetwork } from "@wormhole-foundation/sdk";
33
import type { Argv } from "yargs";
44
import { colors } from "../colors.js";
5+
import { emitResult } from "../output.js";
56
import { options } from "./shared";
67

78
export function createInitCommand() {
@@ -48,6 +49,7 @@ export function createInitCommand() {
4849
`\nTip: To use custom RPC endpoints, rename example-overrides.json to overrides.json and edit as needed.`
4950
)
5051
);
52+
emitResult("init", { path, network: argv["network"] });
5153
},
5254
};
5355
}

cli/src/commands/push.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Argv } from "yargs";
1616
import { ethers, Interface } from "ethers";
1717
import { colors } from "../colors.js";
1818
import { loadConfig, type Config } from "../deployments";
19+
import { emitResult } from "../output.js";
1920
import { getSigner, type SignerType } from "../signers/getSigner";
2021
import { newSignSendWaiter } from "../signers/signSendWait.js";
2122
import { registerSolanaTransceiver } from "../solana/transceiver";
@@ -264,6 +265,7 @@ export function createPushCommand(overrides: WormholeConfigOverrides<Network>) {
264265
process.exit(1);
265266
}
266267

268+
const chainsTouched: string[] = [];
267269
for (const [chain, deployment] of Object.entries(
268270
depsAfterRegistrations
269271
)) {
@@ -284,7 +286,17 @@ export function createPushCommand(overrides: WormholeConfigOverrides<Network>) {
284286
argv["dangerously-transfer-ownership-in-one-step"],
285287
overrides
286288
);
289+
chainsTouched.push(chain);
287290
}
291+
emitResult("push", {
292+
path: argv["path"],
293+
chainsTouched,
294+
skipChains,
295+
onlyChains,
296+
ownershipTransferredOneStep: Boolean(
297+
argv["dangerously-transfer-ownership-in-one-step"]
298+
),
299+
});
288300
},
289301
};
290302
}

cli/src/commands/transfer-ownership.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ethers } from "ethers";
1717

1818
import { colors } from "../colors.js";
1919
import { loadConfig, type Config } from "../deployments";
20+
import { emitResult } from "../output.js";
2021
import { getSigner } from "../signers/getSigner";
2122

2223
import { options } from "./shared";
@@ -144,6 +145,13 @@ export function createTransferOwnershipCommand(
144145
console.log(
145146
`✅ Ownership transferred successfully to ${destination}`
146147
);
148+
emitResult("transfer-ownership", {
149+
chain,
150+
manager: chainConfig.manager,
151+
previousOwner: currentOwner.toString(),
152+
newOwner: newOwnerFromContract,
153+
txHash: tx.hash,
154+
});
147155
} else {
148156
console.error(`❌ Ownership transfer verification failed`);
149157
console.error(` New owner: ${newOwnerFromContract}`);

cli/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ const overrides: WormholeConfigOverrides<Network> = loadOverrides();
4141
yargs(hideBin(process.argv))
4242
.wrap(Math.min(process.stdout.columns || 120, 160)) // Use terminal width, but no more than 160 characters
4343
.scriptName("ntt")
44+
.option("json", {
45+
describe:
46+
"Emit a single JSON result line on stdout (everything else routed to stderr). Also activated by WL_NTT_JSON=1.",
47+
type: "boolean",
48+
default: false,
49+
global: true,
50+
})
4451
.version(
4552
(() => {
4653
if (!nttVersion()) return "unknown";

cli/src/output.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Structured-output mode for the NTT CLI.
2+
//
3+
// Enabled by `--json` (anywhere in argv) or `WL_NTT_JSON=1`. While active,
4+
// every stdout write is redirected to stderr — the only thing that lands on
5+
// stdout is the final `{ ok: true, command, data }` line emitted by
6+
// `emitResult()` at the end of a successful command. Callers (`wl sunrise`,
7+
// scripts, the future web server) can read the single trailing line and
8+
// ignore everything else as logs.
9+
//
10+
// Failure paths are intentionally unstructured in v1: a non-zero exit code +
11+
// stderr is the contract. If we ever need typed errors on stdout, add an
12+
// `{ ok: false, ... }` envelope here.
13+
14+
let mode: "human" | "json" = "human";
15+
let originalStdoutWrite: typeof process.stdout.write | null = null;
16+
17+
/**
18+
* Activate JSON mode if `--json` is in argv or `WL_NTT_JSON=1` is set. Must
19+
* run before any command handler logs (called from `side-effects.ts` at
20+
* module load time so the hijack lands before any other import has a chance
21+
* to write).
22+
*
23+
* Bun's `console.log` doesn't always route through `process.stdout.write`, so
24+
* we override the console methods directly *and* keep a reference to the
25+
* original `process.stdout.write` for `emitResult` to use.
26+
*/
27+
export function initOutputMode(): void {
28+
if (mode === "json") return;
29+
const requested =
30+
process.env.WL_NTT_JSON === "1" || process.argv.includes("--json");
31+
if (!requested) return;
32+
33+
mode = "json";
34+
originalStdoutWrite = process.stdout.write.bind(process.stdout);
35+
36+
// Redirect console.log / .info / .warn / .debug to stderr. console.error
37+
// already goes to stderr; leave it alone.
38+
const redirect =
39+
(label: string) =>
40+
(...args: unknown[]): void => {
41+
// Use console.error so chalk / colors keep working and we don't have to
42+
// reimplement util.format.
43+
console.error(...args);
44+
};
45+
console.log = redirect("log") as typeof console.log;
46+
console.info = redirect("info") as typeof console.info;
47+
console.warn = redirect("warn") as typeof console.warn;
48+
console.debug = redirect("debug") as typeof console.debug;
49+
50+
// Belt-and-braces: also hijack process.stdout.write so any direct stdout
51+
// write from a transitive dep lands on stderr.
52+
process.stdout.write = ((chunk: any, ...rest: any[]): boolean =>
53+
(process.stderr.write as any).call(
54+
process.stderr,
55+
chunk,
56+
...rest
57+
)) as typeof process.stdout.write;
58+
}
59+
60+
export function isJsonMode(): boolean {
61+
return mode === "json";
62+
}
63+
64+
/**
65+
* Emit the final `{ ok: true, command, data }` line on stdout. No-op in human
66+
* mode. Call this exactly once at the end of a successful command handler.
67+
*/
68+
export function emitResult(
69+
command: string,
70+
data: Record<string, unknown>
71+
): void {
72+
if (mode !== "json" || !originalStdoutWrite) return;
73+
originalStdoutWrite(JSON.stringify({ ok: true, command, data }) + "\n");
74+
}

cli/src/side-effects.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import { initOutputMode } from "./output";
2+
3+
// Activate `--json` / `WL_NTT_JSON=1` stdout hijack before any other import
4+
// has a chance to log.
5+
initOutputMode();
6+
17
// <sigh>
28
// when the native secp256k1 is missing, the eccrypto library decides TO PRINT A MESSAGE TO STDOUT:
39
// https://github.com/bitchan/eccrypto/blob/a4f4a5f85ef5aa1776dfa1b7801cad808264a19c/index.js#L23

0 commit comments

Comments
 (0)