Skip to content

cli: add --json structured-output mode#885

Draft
kcsongor wants to merge 14 commits into
cs/solana-multi-hostfrom
cs/ntt-json-output
Draft

cli: add --json structured-output mode#885
kcsongor wants to merge 14 commits into
cs/solana-multi-hostfrom
cs/ntt-json-output

Conversation

@kcsongor

Copy link
Copy Markdown
Contributor

Summary

Adds a --json flag (also activated by WL_NTT_JSON=1) to the NTT CLI commands that parent processes / scripts most often need to drive:

  • ntt init
  • ntt add-chain (both the regular and --instance-of multi-tenant paths)
  • ntt push (regular + --dangerously-transfer-ownership-in-one-step)
  • ntt transfer-ownership
  • ntt solana token-authority

In --json mode, every stdout write (including library console.logs and Bun's native console) is redirected to stderr. The only thing that lands on stdout is exactly one final { ok: true, command, data } envelope emitted from the command's success path.

Failure paths intentionally stay unstructured: non-zero exit + stderr message is the contract. Wrappers read the trailing stdout line on exit 0; treat anything else as failure.

Why draft, why this base

This stack technically doesn't depend on #875 — the --json work touches commands that exist independent of the multi-tenant changes. But it's easier to keep the branches stacked while #875 is in flight, since #875 is also under active development and rebasing the JSON work onto main and then again onto #875's eventual merge state is fiddly.

Don't merge this until #875 is merged. Once #875 lands on main, I'll re-target this PR to main and lift draft.

Test plan

  • bun test src/__tests__/ — full suite still passes (160 pass)
  • New src/__tests__/json-output.test.ts covers:
    • JSON envelope shape via --json flag
    • Same via WL_NTT_JSON=1 env var
    • Human mode unchanged
    • stdout / stderr separation under --json
  • Manual smoke: ntt init --json | jq ., ntt solana token-authority ... --json | jq .
  • Manual smoke on ntt add-chain / ntt push / ntt transfer-ownership against a real deployment (deferred until solana: multi-tenant deployment #875 lands)

@kcsongor
kcsongor force-pushed the cs/ntt-json-output branch from 7b1c095 to f271ba6 Compare May 20, 2026 15:31
kcsongor added 9 commits June 3, 2026 14:47
- bind OutboxItem to manager instance
- decouple BPF upgrade authority from Config owner
Multi-tenant Solana NTT (>= v4) PDAs are scoped by the per-deployment
Instance pubkey, so every CLI surface that reads or writes a manager
needs to know it:

- ChainConfig grows an optional `instance` field; SolanaDeploymentResult
  threads it back from `deploy()`.
- pullChainConfig / nttFromManager take an optional `solanaInstance`
  arg, plumbed through every caller (add-chain, upgrade, clone,
  transfer-ownership, set-mint-authority, solana subcommands,
  config-mgmt's pull loop).
- deploySvm gains a multi-tenant branch: generates (or loads via
  --instance-key) an Instance keypair, sets contracts.ntt.instance
  before SDK construction, co-signs `initialize`, and returns the
  instance pubkey alongside the program address. addSolanaInstance
  no longer rethrows on initializeOrUpdateLUT failure (matches
  deploySvm's swallow-on-LUT shape; lets dev environments without
  the wormhole core bridge still write deployment.json).
- `ntt solana token-authority --instance <pubkey>` derives the
  per-instance PDA before mint-authority handoff.

SolanaNtt's constructor now refuses the v4-without-instance and
v3-with-instance footguns at construction time (the PDA factory
accepts an optional config arg for back-compat, so without this the
SDK silently falls back to legacy singleton derivations against a
multi-tenant manager). Old "isV4" branches renamed to "multiTenant"
in cli/src/solana/deploy.ts to capture the property we're checking.

Local cli/test/solana.sh:
- export COPYFILE_DISABLE=1 (macOS' AppleDouble metadata files break
  solana-test-validator's genesis-archive unpacker).
- airdrop with --commitment finalized — `solana program deploy`
  uses --commitment finalized and was racing finalization on a
  fresh airdrop, surfacing as a bogus "insufficient funds" against
  a 50-SOL account.
- new v4 multi-tenant section: asserts `ntt upgrade` is blocked at
  the v3->v4 boundary by canUpgrade(), then patches Anchor.toml +
  lib.rs to declare a locally-keypair'd id, rebuilds, deploys, and
  walks `add-chain --instance-of` end-to-end. cleanup() trap
  unconditionally restores the patched source files on exit.
cli/e2e/e2e-solana.test.ts spins up its own `solana-test-validator`
loaded with the wormhole core bridge + post-message shim + verify-vaa
shim as genesis programs (mirroring solana/Anchor.toml's [[test.genesis]]
setup) plus the local v4 NTT .so at its declared id, then drives `ntt`
end-to-end via Bun.spawn. Three tests:

- `ntt init Mainnet` writes deployment.json with the expected shape.
- `add-chain --instance-of` creates a multi-tenant Instance under the
  pre-loaded program (skipping deploy) and persists the `instance`
  pubkey alongside `manager` in deployment.json.
- `ntt upgrade Solana --ver 4.0.0` from a synthetic v3 deployment.json
  is blocked by canUpgrade() with the migration-steer error message.

Logging knobs:
  NTT_E2E_DEBUG=1    one progress line per `ntt` invocation
  NTT_E2E_VERBOSE=1  full stdout+stderr per invocation
On failure, full stdout+stderr is dumped through the thrown error.
The validator's stdout/stderr is unconditionally appended to
/tmp/ntt-e2e-validator.log for `tail -f`-style real-time inspection.

Per-test timeouts are set in-file so `bun test cli/e2e/e2e-solana.test.ts`
runs without a `--timeout` flag: validator boot ~10s, full add-chain
~70s, upgrade-block <1s.

cli/src/index.ts: `.parseAsync().then(() => process.exit(0))` instead
of `.parse()`. Without the explicit exit, the Solana SDK's `Connection`
leaves a websocket subscription open after a successful command and
the CLI hangs indefinitely waiting for an event-loop drain that won't
come. This bites real users too — `ntt add-chain`/`upgrade`/`push`
exiting cleanly is what everyone expects.

.github/workflows/cli.yml: adds `test-cli-solana-e2e` job mirroring
solana.yml's `anchor-test` setup (bun 1.3.4, solana 1.18.26, anchor
0.29.0) plus `make sdk` to produce the v4 .so, then runs the bun
suite. Uploads /tmp/ntt-e2e-validator.log as an artifact on failure
so CI-only flakes are debuggable.
The CLI imports several @wormhole-foundation/sdk-*-ntt workspace
packages that resolve via bun's workspace symlinks under root
node_modules. Without 'bun ci' at the repo root, those symlinks
don't exist and 'ntt' fails on first import:

  Cannot find module '@wormhole-foundation/sdk-evm-ntt'
    from cli/src/index.ts

Mirrors the pattern in test-cli-unit, which already does 'bun ci'
first.
`bun run --cwd cli test:e2e` was a glob over `e2e/*.test.ts`, which
meant the EVM job in cli.yml started running the new Solana e2e
suite too. The EVM CI runner has no `solana-test-validator`, so
the suite errored out in beforeAll and the job failed.

Split into:
  test:e2e:evm     — anvil-only (used by the EVM CI job)
  test:e2e:solana  — solana-test-validator-only (used locally;
                     test-cli-solana-e2e runs the file directly)
  test:e2e         — both, sequentially (for local 'run everything')
`make sdk` calls `bun run build:solana` which only builds
sdk-definitions-ntt + sdk-solana-ntt. The CLI also imports
@wormhole-foundation/sdk-evm-ntt and sdk-sui-ntt — without their
dist/ populated, bun resolves the workspace symlink to a package
whose main/module fields point at non-existent files and the
resolver surfaces it as 'Cannot find module sdk-evm-ntt'.

Replace `make sdk` with `make anchor-build` (produces the .so
and patches the IDL — what we actually need from the solana side)
plus `bun run build` at the repo root, which builds every
workspace package's TypeScript. Mirrors what cli/install.sh does
for the EVM e2e job.
@kcsongor
kcsongor force-pushed the cs/solana-multi-host branch from 0ad1b81 to 44239cf Compare June 3, 2026 12:55
@kcsongor
kcsongor force-pushed the cs/ntt-json-output branch from f271ba6 to 76f2c77 Compare June 3, 2026 12:56
@kcsongor
kcsongor force-pushed the cs/ntt-json-output branch from 76f2c77 to 11bf414 Compare June 3, 2026 13:05
kcsongor added 4 commits June 3, 2026 15:47
…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.
Lets parent processes consume the per-instance token_authority PDA without
text-scraping stdout. Same { ok, command, data } envelope shape as the
other --json-aware commands.
@kcsongor
kcsongor force-pushed the cs/ntt-json-output branch from 11bf414 to 68ca889 Compare June 3, 2026 19:26
@kcsongor
kcsongor force-pushed the cs/solana-multi-host branch 2 times, most recently from de8534b to 63910c4 Compare June 19, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant