From c7e9d32fff90a9be78664117e700bac2946dccd0 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 14:14:57 -0400 Subject: [PATCH 01/30] feat(jsonschema): add versioned CLI output schema package --- internal/jsonschema/schema.go | 323 ++++++++++++++ internal/jsonschema/schema_test.go | 656 +++++++++++++++++++++++++++++ 2 files changed, 979 insertions(+) create mode 100644 internal/jsonschema/schema.go create mode 100644 internal/jsonschema/schema_test.go diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go new file mode 100644 index 00000000..0ca094af --- /dev/null +++ b/internal/jsonschema/schema.go @@ -0,0 +1,323 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package jsonschema defines the versioned JSON output schema for the dbc CLI. +// It is the single source of truth for every --json payload emitted by the CLI +// and consumed by the Rust GUI. Field names MUST NOT be renamed without updating +// both consumers. +package jsonschema + +import "encoding/json" + +// SchemaVersion is the current JSON schema version. Increment when making +// backward-incompatible changes to any payload type. +const SchemaVersion = 1 + +// Envelope is the top-level wrapper for every structured JSON response emitted +// by the CLI. The payload field contains a type-specific JSON object identified +// by the kind field. +type Envelope struct { + // SchemaVersion identifies the schema revision for forward-compatibility checks. + SchemaVersion int `json:"schema_version"` + // Kind names the payload type (e.g. "install.status", "search.response"). + Kind string `json:"kind"` + // Payload is the raw JSON-encoded type-specific data. + Payload json.RawMessage `json:"payload"` +} + +// ----------------------------------------------------------------------------- +// Install +// ----------------------------------------------------------------------------- + +// InstallStatus is the final JSON payload emitted after a driver installation +// attempt. It corresponds to the inline struct in cmd/dbc/install.go. +type InstallStatus struct { + // Status is the outcome: "installed", "already installed", or "error". + Status string `json:"status"` + // Driver is the driver identifier (e.g. "snowflake"). + Driver string `json:"driver"` + // Version is the installed driver version string. + Version string `json:"version"` + // Location is the filesystem path where the driver was installed. + Location string `json:"location"` + // Message is an optional post-install notice provided by the driver manifest. + Message string `json:"message,omitempty"` + // Conflict describes any pre-existing driver that was replaced, if applicable. + Conflict string `json:"conflict,omitempty"` + // Checksum is the hex-encoded checksum of the installed artifact (added for T7). + Checksum string `json:"checksum,omitempty"` +} + +// InstallProgressEvent is a single line in the NDJSON progress stream emitted +// during a driver installation. Clients should consume a stream of these events +// until install.complete is received. +type InstallProgressEvent struct { + // Event identifies the progress step. Valid values: + // "download.start", "download.progress", "download.complete", + // "extract.start", "extract.complete", + // "verify.start", "verify.complete", "verify.checksum.ok", "verify.checksum.mismatch", + // "manifest.create", "install.complete". + Event string `json:"event"` + // Driver is the driver identifier being installed. + Driver string `json:"driver"` + // Bytes is the number of bytes transferred so far (download events only). + Bytes int64 `json:"bytes,omitempty"` + // Total is the expected total byte count (download events only). + Total int64 `json:"total,omitempty"` + // Checksum is the computed or expected checksum value (verify events only). + Checksum string `json:"checksum,omitempty"` +} + +// ----------------------------------------------------------------------------- +// Uninstall +// ----------------------------------------------------------------------------- + +// UninstallStatus is the JSON payload emitted after a driver uninstallation. +// It corresponds to the inline format in cmd/dbc/uninstall.go. +type UninstallStatus struct { + // Status is the outcome: "success" or "error". + Status string `json:"status"` + // Driver is the driver identifier that was uninstalled. + Driver string `json:"driver"` +} + +// ----------------------------------------------------------------------------- +// Search +// ----------------------------------------------------------------------------- + +// SearchDriverBasic is a single driver entry in a non-verbose search response. +// It corresponds to the basic output struct in cmd/dbc/search.go. +type SearchDriverBasic struct { + // Driver is the driver identifier path. + Driver string `json:"driver"` + // Description is a human-readable summary of the driver. + Description string `json:"description"` + // Installed lists the installed versions of this driver, if any. + Installed []string `json:"installed,omitempty"` + // Registry is the source registry name, if not the default. + Registry string `json:"registry,omitempty"` +} + +// SearchDriverVerbose is a single driver entry in a verbose search response. +// It corresponds to the verbose output struct in cmd/dbc/search.go. +type SearchDriverVerbose struct { + // Driver is the driver identifier path. + Driver string `json:"driver"` + // Description is a human-readable summary of the driver. + Description string `json:"description"` + // License is the SPDX license identifier for the driver. + License string `json:"license"` + // Registry is the source registry name, if not the default. + Registry string `json:"registry,omitempty"` + // InstalledVersions maps platform tuple to a list of installed versions. + InstalledVersions map[string][]string `json:"installed_versions,omitempty"` + // AvailableVersions lists all versions available for the current platform. + AvailableVersions []string `json:"available_versions,omitempty"` +} + +// SearchResponse is the top-level JSON payload for the search command. The +// drivers field is raw JSON to accommodate both SearchDriverBasic and +// SearchDriverVerbose slices without a common interface. +type SearchResponse struct { + // Drivers is the raw JSON array of driver entries (basic or verbose). + Drivers json.RawMessage `json:"drivers"` + // Warning is an optional message when some registries were unavailable. + Warning string `json:"warning,omitempty"` +} + +// ----------------------------------------------------------------------------- +// Info +// ----------------------------------------------------------------------------- + +// DriverInfo is the JSON payload for the info command. +// It corresponds to the inline struct in cmd/dbc/info.go. +type DriverInfo struct { + // Driver is the driver identifier path. + Driver string `json:"driver"` + // Version is the latest version string. + Version string `json:"version"` + // Title is the human-readable display name of the driver. + Title string `json:"title"` + // License is the SPDX license identifier. + License string `json:"license"` + // Description is a detailed description of the driver. + Description string `json:"description"` + // Packages lists the supported platform tuples. + Packages []string `json:"packages"` +} + +// ----------------------------------------------------------------------------- +// Init / Add / Remove (driver list management) +// ----------------------------------------------------------------------------- + +// InitResponse is the JSON payload emitted when a driver list file is initialised. +type InitResponse struct { + // DriverListPath is the filesystem path of the created or existing driver list. + DriverListPath string `json:"driver_list_path"` + // Created is true when the file was newly created, false if it already existed. + Created bool `json:"created"` +} + +// AddResponseDriver carries the driver entry that was appended to the driver list. +type AddResponseDriver struct { + // Name is the driver identifier. + Name string `json:"name"` + // VersionConstraint is the optional semver constraint that was recorded. + VersionConstraint string `json:"version_constraint,omitempty"` +} + +// AddResponse is the JSON payload emitted after adding a driver to a driver list. +type AddResponse struct { + // DriverListPath is the filesystem path of the driver list that was modified. + DriverListPath string `json:"driver_list_path"` + // Driver is the entry that was added. + Driver AddResponseDriver `json:"driver"` +} + +// RemoveResponseDriver carries the driver entry that was removed from the list. +type RemoveResponseDriver struct { + // Name is the driver identifier. + Name string `json:"name"` +} + +// RemoveResponse is the JSON payload emitted after removing a driver from a list. +type RemoveResponse struct { + // DriverListPath is the filesystem path of the driver list that was modified. + DriverListPath string `json:"driver_list_path"` + // Driver is the entry that was removed. + Driver RemoveResponseDriver `json:"driver"` +} + +// ----------------------------------------------------------------------------- +// Sync +// ----------------------------------------------------------------------------- + +// SyncProgressEvent is a single NDJSON line in the sync progress stream. +type SyncProgressEvent struct { + // Phase is the current sync step: "resolving", "downloading", "verifying", or "installed". + Phase string `json:"phase"` + // Driver is the driver identifier being synced. + Driver string `json:"driver"` + // Bytes is the number of bytes transferred so far (downloading phase only). + Bytes int64 `json:"bytes,omitempty"` + // Total is the expected total byte count (downloading phase only). + Total int64 `json:"total,omitempty"` + // Version is the resolved version string (available after resolving phase). + Version string `json:"version,omitempty"` +} + +// SyncedDriver records a driver that was successfully installed or skipped during sync. +type SyncedDriver struct { + // Name is the driver identifier. + Name string `json:"name"` + // Version is the version that was installed or already present. + Version string `json:"version"` +} + +// SyncError records a driver that failed to install during sync. +type SyncError struct { + // Name is the driver identifier. + Name string `json:"name"` + // Error is a human-readable description of the failure. + Error string `json:"error"` +} + +// SyncStatus is the final JSON payload emitted after a sync operation completes. +type SyncStatus struct { + // Installed lists drivers that were newly installed. + Installed []SyncedDriver `json:"installed"` + // Skipped lists drivers that were already present and required no action. + Skipped []SyncedDriver `json:"skipped"` + // Errors lists drivers that failed to install. + Errors []SyncError `json:"errors"` +} + +// ----------------------------------------------------------------------------- +// Auth +// ----------------------------------------------------------------------------- + +// AuthDeviceCodeEvent is the JSON payload emitted during OAuth device-code flow. +// The CLI emits this once so the user can open a browser to complete auth. +type AuthDeviceCodeEvent struct { + // VerificationURI is the URL the user should visit to authorise the device. + VerificationURI string `json:"verification_uri"` + // VerificationURIComplete is the URL with the user code pre-filled. + VerificationURIComplete string `json:"verification_uri_complete"` + // UserCode is the short code the user must enter at the verification URI. + UserCode string `json:"user_code"` + // ExpiresIn is the number of seconds until the code expires. + ExpiresIn int `json:"expires_in"` + // Interval is the minimum polling interval in seconds. + Interval int `json:"interval"` +} + +// AuthLoginResponse is the JSON payload emitted after an auth login attempt. +type AuthLoginResponse struct { + // Status is the outcome: "success" or "failed". + Status string `json:"status"` + // Registry is the registry URL that was authenticated against. + Registry string `json:"registry"` + // Message is an optional detail string (e.g. failure reason). + Message string `json:"message,omitempty"` +} + +// AuthLogoutResponse is the JSON payload emitted after an auth logout. +type AuthLogoutResponse struct { + // Status is the outcome: "success" or "error". + Status string `json:"status"` + // Registry is the registry URL from which credentials were removed. + Registry string `json:"registry"` +} + +// AuthLicenseInstallResponse is the JSON payload emitted after installing a license file. +type AuthLicenseInstallResponse struct { + // Status is the outcome: "success" or "error". + Status string `json:"status"` + // LicensePath is the filesystem path where the license was written. + LicensePath string `json:"license_path"` +} + +// AuthRegistryStatus describes the authentication state for a single registry. +type AuthRegistryStatus struct { + // URL is the registry URL. + URL string `json:"url"` + // Authenticated indicates whether valid credentials exist for this registry. + Authenticated bool `json:"authenticated"` + // AuthType is the credential type in use: "oauth" or "api_key". Empty when not authenticated. + AuthType string `json:"auth_type,omitempty"` + // LicenseValid indicates whether a valid Columnar license is present. + LicenseValid bool `json:"license_valid"` +} + +// AuthStatus is the JSON payload for the auth status command. It summarises +// the authentication state across all known registries. +type AuthStatus struct { + // Registries lists the status for each known registry. + Registries []AuthRegistryStatus `json:"registries"` +} + +// ----------------------------------------------------------------------------- +// Error +// ----------------------------------------------------------------------------- + +// ErrorResponse is the JSON payload emitted when a command fails with an error. +// It is used in place of all other payloads when Kind == "error". +type ErrorResponse struct { + // Code is a machine-readable error identifier (e.g. "not_found", "permission_denied"). + Code string `json:"code"` + // Message is a human-readable error description. + Message string `json:"message"` + // OwnerPID is the PID of the process holding a lock, when applicable. + OwnerPID int `json:"owner_pid,omitempty"` +} diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go new file mode 100644 index 00000000..d13a69bf --- /dev/null +++ b/internal/jsonschema/schema_test.go @@ -0,0 +1,656 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jsonschema_test + +import ( + "encoding/json" + "testing" + + "github.com/columnar-tech/dbc/internal/jsonschema" +) + +func TestSchemaVersion(t *testing.T) { + if jsonschema.SchemaVersion != 1 { + t.Fatalf("expected SchemaVersion == 1, got %d", jsonschema.SchemaVersion) + } +} + +func roundTrip[T any](t *testing.T, v T) T { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var out T + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + return out +} + +func TestEnvelope(t *testing.T) { + payload, _ := json.Marshal(map[string]string{"key": "value"}) + v := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "test.event", + Payload: json.RawMessage(payload), + } + got := roundTrip(t, v) + if got.SchemaVersion != v.SchemaVersion { + t.Errorf("SchemaVersion: want %d got %d", v.SchemaVersion, got.SchemaVersion) + } + if got.Kind != v.Kind { + t.Errorf("Kind: want %q got %q", v.Kind, got.Kind) + } + if string(got.Payload) != string(v.Payload) { + t.Errorf("Payload: want %s got %s", v.Payload, got.Payload) + } +} + +func TestEnvelope_JSONFieldNames(t *testing.T) { + v := jsonschema.Envelope{SchemaVersion: 1, Kind: "k", Payload: json.RawMessage(`{}`)} + b, _ := json.Marshal(v) + var m map[string]json.RawMessage + _ = json.Unmarshal(b, &m) + for _, key := range []string{"schema_version", "kind", "payload"} { + if _, ok := m[key]; !ok { + t.Errorf("missing JSON key %q", key) + } + } +} + +func TestInstallStatus(t *testing.T) { + tests := []struct { + name string + in jsonschema.InstallStatus + }{ + { + name: "full", + in: jsonschema.InstallStatus{ + Status: "installed", + Driver: "snowflake", + Version: "1.2.3", + Location: "/usr/local/lib", + Message: "post-install note", + Conflict: "snowflake (version: 1.0.0)", + Checksum: "abc123", + }, + }, + { + name: "omitempty fields absent", + in: jsonschema.InstallStatus{ + Status: "installed", + Driver: "sqlite", + Version: "0.1.0", + Location: "/home/user/.local", + }, + }, + { + name: "already installed", + in: jsonschema.InstallStatus{ + Status: "already installed", + Driver: "duckdb", + Version: "2.0.0", + Location: "/opt/drivers", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestInstallStatus_OmitemptyAbsent(t *testing.T) { + v := jsonschema.InstallStatus{Status: "installed", Driver: "d", Version: "1", Location: "/x"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"message", "conflict", "checksum"} { + if _, ok := m[key]; ok { + t.Errorf("omitempty field %q should be absent", key) + } + } +} + +func TestInstallProgressEvent(t *testing.T) { + tests := []struct { + name string + in jsonschema.InstallProgressEvent + }{ + { + name: "download progress", + in: jsonschema.InstallProgressEvent{ + Event: "download.progress", + Driver: "snowflake", + Bytes: 512, + Total: 1024, + }, + }, + { + name: "verify checksum ok", + in: jsonschema.InstallProgressEvent{ + Event: "verify.checksum.ok", + Driver: "duckdb", + Checksum: "deadbeef", + }, + }, + { + name: "install complete", + in: jsonschema.InstallProgressEvent{ + Event: "install.complete", + Driver: "sqlite", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestInstallProgressEvent_OmitemptyAbsent(t *testing.T) { + v := jsonschema.InstallProgressEvent{Event: "install.complete", Driver: "d"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"bytes", "total", "checksum"} { + if _, ok := m[key]; ok { + t.Errorf("omitempty field %q should be absent", key) + } + } +} + +func TestUninstallStatus(t *testing.T) { + tests := []struct { + name string + in jsonschema.UninstallStatus + }{ + {name: "success", in: jsonschema.UninstallStatus{Status: "success", Driver: "snowflake"}}, + {name: "error", in: jsonschema.UninstallStatus{Status: "error", Driver: "duckdb"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestSearchDriverBasic(t *testing.T) { + v := jsonschema.SearchDriverBasic{ + Driver: "snowflake", + Description: "Snowflake ADBC driver", + Installed: []string{"1.0.0", "1.1.0"}, + Registry: "private", + } + got := roundTrip(t, v) + if got.Driver != v.Driver || got.Description != v.Description || got.Registry != v.Registry { + t.Errorf("field mismatch: %+v", got) + } + if len(got.Installed) != len(v.Installed) { + t.Errorf("Installed len: want %d got %d", len(v.Installed), len(got.Installed)) + } +} + +func TestSearchDriverBasic_OmitemptyAbsent(t *testing.T) { + v := jsonschema.SearchDriverBasic{Driver: "d", Description: "desc"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"installed", "registry"} { + if _, ok := m[key]; ok { + t.Errorf("omitempty field %q should be absent", key) + } + } +} + +func TestSearchDriverVerbose(t *testing.T) { + v := jsonschema.SearchDriverVerbose{ + Driver: "duckdb", + Description: "DuckDB ADBC driver", + License: "MIT", + Registry: "public", + InstalledVersions: map[string][]string{ + "linux-amd64": {"1.0.0"}, + }, + AvailableVersions: []string{"1.0.0", "1.1.0"}, + } + b, _ := json.Marshal(v) + var got jsonschema.SearchDriverVerbose + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Driver != v.Driver || got.License != v.License { + t.Errorf("field mismatch: %+v", got) + } + if len(got.InstalledVersions["linux-amd64"]) != 1 { + t.Errorf("InstalledVersions mismatch") + } + if len(got.AvailableVersions) != 2 { + t.Errorf("AvailableVersions len: want 2 got %d", len(got.AvailableVersions)) + } +} + +func TestSearchResponse(t *testing.T) { + drivers, _ := json.Marshal([]jsonschema.SearchDriverBasic{ + {Driver: "d", Description: "desc"}, + }) + v := jsonschema.SearchResponse{ + Drivers: json.RawMessage(drivers), + Warning: "some registry unavailable", + } + got := roundTrip(t, v) + if string(got.Drivers) != string(v.Drivers) { + t.Errorf("Drivers mismatch") + } + if got.Warning != v.Warning { + t.Errorf("Warning: want %q got %q", v.Warning, got.Warning) + } +} + +func TestSearchResponse_OmitemptyAbsent(t *testing.T) { + drivers, _ := json.Marshal([]jsonschema.SearchDriverBasic{}) + v := jsonschema.SearchResponse{Drivers: json.RawMessage(drivers)} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if _, ok := m["warning"]; ok { + t.Errorf("omitempty field 'warning' should be absent") + } +} + +func TestDriverInfo(t *testing.T) { + v := jsonschema.DriverInfo{ + Driver: "snowflake", + Version: "1.2.3", + Title: "Snowflake Driver", + License: "Apache-2.0", + Description: "Connects to Snowflake via ADBC", + Packages: []string{"linux-amd64", "darwin-arm64"}, + } + b, _ := json.Marshal(v) + var got jsonschema.DriverInfo + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Driver != v.Driver || got.Version != v.Version || got.Title != v.Title || + got.License != v.License || got.Description != v.Description { + t.Errorf("field mismatch: %+v", got) + } + if len(got.Packages) != 2 { + t.Errorf("Packages len: want 2 got %d", len(got.Packages)) + } +} + +func TestDriverInfo_JSONFieldNames(t *testing.T) { + v := jsonschema.DriverInfo{Driver: "d", Version: "1", Title: "t", License: "MIT", Description: "desc", Packages: []string{}} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"driver", "version", "title", "license", "description", "packages"} { + if _, ok := m[key]; !ok { + t.Errorf("missing JSON key %q", key) + } + } +} + +func TestInitResponse(t *testing.T) { + tests := []struct { + name string + in jsonschema.InitResponse + }{ + {name: "created", in: jsonschema.InitResponse{DriverListPath: "/path/to/dbc.toml", Created: true}}, + {name: "existed", in: jsonschema.InitResponse{DriverListPath: "/path/to/dbc.toml", Created: false}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestAddResponse(t *testing.T) { + v := jsonschema.AddResponse{ + DriverListPath: "/proj/dbc.toml", + Driver: jsonschema.AddResponseDriver{ + Name: "snowflake", + VersionConstraint: ">=1.0.0", + }, + } + got := roundTrip(t, v) + if got.DriverListPath != v.DriverListPath { + t.Errorf("DriverListPath mismatch") + } + if got.Driver.Name != v.Driver.Name || got.Driver.VersionConstraint != v.Driver.VersionConstraint { + t.Errorf("Driver mismatch: %+v", got.Driver) + } +} + +func TestAddResponse_OmitemptyAbsent(t *testing.T) { + v := jsonschema.AddResponseDriver{Name: "d"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if _, ok := m["version_constraint"]; ok { + t.Errorf("omitempty field 'version_constraint' should be absent") + } +} + +func TestRemoveResponse(t *testing.T) { + v := jsonschema.RemoveResponse{ + DriverListPath: "/proj/dbc.toml", + Driver: jsonschema.RemoveResponseDriver{Name: "snowflake"}, + } + got := roundTrip(t, v) + if got.DriverListPath != v.DriverListPath || got.Driver.Name != v.Driver.Name { + t.Errorf("round-trip mismatch: %+v", got) + } +} + +func TestSyncProgressEvent(t *testing.T) { + tests := []struct { + name string + in jsonschema.SyncProgressEvent + }{ + { + name: "downloading", + in: jsonschema.SyncProgressEvent{Phase: "downloading", Driver: "duckdb", Bytes: 100, Total: 200}, + }, + { + name: "installed", + in: jsonschema.SyncProgressEvent{Phase: "installed", Driver: "duckdb", Version: "1.2.3"}, + }, + { + name: "resolving no optional", + in: jsonschema.SyncProgressEvent{Phase: "resolving", Driver: "sqlite"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestSyncProgressEvent_OmitemptyAbsent(t *testing.T) { + v := jsonschema.SyncProgressEvent{Phase: "resolving", Driver: "d"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"bytes", "total", "version"} { + if _, ok := m[key]; ok { + t.Errorf("omitempty field %q should be absent", key) + } + } +} + +func TestSyncStatus(t *testing.T) { + v := jsonschema.SyncStatus{ + Installed: []jsonschema.SyncedDriver{{Name: "snowflake", Version: "1.0.0"}}, + Skipped: []jsonschema.SyncedDriver{{Name: "duckdb", Version: "2.0.0"}}, + Errors: []jsonschema.SyncError{{Name: "sqlite", Error: "not found"}}, + } + b, _ := json.Marshal(v) + var got jsonschema.SyncStatus + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Installed) != 1 || got.Installed[0] != v.Installed[0] { + t.Errorf("Installed mismatch") + } + if len(got.Skipped) != 1 || got.Skipped[0] != v.Skipped[0] { + t.Errorf("Skipped mismatch") + } + if len(got.Errors) != 1 || got.Errors[0] != v.Errors[0] { + t.Errorf("Errors mismatch") + } +} + +func TestSyncStatus_EmptySlices(t *testing.T) { + v := jsonschema.SyncStatus{ + Installed: []jsonschema.SyncedDriver{}, + Skipped: []jsonschema.SyncedDriver{}, + Errors: []jsonschema.SyncError{}, + } + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"installed", "skipped", "errors"} { + if _, ok := m[key]; !ok { + t.Errorf("field %q should be present (not omitempty)", key) + } + } +} + +func TestAuthDeviceCodeEvent(t *testing.T) { + v := jsonschema.AuthDeviceCodeEvent{ + VerificationURI: "https://auth.example.com/activate", + VerificationURIComplete: "https://auth.example.com/activate?user_code=ABCD-1234", + UserCode: "ABCD-1234", + ExpiresIn: 300, + Interval: 5, + } + got := roundTrip(t, v) + if got != v { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", v, got) + } +} + +func TestAuthDeviceCodeEvent_JSONFieldNames(t *testing.T) { + v := jsonschema.AuthDeviceCodeEvent{ + VerificationURI: "u", + VerificationURIComplete: "uc", + UserCode: "CODE", + ExpiresIn: 60, + Interval: 5, + } + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"verification_uri", "verification_uri_complete", "user_code", "expires_in", "interval"} { + if _, ok := m[key]; !ok { + t.Errorf("missing JSON key %q", key) + } + } +} + +func TestAuthLoginResponse(t *testing.T) { + tests := []struct { + name string + in jsonschema.AuthLoginResponse + }{ + {name: "success", in: jsonschema.AuthLoginResponse{Status: "success", Registry: "https://reg.example.com"}}, + {name: "failed", in: jsonschema.AuthLoginResponse{Status: "failed", Registry: "https://reg.example.com", Message: "invalid token"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestAuthLoginResponse_OmitemptyAbsent(t *testing.T) { + v := jsonschema.AuthLoginResponse{Status: "success", Registry: "https://r.example.com"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if _, ok := m["message"]; ok { + t.Errorf("omitempty field 'message' should be absent") + } +} + +func TestAuthLogoutResponse(t *testing.T) { + v := jsonschema.AuthLogoutResponse{Status: "success", Registry: "https://reg.example.com"} + got := roundTrip(t, v) + if got != v { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", v, got) + } +} + +func TestAuthLicenseInstallResponse(t *testing.T) { + v := jsonschema.AuthLicenseInstallResponse{Status: "success", LicensePath: "/home/.config/dbc/columnar.lic"} + got := roundTrip(t, v) + if got != v { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", v, got) + } +} + +func TestAuthLicenseInstallResponse_JSONFieldNames(t *testing.T) { + v := jsonschema.AuthLicenseInstallResponse{Status: "s", LicensePath: "/p"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + for _, key := range []string{"status", "license_path"} { + if _, ok := m[key]; !ok { + t.Errorf("missing JSON key %q", key) + } + } +} + +func TestAuthRegistryStatus(t *testing.T) { + tests := []struct { + name string + in jsonschema.AuthRegistryStatus + }{ + { + name: "oauth authenticated", + in: jsonschema.AuthRegistryStatus{ + URL: "https://reg.example.com", + Authenticated: true, + AuthType: "oauth", + LicenseValid: true, + }, + }, + { + name: "api_key authenticated", + in: jsonschema.AuthRegistryStatus{ + URL: "https://private.example.com", + Authenticated: true, + AuthType: "api_key", + LicenseValid: false, + }, + }, + { + name: "not authenticated", + in: jsonschema.AuthRegistryStatus{ + URL: "https://reg.example.com", + Authenticated: false, + LicenseValid: false, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestAuthRegistryStatus_OmitemptyAbsent(t *testing.T) { + v := jsonschema.AuthRegistryStatus{URL: "https://r.example.com", Authenticated: false, LicenseValid: false} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if _, ok := m["auth_type"]; ok { + t.Errorf("omitempty field 'auth_type' should be absent") + } +} + +func TestAuthStatus(t *testing.T) { + v := jsonschema.AuthStatus{ + Registries: []jsonschema.AuthRegistryStatus{ + {URL: "https://r1.example.com", Authenticated: true, AuthType: "oauth", LicenseValid: true}, + {URL: "https://r2.example.com", Authenticated: false, LicenseValid: false}, + }, + } + b, _ := json.Marshal(v) + var got jsonschema.AuthStatus + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Registries) != 2 { + t.Fatalf("Registries len: want 2 got %d", len(got.Registries)) + } + if got.Registries[0] != v.Registries[0] || got.Registries[1] != v.Registries[1] { + t.Errorf("Registries mismatch") + } +} + +func TestErrorResponse(t *testing.T) { + tests := []struct { + name string + in jsonschema.ErrorResponse + }{ + {name: "simple", in: jsonschema.ErrorResponse{Code: "not_found", Message: "driver not found"}}, + {name: "with pid", in: jsonschema.ErrorResponse{Code: "locked", Message: "file locked", OwnerPID: 12345}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := roundTrip(t, tc.in) + if got != tc.in { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", tc.in, got) + } + }) + } +} + +func TestErrorResponse_OmitemptyAbsent(t *testing.T) { + v := jsonschema.ErrorResponse{Code: "err", Message: "msg"} + b, _ := json.Marshal(v) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + if _, ok := m["owner_pid"]; ok { + t.Errorf("omitempty field 'owner_pid' should be absent") + } +} + +func TestSyncedDriver(t *testing.T) { + v := jsonschema.SyncedDriver{Name: "snowflake", Version: "1.0.0"} + got := roundTrip(t, v) + if got != v { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", v, got) + } +} + +func TestSyncError(t *testing.T) { + v := jsonschema.SyncError{Name: "sqlite", Error: "download failed"} + got := roundTrip(t, v) + if got != v { + t.Errorf("round-trip mismatch:\n want %+v\n got %+v", v, got) + } +} From 59b108ee48cf98d6097519e14c080abfdc0550dd Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 15:11:49 -0400 Subject: [PATCH 02/30] refactor(install): use jsonschema package for --json output --- cmd/dbc/install.go | 61 +++++++++++++++++++++++++---------------- cmd/dbc/install_test.go | 22 +++++++++++++++ 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index d4d61221..0a21dd42 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -30,6 +30,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" ) func manifestToPackageInfo(m config.Manifest) dbc.PkgInfo { @@ -238,8 +239,20 @@ func (m progressiveInstallModel) isAlreadyInstalled() bool { func (m progressiveInstallModel) FinalOutput() string { if m.isAlreadyInstalled() { if m.jsonOutput { - return fmt.Sprintf(`{"status":"already installed","driver":"%s","version":"%s","location":"%s"}`, - m.conflictingInfo.ID, m.conflictingInfo.Version, filepath.SplitList(m.cfg.Location)[0]) + payload := jsonschema.InstallStatus{ + Status: "already installed", + Driver: m.conflictingInfo.ID, + Version: m.conflictingInfo.Version.String(), + Location: filepath.SplitList(m.cfg.Location)[0], + } + payloadBytes, _ := json.Marshal(payload) + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "install.status", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, _ := json.Marshal(env) + return string(jsonOutput) } return fmt.Sprintf("\nDriver %s %s already installed at %s", m.conflictingInfo.ID, m.conflictingInfo.Version, filepath.SplitList(m.cfg.Location)[0]) @@ -247,44 +260,46 @@ func (m progressiveInstallModel) FinalOutput() string { var b strings.Builder if m.state == stDone { - var output struct { - Status string `json:"status"` - Driver string `json:"driver"` - Version string `json:"version"` - Location string `json:"location"` - Message string `json:"message,omitempty"` - Conflict string `json:"conflict,omitempty"` + installStatus := jsonschema.InstallStatus{ + Status: "installed", + Driver: m.Driver, + Version: m.DriverPackage.Version.String(), + Location: filepath.SplitList(m.cfg.Location)[0], } - - output.Status = "installed" - output.Driver = m.Driver - output.Version = m.DriverPackage.Version.String() - output.Location = filepath.SplitList(m.cfg.Location)[0] if m.hasConflict() { - output.Conflict = fmt.Sprintf("%s (version: %s)", m.conflictingInfo.ID, m.conflictingInfo.Version) + installStatus.Conflict = fmt.Sprintf("%s (version: %s)", m.conflictingInfo.ID, m.conflictingInfo.Version) } if m.postInstallMessage != "" { - output.Message = m.postInstallMessage + installStatus.Message = m.postInstallMessage } if m.jsonOutput { - jsonOutput, err := json.Marshal(output) + payloadBytes, err := json.Marshal(installStatus) + if err != nil { + return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) + } + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "install.status", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, err := json.Marshal(env) if err != nil { - return fmt.Sprintf(`{"status":"error","error":"%s"}`, err.Error()) + return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) } return string(jsonOutput) } - if output.Conflict != "" { - fmt.Fprintf(&b, "\nRemoved conflicting driver: %s", output.Conflict) + if installStatus.Conflict != "" { + fmt.Fprintf(&b, "\nRemoved conflicting driver: %s", installStatus.Conflict) } fmt.Fprintf(&b, "\nInstalled %s %s to %s", - output.Driver, output.Version, output.Location) + installStatus.Driver, installStatus.Version, installStatus.Location) - if output.Message != "" { - b.WriteString("\n\n" + postMsgStyle.Render(output.Message)) + if installStatus.Message != "" { + b.WriteString("\n\n" + postMsgStyle.Render(installStatus.Message)) } } return b.String() diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 1db1ee9a..d7787bb5 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -17,6 +17,7 @@ package main import ( "archive/tar" "compress/gzip" + "encoding/json" "fmt" "os" "path/filepath" @@ -24,6 +25,7 @@ import ( "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" ) func (suite *SubcommandTestSuite) TestInstall() { @@ -462,3 +464,23 @@ func (suite *SubcommandTestSuite) TestInstallDriverWithSubdirectories() { // and return an error with this suite.Contains(out, "driver archives shouldn't contain subdirectories") } + +func (suite *SubcommandTestSuite) TestInstallJSON() { + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, 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("install.status", env.Kind) + + var status jsonschema.InstallStatus + suite.Require().NoError(json.Unmarshal(env.Payload, &status)) + + suite.Equal("installed", status.Status) + suite.Equal("test-driver-1", status.Driver) + suite.NotEmpty(status.Version) + suite.NotEmpty(status.Location) +} From 12207ebcfbeb2e4c481219542c586b4bc74b1bbc Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 15:21:10 -0400 Subject: [PATCH 03/30] refactor(cli): use jsonschema for uninstall/search/info --json --- cmd/dbc/info.go | 36 +++++++++++---------- cmd/dbc/info_test.go | 20 ++++++++++++ cmd/dbc/search.go | 67 ++++++++++++++++++++------------------- cmd/dbc/search_test.go | 41 ++++++++++++++++++++++++ cmd/dbc/uninstall.go | 28 ++++++++++++++-- cmd/dbc/uninstall_test.go | 26 +++++++++++++++ 6 files changed, 167 insertions(+), 51 deletions(-) diff --git a/cmd/dbc/info.go b/cmd/dbc/info.go index 3a0aeb0d..25d7aed3 100644 --- a/cmd/dbc/info.go +++ b/cmd/dbc/info.go @@ -21,6 +21,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/internal/jsonschema" ) type InfoCmd struct { @@ -95,30 +96,31 @@ func driverInfoJSON(drv dbc.Driver) string { return "{}" } - var driverInfoOutput = struct { - Driver string `json:"driver"` - Version string `json:"version"` - Title string `json:"title"` - License string `json:"license"` - Desc string `json:"description"` - Packages []string `json:"packages"` - }{ - Driver: drv.Path, - Version: info.Version.String(), - Title: drv.Title, - License: drv.License, - Desc: drv.Desc, + driverInfo := jsonschema.DriverInfo{ + Driver: drv.Path, + Version: info.Version.String(), + Title: drv.Title, + License: drv.License, + Description: drv.Desc, } for _, pkg := range info.Packages { - driverInfoOutput.Packages = append(driverInfoOutput.Packages, pkg.Platform) + driverInfo.Packages = append(driverInfo.Packages, pkg.Platform) } - jsonBytes, err := json.Marshal(driverInfoOutput) + payloadBytes, err := json.Marshal(driverInfo) if err != nil { return err.Error() } - - return string(jsonBytes) + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "driver.info", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, err := json.Marshal(env) + if err != nil { + return err.Error() + } + return string(jsonOutput) } func (m infoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/cmd/dbc/info_test.go b/cmd/dbc/info_test.go index a6388985..d07bc641 100644 --- a/cmd/dbc/info_test.go +++ b/cmd/dbc/info_test.go @@ -15,9 +15,11 @@ package main import ( + "encoding/json" "fmt" "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/internal/jsonschema" ) func (suite *SubcommandTestSuite) TestInfo() { @@ -94,3 +96,21 @@ func (suite *SubcommandTestSuite) TestInfoCompleteRegistryFailure() { out := suite.runCmdErr(m) suite.Contains(out, "network unreachable") } + +func (suite *SubcommandTestSuite) TestInfo_JSON() { + m := InfoCmd{Driver: "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("driver.info", env.Kind) + + var info jsonschema.DriverInfo + suite.Require().NoError(json.Unmarshal(env.Payload, &info)) + suite.Equal("test-driver-1", info.Driver) + suite.Equal("1.1.0", info.Version) + suite.NotEmpty(info.Title) + suite.NotEmpty(info.Packages) +} diff --git a/cmd/dbc/search.go b/cmd/dbc/search.go index dd046e41..dab664d6 100644 --- a/cmd/dbc/search.go +++ b/cmd/dbc/search.go @@ -27,6 +27,7 @@ import ( "charm.land/lipgloss/v2/tree" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" ) var ( @@ -219,21 +220,14 @@ func viewDriversJSON(d []dbc.Driver, verbose bool, allowPre bool, registryErrors current := config.Get() if !verbose { - type output struct { - Driver string `json:"driver"` - Description string `json:"description"` - Installed []string `json:"installed,omitempty"` - Registry string `json:"registry,omitempty"` - } - - var driverList []output + var driverList []jsonschema.SearchDriverBasic for _, driver := range d { installed, _ := getInstalled(driver, current) if !allowPre && !driver.HasNonPrerelease() && len(installed) == 0 { continue } - driverList = append(driverList, output{ + driverList = append(driverList, jsonschema.SearchDriverBasic{ Driver: driver.Path, Description: driver.Desc, Installed: installed, @@ -241,33 +235,33 @@ func viewDriversJSON(d []dbc.Driver, verbose bool, allowPre bool, registryErrors }) } - type result struct { - Drivers []output `json:"drivers"` - Warning string `json:"warning,omitempty"` + type basicResult struct { + Drivers []jsonschema.SearchDriverBasic `json:"drivers"` + Warning string `json:"warning,omitempty"` } - res := result{Drivers: driverList} + res := basicResult{Drivers: driverList} if registryErrors != nil && len(d) > 0 { res.Warning = registryErrors.Error() } - jsonBytes, err := json.Marshal(res) + payloadBytes, err := json.Marshal(res) if err != nil { return fmt.Sprintf("error marshaling JSON: %v", err) } - return string(jsonBytes) - } - - type output struct { - Driver string `json:"driver"` - Description string `json:"description"` - License string `json:"license"` - Registry string `json:"registry,omitempty"` - InstalledVersions map[string][]string `json:"installed_versions,omitempty"` - AvailableVersions []string `json:"available_versions,omitempty"` + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "search.results", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, err := json.Marshal(env) + if err != nil { + return fmt.Sprintf("error marshaling JSON: %v", err) + } + return string(jsonOutput) } - var driverList []output + var driverList []jsonschema.SearchDriverVerbose for _, driver := range d { _, installedVerbose := getInstalled(driver, current) @@ -280,7 +274,7 @@ func viewDriversJSON(d []dbc.Driver, verbose bool, allowPre bool, registryErrors availableVersions = append(availableVersions, v.String()) } - driverList = append(driverList, output{ + driverList = append(driverList, jsonschema.SearchDriverVerbose{ Driver: driver.Path, Description: driver.Desc, License: driver.License, @@ -290,21 +284,30 @@ func viewDriversJSON(d []dbc.Driver, verbose bool, allowPre bool, registryErrors }) } - type result struct { - Drivers []output `json:"drivers"` - Warning string `json:"warning,omitempty"` + type verboseResult struct { + Drivers []jsonschema.SearchDriverVerbose `json:"drivers"` + Warning string `json:"warning,omitempty"` } - res := result{Drivers: driverList} + res := verboseResult{Drivers: driverList} if registryErrors != nil && len(d) > 0 { res.Warning = registryErrors.Error() } - jsonBytes, err := json.Marshal(res) + payloadBytes, err := json.Marshal(res) + if err != nil { + return fmt.Sprintf("error marshaling JSON: %v", err) + } + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "search.results", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, err := json.Marshal(env) if err != nil { return fmt.Sprintf("error marshaling JSON: %v", err) } - return string(jsonBytes) + return string(jsonOutput) } func getInstalled(driver dbc.Driver, cfg map[config.ConfigLevel]config.Config) ([]string, map[string][]string) { diff --git a/cmd/dbc/search_test.go b/cmd/dbc/search_test.go index 2f578e89..c745d32e 100644 --- a/cmd/dbc/search_test.go +++ b/cmd/dbc/search_test.go @@ -15,6 +15,7 @@ package main import ( + "encoding/json" "fmt" "net/url" "os" @@ -23,6 +24,7 @@ import ( "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" ) func (suite *SubcommandTestSuite) TestSearchCmd() { @@ -363,3 +365,42 @@ func (suite *SubcommandTestSuite) TestSearchCmdRegistryTagAlignment() { suite.Require().Len(privateColumns, 2, "expected exactly 2 [private] tags") suite.Equal(privateColumns[0], privateColumns[1], "[private] tags should be at the same column") } + +func (suite *SubcommandTestSuite) TestSearch_JSON() { + m := SearchCmd{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("search.results", env.Kind) + + var result struct { + Drivers []jsonschema.SearchDriverBasic `json:"drivers"` + Warning string `json:"warning,omitempty"` + } + suite.Require().NoError(json.Unmarshal(env.Payload, &result)) + suite.NotEmpty(result.Drivers) + suite.Equal("test-driver-1", result.Drivers[0].Driver) +} + +func (suite *SubcommandTestSuite) TestSearch_JSON_Verbose() { + m := SearchCmd{Json: true, Verbose: 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("search.results", env.Kind) + + var result struct { + Drivers []jsonschema.SearchDriverVerbose `json:"drivers"` + Warning string `json:"warning,omitempty"` + } + suite.Require().NoError(json.Unmarshal(env.Payload, &result)) + suite.NotEmpty(result.Drivers) + suite.Equal("test-driver-1", result.Drivers[0].Driver) + suite.NotEmpty(result.Drivers[0].License) +} diff --git a/cmd/dbc/uninstall.go b/cmd/dbc/uninstall.go index feb61905..459c17ea 100644 --- a/cmd/dbc/uninstall.go +++ b/cmd/dbc/uninstall.go @@ -15,10 +15,12 @@ package main import ( + "encoding/json" "fmt" tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" ) type driverDidUninstallMsg struct{} @@ -65,7 +67,21 @@ func (m uninstallModel) FinalOutput() string { } if m.jsonOutput { - return fmt.Sprintf("{\"status\": \"success\", \"driver\": \"%s\"}\n", m.Driver) + payload := jsonschema.UninstallStatus{Status: "success", Driver: m.Driver} + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) + } + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "uninstall.status", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, err := json.Marshal(env) + if err != nil { + return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) + } + return string(jsonOutput) } return fmt.Sprintf("Driver `%s` uninstalled successfully!", m.Driver) } @@ -81,7 +97,15 @@ func (m uninstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = 1 m.err = msg if m.jsonOutput { - return m, tea.Sequence(tea.Printf("{\"status\": \"error\", \"error\": \"%s\"}\n", msg.Error()), tea.Quit) + payload := jsonschema.UninstallStatus{Status: "error", Driver: m.Driver} + payloadBytes, _ := json.Marshal(payload) + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: "uninstall.status", + Payload: json.RawMessage(payloadBytes), + } + jsonOutput, _ := json.Marshal(env) + return m, tea.Sequence(tea.Println(string(jsonOutput)), tea.Quit) } return m, tea.Quit } diff --git a/cmd/dbc/uninstall_test.go b/cmd/dbc/uninstall_test.go index f401d763..3d6c1f00 100644 --- a/cmd/dbc/uninstall_test.go +++ b/cmd/dbc/uninstall_test.go @@ -15,6 +15,7 @@ package main import ( + "encoding/json" "fmt" "os" "path" @@ -22,6 +23,7 @@ import ( "runtime" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" "github.com/pelletier/go-toml/v2" ) @@ -261,3 +263,27 @@ func (suite *SubcommandTestSuite) TestUninstallRemovesSymlink() { // Verify symlink is gone suite.NoFileExists(manifestPath) } + +func (suite *SubcommandTestSuite) TestUninstall_JSON() { + if runtime.GOOS == "windows" { + suite.T().Skip() + } + + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + suite.runCmd(m) + + m = UninstallCmd{Driver: "test-driver-1", Level: suite.configLevel, 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("uninstall.status", env.Kind) + + var status jsonschema.UninstallStatus + suite.Require().NoError(json.Unmarshal(env.Payload, &status)) + suite.Equal("success", status.Status) + suite.Equal("test-driver-1", status.Driver) +} From 9d19ff1423bb7b6f5a14c433b1b7834a26dda004 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 15:42:21 -0400 Subject: [PATCH 04/30] feat(cli): add --json output to init, add, remove --- cmd/dbc/add.go | 69 +++++++++++++++++++++++++++++++++--------- cmd/dbc/add_test.go | 26 ++++++++++++++++ cmd/dbc/init.go | 28 +++++++++++++++-- cmd/dbc/init_test.go | 18 +++++++++++ cmd/dbc/remove.go | 43 +++++++++++++++++++------- cmd/dbc/remove_test.go | 34 +++++++++++++++++++++ 6 files changed, 190 insertions(+), 28 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index f11392ca..ce29285c 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -15,6 +15,7 @@ package main import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -23,11 +24,23 @@ import ( "charm.land/lipgloss/v2" "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc/config" + "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 { + payloadBytes, _ := json.Marshal(payload) + env := jsonschema.Envelope{ + SchemaVersion: jsonschema.SchemaVersion, + Kind: kind, + Payload: json.RawMessage(payloadBytes), + } + out, _ := json.Marshal(env) + return string(out) +} + func driverListPath(path string) (string, error) { p, err := filepath.Abs(path) if err != nil { @@ -44,34 +57,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:"Output 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 { @@ -187,12 +210,16 @@ func (m addModel) Init() tea.Cmd { 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 @@ -208,6 +235,20 @@ func (m addModel) FinalOutput() string { if m.status != 0 { return "" } + if m.jsonOutput { + driverName, constraint, _ := parseDriverConstraint(m.Driver[0]) + var constraintStr string + if constraint != nil { + constraintStr = constraint.String() + } + return marshalEnvelope("add.response", jsonschema.AddResponse{ + DriverListPath: m.resolvedPath, + Driver: jsonschema.AddResponseDriver{ + Name: driverName, + VersionConstraint: constraintStr, + }, + }) + } return m.result } diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index b3d2a787..b8d3ad33 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -17,6 +17,7 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -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" ) @@ -427,6 +429,30 @@ 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.Equal("test-driver-1", resp.Driver.Name) + suite.NotEmpty(resp.DriverListPath) +} + func (suite *SubcommandTestSuite) TestAddMultipleOutput() { m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() suite.runCmd(m) diff --git a/cmd/dbc/init.go b/cmd/dbc/init.go index 25ea4e66..a0c3b014 100644 --- a/cmd/dbc/init.go +++ b/cmd/dbc/init.go @@ -22,18 +22,24 @@ import ( "path/filepath" tea "charm.land/bubbletea/v2" + "github.com/columnar-tech/dbc/internal/jsonschema" ) type InitCmd struct { Path string `arg:"positional" default:"./dbc.toml" help:"File to create"` + Json bool `arg:"--json" help:"Output JSON instead of plaintext"` } func (c InitCmd) GetModel() tea.Model { - return initModel{Path: c.Path} + return initModel{Path: c.Path, jsonOutput: c.Json} } +type initDoneMsg struct{ path string } + type initModel struct { - Path string + Path string + jsonOutput bool + resolvedPath string status int err error @@ -67,12 +73,15 @@ func (m initModel) Init() tea.Cmd { return fmt.Errorf("error creating file %s: %w", p, err) } - return tea.Quit() + return initDoneMsg{path: p} } } func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case initDoneMsg: + m.resolvedPath = msg.path + return m, tea.Quit case error: m.status = 1 m.err = msg @@ -81,4 +90,17 @@ func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m initModel) FinalOutput() string { + if m.status != 0 { + return "" + } + if m.jsonOutput { + return marshalEnvelope("init.response", jsonschema.InitResponse{ + DriverListPath: m.resolvedPath, + Created: true, + }) + } + return "" +} + func (m initModel) View() tea.View { return tea.NewView("") } diff --git a/cmd/dbc/init_test.go b/cmd/dbc/init_test.go index fa55b7db..98cf0fb7 100644 --- a/cmd/dbc/init_test.go +++ b/cmd/dbc/init_test.go @@ -17,12 +17,14 @@ package main import ( "bytes" "context" + "encoding/json" "os" "path/filepath" "testing" "time" tea "charm.land/bubbletea/v2" + "github.com/columnar-tech/dbc/internal/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -93,3 +95,19 @@ func TestInit(t *testing.T) { }) } } + +func (suite *SubcommandTestSuite) TestInit_JSON() { + tmpPath := filepath.Join(suite.T().TempDir(), "dbc.toml") + m := InitCmd{Path: tmpPath, Json: true}.GetModel() + 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("init.response", env.Kind) + + var resp jsonschema.InitResponse + suite.Require().NoError(json.Unmarshal(env.Payload, &resp)) + suite.True(resp.Created) + suite.NotEmpty(resp.DriverListPath) +} diff --git a/cmd/dbc/remove.go b/cmd/dbc/remove.go index 6baf2ce5..0997cb64 100644 --- a/cmd/dbc/remove.go +++ b/cmd/dbc/remove.go @@ -20,38 +20,49 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/columnar-tech/dbc/internal/jsonschema" "github.com/pelletier/go-toml/v2" ) type RemoveCmd struct { Driver string `arg:"positional,required" help:"Driver to remove"` Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to remove from"` + Json bool `arg:"--json" help:"Output JSON instead of plaintext"` } func (c RemoveCmd) GetModelCustom(baseModel baseModel) tea.Model { return removeModel{ - baseModel: baseModel, - Driver: c.Driver, - Path: c.Path, + baseModel: baseModel, + Driver: c.Driver, + Path: c.Path, + jsonOutput: c.Json, } } func (c RemoveCmd) GetModel() tea.Model { return removeModel{ - Driver: c.Driver, - Path: c.Path, - baseModel: defaultBaseModel(), + Driver: c.Driver, + Path: c.Path, + jsonOutput: c.Json, + baseModel: defaultBaseModel(), } } +type removeDoneMsg struct { + result string + resolvedPath string +} + type removeModel struct { baseModel - Driver string - Path string + Driver string + Path string + jsonOutput bool - list DriversList - result string + list DriversList + result string + resolvedPath string } func (m removeModel) Init() tea.Cmd { @@ -88,12 +99,16 @@ func (m removeModel) Init() tea.Cmd { return err } - return fmt.Sprintf("removed '%s' from driver list", m.Driver) + return removeDoneMsg{result: fmt.Sprintf("removed '%s' from driver list", m.Driver), resolvedPath: p} } } func (m removeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case removeDoneMsg: + m.result = msg.result + m.resolvedPath = msg.resolvedPath + return m, tea.Quit case string: m.result = msg return m, tea.Quit @@ -109,6 +124,12 @@ func (m removeModel) FinalOutput() string { if m.status != 0 { return "" } + if m.jsonOutput { + return marshalEnvelope("remove.response", jsonschema.RemoveResponse{ + DriverListPath: m.resolvedPath, + Driver: jsonschema.RemoveResponseDriver{Name: strings.TrimSpace(m.Driver)}, + }) + } return m.result } diff --git a/cmd/dbc/remove_test.go b/cmd/dbc/remove_test.go index a31c0d4e..5a001875 100644 --- a/cmd/dbc/remove_test.go +++ b/cmd/dbc/remove_test.go @@ -15,7 +15,10 @@ package main import ( + "encoding/json" "path/filepath" + + "github.com/columnar-tech/dbc/internal/jsonschema" ) func (suite *SubcommandTestSuite) TestRemoveOutput() { @@ -76,3 +79,34 @@ func (suite *SubcommandTestSuite) TestRemoveFromNonexistentFile() { suite.Contains(out, "doesn't exist") suite.Contains(out, "Did you run `dbc init`?") } + +func (suite *SubcommandTestSuite) TestRemove_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"}, + }.GetModelCustom( + baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + suite.runCmd(m) + + m = RemoveCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + Driver: "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("remove.response", env.Kind) + + var resp jsonschema.RemoveResponse + suite.Require().NoError(json.Unmarshal(env.Payload, &resp)) + suite.Equal("test-driver-1", resp.Driver.Name) + suite.NotEmpty(resp.DriverListPath) +} From 348043b3ff898df03ac797ea17eba5dfc141d749 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 16:05:25 -0400 Subject: [PATCH 05/30] feat(sync): --json with NDJSON progress events --- cmd/dbc/main.go | 3 ++ cmd/dbc/sync.go | 117 ++++++++++++++++++++++++++++++++++++------- cmd/dbc/sync_test.go | 34 +++++++++++++ 3 files changed, 137 insertions(+), 17 deletions(-) diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index 43386a0d..999a627d 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -306,6 +306,9 @@ func main() { // defer f.Close() _, needsRenderer := m.(NeedsRenderer) + if jm, ok := m.(interface{ IsJSONMode() bool }); ok && jm.IsJSONMode() { + needsRenderer = false + } // Work around https://github.com/columnar-tech/dbc/issues/351 usedRenderer := false if !isatty.IsTerminal(os.Stdout.Fd()) || !needsRenderer { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 1af29b94..1aec253b 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -30,6 +30,7 @@ import ( "charm.land/lipgloss/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" "github.com/pelletier/go-toml/v2" ) @@ -37,28 +38,58 @@ type SyncCmd struct { Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to sync from"` Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` + Json bool `arg:"--json" help:"Output NDJSON progress events instead of TUI"` } func (c SyncCmd) GetModelCustom(baseModel baseModel) tea.Model { return syncModel{ - baseModel: baseModel, - Path: c.Path, - cfg: getConfig(c.Level), - NoVerify: c.NoVerify, + baseModel: baseModel, + Path: c.Path, + cfg: getConfig(c.Level), + NoVerify: c.NoVerify, + jsonOutput: c.Json, } } func (c SyncCmd) GetModel() tea.Model { return syncModel{ - Path: c.Path, - cfg: getConfig(c.Level), - NoVerify: c.NoVerify, - baseModel: defaultBaseModel(), + Path: c.Path, + cfg: getConfig(c.Level), + NoVerify: c.NoVerify, + jsonOutput: c.Json, + baseModel: defaultBaseModel(), } } func (syncModel) NeedsRenderer() {} +func (s syncModel) IsJSONMode() bool { return s.jsonOutput } + +func (s syncModel) emitJSON(kind string, payload any) { + fmt.Fprintln(os.Stdout, marshalEnvelope(kind, payload)) +} + +func (s syncModel) FinalOutput() string { + if s.status != 0 { + return "" + } + if !s.jsonOutput { + return "" + } + installed := make([]jsonschema.SyncedDriver, 0, len(s.locked.Drivers)) + for _, d := range s.locked.Drivers { + installed = append(installed, jsonschema.SyncedDriver{ + Name: d.Name, + Version: d.Version.String(), + }) + } + return marshalEnvelope("sync.status", jsonschema.SyncStatus{ + Installed: installed, + Skipped: []jsonschema.SyncedDriver{}, + Errors: []jsonschema.SyncError{}, + }) +} + type syncModel struct { baseModel @@ -70,6 +101,8 @@ type syncModel struct { locked LockFile cfg config.Config + jsonOutput bool + // the list of drivers in the driver list list DriversList // cdn driver registry index @@ -352,6 +385,15 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) s.installItems = msg + if s.jsonOutput { + for _, item := range msg { + s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ + Phase: "resolving", + Driver: item.Driver.Path, + }) + } + } + return s, tea.Batch(s.installDriver(s.cfg, s.installItems[s.index]), s.spinner.Tick) case alreadyInstalledDrvMsg: s.locked.Drivers = append(s.locked.Drivers, lockInfo{ @@ -361,8 +403,21 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Checksum: msg.item.Checksum, }) + if s.jsonOutput { + s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ + Phase: "installed", + Driver: msg.info.ID, + Version: msg.info.Version.String(), + }) + } + if s.index >= len(s.installItems)-1 { s.done = true + if s.jsonOutput { + return s, tea.Sequence( + func() tea.Msg { return s.writeLockFile() }, + tea.Quit) + } return s, tea.Sequence( tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), func() tea.Msg { return s.writeLockFile() }, @@ -371,6 +426,12 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.index++ progressCmd := s.progress.SetPercent(float64(s.index) / float64(len(s.installItems))) + if s.jsonOutput { + return s, tea.Batch( + progressCmd, + s.installDriver(s.cfg, s.installItems[s.index]), + ) + } return s, tea.Batch( progressCmd, tea.Printf("%s %s-%s already installed", checkMark, msg.info.ID, msg.info.Version), @@ -389,25 +450,41 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Checksum: chksum, }) - printCmd := tea.Printf("%s %s-%s", checkMark, msg.info.ID, msg.info.Version) - if msg.removed != nil { - printCmd = tea.Sequence( - printCmd, - tea.Printf("%s removed %s-%s", checkMark, msg.removed.ID, msg.removed.Version), - ) + if s.jsonOutput { + s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ + Phase: "installed", + Driver: msg.info.ID, + Version: msg.info.Version.String(), + }) } - if len(msg.postInstall) > 0 { - for _, m := range msg.postInstall { + var printCmd tea.Cmd + if !s.jsonOutput { + printCmd = tea.Printf("%s %s-%s", checkMark, msg.info.ID, msg.info.Version) + if msg.removed != nil { printCmd = tea.Sequence( printCmd, - tea.Printf("%s post-install: %s", checkMark, m), + tea.Printf("%s removed %s-%s", checkMark, msg.removed.ID, msg.removed.Version), ) } + + if len(msg.postInstall) > 0 { + for _, m := range msg.postInstall { + printCmd = tea.Sequence( + printCmd, + tea.Printf("%s post-install: %s", checkMark, m), + ) + } + } } if s.index >= len(s.installItems)-1 { s.done = true + if s.jsonOutput { + return s, tea.Sequence( + func() tea.Msg { return s.writeLockFile() }, + tea.Quit) + } return s, tea.Sequence( printCmd, func() tea.Msg { return s.writeLockFile() }, @@ -416,6 +493,12 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.index++ progressCmd := s.progress.SetPercent(float64(s.index) / float64(len(s.installItems))) + if s.jsonOutput { + return s, tea.Batch( + progressCmd, + s.installDriver(s.cfg, s.installItems[s.index]), + ) + } return s, tea.Batch( progressCmd, printCmd, diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index b91c7de0..35623979 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -15,11 +15,14 @@ package main import ( + "encoding/json" "fmt" "os" "path/filepath" + "strings" "github.com/columnar-tech/dbc" + "github.com/columnar-tech/dbc/internal/jsonschema" ) func (suite *SubcommandTestSuite) TestSync() { @@ -243,3 +246,34 @@ func (suite *SubcommandTestSuite) TestSyncCompleteRegistryFailure() { out := suite.runCmdErr(m) suite.Contains(out, "connection refused") } + +func (suite *SubcommandTestSuite) TestSync_JSONStream() { + tmpDir := suite.T().TempDir() + driverListPath := filepath.Join(tmpDir, "dbc.toml") + err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644) + suite.Require().NoError(err) + + m := SyncCmd{Path: driverListPath, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 0, "expected at least one NDJSON line") + + var lastEnv jsonschema.Envelope + for _, line := range lines { + if line == "" { + continue + } + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) + suite.Equal(1, env.SchemaVersion) + lastEnv = env + } + suite.Equal("sync.status", lastEnv.Kind) + + var status jsonschema.SyncStatus + suite.Require().NoError(json.Unmarshal(lastEnv.Payload, &status)) + suite.Len(status.Installed, 1) + suite.Equal("test-driver-1", status.Installed[0].Name) +} From a280e8da0ff50429374d8b462e45dfadcc0cd685 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 16:29:30 -0400 Subject: [PATCH 06/30] feat(cli): file-lock mutations + auth verification_uri_complete + dead code removal --- cmd/dbc/add.go | 28 +++++++++++-- cmd/dbc/auth.go | 13 ++++++ cmd/dbc/install.go | 27 +++++++++++- cmd/dbc/main.go | 6 --- cmd/dbc/remove.go | 28 +++++++++++-- cmd/dbc/sync.go | 9 ++++ cmd/dbc/uninstall.go | 30 ++++++++++++- cmd/dbc/view_config.go | 70 ------------------------------- internal/fslock/fslock.go | 33 +++++++++++++++ internal/fslock/fslock_test.go | 63 ++++++++++++++++++++++++++++ internal/fslock/fslock_unix.go | 46 ++++++++++++++++++++ internal/fslock/fslock_windows.go | 50 ++++++++++++++++++++++ 12 files changed, 316 insertions(+), 87 deletions(-) delete mode 100644 cmd/dbc/view_config.go create mode 100644 internal/fslock/fslock.go create mode 100644 internal/fslock/fslock_test.go create mode 100644 internal/fslock/fslock_unix.go create mode 100644 internal/fslock/fslock_windows.go diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index ce29285c..64febbca 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -16,14 +16,17 @@ package main import ( "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" ) @@ -128,10 +131,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) } @@ -200,13 +220,13 @@ 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" diff --git a/cmd/dbc/auth.go b/cmd/dbc/auth.go index 0e352b8b..de171364 100644 --- a/cmd/dbc/auth.go +++ b/cmd/dbc/auth.go @@ -29,6 +29,7 @@ import ( "github.com/cli/browser" "github.com/cli/oauth/device" "github.com/columnar-tech/dbc/auth" + "github.com/columnar-tech/dbc/internal/jsonschema" ) func ensureHTTPS(uri string) string { @@ -57,6 +58,7 @@ type LoginCmd struct { RegistryURL string `arg:"positional" help:"URL of the driver registry to authenticate with [default: https://dbc-cdn-private.columnar.tech]"` ClientID string `arg:"env:OAUTH_CLIENT_ID" help:"OAuth Client ID (can also be set via DBC_OAUTH_CLIENT_ID)"` ApiKey string `arg:"--api-key" help:"Authenticate using an API key instead of OAuth (use '-' to read from stdin)"` + Json bool `arg:"--json" help:"Output JSON instead of plaintext"` } func (l LoginCmd) GetModelCustom(baseModel baseModel) tea.Model { @@ -88,6 +90,7 @@ func (l LoginCmd) GetModelCustom(baseModel baseModel) tea.Model { inputURI: l.RegistryURL, oauthClientID: l.ClientID, apiKey: l.ApiKey, + jsonOutput: l.Json, baseModel: baseModel, } } @@ -110,6 +113,7 @@ type loginModel struct { inputURI string oauthClientID string apiKey string + jsonOutput bool tokenURI *url.URL parsedURI *url.URL } @@ -184,6 +188,15 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tokenURI = (*url.URL)(&msg.TokenEndpoint) return m, m.requestDeviceCode(msg) case *device.CodeResponse: + if m.jsonOutput { + fmt.Fprintln(os.Stdout, marshalEnvelope("auth.device_code", jsonschema.AuthDeviceCodeEvent{ + VerificationURI: msg.VerificationURI, + VerificationURIComplete: msg.VerificationURIComplete, + UserCode: msg.UserCode, + ExpiresIn: msg.ExpiresIn, + Interval: msg.Interval, + })) + } return m, tea.Sequence( tea.Println("Opening ", msg.VerificationURIComplete, " in your default web browser..."), func() tea.Msg { diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 0a21dd42..907d25b0 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" "strings" + "time" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" @@ -30,6 +31,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/fslock" "github.com/columnar-tech/dbc/internal/jsonschema" ) @@ -210,9 +212,30 @@ func (m progressiveInstallModel) Init() tea.Cmd { } return tea.Batch(m.spinner.Tick, func() tea.Msg { + installDir := "." + if locs := filepath.SplitList(m.cfg.Location); len(locs) > 0 && locs[0] != "" { + installDir = locs[0] + } + lockDir := installDir + for { + if _, err := os.Stat(lockDir); err == nil { + break + } + parent := filepath.Dir(lockDir) + if parent == lockDir { + lockDir = os.TempDir() + break + } + lockDir = parent + } + lockPath := filepath.Join(lockDir, ".dbc.install.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() + drivers, err := m.getDriverRegistry() - // Return both drivers and error - we'll decide how to handle based on whether - // the requested driver is found return driversWithRegistryError{ drivers: drivers, err: err, diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index 999a627d..1b44e638 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -38,12 +38,6 @@ var ( skipMark = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).SetString("-") ) -type TuiCmd struct{} - -func (TuiCmd) GetModel() tea.Model { - return getTuiModel() -} - type modelCmd interface { GetModel() tea.Model } diff --git a/cmd/dbc/remove.go b/cmd/dbc/remove.go index 0997cb64..55e3d26b 100644 --- a/cmd/dbc/remove.go +++ b/cmd/dbc/remove.go @@ -15,11 +15,15 @@ package main import ( + "errors" "fmt" "os" + "path/filepath" "strings" + "time" tea "charm.land/bubbletea/v2" + "github.com/columnar-tech/dbc/internal/fslock" "github.com/columnar-tech/dbc/internal/jsonschema" "github.com/pelletier/go-toml/v2" ) @@ -72,8 +76,24 @@ func (m removeModel) 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 } @@ -89,13 +109,13 @@ func (m removeModel) Init() tea.Cmd { delete(m.list.Drivers, m.Driver) - 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 } diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 1aec253b..cabb5438 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -23,6 +23,7 @@ import ( "path" "path/filepath" "strings" + "time" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" @@ -30,6 +31,7 @@ import ( "charm.land/lipgloss/v2" "github.com/columnar-tech/dbc" "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" ) @@ -136,6 +138,13 @@ func (s syncModel) Init() tea.Cmd { p = filepath.Join(p, "dbc.toml") } + 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() + drivers, err := loadDriverList(p) if err != nil { return err diff --git a/cmd/dbc/uninstall.go b/cmd/dbc/uninstall.go index 459c17ea..d5512135 100644 --- a/cmd/dbc/uninstall.go +++ b/cmd/dbc/uninstall.go @@ -17,9 +17,13 @@ package main import ( "encoding/json" "fmt" + "os" + "path/filepath" + "time" tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/fslock" "github.com/columnar-tech/dbc/internal/jsonschema" ) @@ -58,7 +62,31 @@ type uninstallModel struct { } func (m uninstallModel) Init() tea.Cmd { - return m.startUninstall + return func() tea.Msg { + installDir := "." + if locs := filepath.SplitList(m.cfg.Location); len(locs) > 0 && locs[0] != "" { + installDir = locs[0] + } + lockDir := installDir + for { + if _, err := os.Stat(lockDir); err == nil { + break + } + parent := filepath.Dir(lockDir) + if parent == lockDir { + lockDir = os.TempDir() + break + } + lockDir = parent + } + lockPath := filepath.Join(lockDir, ".dbc.install.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() + return m.startUninstall() + } } func (m uninstallModel) FinalOutput() string { diff --git a/cmd/dbc/view_config.go b/cmd/dbc/view_config.go deleted file mode 100644 index 4a798919..00000000 --- a/cmd/dbc/view_config.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2026 Columnar Technologies Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/tree" - "github.com/columnar-tech/dbc/config" -) - -type ViewConfigCmd struct{} - -func (ViewConfigCmd) GetModel() tea.Model { - return simpleViewConfigModel{} -} - -type simpleViewConfigModel struct{} - -func (m simpleViewConfigModel) Init() tea.Cmd { - return func() tea.Msg { - return config.Get() - } -} - -func (m simpleViewConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case map[config.ConfigLevel]config.Config: - return m, tea.Sequence( - // tea.Println(viewConfig(msg[config.ConfigEnv])), - tea.Println(viewConfig(msg[config.ConfigUser])), - tea.Println(viewConfig(msg[config.ConfigSystem])), - tea.Quit) - } - - return m, nil -} - -func (m simpleViewConfigModel) View() tea.View { return tea.NewView("") } - -func viewConfig(cfg config.Config) string { - if cfg.Level == config.ConfigUnknown { - return "" - } - - t := tree.Root(cfg.Level.String() + ": " + cfg.Location). - RootStyle(nameStyle) - if !cfg.Exists { - t.Child(descStyle.Bold(true).Foreground(lipgloss.Color("1")).Render("does not exist")) - } else { - for _, d := range cfg.Drivers { - t.Child(tree.New().Root(d.Name). - Child(descStyle.Render(d.ID) + " (" + d.Version.String() + ")")) - } - } - - return t.String() + "\n" -} diff --git a/internal/fslock/fslock.go b/internal/fslock/fslock.go new file mode 100644 index 00000000..ceb20842 --- /dev/null +++ b/internal/fslock/fslock.go @@ -0,0 +1,33 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package fslock provides advisory file locking for coordinating exclusive +// access to shared resources across processes. +package fslock + +import "os" + +// Lock represents an acquired advisory file lock. +type Lock struct { + f *os.File +} + +// Release releases the lock, closes the file, and removes the lock file. +func (l Lock) Release() error { + name := l.f.Name() + if err := l.f.Close(); err != nil { + return err + } + return os.Remove(name) +} diff --git a/internal/fslock/fslock_test.go b/internal/fslock/fslock_test.go new file mode 100644 index 00000000..423f3d8a --- /dev/null +++ b/internal/fslock/fslock_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fslock_test + +import ( + "path/filepath" + "testing" + "time" + + "github.com/columnar-tech/dbc/internal/fslock" +) + +func TestAcquireAndRelease(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + lock, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("Release: %v", err) + } +} + +func TestAcquireTwiceSequential(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + lock1, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("first Acquire: %v", err) + } + lock1.Release() + + lock2, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("second Acquire: %v", err) + } + lock2.Release() +} + +func TestAcquireTimeout(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.lock") + lock1, err := fslock.Acquire(path, 5*time.Second) + if err != nil { + t.Fatalf("first Acquire: %v", err) + } + defer lock1.Release() + + _, err = fslock.Acquire(path, 100*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error, got nil") + } +} diff --git a/internal/fslock/fslock_unix.go b/internal/fslock/fslock_unix.go new file mode 100644 index 00000000..52c3f4c2 --- /dev/null +++ b/internal/fslock/fslock_unix.go @@ -0,0 +1,46 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package fslock + +import ( + "fmt" + "os" + "syscall" + "time" +) + +// Acquire acquires an exclusive advisory lock on the file at path, retrying +// until timeout elapses. Returns an error if the lock cannot be acquired. +func Acquire(path string, timeout time.Duration) (Lock, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return Lock{}, fmt.Errorf("fslock: open %s: %w", path, err) + } + + deadline := time.Now().Add(timeout) + for { + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return Lock{f: f}, nil + } + if time.Now().After(deadline) { + f.Close() + return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s: %w", path, timeout, err) + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/internal/fslock/fslock_windows.go b/internal/fslock/fslock_windows.go new file mode 100644 index 00000000..f4050261 --- /dev/null +++ b/internal/fslock/fslock_windows.go @@ -0,0 +1,50 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package fslock + +import ( + "fmt" + "os" + "time" + + "golang.org/x/sys/windows" +) + +// Acquire acquires an exclusive advisory lock on the file at path, retrying +// until timeout elapses. Returns an error if the lock cannot be acquired. +func Acquire(path string, timeout time.Duration) (Lock, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return Lock{}, fmt.Errorf("fslock: open %s: %w", path, err) + } + + ol := new(windows.Overlapped) + deadline := time.Now().Add(timeout) + for { + err = windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, 1, 0, ol) + if err == nil { + return Lock{f: f}, nil + } + if time.Now().After(deadline) { + f.Close() + return Lock{}, fmt.Errorf("fslock: could not acquire lock on %s within %s: %w", path, timeout, err) + } + time.Sleep(50 * time.Millisecond) + } +} From debe9d6a2ea5fcc26d3baf35e2a441be4f690b51 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 16:43:38 -0400 Subject: [PATCH 07/30] feat(install): emit NDJSON progress events when --json set --- cmd/dbc/install.go | 48 ++++++++++++++++++++++++++++++++++++----- cmd/dbc/install_test.go | 37 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 907d25b0..194658e8 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -174,6 +174,23 @@ func (s installState) String() string { func (progressiveInstallModel) NeedsRenderer() {} +func (m progressiveInstallModel) IsJSONMode() bool { return m.jsonOutput } + +func (m progressiveInstallModel) addEvent(event string, extra ...func(*jsonschema.InstallProgressEvent)) progressiveInstallModel { + if !m.jsonOutput { + return m + } + evt := jsonschema.InstallProgressEvent{ + Event: event, + Driver: m.Driver, + } + for _, fn := range extra { + fn(&evt) + } + m.jsonEvents = append(m.jsonEvents, marshalEnvelope("install.progress", evt)) + return m +} + type progressiveInstallModel struct { baseModel @@ -194,9 +211,10 @@ type progressiveInstallModel struct { width, height int isLocal bool - localPackagePath string // original path for display; only set when isLocal=true + localPackagePath string - registryErrors error // Store registry errors for better error messages + registryErrors error + jsonEvents []string } type driversWithRegistryError struct { @@ -311,7 +329,14 @@ func (m progressiveInstallModel) FinalOutput() string { if err != nil { return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) } - return string(jsonOutput) + completeLine := marshalEnvelope("install.progress", jsonschema.InstallProgressEvent{ + Event: "install.complete", + Driver: m.Driver, + }) + allLines := make([]string, 0, len(m.jsonEvents)+2) + allLines = append(allLines, m.jsonEvents...) + allLines = append(allLines, completeLine, string(jsonOutput)) + return strings.Join(allLines, "\n") } if installStatus.Conflict != "" { @@ -377,6 +402,7 @@ func (m progressiveInstallModel) startDownloading() (tea.Model, tea.Cmd) { return m, tea.Quit } + m = m.addEvent("download.start") return m, func() tea.Msg { output, err := m.downloadPkg(m.DriverPackage) if err != nil { @@ -423,8 +449,14 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) return m, cmd case progressMsg: - cmd := m.p.SetPercent(msg.written, msg.total) - return m, cmd + if m.jsonOutput { + m = m.addEvent("download.progress", func(e *jsonschema.InstallProgressEvent) { + e.Bytes = msg.written + e.Total = msg.total + }) + } + progressCmd := m.p.SetPercent(msg.written, msg.total) + return m, progressCmd case progress.FrameMsg: var cmd tea.Cmd m.p, cmd = m.p.Update(msg) @@ -456,6 +488,8 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.startDownloading() case *os.File: + m = m.addEvent("download.complete") + m = m.addEvent("extract.start") return m.startInstalling(msg) case config.Manifest: if m.DriverPackage.Version == nil { @@ -464,6 +498,8 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stVerifying m.postInstallMessage = strings.Join(msg.PostInstall.Messages, "\n") + m = m.addEvent("extract.complete") + m = m.addEvent("verify.start") return m, func() tea.Msg { if err := verifySignature(msg, m.NoVerify); err != nil { path := filepath.Dir(msg.Driver.Shared.Get(config.PlatformTuple())) @@ -474,6 +510,8 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case writeDriverManifestMsg: m.state = stDone + m = m.addEvent("verify.complete") + m = m.addEvent("manifest.create") return m, tea.Sequence(func() tea.Msg { return config.CreateManifest(m.cfg, msg.DriverInfo) }, tea.Quit) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index d7787bb5..b57ad0b9 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" @@ -470,8 +471,10 @@ func (suite *SubcommandTestSuite) TestInstallJSON() { GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) out := suite.runCmd(m) + lines := strings.Split(strings.TrimSpace(out), "\n") + lastLine := lines[len(lines)-1] var env jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(out), &env), "output must be valid JSON: %s", out) + suite.Require().NoError(json.Unmarshal([]byte(lastLine), &env), "last output line must be valid JSON: %s", lastLine) suite.Equal(1, env.SchemaVersion) suite.Equal("install.status", env.Kind) @@ -484,3 +487,35 @@ func (suite *SubcommandTestSuite) TestInstallJSON() { suite.NotEmpty(status.Version) suite.NotEmpty(status.Location) } + +func (suite *SubcommandTestSuite) TestInstall_JSONProgressStream() { + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 1, "expected multiple NDJSON lines") + + var kinds []string + for _, line := range lines { + if line == "" { + continue + } + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) + suite.Equal(1, env.SchemaVersion) + kinds = append(kinds, env.Kind) + } + + suite.Contains(kinds, "install.progress") + suite.Equal("install.status", kinds[len(kinds)-1]) + + var hasDownloadStart bool + for _, line := range lines { + if strings.Contains(line, `"download.start"`) { + hasDownloadStart = true + break + } + } + suite.True(hasDownloadStart, "expected download.start event") +} From 460bc3dc37c17ee49897c3840b547e289711a11e Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Wed, 22 Apr 2026 16:48:30 -0400 Subject: [PATCH 08/30] feat(install): verify sha256 checksum alongside signature --- cmd/dbc/install.go | 46 ++++++++++++++++++++++++++++------------- cmd/dbc/install_test.go | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 194658e8..59b17c34 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -64,11 +64,12 @@ func parseDriverConstraint(driver string) (string, *semver.Constraints, error) { type InstallCmd struct { // URI url.URL `arg:"-u" placeholder:"URL" help:"Base URL for fetching drivers"` - Driver string `arg:"positional,required" help:"Driver to install, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` - Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` - Json bool `arg:"--json" help:"Output JSON instead of plaintext"` - NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` - Pre bool `arg:"--pre" help:"Allow implicit installation of pre-release versions"` + Driver string `arg:"positional,required" help:"Driver to install, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` + Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` + Json bool `arg:"--json" help:"Output JSON instead of plaintext"` + NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` + Pre bool `arg:"--pre" help:"Allow implicit installation of pre-release versions"` + InsecureNoChecksum bool `arg:"--insecure-no-checksum" help:"Skip sha256 checksum recording (not recommended)"` } func (InstallCmd) Description() string { @@ -86,15 +87,16 @@ func (c InstallCmd) GetModelCustom(baseModel baseModel) tea.Model { localPackagePath = c.Driver } return progressiveInstallModel{ - Driver: c.Driver, - NoVerify: c.NoVerify, - jsonOutput: c.Json, - Pre: c.Pre, - spinner: s, - cfg: getConfig(c.Level), - baseModel: baseModel, - isLocal: isLocal, - localPackagePath: localPackagePath, + Driver: c.Driver, + NoVerify: c.NoVerify, + jsonOutput: c.Json, + Pre: c.Pre, + insecureNoChecksum: c.InsecureNoChecksum, + spinner: s, + cfg: getConfig(c.Level), + baseModel: baseModel, + isLocal: isLocal, + localPackagePath: localPackagePath, p: NewFileProgress( progress.WithDefaultBlend(), progress.WithWidth(20), @@ -201,6 +203,9 @@ type progressiveInstallModel struct { Pre bool cfg config.Config + insecureNoChecksum bool + installedDriverInfo config.DriverInfo + DriverPackage dbc.PkgInfo conflictingInfo config.DriverInfo postInstallMessage string @@ -315,7 +320,19 @@ func (m progressiveInstallModel) FinalOutput() string { installStatus.Message = m.postInstallMessage } + if !m.insecureNoChecksum && m.installedDriverInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { + driverPath := m.installedDriverInfo.Driver.Shared.Get(config.PlatformTuple()) + if chksum, err := checksum(driverPath); err == nil { + installStatus.Checksum = "sha256:" + chksum + } + } + if m.jsonOutput { + if installStatus.Checksum != "" { + m = m.addEvent("verify.checksum.ok", func(e *jsonschema.InstallProgressEvent) { + e.Checksum = installStatus.Checksum + }) + } payloadBytes, err := json.Marshal(installStatus) if err != nil { return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) @@ -510,6 +527,7 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case writeDriverManifestMsg: m.state = stDone + m.installedDriverInfo = msg.DriverInfo m = m.addEvent("verify.complete") m = m.addEvent("manifest.create") return m, tea.Sequence(func() tea.Msg { diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index b57ad0b9..bad28aa9 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -488,6 +488,46 @@ func (suite *SubcommandTestSuite) TestInstallJSON() { suite.NotEmpty(status.Location) } +func (suite *SubcommandTestSuite) TestInstall_ChecksumInStatus() { + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 0) + + lastLine := lines[len(lines)-1] + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(lastLine), &env)) + suite.Equal("install.status", env.Kind) + + var status jsonschema.InstallStatus + suite.Require().NoError(json.Unmarshal(env.Payload, &status)) + suite.Equal("installed", status.Status) + // Checksum should be present and start with "sha256:" + suite.True(strings.HasPrefix(status.Checksum, "sha256:"), "expected checksum to start with sha256:, got: %s", status.Checksum) +} + +func (suite *SubcommandTestSuite) TestInstall_InsecureNoChecksumFlag() { + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true, InsecureNoChecksum: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 0) + + lastLine := lines[len(lines)-1] + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(lastLine), &env)) + suite.Equal("install.status", env.Kind) + + var status jsonschema.InstallStatus + suite.Require().NoError(json.Unmarshal(env.Payload, &status)) + suite.Equal("installed", status.Status) + // Checksum should be absent when --insecure-no-checksum is set + suite.Empty(status.Checksum, "expected no checksum when InsecureNoChecksum is set") +} + func (suite *SubcommandTestSuite) TestInstall_JSONProgressStream() { m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) From 3390e093419c2148188615ff28bf1304d4b7b850 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Fri, 24 Apr 2026 18:38:12 -0400 Subject: [PATCH 09/30] fix: address roborev review findings across json output and fslock - 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) --- cmd/dbc/add.go | 20 ++++--- cmd/dbc/add_test.go | 3 +- cmd/dbc/auth.go | 83 +++++++++++++++++++----------- cmd/dbc/completions/dbc.bash | 6 +-- cmd/dbc/completions/dbc.zsh | 3 ++ cmd/dbc/install.go | 31 +++++++++-- cmd/dbc/install_test.go | 5 +- cmd/dbc/subcommand_test.go | 6 ++- cmd/dbc/sync.go | 46 +++++++++++++---- cmd/dbc/uninstall.go | 13 ++--- internal/fslock/fslock.go | 9 ++-- internal/jsonschema/schema.go | 6 +-- internal/jsonschema/schema_test.go | 12 +++-- 13 files changed, 160 insertions(+), 83 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index 64febbca..0210874e 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -256,17 +256,21 @@ func (m addModel) FinalOutput() string { return "" } if m.jsonOutput { - driverName, constraint, _ := parseDriverConstraint(m.Driver[0]) - var constraintStr string - if constraint != nil { - constraintStr = constraint.String() + 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, - Driver: jsonschema.AddResponseDriver{ - Name: driverName, - VersionConstraint: constraintStr, - }, + Drivers: drivers, }) } return m.result diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index b8d3ad33..be96152c 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -449,7 +449,8 @@ func (suite *SubcommandTestSuite) TestAdd_JSON() { var resp jsonschema.AddResponse suite.Require().NoError(json.Unmarshal(env.Payload, &resp)) - suite.Equal("test-driver-1", resp.Driver.Name) + suite.Require().Len(resp.Drivers, 1) + suite.Equal("test-driver-1", resp.Drivers[0].Name) suite.NotEmpty(resp.DriverListPath) } diff --git a/cmd/dbc/auth.go b/cmd/dbc/auth.go index de171364..d496b31b 100644 --- a/cmd/dbc/auth.go +++ b/cmd/dbc/auth.go @@ -103,7 +103,9 @@ type authSuccessMsg struct { cred auth.Credential } -func (loginModel) NeedsRenderer() {} +func (m loginModel) NeedsRenderer() {} + +func (m loginModel) IsJSONMode() bool { return m.jsonOutput } type loginModel struct { baseModel @@ -197,28 +199,33 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Interval: msg.Interval, })) } + waitCmd := func() tea.Msg { + browser.OpenURL(msg.VerificationURIComplete) + accessToken, err := device.Wait(context.TODO(), dbcClient.HTTPClient(), m.tokenURI.String(), device.WaitOptions{ + ClientID: m.oauthClientID, + DeviceCode: msg, + }) + + if err != nil { + return fmt.Errorf("failed to obtain access token: %w", err) + } + + return auth.Credential{ + Type: auth.TypeToken, + AuthURI: auth.Uri(*m.parsedURI), + Token: accessToken.Token, + ClientID: m.oauthClientID, + RegistryURL: auth.Uri(*m.parsedURI), + RefreshToken: accessToken.RefreshToken, + } + } + if m.jsonOutput { + return m, waitCmd + } return m, tea.Sequence( tea.Println("Opening ", msg.VerificationURIComplete, " in your default web browser..."), - func() tea.Msg { - browser.OpenURL(msg.VerificationURIComplete) - accessToken, err := device.Wait(context.TODO(), dbcClient.HTTPClient(), m.tokenURI.String(), device.WaitOptions{ - ClientID: m.oauthClientID, - DeviceCode: msg, - }) - - if err != nil { - return fmt.Errorf("failed to obtain access token: %w", err) - } - - return auth.Credential{ - Type: auth.TypeToken, - AuthURI: auth.Uri(*m.parsedURI), - Token: accessToken.Token, - ClientID: m.oauthClientID, - RegistryURL: auth.Uri(*m.parsedURI), - RefreshToken: accessToken.RefreshToken, - } - }) + waitCmd, + ) case auth.Credential: return m, func() tea.Msg { if err := auth.AddCredential(msg, true); err != nil { @@ -227,15 +234,22 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return authSuccessMsg{cred: msg} } case authSuccessMsg: - return m, tea.Sequence(tea.Println("Authentication successful!"), - func() tea.Msg { - if auth.IsColumnarPrivateRegistry((*url.URL)(&msg.cred.RegistryURL)) { - if err := auth.FetchColumnarLicense(&msg.cred); err != nil { - return err - } + postLoginCmd := func() tea.Msg { + if auth.IsColumnarPrivateRegistry((*url.URL)(&msg.cred.RegistryURL)) { + if err := auth.FetchColumnarLicense(&msg.cred); err != nil { + return err } - return tea.Quit() - }) + } + return tea.Quit() + } + if m.jsonOutput { + fmt.Fprintln(os.Stdout, marshalEnvelope("auth.login", jsonschema.AuthLoginResponse{ + Status: "success", + Registry: msg.cred.RegistryURL.String(), + })) + return m, postLoginCmd + } + return m, tea.Sequence(tea.Println("Authentication successful!"), postLoginCmd) case error: switch { case errors.Is(msg, auth.ErrTrialExpired) || @@ -244,6 +258,14 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // the user can still login but won't be able to download trial licenses return m, tea.Quit default: + if m.jsonOutput { + fmt.Fprintln(os.Stdout, marshalEnvelope("auth.login", jsonschema.AuthLoginResponse{ + Status: "failed", + Registry: m.inputURI, + Message: msg.Error(), + })) + return m, tea.Quit + } // for other errors, let the baseModel update handle it. } } @@ -254,6 +276,9 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m loginModel) View() tea.View { + if m.jsonOutput { + return tea.NewView("") + } return tea.NewView(m.spinner.View() + " Waiting for confirmation...") } diff --git a/cmd/dbc/completions/dbc.bash b/cmd/dbc/completions/dbc.bash index 37ec470b..2ecf47ac 100644 --- a/cmd/dbc/completions/dbc.bash +++ b/cmd/dbc/completions/dbc.bash @@ -104,7 +104,7 @@ _dbc_init_completions() { prev="${COMP_WORDS[COMP_CWORD-1]}" if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "-h" -- "$cur")) + COMPREPLY=($(compgen -W "-h --json" -- "$cur")) return 0 fi @@ -133,7 +133,7 @@ _dbc_add_completions() { esac if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "-h --path -p --pre" -- "$cur")) + COMPREPLY=($(compgen -W "-h --path -p --pre --json" -- "$cur")) return 0 fi @@ -228,7 +228,7 @@ _dbc_remove_completions() { esac if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "-h --path -p" -- "$cur")) + COMPREPLY=($(compgen -W "-h --path -p --json" -- "$cur")) return 0 fi diff --git a/cmd/dbc/completions/dbc.zsh b/cmd/dbc/completions/dbc.zsh index a9fc6ad9..2fd7d1e7 100644 --- a/cmd/dbc/completions/dbc.zsh +++ b/cmd/dbc/completions/dbc.zsh @@ -94,6 +94,7 @@ function _dbc_init_completions { _arguments \ '(--help)-h[Help]' \ '(-h)--help[Help]' \ + '--json[Output JSON instead of plaintext]' \ ':file to create:_files -g \*.toml' } @@ -102,6 +103,7 @@ function _dbc_add_completions { '(--help)-h[Help]' \ '(-h)--help[Help]' \ '--pre[Allow pre-release versions implicitly]' \ + '--json[Output JSON instead of plaintext]' \ '(-p)--path[driver list to add to]: :_files -g \*.toml' \ '(--path)-p[driver list to add to]: :_files -g \*.toml' \ ':driver name: ' @@ -148,6 +150,7 @@ function _dbc_remove_completions { _arguments \ '(--help)-h[Help]' \ '(-h)--help[Help]' \ + '--json[Output JSON instead of plaintext]' \ '(-p)--path[driver list to remove from]: :_files -g \*.toml' \ '(--path)-p[driver list to remove from]: :_files -g \*.toml' \ ':driver name: ' diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 59b17c34..9d14dbd1 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -291,13 +291,24 @@ func (m progressiveInstallModel) FinalOutput() string { Version: m.conflictingInfo.Version.String(), Location: filepath.SplitList(m.cfg.Location)[0], } - payloadBytes, _ := json.Marshal(payload) + if !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { + if chksum, err := checksum(m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple())); err == nil { + payload.Checksum = chksum + } + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) + } env := jsonschema.Envelope{ SchemaVersion: jsonschema.SchemaVersion, Kind: "install.status", Payload: json.RawMessage(payloadBytes), } - jsonOutput, _ := json.Marshal(env) + jsonOutput, err := json.Marshal(env) + if err != nil { + return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) + } return string(jsonOutput) } return fmt.Sprintf("\nDriver %s %s already installed at %s", @@ -322,8 +333,15 @@ func (m progressiveInstallModel) FinalOutput() string { if !m.insecureNoChecksum && m.installedDriverInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { driverPath := m.installedDriverInfo.Driver.Shared.Get(config.PlatformTuple()) - if chksum, err := checksum(driverPath); err == nil { - installStatus.Checksum = "sha256:" + chksum + chksum, err := checksum(driverPath) + if err != nil && m.jsonOutput { + return marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "checksum_failed", + Message: err.Error(), + }) + } + if err == nil { + installStatus.Checksum = chksum } } @@ -535,7 +553,10 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }, tea.Quit) case error: if m.jsonOutput { - return m, tea.Sequence(tea.Println(fmt.Sprintf(`{"status":"error","error":"%s"}`, msg.Error())), tea.Quit) + return m, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "install_failed", + Message: msg.Error(), + })), tea.Quit) } } diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index bad28aa9..48727712 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -504,8 +504,9 @@ func (suite *SubcommandTestSuite) TestInstall_ChecksumInStatus() { var status jsonschema.InstallStatus suite.Require().NoError(json.Unmarshal(env.Payload, &status)) suite.Equal("installed", status.Status) - // Checksum should be present and start with "sha256:" - suite.True(strings.HasPrefix(status.Checksum, "sha256:"), "expected checksum to start with sha256:, got: %s", status.Checksum) + // Checksum should be present as a bare hex string (no prefix) + suite.NotEmpty(status.Checksum, "expected checksum to be non-empty") + suite.False(strings.HasPrefix(status.Checksum, "sha256:"), "expected bare hex checksum without sha256: prefix, got: %s", status.Checksum) } func (suite *SubcommandTestSuite) TestInstall_InsecureNoChecksumFlag() { diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index 4b8cd507..ec20fc4b 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -22,6 +22,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "testing" "time" @@ -123,7 +124,10 @@ func (suite *SubcommandTestSuite) getFilesInTempDir() []string { if d.IsDir() { return nil } - + // Skip advisory lock files created by fslock + if strings.HasSuffix(path, ".lock") { + return nil + } filelist = append(filelist, path) return nil })) diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index cabb5438..20b5a166 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -72,22 +72,20 @@ func (s syncModel) emitJSON(kind string, payload any) { } func (s syncModel) FinalOutput() string { - if s.status != 0 { + if s.status != 0 || !s.jsonOutput { return "" } - if !s.jsonOutput { - return "" + installed := s.newlyInstalled + if installed == nil { + installed = []jsonschema.SyncedDriver{} } - installed := make([]jsonschema.SyncedDriver, 0, len(s.locked.Drivers)) - for _, d := range s.locked.Drivers { - installed = append(installed, jsonschema.SyncedDriver{ - Name: d.Name, - Version: d.Version.String(), - }) + skipped := s.skippedDrivers + if skipped == nil { + skipped = []jsonschema.SyncedDriver{} } return marshalEnvelope("sync.status", jsonschema.SyncStatus{ Installed: installed, - Skipped: []jsonschema.SyncedDriver{}, + Skipped: skipped, Errors: []jsonschema.SyncError{}, }) } @@ -120,6 +118,11 @@ type syncModel struct { done bool registryErrors error // Store registry errors for better error messages + + // skippedDrivers tracks already-installed drivers for JSON output + skippedDrivers []jsonschema.SyncedDriver + // newlyInstalled tracks freshly installed drivers for JSON output + newlyInstalled []jsonschema.SyncedDriver } type driversListMsg struct { @@ -411,10 +414,14 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Platform: config.PlatformTuple(), Checksum: msg.item.Checksum, }) + s.skippedDrivers = append(s.skippedDrivers, jsonschema.SyncedDriver{ + Name: msg.info.ID, + Version: msg.info.Version.String(), + }) if s.jsonOutput { s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ - Phase: "installed", + Phase: "skipped", Driver: msg.info.ID, Version: msg.info.Version.String(), }) @@ -450,6 +457,12 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { chksum, err := checksum(msg.info.Driver.Shared.Get(config.PlatformTuple())) if err != nil { s.status = 1 + if s.jsonOutput { + return s, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "checksum_failed", + Message: err.Error(), + })), tea.Quit) + } return s, tea.Sequence(tea.Println("Error: ", err), tea.Quit) } s.locked.Drivers = append(s.locked.Drivers, lockInfo{ @@ -458,6 +471,10 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Platform: config.PlatformTuple(), Checksum: chksum, }) + s.newlyInstalled = append(s.newlyInstalled, jsonschema.SyncedDriver{ + Name: msg.info.ID, + Version: msg.info.Version.String(), + }) if s.jsonOutput { s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ @@ -513,6 +530,13 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { printCmd, s.installDriver(s.cfg, s.installItems[s.index]), ) + case error: + if s.jsonOutput { + return s, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "sync_failed", + Message: msg.Error(), + })), tea.Quit) + } } bm, cmd := s.baseModel.Update(msg) diff --git a/cmd/dbc/uninstall.go b/cmd/dbc/uninstall.go index d5512135..ed106ca6 100644 --- a/cmd/dbc/uninstall.go +++ b/cmd/dbc/uninstall.go @@ -125,15 +125,10 @@ func (m uninstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = 1 m.err = msg if m.jsonOutput { - payload := jsonschema.UninstallStatus{Status: "error", Driver: m.Driver} - payloadBytes, _ := json.Marshal(payload) - env := jsonschema.Envelope{ - SchemaVersion: jsonschema.SchemaVersion, - Kind: "uninstall.status", - Payload: json.RawMessage(payloadBytes), - } - jsonOutput, _ := json.Marshal(env) - return m, tea.Sequence(tea.Println(string(jsonOutput)), tea.Quit) + return m, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "uninstall_failed", + Message: msg.Error(), + })), tea.Quit) } return m, tea.Quit } diff --git a/internal/fslock/fslock.go b/internal/fslock/fslock.go index ceb20842..99a25c6b 100644 --- a/internal/fslock/fslock.go +++ b/internal/fslock/fslock.go @@ -23,11 +23,8 @@ type Lock struct { f *os.File } -// Release releases the lock, closes the file, and removes the lock file. +// Release releases the lock and closes the file. The lock file is left on +// disk so that concurrent waiters do not race on a deleted path. func (l Lock) Release() error { - name := l.f.Name() - if err := l.f.Close(); err != nil { - return err - } - return os.Remove(name) + return l.f.Close() } diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go index 0ca094af..6c327992 100644 --- a/internal/jsonschema/schema.go +++ b/internal/jsonschema/schema.go @@ -177,12 +177,12 @@ type AddResponseDriver struct { VersionConstraint string `json:"version_constraint,omitempty"` } -// AddResponse is the JSON payload emitted after adding a driver to a driver list. +// AddResponse is the JSON payload emitted after adding drivers to a driver list. type AddResponse struct { // DriverListPath is the filesystem path of the driver list that was modified. DriverListPath string `json:"driver_list_path"` - // Driver is the entry that was added. - Driver AddResponseDriver `json:"driver"` + // Drivers lists every driver entry that was added or updated. + Drivers []AddResponseDriver `json:"drivers"` } // RemoveResponseDriver carries the driver entry that was removed from the list. diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go index d13a69bf..7c2d3087 100644 --- a/internal/jsonschema/schema_test.go +++ b/internal/jsonschema/schema_test.go @@ -338,17 +338,19 @@ func TestInitResponse(t *testing.T) { func TestAddResponse(t *testing.T) { v := jsonschema.AddResponse{ DriverListPath: "/proj/dbc.toml", - Driver: jsonschema.AddResponseDriver{ - Name: "snowflake", - VersionConstraint: ">=1.0.0", + Drivers: []jsonschema.AddResponseDriver{ + { + Name: "snowflake", + VersionConstraint: ">=1.0.0", + }, }, } got := roundTrip(t, v) if got.DriverListPath != v.DriverListPath { t.Errorf("DriverListPath mismatch") } - if got.Driver.Name != v.Driver.Name || got.Driver.VersionConstraint != v.Driver.VersionConstraint { - t.Errorf("Driver mismatch: %+v", got.Driver) + if len(got.Drivers) != len(v.Drivers) || got.Drivers[0].Name != v.Drivers[0].Name || got.Drivers[0].VersionConstraint != v.Drivers[0].VersionConstraint { + t.Errorf("Drivers mismatch: %+v", got.Drivers) } } From 4963faab0e5157624ff3ec46e22c48cf13ed736c Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 12:45:18 -0400 Subject: [PATCH 10/30] fix: address job-437 review findings - 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) --- cmd/dbc/auth.go | 15 +++++++++++---- cmd/dbc/install.go | 9 +++++++-- cmd/dbc/sync.go | 2 ++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/cmd/dbc/auth.go b/cmd/dbc/auth.go index d496b31b..5d6a97d5 100644 --- a/cmd/dbc/auth.go +++ b/cmd/dbc/auth.go @@ -103,6 +103,10 @@ type authSuccessMsg struct { cred auth.Credential } +type authLoginCompleteMsg struct { + cred auth.Credential +} + func (m loginModel) NeedsRenderer() {} func (m loginModel) IsJSONMode() bool { return m.jsonOutput } @@ -234,22 +238,23 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return authSuccessMsg{cred: msg} } case authSuccessMsg: - postLoginCmd := func() tea.Msg { + return m, func() tea.Msg { if auth.IsColumnarPrivateRegistry((*url.URL)(&msg.cred.RegistryURL)) { if err := auth.FetchColumnarLicense(&msg.cred); err != nil { return err } } - return tea.Quit() + return authLoginCompleteMsg{cred: msg.cred} } + case authLoginCompleteMsg: if m.jsonOutput { fmt.Fprintln(os.Stdout, marshalEnvelope("auth.login", jsonschema.AuthLoginResponse{ Status: "success", Registry: msg.cred.RegistryURL.String(), })) - return m, postLoginCmd + return m, tea.Quit } - return m, tea.Sequence(tea.Println("Authentication successful!"), postLoginCmd) + return m, tea.Sequence(tea.Println("Authentication successful!"), tea.Quit) case error: switch { case errors.Is(msg, auth.ErrTrialExpired) || @@ -259,6 +264,8 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit default: if m.jsonOutput { + m.status = 1 + m.err = msg fmt.Fprintln(os.Stdout, marshalEnvelope("auth.login", jsonschema.AuthLoginResponse{ Status: "failed", Registry: m.inputURI, diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 9d14dbd1..5fefbdcb 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -292,9 +292,14 @@ func (m progressiveInstallModel) FinalOutput() string { Location: filepath.SplitList(m.cfg.Location)[0], } if !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { - if chksum, err := checksum(m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple())); err == nil { - payload.Checksum = chksum + chksum, err := checksum(m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple())) + if err != nil { + return marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "checksum_failed", + Message: err.Error(), + }) } + payload.Checksum = chksum } payloadBytes, err := json.Marshal(payload) if err != nil { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 20b5a166..89189f26 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -531,6 +531,8 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.installDriver(s.cfg, s.installItems[s.index]), ) case error: + s.status = 1 + s.err = msg if s.jsonOutput { return s, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ Code: "sync_failed", From 7accc20e52cbaa5dc33198a8332fd866e914af93 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 12:51:30 -0400 Subject: [PATCH 11/30] fix: address job-438 review findings - 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) --- cmd/dbc/auth.go | 13 +++++++++++-- cmd/dbc/install.go | 34 +++++++++++++++++++++++----------- cmd/dbc/main.go | 9 ++++++++- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/cmd/dbc/auth.go b/cmd/dbc/auth.go index 5d6a97d5..7770ebea 100644 --- a/cmd/dbc/auth.go +++ b/cmd/dbc/auth.go @@ -122,6 +122,9 @@ type loginModel struct { jsonOutput bool tokenURI *url.URL parsedURI *url.URL + // storedCred holds the saved credential so ignorable post-login errors + // can still emit the success response. + storedCred *auth.Credential } func (m loginModel) Init() tea.Cmd { @@ -238,6 +241,7 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return authSuccessMsg{cred: msg} } case authSuccessMsg: + m.storedCred = &msg.cred return m, func() tea.Msg { if auth.IsColumnarPrivateRegistry((*url.URL)(&msg.cred.RegistryURL)) { if err := auth.FetchColumnarLicense(&msg.cred); err != nil { @@ -259,8 +263,13 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case errors.Is(msg, auth.ErrTrialExpired) || errors.Is(msg, auth.ErrNoTrialLicense): - // ignore these errors during auth login - // the user can still login but won't be able to download trial licenses + // Credentials were already saved; these license errors are ignorable. + // Treat as successful login completion. + if m.storedCred != nil { + return m, func() tea.Msg { + return authLoginCompleteMsg{cred: *m.storedCred} + } + } return m, tea.Quit default: if m.jsonOutput { diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 5fefbdcb..de683ecb 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -149,6 +149,9 @@ type writeDriverManifestMsg struct { type localInstallMsg struct{} +// alreadyInstalledChecksumMsg carries the checksum computed for an already-installed driver. +type alreadyInstalledChecksumMsg string + type installState int const ( @@ -218,8 +221,9 @@ type progressiveInstallModel struct { isLocal bool localPackagePath string - registryErrors error - jsonEvents []string + registryErrors error + jsonEvents []string + alreadyInstalledChecksum string } type driversWithRegistryError struct { @@ -291,15 +295,8 @@ func (m progressiveInstallModel) FinalOutput() string { Version: m.conflictingInfo.Version.String(), Location: filepath.SplitList(m.cfg.Location)[0], } - if !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { - chksum, err := checksum(m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple())) - if err != nil { - return marshalEnvelope("error", jsonschema.ErrorResponse{ - Code: "checksum_failed", - Message: err.Error(), - }) - } - payload.Checksum = chksum + if m.alreadyInstalledChecksum != "" { + payload.Checksum = m.alreadyInstalledChecksum } payloadBytes, err := json.Marshal(payload) if err != nil { @@ -439,6 +436,16 @@ func (m progressiveInstallModel) startDownloading() (tea.Model, tea.Cmd) { m.state = stDownloading if m.isAlreadyInstalled() { m.state = stDone + if m.jsonOutput && !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { + driverPath := m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) + return m, func() tea.Msg { + chksum, err := checksum(driverPath) + if err != nil { + return fmt.Errorf("checksum_failed: %w", err) + } + return alreadyInstalledChecksumMsg(chksum) + } + } return m, tea.Quit } @@ -482,6 +489,9 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case alreadyInstalledChecksumMsg: + m.alreadyInstalledChecksum = string(msg) + return m, tea.Quit case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case spinner.TickMsg: @@ -557,6 +567,8 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return config.CreateManifest(m.cfg, msg.DriverInfo) }, tea.Quit) case error: + m.status = 1 + m.err = msg if m.jsonOutput { return m, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ Code: "install_failed", diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index 1b44e638..e694c74b 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -345,7 +345,14 @@ func main() { } if h, ok := m.(HasStatus); ok { - if err := h.Err(); err != nil { + // Suppress plaintext error formatting when the model already emitted a + // structured JSON error envelope to stdout (JSON mode). Printing + // formatErr after a JSON envelope would corrupt NDJSON consumers. + inJSONMode := false + if jm, ok := m.(interface{ IsJSONMode() bool }); ok { + inJSONMode = jm.IsJSONMode() + } + if err := h.Err(); err != nil && !inJSONMode { lipgloss.Println(formatErr(err)) } os.Exit(h.Status()) From 3d44db2f878ba420946017963d90a1918ffdfcd5 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 12:55:07 -0400 Subject: [PATCH 12/30] fix: gate install FinalOutput on status to prevent contradictory NDJSON 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) --- cmd/dbc/install.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index de683ecb..c0b92b17 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -287,6 +287,9 @@ func (m progressiveInstallModel) isAlreadyInstalled() bool { } func (m progressiveInstallModel) FinalOutput() string { + if m.status != 0 { + return "" + } if m.isAlreadyInstalled() { if m.jsonOutput { payload := jsonschema.InstallStatus{ From f2ff017fdf8870035962089afdfff02b5e36e52a Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 12:59:09 -0400 Subject: [PATCH 13/30] test(install): add regression test for already-installed checksum failure 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) --- cmd/dbc/install_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 48727712..35ea7a9c 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -560,3 +560,32 @@ func (suite *SubcommandTestSuite) TestInstall_JSONProgressStream() { } suite.True(hasDownloadStart, "expected download.start event") } + +// TestInstallJSON_AlreadyInstalledChecksumFailure is a regression test for the +// fix that gates FinalOutput() on m.status. When the driver binary is missing +// the checksum computation fails, the model exits with status 1, and +// FinalOutput() must not emit an install.status success envelope. +func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailure() { + // First install the driver normally. + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + suite.runCmd(m) + + // Locate and delete the shared library so checksum() will fail. + cfg := config.Get()[suite.configLevel] + driver, err := config.GetDriver(cfg, "test-driver-1") + suite.Require().NoError(err) + sharedPath := driver.Driver.Shared.Get(config.PlatformTuple()) + suite.Require().NotEmpty(sharedPath, "shared library path should not be empty") + suite.Require().NoError(os.Remove(sharedPath)) + + // Reinstall with --json. The already-installed path fires, but checksum + // fails because the file is gone. Expect a non-zero exit and an error + // envelope — no install.status success line. + m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmdErr(m2) + + // No install.status line should appear in the output. + suite.NotContains(out, `"install.status"`, "must not emit success envelope when checksum fails") +} From 6a4255c1af0e74ad8acf225051efbf049c8666af Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:03:56 -0400 Subject: [PATCH 14/30] test(install): fix regression test to exercise FinalOutput() directly 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) --- cmd/dbc/install_test.go | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 35ea7a9c..662cfff8 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -16,14 +16,18 @@ package main import ( "archive/tar" + "bytes" "compress/gzip" + "context" "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" + "time" + tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" @@ -580,12 +584,31 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur suite.Require().NoError(os.Remove(sharedPath)) // Reinstall with --json. The already-installed path fires, but checksum - // fails because the file is gone. Expect a non-zero exit and an error - // envelope — no install.status success line. + // fails because the file is gone. Run the model manually to mirror + // main.go's ordering: prog.Run() first, then FinalOutput(). + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) - out := suite.runCmdErr(m2) - - // No install.status line should appear in the output. - suite.NotContains(out, `"install.status"`, "must not emit success envelope when checksum fails") + var out bytes.Buffer + prog = tea.NewProgram(m2, tea.WithInput(nil), tea.WithOutput(&out), + tea.WithoutRenderer(), tea.WithContext(ctx)) + defer func() { prog = nil }() + finishedModel, runErr := prog.Run() + prog.Wait() + suite.Require().NoError(runErr) + + // Assert non-zero exit status. + suite.Equal(1, finishedModel.(HasStatus).Status(), "expected non-zero status on checksum failure") + + // FinalOutput() must return empty — this is the core regression: previously + // it emitted an install.status success envelope even when status was 1. + // This directly tests the m.status != 0 guard added to FinalOutput(). + finalOutput := finishedModel.(HasFinalOutput).FinalOutput() + suite.Empty(finalOutput, "FinalOutput() must be empty when status != 0; a non-empty value means the install.status success envelope would be printed by main.go after the error") + + // The in-process output (tea.Println emits to the configured output buffer + // in this test harness) must not contain an install.status success envelope. + suite.NotContains(out.String()+finalOutput, `"install.status"`, "must not emit success envelope when checksum fails") } From 09f83a2df546b7146d4d639b4f4cab04e202f884 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:07:17 -0400 Subject: [PATCH 15/30] fix(install): emit error envelope via Fprintln + assert it in test 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) --- cmd/dbc/install.go | 5 +++-- cmd/dbc/install_test.go | 30 +++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index c0b92b17..009677e3 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -573,10 +573,11 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = 1 m.err = msg if m.jsonOutput { - return m, tea.Sequence(tea.Println(marshalEnvelope("error", jsonschema.ErrorResponse{ + fmt.Fprintln(os.Stdout, marshalEnvelope("error", jsonschema.ErrorResponse{ Code: "install_failed", Message: msg.Error(), - })), tea.Quit) + })) + return m, tea.Quit } } diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 662cfff8..a48c9646 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -586,17 +586,33 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur // Reinstall with --json. The already-installed path fires, but checksum // fails because the file is gone. Run the model manually to mirror // main.go's ordering: prog.Run() first, then FinalOutput(). + // Capture os.Stdout to inspect the structured error envelope emitted via + // fmt.Fprintln(os.Stdout, ...) in the error path. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + // Redirect os.Stdout to a pipe so we can read what's written there. + r, w, pipeErr := os.Pipe() + suite.Require().NoError(pipeErr) + origStdout := os.Stdout + os.Stdout = w + m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) - var out bytes.Buffer - prog = tea.NewProgram(m2, tea.WithInput(nil), tea.WithOutput(&out), + var teaOut bytes.Buffer + prog = tea.NewProgram(m2, tea.WithInput(nil), tea.WithOutput(&teaOut), tea.WithoutRenderer(), tea.WithContext(ctx)) defer func() { prog = nil }() finishedModel, runErr := prog.Run() prog.Wait() + + // Restore stdout and read captured output. + w.Close() + os.Stdout = origStdout + var stdoutBuf bytes.Buffer + _, _ = stdoutBuf.ReadFrom(r) + r.Close() + suite.Require().NoError(runErr) // Assert non-zero exit status. @@ -608,7 +624,11 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur finalOutput := finishedModel.(HasFinalOutput).FinalOutput() suite.Empty(finalOutput, "FinalOutput() must be empty when status != 0; a non-empty value means the install.status success envelope would be printed by main.go after the error") - // The in-process output (tea.Println emits to the configured output buffer - // in this test harness) must not contain an install.status success envelope. - suite.NotContains(out.String()+finalOutput, `"install.status"`, "must not emit success envelope when checksum fails") + // The error envelope must be present in the captured stdout. + stdoutStr := stdoutBuf.String() + suite.Contains(stdoutStr, `"kind":"error"`, "expected error envelope in stdout") + + // No install.status success envelope should appear anywhere. + combined := stdoutStr + finalOutput + suite.NotContains(combined, `"install.status"`, "must not emit success envelope when checksum fails") } From cecd7f2b04b7ba4d307e0ebc6a2dd1e58b15eb45 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:12:19 -0400 Subject: [PATCH 16/30] fix(install): injectable jsonWriter + assert error envelope in test 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) --- cmd/dbc/install.go | 14 +++++++++++++- cmd/dbc/install_test.go | 35 +++++++++++++---------------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 009677e3..12089f5e 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -181,6 +182,13 @@ func (progressiveInstallModel) NeedsRenderer() {} func (m progressiveInstallModel) IsJSONMode() bool { return m.jsonOutput } +func (m progressiveInstallModel) jsonOut() io.Writer { + if m.jsonWriter != nil { + return m.jsonWriter + } + return os.Stdout +} + func (m progressiveInstallModel) addEvent(event string, extra ...func(*jsonschema.InstallProgressEvent)) progressiveInstallModel { if !m.jsonOutput { return m @@ -224,6 +232,10 @@ type progressiveInstallModel struct { registryErrors error jsonEvents []string alreadyInstalledChecksum string + + // jsonWriter is where JSON error envelopes are written. It defaults to + // os.Stdout but can be overridden in tests. + jsonWriter io.Writer } type driversWithRegistryError struct { @@ -573,7 +585,7 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = 1 m.err = msg if m.jsonOutput { - fmt.Fprintln(os.Stdout, marshalEnvelope("error", jsonschema.ErrorResponse{ + fmt.Fprintln(m.jsonOut(), marshalEnvelope("error", jsonschema.ErrorResponse{ Code: "install_failed", Message: msg.Error(), })) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index a48c9646..7d548a0d 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -586,33 +586,24 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur // Reinstall with --json. The already-installed path fires, but checksum // fails because the file is gone. Run the model manually to mirror // main.go's ordering: prog.Run() first, then FinalOutput(). - // Capture os.Stdout to inspect the structured error envelope emitted via - // fmt.Fprintln(os.Stdout, ...) in the error path. + // Inject a custom jsonWriter so error envelope output is captured + // alongside the Bubble Tea output buffer. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // Redirect os.Stdout to a pipe so we can read what's written there. - r, w, pipeErr := os.Pipe() - suite.Require().NoError(pipeErr) - origStdout := os.Stdout - os.Stdout = w + var jsonBuf bytes.Buffer + // progressiveInstallModel is a value type; get a copy via type assertion + // and set the jsonWriter field before passing it to Bubble Tea. + im := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}).(progressiveInstallModel) + im.jsonWriter = &jsonBuf - m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. - GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) var teaOut bytes.Buffer - prog = tea.NewProgram(m2, tea.WithInput(nil), tea.WithOutput(&teaOut), + prog = tea.NewProgram(im, tea.WithInput(nil), tea.WithOutput(&teaOut), tea.WithoutRenderer(), tea.WithContext(ctx)) defer func() { prog = nil }() finishedModel, runErr := prog.Run() prog.Wait() - - // Restore stdout and read captured output. - w.Close() - os.Stdout = origStdout - var stdoutBuf bytes.Buffer - _, _ = stdoutBuf.ReadFrom(r) - r.Close() - suite.Require().NoError(runErr) // Assert non-zero exit status. @@ -624,11 +615,11 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur finalOutput := finishedModel.(HasFinalOutput).FinalOutput() suite.Empty(finalOutput, "FinalOutput() must be empty when status != 0; a non-empty value means the install.status success envelope would be printed by main.go after the error") - // The error envelope must be present in the captured stdout. - stdoutStr := stdoutBuf.String() - suite.Contains(stdoutStr, `"kind":"error"`, "expected error envelope in stdout") + // The error envelope must be present in the injected JSON writer. + jsonStr := jsonBuf.String() + suite.Contains(jsonStr, `"kind":"error"`, "expected error envelope in JSON output") // No install.status success envelope should appear anywhere. - combined := stdoutStr + finalOutput + combined := jsonStr + teaOut.String() + finalOutput suite.NotContains(combined, `"install.status"`, "must not emit success envelope when checksum fails") } From 52319b15db6c1694ec514d51e7da01066b5ebf83 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:18:19 -0400 Subject: [PATCH 17/30] fix(install): move jsonWriter to baseModel + decode error envelope in 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) --- cmd/dbc/install.go | 14 +++++--------- cmd/dbc/install_test.go | 19 +++++++++++++------ cmd/dbc/main.go | 6 ++++++ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 12089f5e..cb5f90c3 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -183,8 +183,8 @@ func (progressiveInstallModel) NeedsRenderer() {} func (m progressiveInstallModel) IsJSONMode() bool { return m.jsonOutput } func (m progressiveInstallModel) jsonOut() io.Writer { - if m.jsonWriter != nil { - return m.jsonWriter + if m.baseModel.jsonWriter != nil { + return m.baseModel.jsonWriter } return os.Stdout } @@ -229,13 +229,9 @@ type progressiveInstallModel struct { isLocal bool localPackagePath string - registryErrors error - jsonEvents []string - alreadyInstalledChecksum string - - // jsonWriter is where JSON error envelopes are written. It defaults to - // os.Stdout but can be overridden in tests. - jsonWriter io.Writer + registryErrors error + jsonEvents []string + alreadyInstalledChecksum string } type driversWithRegistryError struct { diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 7d548a0d..6974129c 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -592,11 +592,11 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur defer cancel() var jsonBuf bytes.Buffer - // progressiveInstallModel is a value type; get a copy via type assertion - // and set the jsonWriter field before passing it to Bubble Tea. + // Set jsonWriter on baseModel so JSON error output is captured without + // type-asserting the concrete model type. + bm := baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg, jsonWriter: &jsonBuf} im := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. - GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}).(progressiveInstallModel) - im.jsonWriter = &jsonBuf + GetModelCustom(bm).(progressiveInstallModel) var teaOut bytes.Buffer prog = tea.NewProgram(im, tea.WithInput(nil), tea.WithOutput(&teaOut), @@ -615,9 +615,16 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur finalOutput := finishedModel.(HasFinalOutput).FinalOutput() suite.Empty(finalOutput, "FinalOutput() must be empty when status != 0; a non-empty value means the install.status success envelope would be printed by main.go after the error") - // The error envelope must be present in the injected JSON writer. + // The error envelope must be present in the injected JSON writer with the + // correct kind and code. jsonStr := jsonBuf.String() - suite.Contains(jsonStr, `"kind":"error"`, "expected error envelope in JSON output") + suite.NotEmpty(jsonStr, "expected JSON output from install error path") + var errEnv jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(strings.TrimSpace(jsonStr)), &errEnv), "error output must be valid JSON: %s", jsonStr) + suite.Equal("error", errEnv.Kind, "expected kind=error") + var errPayload jsonschema.ErrorResponse + suite.Require().NoError(json.Unmarshal(errEnv.Payload, &errPayload)) + suite.Equal("install_failed", errPayload.Code, "expected install_failed error code") // No install.status success envelope should appear anywhere. combined := jsonStr + teaOut.String() + finalOutput diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index e694c74b..96b0f58d 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -17,6 +17,7 @@ package main import ( "errors" "fmt" + "io" "os" "slices" "strings" @@ -143,6 +144,11 @@ type baseModel struct { status int err error + + // jsonWriter is where JSON error/event lines are written. When nil, + // individual models fall back to os.Stdout. Tests may set this to capture + // output without type-asserting the concrete model. + jsonWriter io.Writer } func (m baseModel) Init() tea.Cmd { return nil } From 3a3a245b08ceb08d79c2c2dee43be14feb2f53e8 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:22:53 -0400 Subject: [PATCH 18/30] fix(install): route JSON errors through FinalOutput instead of direct 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) --- cmd/dbc/install.go | 15 ++++----------- cmd/dbc/install_test.go | 38 +++++++++++++++----------------------- cmd/dbc/main.go | 6 ------ 3 files changed, 19 insertions(+), 40 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index cb5f90c3..b9744f1a 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -18,7 +18,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "io/fs" "os" "path/filepath" @@ -182,13 +181,6 @@ func (progressiveInstallModel) NeedsRenderer() {} func (m progressiveInstallModel) IsJSONMode() bool { return m.jsonOutput } -func (m progressiveInstallModel) jsonOut() io.Writer { - if m.baseModel.jsonWriter != nil { - return m.baseModel.jsonWriter - } - return os.Stdout -} - func (m progressiveInstallModel) addEvent(event string, extra ...func(*jsonschema.InstallProgressEvent)) progressiveInstallModel { if !m.jsonOutput { return m @@ -232,6 +224,7 @@ type progressiveInstallModel struct { registryErrors error jsonEvents []string alreadyInstalledChecksum string + jsonErrorOutput string // JSON error envelope to emit via FinalOutput } type driversWithRegistryError struct { @@ -296,7 +289,7 @@ func (m progressiveInstallModel) isAlreadyInstalled() bool { func (m progressiveInstallModel) FinalOutput() string { if m.status != 0 { - return "" + return m.jsonErrorOutput // empty string for non-JSON errors; structured envelope for JSON mode } if m.isAlreadyInstalled() { if m.jsonOutput { @@ -581,10 +574,10 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = 1 m.err = msg if m.jsonOutput { - fmt.Fprintln(m.jsonOut(), marshalEnvelope("error", jsonschema.ErrorResponse{ + m.jsonErrorOutput = marshalEnvelope("error", jsonschema.ErrorResponse{ Code: "install_failed", Message: msg.Error(), - })) + }) return m, tea.Quit } } diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 6974129c..8df29ef4 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -586,20 +586,15 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur // Reinstall with --json. The already-installed path fires, but checksum // fails because the file is gone. Run the model manually to mirror // main.go's ordering: prog.Run() first, then FinalOutput(). - // Inject a custom jsonWriter so error envelope output is captured - // alongside the Bubble Tea output buffer. + // JSON error output is stored in model state and returned by FinalOutput(), + // so it flows through the standard tea.WithOutput capture path. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - var jsonBuf bytes.Buffer - // Set jsonWriter on baseModel so JSON error output is captured without - // type-asserting the concrete model type. - bm := baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg, jsonWriter: &jsonBuf} - im := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. - GetModelCustom(bm).(progressiveInstallModel) - + m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) var teaOut bytes.Buffer - prog = tea.NewProgram(im, tea.WithInput(nil), tea.WithOutput(&teaOut), + prog = tea.NewProgram(m2, tea.WithInput(nil), tea.WithOutput(&teaOut), tea.WithoutRenderer(), tea.WithContext(ctx)) defer func() { prog = nil }() finishedModel, runErr := prog.Run() @@ -609,24 +604,21 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur // Assert non-zero exit status. suite.Equal(1, finishedModel.(HasStatus).Status(), "expected non-zero status on checksum failure") - // FinalOutput() must return empty — this is the core regression: previously - // it emitted an install.status success envelope even when status was 1. - // This directly tests the m.status != 0 guard added to FinalOutput(). + // FinalOutput() must return the structured error envelope (not a success + // envelope). Previously it returned an install.status success payload even + // when status was 1. finalOutput := finishedModel.(HasFinalOutput).FinalOutput() - suite.Empty(finalOutput, "FinalOutput() must be empty when status != 0; a non-empty value means the install.status success envelope would be printed by main.go after the error") + suite.NotEmpty(finalOutput, "FinalOutput() must return the JSON error envelope") + suite.NotContains(finalOutput, `"install.status"`, "must not emit success envelope when checksum fails") - // The error envelope must be present in the injected JSON writer with the - // correct kind and code. - jsonStr := jsonBuf.String() - suite.NotEmpty(jsonStr, "expected JSON output from install error path") + // Decode the envelope and assert the correct kind and code. var errEnv jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(strings.TrimSpace(jsonStr)), &errEnv), "error output must be valid JSON: %s", jsonStr) - suite.Equal("error", errEnv.Kind, "expected kind=error") + suite.Require().NoError(json.Unmarshal([]byte(strings.TrimSpace(finalOutput)), &errEnv), "FinalOutput must be valid JSON: %s", finalOutput) + suite.Equal("error", errEnv.Kind, "expected kind=error in FinalOutput") var errPayload jsonschema.ErrorResponse suite.Require().NoError(json.Unmarshal(errEnv.Payload, &errPayload)) suite.Equal("install_failed", errPayload.Code, "expected install_failed error code") - // No install.status success envelope should appear anywhere. - combined := jsonStr + teaOut.String() + finalOutput - suite.NotContains(combined, `"install.status"`, "must not emit success envelope when checksum fails") + // The Bubble Tea output buffer must also not contain an install.status line. + suite.NotContains(teaOut.String()+finalOutput, `"install.status"`, "must not emit success envelope anywhere") } diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index 96b0f58d..e694c74b 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -17,7 +17,6 @@ package main import ( "errors" "fmt" - "io" "os" "slices" "strings" @@ -144,11 +143,6 @@ type baseModel struct { status int err error - - // jsonWriter is where JSON error/event lines are written. When nil, - // individual models fall back to os.Stdout. Tests may set this to capture - // output without type-asserting the concrete model. - jsonWriter io.Writer } func (m baseModel) Init() tea.Cmd { return nil } From 4fa6d72f2feb9dc30fbee0c85e42c9164c210465 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:31:33 -0400 Subject: [PATCH 19/30] fix: emit JSON FinalOutput even with --quiet; update runCmdErr harness 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) --- cmd/dbc/install_test.go | 53 ++++++++++++++------------------------ cmd/dbc/main.go | 16 +++++++----- cmd/dbc/subcommand_test.go | 18 ++++++++++--- 3 files changed, 43 insertions(+), 44 deletions(-) diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 8df29ef4..56a65fcb 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -16,18 +16,14 @@ package main import ( "archive/tar" - "bytes" "compress/gzip" - "context" "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" - "time" - tea "charm.land/bubbletea/v2" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" "github.com/columnar-tech/dbc/internal/jsonschema" @@ -584,41 +580,32 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur suite.Require().NoError(os.Remove(sharedPath)) // Reinstall with --json. The already-installed path fires, but checksum - // fails because the file is gone. Run the model manually to mirror - // main.go's ordering: prog.Run() first, then FinalOutput(). - // JSON error output is stored in model state and returned by FinalOutput(), - // so it flows through the standard tea.WithOutput capture path. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - + // fails because the file is gone. runCmdErr now appends FinalOutput() so + // the JSON error envelope is captured through the shared harness path, + // matching how main.go emits it. m2 := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) - var teaOut bytes.Buffer - prog = tea.NewProgram(m2, tea.WithInput(nil), tea.WithOutput(&teaOut), - tea.WithoutRenderer(), tea.WithContext(ctx)) - defer func() { prog = nil }() - finishedModel, runErr := prog.Run() - prog.Wait() - suite.Require().NoError(runErr) - - // Assert non-zero exit status. - suite.Equal(1, finishedModel.(HasStatus).Status(), "expected non-zero status on checksum failure") - - // FinalOutput() must return the structured error envelope (not a success - // envelope). Previously it returned an install.status success payload even - // when status was 1. - finalOutput := finishedModel.(HasFinalOutput).FinalOutput() - suite.NotEmpty(finalOutput, "FinalOutput() must return the JSON error envelope") - suite.NotContains(finalOutput, `"install.status"`, "must not emit success envelope when checksum fails") + out := suite.runCmdErr(m2) + + // The combined output must contain the structured error envelope. + suite.NotEmpty(out, "expected error output from install JSON error path") + suite.NotContains(out, `"install.status"`, "must not emit success envelope when checksum fails") // Decode the envelope and assert the correct kind and code. + // runCmdErr appends FinalOutput(), so the last non-empty line is the JSON. + lines := strings.Split(strings.TrimSpace(out), "\n") + var jsonLine string + for i := len(lines) - 1; i >= 0; i-- { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "{") { + jsonLine = strings.TrimSpace(lines[i]) + break + } + } + suite.NotEmpty(jsonLine, "expected a JSON line in output: %s", out) var errEnv jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(strings.TrimSpace(finalOutput)), &errEnv), "FinalOutput must be valid JSON: %s", finalOutput) - suite.Equal("error", errEnv.Kind, "expected kind=error in FinalOutput") + suite.Require().NoError(json.Unmarshal([]byte(jsonLine), &errEnv), "must be valid JSON: %s", jsonLine) + suite.Equal("error", errEnv.Kind, "expected kind=error") var errPayload jsonschema.ErrorResponse suite.Require().NoError(json.Unmarshal(errEnv.Payload, &errPayload)) suite.Equal("install_failed", errPayload.Code, "expected install_failed error code") - - // The Bubble Tea output buffer must also not contain an install.status line. - suite.NotContains(teaOut.String()+finalOutput, `"install.status"`, "must not emit success envelope anywhere") } diff --git a/cmd/dbc/main.go b/cmd/dbc/main.go index e694c74b..81720cea 100644 --- a/cmd/dbc/main.go +++ b/cmd/dbc/main.go @@ -333,7 +333,13 @@ func main() { suppressTerminalProbeResponses() } - if !args.Quiet { + inJSONMode := false + if jm, ok := m.(interface{ IsJSONMode() bool }); ok { + inJSONMode = jm.IsJSONMode() + } + // Always print FinalOutput in JSON mode — machine-readable payloads must + // not be suppressed by --quiet. For non-JSON mode, respect the quiet flag. + if !args.Quiet || inJSONMode { if fo, ok := m.(HasFinalOutput); ok { if output := fo.FinalOutput(); output != "" { // Use lipgloss.Println instead of fmt.Println so that @@ -346,12 +352,8 @@ func main() { if h, ok := m.(HasStatus); ok { // Suppress plaintext error formatting when the model already emitted a - // structured JSON error envelope to stdout (JSON mode). Printing - // formatErr after a JSON envelope would corrupt NDJSON consumers. - inJSONMode := false - if jm, ok := m.(interface{ IsJSONMode() bool }); ok { - inJSONMode = jm.IsJSONMode() - } + // structured JSON error envelope (JSON mode). Printing formatErr after + // a JSON envelope would corrupt NDJSON consumers. if err := h.Err(); err != nil && !inJSONMode { lipgloss.Println(formatErr(err)) } diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index ec20fc4b..2eb0f01f 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -156,12 +156,22 @@ func (suite *SubcommandTestSuite) runCmdErr(m tea.Model) string { var err error m, err = prog.Run() + prog.Wait() suite.Require().NoError(err) suite.Equal(1, m.(HasStatus).Status(), "The subcommand did not exit with a status of 1 as expected.") - err = m.(HasStatus).Err() - suite.Require().Error(err, "Expected an error from the subcommand") - out.WriteString("\n" + formatErr(err)) - return ansi.Strip(out.String()) + + // Append FinalOutput so JSON error envelopes (stored in model state) are + // included in the returned string, mirroring main.go's behaviour. + var extra string + if fo, ok := m.(HasFinalOutput); ok { + extra = fo.FinalOutput() + } + + // For non-JSON errors, also append the formatted plaintext error. + if cmdErr := m.(HasStatus).Err(); cmdErr != nil && extra == "" { + extra = "\n" + formatErr(cmdErr) + } + return ansi.Strip(out.String() + extra) } func (suite *SubcommandTestSuite) runCmd(m tea.Model) string { From c6b8fc724b81dad9a66fa66797d5766e3afc9e8d Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:35:53 -0400 Subject: [PATCH 20/30] fix: add IsJSONMode() to all JSON-producing models; fix runCmdErr harness 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) --- cmd/dbc/add.go | 2 ++ cmd/dbc/info.go | 2 ++ cmd/dbc/init.go | 2 ++ cmd/dbc/remove.go | 2 ++ cmd/dbc/search.go | 2 ++ cmd/dbc/subcommand_test.go | 10 +++++++--- cmd/dbc/uninstall.go | 2 ++ 7 files changed, 19 insertions(+), 3 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index 0210874e..4569cb40 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -251,6 +251,8 @@ 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 { return "" diff --git a/cmd/dbc/info.go b/cmd/dbc/info.go index 25d7aed3..afb7144d 100644 --- a/cmd/dbc/info.go +++ b/cmd/dbc/info.go @@ -135,6 +135,8 @@ func (m infoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } +func (m infoModel) IsJSONMode() bool { return m.jsonOutput } + func (m infoModel) FinalOutput() string { if m.jsonOutput { return driverInfoJSON(m.drv) diff --git a/cmd/dbc/init.go b/cmd/dbc/init.go index a0c3b014..2cd222a3 100644 --- a/cmd/dbc/init.go +++ b/cmd/dbc/init.go @@ -90,6 +90,8 @@ func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m initModel) IsJSONMode() bool { return m.jsonOutput } + func (m initModel) FinalOutput() string { if m.status != 0 { return "" diff --git a/cmd/dbc/remove.go b/cmd/dbc/remove.go index 55e3d26b..a14feb47 100644 --- a/cmd/dbc/remove.go +++ b/cmd/dbc/remove.go @@ -140,6 +140,8 @@ func (m removeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } +func (m removeModel) IsJSONMode() bool { return m.jsonOutput } + func (m removeModel) FinalOutput() string { if m.status != 0 { return "" diff --git a/cmd/dbc/search.go b/cmd/dbc/search.go index dab664d6..7e5c945e 100644 --- a/cmd/dbc/search.go +++ b/cmd/dbc/search.go @@ -324,6 +324,8 @@ func getInstalled(driver dbc.Driver, cfg map[config.ConfigLevel]config.Config) ( return installed, installedVerbose } +func (m searchModel) IsJSONMode() bool { return m.outputJson } + func (m searchModel) FinalOutput() string { var output string diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index 2eb0f01f..f167309e 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -167,9 +167,13 @@ func (suite *SubcommandTestSuite) runCmdErr(m tea.Model) string { extra = fo.FinalOutput() } - // For non-JSON errors, also append the formatted plaintext error. - if cmdErr := m.(HasStatus).Err(); cmdErr != nil && extra == "" { - extra = "\n" + formatErr(cmdErr) + // Mirror main.go: suppress plaintext error formatting in JSON mode. + inJSONMode := false + if jm, ok := m.(interface{ IsJSONMode() bool }); ok { + inJSONMode = jm.IsJSONMode() + } + if cmdErr := m.(HasStatus).Err(); cmdErr != nil && !inJSONMode { + extra += "\n" + formatErr(cmdErr) } return ansi.Strip(out.String() + extra) } diff --git a/cmd/dbc/uninstall.go b/cmd/dbc/uninstall.go index ed106ca6..631e7863 100644 --- a/cmd/dbc/uninstall.go +++ b/cmd/dbc/uninstall.go @@ -89,6 +89,8 @@ func (m uninstallModel) Init() tea.Cmd { } } +func (m uninstallModel) IsJSONMode() bool { return m.jsonOutput } + func (m uninstallModel) FinalOutput() string { if m.status != 0 { return "" From 80d78cf8ece72343a6c3dfe6cab6a027aea8cf2d Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:40:31 -0400 Subject: [PATCH 21/30] fix: emit JSON error envelopes on failure for all JSON-mode commands 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) --- cmd/dbc/add.go | 6 ++++++ cmd/dbc/info.go | 13 +++++++++++++ cmd/dbc/init.go | 6 ++++++ cmd/dbc/remove.go | 6 ++++++ cmd/dbc/search.go | 10 ++++++++++ 5 files changed, 41 insertions(+) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index 4569cb40..d2acc60d 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -255,6 +255,12 @@ 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 { diff --git a/cmd/dbc/info.go b/cmd/dbc/info.go index afb7144d..bfd4e7e6 100644 --- a/cmd/dbc/info.go +++ b/cmd/dbc/info.go @@ -128,6 +128,10 @@ func (m infoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dbc.Driver: m.drv = msg return m, tea.Quit + case error: + m.status = 1 + m.err = msg + return m, tea.Quit default: bm, cmd := m.baseModel.Update(msg) m.baseModel = bm.(baseModel) @@ -138,6 +142,15 @@ func (m infoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m infoModel) IsJSONMode() bool { return m.jsonOutput } func (m infoModel) FinalOutput() string { + if m.status != 0 { + if m.jsonOutput { + return marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "info_failed", + Message: m.err.Error(), + }) + } + return "" + } if m.jsonOutput { return driverInfoJSON(m.drv) } diff --git a/cmd/dbc/init.go b/cmd/dbc/init.go index 2cd222a3..1a324c9e 100644 --- a/cmd/dbc/init.go +++ b/cmd/dbc/init.go @@ -94,6 +94,12 @@ func (m initModel) IsJSONMode() bool { return m.jsonOutput } func (m initModel) FinalOutput() string { if m.status != 0 { + if m.jsonOutput { + return marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "init_failed", + Message: m.err.Error(), + }) + } return "" } if m.jsonOutput { diff --git a/cmd/dbc/remove.go b/cmd/dbc/remove.go index a14feb47..a4a0b52f 100644 --- a/cmd/dbc/remove.go +++ b/cmd/dbc/remove.go @@ -144,6 +144,12 @@ func (m removeModel) IsJSONMode() bool { return m.jsonOutput } func (m removeModel) FinalOutput() string { if m.status != 0 { + if m.jsonOutput { + return marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "remove_failed", + Message: m.err.Error(), + }) + } return "" } if m.jsonOutput { diff --git a/cmd/dbc/search.go b/cmd/dbc/search.go index 7e5c945e..ca98e796 100644 --- a/cmd/dbc/search.go +++ b/cmd/dbc/search.go @@ -327,6 +327,16 @@ func getInstalled(driver dbc.Driver, cfg map[config.ConfigLevel]config.Config) ( func (m searchModel) IsJSONMode() bool { return m.outputJson } func (m searchModel) FinalOutput() string { + if m.status != 0 { + if m.outputJson { + return marshalEnvelope("error", jsonschema.ErrorResponse{ + Code: "search_failed", + Message: m.err.Error(), + }) + } + return "" + } + var output string // Display driver list first From 476743a912989d2fc48f8aba5d93b0b3464e0e51 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:45:37 -0400 Subject: [PATCH 22/30] test: add JSON error-path regression tests for info, search, add, init, 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) --- cmd/dbc/add_test.go | 17 +++++++++++++++++ cmd/dbc/info_test.go | 10 ++++++++++ cmd/dbc/init_test.go | 15 +++++++++++++++ cmd/dbc/remove_test.go | 20 ++++++++++++++++++++ cmd/dbc/search_test.go | 10 ++++++++++ cmd/dbc/subcommand_test.go | 22 ++++++++++++++++++++++ 6 files changed, 94 insertions(+) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index be96152c..ee90cb9f 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -495,3 +495,20 @@ 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) + + // Add a driver that doesn't exist in the registry using a failing registry + failingRegistry := func() ([]dbc.Driver, error) { + return nil, fmt.Errorf("registry unreachable") + } + m = AddCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + Driver: []string{"nonexistent-driver"}, + Json: true, + }.GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmdErr(m) + suite.assertJSONErrorEnvelope(out, "add_failed") +} diff --git a/cmd/dbc/info_test.go b/cmd/dbc/info_test.go index d07bc641..c9eecf42 100644 --- a/cmd/dbc/info_test.go +++ b/cmd/dbc/info_test.go @@ -114,3 +114,13 @@ func (suite *SubcommandTestSuite) TestInfo_JSON() { suite.NotEmpty(info.Title) suite.NotEmpty(info.Packages) } + +func (suite *SubcommandTestSuite) TestInfo_JSON_DriverNotFound() { + failingRegistry := func() ([]dbc.Driver, error) { + return nil, fmt.Errorf("network unreachable") + } + m := InfoCmd{Driver: "nonexistent-driver", Json: true}. + GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmdErr(m) + suite.assertJSONErrorEnvelope(out, "info_failed") +} diff --git a/cmd/dbc/init_test.go b/cmd/dbc/init_test.go index 98cf0fb7..82fe027b 100644 --- a/cmd/dbc/init_test.go +++ b/cmd/dbc/init_test.go @@ -20,6 +20,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" @@ -111,3 +112,17 @@ func (suite *SubcommandTestSuite) TestInit_JSON() { suite.True(resp.Created) suite.NotEmpty(resp.DriverListPath) } + +func (suite *SubcommandTestSuite) TestInit_JSON_Failure() { + if runtime.GOOS == "windows" { + suite.T().Skip("chmod not reliable on Windows") + } + // Use a non-writable directory to force a write failure. + readonlyDir := suite.T().TempDir() + suite.Require().NoError(os.Chmod(readonlyDir, 0o555)) + defer os.Chmod(readonlyDir, 0o755) //nolint:errcheck + + m := InitCmd{Path: filepath.Join(readonlyDir, "dbc.toml"), Json: true}.GetModel() + out := suite.runCmdErr(m) + suite.assertJSONErrorEnvelope(out, "init_failed") +} diff --git a/cmd/dbc/remove_test.go b/cmd/dbc/remove_test.go index 5a001875..f6d8df31 100644 --- a/cmd/dbc/remove_test.go +++ b/cmd/dbc/remove_test.go @@ -110,3 +110,23 @@ func (suite *SubcommandTestSuite) TestRemove_JSON() { suite.Equal("test-driver-1", resp.Driver.Name) suite.NotEmpty(resp.DriverListPath) } + +func (suite *SubcommandTestSuite) TestRemove_JSON_DriverNotFound() { + m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() + suite.runCmd(m) + + // Add a driver so the list isn't empty, then try to remove a nonexistent one. + m = AddCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + Driver: []string{"test-driver-1"}, + }.GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + suite.runCmd(m) + + m = RemoveCmd{ + Path: filepath.Join(suite.tempdir, "dbc.toml"), + Driver: "nonexistent-driver", + Json: true, + }.GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmdErr(m) + suite.assertJSONErrorEnvelope(out, "remove_failed") +} diff --git a/cmd/dbc/search_test.go b/cmd/dbc/search_test.go index c745d32e..cb28b57d 100644 --- a/cmd/dbc/search_test.go +++ b/cmd/dbc/search_test.go @@ -404,3 +404,13 @@ func (suite *SubcommandTestSuite) TestSearch_JSON_Verbose() { suite.Equal("test-driver-1", result.Drivers[0].Driver) suite.NotEmpty(result.Drivers[0].License) } + +func (suite *SubcommandTestSuite) TestSearch_JSON_CompleteRegistryFailure() { + completeFailingRegistry := func() ([]dbc.Driver, error) { + return nil, fmt.Errorf("registry unreachable") + } + m := SearchCmd{Json: true}.GetModelCustom( + baseModel{getDriverRegistry: completeFailingRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmdErr(m) + suite.assertJSONErrorEnvelope(out, "search_failed") +} diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index f167309e..85903375 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -17,6 +17,7 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" "io/fs" "net/url" @@ -30,6 +31,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/columnar-tech/dbc" "github.com/columnar-tech/dbc/config" + "github.com/columnar-tech/dbc/internal/jsonschema" "github.com/go-faster/yaml" "github.com/stretchr/testify/suite" ) @@ -251,6 +253,26 @@ func TestSubcommandsSystem(t *testing.T) { suite.Run(t, &SubcommandTestSuite{configLevel: config.ConfigSystem}) } +// assertJSONErrorEnvelope parses the last JSON line in output and asserts +// it is an error envelope with the expected error code. +func (suite *SubcommandTestSuite) assertJSONErrorEnvelope(output, expectedCode string) { + lines := strings.Split(strings.TrimSpace(output), "\n") + var jsonLine string + for i := len(lines) - 1; i >= 0; i-- { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "{") { + jsonLine = strings.TrimSpace(lines[i]) + break + } + } + suite.Require().NotEmpty(jsonLine, "expected a JSON line in output: %s", output) + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(jsonLine), &env), "must be valid JSON: %s", jsonLine) + suite.Equal("error", env.Kind, "expected kind=error") + var errPayload jsonschema.ErrorResponse + suite.Require().NoError(json.Unmarshal(env.Payload, &errPayload)) + suite.Equal(expectedCode, errPayload.Code, "expected error code %q, got %q", expectedCode, errPayload.Code) +} + func (suite *SubcommandTestSuite) driverIsInstalled(path string, checkShared bool) { cfg := config.Get()[suite.configLevel] From a678f13bbfdc42c9d69da3fe2a3702c7ad770630 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:48:24 -0400 Subject: [PATCH 23/30] fix(test): use pre-existing file instead of chmod to trigger init failure 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) --- cmd/dbc/init_test.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/cmd/dbc/init_test.go b/cmd/dbc/init_test.go index 82fe027b..d0b36bc1 100644 --- a/cmd/dbc/init_test.go +++ b/cmd/dbc/init_test.go @@ -20,7 +20,6 @@ import ( "encoding/json" "os" "path/filepath" - "runtime" "testing" "time" @@ -114,15 +113,11 @@ func (suite *SubcommandTestSuite) TestInit_JSON() { } func (suite *SubcommandTestSuite) TestInit_JSON_Failure() { - if runtime.GOOS == "windows" { - suite.T().Skip("chmod not reliable on Windows") - } - // Use a non-writable directory to force a write failure. - readonlyDir := suite.T().TempDir() - suite.Require().NoError(os.Chmod(readonlyDir, 0o555)) - defer os.Chmod(readonlyDir, 0o755) //nolint:errcheck + // Force a failure by trying to init a file that already exists. + existingFile := filepath.Join(suite.T().TempDir(), "dbc.toml") + suite.Require().NoError(os.WriteFile(existingFile, []byte(""), 0o644)) - m := InitCmd{Path: filepath.Join(readonlyDir, "dbc.toml"), Json: true}.GetModel() + m := InitCmd{Path: existingFile, Json: true}.GetModel() out := suite.runCmdErr(m) suite.assertJSONErrorEnvelope(out, "init_failed") } From ab472bf9044bb8dd7a7b6ee83c868fc8c07f9068 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 13:54:09 -0400 Subject: [PATCH 24/30] test: stricter JSON error assertion; use real registry for not-found 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) --- cmd/dbc/add_test.go | 8 +++----- cmd/dbc/info_test.go | 7 +++---- cmd/dbc/subcommand_test.go | 28 ++++++++++++++++------------ 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index ee90cb9f..782ca104 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -500,15 +500,13 @@ func (suite *SubcommandTestSuite) TestAdd_JSON_DriverNotFound() { m := InitCmd{Path: filepath.Join(suite.tempdir, "dbc.toml")}.GetModel() suite.runCmd(m) - // Add a driver that doesn't exist in the registry using a failing registry - failingRegistry := func() ([]dbc.Driver, error) { - return nil, fmt.Errorf("registry unreachable") - } + // 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: failingRegistry, downloadPkg: downloadTestPkg}) + }.GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) out := suite.runCmdErr(m) suite.assertJSONErrorEnvelope(out, "add_failed") } diff --git a/cmd/dbc/info_test.go b/cmd/dbc/info_test.go index c9eecf42..42968d6c 100644 --- a/cmd/dbc/info_test.go +++ b/cmd/dbc/info_test.go @@ -116,11 +116,10 @@ func (suite *SubcommandTestSuite) TestInfo_JSON() { } func (suite *SubcommandTestSuite) TestInfo_JSON_DriverNotFound() { - failingRegistry := func() ([]dbc.Driver, error) { - return nil, fmt.Errorf("network unreachable") - } + // Use the real test registry but request a driver that doesn't exist, + // exercising the findDriver path rather than the registry-failure path. m := InfoCmd{Driver: "nonexistent-driver", Json: true}. - GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg}) + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) out := suite.runCmdErr(m) suite.assertJSONErrorEnvelope(out, "info_failed") } diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index 85903375..25baefcd 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -253,23 +253,27 @@ func TestSubcommandsSystem(t *testing.T) { suite.Run(t, &SubcommandTestSuite{configLevel: config.ConfigSystem}) } -// assertJSONErrorEnvelope parses the last JSON line in output and asserts -// it is an error envelope with the expected error code. +// assertJSONErrorEnvelope asserts that every non-empty line in output is valid +// JSON, and that the last JSON envelope has kind=="error" and the expected code. +// Any non-JSON line causes the assertion to fail so regressions that mix +// plaintext and structured output are caught immediately. func (suite *SubcommandTestSuite) assertJSONErrorEnvelope(output, expectedCode string) { lines := strings.Split(strings.TrimSpace(output), "\n") - var jsonLine string - for i := len(lines) - 1; i >= 0; i-- { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "{") { - jsonLine = strings.TrimSpace(lines[i]) - break + var lastEnv jsonschema.Envelope + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue } + suite.Require().True(strings.HasPrefix(line, "{"), + "expected all non-empty output lines to be JSON objects, got: %q (full output: %s)", line, output) + suite.Require().NoError(json.Unmarshal([]byte(line), &lastEnv), + "line must be valid JSON: %q", line) } - suite.Require().NotEmpty(jsonLine, "expected a JSON line in output: %s", output) - var env jsonschema.Envelope - suite.Require().NoError(json.Unmarshal([]byte(jsonLine), &env), "must be valid JSON: %s", jsonLine) - suite.Equal("error", env.Kind, "expected kind=error") + suite.Require().NotEmpty(lastEnv.Kind, "expected at least one JSON envelope in output: %s", output) + suite.Equal("error", lastEnv.Kind, "expected kind=error (full output: %s)", output) var errPayload jsonschema.ErrorResponse - suite.Require().NoError(json.Unmarshal(env.Payload, &errPayload)) + suite.Require().NoError(json.Unmarshal(lastEnv.Payload, &errPayload)) suite.Equal(expectedCode, errPayload.Code, "expected error code %q, got %q", expectedCode, errPayload.Code) } From 3e610a2ebef9d72e92c115ba8df5ec749c48307a Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 14:16:22 -0400 Subject: [PATCH 25/30] test: add JSON registry-failure tests for info and add commands 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) --- cmd/dbc/add_test.go | 17 +++++++++++++++++ cmd/dbc/info_test.go | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index 782ca104..db98dc73 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -510,3 +510,20 @@ func (suite *SubcommandTestSuite) TestAdd_JSON_DriverNotFound() { 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. + 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") +} diff --git a/cmd/dbc/info_test.go b/cmd/dbc/info_test.go index 42968d6c..51b0aa54 100644 --- a/cmd/dbc/info_test.go +++ b/cmd/dbc/info_test.go @@ -123,3 +123,14 @@ func (suite *SubcommandTestSuite) TestInfo_JSON_DriverNotFound() { out := suite.runCmdErr(m) suite.assertJSONErrorEnvelope(out, "info_failed") } + +func (suite *SubcommandTestSuite) TestInfo_JSON_RegistryFailure() { + // Verify that a complete registry failure also emits a structured error envelope. + failingRegistry := func() ([]dbc.Driver, error) { + return nil, fmt.Errorf("network unreachable") + } + m := InfoCmd{Driver: "test-driver-1", Json: true}. + GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmdErr(m) + suite.assertJSONErrorEnvelope(out, "info_failed") +} From c61d416e34078a1c0ee2dd15a10dd9932572caa1 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Sat, 25 Apr 2026 14:20:54 -0400 Subject: [PATCH 26/30] test: assert error message content in JSON registry-failure tests 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) --- cmd/dbc/add_test.go | 5 +++-- cmd/dbc/info_test.go | 5 +++-- cmd/dbc/subcommand_test.go | 6 +++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index db98dc73..772dda73 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -515,7 +515,8 @@ 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. + // 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") } @@ -525,5 +526,5 @@ func (suite *SubcommandTestSuite) TestAdd_JSON_RegistryFailure() { Json: true, }.GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg}) out := suite.runCmdErr(m) - suite.assertJSONErrorEnvelope(out, "add_failed") + suite.assertJSONErrorEnvelope(out, "add_failed", "network unreachable") } diff --git a/cmd/dbc/info_test.go b/cmd/dbc/info_test.go index 51b0aa54..b04e65f5 100644 --- a/cmd/dbc/info_test.go +++ b/cmd/dbc/info_test.go @@ -125,12 +125,13 @@ func (suite *SubcommandTestSuite) TestInfo_JSON_DriverNotFound() { } func (suite *SubcommandTestSuite) TestInfo_JSON_RegistryFailure() { - // Verify that a complete registry failure also emits a structured error envelope. + // 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 := InfoCmd{Driver: "test-driver-1", Json: true}. GetModelCustom(baseModel{getDriverRegistry: failingRegistry, downloadPkg: downloadTestPkg}) out := suite.runCmdErr(m) - suite.assertJSONErrorEnvelope(out, "info_failed") + suite.assertJSONErrorEnvelope(out, "info_failed", "network unreachable") } diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index 25baefcd..acf47e0b 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -257,7 +257,8 @@ func TestSubcommandsSystem(t *testing.T) { // JSON, and that the last JSON envelope has kind=="error" and the expected code. // Any non-JSON line causes the assertion to fail so regressions that mix // plaintext and structured output are caught immediately. -func (suite *SubcommandTestSuite) assertJSONErrorEnvelope(output, expectedCode string) { +// Optional msgSubstrings are checked against the error payload's message field. +func (suite *SubcommandTestSuite) assertJSONErrorEnvelope(output, expectedCode string, msgSubstrings ...string) { lines := strings.Split(strings.TrimSpace(output), "\n") var lastEnv jsonschema.Envelope for _, line := range lines { @@ -275,6 +276,9 @@ func (suite *SubcommandTestSuite) assertJSONErrorEnvelope(output, expectedCode s var errPayload jsonschema.ErrorResponse suite.Require().NoError(json.Unmarshal(lastEnv.Payload, &errPayload)) suite.Equal(expectedCode, errPayload.Code, "expected error code %q, got %q", expectedCode, errPayload.Code) + for _, sub := range msgSubstrings { + suite.Contains(errPayload.Message, sub, "expected error message to contain %q", sub) + } } func (suite *SubcommandTestSuite) driverIsInstalled(path string, checkShared bool) { From 248c1518ce1a4d028224ee9906672980f9c2ca77 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Mon, 27 Apr 2026 17:25:09 -0400 Subject: [PATCH 27/30] update text description from review comment --- cmd/dbc/add.go | 16 ++++++++-------- cmd/dbc/auth.go | 2 +- cmd/dbc/completions/dbc.zsh | 6 +++--- cmd/dbc/init.go | 2 +- cmd/dbc/install.go | 2 +- cmd/dbc/remove.go | 8 ++++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index d2acc60d..c1f66b92 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -60,7 +60,7 @@ 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:"Output JSON instead of plaintext"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` } func (c AddCmd) GetModelCustom(baseModel baseModel) tea.Model { @@ -84,19 +84,19 @@ func (c AddCmd) GetModel() tea.Model { } type addDoneMsg struct { - result string + result string resolvedPath string } type addModel struct { baseModel - Driver []string - Path string - Pre bool - jsonOutput bool - list DriversList - result string + Driver []string + Path string + Pre bool + jsonOutput bool + list DriversList + result string resolvedPath string } diff --git a/cmd/dbc/auth.go b/cmd/dbc/auth.go index 7770ebea..cf0870be 100644 --- a/cmd/dbc/auth.go +++ b/cmd/dbc/auth.go @@ -58,7 +58,7 @@ type LoginCmd struct { RegistryURL string `arg:"positional" help:"URL of the driver registry to authenticate with [default: https://dbc-cdn-private.columnar.tech]"` ClientID string `arg:"env:OAUTH_CLIENT_ID" help:"OAuth Client ID (can also be set via DBC_OAUTH_CLIENT_ID)"` ApiKey string `arg:"--api-key" help:"Authenticate using an API key instead of OAuth (use '-' to read from stdin)"` - Json bool `arg:"--json" help:"Output JSON instead of plaintext"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` } func (l LoginCmd) GetModelCustom(baseModel baseModel) tea.Model { diff --git a/cmd/dbc/completions/dbc.zsh b/cmd/dbc/completions/dbc.zsh index 2fd7d1e7..59d5d278 100644 --- a/cmd/dbc/completions/dbc.zsh +++ b/cmd/dbc/completions/dbc.zsh @@ -94,7 +94,7 @@ function _dbc_init_completions { _arguments \ '(--help)-h[Help]' \ '(-h)--help[Help]' \ - '--json[Output JSON instead of plaintext]' \ + '--json[Print output as JSON instead of plaintext]' \ ':file to create:_files -g \*.toml' } @@ -103,7 +103,7 @@ function _dbc_add_completions { '(--help)-h[Help]' \ '(-h)--help[Help]' \ '--pre[Allow pre-release versions implicitly]' \ - '--json[Output JSON instead of plaintext]' \ + '--json[Print output as JSON instead of plaintext]' \ '(-p)--path[driver list to add to]: :_files -g \*.toml' \ '(--path)-p[driver list to add to]: :_files -g \*.toml' \ ':driver name: ' @@ -150,7 +150,7 @@ function _dbc_remove_completions { _arguments \ '(--help)-h[Help]' \ '(-h)--help[Help]' \ - '--json[Output JSON instead of plaintext]' \ + '--json[Print output as JSON instead of plaintext]' \ '(-p)--path[driver list to remove from]: :_files -g \*.toml' \ '(--path)-p[driver list to remove from]: :_files -g \*.toml' \ ':driver name: ' diff --git a/cmd/dbc/init.go b/cmd/dbc/init.go index 1a324c9e..6ccf5886 100644 --- a/cmd/dbc/init.go +++ b/cmd/dbc/init.go @@ -27,7 +27,7 @@ import ( type InitCmd struct { Path string `arg:"positional" default:"./dbc.toml" help:"File to create"` - Json bool `arg:"--json" help:"Output JSON instead of plaintext"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` } func (c InitCmd) GetModel() tea.Model { diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index b9744f1a..9ec80293 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -66,7 +66,7 @@ type InstallCmd struct { // URI url.URL `arg:"-u" placeholder:"URL" help:"Base URL for fetching drivers"` Driver string `arg:"positional,required" help:"Driver to install, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` - Json bool `arg:"--json" help:"Output JSON instead of plaintext"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` Pre bool `arg:"--pre" help:"Allow implicit installation of pre-release versions"` InsecureNoChecksum bool `arg:"--insecure-no-checksum" help:"Skip sha256 checksum recording (not recommended)"` diff --git a/cmd/dbc/remove.go b/cmd/dbc/remove.go index a4a0b52f..91329de3 100644 --- a/cmd/dbc/remove.go +++ b/cmd/dbc/remove.go @@ -31,7 +31,7 @@ import ( type RemoveCmd struct { Driver string `arg:"positional,required" help:"Driver to remove"` Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to remove from"` - Json bool `arg:"--json" help:"Output JSON instead of plaintext"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` } func (c RemoveCmd) GetModelCustom(baseModel baseModel) tea.Model { @@ -53,7 +53,7 @@ func (c RemoveCmd) GetModel() tea.Model { } type removeDoneMsg struct { - result string + result string resolvedPath string } @@ -64,8 +64,8 @@ type removeModel struct { Path string jsonOutput bool - list DriversList - result string + list DriversList + result string resolvedPath string } From 1f1cc72439dc12bc09edc14820e1e731d9f943fb Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Tue, 28 Apr 2026 13:28:33 -0400 Subject: [PATCH 28/30] updates from comments --- cmd/dbc/add.go | 15 ++++++--- cmd/dbc/add_test.go | 30 ++++++++++++++++++ cmd/dbc/completions/dbc.bash | 4 +-- cmd/dbc/completions/dbc.fish | 3 ++ cmd/dbc/completions/dbc.zsh | 5 ++- cmd/dbc/install.go | 59 ++++++++++++++++++------------------ cmd/dbc/install_test.go | 2 +- cmd/dbc/subcommand_test.go | 12 ++++++++ cmd/dbc/sync.go | 53 ++++++++++++++++++++------------ cmd/dbc/sync_test.go | 28 +++++++++++++++++ 10 files changed, 155 insertions(+), 56 deletions(-) diff --git a/cmd/dbc/add.go b/cmd/dbc/add.go index c1f66b92..714428b7 100644 --- a/cmd/dbc/add.go +++ b/cmd/dbc/add.go @@ -15,6 +15,7 @@ package main import ( + "bytes" "encoding/json" "errors" "fmt" @@ -34,14 +35,20 @@ import ( var msgStyle = lipgloss.NewStyle().Faint(true) func marshalEnvelope(kind string, payload any) string { - payloadBytes, _ := json.Marshal(payload) + 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(payloadBytes), + Payload: json.RawMessage(b.Bytes()), } - out, _ := json.Marshal(env) - return string(out) + + b.Reset() + enc.Encode(env) + return b.String() } func driverListPath(path string) (string, error) { diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index 772dda73..e0965c87 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -454,6 +454,36 @@ func (suite *SubcommandTestSuite) TestAdd_JSON() { 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 we aren't escaping the > character in the JSON output + suite.Equal(string(env.Payload), `{"driver_list_path":"`+filepath.Join(suite.tempdir, "dbc.toml")+ + `","drivers":[{"name":"test-driver-1","version_constraint":">=1.0.0"}]}`) + + 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) diff --git a/cmd/dbc/completions/dbc.bash b/cmd/dbc/completions/dbc.bash index 2ecf47ac..45ae23a2 100644 --- a/cmd/dbc/completions/dbc.bash +++ b/cmd/dbc/completions/dbc.bash @@ -69,7 +69,7 @@ _dbc_install_completions() { esac if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--json --no-verify --level -l --pre" -- "$cur")) + COMPREPLY=($(compgen -W "--json --json-stream-progress --no-verify --level -l --pre" -- "$cur")) return 0 fi @@ -162,7 +162,7 @@ _dbc_sync_completions() { esac if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "-h --level -l --path -p --no-verify" -- "$cur")) + COMPREPLY=($(compgen -W "-h --level -l --path -p --no-verify --json --json-stream-progress" -- "$cur")) return 0 fi diff --git a/cmd/dbc/completions/dbc.fish b/cmd/dbc/completions/dbc.fish index 1b6d1eb4..95d0308a 100644 --- a/cmd/dbc/completions/dbc.fish +++ b/cmd/dbc/completions/dbc.fish @@ -44,6 +44,7 @@ complete -f -c dbc -n '__fish_dbc_needs_command' -a 'auth' -d 'Authenticate with complete -f -c dbc -n '__fish_dbc_using_subcommand install' -s h -d 'Show Help' complete -f -c dbc -n '__fish_dbc_using_subcommand install' -l help -d 'Show Help' complete -f -c dbc -n '__fish_dbc_using_subcommand install' -l json -d 'Print output as JSON instead of plaintext' +complete -f -c dbc -n '__fish_dbc_using_subcommand install' -l json-stream-progress -d 'Stream progress events as JSON lines (implies --json)' complete -f -c dbc -n '__fish_dbc_using_subcommand install' -l no-verify -d 'Do not verify the driver after installation' complete -f -c dbc -n '__fish_dbc_using_subcommand install' -l pre -d 'Allow implicit installation of pre-release versions' complete -f -c dbc -n '__fish_dbc_using_subcommand install' -l level -s l -d 'Installation level' -xa 'user system' @@ -69,6 +70,8 @@ complete -f -c dbc -n '__fish_dbc_using_subcommand sync' -l help -d 'Help' complete -f -c dbc -n '__fish_dbc_using_subcommand sync' -l level -s l -d 'Installation level' -xa 'user system' complete -c dbc -n '__fish_dbc_using_subcommand sync' -l path -s p -r -F -a '*.toml' -d 'Driver list to sync' complete -f -c dbc -n '__fish_dbc_using_subcommand sync' -l no-verify -d 'Do not verify the driver after installation' +complete -f -c dbc -n '__fish_dbc_using_subcommand sync' -l json -d 'Print output as JSON instead of plaintext' +complete -f -c dbc -n '__fish_dbc_using_subcommand sync' -l json-stream-progress -d 'Stream progress events as JSON lines (implies --json)' # search subcommand complete -f -c dbc -n '__fish_dbc_using_subcommand search' -s h -d 'Help' diff --git a/cmd/dbc/completions/dbc.zsh b/cmd/dbc/completions/dbc.zsh index 59d5d278..5b5f3b7c 100644 --- a/cmd/dbc/completions/dbc.zsh +++ b/cmd/dbc/completions/dbc.zsh @@ -76,6 +76,7 @@ function _dbc_install_completions { '(-h)--help[Help]' \ '--no-verify[do not verify the driver after installation]' \ '--json[Print output as JSON instead of plaintext]' \ + '--json-stream-progress[Stream progress events as JSON lines (implies --json)]' \ '--pre[Allow implicit installation of pre-release versions]' \ '(-l)--level[installation level]: :(user system)' \ '(--level)-l[installation level]: :(user system)' \ @@ -117,7 +118,9 @@ function _dbc_sync_completions { '(--level)-l[installation level]: :(user system)' \ '(-p)--path[driver list to add to]: :_files -g \*.toml' \ '(--path)-p[driver list to add to]: :_files -g \*.toml' \ - '--no-verify[do not verify the driver after installation]' + '--no-verify[do not verify the driver after installation]' \ + '--json[Print output as JSON instead of plaintext]' \ + '--json-stream-progress[Stream progress events as JSON lines (implies --json)]' } function _dbc_search_completions { diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index 9ec80293..e94d095c 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -67,6 +68,7 @@ type InstallCmd struct { Driver string `arg:"positional,required" help:"Driver to install, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` + JsonStreamProgress bool `arg:"--json-stream-progress" help:"Stream progress events as JSON lines (implies --json)"` NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` Pre bool `arg:"--pre" help:"Allow implicit installation of pre-release versions"` InsecureNoChecksum bool `arg:"--insecure-no-checksum" help:"Skip sha256 checksum recording (not recommended)"` @@ -89,7 +91,8 @@ func (c InstallCmd) GetModelCustom(baseModel baseModel) tea.Model { return progressiveInstallModel{ Driver: c.Driver, NoVerify: c.NoVerify, - jsonOutput: c.Json, + jsonOutput: c.Json || c.JsonStreamProgress, + jsonStreamProgress: c.JsonStreamProgress, Pre: c.Pre, insecureNoChecksum: c.InsecureNoChecksum, spinner: s, @@ -181,8 +184,21 @@ func (progressiveInstallModel) NeedsRenderer() {} func (m progressiveInstallModel) IsJSONMode() bool { return m.jsonOutput } +func (m progressiveInstallModel) WithJSONWriter(w io.Writer) tea.Model { + m.jsonOut = w + return m +} + +func (m progressiveInstallModel) emitJSON(kind string, payload any) { + out := m.jsonOut + if out == nil { + out = os.Stdout + } + fmt.Fprintln(out, marshalEnvelope(kind, payload)) +} + func (m progressiveInstallModel) addEvent(event string, extra ...func(*jsonschema.InstallProgressEvent)) progressiveInstallModel { - if !m.jsonOutput { + if !m.jsonStreamProgress { return m } evt := jsonschema.InstallProgressEvent{ @@ -192,19 +208,20 @@ func (m progressiveInstallModel) addEvent(event string, extra ...func(*jsonschem for _, fn := range extra { fn(&evt) } - m.jsonEvents = append(m.jsonEvents, marshalEnvelope("install.progress", evt)) + m.emitJSON("install.progress", evt) return m } type progressiveInstallModel struct { baseModel - Driver string - VersionInput *semver.Version - NoVerify bool - jsonOutput bool - Pre bool - cfg config.Config + Driver string + VersionInput *semver.Version + NoVerify bool + jsonOutput bool + jsonStreamProgress bool + Pre bool + cfg config.Config insecureNoChecksum bool installedDriverInfo config.DriverInfo @@ -222,8 +239,8 @@ type progressiveInstallModel struct { localPackagePath string registryErrors error - jsonEvents []string alreadyInstalledChecksum string + jsonOut io.Writer jsonErrorOutput string // JSON error envelope to emit via FinalOutput } @@ -353,31 +370,15 @@ func (m progressiveInstallModel) FinalOutput() string { if m.jsonOutput { if installStatus.Checksum != "" { - m = m.addEvent("verify.checksum.ok", func(e *jsonschema.InstallProgressEvent) { + m.addEvent("verify.checksum.ok", func(e *jsonschema.InstallProgressEvent) { e.Checksum = installStatus.Checksum }) } - payloadBytes, err := json.Marshal(installStatus) - if err != nil { - return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) - } - env := jsonschema.Envelope{ - SchemaVersion: jsonschema.SchemaVersion, - Kind: "install.status", - Payload: json.RawMessage(payloadBytes), - } - jsonOutput, err := json.Marshal(env) - if err != nil { - return fmt.Sprintf(`{"schema_version":1,"kind":"error","payload":{"code":"marshal_error","message":"%s"}}`, err.Error()) - } - completeLine := marshalEnvelope("install.progress", jsonschema.InstallProgressEvent{ + m.emitJSON("install.progress", jsonschema.InstallProgressEvent{ Event: "install.complete", Driver: m.Driver, }) - allLines := make([]string, 0, len(m.jsonEvents)+2) - allLines = append(allLines, m.jsonEvents...) - allLines = append(allLines, completeLine, string(jsonOutput)) - return strings.Join(allLines, "\n") + return marshalEnvelope("install.status", installStatus) } if installStatus.Conflict != "" { diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index 56a65fcb..410cdb82 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -530,7 +530,7 @@ func (suite *SubcommandTestSuite) TestInstall_InsecureNoChecksumFlag() { } func (suite *SubcommandTestSuite) TestInstall_JSONProgressStream() { - m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, Json: true}. + m := InstallCmd{Driver: "test-driver-1", Level: suite.configLevel, JsonStreamProgress: true}. GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) out := suite.runCmd(m) diff --git a/cmd/dbc/subcommand_test.go b/cmd/dbc/subcommand_test.go index acf47e0b..a6f0c166 100644 --- a/cmd/dbc/subcommand_test.go +++ b/cmd/dbc/subcommand_test.go @@ -19,6 +19,7 @@ import ( "context" "encoding/json" "fmt" + "io" "io/fs" "net/url" "os" @@ -145,11 +146,19 @@ func (suite *SubcommandTestSuite) Dir() string { return suite.configLevel.ConfigLocation() } +// HasJSONWriter is implemented by models that stream JSON events directly to an io.Writer. +type HasJSONWriter interface { + WithJSONWriter(io.Writer) tea.Model +} + func (suite *SubcommandTestSuite) runCmdErr(m tea.Model) string { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var out bytes.Buffer + if jw, ok := m.(HasJSONWriter); ok { + m = jw.WithJSONWriter(&out) + } prog = tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(&out), tea.WithoutRenderer(), tea.WithContext(ctx)) defer func() { @@ -185,6 +194,9 @@ func (suite *SubcommandTestSuite) runCmd(m tea.Model) string { defer cancel() var out bytes.Buffer + if jw, ok := m.(HasJSONWriter); ok { + m = jw.WithJSONWriter(&out) + } prog = tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(&out), tea.WithoutRenderer(), tea.WithContext(ctx)) defer func() { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index 89189f26..dccadf51 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -37,29 +37,32 @@ import ( ) type SyncCmd struct { - Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to sync from"` - Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` - NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` - Json bool `arg:"--json" help:"Output NDJSON progress events instead of TUI"` + Path string `arg:"-p" placeholder:"FILE" default:"./dbc.toml" help:"Driver list to sync from"` + Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` + NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` + JsonStreamProgress bool `arg:"--json-stream-progress" help:"Stream progress events as JSON lines (implies --json)"` } func (c SyncCmd) GetModelCustom(baseModel baseModel) tea.Model { return syncModel{ - baseModel: baseModel, - Path: c.Path, - cfg: getConfig(c.Level), - NoVerify: c.NoVerify, - jsonOutput: c.Json, + baseModel: baseModel, + Path: c.Path, + cfg: getConfig(c.Level), + NoVerify: c.NoVerify, + jsonOutput: c.Json || c.JsonStreamProgress, + jsonStreamProgress: c.JsonStreamProgress, } } func (c SyncCmd) GetModel() tea.Model { return syncModel{ - Path: c.Path, - cfg: getConfig(c.Level), - NoVerify: c.NoVerify, - jsonOutput: c.Json, - baseModel: defaultBaseModel(), + Path: c.Path, + cfg: getConfig(c.Level), + NoVerify: c.NoVerify, + jsonOutput: c.Json || c.JsonStreamProgress, + jsonStreamProgress: c.JsonStreamProgress, + baseModel: defaultBaseModel(), } } @@ -67,8 +70,17 @@ func (syncModel) NeedsRenderer() {} func (s syncModel) IsJSONMode() bool { return s.jsonOutput } +func (s syncModel) WithJSONWriter(w io.Writer) tea.Model { + s.jsonOut = w + return s +} + func (s syncModel) emitJSON(kind string, payload any) { - fmt.Fprintln(os.Stdout, marshalEnvelope(kind, payload)) + out := s.jsonOut + if out == nil { + out = os.Stdout + } + fmt.Fprintln(out, marshalEnvelope(kind, payload)) } func (s syncModel) FinalOutput() string { @@ -101,7 +113,8 @@ type syncModel struct { locked LockFile cfg config.Config - jsonOutput bool + jsonOutput bool + jsonStreamProgress bool // the list of drivers in the driver list list DriversList @@ -123,6 +136,8 @@ type syncModel struct { skippedDrivers []jsonschema.SyncedDriver // newlyInstalled tracks freshly installed drivers for JSON output newlyInstalled []jsonschema.SyncedDriver + + jsonOut io.Writer } type driversListMsg struct { @@ -397,7 +412,7 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) s.installItems = msg - if s.jsonOutput { + if s.jsonStreamProgress { for _, item := range msg { s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ Phase: "resolving", @@ -419,7 +434,7 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Version: msg.info.Version.String(), }) - if s.jsonOutput { + if s.jsonStreamProgress { s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ Phase: "skipped", Driver: msg.info.ID, @@ -476,7 +491,7 @@ func (s syncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Version: msg.info.Version.String(), }) - if s.jsonOutput { + if s.jsonStreamProgress { s.emitJSON("sync.progress", jsonschema.SyncProgressEvent{ Phase: "installed", Driver: msg.info.ID, diff --git a/cmd/dbc/sync_test.go b/cmd/dbc/sync_test.go index 35623979..d82f7695 100644 --- a/cmd/dbc/sync_test.go +++ b/cmd/dbc/sync_test.go @@ -277,3 +277,31 @@ func (suite *SubcommandTestSuite) TestSync_JSONStream() { suite.Len(status.Installed, 1) suite.Equal("test-driver-1", status.Installed[0].Name) } + +func (suite *SubcommandTestSuite) TestSync_JSONProgressStream() { + tmpDir := suite.T().TempDir() + driverListPath := filepath.Join(tmpDir, "dbc.toml") + err := os.WriteFile(driverListPath, []byte("[drivers]\n[drivers.test-driver-1]\n"), 0644) + suite.Require().NoError(err) + + m := SyncCmd{Path: driverListPath, JsonStreamProgress: true}. + GetModelCustom(baseModel{getDriverRegistry: getTestDriverRegistry, downloadPkg: downloadTestPkg}) + out := suite.runCmd(m) + + lines := strings.Split(strings.TrimSpace(out), "\n") + suite.Greater(len(lines), 1, "expected multiple NDJSON lines") + + var kinds []string + for _, line := range lines { + if line == "" { + continue + } + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(line), &env), "line must be valid JSON: %s", line) + suite.Equal(1, env.SchemaVersion) + kinds = append(kinds, env.Kind) + } + + suite.Contains(kinds, "sync.progress") + suite.Equal("sync.status", kinds[len(kinds)-1]) +} From a14621fcbe239b95bcd2ed217011af21e1163ab1 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Tue, 28 Apr 2026 13:35:25 -0400 Subject: [PATCH 29/30] make test better --- cmd/dbc/add_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/dbc/add_test.go b/cmd/dbc/add_test.go index e0965c87..bd615694 100644 --- a/cmd/dbc/add_test.go +++ b/cmd/dbc/add_test.go @@ -472,9 +472,8 @@ func (suite *SubcommandTestSuite) TestAdd_JSON_Constraint() { suite.Equal(1, env.SchemaVersion) suite.Equal("add.response", env.Kind) - // verify we aren't escaping the > character in the JSON output - suite.Equal(string(env.Payload), `{"driver_list_path":"`+filepath.Join(suite.tempdir, "dbc.toml")+ - `","drivers":[{"name":"test-driver-1","version_constraint":">=1.0.0"}]}`) + // 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)) From c80660ea8a16097044442fbe9beaa71569da1040 Mon Sep 17 00:00:00 2001 From: Matt Topol Date: Tue, 28 Apr 2026 14:14:07 -0400 Subject: [PATCH 30/30] only output install.progress for streaming case --- cmd/dbc/install.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index e94d095c..33d470d7 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -374,10 +374,12 @@ func (m progressiveInstallModel) FinalOutput() string { e.Checksum = installStatus.Checksum }) } - m.emitJSON("install.progress", jsonschema.InstallProgressEvent{ - Event: "install.complete", - Driver: m.Driver, - }) + if m.jsonStreamProgress { + m.emitJSON("install.progress", jsonschema.InstallProgressEvent{ + Event: "install.complete", + Driver: m.Driver, + }) + } return marshalEnvelope("install.status", installStatus) }