From fc3ffec42edfd6c955d624a4297dbd2787294d08 Mon Sep 17 00:00:00 2001 From: bilby91 Date: Fri, 22 May 2026 16:43:00 -0300 Subject: [PATCH 1/4] cli: scaffold cobra-based devcontainer CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cmd/devcontainer, a thin CLI on top of the engine. The CLI is meant for humans at a terminal — logs to stderr, results to stdout, non-zero exit on failure. Tools wanting programmatic access import the library directly, so no outcome-envelope JSON shim. Commands wired: up Engine.Up + streamed event progress exec Attach + Exec with raw-mode TTY + SIGWINCH down / stop Engine.Down (with/without remove) read-configuration Resolve, JSON to stdout run-user-commands Iterate Engine.RunLifecycle across phases Global flags: --workspace-folder, --config, --runtime (docker | applecontainer, default docker), --log-level. Subcommand-specific flags mirror the upstream @devcontainers/cli names where they map cleanly (--remove-existing-container, --run-initialize-command, --run-secrets-command, --remove-volumes). Follow-ups tracked in #74-#78 (log-level plumbing, exec --env, --recreate alias, ResolvedConfig json tags, Engine.Build). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/devcontainer/down.go | 96 +++++++++++++++ cmd/devcontainer/events.go | 74 ++++++++++++ cmd/devcontainer/exec.go | 156 +++++++++++++++++++++++++ cmd/devcontainer/main.go | 32 +++++ cmd/devcontainer/read_configuration.go | 37 ++++++ cmd/devcontainer/root.go | 103 ++++++++++++++++ cmd/devcontainer/run_user_commands.go | 92 +++++++++++++++ cmd/devcontainer/up.go | 64 ++++++++++ go.mod | 6 +- go.sum | 16 ++- 10 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 cmd/devcontainer/down.go create mode 100644 cmd/devcontainer/events.go create mode 100644 cmd/devcontainer/exec.go create mode 100644 cmd/devcontainer/main.go create mode 100644 cmd/devcontainer/read_configuration.go create mode 100644 cmd/devcontainer/root.go create mode 100644 cmd/devcontainer/run_user_commands.go create mode 100644 cmd/devcontainer/up.go diff --git a/cmd/devcontainer/down.go b/cmd/devcontainer/down.go new file mode 100644 index 0000000..91c052d --- /dev/null +++ b/cmd/devcontainer/down.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + devcontainer "github.com/crunchloop/devcontainer" +) + +func newDownCmd(rf *rootFlags) *cobra.Command { + var removeVolumes bool + return newDownLikeCmd(rf, downLikeOpts{ + use: "down", + short: "Stop and remove the workspace's dev container", + remove: true, + extraFlags: func(c *cobra.Command) { c.Flags().BoolVar(&removeVolumes, "remove-volumes", false, "Also remove anonymous volumes created by the container") }, + removeVolumes: &removeVolumes, + }) +} + +func newStopCmd(rf *rootFlags) *cobra.Command { + return newDownLikeCmd(rf, downLikeOpts{ + use: "stop", + short: "Stop the workspace's dev container without removing it", + remove: false, + }) +} + +type downLikeOpts struct { + use string + short string + remove bool + removeVolumes *bool + extraFlags func(*cobra.Command) +} + +func newDownLikeCmd(rf *rootFlags, o downLikeOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: o.use, + Short: o.short, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + ws, err := rf.resolveWorkspaceFolder() + if err != nil { + return err + } + + cfg, err := devcontainer.Resolve(ctx, devcontainer.ResolveOptions{ + LocalWorkspaceFolder: ws, + ConfigPath: rf.configPath, + }) + if err != nil { + return err + } + + eng, closeEng, err := rf.newEngine(ctx) + if err != nil { + return err + } + defer closeEng() + + workspace, err := eng.Attach(ctx, devcontainer.WorkspaceID(cfg.DevcontainerID)) + if err != nil { + return err + } + + evCh, stop := startEventPrinter() + downOpts := devcontainer.DownOptions{ + Remove: o.remove, + Events: evCh, + } + if o.removeVolumes != nil { + downOpts.RemoveVolumes = *o.removeVolumes + } + err = eng.Down(ctx, workspace, downOpts) + stop() + if err != nil { + return err + } + + if o.remove { + fmt.Fprintf(os.Stderr, "✓ workspace %s down\n", workspace.ID) + } else { + fmt.Fprintf(os.Stderr, "✓ workspace %s stopped\n", workspace.ID) + } + return nil + }, + } + if o.extraFlags != nil { + o.extraFlags(cmd) + } + return cmd +} diff --git a/cmd/devcontainer/events.go b/cmd/devcontainer/events.go new file mode 100644 index 0000000..551e8a1 --- /dev/null +++ b/cmd/devcontainer/events.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/crunchloop/devcontainer/events" +) + +// startEventPrinter spawns a goroutine that drains ch to stderr in +// human-readable form and returns the channel for callers to pass to +// the engine, plus a stop function that closes the channel and waits +// for the drainer to finish. The returned channel must not be closed +// by the caller — stop() does that. +func startEventPrinter() (chan events.Event, func()) { + ch := make(chan events.Event, 64) + done := make(chan struct{}) + go func() { + defer close(done) + for ev := range ch { + printEvent(os.Stderr, ev) + } + }() + return ch, func() { + close(ch) + <-done + } +} + +// printEvent renders an engine event as a single human-readable line. +// Events without a meaningful textual representation print their type tag. +func printEvent(w io.Writer, ev events.Event) { + switch e := ev.(type) { + case events.ConfigWarningEvent: + fmt.Fprintf(w, "[warn] %s: %s\n", e.Code, e.Message) + case events.WarnEvent: + fmt.Fprintf(w, "[warn] %s\n", e.Message) + case events.LifecycleStartEvent: + fmt.Fprintf(w, "[lifecycle] %s starting\n", e.Phase) + case events.LifecycleOutputEvent: + fmt.Fprintf(w, "[%s] %s", e.Phase, e.Line) + case events.LifecycleCompletedEvent: + fmt.Fprintf(w, "[lifecycle] %s done\n", e.Phase) + case events.LifecycleSkippedEvent: + fmt.Fprintf(w, "[lifecycle] %s skipped: %s\n", e.Phase, e.Reason) + case events.BuildStartEvent: + fmt.Fprintf(w, "[build] start\n") + case events.BuildLogEvent: + fmt.Fprint(w, e.Line) + case events.BuildCompletedEvent: + fmt.Fprintf(w, "[build] done: %s\n", e.ImageID) + case events.FeatureResolveStartEvent: + fmt.Fprintf(w, "[feature] resolving %s\n", e.Ref) + case events.FeatureResolvedEvent: + if e.FromCache { + fmt.Fprintf(w, "[feature] %s (cached)\n", e.Ref) + } else { + fmt.Fprintf(w, "[feature] %s\n", e.Ref) + } + case events.ContainerCreatingEvent: + fmt.Fprintf(w, "[container] creating\n") + case events.ContainerCreatedEvent: + fmt.Fprintf(w, "[container] created: %s\n", e.ContainerID) + case events.ContainerStartedEvent: + fmt.Fprintf(w, "[container] started\n") + case events.ContainerStoppedEvent: + fmt.Fprintf(w, "[container] stopped: %s\n", e.ContainerID) + case events.ContainerRemovedEvent: + fmt.Fprintf(w, "[container] removed: %s\n", e.ContainerID) + default: + fmt.Fprintf(w, "[%s]\n", ev.EventType()) + } +} diff --git a/cmd/devcontainer/exec.go b/cmd/devcontainer/exec.go new file mode 100644 index 0000000..a3b64f1 --- /dev/null +++ b/cmd/devcontainer/exec.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + "golang.org/x/term" + + devcontainer "github.com/crunchloop/devcontainer" + "github.com/crunchloop/devcontainer/runtime" +) + +func newExecCmd(rf *rootFlags) *cobra.Command { + var ( + user string + workingDir string + noTty bool + ) + + cmd := &cobra.Command{ + Use: "exec [args...]", + Short: "Run a command inside the workspace's dev container", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + ws, err := rf.resolveWorkspaceFolder() + if err != nil { + return err + } + + cfg, err := devcontainer.Resolve(ctx, devcontainer.ResolveOptions{ + LocalWorkspaceFolder: ws, + ConfigPath: rf.configPath, + }) + if err != nil { + return err + } + + eng, closeEng, err := rf.newEngine(ctx) + if err != nil { + return err + } + defer closeEng() + + workspace, err := eng.Attach(ctx, devcontainer.WorkspaceID(cfg.DevcontainerID)) + if err != nil { + return err + } + + tty := !noTty && term.IsTerminal(int(os.Stdin.Fd())) + initialSize, resizeCh, restore, err := setupTty(ctx, tty) + if err != nil { + return err + } + defer restore() + + execOpts := devcontainer.ExecOptions{ + Cmd: args, + User: user, + WorkingDir: workingDir, + Tty: tty, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + InitialTtySize: initialSize, + ResizeCh: resizeCh, + } + + res, err := eng.Exec(ctx, workspace, execOpts) + if err != nil { + return err + } + if res.ExitCode != 0 { + // Propagate non-zero exit without printing an error + // banner — the exec'd command speaks for itself. + return silentExitError{code: res.ExitCode} + } + return nil + }, + } + + cmd.Flags().StringVar(&user, "user", "", "Override remoteUser/containerUser for this exec") + cmd.Flags().StringVar(&workingDir, "working-dir", "", "Working directory inside the container") + cmd.Flags().BoolVar(&noTty, "no-tty", false, "Do not allocate a TTY even if stdin is a terminal") + + return cmd +} + +// setupTty puts the terminal in raw mode and wires SIGWINCH to a resize +// channel when tty is true. Returns a restore func that's always safe +// to call (no-op when tty was false). +func setupTty(ctx context.Context, tty bool) (runtime.TtySize, <-chan runtime.TtySize, func(), error) { + if !tty { + return runtime.TtySize{}, nil, func() {}, nil + } + fd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return runtime.TtySize{}, nil, func() {}, fmt.Errorf("make raw: %w", err) + } + + var initial runtime.TtySize + if w, h, err := term.GetSize(fd); err == nil { + initial = runtime.TtySize{Width: uint16(w), Height: uint16(h)} + } + + resizeCh := make(chan runtime.TtySize, 1) + sigwinch := make(chan os.Signal, 1) + signal.Notify(sigwinch, syscall.SIGWINCH) + winchCtx, cancelWinch := context.WithCancel(ctx) + go func() { + defer signal.Stop(sigwinch) + for { + select { + case <-winchCtx.Done(): + return + case <-sigwinch: + w, h, err := term.GetSize(fd) + if err != nil { + continue + } + select { + case resizeCh <- runtime.TtySize{Width: uint16(w), Height: uint16(h)}: + case <-winchCtx.Done(): + return + } + } + } + }() + + restore := func() { + cancelWinch() + _ = term.Restore(fd, oldState) + } + return initial, resizeCh, restore, nil +} + +// silentExitError carries an exit code without a printed message. +// main.go recognizes it and exits with the given code. +type silentExitError struct{ code int } + +func (s silentExitError) Error() string { return fmt.Sprintf("exit status %d", s.code) } + +func exitCodeFor(err error) int { + var s silentExitError + if errors.As(err, &s) { + return s.code + } + return 1 +} diff --git a/cmd/devcontainer/main.go b/cmd/devcontainer/main.go new file mode 100644 index 0000000..2dcbcb6 --- /dev/null +++ b/cmd/devcontainer/main.go @@ -0,0 +1,32 @@ +// Command devcontainer is a CLI on top of the devcontainer Go library. +// +// Unlike @devcontainers/cli, this binary is meant for humans at a +// terminal: logs go to stderr, results to stdout, non-zero exit on +// failure. Tools that want programmatic access should import the +// library directly. +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + err := newRootCmd().ExecuteContext(ctx) + if err == nil { + return + } + var silent silentExitError + if errors.As(err, &silent) { + os.Exit(silent.code) + } + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) +} diff --git a/cmd/devcontainer/read_configuration.go b/cmd/devcontainer/read_configuration.go new file mode 100644 index 0000000..ed1ca5c --- /dev/null +++ b/cmd/devcontainer/read_configuration.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "os" + + "github.com/spf13/cobra" + + devcontainer "github.com/crunchloop/devcontainer" +) + +func newReadConfigurationCmd(rf *rootFlags) *cobra.Command { + return &cobra.Command{ + Use: "read-configuration", + Short: "Resolve devcontainer.json and print the resolved configuration as JSON", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + ws, err := rf.resolveWorkspaceFolder() + if err != nil { + return err + } + + cfg, err := devcontainer.Resolve(ctx, devcontainer.ResolveOptions{ + LocalWorkspaceFolder: ws, + ConfigPath: rf.configPath, + }) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(cfg) + }, + } +} diff --git a/cmd/devcontainer/root.go b/cmd/devcontainer/root.go new file mode 100644 index 0000000..93d3ee2 --- /dev/null +++ b/cmd/devcontainer/root.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + devcontainer "github.com/crunchloop/devcontainer" + "github.com/crunchloop/devcontainer/runtime" + "github.com/crunchloop/devcontainer/runtime/applecontainer" + "github.com/crunchloop/devcontainer/runtime/docker" +) + +type rootFlags struct { + workspaceFolder string + configPath string + runtimeName string + logLevel string +} + +func newRootCmd() *cobra.Command { + f := &rootFlags{} + + cmd := &cobra.Command{ + Use: "devcontainer", + Short: "Manage dev containers (containers.dev) from the command line", + SilenceUsage: true, + SilenceErrors: true, + } + + pf := cmd.PersistentFlags() + pf.StringVar(&f.workspaceFolder, "workspace-folder", "", "Path to the project workspace (defaults to current directory)") + pf.StringVar(&f.configPath, "config", "", "Path to devcontainer.json (defaults to .devcontainer/devcontainer.json under the workspace)") + pf.StringVar(&f.runtimeName, "runtime", "docker", "Container backend: docker | applecontainer") + pf.StringVar(&f.logLevel, "log-level", "info", "Log verbosity: info | debug | trace") + + cmd.AddCommand( + newUpCmd(f), + newExecCmd(f), + newDownCmd(f), + newStopCmd(f), + newReadConfigurationCmd(f), + newRunUserCommandsCmd(f), + ) + + return cmd +} + +// resolveWorkspaceFolder turns the --workspace-folder flag into an absolute +// path, defaulting to the current working directory when unset. +func (f *rootFlags) resolveWorkspaceFolder() (string, error) { + ws := f.workspaceFolder + if ws == "" { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + ws = cwd + } + abs, err := filepath.Abs(ws) + if err != nil { + return "", fmt.Errorf("resolve workspace path: %w", err) + } + return abs, nil +} + +// newRuntime constructs the configured container backend. The returned +// closer must be called once the runtime is no longer needed. +func (f *rootFlags) newRuntime(ctx context.Context) (runtime.Runtime, func(), error) { + switch f.runtimeName { + case "docker": + rt, err := docker.New(ctx, docker.Options{}) + if err != nil { + return nil, nil, fmt.Errorf("docker runtime: %w", err) + } + return rt, func() { _ = rt.Close() }, nil + case "applecontainer": + rt, err := applecontainer.New(ctx, applecontainer.Options{}) + if err != nil { + return nil, nil, fmt.Errorf("applecontainer runtime: %w", err) + } + return rt, func() {}, nil + default: + return nil, nil, fmt.Errorf("unknown runtime %q (want docker | applecontainer)", f.runtimeName) + } +} + +// newEngine builds the engine wired to the configured backend. +func (f *rootFlags) newEngine(ctx context.Context) (*devcontainer.Engine, func(), error) { + rt, closeRT, err := f.newRuntime(ctx) + if err != nil { + return nil, nil, err + } + eng, err := devcontainer.New(devcontainer.EngineOptions{Runtime: rt}) + if err != nil { + closeRT() + return nil, nil, fmt.Errorf("engine: %w", err) + } + return eng, closeRT, nil +} diff --git a/cmd/devcontainer/run_user_commands.go b/cmd/devcontainer/run_user_commands.go new file mode 100644 index 0000000..b305f55 --- /dev/null +++ b/cmd/devcontainer/run_user_commands.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + devcontainer "github.com/crunchloop/devcontainer" + "github.com/crunchloop/devcontainer/config" +) + +// Phases that run-user-commands iterates over by default. initialize is +// host-side and opt-in, mirroring Up's behavior. +var defaultLifecyclePhases = []config.LifecyclePhase{ + config.LifecycleOnCreate, + config.LifecycleUpdateContent, + config.LifecyclePostCreate, + config.LifecyclePostStart, + config.LifecyclePostAttach, +} + +func newRunUserCommandsCmd(rf *rootFlags) *cobra.Command { + var phaseFlag string + + cmd := &cobra.Command{ + Use: "run-user-commands", + Short: "Run devcontainer.json lifecycle commands against the running container", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + ws, err := rf.resolveWorkspaceFolder() + if err != nil { + return err + } + + // Resolve again here (rather than relying on Attach's + // minimal config) so we get the full lifecycle hooks + // straight from devcontainer.json. + cfg, err := devcontainer.Resolve(ctx, devcontainer.ResolveOptions{ + LocalWorkspaceFolder: ws, + ConfigPath: rf.configPath, + }) + if err != nil { + return err + } + + eng, closeEng, err := rf.newEngine(ctx) + if err != nil { + return err + } + defer closeEng() + + workspace, err := eng.Attach(ctx, devcontainer.WorkspaceID(cfg.DevcontainerID)) + if err != nil { + return err + } + // Replace Attach's minimal config with the freshly + // resolved one so RunLifecycle sees declared hooks. + workspace.Config = cfg + + phases := defaultLifecyclePhases + if phaseFlag != "" { + p := config.LifecyclePhase(phaseFlag) + if !isKnownPhase(p) { + return fmt.Errorf("unknown lifecycle phase %q", phaseFlag) + } + phases = []config.LifecyclePhase{p} + } + + for _, phase := range phases { + if err := eng.RunLifecycle(ctx, workspace, phase); err != nil { + return err + } + } + fmt.Fprintf(os.Stderr, "✓ lifecycle commands complete\n") + return nil + }, + } + + cmd.Flags().StringVar(&phaseFlag, "phase", "", "Run only this phase (onCreate|updateContent|postCreate|postStart|postAttach)") + return cmd +} + +func isKnownPhase(p config.LifecyclePhase) bool { + for _, k := range defaultLifecyclePhases { + if p == k { + return true + } + } + return false +} diff --git a/cmd/devcontainer/up.go b/cmd/devcontainer/up.go new file mode 100644 index 0000000..350b1fb --- /dev/null +++ b/cmd/devcontainer/up.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + devcontainer "github.com/crunchloop/devcontainer" +) + +func newUpCmd(rf *rootFlags) *cobra.Command { + var ( + recreate bool + runInitializeCommand bool + runSecretsCommand bool + ) + + cmd := &cobra.Command{ + Use: "up", + Short: "Create and start the dev container for a workspace", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + ws, err := rf.resolveWorkspaceFolder() + if err != nil { + return err + } + + eng, closeEng, err := rf.newEngine(ctx) + if err != nil { + return err + } + defer closeEng() + + evCh, stop := startEventPrinter() + workspace, upErr := eng.Up(ctx, devcontainer.UpOptions{ + LocalWorkspaceFolder: ws, + ConfigPath: rf.configPath, + Recreate: recreate, + RunInitializeCommand: runInitializeCommand, + RunSecretsCommand: runSecretsCommand, + Events: evCh, + }) + stop() + + if upErr != nil { + return upErr + } + + fmt.Fprintf(os.Stderr, "✓ workspace %s ready\n", workspace.ID) + if workspace.Container != nil { + fmt.Fprintf(os.Stderr, " container: %s\n", workspace.Container.ID) + } + return nil + }, + } + + cmd.Flags().BoolVar(&recreate, "remove-existing-container", false, "Stop and remove any existing container before creating a fresh one") + cmd.Flags().BoolVar(&runInitializeCommand, "run-initialize-command", false, "Run devcontainer.json initializeCommand on the host before container creation") + cmd.Flags().BoolVar(&runSecretsCommand, "run-secrets-command", false, "Run devcontainer.json secretsCommand on the host and inject its output as container env") + + return cmd +} diff --git a/go.mod b/go.mod index b50593e..778e7d8 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/google/go-containerregistry v0.21.5 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 + github.com/spf13/cobra v1.10.2 github.com/tidwall/jsonc v0.3.3 + golang.org/x/term v0.43.0 google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -26,6 +28,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -34,6 +37,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -43,6 +47,6 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 5665a66..ec68311 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,7 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -37,6 +38,8 @@ github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iq github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -61,10 +64,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w= @@ -87,12 +96,15 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= From 58a5994b6f8172feee1f3dfec4e35595a6c401f3 Mon Sep 17 00:00:00 2001 From: bilby91 Date: Fri, 22 May 2026 17:41:21 -0300 Subject: [PATCH 2/4] cli: fix linux build + drop in-flight tty-resize plumbing CI was red because the first commit reached for runtime API surface that lives in an in-progress local branch, not main: - runtime.TtySize / ExecOptions.InitialTtySize / ExecOptions.ResizeCh aren't on main yet, so exec.go failed to compile in CI. Strip the SIGWINCH forwarding for now; TTY raw mode still works. Re-add window resize once the runtime change lands. - runtime/applecontainer's non-darwin stub doesn't implement runtime.Runtime, so importing it unconditionally broke the linux build. Move applecontainer wiring into a build-tagged file (darwin/arm64 only); other platforms return a clear error from --runtime=applecontainer. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/devcontainer/exec.go | 79 +++++-------------- cmd/devcontainer/root.go | 7 +- .../runtime_applecontainer_darwin_arm64.go | 19 +++++ .../runtime_applecontainer_other.go | 14 ++++ 4 files changed, 54 insertions(+), 65 deletions(-) create mode 100644 cmd/devcontainer/runtime_applecontainer_darwin_arm64.go create mode 100644 cmd/devcontainer/runtime_applecontainer_other.go diff --git a/cmd/devcontainer/exec.go b/cmd/devcontainer/exec.go index a3b64f1..23d31f9 100644 --- a/cmd/devcontainer/exec.go +++ b/cmd/devcontainer/exec.go @@ -1,18 +1,14 @@ package main import ( - "context" "errors" "fmt" "os" - "os/signal" - "syscall" "github.com/spf13/cobra" "golang.org/x/term" devcontainer "github.com/crunchloop/devcontainer" - "github.com/crunchloop/devcontainer/runtime" ) func newExecCmd(rf *rootFlags) *cobra.Command { @@ -54,25 +50,25 @@ func newExecCmd(rf *rootFlags) *cobra.Command { } tty := !noTty && term.IsTerminal(int(os.Stdin.Fd())) - initialSize, resizeCh, restore, err := setupTty(ctx, tty) + restore, err := setupTty(tty) if err != nil { return err } defer restore() - execOpts := devcontainer.ExecOptions{ - Cmd: args, - User: user, - WorkingDir: workingDir, - Tty: tty, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - InitialTtySize: initialSize, - ResizeCh: resizeCh, - } - - res, err := eng.Exec(ctx, workspace, execOpts) + // NOTE: window-size forwarding (SIGWINCH → resize) is not + // wired here yet — the runtime ExecOptions surface for it + // is still in-flight on main. Once it lands we can plumb + // term.GetSize + signal.Notify(SIGWINCH) through. + res, err := eng.Exec(ctx, workspace, devcontainer.ExecOptions{ + Cmd: args, + User: user, + WorkingDir: workingDir, + Tty: tty, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) if err != nil { return err } @@ -92,53 +88,18 @@ func newExecCmd(rf *rootFlags) *cobra.Command { return cmd } -// setupTty puts the terminal in raw mode and wires SIGWINCH to a resize -// channel when tty is true. Returns a restore func that's always safe -// to call (no-op when tty was false). -func setupTty(ctx context.Context, tty bool) (runtime.TtySize, <-chan runtime.TtySize, func(), error) { +// setupTty puts the terminal in raw mode when tty is true and returns a +// restore func that's always safe to call (no-op when tty was false). +func setupTty(tty bool) (func(), error) { if !tty { - return runtime.TtySize{}, nil, func() {}, nil + return func() {}, nil } fd := int(os.Stdin.Fd()) oldState, err := term.MakeRaw(fd) if err != nil { - return runtime.TtySize{}, nil, func() {}, fmt.Errorf("make raw: %w", err) - } - - var initial runtime.TtySize - if w, h, err := term.GetSize(fd); err == nil { - initial = runtime.TtySize{Width: uint16(w), Height: uint16(h)} - } - - resizeCh := make(chan runtime.TtySize, 1) - sigwinch := make(chan os.Signal, 1) - signal.Notify(sigwinch, syscall.SIGWINCH) - winchCtx, cancelWinch := context.WithCancel(ctx) - go func() { - defer signal.Stop(sigwinch) - for { - select { - case <-winchCtx.Done(): - return - case <-sigwinch: - w, h, err := term.GetSize(fd) - if err != nil { - continue - } - select { - case resizeCh <- runtime.TtySize{Width: uint16(w), Height: uint16(h)}: - case <-winchCtx.Done(): - return - } - } - } - }() - - restore := func() { - cancelWinch() - _ = term.Restore(fd, oldState) + return func() {}, fmt.Errorf("make raw: %w", err) } - return initial, resizeCh, restore, nil + return func() { _ = term.Restore(fd, oldState) }, nil } // silentExitError carries an exit code without a printed message. diff --git a/cmd/devcontainer/root.go b/cmd/devcontainer/root.go index 93d3ee2..e8d3bb9 100644 --- a/cmd/devcontainer/root.go +++ b/cmd/devcontainer/root.go @@ -10,7 +10,6 @@ import ( devcontainer "github.com/crunchloop/devcontainer" "github.com/crunchloop/devcontainer/runtime" - "github.com/crunchloop/devcontainer/runtime/applecontainer" "github.com/crunchloop/devcontainer/runtime/docker" ) @@ -78,11 +77,7 @@ func (f *rootFlags) newRuntime(ctx context.Context) (runtime.Runtime, func(), er } return rt, func() { _ = rt.Close() }, nil case "applecontainer": - rt, err := applecontainer.New(ctx, applecontainer.Options{}) - if err != nil { - return nil, nil, fmt.Errorf("applecontainer runtime: %w", err) - } - return rt, func() {}, nil + return newAppleContainerRuntime(ctx) default: return nil, nil, fmt.Errorf("unknown runtime %q (want docker | applecontainer)", f.runtimeName) } diff --git a/cmd/devcontainer/runtime_applecontainer_darwin_arm64.go b/cmd/devcontainer/runtime_applecontainer_darwin_arm64.go new file mode 100644 index 0000000..c861142 --- /dev/null +++ b/cmd/devcontainer/runtime_applecontainer_darwin_arm64.go @@ -0,0 +1,19 @@ +//go:build darwin && arm64 + +package main + +import ( + "context" + "fmt" + + "github.com/crunchloop/devcontainer/runtime" + "github.com/crunchloop/devcontainer/runtime/applecontainer" +) + +func newAppleContainerRuntime(ctx context.Context) (runtime.Runtime, func(), error) { + rt, err := applecontainer.New(ctx, applecontainer.Options{}) + if err != nil { + return nil, nil, fmt.Errorf("applecontainer runtime: %w", err) + } + return rt, func() {}, nil +} diff --git a/cmd/devcontainer/runtime_applecontainer_other.go b/cmd/devcontainer/runtime_applecontainer_other.go new file mode 100644 index 0000000..aeecfad --- /dev/null +++ b/cmd/devcontainer/runtime_applecontainer_other.go @@ -0,0 +1,14 @@ +//go:build !(darwin && arm64) + +package main + +import ( + "context" + "fmt" + + "github.com/crunchloop/devcontainer/runtime" +) + +func newAppleContainerRuntime(_ context.Context) (runtime.Runtime, func(), error) { + return nil, nil, fmt.Errorf("applecontainer runtime is only supported on darwin/arm64") +} From 91ca6f9ad72d4449434eed3a3af6b723a6ed733e Mon Sep 17 00:00:00 2001 From: bilby91 Date: Fri, 22 May 2026 17:49:01 -0300 Subject: [PATCH 3/4] cli: gofmt down.go Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/devcontainer/down.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/devcontainer/down.go b/cmd/devcontainer/down.go index 91c052d..2a06278 100644 --- a/cmd/devcontainer/down.go +++ b/cmd/devcontainer/down.go @@ -12,10 +12,12 @@ import ( func newDownCmd(rf *rootFlags) *cobra.Command { var removeVolumes bool return newDownLikeCmd(rf, downLikeOpts{ - use: "down", - short: "Stop and remove the workspace's dev container", - remove: true, - extraFlags: func(c *cobra.Command) { c.Flags().BoolVar(&removeVolumes, "remove-volumes", false, "Also remove anonymous volumes created by the container") }, + use: "down", + short: "Stop and remove the workspace's dev container", + remove: true, + extraFlags: func(c *cobra.Command) { + c.Flags().BoolVar(&removeVolumes, "remove-volumes", false, "Also remove anonymous volumes created by the container") + }, removeVolumes: &removeVolumes, }) } From eea425de9a6f60d0358c2849b5c6e5d3c8de3186 Mon Sep 17 00:00:00 2001 From: bilby91 Date: Fri, 22 May 2026 17:54:31 -0300 Subject: [PATCH 4/4] cli: satisfy errcheck + drop unused helper golangci-lint surfaced two real issues: - 18 unchecked fmt.Fprintf returns to stderr (errcheck). Centralise the swallow in two tiny helpers (stderrf / outf) so the silenced error is named once, not scattered around. Writing to stderr has no actionable recovery; the explicit ignore is more honest than a blanket exclusion in .golangci.yml. - exitCodeFor was unused (main.go uses errors.As directly). Removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/devcontainer/down.go | 7 ++-- cmd/devcontainer/events.go | 48 +++++++++++++++++---------- cmd/devcontainer/exec.go | 9 ----- cmd/devcontainer/main.go | 3 +- cmd/devcontainer/run_user_commands.go | 3 +- cmd/devcontainer/up.go | 7 ++-- 6 files changed, 36 insertions(+), 41 deletions(-) diff --git a/cmd/devcontainer/down.go b/cmd/devcontainer/down.go index 2a06278..c638c7c 100644 --- a/cmd/devcontainer/down.go +++ b/cmd/devcontainer/down.go @@ -1,9 +1,6 @@ package main import ( - "fmt" - "os" - "github.com/spf13/cobra" devcontainer "github.com/crunchloop/devcontainer" @@ -84,9 +81,9 @@ func newDownLikeCmd(rf *rootFlags, o downLikeOpts) *cobra.Command { } if o.remove { - fmt.Fprintf(os.Stderr, "✓ workspace %s down\n", workspace.ID) + stderrf("✓ workspace %s down\n", workspace.ID) } else { - fmt.Fprintf(os.Stderr, "✓ workspace %s stopped\n", workspace.ID) + stderrf("✓ workspace %s stopped\n", workspace.ID) } return nil }, diff --git a/cmd/devcontainer/events.go b/cmd/devcontainer/events.go index 551e8a1..7e246fd 100644 --- a/cmd/devcontainer/events.go +++ b/cmd/devcontainer/events.go @@ -8,6 +8,18 @@ import ( "github.com/crunchloop/devcontainer/events" ) +// stderrf writes a formatted message to stderr, ignoring write errors — +// there's nothing reasonable to do if writing to stderr itself fails. +func stderrf(format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, format, args...) +} + +// outf is the io.Writer-targeted variant used by printEvent (the writer +// is injectable so tests can substitute a buffer). +func outf(w io.Writer, format string, args ...any) { + _, _ = fmt.Fprintf(w, format, args...) +} + // startEventPrinter spawns a goroutine that drains ch to stderr in // human-readable form and returns the channel for callers to pass to // the engine, plus a stop function that closes the channel and waits @@ -33,42 +45,42 @@ func startEventPrinter() (chan events.Event, func()) { func printEvent(w io.Writer, ev events.Event) { switch e := ev.(type) { case events.ConfigWarningEvent: - fmt.Fprintf(w, "[warn] %s: %s\n", e.Code, e.Message) + outf(w, "[warn] %s: %s\n", e.Code, e.Message) case events.WarnEvent: - fmt.Fprintf(w, "[warn] %s\n", e.Message) + outf(w, "[warn] %s\n", e.Message) case events.LifecycleStartEvent: - fmt.Fprintf(w, "[lifecycle] %s starting\n", e.Phase) + outf(w, "[lifecycle] %s starting\n", e.Phase) case events.LifecycleOutputEvent: - fmt.Fprintf(w, "[%s] %s", e.Phase, e.Line) + outf(w, "[%s] %s", e.Phase, e.Line) case events.LifecycleCompletedEvent: - fmt.Fprintf(w, "[lifecycle] %s done\n", e.Phase) + outf(w, "[lifecycle] %s done\n", e.Phase) case events.LifecycleSkippedEvent: - fmt.Fprintf(w, "[lifecycle] %s skipped: %s\n", e.Phase, e.Reason) + outf(w, "[lifecycle] %s skipped: %s\n", e.Phase, e.Reason) case events.BuildStartEvent: - fmt.Fprintf(w, "[build] start\n") + outf(w, "[build] start\n") case events.BuildLogEvent: - fmt.Fprint(w, e.Line) + _, _ = fmt.Fprint(w, e.Line) case events.BuildCompletedEvent: - fmt.Fprintf(w, "[build] done: %s\n", e.ImageID) + outf(w, "[build] done: %s\n", e.ImageID) case events.FeatureResolveStartEvent: - fmt.Fprintf(w, "[feature] resolving %s\n", e.Ref) + outf(w, "[feature] resolving %s\n", e.Ref) case events.FeatureResolvedEvent: if e.FromCache { - fmt.Fprintf(w, "[feature] %s (cached)\n", e.Ref) + outf(w, "[feature] %s (cached)\n", e.Ref) } else { - fmt.Fprintf(w, "[feature] %s\n", e.Ref) + outf(w, "[feature] %s\n", e.Ref) } case events.ContainerCreatingEvent: - fmt.Fprintf(w, "[container] creating\n") + outf(w, "[container] creating\n") case events.ContainerCreatedEvent: - fmt.Fprintf(w, "[container] created: %s\n", e.ContainerID) + outf(w, "[container] created: %s\n", e.ContainerID) case events.ContainerStartedEvent: - fmt.Fprintf(w, "[container] started\n") + outf(w, "[container] started\n") case events.ContainerStoppedEvent: - fmt.Fprintf(w, "[container] stopped: %s\n", e.ContainerID) + outf(w, "[container] stopped: %s\n", e.ContainerID) case events.ContainerRemovedEvent: - fmt.Fprintf(w, "[container] removed: %s\n", e.ContainerID) + outf(w, "[container] removed: %s\n", e.ContainerID) default: - fmt.Fprintf(w, "[%s]\n", ev.EventType()) + outf(w, "[%s]\n", ev.EventType()) } } diff --git a/cmd/devcontainer/exec.go b/cmd/devcontainer/exec.go index 23d31f9..263d4d9 100644 --- a/cmd/devcontainer/exec.go +++ b/cmd/devcontainer/exec.go @@ -1,7 +1,6 @@ package main import ( - "errors" "fmt" "os" @@ -107,11 +106,3 @@ func setupTty(tty bool) (func(), error) { type silentExitError struct{ code int } func (s silentExitError) Error() string { return fmt.Sprintf("exit status %d", s.code) } - -func exitCodeFor(err error) int { - var s silentExitError - if errors.As(err, &s) { - return s.code - } - return 1 -} diff --git a/cmd/devcontainer/main.go b/cmd/devcontainer/main.go index 2dcbcb6..ffdb54e 100644 --- a/cmd/devcontainer/main.go +++ b/cmd/devcontainer/main.go @@ -9,7 +9,6 @@ package main import ( "context" "errors" - "fmt" "os" "os/signal" "syscall" @@ -27,6 +26,6 @@ func main() { if errors.As(err, &silent) { os.Exit(silent.code) } - fmt.Fprintln(os.Stderr, "error:", err) + stderrf("error: %s\n", err) os.Exit(1) } diff --git a/cmd/devcontainer/run_user_commands.go b/cmd/devcontainer/run_user_commands.go index b305f55..5938197 100644 --- a/cmd/devcontainer/run_user_commands.go +++ b/cmd/devcontainer/run_user_commands.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "os" "github.com/spf13/cobra" @@ -73,7 +72,7 @@ func newRunUserCommandsCmd(rf *rootFlags) *cobra.Command { return err } } - fmt.Fprintf(os.Stderr, "✓ lifecycle commands complete\n") + stderrf("✓ lifecycle commands complete\n") return nil }, } diff --git a/cmd/devcontainer/up.go b/cmd/devcontainer/up.go index 350b1fb..065d7ff 100644 --- a/cmd/devcontainer/up.go +++ b/cmd/devcontainer/up.go @@ -1,9 +1,6 @@ package main import ( - "fmt" - "os" - "github.com/spf13/cobra" devcontainer "github.com/crunchloop/devcontainer" @@ -48,9 +45,9 @@ func newUpCmd(rf *rootFlags) *cobra.Command { return upErr } - fmt.Fprintf(os.Stderr, "✓ workspace %s ready\n", workspace.ID) + stderrf("✓ workspace %s ready\n", workspace.ID) if workspace.Container != nil { - fmt.Fprintf(os.Stderr, " container: %s\n", workspace.Container.ID) + stderrf(" container: %s\n", workspace.Container.ID) } return nil },