Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c7e9d32
feat(jsonschema): add versioned CLI output schema package
zeroshade Apr 22, 2026
59b108e
refactor(install): use jsonschema package for --json output
zeroshade Apr 22, 2026
12207eb
refactor(cli): use jsonschema for uninstall/search/info --json
zeroshade Apr 22, 2026
9d19ff1
feat(cli): add --json output to init, add, remove
zeroshade Apr 22, 2026
348043b
feat(sync): --json with NDJSON progress events
zeroshade Apr 22, 2026
a280e8d
feat(cli): file-lock mutations + auth verification_uri_complete + dea…
zeroshade Apr 22, 2026
debe9d6
feat(install): emit NDJSON progress events when --json set
zeroshade Apr 22, 2026
460bc3d
feat(install): verify sha256 checksum alongside signature
zeroshade Apr 22, 2026
3390e09
fix: address roborev review findings across json output and fslock
zeroshade Apr 24, 2026
4963faa
fix: address job-437 review findings
zeroshade Apr 25, 2026
7accc20
fix: address job-438 review findings
zeroshade Apr 25, 2026
3d44db2
fix: gate install FinalOutput on status to prevent contradictory NDJSON
zeroshade Apr 25, 2026
f2ff017
test(install): add regression test for already-installed checksum fai…
zeroshade Apr 25, 2026
6a4255c
test(install): fix regression test to exercise FinalOutput() directly
zeroshade Apr 25, 2026
09f83a2
fix(install): emit error envelope via Fprintln + assert it in test
zeroshade Apr 25, 2026
cecd7f2
fix(install): injectable jsonWriter + assert error envelope in test
zeroshade Apr 25, 2026
52319b1
fix(install): move jsonWriter to baseModel + decode error envelope in…
zeroshade Apr 25, 2026
3a3a245
fix(install): route JSON errors through FinalOutput instead of direct…
zeroshade Apr 25, 2026
4fa6d72
fix: emit JSON FinalOutput even with --quiet; update runCmdErr harness
zeroshade Apr 25, 2026
c6b8fc7
fix: add IsJSONMode() to all JSON-producing models; fix runCmdErr har…
zeroshade Apr 25, 2026
80d78cf
fix: emit JSON error envelopes on failure for all JSON-mode commands
zeroshade Apr 25, 2026
476743a
test: add JSON error-path regression tests for info, search, add, ini…
zeroshade Apr 25, 2026
a678f13
fix(test): use pre-existing file instead of chmod to trigger init fai…
zeroshade Apr 25, 2026
ab472bf
test: stricter JSON error assertion; use real registry for not-found …
zeroshade Apr 25, 2026
3e610a2
test: add JSON registry-failure tests for info and add commands
zeroshade Apr 25, 2026
c61d416
test: assert error message content in JSON registry-failure tests
zeroshade Apr 25, 2026
248c151
update text description from review comment
zeroshade Apr 27, 2026
1f1cc72
updates from comments
zeroshade Apr 28, 2026
a14621f
make test better
zeroshade Apr 28, 2026
c80660e
only output install.progress for streaming case
zeroshade Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 98 additions & 18 deletions cmd/dbc/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,42 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Masterminds/semver/v3"
"github.com/columnar-tech/dbc/config"
"github.com/columnar-tech/dbc/internal/fslock"
"github.com/columnar-tech/dbc/internal/jsonschema"
"github.com/pelletier/go-toml/v2"
)

var msgStyle = lipgloss.NewStyle().Faint(true)

func marshalEnvelope(kind string, payload any) string {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false) // don't escape <, >, & in JSON output
enc.Encode(payload)

env := jsonschema.Envelope{
SchemaVersion: jsonschema.SchemaVersion,
Kind: kind,
Payload: json.RawMessage(b.Bytes()),
}

b.Reset()
enc.Encode(env)
return b.String()
}

func driverListPath(path string) (string, error) {
p, err := filepath.Abs(path)
if err != nil {
Expand All @@ -44,34 +67,44 @@ type AddCmd struct {
Driver []string `arg:"positional,required" help:"One or more drivers to add, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"`
Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to add to"`
Pre bool `arg:"--pre" help:"Allow pre-release versions implicitly"`
Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"`
}

func (c AddCmd) GetModelCustom(baseModel baseModel) tea.Model {
return addModel{
baseModel: baseModel,
Driver: c.Driver,
Path: c.Path,
Pre: c.Pre,
baseModel: baseModel,
Driver: c.Driver,
Path: c.Path,
Pre: c.Pre,
jsonOutput: c.Json,
}
}

func (c AddCmd) GetModel() tea.Model {
return addModel{
Driver: c.Driver,
Path: c.Path,
Pre: c.Pre,
baseModel: defaultBaseModel(),
Driver: c.Driver,
Path: c.Path,
Pre: c.Pre,
jsonOutput: c.Json,
baseModel: defaultBaseModel(),
}
}

type addDoneMsg struct {
result string
resolvedPath string
}

type addModel struct {
baseModel

Driver []string
Path string
Pre bool
list DriversList
result string
Driver []string
Path string
Pre bool
jsonOutput bool
list DriversList
result string
resolvedPath string
}

func (m addModel) Init() tea.Cmd {
Expand Down Expand Up @@ -105,10 +138,27 @@ func (m addModel) Init() tea.Cmd {
return err
}

m.list, err = openAndDecodeDriverList(m.Path)
lockPath := filepath.Join(filepath.Dir(p), ".dbc.project.lock")
lock, err := fslock.Acquire(lockPath, 10*time.Second)
if err != nil {
return fmt.Errorf("another dbc operation is in progress: %w", err)
}
defer lock.Release()

f, err := os.Open(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("error opening driver list: %s doesn't exist\nDid you run `dbc init`?", m.Path)
} else {
return fmt.Errorf("error opening driver list at %s: %w", m.Path, err)
}
}
defer f.Close()

if err := toml.NewDecoder(f).Decode(&m.list); err != nil {
return err
}

if m.list.Drivers == nil {
m.list.Drivers = make(map[string]driverSpec)
}
Expand Down Expand Up @@ -177,22 +227,26 @@ func (m addModel) Init() tea.Cmd {
}
}

f, err := os.Create(p)
wf, err := os.Create(p)
if err != nil {
return fmt.Errorf("error creating file %s: %w", p, err)
}
defer f.Close()
defer wf.Close()

if err := toml.NewEncoder(f).Encode(m.list); err != nil {
if err := toml.NewEncoder(wf).Encode(m.list); err != nil {
return err
}
result += "\nuse `dbc sync` to install the drivers in the list"
return result
return addDoneMsg{result: result, resolvedPath: p}
}
}

func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case addDoneMsg:
m.result = msg.result
m.resolvedPath = msg.resolvedPath
return m, tea.Quit
case string:
m.result = msg
return m, tea.Quit
Expand All @@ -204,10 +258,36 @@ func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}

func (m addModel) IsJSONMode() bool { return m.jsonOutput }

func (m addModel) FinalOutput() string {
if m.status != 0 {
if m.jsonOutput {
return marshalEnvelope("error", jsonschema.ErrorResponse{
Code: "add_failed",
Message: m.err.Error(),
})
}
return ""
}
if m.jsonOutput {
drivers := make([]jsonschema.AddResponseDriver, 0, len(m.Driver))
for _, d := range m.Driver {
driverName, constraint, _ := parseDriverConstraint(d)
var constraintStr string
if constraint != nil {
constraintStr = constraint.String()
}
drivers = append(drivers, jsonschema.AddResponseDriver{
Name: driverName,
VersionConstraint: constraintStr,
})
}
return marshalEnvelope("add.response", jsonschema.AddResponse{
DriverListPath: m.resolvedPath,
Drivers: drivers,
})
}
return m.result
}

Expand Down
89 changes: 89 additions & 0 deletions cmd/dbc/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -25,6 +26,7 @@ import (

tea "charm.land/bubbletea/v2"
"github.com/columnar-tech/dbc"
"github.com/columnar-tech/dbc/internal/jsonschema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -427,6 +429,60 @@ func (suite *SubcommandTestSuite) TestAddOutput() {
suite.Contains(out, "use `dbc sync` to install the drivers in the list")
}

func (suite *SubcommandTestSuite) TestAdd_JSON() {
m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel()
suite.runCmd(m)

m = AddCmd{
Path: filepath.Join(suite.tempdir, "dbc.toml"),
Driver: []string{"test-driver-1"},
Json: true,
}.GetModelCustom(
baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg})

out := suite.runCmd(m)

var env jsonschema.Envelope
suite.Require().NoError(json.Unmarshal([]byte(out), &env), "output must be valid JSON: %s", out)
suite.Equal(1, env.SchemaVersion)
suite.Equal("add.response", env.Kind)

var resp jsonschema.AddResponse
suite.Require().NoError(json.Unmarshal(env.Payload, &resp))
suite.Require().Len(resp.Drivers, 1)
suite.Equal("test-driver-1", resp.Drivers[0].Name)
suite.NotEmpty(resp.DriverListPath)
}

func (suite *SubcommandTestSuite) TestAdd_JSON_Constraint() {
m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel()
suite.runCmd(m)

m = AddCmd{
Path: filepath.Join(suite.tempdir, "dbc.toml"),
Driver: []string{"test-driver-1>=1.0.0"},
Json: true,
}.GetModelCustom(
baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg})

out := suite.runCmd(m)

var env jsonschema.Envelope
suite.Require().NoError(json.Unmarshal([]byte(out), &env), "output must be valid JSON: %s", out)
suite.Equal(1, env.SchemaVersion)
suite.Equal("add.response", env.Kind)

// verify > is not HTML-escaped (>) in the JSON output
suite.NotContains(string(env.Payload), "\\u003e")

var resp jsonschema.AddResponse
suite.Require().NoError(json.Unmarshal(env.Payload, &resp))
suite.Require().Len(resp.Drivers, 1)
suite.Equal("test-driver-1", resp.Drivers[0].Name)
suite.Equal(">=1.0.0", resp.Drivers[0].VersionConstraint)
suite.NotEmpty(resp.DriverListPath)
}

func (suite *SubcommandTestSuite) TestAddMultipleOutput() {
m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel()
suite.runCmd(m)
Expand Down Expand Up @@ -468,3 +524,36 @@ func (suite *SubcommandTestSuite) TestAddReplacingDriverOutput() {
suite.Contains(out, "added test-driver-1 to driver list")
suite.Contains(out, "with constraint >=1.0.0")
}

func (suite *SubcommandTestSuite) TestAdd_JSON_DriverNotFound() {
m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel()
suite.runCmd(m)

// Use the real test registry but request a driver that doesn't exist,
// exercising the findDriver path rather than the registry-failure path.
m = AddCmd{
Path: filepath.Join(suite.tempdir, "dbc.toml"),
Driver: []string{"nonexistent-driver"},
Json: true,
}.GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg})
out := suite.runCmdErr(m)
suite.assertJSONErrorEnvelope(out, "add_failed")
}

func (suite *SubcommandTestSuite) TestAdd_JSON_RegistryFailure() {
m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel()
suite.runCmd(m)

// Verify that a complete registry failure also emits a structured error envelope
// and that the underlying registry error detail is preserved in the message.
failingRegistry := func() ([]dbc.Driver, error) {
return nil, fmt.Errorf("network unreachable")
}
m = AddCmd{
Path: filepath.Join(suite.tempdir, "dbc.toml"),
Driver: []string{"test-driver-1"},
Json: true,
}.GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg})
out := suite.runCmdErr(m)
suite.assertJSONErrorEnvelope(out, "add_failed", "network unreachable")
}
Loading
Loading