diff --git a/docs/CLI.md b/docs/CLI.md index e814a29d..d8f97626 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -56,6 +56,8 @@ chunk │ --identity-file # SSH identity file for sidecar │ --workdir # Working directory on sidecar │ --project # Override project directory +│ -e / --env KEY=VALUE # Set env var in remote sidecar session (repeatable) +│ --env-file # Env file to load (default: .env.local; pass a path to override) │ ├── sidecar │ ├── list --org-id # List sidecars @@ -78,7 +80,7 @@ chunk │ │ --sidecar-id # Sidecar ID (defaults to active sidecar) │ │ --identity-file # SSH identity file │ │ -e / --env KEY=VALUE # Set env var in remote session (repeatable) -│ │ --env-file # Load env file (defaults to .env.local when flag is present) +│ │ --env-file # Env file to load (default: .env.local; pass a path to override) │ ├── sync # Sync files to sidecar │ │ --sidecar-id # Sidecar ID (defaults to active sidecar) │ │ --identity-file # SSH identity file @@ -99,6 +101,8 @@ chunk │ │ --skip-sync # Skip syncing files to the sidecar │ │ --skip-snapshot # Skip creating a snapshot after install │ │ --force # Re-detect environment even if cached +│ │ -e / --env KEY=VALUE # Set env var in remote sidecar session (repeatable) +│ │ --env-file # Env file to load (default: .env.local; pass a path to override) │ └── snapshot │ ├── create # Snapshot a sidecar, then delete the source sidecar │ │ --sidecar-id # Sidecar ID (defaults to active sidecar) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f86fa57e..513359ba 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -205,6 +205,23 @@ chunk sidecar env | chunk sidecar build --tag myimg # build Docker image chunk sidecar create --image myimg # name auto-generated ``` +### Environment variables + +`chunk sidecar ssh` and `chunk sidecar setup` automatically load `.env.local` from your working directory and forward its variables to the remote sidecar session. This lets you pass secrets and configuration without embedding them in your shell or committing them to the repo. + +```bash +# .env.local is loaded automatically — no flag needed +chunk sidecar ssh + +# Override with a different file +chunk sidecar ssh --env-file /path/to/other.env + +# Add individual variables (merged on top of the file) +chunk sidecar ssh --env MY_VAR=value +``` + +Variables from `--env` flags take precedence over those in `--env-file`. `.env.local` is gitignored by convention, so it's a safe place to store project-specific secrets. + ### Snapshots Capture a configured environment so future sidecars boot fast: diff --git a/internal/cmd/env.go b/internal/cmd/env.go new file mode 100644 index 00000000..af73ae85 --- /dev/null +++ b/internal/cmd/env.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/CircleCI-Public/chunk-cli/internal/secrets" + "github.com/CircleCI-Public/chunk-cli/internal/sidecar" +) + +const defaultEnvFile = ".env.local" + +// resolveEnvVars builds the env var map from --env flags and --env-file. Flags win over file. +func resolveEnvVars(ctx context.Context, workDir, envFile string, envVarsFlag []string) (map[string]string, error) { + flagVars, err := sidecar.ParseEnvPairs(envVarsFlag) + if err != nil { + return nil, &userError{msg: fmt.Sprintf("invalid --env value: %s", err), err: err} + } + var fileVars map[string]string + if envFile != "" { + path := envFile + if !filepath.IsAbs(path) { + path = filepath.Join(workDir, path) + } + fileVars, err = sidecar.LoadEnvFileAt(path) + if err != nil { + return nil, &userError{msg: fmt.Sprintf("load %s: %s", envFile, err), err: err} + } + } + envVars := sidecar.MergeEnv(fileVars, flagVars) + if len(envVars) > 0 { + envVars, err = secrets.ResolveAll(ctx, envVars, nil) + if err != nil { + return nil, fmt.Errorf("resolve secrets: %w", err) + } + } + return envVars, nil +} diff --git a/internal/cmd/sidecar.go b/internal/cmd/sidecar.go index 987e3287..f901b18a 100644 --- a/internal/cmd/sidecar.go +++ b/internal/cmd/sidecar.go @@ -8,7 +8,6 @@ import ( "io" "os" "os/exec" - "path/filepath" "regexp" petname "github.com/dustinkirkland/golang-petname" @@ -18,7 +17,6 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/circleci" "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/iostream" - "github.com/CircleCI-Public/chunk-cli/internal/secrets" "github.com/CircleCI-Public/chunk-cli/internal/sidecar" "github.com/CircleCI-Public/chunk-cli/internal/tui" "github.com/CircleCI-Public/chunk-cli/internal/ui" @@ -343,33 +341,14 @@ func newSidecarSSHCmd() *cobra.Command { if err != nil { return err } - flagVars, err := sidecar.ParseEnvPairs(envVarsFlag) + cwd, err := os.Getwd() if err != nil { - return &userError{msg: fmt.Sprintf("invalid --env value: %s", err), err: err} - } - var envVars map[string]string - if envFile != "" { - path := envFile - if !filepath.IsAbs(path) { - cwd, err := os.Getwd() - if err != nil { - return &userError{msg: "Could not determine the current directory.", err: err} - } - path = filepath.Join(cwd, path) - } - fileVars, err := sidecar.LoadEnvFileAt(path) - if err != nil { - return &userError{msg: fmt.Sprintf("load %s: %s", envFile, err), err: err} - } - envVars = sidecar.MergeEnv(fileVars, flagVars) - } else { - envVars = flagVars + return &userError{msg: "Could not determine the current directory.", err: err} } - resolved, err := secrets.ResolveAll(cmd.Context(), envVars, nil) + envVars, err := resolveEnvVars(cmd.Context(), cwd, envFile, envVarsFlag) if err != nil { - return &userError{msg: fmt.Sprintf("resolve secrets: %s", err), err: err} + return err } - envVars = resolved err = sidecar.SSH(cmd.Context(), client, sidecarID, identityFile, authSock, args, envVars, io) if err != nil { if err := sshSessionError(err); err != nil { @@ -387,8 +366,7 @@ func newSidecarSSHCmd() *cobra.Command { cmd.Flags().StringVar(&sidecarID, "sidecar-id", "", "Sidecar ID (defaults to active sidecar)") cmd.Flags().StringVar(&identityFile, "identity-file", "", "SSH identity file") cmd.Flags().StringArrayVarP(&envVarsFlag, "env", "e", nil, "KEY=VALUE pairs to set in the remote session (repeatable)") - cmd.Flags().StringVar(&envFile, "env-file", "", "Env file to load (default .env.local when flag is present)") - cmd.Flags().Lookup("env-file").NoOptDefVal = ".env.local" + cmd.Flags().StringVar(&envFile, "env-file", defaultEnvFile, "Env file to load (default: .env.local; pass a path to override)") return cmd } @@ -756,6 +734,8 @@ func newSidecarSnapshotGetCmd() *cobra.Command { func newSidecarSetupCmd() *cobra.Command { var sidecarID, orgID, name, identityFile, dir string var skipSync, force bool + var envVarsFlag []string + var envFile string cmd := &cobra.Command{ Use: "setup", @@ -832,8 +812,24 @@ Example: } } - // Step 5: Run setup steps over SSH. - if err := sidecarSetupRunSetup(cmd.Context(), client, sidecarID, identityFile, authSock, env, streams, status); err != nil { + // Step 5: Resolve env vars for SSH execution. + envVars, err := resolveEnvVars(cmd.Context(), dir, envFile, envVarsFlag) + if err != nil { + return err + } + + // Step 6: Run setup steps over SSH. + opts := sidecarRunSetupOpts{ + client: client, + sidecarID: sidecarID, + identityFile: identityFile, + authSock: authSock, + env: env, + envVars: envVars, + streams: streams, + status: status, + } + if err := sidecarSetupRunSetup(cmd.Context(), opts); err != nil { return err } @@ -851,6 +847,8 @@ Example: cmd.Flags().StringVar(&identityFile, "identity-file", "", "SSH identity file") cmd.Flags().BoolVar(&skipSync, "skip-sync", false, "Skip syncing files to the sidecar") cmd.Flags().BoolVar(&force, "force", false, "Re-detect environment even if cached in .chunk/config.json") + cmd.Flags().StringArrayVarP(&envVarsFlag, "env", "e", nil, "KEY=VALUE pairs to set in remote sidecar session (repeatable)") + cmd.Flags().StringVar(&envFile, "env-file", defaultEnvFile, "Env file to load (default: .env.local; pass a path to override)") return cmd } @@ -941,16 +939,20 @@ func sidecarSetupSync( return err } -func sidecarSetupRunSetup( - ctx context.Context, - client *circleci.Client, - sidecarID, identityFile, authSock string, - env *envbuilder.Environment, - streams iostream.Streams, - status iostream.StatusFunc, -) error { - if len(env.Setup) == 0 { - status(iostream.LevelInfo, "No setup steps detected, skipping") +type sidecarRunSetupOpts struct { + client *circleci.Client + sidecarID string + identityFile string + authSock string + env *envbuilder.Environment + envVars map[string]string + streams iostream.Streams + status iostream.StatusFunc +} + +func sidecarSetupRunSetup(ctx context.Context, opts sidecarRunSetupOpts) error { + if len(opts.env.Setup) == 0 { + opts.status(iostream.LevelInfo, "No setup steps detected, skipping") return nil } @@ -961,12 +963,12 @@ func sidecarSetupRunSetup( ws = active.Workspace } - for _, step := range env.Setup { + for _, step := range opts.env.Setup { if step.Name == "test" { continue // test step is for Dockerfile CMD only, not for SSH execution } - status(iostream.LevelStep, fmt.Sprintf("Running setup step %q: %s", step.Name, step.Command)) - session, err := sidecar.OpenSession(ctx, client, sidecarID, identityFile, authSock) + opts.status(iostream.LevelStep, fmt.Sprintf("Running setup step %q: %s", step.Name, step.Command)) + session, err := sidecar.OpenSession(ctx, opts.client, opts.sidecarID, opts.identityFile, opts.authSock) if err != nil { if sessErr := sshSessionError(err); sessErr != nil { return sessErr @@ -981,11 +983,11 @@ func sidecarSetupRunSetup( } // cimg images set PATH via Docker ENV which e2b does not propagate to SSH // sessions, so prepend the stack's binary locations explicitly. - if paths := env.BinaryPaths(); paths != "" { + if paths := opts.env.BinaryPaths(); paths != "" { workspaceCmd = "export PATH=" + paths + ":$PATH && " + workspaceCmd } loginCmd := "bash -l -c " + sidecar.ShellEscape(workspaceCmd) - result, err := sidecar.ExecOverSSH(ctx, session, loginCmd, nil, nil) + result, err := sidecar.ExecOverSSH(ctx, session, loginCmd, nil, opts.envVars) if err != nil { if sessErr := sshSessionError(err); sessErr != nil { return sessErr @@ -993,10 +995,10 @@ func sidecarSetupRunSetup( return err } if result.Stdout != "" { - streams.Printf("%s", result.Stdout) + opts.streams.Printf("%s", result.Stdout) } if result.Stderr != "" { - streams.ErrPrintf("%s", result.Stderr) + opts.streams.ErrPrintf("%s", result.Stderr) } if result.ExitCode != 0 { return &userError{ @@ -1005,7 +1007,7 @@ func sidecarSetupRunSetup( errMsg: fmt.Sprintf("setup step %q exited with status %d", step.Name, result.ExitCode), } } - status(iostream.LevelDone, fmt.Sprintf("Step %q complete", step.Name)) + opts.status(iostream.LevelDone, fmt.Sprintf("Step %q complete", step.Name)) } return nil } diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index 961e319a..171c5c70 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -77,10 +77,24 @@ func runValidateList(workDir string, jsonOut bool, streams iostream.Streams, sta return validate.List(cfg, statusFn) } +type validateOpts struct { + sidecarID string + identityFile string + workdir string + orgID string + dryRun bool + list bool + save bool + remote bool + jsonOut bool + inlineCmd string + projectDir string + envVarsFlag []string + envFile string +} + func newValidateCmd() *cobra.Command { - var sidecarID, identityFile, workdir, orgID string - var dryRun, list, save, remote, jsonOut bool - var inlineCmd, projectDir string + var opts validateOpts cmd := &cobra.Command{ Use: "validate [name]", @@ -88,130 +102,167 @@ func newValidateCmd() *cobra.Command { SilenceUsage: true, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - streams := iostream.FromCmd(cmd) + return runValidateCmdE(cmd, args, &opts) + }, + } - workDir := projectDir - if workDir == "" { - var err error - workDir, err = os.Getwd() - if err != nil { - return err - } - } + cmd.Flags().BoolVar(&opts.remote, "remote", false, "Run on active sidecar, or create one if none is set") + cmd.Flags().StringVar(&opts.sidecarID, "sidecar-id", "", "Sidecar ID for remote execution") + cmd.Flags().StringVar(&opts.orgID, "org-id", "", "Organization ID (used when creating a new sidecar)") + cmd.Flags().StringVar(&opts.identityFile, "identity-file", "", "SSH identity file (uses ssh-agent or ~/.ssh/chunk_ai when omitted)") + cmd.Flags().StringVar(&opts.workdir, "workdir", "", "Working directory on sidecar (reads from sidecar.json, defaults to ./workspace)") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Show commands without executing") + cmd.Flags().BoolVar(&opts.list, "list", false, "List all configured commands") + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Output as JSON (only applies with --list)") + cmd.Flags().StringVar(&opts.inlineCmd, "cmd", "", "Run an inline command instead of config") + cmd.Flags().BoolVar(&opts.save, "save", false, "Save --cmd to .chunk/config.json") + cmd.Flags().StringVar(&opts.projectDir, "project", "", "Override project directory") + cmd.Flags().StringArrayVarP(&opts.envVarsFlag, "env", "e", nil, "KEY=VALUE pairs to set in remote sidecar session (repeatable)") + cmd.Flags().StringVar(&opts.envFile, "env-file", defaultEnvFile, "Env file to load (default: .env.local; pass a path to override)") - hook := detectHook(cmd.InOrStdin()) - ctx := cmd.Context() - if hook != nil { - ctx = session.WithID(ctx, hook.sessionID) - if !hook.stopHookActive { - validate.ResetAttempts(hook.sessionID) - } - // Route stdout to stderr so all output appears in the Stop - // hook feedback block that Claude Code shows the agent. - streams = iostream.Streams{Out: streams.Err, Err: streams.Err} - } - statusFn := newStatusFunc(streams) + return cmd +} - // Hook: exit 1 with a message when hooks are disabled. - envDisabled := os.Getenv(config.EnvChunkHooksDisabled) != "" - if hook != nil && validate.HooksDisabled(workDir, envDisabled) { - streams.ErrPrintln("chunk validate: hooks are disabled — skipping validation") - return validate.NewHookExitError(1) - } +// initHook applies hook-specific context, stream, and early-exit logic. +// Returns updated ctx and streams, a skip flag (true = return nil immediately), +// and a non-nil error when the hook should exit with a non-zero code. +func initHook(ctx context.Context, hook *hookContext, workDir string, streams iostream.Streams) (context.Context, iostream.Streams, bool, error) { + if hook == nil { + return ctx, streams, false, nil + } + ctx = session.WithID(ctx, hook.sessionID) + if !hook.stopHookActive { + validate.ResetAttempts(hook.sessionID) + } + // Route stdout to stderr so all output appears in the Stop + // hook feedback block that Claude Code shows the agent. + streams = iostream.Streams{Out: streams.Err, Err: streams.Err} + if validate.HooksDisabled(workDir, os.Getenv(config.EnvChunkHooksDisabled) != "") { + streams.ErrPrintln("chunk validate: hooks are disabled — skipping validation") + return ctx, streams, false, validate.NewHookExitError(1) + } + if !validate.HasGitChanges(workDir) { + return ctx, streams, true, nil + } + return ctx, streams, false, nil +} - // Hook: skip entirely when the working tree is clean. - if hook != nil && !validate.HasGitChanges(workDir) { - return nil - } +func runValidateCmdE(cmd *cobra.Command, args []string, opts *validateOpts) error { + streams := iostream.FromCmd(cmd) - var name string - if len(args) == 1 { - name = args[0] - } + workDir := opts.projectDir + if workDir == "" { + var err error + workDir, err = os.Getwd() + if err != nil { + return err + } + } - // --list: show configured commands - if list { - return runValidateList(workDir, jsonOut, streams, statusFn) - } - if jsonOut { - return fmt.Errorf("--json requires --list") - } + hook := detectHook(cmd.InOrStdin()) + ctx := cmd.Context() - cfg, err := config.LoadProjectConfig(workDir) - if hook != nil && (err != nil || !cfg.HasCommands()) && inlineCmd == "" { - return nil // no config in hook context: skip silently - } - if (err != nil || !cfg.HasCommands()) && inlineCmd == "" { - return &userError{ - msg: "No validate commands configured.", - suggestion: "Run 'chunk init' first.", - errMsg: "no validate commands configured", - } - } + var skip bool + var hookErr error + ctx, streams, skip, hookErr = initHook(ctx, hook, workDir, streams) + if hookErr != nil { + return hookErr + } + if skip { + return nil + } + statusFn := newStatusFunc(streams) - if dryRun { - return runValidateDryRun(cfg, name, inlineCmd, statusFn) - } + var name string + if len(args) == 1 { + name = args[0] + } - // Hook: fail early when CircleCI auth is missing and remote commands need it. - // In non-hook context ensureCircleCIClient prompts interactively; hooks have - // no TTY so we surface a clear message here instead of a confusing fallback. - rc, _ := config.Resolve("", "") - if hook != nil && cfg.HasRemoteCommands() && rc.CircleCIToken == "" { - streams.ErrPrintln("CircleCI auth is not configured.") - streams.ErrPrintln("Suggestion: " + suggestionCircleCIAuth) - return errSilentExit - } + // --list: show configured commands + if opts.list { + return runValidateList(workDir, opts.jsonOut, streams, statusFn) + } + if opts.jsonOut { + return fmt.Errorf("--json requires --list") + } + + cfg, err := config.LoadProjectConfig(workDir) + if hook != nil && (err != nil || !cfg.HasCommands()) && opts.inlineCmd == "" { + return nil // no config in hook context: skip silently + } + if (err != nil || !cfg.HasCommands()) && opts.inlineCmd == "" { + return &userError{ + msg: "No validate commands configured.", + suggestion: "Run 'chunk init' first.", + errMsg: "no validate commands configured", + } + } - // allRemote is true when the caller explicitly targets the sidecar - // (--remote or --sidecar-id), meaning every command runs there. - // Per-command routing only applies when the sidecar is resolved implicitly. - allRemote := remote || sidecarID != "" + // Validate --env flag syntax before any remote resolution so bad + // values are caught immediately regardless of execution mode. + if len(opts.envVarsFlag) > 0 { + if _, vErr := sidecar.ParseEnvPairs(opts.envVarsFlag); vErr != nil { + return &userError{msg: fmt.Sprintf("invalid --env value: %s", vErr), err: vErr} + } + } - image := resolveImage(name, cfg) + if opts.dryRun { + return runValidateDryRun(name, opts.inlineCmd, cfg, statusFn) + } - freshlyCreated := false - if remote { - // --remote: force all commands to sidecar, creating one if needed. - var err error - freshlyCreated, err = resolveOrCreateSidecarID(ctx, &sidecarID, orgID, image, workDir, streams) - if err != nil { - return err - } - statusFn(iostream.LevelInfo, fmt.Sprintf("running all commands on sidecar %s", sidecarID)) - } else if cfg.HasRemoteCommands() { - freshlyCreated = resolveSidecar(ctx, &sidecarID, orgID, image, workDir, hook, streams) - } + // Hook: fail early when CircleCI auth is missing and remote commands need it. + // In non-hook context ensureCircleCIClient prompts interactively; hooks have + // no TTY so we surface a clear message here instead of a confusing fallback. + rc, _ := config.Resolve("", "") + if hook != nil && cfg.HasRemoteCommands() && rc.CircleCIToken == "" { + streams.ErrPrintln("CircleCI auth is not configured.") + streams.ErrPrintln("Suggestion: " + suggestionCircleCIAuth) + return errSilentExit + } - execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, freshlyCreated, identityFile, workdir, allRemote, cfg, statusFn, streams) + // allRemote is true when the caller explicitly targets the sidecar + // (--remote or --sidecar-id), meaning every command runs there. + // Per-command routing only applies when the sidecar is resolved implicitly. + allRemote := opts.remote || opts.sidecarID != "" - if hook != nil { - maxAttempts := cfg.StopHookMaxAttempts - if maxAttempts <= 0 { - maxAttempts = validate.DefaultMaxAttempts - } - return validate.WrapHookResult(hook.sessionID, execErr, maxAttempts, streams.Err) - } - return execErr - }, + image := resolveImage(name, cfg) + + freshlyCreated := false + if opts.remote { + // --remote: force all commands to sidecar, creating one if needed. + freshlyCreated, err = resolveOrCreateSidecarID(ctx, &opts.sidecarID, opts.orgID, image, workDir, streams) + if err != nil { + return err + } + statusFn(iostream.LevelInfo, fmt.Sprintf("running all commands on sidecar %s", opts.sidecarID)) + } else if cfg.HasRemoteCommands() { + freshlyCreated = resolveSidecar(ctx, &opts.sidecarID, opts.orgID, image, workDir, hook, streams) + } + + // Only load env vars and resolve secrets when a sidecar is actually + // being used — avoids parsing .env.local or hitting secrets APIs on + // purely local runs. + var envVars map[string]string + if opts.sidecarID != "" { + envVars, err = resolveEnvVars(ctx, workDir, opts.envFile, opts.envVarsFlag) + if err != nil { + return err + } } - cmd.Flags().BoolVar(&remote, "remote", false, "Run on active sidecar, or create one if none is set") - cmd.Flags().StringVar(&sidecarID, "sidecar-id", "", "Sidecar ID for remote execution") - cmd.Flags().StringVar(&orgID, "org-id", "", "Organization ID (used when creating a new sidecar)") - cmd.Flags().StringVar(&identityFile, "identity-file", "", "SSH identity file (uses ssh-agent or ~/.ssh/chunk_ai when omitted)") - cmd.Flags().StringVar(&workdir, "workdir", "", "Working directory on sidecar (reads from sidecar.json, defaults to ./workspace)") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show commands without executing") - cmd.Flags().BoolVar(&list, "list", false, "List all configured commands") - cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON (only applies with --list)") - cmd.Flags().StringVar(&inlineCmd, "cmd", "", "Run an inline command instead of config") - cmd.Flags().BoolVar(&save, "save", false, "Save --cmd to .chunk/config.json") - cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory") + execErr := runValidate(ctx, workDir, name, opts.inlineCmd, opts.save, opts.sidecarID, freshlyCreated, opts.identityFile, opts.workdir, allRemote, envVars, cfg, statusFn, streams) - return cmd + if hook != nil { + maxAttempts := cfg.StopHookMaxAttempts + if maxAttempts <= 0 { + maxAttempts = validate.DefaultMaxAttempts + } + return validate.WrapHookResult(hook.sessionID, execErr, maxAttempts, streams.Err) + } + return execErr } -func runValidateDryRun(cfg *config.ProjectConfig, name, inlineCmd string, statusFn iostream.StatusFunc) error { +func runValidateDryRun(name, inlineCmd string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc) error { if inlineCmd != "" { cmdName := name if cmdName == "" { @@ -227,7 +278,7 @@ func runValidateDryRun(cfg *config.ProjectConfig, name, inlineCmd string, status // provided options. It is shared by both direct and hook invocations. // allRemote is true when --remote is passed explicitly (all commands run on the // sidecar); false means only commands with Remote:true are routed to the sidecar. -func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID string, freshlyCreated bool, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error { +func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID string, freshlyCreated bool, identityFile, workdir string, allRemote bool, envVars map[string]string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error { // --cmd: inline command (always local in per-command mode) if inlineCmd != "" { cmdName := name @@ -241,7 +292,7 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", cmdName))) } if sidecarID != "" && allRemote { - execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) + execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, envVars, streams) if err != nil { return err } @@ -252,7 +303,7 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool // All-remote execution (--remote flag): send everything to the sidecar. if sidecarID != "" && allRemote { - execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) + execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, envVars, streams) if err != nil { return err } @@ -265,7 +316,7 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool if name != "" { if cmd := cfg.FindCommand(name); cmd != nil && cmd.Remote { statusFn(iostream.LevelInfo, fmt.Sprintf("running %s on sidecar %s", name, sidecarID)) - execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) + execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, envVars, streams) if err != nil { return err } @@ -274,7 +325,7 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool statusFn(iostream.LevelInfo, fmt.Sprintf("running %s locally (not marked remote)", name)) // Named command is not marked remote; fall through to local execution. } else { - return runSplitCommands(ctx, sidecarID, freshlyCreated, identityFile, workdir, workDir, cfg, statusFn, streams) + return runSplitCommands(ctx, sidecarID, freshlyCreated, identityFile, workdir, workDir, envVars, cfg, statusFn, streams) } } @@ -319,7 +370,7 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool // openSSHSession establishes an SSH session to the sidecar and returns an // exec function and the resolved remote working directory. -func openSSHSession(ctx context.Context, sidecarID, identityFile, workdir string, streams iostream.Streams) (func(context.Context, string) (string, string, int, error), string, error) { +func openSSHSession(ctx context.Context, sidecarID, identityFile, workdir string, envVars map[string]string, streams iostream.Streams) (func(context.Context, string) (string, string, int, error), string, error) { client, err := ensureCircleCIClient(ctx, streams, tui.PromptHidden) if err != nil { return nil, "", err @@ -336,9 +387,15 @@ func openSSHSession(ctx context.Context, sidecarID, identityFile, workdir string if err != nil { return nil, "", &userError{msg: "Could not resolve config.", err: err} } - envVars := hostForwardEnv(rc.CircleCIToken) + merged := hostForwardEnv(rc.CircleCIToken) + if merged == nil { + merged = make(map[string]string, len(envVars)) + } + for k, v := range envVars { + merged[k] = v + } execFn := func(ctx context.Context, script string) (string, string, int, error) { - result, err := sidecar.ExecOverSSH(ctx, session, "sh -c "+sidecar.ShellEscape(script), nil, envVars) + result, err := sidecar.ExecOverSSH(ctx, session, "sh -c "+sidecar.ShellEscape(script), nil, merged) if err != nil { return "", "", 0, err } @@ -365,7 +422,7 @@ func hostForwardEnv(token string) map[string]string { // When freshlyCreated is true, SSH failures are hard errors rather than // silent local fallbacks (a newly provisioned sidecar that can't be reached // indicates a real problem, not temporary unavailability). -func runSplitCommands(ctx context.Context, sidecarID string, freshlyCreated bool, identityFile, workdir, workDir string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error { +func runSplitCommands(ctx context.Context, sidecarID string, freshlyCreated bool, identityFile, workdir, workDir string, envVars map[string]string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error { remoteCfg, localCfg := splitByRemote(cfg) if len(remoteCfg.Commands) > 0 { statusFn(iostream.LevelInfo, fmt.Sprintf("running on sidecar %s: %s", sidecarID, commandNames(remoteCfg.Commands))) @@ -375,7 +432,7 @@ func runSplitCommands(ctx context.Context, sidecarID string, freshlyCreated bool } var runErr error if len(remoteCfg.Commands) > 0 { - execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) + execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, envVars, streams) if err != nil { if freshlyCreated { return newUserError(fmt.Sprintf("Could not reach newly created sidecar %s.", sidecarID)). diff --git a/internal/cmd/validate_test.go b/internal/cmd/validate_test.go index f920900c..195149e5 100644 --- a/internal/cmd/validate_test.go +++ b/internal/cmd/validate_test.go @@ -2,13 +2,18 @@ package cmd import ( "bytes" + "context" "errors" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "gotest.tools/v3/assert" "github.com/CircleCI-Public/chunk-cli/internal/config" + "github.com/CircleCI-Public/chunk-cli/internal/testing/fakes" ) // hookPayload is the JSON Claude Code sends to Stop hooks via stdin. @@ -87,3 +92,63 @@ func TestHostForwardEnv(t *testing.T) { assert.Assert(t, !hasAlias) }) } + +// setupSSHSession starts fake CCI + SSH servers and sets env vars so +// ensureCircleCIClient resolves to the fake. Returns the SSH server and the +// identity key file path. +func setupSSHSession(t *testing.T) (*fakes.SSHServer, string) { + t.Helper() + isolateConfig(t) + + keyFile, pubKey := fakes.GenerateSSHKeypair(t) + sshSrv := fakes.NewSSHServer(t, pubKey) + sshSrv.SetResult("hello\n", 0) + + cci := fakes.NewFakeCircleCI() + cci.AddKeyURL = sshSrv.Addr() + cciSrv := httptest.NewServer(cci) + t.Cleanup(cciSrv.Close) + + t.Setenv(config.EnvCircleToken, "test-token") + t.Setenv(config.EnvCircleCIBaseURL, cciSrv.URL) + + return sshSrv, keyFile +} + +func TestOpenSSHSessionPassesEnvVars(t *testing.T) { + sshSrv, keyFile := setupSSHSession(t) + + envVars := map[string]string{"FOO": "bar", "BAZ": "qux"} + execFn, _, err := openSSHSession(context.Background(), "sidecar-123", keyFile, "", envVars, discardStreams()) + assert.NilError(t, err) + + _, _, _, err = execFn(context.Background(), "echo hello") + assert.NilError(t, err) + + got := sshSrv.EnvVars() + assert.Equal(t, got["FOO"], "bar") + assert.Equal(t, got["BAZ"], "qux") +} + +func TestValidateEnvFlagBadValue(t *testing.T) { + isolateConfig(t) + dir := t.TempDir() + + // Write a minimal project config so validate doesn't fail with "no commands". + cfgDir := filepath.Join(dir, ".chunk") + assert.NilError(t, os.MkdirAll(cfgDir, 0o755)) + assert.NilError(t, os.WriteFile( + filepath.Join(cfgDir, "config.json"), + []byte(`{"commands":[{"name":"test","run":"true"}]}`), + 0o644, + )) + + cmd := newValidateCmd() + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + cmd.SetArgs([]string{"--project", dir, "--env", "BADVALUE"}) + + err := cmd.Execute() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), "BADVALUE"), "got: %v", err) +} diff --git a/internal/github/github_test.go b/internal/github/github_test.go index 81d0cc5d..26d9ad8d 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -148,6 +148,7 @@ func TestFetchReviewActivity(t *testing.T) { } if result == nil { t.Fatal("expected non-nil result") + return } // Should have 2 reviewers: alice and bob @@ -158,6 +159,7 @@ func TestFetchReviewActivity(t *testing.T) { alice := result.Activity["reviewer-alice"] if alice == nil { t.Fatal("expected alice in activity") + return } if alice.Approvals != 1 { t.Errorf("alice approvals: got %d, want 1", alice.Approvals) @@ -169,6 +171,7 @@ func TestFetchReviewActivity(t *testing.T) { bob := result.Activity["reviewer-bob"] if bob == nil { t.Fatal("expected bob in activity") + return } if bob.ChangesRequested != 1 { t.Errorf("bob changes_requested: got %d, want 1", bob.ChangesRequested) @@ -268,6 +271,7 @@ func TestFetchReviewActivity(t *testing.T) { bob := result.Activity["bob"] if bob == nil { t.Fatal("expected bob") + return } if bob.Approvals != 1 { t.Errorf("bob approvals: got %d, want 1", bob.Approvals) diff --git a/internal/sidecar/env.go b/internal/sidecar/env.go index 35d4a1ce..fb69c528 100644 --- a/internal/sidecar/env.go +++ b/internal/sidecar/env.go @@ -24,6 +24,9 @@ func ParseEnvPairs(pairs []string) (map[string]string, error) { return nil, fmt.Errorf("%q is not a KEY=VALUE pair", pair) } key := pair[:idx] + if key == "" { + return nil, fmt.Errorf("%q has an empty key", pair) + } val := pair[idx+1:] result[key] = val } diff --git a/internal/sidecar/env_test.go b/internal/sidecar/env_test.go index 396c8a46..26d8248a 100644 --- a/internal/sidecar/env_test.go +++ b/internal/sidecar/env_test.go @@ -47,6 +47,11 @@ func TestParseEnvPairs(t *testing.T) { _, err := ParseEnvPairs([]string{"NOEQUALS"}) assert.ErrorContains(t, err, "NOEQUALS") }) + + t.Run("empty key returns error", func(t *testing.T) { + _, err := ParseEnvPairs([]string{"=value"}) + assert.ErrorContains(t, err, "empty key") + }) } func TestParseEnvFile(t *testing.T) { diff --git a/internal/testing/fakes/ssh.go b/internal/testing/fakes/ssh.go index 0d485683..26d09f33 100644 --- a/internal/testing/fakes/ssh.go +++ b/internal/testing/fakes/ssh.go @@ -24,11 +24,13 @@ import ( // SSHServer is a WebSocket+SSH server for testing SSH-based sidecar interactions. // It accepts a single authorized public key and records exec requests. type SSHServer struct { + t *testing.T srv *httptest.Server mu sync.Mutex stdout string exitCode int commands []string + envVars map[string]string } // GenerateSSHKeypair generates an ed25519 keypair, writes the private and public @@ -86,7 +88,7 @@ func NewSSHServer(t *testing.T, authorizedKey ssh.PublicKey) *SSHServer { } sshCfg.AddHostKey(hostSigner) - srv := &SSHServer{} + srv := &SSHServer{t: t} mux := http.NewServeMux() mux.HandleFunc("/ssh/tunnel", func(w http.ResponseWriter, r *http.Request) { @@ -130,6 +132,17 @@ func (s *SSHServer) Commands() []string { return out } +// EnvVars returns a copy of all environment variables received via "env" requests. +func (s *SSHServer) EnvVars() map[string]string { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]string, len(s.envVars)) + for k, v := range s.envVars { + out[k] = v + } + return out +} + func (s *SSHServer) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { defer conn.Close() //nolint:errcheck @@ -161,6 +174,17 @@ func (s *SSHServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) switch req.Type { case "env": _ = req.Reply(true, nil) + // SSH env request payload: [4-byte name length][name bytes][4-byte value length][value bytes] + nameLen := binary.BigEndian.Uint32(req.Payload[:4]) + name := string(req.Payload[4 : 4+nameLen]) + valLen := binary.BigEndian.Uint32(req.Payload[4+nameLen : 8+nameLen]) + val := string(req.Payload[8+nameLen : 8+nameLen+valLen]) + s.mu.Lock() + if s.envVars == nil { + s.envVars = make(map[string]string) + } + s.envVars[name] = val + s.mu.Unlock() continue case "exec": // handled below