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
95 changes: 95 additions & 0 deletions cmd/devcontainer/down.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"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 {
stderrf("✓ workspace %s down\n", workspace.ID)
} else {
stderrf("✓ workspace %s stopped\n", workspace.ID)
}
return nil
},
}
if o.extraFlags != nil {
o.extraFlags(cmd)
}
return cmd
}
86 changes: 86 additions & 0 deletions cmd/devcontainer/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"fmt"
"io"
"os"

"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
// 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:
outf(w, "[warn] %s: %s\n", e.Code, e.Message)
case events.WarnEvent:
outf(w, "[warn] %s\n", e.Message)
case events.LifecycleStartEvent:
outf(w, "[lifecycle] %s starting\n", e.Phase)
case events.LifecycleOutputEvent:
outf(w, "[%s] %s", e.Phase, e.Line)
case events.LifecycleCompletedEvent:
outf(w, "[lifecycle] %s done\n", e.Phase)
case events.LifecycleSkippedEvent:
outf(w, "[lifecycle] %s skipped: %s\n", e.Phase, e.Reason)
case events.BuildStartEvent:
outf(w, "[build] start\n")
case events.BuildLogEvent:
_, _ = fmt.Fprint(w, e.Line)
case events.BuildCompletedEvent:
outf(w, "[build] done: %s\n", e.ImageID)
case events.FeatureResolveStartEvent:
outf(w, "[feature] resolving %s\n", e.Ref)
case events.FeatureResolvedEvent:
if e.FromCache {
outf(w, "[feature] %s (cached)\n", e.Ref)
} else {
outf(w, "[feature] %s\n", e.Ref)
}
case events.ContainerCreatingEvent:
outf(w, "[container] creating\n")
case events.ContainerCreatedEvent:
outf(w, "[container] created: %s\n", e.ContainerID)
case events.ContainerStartedEvent:
outf(w, "[container] started\n")
case events.ContainerStoppedEvent:
outf(w, "[container] stopped: %s\n", e.ContainerID)
case events.ContainerRemovedEvent:
outf(w, "[container] removed: %s\n", e.ContainerID)
default:
outf(w, "[%s]\n", ev.EventType())
}
}
108 changes: 108 additions & 0 deletions cmd/devcontainer/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package main

import (
"fmt"
"os"

"github.com/spf13/cobra"
"golang.org/x/term"

devcontainer "github.com/crunchloop/devcontainer"
)

func newExecCmd(rf *rootFlags) *cobra.Command {
var (
user string
workingDir string
noTty bool
)

cmd := &cobra.Command{
Use: "exec <cmd> [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()))
restore, err := setupTty(tty)
if err != nil {
return err
}
defer restore()

// 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
}
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 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 func() {}, nil
}
fd := int(os.Stdin.Fd())
oldState, err := term.MakeRaw(fd)
if err != nil {
return func() {}, fmt.Errorf("make raw: %w", err)
}
return func() { _ = term.Restore(fd, oldState) }, 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) }
31 changes: 31 additions & 0 deletions cmd/devcontainer/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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"
"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)
}
stderrf("error: %s\n", err)
os.Exit(1)
}
37 changes: 37 additions & 0 deletions cmd/devcontainer/read_configuration.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
Loading
Loading