From dca98c2c1eaf15f9227351a5da906487774cac8d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 05:21:25 -0500 Subject: [PATCH 1/2] feat(cmd): add run-user-commands lifecycle command Add a new CLI command that connects to an already-running devcontainer and executes lifecycle commands (postCreateCommand, postStartCommand, postAttachCommand) in order. This enables CI/CD pipelines to trigger lifecycle hooks on existing containers without a full `up` cycle. Includes a hidden `runUserCommands` camelCase alias for devcontainer CLI compatibility and outputs the JSON result envelope on stdout. --- cmd/root.go | 1 + cmd/runusercommands.go | 202 ++++++++++++++++++++++++++++++++++++ cmd/runusercommands_test.go | 103 ++++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 cmd/runusercommands.go create mode 100644 cmd/runusercommands_test.go diff --git a/cmd/root.go b/cmd/root.go index ea698b061..446646a89 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -132,6 +132,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewExecCmd(globalFlags)) rootCmd.AddCommand(NewOutdatedCmd(globalFlags)) rootCmd.AddCommand(NewSetUpCmd(globalFlags)) + rootCmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) diff --git a/cmd/runusercommands.go b/cmd/runusercommands.go new file mode 100644 index 000000000..46485b7ee --- /dev/null +++ b/cmd/runusercommands.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/log" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/types" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/spf13/cobra" +) + +// RunUserCommandsCmd holds the run-user-commands command flags. +type RunUserCommandsCmd struct { + *flags.GlobalFlags + + WorkspaceFolder string + IDLabels []string +} + +// NewRunUserCommandsCmd creates a new run-user-commands command. +func NewRunUserCommandsCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &RunUserCommandsCmd{GlobalFlags: f} + runCmd := &cobra.Command{ + Use: "run-user-commands", + Aliases: []string{"runUserCommands"}, + Short: "Executes lifecycle commands in a running workspace container", + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + + runCmd.Flags(). + StringVar( + &cmd.WorkspaceFolder, + "workspace-folder", + "", + "Path to the workspace folder", + ) + _ = runCmd.MarkFlagRequired("workspace-folder") + runCmd.Flags(). + StringArrayVar( + &cmd.IDLabels, + "id-label", + []string{}, + "Override the default container identification labels (format: key=value, can be specified multiple times)", + ) + + return runCmd +} + +type lifecycleExecParams struct { + ctx context.Context + helper *docker.DockerHelper + containerID string + envArgs []string + workdir string +} + +// Run executes the run-user-commands logic. +func (cmd *RunUserCommandsCmd) Run(ctx context.Context) error { + if err := devcconfig.ValidateIDLabels(cmd.IDLabels); err != nil { + return err + } + + params, result, err := cmd.resolveContainer(ctx) + if err != nil { + return err + } + + if err := cmd.runLifecycleHooks(params, result); err != nil { + return err + } + + user := devcconfig.GetRemoteUser(result) + workdir := params.workdir + log.Infof("lifecycle commands completed for container %s", params.containerID) + _ = devcconfig.WriteResultJSON(os.Stdout, params.containerID, user, workdir) + return nil +} + +func (cmd *RunUserCommandsCmd) resolveContainer( + ctx context.Context, +) (*lifecycleExecParams, *devcconfig.Result, error) { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return nil, nil, err + } + + client, err := workspace2.Get(ctx, workspace2.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{cmd.WorkspaceFolder}, + Owner: cmd.Owner, + }) + if err != nil { + _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + return nil, nil, fmt.Errorf("resolve workspace: %w", err) + } + + workspaceConfig := client.WorkspaceConfig() + dockerCommand := resolveDockerCommand(workspaceConfig) + + containerDetails, err := findRunningContainer( + ctx, dockerCommand, devcontainerGetRunnerID(workspaceConfig), cmd.IDLabels, + ) + if err != nil { + _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + return nil, nil, err + } + + result := loadExecResult(workspaceConfig, containerDetails) + if result == nil || result.MergedConfig == nil { + _ = devcconfig.WriteErrorJSON( + os.Stdout, + "no workspace result found; lifecycle commands unavailable", + ) + return nil, nil, fmt.Errorf("no workspace result found; lifecycle commands unavailable") + } + + params := &lifecycleExecParams{ + ctx: ctx, + helper: &docker.DockerHelper{DockerCommand: dockerCommand}, + containerID: containerDetails.ID, + envArgs: buildLifecycleEnvArgs(result), + workdir: resolveExecWorkdir(result, client.Workspace()), + } + return params, result, nil +} + +func (cmd *RunUserCommandsCmd) runLifecycleHooks( + params *lifecycleExecParams, + result *devcconfig.Result, +) error { + hooks := []struct { + name string + cmds []types.LifecycleHook + }{ + {"postCreateCommand", result.MergedConfig.PostCreateCommands}, + {"postStartCommand", result.MergedConfig.PostStartCommands}, + {"postAttachCommand", result.MergedConfig.PostAttachCommands}, + } + + for _, hook := range hooks { + for _, h := range hook.cmds { + if err := execLifecycleHook(params, hook.name, h); err != nil { + _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + return fmt.Errorf("lifecycle hooks: %s: %w", hook.name, err) + } + } + } + return nil +} + +func execLifecycleHook(params *lifecycleExecParams, name string, hook types.LifecycleHook) error { + if len(hook) == 0 { + return nil + } + + for key, command := range hook { + if len(command) == 0 { + continue + } + log.Infof("running %s: %s %v", name, key, command) + + args := buildDockerExecArgs(params.containerID, params.envArgs, params.workdir, command) + if err := params.helper.Run(params.ctx, args, os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("command %q failed: %w", key, err) + } + } + + return nil +} + +func buildLifecycleEnvArgs(result *devcconfig.Result) []string { + if result == nil || result.MergedConfig == nil { + return nil + } + + env := result.MergedConfig.RemoteEnv + if len(env) == 0 { + return nil + } + + args := make([]string, 0, len(env)*2) + for k, v := range env { + if v != nil { + args = append(args, "-e", k+"="+*v) + } + } + return args +} + +func devcontainerGetRunnerID(ws *provider2.Workspace) string { + return devcontainer.GetRunnerIDFromWorkspace(ws) +} diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go new file mode 100644 index 000000000..0f15384cc --- /dev/null +++ b/cmd/runusercommands_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRunUserCommandsCmd_CommandName(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + assert.Equal(t, "run-user-commands", cmd.Use) +} + +func TestNewRunUserCommandsCmd_HasCamelCaseAlias(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + assert.Contains(t, cmd.Aliases, "runUserCommands") +} + +func TestNewRunUserCommandsCmd_WorkspaceFolderRequired(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +} + +func TestNewRunUserCommandsCmd_IDLabelFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("id-label") + require.NotNil(t, f) + assert.Equal(t, "stringArray", f.Value.Type()) +} + +func TestBuildLifecycleEnvArgs_Nil(t *testing.T) { + args := buildLifecycleEnvArgs(nil) + assert.Nil(t, args) +} + +func TestBuildLifecycleEnvArgs_NilMergedConfig(t *testing.T) { + result := &devcconfig.Result{} + args := buildLifecycleEnvArgs(result) + assert.Nil(t, args) +} + +func TestBuildLifecycleEnvArgs_EmptyEnv(t *testing.T) { + result := &devcconfig.Result{ + MergedConfig: &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + RemoteEnv: map[string]*string{}, + }, + }, + } + args := buildLifecycleEnvArgs(result) + assert.Nil(t, args) +} + +func TestBuildLifecycleEnvArgs_WithValues(t *testing.T) { + val := "bar" + result := &devcconfig.Result{ + MergedConfig: &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + RemoteEnv: map[string]*string{ + "FOO": &val, + }, + }, + }, + } + args := buildLifecycleEnvArgs(result) + assert.Equal(t, []string{"-e", "FOO=bar"}, args) +} + +func TestBuildLifecycleEnvArgs_NilValueSkipped(t *testing.T) { + val := "keep" + result := &devcconfig.Result{ + MergedConfig: &devcconfig.MergedDevContainerConfig{ + DevContainerConfigBase: devcconfig.DevContainerConfigBase{ + RemoteEnv: map[string]*string{ + "KEEP": &val, + "REMOVE": nil, + }, + }, + }, + } + args := buildLifecycleEnvArgs(result) + assert.Contains(t, args, "-e") + assert.Contains(t, args, "KEEP=keep") + assert.NotContains(t, args, "REMOVE") +} + +func TestRunUserCommandsCmd_RegisteredInRoot(t *testing.T) { + rootCmd := BuildRoot() + found := false + for _, sub := range rootCmd.Commands() { + if sub.Use == "run-user-commands" { + found = true + break + } + } + assert.True(t, found, "run-user-commands should be registered in root") +} From d11536a371bd8c8c36859a13144aabeb99ab24b6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 05:30:29 -0500 Subject: [PATCH 2/2] fix(cmd): address review feedback on run-user-commands - Write JSON envelope to stderr (not stdout) so subprocess output doesn't corrupt the parseable envelope - Register camelCase alias as a separate hidden command instead of using Cobra's visible Aliases field - Remove unnecessary devcontainerGetRunnerID wrapper function - Sort env map keys for deterministic arg ordering --- cmd/root.go | 1 + cmd/runusercommands.go | 50 ++++++++++++++++++++++--------------- cmd/runusercommands_test.go | 20 ++++++++++++--- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 446646a89..469efb78f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -133,6 +133,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewOutdatedCmd(globalFlags)) rootCmd.AddCommand(NewSetUpCmd(globalFlags)) rootCmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) + rootCmd.AddCommand(NewRunUserCommandsCmdAlias(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) diff --git a/cmd/runusercommands.go b/cmd/runusercommands.go index 46485b7ee..de1e77f08 100644 --- a/cmd/runusercommands.go +++ b/cmd/runusercommands.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "sort" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" @@ -11,7 +12,6 @@ import ( devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/log" - provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/types" workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" @@ -28,13 +28,14 @@ type RunUserCommandsCmd struct { // NewRunUserCommandsCmd creates a new run-user-commands command. func NewRunUserCommandsCmd(f *flags.GlobalFlags) *cobra.Command { cmd := &RunUserCommandsCmd{GlobalFlags: f} + runE := func(cobraCmd *cobra.Command, _ []string) error { + return cmd.Run(cobraCmd.Context()) + } + runCmd := &cobra.Command{ - Use: "run-user-commands", - Aliases: []string{"runUserCommands"}, - Short: "Executes lifecycle commands in a running workspace container", - RunE: func(cobraCmd *cobra.Command, args []string) error { - return cmd.Run(cobraCmd.Context()) - }, + Use: "run-user-commands", + Short: "Executes lifecycle commands in a running workspace container", + RunE: runE, } runCmd.Flags(). @@ -56,6 +57,14 @@ func NewRunUserCommandsCmd(f *flags.GlobalFlags) *cobra.Command { return runCmd } +// NewRunUserCommandsCmdAlias creates the hidden camelCase alias for devcontainer CLI compat. +func NewRunUserCommandsCmdAlias(f *flags.GlobalFlags) *cobra.Command { + primary := NewRunUserCommandsCmd(f) + primary.Use = "runUserCommands" + primary.Hidden = true + return primary +} + type lifecycleExecParams struct { ctx context.Context helper *docker.DockerHelper @@ -80,9 +89,8 @@ func (cmd *RunUserCommandsCmd) Run(ctx context.Context) error { } user := devcconfig.GetRemoteUser(result) - workdir := params.workdir log.Infof("lifecycle commands completed for container %s", params.containerID) - _ = devcconfig.WriteResultJSON(os.Stdout, params.containerID, user, workdir) + _ = devcconfig.WriteResultJSON(os.Stderr, params.containerID, user, params.workdir) return nil } @@ -100,7 +108,7 @@ func (cmd *RunUserCommandsCmd) resolveContainer( Owner: cmd.Owner, }) if err != nil { - _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) return nil, nil, fmt.Errorf("resolve workspace: %w", err) } @@ -108,17 +116,17 @@ func (cmd *RunUserCommandsCmd) resolveContainer( dockerCommand := resolveDockerCommand(workspaceConfig) containerDetails, err := findRunningContainer( - ctx, dockerCommand, devcontainerGetRunnerID(workspaceConfig), cmd.IDLabels, + ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, ) if err != nil { - _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) return nil, nil, err } result := loadExecResult(workspaceConfig, containerDetails) if result == nil || result.MergedConfig == nil { _ = devcconfig.WriteErrorJSON( - os.Stdout, + os.Stderr, "no workspace result found; lifecycle commands unavailable", ) return nil, nil, fmt.Errorf("no workspace result found; lifecycle commands unavailable") @@ -150,7 +158,7 @@ func (cmd *RunUserCommandsCmd) runLifecycleHooks( for _, hook := range hooks { for _, h := range hook.cmds { if err := execLifecycleHook(params, hook.name, h); err != nil { - _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) return fmt.Errorf("lifecycle hooks: %s: %w", hook.name, err) } } @@ -188,15 +196,17 @@ func buildLifecycleEnvArgs(result *devcconfig.Result) []string { return nil } - args := make([]string, 0, len(env)*2) + keys := make([]string, 0, len(env)) for k, v := range env { if v != nil { - args = append(args, "-e", k+"="+*v) + keys = append(keys, k) } } - return args -} + sort.Strings(keys) -func devcontainerGetRunnerID(ws *provider2.Workspace) string { - return devcontainer.GetRunnerIDFromWorkspace(ws) + args := make([]string, 0, len(keys)*2) + for _, k := range keys { + args = append(args, "-e", k+"="+*env[k]) + } + return args } diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go index 0f15384cc..ba6e7d6ba 100644 --- a/cmd/runusercommands_test.go +++ b/cmd/runusercommands_test.go @@ -14,9 +14,23 @@ func TestNewRunUserCommandsCmd_CommandName(t *testing.T) { assert.Equal(t, "run-user-commands", cmd.Use) } -func TestNewRunUserCommandsCmd_HasCamelCaseAlias(t *testing.T) { - cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) - assert.Contains(t, cmd.Aliases, "runUserCommands") +func TestNewRunUserCommandsCmdAlias_IsHidden(t *testing.T) { + cmd := NewRunUserCommandsCmdAlias(&flags.GlobalFlags{}) + assert.Equal(t, "runUserCommands", cmd.Use) + assert.True(t, cmd.Hidden, "camelCase alias should be hidden") +} + +func TestNewRunUserCommandsCmdAlias_RegisteredInRoot(t *testing.T) { + rootCmd := BuildRoot() + found := false + for _, sub := range rootCmd.Commands() { + if sub.Use == "runUserCommands" { + found = true + assert.True(t, sub.Hidden) + break + } + } + assert.True(t, found, "runUserCommands alias should be registered in root") } func TestNewRunUserCommandsCmd_WorkspaceFolderRequired(t *testing.T) {