Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ Auto-detect your tech stack, install dependencies, and snapshot the result so fu
# Detect environment, run install steps, and create a snapshot
chunk sidecar setup --name my-sidecar

# Or build a local Docker test image from the detected environment
chunk sidecar env | chunk sidecar build --dir .
# Or generate a Dockerfile from the detected environment, then build it yourself
chunk env init --dir .
docker build -f Dockerfile.test -t myapp:test .
```

##### Snapshots
Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ tasks:

env-harness:
desc: |
Run chunk sidecar env/build against target repos, using Claude to improve
Run chunk env init against target repos, using Claude to improve
envbuilder on failure. Args are passed through to harness/environment.py.
Examples:
task env-harness # all known repos
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ func resolveRepos(t *testing.T) []repoEntry {
return entries
}

// TestSidecarsBuildEndToEnd clones real open-source repos, runs
// `chunk sidecars build` to generate a Dockerfile.test, builds the image,
// TestEnvInitEndToEnd clones real open-source repos, runs
// `chunk env init --format json` to get the environment spec, writes
// Dockerfile.test with `chunk env init`, builds the image with docker,
// and runs the tests inside the container.
//
// Enable with: CHUNK_ENV_BUILDER_ACCEPTANCE=1
Expand All @@ -88,7 +89,7 @@ func resolveRepos(t *testing.T) []repoEntry {
// To run a specific subset, set CHUNK_SIDECAR_REPOS to a comma-separated list
// of known nicknames (e.g. "flask,serde") or full Git URLs
// (e.g. "https://github.com/owner/repo.git"), or a mix of both.
func TestSidecarsBuildEndToEnd(t *testing.T) {
func TestEnvInitEndToEnd(t *testing.T) {
if os.Getenv("CHUNK_ENV_BUILDER_ACCEPTANCE") == "" {
t.Skip("set CHUNK_ENV_BUILDER_ACCEPTANCE=1 to run")
}
Expand All @@ -113,10 +114,10 @@ func TestSidecarsBuildEndToEnd(t *testing.T) {
}
e2eCloneRepo(t, repo.URL, repo.Ref, cloneDir)

t.Log("Running chunk sidecar env...")
t.Log("Running chunk env init --format json...")
envJSON, envStderr, exitCode := e2eRunEnv(t, cloneDir)
assert.Equal(t, exitCode, 0,
"chunk sidecar env failed\nstdout: %s\nstderr: %s", envJSON, envStderr)
"chunk env init --format json failed\nstdout: %s\nstderr: %s", envJSON, envStderr)
t.Log(envStderr)
t.Log(envJSON)

Expand All @@ -133,14 +134,19 @@ func TestSidecarsBuildEndToEnd(t *testing.T) {

tag := "chunk-sidecar-" + repo.Name + "-test"

t.Log("Building Docker image...")
buildOutput, buildExitCode := e2eRunBuild(t, cloneDir, tag, envJSON)
assert.Equal(t, buildExitCode, 0,
"chunk sidecar build failed\n%s", lastLines(buildOutput, 30))
t.Log("Running chunk env init (writes Dockerfile.test)...")
renderOutput, renderExitCode := e2eInitDockerfile(t, cloneDir)
assert.Equal(t, renderExitCode, 0,
"chunk env init failed\n%s", lastLines(renderOutput, 30))

_, err := os.Stat(cloneDir + "/Dockerfile.test")
assert.NilError(t, err, "Dockerfile.test not created")

t.Log("Building Docker image...")
buildOutput, buildExitCode := e2eDockerBuild(t, cloneDir, tag)
assert.Equal(t, buildExitCode, 0,
"docker build failed\n%s", lastLines(buildOutput, 30))

t.Log("Running tests in container...")
testsOK, testOutput := e2eDockerRun(t, tag)
assert.Assert(t, testsOK,
Expand Down Expand Up @@ -196,7 +202,7 @@ func e2eRunEnv(t *testing.T, dir string) (stdout, stderr string, exitCode int) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

cmd := exec.CommandContext(ctx, binary.Path(), "sidecar", "env", "--dir", dir)
cmd := exec.CommandContext(ctx, binary.Path(), "env", "init", "--format", "json", "--dir", dir)
cmd.Env = []string{
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
Expand All @@ -217,27 +223,54 @@ func e2eRunEnv(t *testing.T, dir string) (stdout, stderr string, exitCode int) {
return outBuf.String(), errBuf.String(), exitCode
}

func e2eRunBuild(t *testing.T, dir, tag, envJSON string) (output string, exitCode int) {
// e2eInitDockerfile runs `chunk env init` (default dockerfile output) to write
// Dockerfile.test into dir. Runs separately from e2eRunEnv because the JSON
// step uses --format json and the dockerfile step uses the default.
func e2eInitDockerfile(t *testing.T, dir string) (output string, exitCode int) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

cmd := exec.CommandContext(ctx, binary.Path(), "sidecar", "build", "--dir", dir, "--tag", tag)
cmd := exec.CommandContext(ctx, binary.Path(), "env", "init", "--dir", dir)
cmd.Env = []string{
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
"NO_COLOR=1",
}
cmd.Stdin = strings.NewReader(envJSON)
var outBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &outBuf
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return outBuf.String(), exitErr.ExitCode()
}
return outBuf.String(), 1
}
return outBuf.String(), 0
}

err := cmd.Run()
if err != nil {
func e2eDockerBuild(t *testing.T, dir, tag string) (output string, exitCode int) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()

cmd := exec.CommandContext(ctx, "docker", "build", "-f", "Dockerfile.test", "-t", tag, ".")
cmd.Dir = dir
cmd.Env = []string{
fmt.Sprintf("PATH=%s", os.Getenv("PATH")),
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
"NO_COLOR=1",
}
var outBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &outBuf
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
}
return outBuf.String(), exitCode
Expand Down
63 changes: 29 additions & 34 deletions acceptance/sidecar_gaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,73 +127,68 @@ func TestSidecarExecAPIError(t *testing.T) {
assert.Assert(t, result.ExitCode != 0, "expected non-zero exit for 500 response")
}

// --- build error paths ---
// --- env init paths ---

func TestSidecarBuildMissingDockerfile(t *testing.T) {
func TestEnvInitDefault(t *testing.T) {
dir := t.TempDir()

env := testenv.NewTestEnv(t)
// Default output is dockerfile: writes Dockerfile.test, prints path to stdout.
result := binary.RunCLI(t, []string{
"sidecar", "build",
"env", "init",
"--dir", dir,
"--no-save",
}, env, env.HomeDir)

assert.Assert(t, result.ExitCode != 0, "expected non-zero exit when Dockerfile.test missing")
}

func TestSidecarBuildInvalidTag(t *testing.T) {
env := testenv.NewTestEnv(t)
result := binary.RunCLI(t, []string{
"sidecar", "build",
"--tag", "!!!invalid",
}, env, env.HomeDir)

assert.Assert(t, result.ExitCode != 0, "expected non-zero exit for invalid tag")
combined := result.Stdout + result.Stderr
assert.Assert(t, strings.Contains(combined, "Invalid image tag"),
"expected invalid tag error, got: %s", combined)
}

func TestSidecarBuildNonexistentDir(t *testing.T) {
env := testenv.NewTestEnv(t)
result := binary.RunCLI(t, []string{
"sidecar", "build",
"--dir", "/tmp/nonexistent-dir-abc123",
}, env, env.HomeDir)

assert.Assert(t, result.ExitCode != 0, "expected non-zero exit for nonexistent dir")
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
assert.Assert(t, strings.Contains(result.Stdout, "Dockerfile.test"),
"expected Dockerfile.test path on stdout, got: %s", result.Stdout)
}

// --- env error paths ---

func TestSidecarEnvEmptyDir(t *testing.T) {
func TestEnvInitJSONFormat(t *testing.T) {
dir := t.TempDir()

env := testenv.NewTestEnv(t)
result := binary.RunCLI(t, []string{
"sidecar", "env",
"env", "init",
"--format", "json",
"--dir", dir,
}, env, env.HomeDir)

// Empty dir should still succeed (unknown stack) and produce JSON on stdout.
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)

// Verify JSON output on stdout
var envOutput map[string]interface{}
err := json.Unmarshal([]byte(result.Stdout), &envOutput)
assert.NilError(t, err, "expected valid JSON on stdout, got: %s", result.Stdout)
}

func TestSidecarEnvNonexistentDir(t *testing.T) {
func TestEnvInitNonexistentDir(t *testing.T) {
env := testenv.NewTestEnv(t)
result := binary.RunCLI(t, []string{
"sidecar", "env",
"env", "init",
"--dir", "/tmp/nonexistent-dir-xyz789",
}, env, env.HomeDir)

assert.Assert(t, result.ExitCode != 0, "expected non-zero exit for nonexistent dir")
}

func TestEnvInitInvalidFormat(t *testing.T) {
dir := t.TempDir()

env := testenv.NewTestEnv(t)
result := binary.RunCLI(t, []string{
"env", "init",
"--format", "bogus",
"--dir", dir,
}, env, env.HomeDir)

assert.Assert(t, result.ExitCode != 0, "expected non-zero exit for invalid format")
combined := result.Stdout + result.Stderr
assert.Assert(t, strings.Contains(combined, "Invalid --format"),
"expected invalid format error, got: %s", combined)
}

// --- create error paths ---

func TestSidecarCreateOrgIDFromEnv(t *testing.T) {
Expand Down
12 changes: 6 additions & 6 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,6 @@ chunk
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
│ │ --identity-file <path> # SSH identity file
│ │ --workdir <path> # Destination path on sidecar (auto-detected when omitted)
│ ├── env # Detect tech stack and print environment spec as JSON
│ │ --dir <path> # Directory to analyse (default: .)
│ │ --no-save # Print only, do not save to .chunk/config.json
│ ├── build # Generate Dockerfile and build test image from env spec
│ │ --dir <path> # Directory to write Dockerfile.test and build from
│ │ --tag <tag> # Image tag (e.g. myapp:latest)
│ ├── setup # Detect env, sync files, and run install steps
│ │ --dir <path> # Directory to detect environment in (default: .)
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
Expand All @@ -124,6 +118,12 @@ chunk
│ --org-id <id> # Organization ID
│ --json # Output as JSON
├── env # Detect and render project environments
│ └── init # Detect tech stack; write Dockerfile.test (default) or JSON spec
│ --dir <path> # Directory to analyse (default: .)
│ --format <fmt> # Output format: dockerfile (default) or json
│ --no-save # Do not save the detected spec to .chunk/config.json
├── hook # Manage chunk hook execution
│ --project <path> # Override project directory
│ ├── disable # Disable chunk validate hooks
Expand Down
2 changes: 1 addition & 1 deletion docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ The skill handles the full loop: auth checks → find active sidecar → sync
Auto-detect your tech stack and save it to config:

```bash
chunk sidecar env # detect stack, save to config
chunk env init # detect stack, write Dockerfile.test, save to config
```

### Environment variables
Expand Down
10 changes: 5 additions & 5 deletions harness/environment.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
environment: harness for `chunk sidecar env` + the sidecar build acceptance test.
environment: harness for `chunk env init` + the sidecar build acceptance test.
Runs against target repos and uses the Claude Code SDK to improve
internal/envbuilder when tests fail.

Expand Down Expand Up @@ -176,7 +176,7 @@ def _run_test(repo: TargetRepo, cache_dir: Path, timeout: int, extra_env: dict |
}
result = subprocess.run(
["go", "test", "-v", "-count=1", f"-timeout={timeout}s",
"-run", "TestSidecarsBuildEndToEnd", "./acceptance/"],
"-run", "TestEnvInitEndToEnd", "./acceptance/"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
Expand Down Expand Up @@ -277,7 +277,7 @@ def update_envbuilder(
else:
prompt = f"""You are debugging an environment detection tool inside the chunk CLI.

`chunk sidecar env` analyses a repository, detects its tech stack, and writes a
`chunk env init` analyses a repository, detects its tech stack, and writes a
Dockerfile.test for running that repo's tests. It was run on the
{repo.name} repo ({repo.url}).

Expand Down Expand Up @@ -453,7 +453,7 @@ def run_repo(repo: TargetRepo, timeout: int, max_iterations: int, persistent_cac

try:
# Phase 1: env-only — clone + detect env (no docker).
print("Running env detection (clone + sidecar env)...")
print("Running env detection (clone + env init)...")
current_env, env_output = run_env_only_test(repo, cache_dir, timeout)
if current_env is None:
print(f" Env detection failed:\n{env_output}")
Expand Down Expand Up @@ -593,7 +593,7 @@ def main():
known_names = [r["name"] for r in KNOWN_REPOS]

parser = argparse.ArgumentParser(
description="environment: harness for chunk sidecar env/build. "
description="environment: harness for chunk env init. "
"Uses Claude to improve envbuilder on failure.",
)
parser.add_argument(
Expand Down
Loading