Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions cmd/provider/use.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package provider

import (
"bytes"
"context"
"fmt"
"io"
Expand All @@ -9,6 +10,7 @@ import (
"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/client/clientimplementation"
"github.com/devsy-org/devsy/pkg/config"
cliErrors "github.com/devsy-org/devsy/pkg/errors"
"github.com/devsy-org/devsy/pkg/log"
options2 "github.com/devsy-org/devsy/pkg/options"
provider2 "github.com/devsy-org/devsy/pkg/provider"
Expand Down Expand Up @@ -221,6 +223,10 @@ func initProvider(
provider *provider2.ProviderConfig,
stdout, stderr io.Writer,
) error {
// Capture the sub-binary's stderr in parallel with forwarding it to the
// regular log sink so that errors.Classify has the real provider output
// to fingerprint, not just an opaque "exit status 1".
stderrBuf := &bytes.Buffer{}
err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{
Ctx: ctx,
Name: "init",
Expand All @@ -229,10 +235,13 @@ func initProvider(
Options: devsyConfig.ProviderOptions(provider.Name),
Config: provider,
Stdout: stdout,
Stderr: stderr,
Stderr: io.MultiWriter(stderr, stderrBuf),
})
if err != nil {
return fmt.Errorf("init: %w", err)
return cliErrors.Classify(fmt.Errorf("init: %w", err), cliErrors.ClassifyContext{
Provider: provider.Name,
Stderr: stderrBuf.String(),
})
}
if devsyConfig.Current().Providers == nil {
devsyConfig.Current().Providers = map[string]*config.ProviderConfig{}
Expand Down
36 changes: 29 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"fmt"
"os"
"os/exec"

Expand All @@ -18,6 +19,7 @@ import (
"github.com/devsy-org/devsy/cmd/up"
"github.com/devsy-org/devsy/cmd/use"
"github.com/devsy-org/devsy/pkg/config"
cliErrors "github.com/devsy-org/devsy/pkg/errors"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/telemetry"
"github.com/go-logr/logr"
Expand All @@ -26,6 +28,18 @@ import (
"k8s.io/klog/v2"
)

const (
logOutputJSON = "json"
logOutputLogfmt = "logfmt"
)

// isMachineLogFormat reports whether the configured --log-output mode produces
// a structured, machine-parseable stream (json or logfmt). Callers use this to
// suppress decorative human-readable affordances that would corrupt the stream.
func isMachineLogFormat(format string) bool {
return format == logOutputJSON || format == logOutputLogfmt
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
Expand All @@ -47,14 +61,22 @@ func Execute() {
os.Exit(execExitErr.ExitCode())
}

if globalFlags.Debug {
log.Errorf("%+v", err)
} else {
if rootCmd.Annotations == nil ||
rootCmd.Annotations[agent.AgentExecutedAnnotation] != config.BoolTrue {
log.Error("Try using -v or --debug flag to see more verbose output")
cliErr := cliErrors.Classify(err, cliErrors.ClassifyContext{})
// Always emit the error through zap so the configured log encoder
// (json/logfmt/text) governs the wire format. JSONError preserves
// the full err.Error() chain in the top-level "msg" field and ships
// the structured CLIError under "cliError" for the desktop IPC.
log.JSONError(cliErr)
// In human-friendly text mode, follow up with hint/doc affordances
// that don't fit cleanly into the zap line. These extras are
// suppressed in machine-readable modes so log streams stay parseable.
if !isMachineLogFormat(globalFlags.LogOutput) {
if cliErr.Hint != "" {
fmt.Fprintf(os.Stderr, "Hint: %s\n", cliErr.Hint)
}
if cliErr.DocURL != "" {
fmt.Fprintf(os.Stderr, "See: %s\n", cliErr.DocURL)
}
log.Errorf("%v", err)
}
os.Exit(1)
}
Expand Down
1 change: 1 addition & 0 deletions desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default defineConfig({
resolve: {
alias: {
$lib: resolve(__dirname, "src/renderer/src/lib"),
$shared: resolve(__dirname, "src/shared"),
},
},
build: {
Expand Down
42 changes: 40 additions & 2 deletions desktop/src/main/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("CliRunner", () => {
expect(result).toEqual([{ id: "ws-1" }])
expect(mockExecFile).toHaveBeenCalledWith(
"/usr/local/bin/devsy",
["list", "--skip-pro", "--result-format", "json"],
["list", "--skip-pro", "--result-format", "json", "--log-output", "json"],
expect.objectContaining({ env: expect.any(Object) }),
expect.any(Function),
)
Expand All @@ -59,6 +59,44 @@ describe("CliRunner", () => {

await expect(cli.run(["list"])).rejects.toThrow("workspace not found")
})

it("extracts cliError from a zap JSON stderr line and attaches it to the thrown Error", async () => {
const mockExecFile = vi.mocked(execFile) as unknown as ReturnType<
typeof vi.fn
>
const cliErrorPayload = {
code: "AWS_PROFILE_MISSING",
message: "AWS credentials are not configured.",
hint: "Set AWS_PROFILE or create ~/.aws/credentials.",
docUrl: "https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html",
provider: "aws",
cause: "init: exit status 1: failed to get shared config profile, default",
}
const stderrLine = JSON.stringify({
level: "error",
ts: "2026-05-25T06:02:47.423-0500",
msg: cliErrorPayload.message,
cliError: cliErrorPayload,
})
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, callback: Function) => {
const error = new Error("Command failed") as Error & {
code: number
stderr: string
}
error.code = 1
error.stderr = `noise before\n${stderrLine}\n`
callback(error, { stdout: "", stderr: error.stderr })
},
)

const rejection = await cli
.run(["provider", "set-options", "aws"])
.catch((e) => e as Error & { cliError?: typeof cliErrorPayload })
expect(rejection).toBeInstanceOf(Error)
expect(rejection.cliError).toEqual(cliErrorPayload)
expect(rejection.message).toBe(cliErrorPayload.message)
})
})

describe("runRaw", () => {
Expand Down Expand Up @@ -92,7 +130,7 @@ describe("CliRunner", () => {
await jsCli.run(["list"])
expect(mockExecFile).toHaveBeenCalledWith(
"node",
["/tmp/mock.cjs", "list", "--result-format", "json"],
["/tmp/mock.cjs", "list", "--result-format", "json", "--log-output", "json"],
expect.objectContaining({ env: expect.any(Object) }),
expect.any(Function),
)
Expand Down
118 changes: 107 additions & 11 deletions desktop/src/main/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,73 @@ import type { ChildProcess } from "node:child_process"
import { execFile as execFileCb, spawn } from "node:child_process"
import { createInterface } from "node:readline"
import { promisify } from "node:util"
import type { CLIError, CliLogLine } from "../shared/cli-error.js"

const execFile = promisify(execFileCb)
const MAX_CONCURRENT = 50

export interface StreamLine {
/** Raw line text from the CLI (already ANSI-stripped upstream is not assumed). */
raw: string
/** Parsed zap JSON log line, if the line was valid JSON. */
parsed?: CliLogLine
/** Convenience: the structured cliError extracted from a parsed line, if any. */
cliError?: CLIError
/** Log level from the parsed line, if available. */
level?: "info" | "warn" | "error"
}

/**
* Coerce an arbitrary zap level string into the narrow set the IPC payload uses.
* Unknown levels map to "info".
*/
function normalizeLevel(level: unknown): "info" | "warn" | "error" | undefined {
if (typeof level !== "string") return undefined
const lower = level.toLowerCase()
if (lower === "warn" || lower === "warning") return "warn"
if (
lower === "error" ||
lower === "fatal" ||
lower === "panic" ||
lower === "dpanic"
)
return "error"
return "info"
}

/**
* Walk a multi-line stderr blob, parse each line as a zap JSON record, and
* return the last `cliError` field encountered. Used by the non-streaming
* `run`/`runRaw` paths where the entire stderr is delivered after exit.
*/
function extractCliErrorFromStderr(stderr: string): CLIError | undefined {
let found: CLIError | undefined
for (const line of stderr.split(/\r?\n/)) {
const parsed = parseStderrLine(line)
if (parsed?.cliError) found = parsed.cliError
}
return found
}

/**
* Parse a single stderr line as a zap JSON record. Returns undefined when the
* line is not valid JSON or not a plain object — callers should treat the line
* as opaque text in that case.
*/
function parseStderrLine(line: string): CliLogLine | undefined {
const trimmed = line.trim()
if (!trimmed.startsWith("{")) return undefined
try {
const obj = JSON.parse(trimmed) as unknown
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return obj as CliLogLine
}
} catch {
// not JSON — fall through
}
return undefined
}

/**
* On macOS, GUI apps (including Electron) inherit a minimal PATH that excludes
* /usr/local/bin and /opt/homebrew/bin. This means tools like docker, git, etc.
Expand Down Expand Up @@ -71,7 +134,14 @@ export class CliRunner {
async run<T>(args: string[]): Promise<T> {
await this.acquire()
try {
const fullArgs = [...this.prefixArgs, ...args, "--result-format", "json"]
const fullArgs = [
...this.prefixArgs,
...args,
"--result-format",
"json",
"--log-output",
"json",
]
const { stdout } = await execFile(this.execPath, fullArgs, {
env: this.env,
})
Expand All @@ -88,7 +158,7 @@ export class CliRunner {
try {
const { stdout } = await execFile(
this.execPath,
[...this.prefixArgs, ...args],
[...this.prefixArgs, ...args, "--log-output", "json"],
{ env: this.env },
)
return stdout
Expand All @@ -103,30 +173,50 @@ export class CliRunner {

async runStreaming(
args: string[],
onLine: (line: string, stream: "stdout" | "stderr") => void,
onExit: (code: number) => void,
onLine: (
line: string,
stream: "stdout" | "stderr",
meta?: StreamLine,
) => void,
onExit: (code: number, cliError?: CLIError) => void,
): Promise<ChildProcess> {
await this.acquire()
const child = spawn(this.execPath, [...this.prefixArgs, ...args], {
env: this.env,
})
const child = spawn(
this.execPath,
[...this.prefixArgs, ...args, "--log-output", "json"],
{ env: this.env },
)

this.activeChildren.add(child)

let lastCliError: CLIError | undefined

if (child.stdout) {
const rl = createInterface({ input: child.stdout })
rl.on("line", (line) => onLine(line, "stdout"))
}

if (child.stderr) {
const rl = createInterface({ input: child.stderr })
rl.on("line", (line) => onLine(line, "stderr"))
rl.on("line", (line) => {
const parsed = parseStderrLine(line)
if (parsed?.cliError) {
lastCliError = parsed.cliError
}
const meta: StreamLine = {
raw: line,
parsed,
cliError: parsed?.cliError,
level: normalizeLevel(parsed?.level),
}
onLine(line, "stderr", meta)
})
}

child.on("close", (code) => {
this.activeChildren.delete(child)
this.release()
onExit(code ?? -1)
onExit(code ?? -1, lastCliError)
})

return child
Expand All @@ -149,12 +239,18 @@ export class CliRunner {
return join(resourcesPath, "bin", binaryName)
}

private wrapError(error: unknown): Error {
private wrapError(error: unknown): Error & { cliError?: CLIError } {
if (error instanceof Error && "stderr" in error) {
const stderr = CliRunner.stripAnsi(
String((error as { stderr: string }).stderr),
)
return new Error(this.sanitizeMessage(stderr || error.message))
const cliError = extractCliErrorFromStderr(stderr)
const message = cliError
? cliError.message
: this.sanitizeMessage(stderr || error.message)
const wrapped = new Error(message) as Error & { cliError?: CLIError }
if (cliError) wrapped.cliError = cliError
return wrapped
}
return error instanceof Error
? new Error(this.sanitizeMessage(error.message))
Expand Down
Loading
Loading