Repository navigation
Conversation
Spec for #383: settles the open questions (shared OutputFormat enum, `text` default, stdout/stderr discipline, error envelopes, schema versioning, bundled stubs) and defines the per-command JSON schema. Also corrects the docs/.prettierignore comment: design docs are tracked in git.
active-version, auth status, build, config gc, config validate, deploy, healthcheck, provision and rollback accept --format text|json. JSON mode writes one versioned envelope to stdout and routes logs and child-process stdout to stderr; text mode is byte-identical to before. Adapters now return typed outcomes (ActionOutcome, ProvisionReport, GcReport) instead of printing their results, and every inheriting child spawn goes through edgezero_adapter::process::status, enforced by a clippy disallowed-methods lint. Closes #383
aram356
left a comment
There was a problem hiding this comment.
PR Review
Summary
Adds --format json to nine commands: one versioned envelope on stdout, with logs and child stdout moved to stderr, and typed adapter outcomes instead of printed lines. The design holds up, and the text path is unchanged (see CI Status). Three JSON results are wrong and should be fixed before schema v1 ships: version_verified on an unhealthy probe, build silently dropping a trailing --format json, and config gc --yes reporting deleted: null when there was nothing to reclaim.
Findings
Blocking
- 🔧
version_verified: trueon an unhealthy probe: the after-probe check only runs when the probe is healthy (crates/edgezero-adapter-fastly/src/cli.rs:5641) - 🔧
buildswallows a trailing--format json: exits 0 with text on stdout (crates/edgezero-cli/src/args.rs:251) - 🔧
config gc --yeswith nothing to reclaim reportsdeleted: null: the docs say null means a dry run (crates/edgezero-adapter-fastly/src/cli.rs:2497) - ❓ Spec approval: the spec header says "Status: Approved", and #383 asks for the spec to be approved before implementation starts. I couldn't find the approval on #383 or on this PR (the spec and the implementation arrived in the same push). Was it approved somewhere else? If so, a link in the spec header would help.
Non-blocking
- 🤔 Raw
config validatereportsapp_config: nullalthough it reads and requires that file (crates/edgezero-cli/src/config.rs:235) - 🤔 Provision dry-run actions don't pair with real-run actions (
crates/edgezero-adapter-spin/src/cli.rs:249,crates/edgezero-adapter-fastly/src/cli.rs:522) - 🤔 A deploy that went live but couldn't resolve its version reports
result: null(crates/edgezero-cli/src/lib.rs:382) - 🤔
deploy.service_idreflects only the flag (crates/edgezero-cli/src/lib.rs:216) - ♻️ Outcome types can hold contradictory values (
crates/edgezero-adapter/src/registry.rs:116) - ⛏ Small items: the
AuthSub::Statusvariant (args.rs:237),OutputFormatcompared with==(output.rs:75), a leftover bare block (edgezero-adapter-fastly/src/cli.rs:3958), and an unreachable--require-activepromise in the spec (spec L152) - 🤔 Docs: nullability and edge cases
- The compatibility policy says making a non-null key nullable is breaking, but the Results section lists key names only, so readers can't tell which keys may be null. Spec §10 called for one schema table per command. Nullable today:
active-version.version,build.artifact,deploy.service_id,deploy.version,healthcheck.staging_ip,healthcheck.status_code,rollback.rolled_back_to,provision.entries[].store,provision.entries[].store.logical,config gcolder_than_secs/deleted/store.id, andconfig validate.app_config. - Exit code 2 means three things in a generated CLI: a command failure, a clap usage error, and an unsupported
config diff. The "empty stdout means no envelope" rule in Streams is what tells them apart; worth repeating next to Exit codes. --format json --help(and--version) print clap's text to stdout and exit 0, which contradicts "stdout holds exactly one JSON document".- JSON routing depends on
init_cli_logger()being the installed logger. A downstream CLI that installs its own logger (e.g.simple_logger, which writes every level to stdout) would put log lines on stdout. One sentence in the docs would cover it.
- The compatibility policy says making a non-null key nullable is breaking, but the Results section lists key names only, so readers can't tell which keys may be null. Spec §10 called for one schema table per command. Nullable today:
- 🤔 Test gaps
- No test produces
version_verified: true(the existing healthcheck test removes the token). - The
config gcmapping fromfailure_diagnosticto a partial result (config.rs:467-469), and howfailed/stranded/uncertain/deletedare filled, have no JSON-level test. deploy,rollbackandactive-versionhave no tests of their result mapping, or of theversion=/rolled-back-to=/ "no active version yet" lines that moved from the adapter into the CLI.config validatehas no failure (null result) or typed-mode test.run_shell_tee(the deploy capture path) isn't exercised under JSON.- The end-to-end tests cover 5 of the 9 commands, and the 21-scenario byte comparison from the PR description isn't committed. Even a reduced version committed as a test would keep the text contract from drifting.
- No test produces
- 🌱 Lint gaps:
disallowed-methodsfires on everystatus/spawnform I tried (checked in a scratch crate using thisclippy.toml). It does not catch.stdout(Stdio::inherit()).output()(which really does leak at runtime),CommandExt::exec, orwriteln!(io::stdout(), …)(print_stdoutonly covers the macros). None of these is used today. Addingstd::io::stdoutto the list, with#[expect]at the three existing call sites (output.rs:537,adapter.rs:431,config.rs:1370), would close the last gap. The generated project'stemplates/root/clippy.toml.hbsdoesn't carry the lint, so a downstream CLI's own subcommands are unguarded. - 🌱 A closed stderr loses the envelope:
CliLoggerwrites routedinfowitheprintln!, which panics on a broken pipe. Under JSON all info goes to stderr, so if the stderr reader exits first, the command panics at its first log line (exit 101) and writes no envelope. Text mode has the same failure on stdout, so this isn't new, but JSON makes stderr the busy stream.let _ = writeln!(io::stderr(), "{}", record.args());avoids it.
📌 Out of Scope
wrangler whoamiexit code when logged out (unverified;wranglerwasn't available to test): some wrangler versions print "You are not authenticated" and still exit 0. If current ones do,auth status --adapter cloudflare --format jsonreportsstate: authenticated. Text mode already exited 0 in that case, so this predates the PR, but the JSON value now states it outright. Worth checking, and if so parsing thewhoamioutput rather than trusting the exit code.
CI Status
- fmt: PASS
- clippy: PASS
- tests: PASS (
cargo test --workspace --all-targets: 1459 passed, 0 failed) cargo check --workspace --all-targets --features "fastly cloudflare spin": PASScargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin: PASScargo test -p edgezero-adapter-fastly --features cli: PASS (288 passed)- Text mode vs
main: 130 paired hermetic runs across the 9 commands (fakecurl/fastly/wrangler/spin/cargo) gave byte-identical stdout, stderr and exit codes. The only difference is the usage-error text for--format yaml.
Revision 3 records the review's contract fixes: version_verified only when both active checks ran, config gc deleted: 0 for an empty real run, build rejecting a late --format, config validate always reporting app_config, deploy keeping its result and resolved service id, and Spin reporting created. Drops the unreachable active-version --require-active promise and documents --help/--version and the exit-code-2 overlap.
Maps each review comment to the change that addresses it, with the tests and verification that back it.
- healthcheck: version_verified is true only when the after-probe check ran
- config gc: a real run with nothing to reclaim reports deleted: 0, not null
- build: reject a --format that follows passthrough args instead of forwarding it
- config validate: always report app_config; the mode is passed explicitly
- deploy: report the adapter-resolved service id, and keep the result when the deploy went live but its version could not be resolved
- spin provision: an added label reports created, pairing with would_create
- Outcome types store each fact once: HealthcheckOutcome::healthy(), AuthState::Unauthenticated { reason }, GcFailure
- Lint direct std::io::stdout writers (also in the generated project's clippy.toml) and never panic the logger on a closed stderr
- Exhaustive OutputFormat matches, #[non_exhaustive] AuthSub::Status, flatten a leftover block
- Docs: per-command result tables with nullability, --help/--version, exit code 2, logger dependency, provision action pairing
- Tests: token healthcheck, gc report fields, deploy tee and partial result, active-version, rollback, validate failure, late --format
prk-Jr
left a comment
There was a problem hiding this comment.
PR Review (re-review at cc6f1ac)
Summary
The fix commit addresses aram356's blocking items, and the new tests fail against the old code: version_verified only when both checks ran, deleted: 0 on an empty real gc run, and the late---format guard. Almost every non-blocking item is closed too.
I also built this branch and main and ran them with fake fastly / curl / wrangler / spin / cargo:
- Under
--format json, stdout held exactly one valid envelope in every scenario tried. That covered all 9 commands on success and failure paths, more than 1 MB of output from a child process, a killed child,RUST_LOG=trace, a closed stderr, and Unicode/escaping in paths and messages. - In text mode, 49 of 54 scenarios were byte-identical to
main. All 5 differences come from the new flag, and one is thebuild -- --formatcase below.
What remains is the unanswered spec-approval question, two doc errors in the output contract, and some test gaps.
😃 Praise
- One gate for child stdout: every child process that inherits our stdout goes through
edgezero_adapter::process::status, thedisallowed-methodslint enforces it, and the exceptions carry#[expect]with reasons. "stdout stays clean" can't quietly regress. - Routing can't get stuck:
OutputScopesaves and restores the routing flags inDrop, so a?, an early return or a panic always restores routing. - Each fact stored once:
AuthState::Unauthenticated { reason },HealthcheckOutcome::healthy()andGcFailurerule out the contradictory states. - Tests that check behaviour: the fake-
curlAPI call log proves both version checks ran, rather than just trusting the flag.sole_json_documentandassert_envelope_invariantsmake the one-envelope rule hard to break. - Docs match the code: the per-command result tables match the serde wire structs field by field, including nullability and enum spellings.
Findings
Blocking
- ❓ Spec approval still unanswered (spec:4)
- 🔧 Docs promise a
--versionflag that doesn't exist (cli-reference.md:662) - 🔧 Empty-stdout list misses the late-
--formatrejection, which contradicts the exit-1 guarantee (cli-reference.md:657)
Non-blocking
- 🤔
buildrejects-- --format json,deployforwards it. This also changes text mode forbuild(lib.rs:176). - 🤔
auth statusdecidesauthenticatedfrom the exit code alone. Real wrangler exits 0 when not logged in (cli_support.rs:112). - 🤔 A broken stdout pipe hides the command's real error (
output.rs:545). - 🤔 The plan doc's status is wrong, and it includes a line specific to one machine (plan:5).
- 🤔 Spec §7.2 types and §9.3 test list are stale (spec:331).
- 🤔
OutputScopedoesn't support overlapping runs on separate threads. It needs a doc note (output.rs:68). - 🤔 Test gaps:
config gcis the only one of the 9 commands with no end-to-end test. The CLI-side mapping (dry_run = args.dry_run || !args.yes,older_than_secs, and not printingtext_lineson failure) is untested. Thefake_fastly_gchelper in the Fastly adapter tests could be reused.auth statushas end-to-end tests only for the unauthenticated case. There's no test of the authenticated path or of a missing native CLI (result: nullper the docs).- Staging
deploy/rollbackaren't tested. That includes theversion=line that moved from the adapter to the CLI, and the resolvedservice_id(flag, thenFASTLY_SERVICE_ID). - The generated project's new
clippy.toml.hbslines aren't asserted. The generator test only checksallow-expect-in-tests, so addingassert!(clippy.contains("std::io::stdout"))would cover it. - The text-mode checks for build, rollback and deploy use
starts_with/ends_with, so lines added in the middle would pass. auth, provision, validate and gc have no text-mode check. The byte comparison againstmainfrom spec §9.2 is still not committed. - The test harness only clears
FASTLY_API_TOKEN/FASTLY_SERVICE_ID. An exportedEDGEZERO__*or app-overlay variable on the machine running the tests can change provision and validate results.
- ♻️ Fastly staged deploy works out the service id a second time with
.ok(). The productionDeployreturnsDeploy{None,None}while other adapters returnEmpty(fastly cli.rs:434). - ♻️
Result<Result<u64, String>, String>would read better as a small enum.deploy()is about 140 lines (lib.rs:368). - ⛏ Small items:
healthcheckreportsstatus_code: 0when curl prints000. The docs saynullwhen no probe got a response.- A production Fastly deploy with only
FASTLY_SERVICE_IDset reportsservice_id: null, althoughfastlyused that id. Worth one sentence in thedeploytable. - The
#[expect]reason atconfig.rs:1374says "config pushhas no--format", butconfig diffcalls the same function. GcResult::newclones the wholeGcFailure, diagnostic string included, just to read three lists. Destructure it by reference instead.auth status --helpdescribes--formatwith shorter wording than the other eight commands (args.rs:236).- The
processtests restore the global setting by hand. Ifexpect("spawn")panics, the setting staystrueandPOLICY_LOCKis poisoned. Use a drop guard. native_auth_status_maps_exit_status_to_staterunstrue/falsebut isn't#[cfg(unix)], unlike the other fake-binary tests.
- 🌱 Lint gaps:
disallowed-methodsdoesn't catchstd::os::unix::process::CommandExt::execor.stdout(Stdio::inherit()).output(). Neither is used today.- The public outcome structs (
HealthcheckOutcome,GcReport,RollbackOutcome,ProvisionEntry, …) aren't#[non_exhaustive], so adding a field later breaks adapters built outside this repo.
📌 Out of Scope
These all predate this PR. Worth tracking as follow-ups:
provisionfailing partway: the result isnulland neither stream says what was already created.- Deploy output parsing: in the tee, a non-UTF-8 line stops the version parsing, so a later
SUCCESS … version 12line is never seen. With more than 64 KB after the bad byte, the child gets SIGPIPE. - Built-in Fastly production deploy: this path doesn't capture
fastly compute deploy's output. Without a token it fails with "noversion=<N>line in the deploy output" even when Fastly printed the version. - Empty token:
FASTLY_API_TOKEN=(empty) is treated as set. - Rollback to the same version:
rollback --version 7 --rollback-to 7is accepted.
CI Status
- fmt: PASS
- clippy (
--workspace --all-targets --all-features -D warnings): PASS - tests (
cargo test --workspace --all-targets): PASS, 0 failures cargo check --workspace --all-targets --features "fastly cloudflare spin": PASScargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin: PASS- Black-box JSON contract: every scenario tried produced exactly one valid envelope. Text mode vs
main: 49/54 byte-identical; the 5 differences are expected, and one is thebuild -- --formatcase above.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Review summary
Reviewed all 24 changed files and traced adapter outcomes, CLI consumers, subprocess routing, and Fastly action parsers.
Head: cc6f1ac99aa09de8025bc26847e424dd7b02fba9
Base: 98930917d96cb665c7255f36ca8bbd61fc7539e1
Safety proof
- JSON remains isolated from logs and child stdout on tested paths. Executed and proven for covered cases: all 18 binary-level format tests passed, including inherited and captured child output, failure envelopes, and token-backed health verification.
- GC preserves partial-failure information without continuing a damaged generation. Proven through the binary with a fake provider: an injected second-delete failure produced exit 1,
deleted: 1, and exact failed/stranded keys. The third delete was not attempted. A rerun retained the incomplete generation; an unknown root blocked deletion.
Findings
No meaningful new findings beyond existing PR feedback. This approval reflects the new-issues-only scope of this review; existing review concerns remain unresolved.
Validation and review context
Passed locally at the locked head:
cargo test --workspace --all-targets --lockedcargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features --locked -- -D warningscargo check --workspace --all-targets --features "fastly cloudflare spin" --lockedcargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin --locked- Focused JSON, Fastly CLI, and adapter tests.
- Binary-level GC interruption and rerun checks.
All currently reported GitHub checks passed. Existing reviews, inline threads, and issue comments were inspected to avoid duplicate findings.
No live-provider operations were attempted. The generated-project compilation test remained ignored locally. Head/base were unchanged at completion, and the worktree remained clean. No repository files changed; no delegation.
Mark the spec as proposed and awaiting maintainer approval, and update its type listings, testing section and revision notes to match the code. Remove the retroactive plan document. Keep the command's own error when writing the JSON envelope fails. Report the staged Fastly deploy's service id from the deploy itself, return Empty for the production deploy, and replace the nested Result with a ProductionDeploy enum. Treat curl's 000 as no response, not status 0. Document that build never forwards --format, that auth status reports the probe's exit status, and that only one command may run per process. Cover config gc, auth status, staged deploy and staged rollback end to end, pin the text output of all nine commands, and clear EDGEZERO__ overlays in the test harness. Disallow CommandExt::exec, and give the lifecycle fixture its own clippy.toml so the CLI stdout guard does not apply to it.
|
Addressed in e6a0b12, beyond the inline threads:
|
Bring in #381 (Fastly stores bound per deployment environment), which moved deploy into the Adapter::preflight_deploy / deploy / finalize_deploy hooks and made Fastly staging an adapter-managed release deploy. Resolution keeps the --format json deploy result on the new hooks: - Adapter::deploy and Adapter::finalize_deploy return ActionOutcome, like execute. Fastly reports DeployOutcome from the managed deploy plan and from finalize_deploy's resolved production version. - The CLI's adapter::deploy returns DeployFailure, separating a failed deploy from a live deploy whose finalization failed; the latter keeps its JSON result with version: null. - Fastly provision takes main's logical/physical alias handling with the typed ProvisionReport; the removed runtime-env provisioning and the old CLI-side staging and version-resolution code are dropped. - emit_active_version_for returns ActiveVersionOutcome; finalize_deploy logs version=<N>, so text output is unchanged. - Tests follow main's version-list schema and the --application-release requirement for staged deploys. Spec revision 5 records the merge.
Summary
key=valuelog lines that were never a stable contract. Nine commands now accept--format json:active-version,auth status,build,config gc,config validate,deploy,healthcheck,provisionandrollback. Each one writes a single versioned envelope,{ schema_version, command, ok, result, error }, to stdout, on success and on failure.cargo,fastly,wrangler,spin, manifest commands) go to stderr. A new clippydisallowed-methodslint means future code can't bypass this.--format textis the default, and its output is byte-identical tomain.docs/superpowers/specs/2026-09-25-cli-format-json-design.md.config diff --format jsonis unchanged.Changes
edgezero-adapter(registry.rs)Adapter::executereturnsActionOutcome,provisionreturnsProvisionReport,gc_config_entriesreturnsGcReport, instead of()or prose lines. A negative result that was still measured (unhealthy probe, unauthenticated session, partly failed gc) is anOkoutcome carrying afailuremessage.edgezero-adapter(process.rs, new)process::status, the one sanctioned inheriting spawn.edgezero-adapter(cli_support.rs)native_auth_status.run_native_cligoes throughprocess::status.edgezero-adapter-{fastly,cloudflare,spin,axum}version=/healthy=/status-code=/rolled-back-to=data lines; the CLI prints the same bytes. All inheriting spawns go throughprocess::status.edgezero-cli(output.rs, new)OutputScope(routes stdout while alive),Failure/Outcome, the envelope, and the serde wire schema, kept separate from the adapter types so internal refactors can't change the JSON.edgezero-cli(args.rs)OutputFormat { Text, Json }and a--formatflag on the nine commands.DiffFormatis unchanged.edgezero-cli(lib.rs,auth.rs,provision.rs,config.rs,adapter.rs)run_*keeps its public signature and emits the envelope itself, so CLIs already generated from the template get JSON without regeneratingmain.rs. The logger'sinfooutput moves to stderr under JSON.clippy.tomldisallowed-methodsforCommand::status/Command::spawn. Piped spawns carry a documented#[expect].edgezero-cli/tests/format_json.rs(new)docs/guide/cli-reference.md--formaton each command, plus a new "Machine-readable output" section covering the envelope, streams, exit codes, compatibility policy, per-command schemas and changelog.docs/superpowers/specs/…-cli-format-json-design.mdCloses
Closes #383
Test plan
cargo test --workspace --all-targetscargo clippy --workspace --all-targets --all-features -- -D warningscargo fmt --all -- --checkcargo check --workspace --all-targets --features "fastly cloudflare spin"wasm32-wasip1(Fastly) /wasm32-wasip2(Spin) /wasm32-unknown-unknown(Cloudflare), via the fullformat.ymlwasm clippy matrix andcargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spinexamples/app-demoworkspace:cd examples/app-demo && cargo test --workspace --all-targets(--locked), plus its fmt and clippycd docs && npm run lint && npm run format && npm run buildedgezero serve --adapter axum(not applicable:serveis out of scope)main: both binaries ran 21 hermetic scenarios (success and failure paths for every in-scope command, a usage error, the bundled stub), with stdout, stderr and exit codes identical in all of them.cargo test -p edgezero-cli --test generated_project_builds -- --ignored,cargo test -p edgezero-adapter-fastly --features cli, and thecheck_no_nested_app_configsteps.Checklist
{id}syntax (not:id)edgezero_core(nothttpcrate)KvRegistry/ConfigRegistry/SecretRegistry(not the legacy single-handle setters) — see spec §6.6