Skip to content
6 changes: 5 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ chunk
│ --identity-file <path> # SSH identity file for sidecar
│ --workdir <path> # Working directory on sidecar
│ --project <path> # Override project directory
│ -e / --env KEY=VALUE # Set env var in remote sidecar session (repeatable)
│ --env-file <path> # Env file to load (default: .env.local; pass a path to override)
├── sidecar
│ ├── list --org-id <id> # List sidecars
Expand All @@ -78,7 +80,7 @@ chunk
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
│ │ --identity-file <path> # SSH identity file
│ │ -e / --env KEY=VALUE # Set env var in remote session (repeatable)
│ │ --env-file <path> # Load env file (defaults to .env.local when flag is present)
│ │ --env-file <path> # Env file to load (default: .env.local; pass a path to override)
│ ├── sync # Sync files to sidecar
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
│ │ --identity-file <path> # SSH identity file
Expand All @@ -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 <path> # Env file to load (default: .env.local; pass a path to override)
│ └── snapshot
│ ├── create # Snapshot a sidecar, then delete the source sidecar
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
Expand Down
17 changes: 17 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions internal/cmd/env.go
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we're moving to always loading i wonder if we want to have this come from an env file specifically for chunk? not blocking just a thought

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at a minimum include some docs that .env.local files will be automatically read and used to passed to the running Chunk sidecar.


// 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
}
96 changes: 49 additions & 47 deletions internal/cmd/sidecar.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"regexp"

petname "github.com/dustinkirkland/golang-petname"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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)")
Comment thread
schurchleycci marked this conversation as resolved.

return cmd
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Comment thread
schurchleycci marked this conversation as resolved.
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
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -981,22 +983,22 @@ 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
}
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{
Expand All @@ -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
}
Loading