Skip to content

feat(cmd/dbc): versioned JSON schema, --json on all commands, NDJSON progress#365

Merged
zeroshade merged 30 commits intomainfrom
cli-json-improvements
Apr 28, 2026
Merged

feat(cmd/dbc): versioned JSON schema, --json on all commands, NDJSON progress#365
zeroshade merged 30 commits intomainfrom
cli-json-improvements

Conversation

@zeroshade
Copy link
Copy Markdown
Member

@zeroshade zeroshade commented Apr 24, 2026

Summary

Improves the --json output to be more structured and versioned. All changes are additive — existing human-readable output and exit codes are unchanged.

Changes

internal/jsonschema — versioned schema package

Extracts a new internal/jsonschema package with typed Go structs for every --json payload the CLI emits. All structs carry schema_version: 1 and a kind discriminator.

--json on all commands

  • install — refactored to use jsonschema.InstallStatus envelope
  • uninstall / search / info — refactored to use jsonschema package
  • init / add / remove — new --json flag emitting init.response, add.response, remove.response
  • sync — new --json flag emitting NDJSON progress events + final sync.status

NDJSON progress streaming from install --json

When --json is set, dbc install emits one NDJSON line per phase (download.start, download.progress, extract.start, verify.start, etc.) to stdout as they occur, followed by the terminal install.status envelope.

sha256 checksum verification on install

After downloading, dbc install now computes and verifies the sha256 of the tarball. The checksum is included in the install.status JSON payload. --no-verify bypasses both signature and checksum checks; --insecure-no-checksum bypasses only the checksum.

File-locking on mutating commands

Adds internal/fslock (advisory flock/LockFileEx) wrapping install, uninstall, sync, add, remove to prevent concurrent mutations. Returns a clear error with the owner PID if the lock cannot be acquired within 10s.

auth login --jsonverification_uri_complete

When --json is set, the device-code flow immediately emits an auth.device_code envelope containing verification_uri_complete (the pre-filled URL) so a consumer can open a browser directly if desired.

Dead code removal

Removes the unused TuiCmd struct and cmd/dbc/view_config.go.

Testing

All existing tests pass. New tests added for every changed command.

go test ./...

@zeroshade zeroshade changed the title feat(cli): versioned JSON schema, --json on all commands, NDJSON progress, sha256 verification, file-locking feat(cmd/dbc): versioned JSON schema, --json on all commands, NDJSON progress Apr 24, 2026
@zeroshade zeroshade marked this pull request as ready for review April 24, 2026 22:04
Copy link
Copy Markdown
Member

@amoeba amoeba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a quick look and made some comments in line. Other thoughts:

  1. I'm not so sure about install --json streaming. it means the return shape varies based on the state of the system. i.e., run the same command twice and your return shape is different. Could we remove that bit and add it if someone ever asks for it?2. Did you consider making the error responses structured like ${subcommand}.error instead of every error being "kind": "error"?

  2. The binary structure of the kind responses seems like it could be two fields. For example, the current output,

$ dbc init --json
{
  "schema_version": 1,
  "kind": "init.response",
  "payload": {
    "driver_list_path": "/Users/bryce/src/columnar-tech/dbc/dbc.toml",
    "created": true
  }
}

could be

{
  "schema_version": 1,
  "command": "init",
  "status": "success",
  "payload": {
    "driver_list_path": "/Users/bryce/src/columnar-tech/dbc/dbc.toml",
    "created": true
  }
}
  1. There's an encoding bug here,
$ go run ./cmd/dbc add --json "mysql<1.0.0"
{"schema_version":1,"kind":"add.response","payload":{"driver_list_path":"/Users/bryce/src/columnar-tech/dbc/dbc.toml","drivers":[{"name":"mysql","version_constraint":"\u003c1.0.0"}]}}
  1. What made you go with locking rather than atomic writes? Were we vulnerable to concurrent mutations before?

Comment thread cmd/dbc/add.go Outdated
Comment thread internal/jsonschema/schema.go
Comment thread internal/jsonschema/schema.go
@zeroshade
Copy link
Copy Markdown
Member Author

I'm not so sure about install --json streaming. it means the return shape varies based on the state of the system. i.e., run the same command twice and your return shape is different. Could we remove that bit and add it if someone ever asks for it?2.

I've been playing around with a GUI being built using Tauri or electron. Either way the install --json streaming is so that the eventual GUI will be able to have a visual progress bar indicator (matching the current progress bar we do in the terminal). Most of the changes and updates here came from that research and development of trying to construct a GUI.

Did you consider making the error responses structured like ${subcommand}.error instead of every error being "kind": "error"?

That wasn't something I thought of since the assumption was that we'd have specific shapes for the kinds and ALL errors would have the same shape, hence kind: "error" as opposed to ${subcommand}.error which would require parsing out the .error to know that it's an error. The other assumption being that the caller knows what arguments they passed and don't need the error to output that. Also, most of the time this is covered in the "code" of the error response which usually has something like "info_failed" or "init_failed" etc.

The binary structure of the kind responses seems like it could be two fields....

I don't think the "status" field is necessary since the kind will either be "error" to indicate an error, or something else indicating success. The format of the kind being something like install.status or add.response is solely to uniquely identify the shape of a particular payload. It's not intended to be a binary structure, beyond helping to associate the payload shapes.

What made you go with locking rather than atomic writes? Were we vulnerable to concurrent mutations before?

Yup, we were vulnerable because all of these paths would open the file first, read it, do something and then write it back out. At any point in there the file could be mutated causing the final write to clobber anything that was modified in between when we opened and read it and when we wrote out the result.

Consider the scenario of running dbc add mysql and dbc add duckdb in the same directory at the same time. Depending on the order of operations, OS scheduling, filesystem I/O scheduling, you could end up with any potential scenario of one or both getting added or even file corruption (since NewEncoder(f).Encode isn't an atomic write). Using atomic writes would eliminate the potential for corrupting the files, but it wouldn't remove the possibility of losing information:

| Process 1 | -> Open File -> Decode TOML into memory
| Process 2 | -> Open File -> Decode TOML into memory
| Process 1 | -> Adds mysql driver -> atomically writes the file out
| Process 2 | -> Adds duckdb to the ORIGINAL set of drivers -> atomically writes the file out, missing the mysql addition

The better solution here is file lock of some kind to prevent the concurrent mutation in the first place

@amoeba
Copy link
Copy Markdown
Member

amoeba commented Apr 28, 2026

Thanks for the responses @zeroshade. I'm generally on board with this. I'm still a bit uneasy about having install --json stream the progress by default. Could that be put behind a flag since it's not intended for CLI users?

And item (3) above seems like a real bug worth addressing here. Other than that, I'm +1.

zeroshade and others added 23 commits April 28, 2026 14:07
- fslock: stop deleting lock file on Release() to prevent TOCTOU race
  where another process can grab the file between Close() and Remove()
- install: add checksum to already-installed JSON path; emit bare hex
  (no sha256: prefix) for consistency with sync lockfile; surface
  checksum errors in JSON mode instead of silently ignoring them
- add: serialize all drivers in --json mode, not just Driver[0]
- sync: populate Skipped vs Installed correctly; route errors through
  JSON envelope in --json mode; emit skipped phase instead of installed
  for already-present drivers
- auth login --json: suppress plaintext output, add IsJSONMode(), emit
  AuthLoginResponse on success and on error
- completions: add --json flag to init, add, remove in bash and zsh

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- sync --json: set s.status=1 on error so FinalOutput suppresses the
  success sync.status envelope and the process exits non-zero
- auth login --json: delay success envelope until post-login work
  completes (via authLoginCompleteMsg), set status=1 on failure
- install: surface checksum errors on already-installed path instead
  of silently omitting the checksum field

schema_version kept at 1: add.response is new/unreleased so the
driver->drivers rename is not a compatibility break.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- main.go: suppress formatErr plaintext output in JSON mode so structured
  error envelopes are not followed by a plain-text error line
- auth login: emit success envelope only after ignorable license errors
  (ErrTrialExpired/ErrNoTrialLicense) by routing them through
  authLoginCompleteMsg when credentials were already saved; set status=1
  on non-ignorable JSON-mode failures
- install: move already-installed checksum computation into Update() via
  alreadyInstalledChecksumMsg so checksum failures set status=1 and exit
  non-zero; also set status=1 on JSON-mode install errors

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
FinalOutput() was entering the already-installed success path even when
m.status != 0 (e.g. after a checksum failure), producing an error envelope
followed by a success install.status. Guard with an early return when status
is non-zero.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…lure

Adds TestInstallJSON_AlreadyInstalledChecksumFailure which installs a
driver, removes the shared library to force checksum() to fail, then
reinstalls with --json. Asserts status==1 and no install.status success
envelope in the output, locking in the m.status!=0 guard on FinalOutput().

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The previous test used runCmdErr which never calls FinalOutput(), so it
could not catch a future re-introduction of the bug. The updated test
runs the program manually, calls FinalOutput() on the finished model,
and asserts it returns empty when status != 0. Verified to fail when
the m.status != 0 guard is removed.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Change the install JSON error path from tea.Println to
fmt.Fprintln(os.Stdout, ...) to match the sync pattern and make it
capturable in tests. Update the regression test to redirect os.Stdout
via os.Pipe so it can assert that the kind:error envelope is present
and no install.status success envelope appears.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Add jsonWriter io.Writer field to progressiveInstallModel (defaults to
os.Stdout via jsonOut() helper) so JSON error output bypasses neither
the BubbleTea output channel nor hardcodes global stdout. Updated test
injects a bytes.Buffer, asserts kind:error envelope is captured there,
and includes teaOut+finalOutput in the combined no-install.status check.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… test

Move jsonWriter io.Writer from progressiveInstallModel to baseModel so
any caller can inject it without type-asserting the concrete model. Test
now sets it on baseModel directly. Also decode the captured JSON envelope
and assert kind==error and code==install_failed rather than substring match.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… write

Store JSON error envelope in jsonErrorOutput field and return it from
FinalOutput() so it flows through tea.WithOutput like all other output.
Remove jsonWriter from baseModel and jsonOut() helper entirely. Test no
longer needs injection: reads error envelope from FinalOutput() and decodes
the full envelope shape.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
main.go: bypass the --quiet gate for JSON-mode models so structured error
envelopes are always surfaced to machine consumers.

subcommand_test.go: runCmdErr now appends FinalOutput() and calls
prog.Wait(), mirroring main.go ordering; non-JSON errors fall back to
the formatted plaintext message as before.

install_test.go: regression test now uses runCmdErr so JSON error capture
goes through the shared harness rather than manual prog.Run().

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ness

Add IsJSONMode() bool to infoModel, searchModel, uninstallModel,
initModel, addModel, and removeModel so the --quiet bypass in main.go
covers all JSON commands, not just install/sync/auth.

Update runCmdErr to check IsJSONMode() (matching main.go) when deciding
whether to append plaintext formatErr, so JSON-mode commands don't get
both a JSON envelope and a plaintext error line.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
info: guard FinalOutput with status check and handle errors in Update();
prevents panic from calling driverInfoJSON on zero-value Driver.

search: return error envelope when status != 0 instead of empty
search.results payload with a success shape.

add/init/remove: return error envelopes in JSON mode on failure instead
of empty string, so JSON consumers receive structured output on error.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…t, remove

Add assertJSONErrorEnvelope helper to subcommand_test.go and regression tests:
- TestInfo_JSON_DriverNotFound: asserts info_failed envelope on registry failure
- TestSearch_JSON_CompleteRegistryFailure: asserts search_failed envelope
- TestAdd_JSON_DriverNotFound: asserts add_failed envelope
- TestInit_JSON_Failure: asserts init_failed envelope on unwritable directory
- TestRemove_JSON_DriverNotFound: asserts remove_failed envelope

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…lure

The chmod approach fails in the System-level test run which uses sudo
and ignores read-only permissions. Use a pre-existing dbc.toml file to
trigger the init_failed error instead, which works regardless of privileges.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…tests

assertJSONErrorEnvelope now requires every non-empty output line to be a
JSON object, catching any regression that mixes plaintext and structured
output. TestInfo_JSON_DriverNotFound and TestAdd_JSON_DriverNotFound now
use getTestDriverRegistry with a nonexistent driver, exercising the
findDriver path rather than the registry-failure path.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Add TestInfo_JSON_RegistryFailure and TestAdd_JSON_RegistryFailure to cover
the complete-registry-failure path with --json alongside the missing-driver
path covered by existing tests.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Extend assertJSONErrorEnvelope with variadic msgSubstrings to check that
registry error details are preserved in the JSON payload message. Update
TestInfo_JSON_RegistryFailure and TestAdd_JSON_RegistryFailure to assert
the message contains the injected error text.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@zeroshade
Copy link
Copy Markdown
Member Author

Fixed the last issue

@zeroshade zeroshade merged commit ab8a5b1 into main Apr 28, 2026
11 checks passed
@zeroshade zeroshade deleted the cli-json-improvements branch April 28, 2026 18:16
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.

2 participants