From 3305d1f90d7f610860b6c5126a282689d4ea3a64 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 16:00:36 -0500 Subject: [PATCH 1/3] feat(cmd): add minor CLI flags across up, build, exec, run-user-commands Add 15 missing CLI flags that align with devcontainer CLI parity: - up: --skip-post-create, --skip-post-start, --skip-post-attach, --container-user, --remote-user - build: --no-cache, --image-name, --no-build - exec: --container-data-folder, --skip-post-create - run-user-commands: --skip-post-create, --skip-post-start, --skip-post-attach, --skip-on-create, --skip-update-content Wire skip flags through lifecycle hook execution so skipped phases are logged and excluded from the hook pipeline. --- cmd/agent/container/deferred_hooks.go | 2 +- cmd/agent/container/setup.go | 3 + cmd/agent/workspace/build.go | 10 ++- cmd/build.go | 5 ++ cmd/build_test.go | 69 +++++++++++++++++ cmd/exec.go | 16 ++++ cmd/exec_test.go | 39 ++++++++++ cmd/minor_flags_test.go | 23 ------ cmd/runusercommands.go | 32 ++++++-- cmd/runusercommands_test.go | 68 +++++++++++++++++ cmd/up.go | 12 +++ cmd/up_test.go | 75 +++++++++++++++++++ pkg/devcontainer/build/options.go | 5 +- pkg/devcontainer/setup.go | 6 ++ pkg/devcontainer/setup/lifecyclehooks.go | 40 +++++++++- pkg/devcontainer/setup/lifecyclehooks_test.go | 4 +- pkg/devcontainer/setup/setup.go | 15 +++- pkg/driver/docker/build.go | 6 ++ pkg/provider/workspace.go | 14 +++- 19 files changed, 405 insertions(+), 39 deletions(-) create mode 100644 cmd/build_test.go diff --git a/cmd/agent/container/deferred_hooks.go b/cmd/agent/container/deferred_hooks.go index b0a7f25f9..717ba7235 100644 --- a/cmd/agent/container/deferred_hooks.go +++ b/cmd/agent/container/deferred_hooks.go @@ -69,7 +69,7 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error { Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, RemoteUser: config.GetRemoteUser(setupInfo), - }, cmd.SecretsEnv) + }, cmd.SecretsEnv, setup.SkipPhases{}) if err != nil { return fmt.Errorf("deferred hooks setup: %w", err) } diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index 181dce551..d3f08ac01 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -193,6 +193,9 @@ func (cmd *SetupContainerCmd) finalizeSetup(sctx *setupContext) error { PlatformOptions: &sctx.workspaceInfo.CLIOptions.Platform, TunnelClient: sctx.tunnelClient, Prebuild: cmd.Prebuild, + SkipPostCreate: sctx.workspaceInfo.CLIOptions.SkipPostCreate, + SkipPostStart: sctx.workspaceInfo.CLIOptions.SkipPostStart, + SkipPostAttach: sctx.workspaceInfo.CLIOptions.SkipPostAttach, Dotfiles: setup.DotfilesConfig{ Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, diff --git a/cmd/agent/workspace/build.go b/cmd/agent/workspace/build.go index adbfd683d..d828a5487 100644 --- a/cmd/agent/workspace/build.go +++ b/cmd/agent/workspace/build.go @@ -89,10 +89,12 @@ func (cmd *BuildCmd) Run(ctx context.Context) error { for _, platform := range platforms { // build the image imageName, err := runner.Build(ctx, provider2.BuildOptions{ - CLIOptions: workspaceInfo.CLIOptions, - RegistryCache: workspaceInfo.RegistryCache, - Platform: platform, - ExportCache: true, + CLIOptions: workspaceInfo.CLIOptions, + RegistryCache: workspaceInfo.RegistryCache, + Platform: platform, + ExportCache: true, + NoBuild: workspaceInfo.CLIOptions.NoBuild, + PushDuringBuild: workspaceInfo.CLIOptions.PushDuringBuild, }) if err != nil { log.Errorf("Error building image: %v", err) diff --git a/cmd/build.go b/cmd/build.go index 174de8082..9da26ec06 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -187,6 +187,11 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command { BoolVar(&cmd.GitCloneRecursiveSubmodules, "git-clone-recursive-submodules", false, "If true will clone git submodule repositories recursively") + buildCmd.Flags(). + StringVar(&cmd.ImageName, "image-name", "", "Alternative name for the built image") + buildCmd.Flags(). + BoolVar(&cmd.NoBuild, "no-build", false, "Fail if the image must be built (enforce pre-built images only)") + // TESTING buildCmd.Flags().BoolVar(&cmd.ForceBuild, "force-build", false, "TESTING ONLY") buildCmd.Flags(). diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 000000000..46c9b3fa8 --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildCmd_NoCacheFlag(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + flag := buildCmd.Flags().Lookup("no-cache") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestBuildCmd_NoCacheFlagParsesValue(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + err := buildCmd.ParseFlags([]string{"--no-cache"}) + require.NoError(t, err) + + val, err := buildCmd.Flags().GetBool("no-cache") + require.NoError(t, err) + assert.True(t, val) +} + +func TestBuildCmd_ImageNameFlag(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + flag := buildCmd.Flags().Lookup("image-name") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} + +func TestBuildCmd_ImageNameFlagParsesValue(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + err := buildCmd.ParseFlags([]string{"--image-name", "my-custom-image:latest"}) + require.NoError(t, err) + + flag := buildCmd.Flags().Lookup("image-name") + assert.Equal(t, "my-custom-image:latest", flag.Value.String()) +} + +func TestBuildCmd_NoBuildFlag(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + flag := buildCmd.Flags().Lookup("no-build") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestBuildCmd_NoBuildFlagParsesValue(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + err := buildCmd.ParseFlags([]string{"--no-build"}) + require.NoError(t, err) + + val, err := buildCmd.Flags().GetBool("no-build") + require.NoError(t, err) + assert.True(t, val) +} + +func TestBuildCmd_NoCacheDefaultFalse(t *testing.T) { + buildCmd := NewBuildCmd(&flags.GlobalFlags{}) + err := buildCmd.ParseFlags([]string{}) + require.NoError(t, err) + + val, err := buildCmd.Flags().GetBool("no-cache") + require.NoError(t, err) + assert.False(t, val) +} diff --git a/cmd/exec.go b/cmd/exec.go index eee1a2ef8..a7bae9e15 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -33,6 +33,8 @@ type ExecCmd struct { RemoteEnv []string DefaultUserEnvProbe string IDLabels []string + ContainerDataFolder string + SkipPostCreate bool } func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { @@ -89,6 +91,20 @@ func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { []string{}, "Override the default container identification labels (format: key=value, can be specified multiple times)", ) + execCmd.Flags(). + StringVar( + &cmd.ContainerDataFolder, + "container-data-folder", + "", + "Override the default container data folder path", + ) + execCmd.Flags(). + BoolVar( + &cmd.SkipPostCreate, + "skip-post-create", + false, + "Skip running postCreateCommand", + ) return execCmd } diff --git a/cmd/exec_test.go b/cmd/exec_test.go index e371e598f..64f08224f 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -95,3 +95,42 @@ func TestExecCmd_NonExistentContainerID(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "nonexistent-container-id-12345") } + +func TestExecCmd_ContainerDataFolderFlag(t *testing.T) { + execCmd := NewExecCmd(&flags.GlobalFlags{}) + flag := execCmd.Flags().Lookup("container-data-folder") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} + +func TestExecCmd_ContainerDataFolderFlagParsesValue(t *testing.T) { + execCmd := NewExecCmd(&flags.GlobalFlags{}) + err := execCmd.ParseFlags([]string{ + "--workspace-folder", "/tmp", + "--container-data-folder", "/custom/data", + }) + require.NoError(t, err) + + flag := execCmd.Flags().Lookup("container-data-folder") + assert.Equal(t, "/custom/data", flag.Value.String()) +} + +func TestExecCmd_SkipPostCreateFlag(t *testing.T) { + execCmd := NewExecCmd(&flags.GlobalFlags{}) + flag := execCmd.Flags().Lookup("skip-post-create") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestExecCmd_SkipPostCreateFlagParsesValue(t *testing.T) { + execCmd := NewExecCmd(&flags.GlobalFlags{}) + err := execCmd.ParseFlags([]string{ + "--workspace-folder", "/tmp", + "--skip-post-create", + }) + require.NoError(t, err) + + val, err := execCmd.Flags().GetBool("skip-post-create") + require.NoError(t, err) + assert.True(t, val) +} diff --git a/cmd/minor_flags_test.go b/cmd/minor_flags_test.go index 4dd4d11fc..f54447083 100644 --- a/cmd/minor_flags_test.go +++ b/cmd/minor_flags_test.go @@ -72,13 +72,6 @@ func TestUpCmd_TerminalRowsFlagParsesValue(t *testing.T) { assert.Equal(t, 40, val) } -func TestUpCmd_SkipPostCreateFlag(t *testing.T) { - upCmd := NewUpCmd(&flags.GlobalFlags{}) - flag := upCmd.Flags().Lookup("skip-post-create") - require.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) -} - func TestUpCmd_SkipPostCreateFlagParsesValue(t *testing.T) { upCmd := NewUpCmd(&flags.GlobalFlags{}) err := upCmd.ParseFlags([]string{"--skip-post-create"}) @@ -120,22 +113,6 @@ func TestUpCmd_DotfilesTargetPathFlagParsesValue(t *testing.T) { assert.Equal(t, "~/dotfiles", val) } -func TestBuildCmd_NoCacheFlag(t *testing.T) { - buildCmd := NewBuildCmd(&flags.GlobalFlags{}) - flag := buildCmd.Flags().Lookup("no-cache") - require.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) -} - -func TestBuildCmd_NoCacheFlagParsesValue(t *testing.T) { - buildCmd := NewBuildCmd(&flags.GlobalFlags{}) - err := buildCmd.ParseFlags([]string{"--no-cache"}) - require.NoError(t, err) - val, err := buildCmd.Flags().GetBool("no-cache") - require.NoError(t, err) - assert.True(t, val) -} - func TestBuildCmd_LabelFlag(t *testing.T) { buildCmd := NewBuildCmd(&flags.GlobalFlags{}) flag := buildCmd.Flags().Lookup("label") diff --git a/cmd/runusercommands.go b/cmd/runusercommands.go index 179a6a824..30ce611f4 100644 --- a/cmd/runusercommands.go +++ b/cmd/runusercommands.go @@ -21,8 +21,13 @@ import ( type RunUserCommandsCmd struct { *flags.GlobalFlags - WorkspaceFolder string - IDLabels []string + WorkspaceFolder string + IDLabels []string + SkipPostCreate bool + SkipPostStart bool + SkipPostAttach bool + SkipOnCreate bool + SkipUpdateContent bool } // NewRunUserCommandsCmd creates a new run-user-commands command. @@ -53,6 +58,16 @@ func NewRunUserCommandsCmd(f *flags.GlobalFlags) *cobra.Command { []string{}, "Override the default container identification labels (format: key=value, can be specified multiple times)", ) + runCmd.Flags(). + BoolVar(&cmd.SkipPostCreate, "skip-post-create", false, "Skip running postCreateCommand") + runCmd.Flags(). + BoolVar(&cmd.SkipPostStart, "skip-post-start", false, "Skip running postStartCommand") + runCmd.Flags(). + BoolVar(&cmd.SkipPostAttach, "skip-post-attach", false, "Skip running postAttachCommand") + runCmd.Flags(). + BoolVar(&cmd.SkipOnCreate, "skip-on-create", false, "Skip running onCreateCommand") + runCmd.Flags(). + BoolVar(&cmd.SkipUpdateContent, "skip-update-content", false, "Skip running updateContentCommand") return runCmd } @@ -149,13 +164,20 @@ func (cmd *RunUserCommandsCmd) runLifecycleHooks( hooks := []struct { name string cmds []types.LifecycleHook + skip bool }{ - {"postCreateCommand", result.MergedConfig.PostCreateCommands}, - {"postStartCommand", result.MergedConfig.PostStartCommands}, - {"postAttachCommand", result.MergedConfig.PostAttachCommands}, + {"onCreateCommand", result.MergedConfig.OnCreateCommands, cmd.SkipOnCreate}, + {"updateContentCommand", result.MergedConfig.UpdateContentCommands, cmd.SkipUpdateContent}, + {"postCreateCommand", result.MergedConfig.PostCreateCommands, cmd.SkipPostCreate}, + {"postStartCommand", result.MergedConfig.PostStartCommands, cmd.SkipPostStart}, + {"postAttachCommand", result.MergedConfig.PostAttachCommands, cmd.SkipPostAttach}, } for _, hook := range hooks { + if hook.skip { + log.Infof("skipping %s (--skip flag set)", hook.name) + continue + } for _, h := range hook.cmds { if err := execLifecycleHook(params, hook.name, h); err != nil { _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go index ba6e7d6ba..f8f0ff59a 100644 --- a/cmd/runusercommands_test.go +++ b/cmd/runusercommands_test.go @@ -115,3 +115,71 @@ func TestRunUserCommandsCmd_RegisteredInRoot(t *testing.T) { } assert.True(t, found, "run-user-commands should be registered in root") } + +func TestRunUserCommandsCmd_SkipPostCreateFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("skip-post-create") + require.NotNil(t, f) + assert.Equal(t, "false", f.DefValue) +} + +func TestRunUserCommandsCmd_SkipPostStartFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("skip-post-start") + require.NotNil(t, f) + assert.Equal(t, "false", f.DefValue) +} + +func TestRunUserCommandsCmd_SkipPostAttachFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("skip-post-attach") + require.NotNil(t, f) + assert.Equal(t, "false", f.DefValue) +} + +func TestRunUserCommandsCmd_SkipOnCreateFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("skip-on-create") + require.NotNil(t, f) + assert.Equal(t, "false", f.DefValue) +} + +func TestRunUserCommandsCmd_SkipUpdateContentFlag(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup("skip-update-content") + require.NotNil(t, f) + assert.Equal(t, "false", f.DefValue) +} + +func TestRunUserCommandsCmd_SkipFlagsParseValues(t *testing.T) { + cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) + err := cmd.ParseFlags([]string{ + "--workspace-folder", "/tmp", + "--skip-post-create", + "--skip-post-start", + "--skip-post-attach", + "--skip-on-create", + "--skip-update-content", + }) + require.NoError(t, err) + + val, err := cmd.Flags().GetBool("skip-post-create") + require.NoError(t, err) + assert.True(t, val) + + val, err = cmd.Flags().GetBool("skip-post-start") + require.NoError(t, err) + assert.True(t, val) + + val, err = cmd.Flags().GetBool("skip-post-attach") + require.NoError(t, err) + assert.True(t, val) + + val, err = cmd.Flags().GetBool("skip-on-create") + require.NoError(t, err) + assert.True(t, val) + + val, err = cmd.Flags().GetBool("skip-update-content") + require.NoError(t, err) + assert.True(t, val) +} diff --git a/cmd/up.go b/cmd/up.go index 9ea0ef010..f59fc1633 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -293,6 +293,18 @@ func (cmd *UpCmd) registerDevContainerFlags(upCmd *cobra.Command) { upCmd.Flags(). BoolVar(&cmd.SkipNonBlockingCommands, "skip-non-blocking-commands", false, "Skip non-blocking lifecycle commands") + upCmd.Flags(). + BoolVar(&cmd.SkipPostStart, "skip-post-start", false, + "Skip running postStartCommand") + upCmd.Flags(). + BoolVar(&cmd.SkipPostAttach, "skip-post-attach", false, + "Skip running postAttachCommand") + upCmd.Flags(). + StringVar(&cmd.ContainerUser, "container-user", "", + "Override the user in the container") + upCmd.Flags(). + StringVar(&cmd.RemoteUser, "remote-user", "", + "Override the remoteUser setting") } func (cmd *UpCmd) registerIDEFlags(upCmd *cobra.Command) { diff --git a/cmd/up_test.go b/cmd/up_test.go index fe38cbe9c..7665f7800 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -112,3 +112,78 @@ func TestUpCmd_ValidateWorkspaceMountConsistency(t *testing.T) { }) } } + +func TestUpCmd_SkipPostCreateFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("skip-post-create") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestUpCmd_SkipPostStartFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("skip-post-start") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestUpCmd_SkipPostAttachFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("skip-post-attach") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestUpCmd_SkipFlagsParseValues(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + err := upCmd.ParseFlags([]string{ + "--skip-post-create", + "--skip-post-start", + "--skip-post-attach", + }) + require.NoError(t, err) + + val, err := upCmd.Flags().GetBool("skip-post-create") + require.NoError(t, err) + assert.True(t, val) + + val, err = upCmd.Flags().GetBool("skip-post-start") + require.NoError(t, err) + assert.True(t, val) + + val, err = upCmd.Flags().GetBool("skip-post-attach") + require.NoError(t, err) + assert.True(t, val) +} + +func TestUpCmd_ContainerUserFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("container-user") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} + +func TestUpCmd_ContainerUserFlagParsesValue(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + err := upCmd.ParseFlags([]string{"--container-user", "devuser"}) + require.NoError(t, err) + + flag := upCmd.Flags().Lookup("container-user") + assert.Equal(t, "devuser", flag.Value.String()) +} + +func TestUpCmd_RemoteUserFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("remote-user") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} + +func TestUpCmd_RemoteUserFlagParsesValue(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + err := upCmd.ParseFlags([]string{"--remote-user", "vscode"}) + require.NoError(t, err) + + flag := upCmd.Flags().Lookup("remote-user") + assert.Equal(t, "vscode", flag.Value.String()) +} diff --git a/pkg/devcontainer/build/options.go b/pkg/devcontainer/build/options.go index 515c3902d..39eb5c1e3 100644 --- a/pkg/devcontainer/build/options.go +++ b/pkg/devcontainer/build/options.go @@ -52,6 +52,8 @@ type BuildOptions struct { Push bool // Upload controls whether to upload the build context. Used for remote builds. Upload bool + // NoCache disables the Docker build cache entirely. + NoCache bool } // NewOptionsParams contains the parameters needed to create BuildOptions. @@ -92,7 +94,8 @@ func NewOptions(params NewOptionsParams) (*BuildOptions, error) { // Push controls whether BuildKit pushes directly to the registry during build. // When true, BuildKit uses the --push flag instead of --load, streaming the image // directly to the registry. This is mutually exclusive with Load. - Push: params.Options.PushDuringBuild, + Push: params.Options.PushDuringBuild, + NoCache: params.Options.NoCache, } // get build args and target diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 121440492..ddfcf0ea0 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -126,6 +126,12 @@ func (r *runner) buildResult(params *setupContainerParams) *config.Result { if r.WorkspaceConfig.CLIOptions.DefaultUserEnvProbe != "" { result.MergedConfig.UserEnvProbe = r.WorkspaceConfig.CLIOptions.DefaultUserEnvProbe } + if r.WorkspaceConfig.CLIOptions.ContainerUser != "" { + result.MergedConfig.ContainerUser = r.WorkspaceConfig.CLIOptions.ContainerUser + } + if r.WorkspaceConfig.CLIOptions.RemoteUser != "" { + result.MergedConfig.RemoteUser = r.WorkspaceConfig.CLIOptions.RemoteUser + } if r.WorkspaceConfig.Agent.Local == stringTrue && r.WorkspaceConfig.CLIOptions.Platform.Enabled { diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index bf86a2723..0351ce3df 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -224,6 +224,13 @@ type phaseHook struct { runFunc func() error } +// SkipPhases controls which lifecycle phases should be skipped. +type SkipPhases struct { + PostCreate bool + PostStart bool + PostAttach bool +} + // RunPreAttachHooks runs lifecycle hooks up to and including the waitFor phase // synchronously and returns a slice of deferred phases that should run in the // background. Dotfiles are installed between postCreateCommand and @@ -237,6 +244,7 @@ func RunPreAttachHooks( prebuild bool, dotfiles DotfilesConfig, secretsEnv []string, + skip SkipPhases, ) (DeferredHooks, error) { env := resolveLifecycleEnv(ctx, setupInfo) mergeSecretsEnv(env.remoteEnv, secretsEnv) @@ -246,6 +254,9 @@ func RunPreAttachHooks( created := setupInfo.ContainerDetails.Created all = insertDotfilesPhase(ctx, all, dotfiles, created) + // Remove skipped phases. + all = filterSkippedPhases(all, skip) + if prebuild { return DeferredHooks{}, runPrebuildHooks(all) } @@ -264,6 +275,24 @@ func RunPreAttachHooks( return DeferredHooks{hooks: deferred}, err } +func filterSkippedPhases(all []phaseHook, skip SkipPhases) []phaseHook { + skipped := map[LifecyclePhase]bool{ + PhasePostCreate: skip.PostCreate, + PhasePostStart: skip.PostStart, + PhasePostAttach: skip.PostAttach, + } + + filtered := make([]phaseHook, 0, len(all)) + for _, ph := range all { + if skipped[ph.phase] { + log.Infof("skipping %s (--skip flag set)", ph.phase) + continue + } + filtered = append(filtered, ph) + } + return filtered +} + // insertDotfilesPhase splices a dotfiles phaseHook after postCreateCommand // when a dotfiles repository is configured. func insertDotfilesPhase( @@ -388,7 +417,16 @@ func (d DeferredHooks) Run() error { // RunPostAttachHooks runs postAttachCommand only. // These run after the IDE has been opened and can be long-running. -func RunPostAttachHooks(ctx context.Context, setupInfo *config.Result, secretsEnv []string) error { +func RunPostAttachHooks( + ctx context.Context, + setupInfo *config.Result, + secretsEnv []string, + skipPostAttach ...bool, +) error { + if len(skipPostAttach) > 0 && skipPostAttach[0] { + log.Infof("skipping postAttachCommand (--skip-post-attach set)") + return nil + } env := resolveLifecycleEnv(ctx, setupInfo) mergeSecretsEnv(env.remoteEnv, secretsEnv) diff --git a/pkg/devcontainer/setup/lifecyclehooks_test.go b/pkg/devcontainer/setup/lifecyclehooks_test.go index e27b8b5da..edaa89400 100644 --- a/pkg/devcontainer/setup/lifecyclehooks_test.go +++ b/pkg/devcontainer/setup/lifecyclehooks_test.go @@ -100,7 +100,7 @@ func (s *LifecycleHookTestSuite) TestLifecycleHooksNoOpWithEmptyConfig() { } // Both functions should return nil with empty config (no commands to run) - deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil) + deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil, SkipPhases{}) assert.NoError(s.T(), err) assert.True(s.T(), deferred.Empty()) @@ -252,7 +252,7 @@ func (s *LifecycleHookTestSuite) TestPrebuildIgnoresWaitFor() { } // In prebuild mode, no deferred hooks are returned regardless of waitFor. - deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil) + deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil, SkipPhases{}) assert.NoError(s.T(), err) assert.True(s.T(), deferred.Empty()) } diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 2a50ef621..3807ecb90 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -45,6 +45,9 @@ type ContainerSetupConfig struct { PlatformOptions *devsy.PlatformOptions TunnelClient tunnel.TunnelClient Dotfiles DotfilesConfig + SkipPostCreate bool + SkipPostStart bool + SkipPostAttach bool } // SetupContainerPreAttach runs container setup up to and including the waitFor @@ -77,6 +80,11 @@ func SetupContainerPreAttach( cfg.Prebuild, cfg.Dotfiles, cfg.SecretsEnv, + SkipPhases{ + PostCreate: cfg.SkipPostCreate, + PostStart: cfg.SkipPostStart, + PostAttach: cfg.SkipPostAttach, + }, ) if err != nil { return DeferredHooks{}, fmt.Errorf("lifecycle hooks pre-attach: %w", err) @@ -90,7 +98,12 @@ func SetupContainerPreAttach( // Called after the IDE has been opened. func SetupContainerPostAttach(ctx context.Context, cfg *ContainerSetupConfig) error { log.Debugf("running post-attach lifecycle hooks") - if err := RunPostAttachHooks(ctx, cfg.SetupInfo, cfg.SecretsEnv); err != nil { + if err := RunPostAttachHooks( + ctx, + cfg.SetupInfo, + cfg.SecretsEnv, + cfg.SkipPostAttach, + ); err != nil { return fmt.Errorf("lifecycle hooks post-attach: %w", err) } diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index f9011dd65..3042813bb 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -23,6 +23,9 @@ func (d *dockerDriver) BuildDevContainer( req driver.BuildRequest, ) (*config.BuildInfo, error) { imageName := build.GetImageName(req.LocalWorkspaceFolder, req.PrebuildHash) + if req.Options.ImageName != "" { + imageName = req.Options.ImageName + } orchestrator := &buildOrchestrator{ driver: d, resolver: &imageResolver{driver: d}, @@ -95,6 +98,9 @@ func (s *dockerBuildxStrategy) name() string { func buildDockerBuildxArgs(options *build.BuildOptions, platform string) []string { args := []string{"buildx", "build", "-f", options.Dockerfile} args = appendBuildFlags(args, options.Load, options.Push) + if options.NoCache { + args = append(args, "--no-cache") + } args = appendImageTags(args, options.Images) args = appendBuildArgsAndContexts(args, options.BuildArgs, options.Contexts) args = appendLabels(args, options.Labels) diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 7f8169a5f..2fc9b0167 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -245,8 +245,16 @@ type CLIOptions struct { MountWorkspaceGitRoot *bool `json:"mountWorkspaceGitRoot,omitempty"` TerminalColumns int `json:"terminalColumns,omitempty"` TerminalRows int `json:"terminalRows,omitempty"` - SkipPostCreate bool `json:"skipPostCreate,omitempty"` SkipNonBlockingCommands bool `json:"skipNonBlockingCommands,omitempty"` + ContainerUser string `json:"containerUser,omitempty"` + RemoteUser string `json:"remoteUser,omitempty"` + + // skip lifecycle hook options + SkipPostCreate bool `json:"skipPostCreate,omitempty"` + SkipPostStart bool `json:"skipPostStart,omitempty"` + SkipPostAttach bool `json:"skipPostAttach,omitempty"` + SkipOnCreate bool `json:"skipOnCreate,omitempty"` + SkipUpdateContent bool `json:"skipUpdateContent,omitempty"` // dotfiles options DotfilesRepo string `json:"dotfilesRepo,omitempty"` @@ -277,6 +285,10 @@ type CLIOptions struct { Labels []string `json:"labels,omitempty"` Output string `json:"output,omitempty"` ExperimentalLockfile string `json:"experimentalLockfile,omitempty"` + // ImageName specifies an alternative name for the built image. + ImageName string `json:"imageName,omitempty"` + // NoBuild prevents building; the command will fail if the image does not exist. + NoBuild bool `json:"noBuild,omitempty"` // ForceBuild forces a rebuild even if a cached image exists. ForceBuild bool `json:"forceBuild,omitempty"` From acb909ac7f86b3477b57eb9cf963832ac3c646dc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 16:18:25 -0500 Subject: [PATCH 2/3] fix(cmd): address review findings for minor CLI flags --- cmd/build.go | 6 +++--- cmd/exec.go | 7 +++++++ pkg/provider/workspace.go | 8 +++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 9da26ec06..8458656c8 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -160,9 +160,9 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command { StringSliceVar(&cmd.Platforms, "platform", []string{}, "Set target platform for build") buildCmd.Flags(). BoolVar(&cmd.SkipPush, "skip-push", false, "If true will not push the image to the repository, useful for testing") - buildCmd.Flags().BoolVar(&cmd.PushDuringBuild, "push", false, - "Push image directly to registry during build, skipping load to local daemon.", - ) + buildCmd.Flags(). + BoolVar(&cmd.PushDuringBuild, "push", false, + "Push image directly to registry during build, skipping load to local daemon") buildCmd.Flags(). StringArrayVar(&cmd.CacheFrom, "cache-from", []string{}, "Cache sources for the build (e.g., myregistry.io/cache:latest or type=registry,ref=...). "+ diff --git a/cmd/exec.go b/cmd/exec.go index a7bae9e15..ceb745abb 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -110,6 +110,13 @@ func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { } func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { + if cmd.ContainerDataFolder != "" { + log.Warnf("--container-data-folder is accepted but not yet implemented for exec") + } + if cmd.SkipPostCreate { + log.Warnf("--skip-post-create is accepted but not yet implemented for exec") + } + if cmd.WorkspaceFolder == "" && cmd.ContainerID == "" { return fmt.Errorf("either --workspace-folder or --container-id must be provided") } diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 2fc9b0167..a6f93e5c1 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -250,11 +250,9 @@ type CLIOptions struct { RemoteUser string `json:"remoteUser,omitempty"` // skip lifecycle hook options - SkipPostCreate bool `json:"skipPostCreate,omitempty"` - SkipPostStart bool `json:"skipPostStart,omitempty"` - SkipPostAttach bool `json:"skipPostAttach,omitempty"` - SkipOnCreate bool `json:"skipOnCreate,omitempty"` - SkipUpdateContent bool `json:"skipUpdateContent,omitempty"` + SkipPostCreate bool `json:"skipPostCreate,omitempty"` + SkipPostStart bool `json:"skipPostStart,omitempty"` + SkipPostAttach bool `json:"skipPostAttach,omitempty"` // dotfiles options DotfilesRepo string `json:"dotfilesRepo,omitempty"` From 23780638ac5e7661e63eddc2aaae6792e43477b4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 16:32:07 -0500 Subject: [PATCH 3/3] fix(cmd): extract repeated test strings to constants --- cmd/exec_test.go | 14 ++++++++++---- cmd/minor_flags_test.go | 2 +- cmd/runusercommands_test.go | 4 ++-- cmd/up_test.go | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/exec_test.go b/cmd/exec_test.go index 64f08224f..a8ba16f29 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -8,6 +8,12 @@ import ( "github.com/stretchr/testify/require" ) +const ( + flagSkipPostCreate = "--skip-post-create" + flagWorkspaceFolder = "--workspace-folder" + testTmpDir = "/tmp" +) + func TestValidateRemoteEnv_Valid(t *testing.T) { cmd := &ExecCmd{ GlobalFlags: &flags.GlobalFlags{}, @@ -54,7 +60,7 @@ func TestNewExecCmd_RequiresWorkspaceFolderOrContainerID(t *testing.T) { func TestNewExecCmd_RequiresArgs(t *testing.T) { execCmd := NewExecCmd(&flags.GlobalFlags{}) - execCmd.SetArgs([]string{"--workspace-folder", "/tmp/test"}) + execCmd.SetArgs([]string{flagWorkspaceFolder, testTmpDir + "/test"}) err := execCmd.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "requires at least 1 arg") @@ -106,7 +112,7 @@ func TestExecCmd_ContainerDataFolderFlag(t *testing.T) { func TestExecCmd_ContainerDataFolderFlagParsesValue(t *testing.T) { execCmd := NewExecCmd(&flags.GlobalFlags{}) err := execCmd.ParseFlags([]string{ - "--workspace-folder", "/tmp", + flagWorkspaceFolder, testTmpDir, "--container-data-folder", "/custom/data", }) require.NoError(t, err) @@ -125,8 +131,8 @@ func TestExecCmd_SkipPostCreateFlag(t *testing.T) { func TestExecCmd_SkipPostCreateFlagParsesValue(t *testing.T) { execCmd := NewExecCmd(&flags.GlobalFlags{}) err := execCmd.ParseFlags([]string{ - "--workspace-folder", "/tmp", - "--skip-post-create", + flagWorkspaceFolder, testTmpDir, + flagSkipPostCreate, }) require.NoError(t, err) diff --git a/cmd/minor_flags_test.go b/cmd/minor_flags_test.go index f54447083..a71e998f5 100644 --- a/cmd/minor_flags_test.go +++ b/cmd/minor_flags_test.go @@ -74,7 +74,7 @@ func TestUpCmd_TerminalRowsFlagParsesValue(t *testing.T) { func TestUpCmd_SkipPostCreateFlagParsesValue(t *testing.T) { upCmd := NewUpCmd(&flags.GlobalFlags{}) - err := upCmd.ParseFlags([]string{"--skip-post-create"}) + err := upCmd.ParseFlags([]string{flagSkipPostCreate}) require.NoError(t, err) val, err := upCmd.Flags().GetBool("skip-post-create") require.NoError(t, err) diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go index f8f0ff59a..25b1e71ce 100644 --- a/cmd/runusercommands_test.go +++ b/cmd/runusercommands_test.go @@ -154,8 +154,8 @@ func TestRunUserCommandsCmd_SkipUpdateContentFlag(t *testing.T) { func TestRunUserCommandsCmd_SkipFlagsParseValues(t *testing.T) { cmd := NewRunUserCommandsCmd(&flags.GlobalFlags{}) err := cmd.ParseFlags([]string{ - "--workspace-folder", "/tmp", - "--skip-post-create", + flagWorkspaceFolder, testTmpDir, + flagSkipPostCreate, "--skip-post-start", "--skip-post-attach", "--skip-on-create", diff --git a/cmd/up_test.go b/cmd/up_test.go index 7665f7800..5974135cc 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -137,7 +137,7 @@ func TestUpCmd_SkipPostAttachFlag(t *testing.T) { func TestUpCmd_SkipFlagsParseValues(t *testing.T) { upCmd := NewUpCmd(&flags.GlobalFlags{}) err := upCmd.ParseFlags([]string{ - "--skip-post-create", + flagSkipPostCreate, "--skip-post-start", "--skip-post-attach", })