diff --git a/internal/cmd/docker.go b/internal/cmd/docker.go new file mode 100644 index 0000000..de251a6 --- /dev/null +++ b/internal/cmd/docker.go @@ -0,0 +1,385 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/keywaysh/cli/internal/api" + "github.com/keywaysh/cli/internal/env" + "github.com/spf13/cobra" +) + +var dockerCmd = &cobra.Command{ + Use: "docker [flags] [docker-args...]", + Short: "Run Docker commands with injected secrets", + Long: `Run Docker or Docker Compose commands with secrets injected from the vault. + +For 'docker run': Secrets are injected as -e KEY=VALUE flags before the image name. +For 'docker compose': Secrets are exported to the environment before running. + +User-provided -e flags take precedence over vault secrets.`, + Example: ` keyway docker --env production run -p 8080:8080 myapp:latest + keyway docker --env staging compose up -d + keyway docker run --rm alpine env # Uses default 'development' environment`, + RunE: runDockerCmd, +} + +func init() { + dockerCmd.Flags().StringP("env", "e", "development", "Environment name") + // Stop parsing flags after first positional arg so docker flags like --rm pass through + dockerCmd.Flags().SetInterspersed(false) +} + +// DockerOptions contains the parsed flags for the docker command +type DockerOptions struct { + EnvName string + EnvFlagSet bool + DockerCommand string // "run", "compose", etc. + DockerArgs []string // Arguments to pass to docker subcommand +} + +// runDockerCmd is the entry point for the docker command (uses default dependencies) +func runDockerCmd(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("docker subcommand required (e.g., 'run' or 'compose')") + } + + opts := DockerOptions{ + EnvFlagSet: cmd.Flags().Changed("env"), + DockerCommand: args[0], + DockerArgs: args[1:], + } + opts.EnvName, _ = cmd.Flags().GetString("env") + + return runDockerWithDeps(opts, defaultDeps) +} + +// runDockerWithDeps is the testable version of runDocker +func runDockerWithDeps(opts DockerOptions, deps *Dependencies) error { + // 1. Detect Repo + repo, err := deps.Git.DetectRepo() + if err != nil { + deps.UI.Error("Not in a git repository with GitHub remote") + return err + } + + // 2. Ensure Login + token, err := deps.Auth.EnsureLogin() + if err != nil { + deps.UI.Error(err.Error()) + return err + } + + // 3. Setup Client + client := deps.APIFactory.NewClient(token) + ctx := context.Background() + + // 4. Determine Environment + envName := opts.EnvName + + if !opts.EnvFlagSet && deps.UI.IsInteractive() { + // Fetch available environments + vaultEnvs, err := client.GetVaultEnvironments(ctx, repo) + if err != nil || len(vaultEnvs) == 0 { + vaultEnvs = []string{"development", "staging", "production"} + } + + // Find default index (development) + defaultIdx := 0 + for i, e := range vaultEnvs { + if e == "development" { + defaultIdx = i + break + } + } + + // Reorder to put default first + if defaultIdx > 0 { + vaultEnvs[0], vaultEnvs[defaultIdx] = vaultEnvs[defaultIdx], vaultEnvs[0] + } + + selected, err := deps.UI.Select("Environment:", vaultEnvs) + if err != nil { + return err + } + envName = selected + } + + deps.UI.Step(fmt.Sprintf("Environment: %s", deps.UI.Value(envName))) + + // 5. Fetch Secrets + var vaultContent string + err = deps.UI.Spin("Fetching secrets...", func() error { + resp, err := client.PullSecrets(ctx, repo, envName) + if err != nil { + return err + } + vaultContent = resp.Content + return nil + }) + + if err != nil { + if apiErr, ok := err.(*api.APIError); ok { + deps.UI.Error(apiErr.Error()) + } else { + deps.UI.Error(err.Error()) + } + return err + } + + // 6. Parse Secrets + secrets := env.Parse(vaultContent) + deps.UI.Success(fmt.Sprintf("Injecting %d secrets", len(secrets))) + + // 7. Execute Docker Command + switch opts.DockerCommand { + case "compose": + return runDockerCompose(opts, secrets, deps) + default: + return runDockerRun(opts, secrets, deps) + } +} + +// runDockerRun handles docker run commands by injecting -e flags +func runDockerRun(opts DockerOptions, secrets map[string]string, deps *Dependencies) error { + args := opts.DockerArgs + + // Extract user's -e flags to ensure they take precedence + userEnvVars := extractUserEnvVars(args) + + // Find where to inject -e flags (before the image name) + imagePos := findImagePosition(args) + + // Build new args with injected -e flags + var newArgs []string + + // Add docker subcommand (e.g., "run") + newArgs = append(newArgs, opts.DockerCommand) + + if imagePos >= 0 { + // Add args before image + newArgs = append(newArgs, args[:imagePos]...) + + // Inject vault secrets (excluding those user explicitly set) + for k, v := range secrets { + if _, userSet := userEnvVars[k]; !userSet { + newArgs = append(newArgs, "-e", fmt.Sprintf("%s=%s", k, v)) + } + } + + // Add image and remaining args + newArgs = append(newArgs, args[imagePos:]...) + } else { + // No image found, inject secrets at the end of options + for k, v := range secrets { + if _, userSet := userEnvVars[k]; !userSet { + newArgs = append(newArgs, "-e", fmt.Sprintf("%s=%s", k, v)) + } + } + newArgs = append(newArgs, args...) + } + + // Execute docker with secrets as -e flags (not in environment) + return deps.CmdRunner.RunCommand("docker", newArgs, nil) +} + +// runDockerCompose handles docker compose commands by injecting secrets via -e flags +func runDockerCompose(opts DockerOptions, secrets map[string]string, deps *Dependencies) error { + args := []string{"compose"} + args = append(args, opts.DockerArgs...) + + // For "run" subcommand, inject -e flags (similar to docker run) + // For other subcommands like "up", we need --env-file approach + if len(opts.DockerArgs) > 0 && opts.DockerArgs[0] == "run" { + // Find position after "run" to inject -e flags + newArgs := []string{"compose", "run"} + for k, v := range secrets { + newArgs = append(newArgs, "-e", fmt.Sprintf("%s=%s", k, v)) + } + // Append remaining args after "run" + if len(opts.DockerArgs) > 1 { + newArgs = append(newArgs, opts.DockerArgs[1:]...) + } + return deps.CmdRunner.RunCommand("docker", newArgs, nil) + } + + // For "up" and other commands, use --env-file + if len(secrets) > 0 { + envFile, err := os.CreateTemp("", "keyway-env-*.env") + if err != nil { + return fmt.Errorf("failed to create temp env file: %w", err) + } + defer os.Remove(envFile.Name()) + + for k, v := range secrets { + fmt.Fprintf(envFile, "%s=%s\n", k, v) + } + envFile.Close() + + // Insert --env-file after "compose" + args = []string{"compose", "--env-file", envFile.Name()} + args = append(args, opts.DockerArgs...) + } + + return deps.CmdRunner.RunCommand("docker", args, nil) +} + +// findImagePosition finds the index where the image name starts in docker run args. +// Docker run syntax: docker run [OPTIONS] IMAGE [COMMAND] [ARG...] +// Returns -1 if no image position found. +func findImagePosition(args []string) int { + // Flags that take a value (require skipping next arg) + flagsWithValue := map[string]bool{ + "-a": true, "--attach": true, + "--add-host": true, + "--blkio-weight": true, + "--blkio-weight-device": true, + "--cap-add": true, + "--cap-drop": true, + "--cgroup-parent": true, + "--cgroupns": true, + "--cidfile": true, + "--cpu-count": true, + "--cpu-percent": true, + "--cpu-period": true, + "--cpu-quota": true, + "--cpu-rt-period": true, + "--cpu-rt-runtime": true, + "--cpu-shares": true, "-c": true, + "--cpus": true, + "--cpuset-cpus": true, + "--cpuset-mems": true, + "--device": true, + "--device-cgroup-rule": true, + "--device-read-bps": true, + "--device-read-iops": true, + "--device-write-bps": true, + "--device-write-iops": true, + "--dns": true, + "--dns-option": true, + "--dns-search": true, + "--domainname": true, + "--entrypoint": true, + "-e": true, "--env": true, + "--env-file": true, + "--expose": true, + "--gpus": true, + "--group-add": true, + "--health-cmd": true, + "--health-interval": true, + "--health-retries": true, + "--health-start-period": true, + "--health-timeout": true, + "-h": true, "--hostname": true, + "--ip": true, + "--ip6": true, + "--ipc": true, + "--isolation": true, + "--kernel-memory": true, + "-l": true, "--label": true, + "--label-file": true, + "--link": true, + "--link-local-ip": true, + "--log-driver": true, + "--log-opt": true, + "--mac-address": true, + "-m": true, "--memory": true, + "--memory-reservation": true, + "--memory-swap": true, + "--memory-swappiness": true, + "--mount": true, + "--name": true, + "--network": true, "--net": true, + "--network-alias": true, "--net-alias": true, + "--oom-score-adj": true, + "--pid": true, + "--pids-limit": true, + "--platform": true, + "-p": true, "--publish": true, + "--pull": true, + "--restart": true, + "--runtime": true, + "--security-opt": true, + "--shm-size": true, + "--stop-signal": true, + "--stop-timeout": true, + "--storage-opt": true, + "--sysctl": true, + "--tmpfs": true, + "--ulimit": true, + "-u": true, "--user": true, + "--userns": true, + "--uts": true, + "-v": true, "--volume": true, + "--volume-driver": true, + "--volumes-from": true, + "-w": true, "--workdir": true, + } + + i := 0 + for i < len(args) { + arg := args[i] + + // Not a flag - this is the image + if !strings.HasPrefix(arg, "-") { + return i + } + + // Check for --flag=value format + if strings.Contains(arg, "=") { + i++ + continue + } + + // Check if this flag takes a value + if flagsWithValue[arg] { + // Skip the flag and its value + i += 2 + continue + } + + // Boolean flag, just skip it + i++ + } + + return -1 +} + +// extractUserEnvVars parses -e and --env flags from docker args +func extractUserEnvVars(args []string) map[string]string { + result := make(map[string]string) + + for i := 0; i < len(args); i++ { + arg := args[i] + + var envVal string + if arg == "-e" || arg == "--env" { + if i+1 < len(args) { + envVal = args[i+1] + i++ + } + } else if strings.HasPrefix(arg, "-e=") { + envVal = strings.TrimPrefix(arg, "-e=") + } else if strings.HasPrefix(arg, "--env=") { + envVal = strings.TrimPrefix(arg, "--env=") + } else { + continue + } + + if envVal != "" { + parts := strings.SplitN(envVal, "=", 2) + if len(parts) >= 1 { + key := parts[0] + value := "" + if len(parts) == 2 { + value = parts[1] + } + result[key] = value + } + } + } + + return result +} diff --git a/internal/cmd/docker_test.go b/internal/cmd/docker_test.go new file mode 100644 index 0000000..d167fd7 --- /dev/null +++ b/internal/cmd/docker_test.go @@ -0,0 +1,435 @@ +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/keywaysh/cli/internal/api" +) + +func TestRunDockerWithDeps_DockerRun_Success(t *testing.T) { + deps, _, _, _, cmdRunner, apiClient := NewTestDepsWithRunner() + + apiClient.PullResponse = &api.PullSecretsResponse{ + Content: "API_KEY=secret123\nDB_URL=postgres://localhost", + } + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + DockerArgs: []string{"-p", "8080:8080", "myapp:latest"}, + } + + err := runDockerWithDeps(opts, deps) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify docker was called + if cmdRunner.LastCommand != "docker" { + t.Errorf("expected command 'docker', got %q", cmdRunner.LastCommand) + } + + // Verify first arg is "run" + if len(cmdRunner.LastArgs) == 0 || cmdRunner.LastArgs[0] != "run" { + t.Errorf("expected first arg 'run', got %v", cmdRunner.LastArgs) + } + + // Verify -e flags were injected + argsStr := strings.Join(cmdRunner.LastArgs, " ") + if !strings.Contains(argsStr, "-e API_KEY=secret123") { + t.Errorf("expected API_KEY to be injected, got args: %v", cmdRunner.LastArgs) + } + if !strings.Contains(argsStr, "-e DB_URL=postgres://localhost") { + t.Errorf("expected DB_URL to be injected, got args: %v", cmdRunner.LastArgs) + } + + // Verify image is still at the end + if cmdRunner.LastArgs[len(cmdRunner.LastArgs)-1] != "myapp:latest" { + t.Errorf("expected image at end, got args: %v", cmdRunner.LastArgs) + } + + // Verify secrets were NOT passed via environment (docker run uses -e flags) + if cmdRunner.LastSecrets != nil && len(cmdRunner.LastSecrets) > 0 { + t.Errorf("expected no secrets in environment for docker run, got %v", cmdRunner.LastSecrets) + } +} + +func TestRunDockerWithDeps_DockerCompose_Success(t *testing.T) { + deps, _, _, _, cmdRunner, apiClient := NewTestDepsWithRunner() + + apiClient.PullResponse = &api.PullSecretsResponse{ + Content: "API_KEY=secret123", + } + + opts := DockerOptions{ + EnvName: "production", + EnvFlagSet: true, + DockerCommand: "compose", + DockerArgs: []string{"up", "-d"}, + } + + err := runDockerWithDeps(opts, deps) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify docker was called + if cmdRunner.LastCommand != "docker" { + t.Errorf("expected command 'docker', got %q", cmdRunner.LastCommand) + } + + // Verify args are "compose up -d" + expectedArgs := []string{"compose", "up", "-d"} + if len(cmdRunner.LastArgs) != len(expectedArgs) { + t.Errorf("expected args %v, got %v", expectedArgs, cmdRunner.LastArgs) + } + for i, expected := range expectedArgs { + if i < len(cmdRunner.LastArgs) && cmdRunner.LastArgs[i] != expected { + t.Errorf("expected arg[%d] = %q, got %q", i, expected, cmdRunner.LastArgs[i]) + } + } + + // Verify secrets were passed via environment (compose uses env injection) + if cmdRunner.LastSecrets["API_KEY"] != "secret123" { + t.Errorf("expected API_KEY in secrets, got %v", cmdRunner.LastSecrets) + } +} + +func TestRunDockerWithDeps_UserEnvTakesPrecedence(t *testing.T) { + deps, _, _, _, cmdRunner, apiClient := NewTestDepsWithRunner() + + // Vault has API_KEY=vault_secret + apiClient.PullResponse = &api.PullSecretsResponse{ + Content: "API_KEY=vault_secret\nOTHER=other_value", + } + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + // User explicitly sets API_KEY + DockerArgs: []string{"-e", "API_KEY=user_override", "alpine"}, + } + + err := runDockerWithDeps(opts, deps) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify that vault API_KEY was NOT injected (user provided their own) + apiKeyCount := 0 + for i, arg := range cmdRunner.LastArgs { + if arg == "-e" && i+1 < len(cmdRunner.LastArgs) && strings.HasPrefix(cmdRunner.LastArgs[i+1], "API_KEY=") { + apiKeyCount++ + // Should be user's value, not vault's + if cmdRunner.LastArgs[i+1] != "API_KEY=user_override" { + t.Errorf("expected user's API_KEY, got %q", cmdRunner.LastArgs[i+1]) + } + } + } + + if apiKeyCount != 1 { + t.Errorf("expected exactly 1 API_KEY, found %d in args: %v", apiKeyCount, cmdRunner.LastArgs) + } + + // OTHER should still be injected from vault + argsStr := strings.Join(cmdRunner.LastArgs, " ") + if !strings.Contains(argsStr, "-e OTHER=other_value") { + t.Errorf("expected OTHER to be injected, got args: %v", cmdRunner.LastArgs) + } +} + +func TestRunDockerWithDeps_GitError(t *testing.T) { + deps, gitMock, _, uiMock, _, _ := NewTestDepsWithRunner() + gitMock.RepoError = errors.New("not a git repo") + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + DockerArgs: []string{"alpine"}, + } + + err := runDockerWithDeps(opts, deps) + + if err == nil { + t.Fatal("expected error, got nil") + } + if len(uiMock.ErrorCalls) == 0 { + t.Error("expected UI.Error to be called") + } +} + +func TestRunDockerWithDeps_AuthError(t *testing.T) { + deps, _, authMock, uiMock, _, _ := NewTestDepsWithRunner() + authMock.Error = errors.New("not logged in") + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + DockerArgs: []string{"alpine"}, + } + + err := runDockerWithDeps(opts, deps) + + if err == nil { + t.Fatal("expected error, got nil") + } + if len(uiMock.ErrorCalls) == 0 { + t.Error("expected UI.Error to be called") + } +} + +func TestRunDockerWithDeps_APIError(t *testing.T) { + deps, _, _, uiMock, _, apiClient := NewTestDepsWithRunner() + apiClient.PullError = &api.APIError{ + StatusCode: 404, + Detail: "Vault not found", + } + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + DockerArgs: []string{"alpine"}, + } + + err := runDockerWithDeps(opts, deps) + + if err == nil { + t.Fatal("expected error, got nil") + } + if len(uiMock.ErrorCalls) == 0 { + t.Error("expected UI.Error to be called") + } +} + +func TestFindImagePosition(t *testing.T) { + tests := []struct { + name string + args []string + expected int + }{ + { + name: "simple image", + args: []string{"alpine"}, + expected: 0, + }, + { + name: "with port flag", + args: []string{"-p", "8080:8080", "myapp:latest"}, + expected: 2, + }, + { + name: "with multiple flags", + args: []string{"-d", "--rm", "-p", "80:80", "-v", "/data:/data", "nginx"}, + expected: 6, + }, + { + name: "with env flag", + args: []string{"-e", "FOO=bar", "alpine"}, + expected: 2, + }, + { + name: "with --name flag", + args: []string{"--name", "mycontainer", "alpine"}, + expected: 2, + }, + { + name: "with equals syntax", + args: []string{"--name=mycontainer", "alpine"}, + expected: 1, + }, + { + name: "image with command", + args: []string{"alpine", "echo", "hello"}, + expected: 0, + }, + { + name: "empty args", + args: []string{}, + expected: -1, + }, + { + name: "only flags no image", + args: []string{"-d", "--rm"}, + expected: -1, + }, + { + name: "complex real-world example", + args: []string{"-d", "--name", "web", "-p", "80:80", "-v", "/var/www:/www", "--restart", "always", "nginx:alpine"}, + expected: 9, + }, + { + name: "with env equals syntax", + args: []string{"-e=FOO=bar", "alpine"}, + expected: 1, + }, + { + name: "with multiple env vars", + args: []string{"-e", "A=1", "-e", "B=2", "-e", "C=3", "myimage"}, + expected: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findImagePosition(tt.args) + if got != tt.expected { + t.Errorf("findImagePosition(%v) = %d, want %d", tt.args, got, tt.expected) + } + }) + } +} + +func TestExtractUserEnvVars(t *testing.T) { + tests := []struct { + name string + args []string + expected map[string]string + }{ + { + name: "short flag with space", + args: []string{"-e", "FOO=bar"}, + expected: map[string]string{"FOO": "bar"}, + }, + { + name: "long flag with space", + args: []string{"--env", "FOO=bar"}, + expected: map[string]string{"FOO": "bar"}, + }, + { + name: "short flag with equals", + args: []string{"-e=FOO=bar"}, + expected: map[string]string{"FOO": "bar"}, + }, + { + name: "long flag with equals", + args: []string{"--env=FOO=bar"}, + expected: map[string]string{"FOO": "bar"}, + }, + { + name: "multiple env vars", + args: []string{"-e", "A=1", "-e", "B=2"}, + expected: map[string]string{"A": "1", "B": "2"}, + }, + { + name: "env var inherit syntax (no value)", + args: []string{"-e", "PATH"}, + expected: map[string]string{"PATH": ""}, + }, + { + name: "mixed with other flags", + args: []string{"-p", "8080:8080", "-e", "FOO=bar", "-d"}, + expected: map[string]string{"FOO": "bar"}, + }, + { + name: "value with equals sign", + args: []string{"-e", "URL=http://example.com?foo=bar"}, + expected: map[string]string{"URL": "http://example.com?foo=bar"}, + }, + { + name: "empty args", + args: []string{}, + expected: map[string]string{}, + }, + { + name: "no env vars", + args: []string{"-p", "8080:8080", "-d", "alpine"}, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractUserEnvVars(tt.args) + if len(got) != len(tt.expected) { + t.Errorf("extractUserEnvVars(%v) = %v, want %v", tt.args, got, tt.expected) + return + } + for k, v := range tt.expected { + if got[k] != v { + t.Errorf("extractUserEnvVars(%v)[%q] = %q, want %q", tt.args, k, got[k], v) + } + } + }) + } +} + +func TestRunDockerWithDeps_EmptySecrets(t *testing.T) { + deps, _, _, _, cmdRunner, apiClient := NewTestDepsWithRunner() + + // Vault returns empty content + apiClient.PullResponse = &api.PullSecretsResponse{ + Content: "", + } + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + DockerArgs: []string{"alpine", "echo", "hello"}, + } + + err := runDockerWithDeps(opts, deps) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify docker was still called with original args + expectedArgs := []string{"run", "alpine", "echo", "hello"} + if len(cmdRunner.LastArgs) != len(expectedArgs) { + t.Errorf("expected args %v, got %v", expectedArgs, cmdRunner.LastArgs) + } +} + +func TestRunDockerRun_SecretsBeforeImage(t *testing.T) { + deps, _, _, _, cmdRunner, apiClient := NewTestDepsWithRunner() + + apiClient.PullResponse = &api.PullSecretsResponse{ + Content: "SECRET=value", + } + + opts := DockerOptions{ + EnvName: "development", + EnvFlagSet: true, + DockerCommand: "run", + DockerArgs: []string{"-d", "--name", "test", "myimage", "cmd", "arg"}, + } + + err := runDockerWithDeps(opts, deps) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Find position of -e SECRET=value and myimage + secretPos := -1 + imagePos := -1 + for i, arg := range cmdRunner.LastArgs { + if arg == "-e" && i+1 < len(cmdRunner.LastArgs) && cmdRunner.LastArgs[i+1] == "SECRET=value" { + secretPos = i + } + if arg == "myimage" { + imagePos = i + } + } + + if secretPos == -1 { + t.Errorf("SECRET not found in args: %v", cmdRunner.LastArgs) + } + if imagePos == -1 { + t.Errorf("myimage not found in args: %v", cmdRunner.LastArgs) + } + if secretPos >= imagePos { + t.Errorf("SECRET (-e at pos %d) should come before image (at pos %d), args: %v", secretPos, imagePos, cmdRunner.LastArgs) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ee77b1d..db5d1da 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -188,6 +188,7 @@ func printCustomHelp(cmd *cobra.Command) { fmt.Printf(" %s %s\n", cyan("keyway pull"), "Download secrets from vault") fmt.Printf(" %s %s\n", cyan("keyway set"), "Set a single secret in vault") fmt.Printf(" %s %s\n", cyan("keyway run"), "Run command with injected secrets (Zero-Trust)") + fmt.Printf(" %s %s\n", cyan("keyway docker"), "Run Docker with injected secrets") fmt.Printf(" %s %s\n", cyan("keyway login"), "Sign in with GitHub") fmt.Println() @@ -285,4 +286,5 @@ func init() { rootCmd.AddCommand(diffCmd) rootCmd.AddCommand(scanCmd) rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(dockerCmd) }