From 290b94c92106baf521492a8124494dc80702724b Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 14:21:12 -0700 Subject: [PATCH 1/8] ci(windows): add Windows test workflow; run host-side suite; gate to repo branches/dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow‑up to the Windows support work. We can’t rely on nested virtualization on GitHub’s Windows runners, so we exercise everything that happens on the host (CLI, repo/lock/config paths, stdio plumbing) and send container execution to PARC Runner. What this sets up: - Runs `go test -v -race ./...` on `windows-latest`. - Installs Dagger (Chocolatey) and verifies `dagger version` for tests that require it. - Builds `container-use.exe` and smoke‑tests `version`, `--help`, and `completion powershell`. Safety: the workflow runs only on branches in this repository and via `workflow_dispatch`. Forks do not run it. Signed-off-by: Guillaume de Rouville --- .github/workflows/test-windows.yml | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/test-windows.yml diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml new file mode 100644 index 00000000..024bc93b --- /dev/null +++ b/.github/workflows/test-windows.yml @@ -0,0 +1,52 @@ +name: Test Windows + +on: + push: + branches: ['**'] + workflow_dispatch: + +jobs: + test-windows: + name: Test Windows + runs-on: windows-latest + env: + _EXPERIMENTAL_DAGGER_RUNNER_HOST: ${{ secrets._EXPERIMENTAL_DAGGER_RUNNER_HOST }} + DAGGER_CLOUD_TOKEN: ${{ secrets.DAGGER_CLOUD_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Configure Git + run: | + git config --global user.email "test@example.com" + git config --global user.name "Test User" + + - name: Install Dagger + shell: pwsh + run: | + choco install dagger -y + echo "$Env:ChocolateyInstall\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Verify Dagger installation + shell: pwsh + run: dagger version + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race ./... + + - name: Build + run: go build -v -o container-use.exe ./cmd/container-use + + - name: Smoke test + run: | + .\container-use.exe version + .\container-use.exe --help + .\container-use.exe completion powershell \ No newline at end of file From 81d6f5c8e23f59d4218ac17985d0fa99c74f314a Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 14:28:37 -0700 Subject: [PATCH 2/8] tests(integration): sandbox cache/tmp per process; short/sanitized paths (Windows); fail fast on missing Dagger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows exposed three issues: shared caches caused rename/lock contention, deep/unsanitized temp paths tripped path limits, and "Dagger unavailable" was silently skipped. This commit makes the test process self‑contained and honest. Why: - Shared global cache/tmp across test processes produced Windows rename/lock stalls (esp. during Dagger CLI downloads). - Long paths + slashes from subtest names exceeded Windows limits. - Skipping when Dagger is missing hid real CI problems. How: - `TestMain`: per‑process `XDG_CACHE_HOME` and `TMP*` sandbox. - `createTestTempDir`: short roots (e.g., `C:\Temp`), sanitized test names, and shorter prefixes. - Relax write‑perf threshold on Windows only (environmental difference). - Treat Dagger availability as a hard precondition (fail fast instead of skip). Signed-off-by: Guillaume de Rouville --- environment/integration/helpers.go | 51 ++++++++++++++++++--- environment/integration/integration_test.go | 25 ++++------ environment/integration/testmain_test.go | 49 ++++++++++++++++++++ 3 files changed, 103 insertions(+), 22 deletions(-) create mode 100644 environment/integration/testmain_test.go diff --git a/environment/integration/helpers.go b/environment/integration/helpers.go index a42ed29e..f0fd34e8 100644 --- a/environment/integration/helpers.go +++ b/environment/integration/helpers.go @@ -5,6 +5,8 @@ import ( "log/slog" "os" "path/filepath" + "runtime" + "strings" "sync" "testing" @@ -34,6 +36,46 @@ func init() { }))) } +// createTestTempDir creates a temporary directory with a shorter path on Windows +func createTestTempDir(t *testing.T, prefix string) string { + var dir string + var err error + + // Get test name and replace slashes with underscores to avoid path separator issues + testName := t.Name() + testName = strings.ReplaceAll(testName, "/", "_") + + if runtime.GOOS == "windows" { + // On Windows, use a shorter base path to avoid path length issues + // Generate a unique short name using last 8 chars of test name + if len(testName) > 8 { + testName = testName[len(testName)-8:] + } + // Use C:\Temp or system temp root + volume := filepath.VolumeName(os.TempDir()) + var tempRoot string + if volume != "" { + tempRoot = volume + `\Temp` + } else { + tempRoot = filepath.Join(os.TempDir(), "Temp") + } + if err := os.MkdirAll(tempRoot, 0755); err != nil { + // Fall back to regular temp dir if we can't create C:\Temp + tempRoot = os.TempDir() + } + dir, err = os.MkdirTemp(tempRoot, prefix+testName+"-*") + } else { + // On other platforms, use the regular temp directory + dir, err = os.MkdirTemp("", prefix+testName+"-*") + } + + require.NoError(t, err, "Failed to create temp dir") + t.Cleanup(func() { + os.RemoveAll(dir) + }) + return dir +} + // WithRepository runs a test function with an isolated repository and UserActions func WithRepository(t *testing.T, name string, setup RepositorySetup, fn func(t *testing.T, repo *repository.Repository, user *UserActions)) { // Initialize Dagger (needed for environment operations) @@ -42,11 +84,8 @@ func WithRepository(t *testing.T, name string, setup RepositorySetup, fn func(t ctx := context.Background() // Create isolated temp directories - repoDir, err := os.MkdirTemp("", "cu-test-"+name+"-*") - require.NoError(t, err, "Failed to create repo dir") - - configDir, err := os.MkdirTemp("", "cu-test-config-"+name+"-*") - require.NoError(t, err, "Failed to create config dir") + repoDir := createTestTempDir(t, "cu-") + configDir := createTestTempDir(t, "cuc-") // Initialize git repo cmds := [][]string{ @@ -169,7 +208,7 @@ func initializeDaggerOnce(t *testing.T) { }) if daggerErr != nil { - t.Skipf("Skipping test - Dagger not available: %v", daggerErr) + t.Fatalf("Dagger not available: %v", daggerErr) } } diff --git a/environment/integration/integration_test.go b/environment/integration/integration_test.go index 7e06def6..01057bc7 100644 --- a/environment/integration/integration_test.go +++ b/environment/integration/integration_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -202,14 +203,17 @@ func TestLargeProjectPerformance(t *testing.T) { WithRepository(t, "large_project_performance", largeProjectSetup, func(t *testing.T, repo *repository.Repository, user *UserActions) { env := user.CreateEnvironment("Performance Test", "Testing performance with large project") - // Time file operations start := time.Now() user.FileWrite(env.ID, "new.txt", "test", "Test write performance") writeTime := time.Since(start) t.Logf("File write took: %v", writeTime) - assert.LessOrEqual(t, writeTime, 2*time.Second, "File write should be fast") + threshold := 2 * time.Second + if runtime.GOOS == "windows" { + threshold = 5 * time.Second + } + assert.LessOrEqual(t, writeTime, threshold, "File write should be fast") }) }) } @@ -338,13 +342,8 @@ func TestWeirdUserScenarios(t *testing.T) { ctx := context.Background() // Create first repository - repoDir1, err := os.MkdirTemp("", "cu-test-repo1-*") - require.NoError(t, err) - defer os.RemoveAll(repoDir1) - - configDir1, err := os.MkdirTemp("", "cu-test-config1-*") - require.NoError(t, err) - defer os.RemoveAll(configDir1) + repoDir1 := createTestTempDir(t, "cu1-") + configDir1 := createTestTempDir(t, "cuc1-") // Initialize git repo1 cmds := [][]string{ @@ -360,13 +359,7 @@ func TestWeirdUserScenarios(t *testing.T) { SetupNodeRepo(t, repoDir1) // Create second repository - repoDir2, err := os.MkdirTemp("", "cu-test-repo2-*") - require.NoError(t, err) - defer os.RemoveAll(repoDir2) - - configDir2, err := os.MkdirTemp("", "cu-test-config2-*") - require.NoError(t, err) - defer os.RemoveAll(configDir2) + repoDir2 := createTestTempDir(t, "cu2-") // Initialize git repo2 for _, cmd := range cmds { diff --git a/environment/integration/testmain_test.go b/environment/integration/testmain_test.go new file mode 100644 index 00000000..8ea4e68c --- /dev/null +++ b/environment/integration/testmain_test.go @@ -0,0 +1,49 @@ +package integration + +import ( + "log/slog" + "os" + "path/filepath" + "runtime" + "testing" +) + +// TestMain isolates cache/temp per test process to avoid Dagger cache races on Windows. +func TestMain(m *testing.M) { + // Private sandbox (per process) + root, err := os.MkdirTemp("", "cu-test-") + if err != nil { + panic(err) + } + cacheHome := filepath.Join(root, "cache") + tmpHome := filepath.Join(root, "tmp") + if err := os.MkdirAll(cacheHome, 0o755); err != nil { + panic(err) + } + if err := os.MkdirAll(tmpHome, 0o755); err != nil { + panic(err) + } + + // Dagger respects XDG; temp isolation also helps with path length/locks on Windows. + _ = os.Setenv("XDG_CACHE_HOME", cacheHome) + if runtime.GOOS == "windows" { + _ = os.Setenv("TEMP", tmpHome) + _ = os.Setenv("TMP", tmpHome) + } else { + _ = os.Setenv("TMPDIR", tmpHome) + } + + if os.Getenv("TEST_VERBOSE") != "" { + slog.Info("using isolated test cache", "XDG_CACHE_HOME", cacheHome, "tmp", tmpHome) + } + + code := m.Run() + + // Release file handles before cleanup. + if testDaggerClient != nil { + _ = testDaggerClient.Close() + } + _ = os.RemoveAll(root) + + os.Exit(code) +} From e6c7ce427086243be5b34f38aa42e0d6757219ba Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 14:37:18 -0700 Subject: [PATCH 3/8] tests(cmd/container-use): sandbox each MCP server; bound stdio Initialize (120s); fix Windows build/run details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stdio tests would hang up to 10 minutes on Windows during Initialize. Root cause: multiple servers sharing a cache/tmp plus Windows file locks during Dagger CLI downloads. Fixes: - Give every MCP server its own cache/tmp sandbox (on top of per‑process isolation). - Bound stdio RPCs with a 120s context to fail fast instead of hanging. - Clean up sandboxes via `t.Cleanup`. - Build helper emits `container-use.exe` on Windows and resolves absolute paths. Result: no hangs, no shared‑cache contention, and deterministic teardown. Signed-off-by: Guillaume de Rouville --- cmd/container-use/main_suite_test.go | 9 ++- cmd/container-use/main_test.go | 60 ++++++++++++++++++ cmd/container-use/stdio_test.go | 95 ++++++++++++++++++++-------- 3 files changed, 136 insertions(+), 28 deletions(-) create mode 100644 cmd/container-use/main_test.go diff --git a/cmd/container-use/main_suite_test.go b/cmd/container-use/main_suite_test.go index d62927bb..67a42295 100644 --- a/cmd/container-use/main_suite_test.go +++ b/cmd/container-use/main_suite_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sync" "testing" @@ -20,7 +21,11 @@ var ( func getContainerUseBinary(t *testing.T) string { binaryPathOnce.Do(func() { t.Log("Building fresh container-use binary...") - cmd := exec.Command("go", "build", "-o", "container-use", ".") + binaryName := "container-use" + if runtime.GOOS == "windows" { + binaryName = "container-use.exe" + } + cmd := exec.Command("go", "build", "-o", binaryName, ".") cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout err := cmd.Run() @@ -28,7 +33,7 @@ func getContainerUseBinary(t *testing.T) string { t.Fatalf("Failed to build container-use binary: %v", err) } - abs, err := filepath.Abs("container-use") + abs, err := filepath.Abs(binaryName) if err != nil { t.Fatalf("Failed to get absolute path: %v", err) } diff --git a/cmd/container-use/main_test.go b/cmd/container-use/main_test.go new file mode 100644 index 00000000..25b72376 --- /dev/null +++ b/cmd/container-use/main_test.go @@ -0,0 +1,60 @@ +package main_test + +import ( + "log/slog" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestMain(m *testing.M) { + // Create a short root; on Windows prefer C:\Temp + root := shortRoot("cu-stdio") + cache := filepath.Join(root, "cache") + tmp := filepath.Join(root, "tmp") + app := filepath.Join(root, "appdata") + lapp := filepath.Join(root, "localappdata") + _ = os.MkdirAll(cache, 0o755) + _ = os.MkdirAll(tmp, 0o755) + _ = os.MkdirAll(app, 0o755) + _ = os.MkdirAll(lapp, 0o755) + + // Shared app/data home for all child servers + _ = os.Setenv("XDG_CACHE_HOME", cache) + if runtime.GOOS == "windows" { + _ = os.Setenv("TEMP", tmp) + _ = os.Setenv("TMP", tmp) + _ = os.Setenv("APPDATA", app) + _ = os.Setenv("LOCALAPPDATA", lapp) + } else { + _ = os.Setenv("TMPDIR", tmp) + } + + // Make Git allow long paths without touching system/global config + gitcfg := filepath.Join(root, "gitconfig") + _ = os.WriteFile(gitcfg, []byte("[core]\nlongpaths = true\n"), 0o644) + _ = os.Setenv("GIT_CONFIG_GLOBAL", gitcfg) + + if os.Getenv("TEST_VERBOSE") != "" { + slog.Info("stdio test cache configured", + "XDG_CACHE_HOME", cache, + "TEMP/TMP/TMPDIR", tmp, + "APPDATA", app) + } + + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} + +func shortRoot(prefix string) string { + if runtime.GOOS != "windows" { + dir, _ := os.MkdirTemp("", prefix+"-") + return dir + } + base := `C:\Temp` + _ = os.MkdirAll(base, 0o755) + dir, _ := os.MkdirTemp(base, prefix+"-") + return dir +} diff --git a/cmd/container-use/stdio_test.go b/cmd/container-use/stdio_test.go index e3fa9f6b..c28cf867 100644 --- a/cmd/container-use/stdio_test.go +++ b/cmd/container-use/stdio_test.go @@ -6,9 +6,12 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "runtime" "strings" "sync" "testing" + "time" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" @@ -28,8 +31,9 @@ type MCPServerProcess struct { // NewMCPServerProcess starts a new container-use MCP server process func NewMCPServerProcess(t *testing.T, testName string) *MCPServerProcess { - ctx := context.Background() + t.Helper() + testName = strings.ReplaceAll(testName, "/", "_") repoDir, err := os.MkdirTemp("", fmt.Sprintf("cu-e2e-%s-repo-*", testName)) require.NoError(t, err, "Failed to create repo dir") @@ -39,12 +43,18 @@ func NewMCPServerProcess(t *testing.T, testName string) *MCPServerProcess { setupGitRepo(t, repoDir) containerUseBinary := getContainerUseBinary(t) - cmd := exec.CommandContext(ctx, containerUseBinary, "stdio") - cmd.Dir = repoDir - cmd.Env = append(os.Environ(), fmt.Sprintf("CONTAINER_USE_CONFIG_DIR=%s", configDir)) - mcpClient, err := client.NewStdioMCPClient(containerUseBinary, cmd.Env, "stdio") + baseEnv := append(os.Environ(), "CONTAINER_USE_CONFIG_DIR="+configDir) + env, srvRoot := serverSandboxEnv(baseEnv) + t.Cleanup(func() { + _ = os.RemoveAll(srvRoot) + }) + + // Spawn the MCP server (stdio transport) with the sandboxed env. + mcpClient, err := client.NewStdioMCPClient(containerUseBinary, env, "stdio") require.NoError(t, err, "Failed to create MCP client") + + // Initialize with a bounded RPC context so failures don’t hang the suite. initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ @@ -53,11 +63,15 @@ func NewMCPServerProcess(t *testing.T, testName string) *MCPServerProcess { } initRequest.Params.Capabilities = mcp.ClientCapabilities{} - serverInfo, err := mcpClient.Initialize(ctx, initRequest) + ctxInit, cancel := ctxRPC() + defer cancel() + + serverInfo, err := mcpClient.Initialize(ctxInit, initRequest) require.NoError(t, err, "Failed to initialize MCP client") server := &MCPServerProcess{ - cmd: cmd, + // cmd is nil because the MCP client manages the child process itself. + cmd: nil, client: mcpClient, repoDir: repoDir, configDir: configDir, @@ -68,10 +82,36 @@ func NewMCPServerProcess(t *testing.T, testName string) *MCPServerProcess { t.Cleanup(func() { server.Close() }) - return server } +func serverSandboxEnv(baseEnv []string) ([]string, string) { + root, err := os.MkdirTemp("", "cu-stdio-srv-") + if err != nil { + panic(err) + } + cache := filepath.Join(root, "cache") + tmp := filepath.Join(root, "tmp") + _ = os.MkdirAll(cache, 0o755) + _ = os.MkdirAll(tmp, 0o755) + + env := append([]string{}, baseEnv...) + env = append(env, "XDG_CACHE_HOME="+cache) + if runtime.GOOS == "windows" { + env = append(env, "TEMP="+tmp, "TMP="+tmp) + } else { + env = append(env, "TMPDIR="+tmp) + } + return env, root +} + +// Make RPCs fail fast instead of hanging for 10m. +const rpcTimeout = 120 * time.Second + +func ctxRPC() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), rpcTimeout) +} + // Close shuts down the MCP server process and cleans up resources func (s *MCPServerProcess) Close() { if s.client != nil { @@ -200,23 +240,22 @@ func (s *MCPServerProcess) RunCommand(envID, command, explanation string) (strin // createMCPServerForRepositoryTest creates an MCP server process for repository contention testing func createMCPServerForRepositoryTest(t *testing.T, i int, repoDir, configDir string, singleTenant bool) *MCPServerProcess { - ctx := context.Background() + t.Helper() + containerUseBinary := getContainerUseBinary(t) - var cmd *exec.Cmd - var clientArgs []string + clientArgs := []string{"stdio"} if singleTenant { - cmd = exec.CommandContext(ctx, containerUseBinary, "stdio", "--single-tenant") - clientArgs = []string{"stdio", "--single-tenant"} - } else { - cmd = exec.CommandContext(ctx, containerUseBinary, "stdio") - clientArgs = []string{"stdio"} + clientArgs = append(clientArgs, "--single-tenant") } - cmd.Dir = repoDir - cmd.Env = append(os.Environ(), fmt.Sprintf("CONTAINER_USE_CONFIG_DIR=%s", configDir)) + baseEnv := append(os.Environ(), "CONTAINER_USE_CONFIG_DIR="+configDir) + env, srvRoot := serverSandboxEnv(baseEnv) + t.Cleanup(func() { + _ = os.RemoveAll(srvRoot) + }) - mcpClient, err := client.NewStdioMCPClient(containerUseBinary, cmd.Env, clientArgs...) + mcpClient, err := client.NewStdioMCPClient(containerUseBinary, env, clientArgs...) require.NoError(t, err) initRequest := mcp.InitializeRequest{} @@ -227,18 +266,20 @@ func createMCPServerForRepositoryTest(t *testing.T, i int, repoDir, configDir st } initRequest.Params.Capabilities = mcp.ClientCapabilities{} - serverInfo, err := mcpClient.Initialize(ctx, initRequest) + ctxInit, cancel := ctxRPC() + defer cancel() + + serverInfo, err := mcpClient.Initialize(ctxInit, initRequest) require.NoError(t, err) server := &MCPServerProcess{ - cmd: cmd, + cmd: nil, client: mcpClient, repoDir: repoDir, configDir: configDir, serverInfo: serverInfo, t: t, } - return server } @@ -344,13 +385,14 @@ func TestRepositoryContention(t *testing.T) { } const numServers = 10 - sharedRepoDir, err := os.MkdirTemp("", "cu-e2e-shared-repo-*") + testName := strings.ReplaceAll(t.Name(), "/", "_") + sharedRepoDir, err := os.MkdirTemp("", fmt.Sprintf("cu-e2e-%s-repo-*", testName)) require.NoError(t, err) defer os.RemoveAll(sharedRepoDir) setupGitRepo(t, sharedRepoDir) - sharedConfigDir, err := os.MkdirTemp("", "cu-e2e-shared-config-*") + sharedConfigDir, err := os.MkdirTemp("", fmt.Sprintf("cu-e2e-%s-config-*", testName)) require.NoError(t, err) defer os.RemoveAll(sharedConfigDir) @@ -372,13 +414,14 @@ func TestSingleTenantRepositoryContention(t *testing.T) { } const numServers = 10 - sharedRepoDir, err := os.MkdirTemp("", "cu-e2e-single-tenant-repo-*") + testName := strings.ReplaceAll(t.Name(), "/", "_") + sharedRepoDir, err := os.MkdirTemp("", fmt.Sprintf("cu-e2e-%s-repo-*", testName)) require.NoError(t, err) defer os.RemoveAll(sharedRepoDir) setupGitRepo(t, sharedRepoDir) - sharedConfigDir, err := os.MkdirTemp("", "cu-e2e-single-tenant-config-*") + sharedConfigDir, err := os.MkdirTemp("", fmt.Sprintf("cu-e2e-%s-config-*", testName)) require.NoError(t, err) defer os.RemoveAll(sharedConfigDir) From 1c95933112b8a19a3260c5636af95bff34868866 Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 15:02:37 -0700 Subject: [PATCH 4/8] fix(repository/locks): anchor flock files under config dir for real cross-process exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After isolating `TMP` per process, our lock files in `os.TempDir()` no longer coordinated across processes. Each process had its own lock root, so git remote edits raced. Change: - Put locks under the container‑use config dir: * Prefer `CONTAINER_USE_CONFIG_DIR`, else the OS default. * Path: /locks/container-use--.lock - Create the dir (0755) and log failures. This restores true cross‑process exclusion without introducing global machine state. Signed-off-by: Guillaume de Rouville --- repository/flock.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/repository/flock.go b/repository/flock.go index acb10408..1a8de2b1 100644 --- a/repository/flock.go +++ b/repository/flock.go @@ -57,19 +57,19 @@ func (rlm *RepositoryLockManager) GetLock(lockType LockType) *RepositoryLock { return lock } + lockRoot := os.Getenv("CONTAINER_USE_CONFIG_DIR") + if lockRoot == "" { + lockRoot = getDefaultConfigPath() + } lockFileName := fmt.Sprintf("container-use-%x-%s.lock", hashString(rlm.repoPath), string(lockType)) - lockDir := filepath.Join(os.TempDir(), "container-use-locks") + lockDir := filepath.Join(lockRoot, "locks") lockFile := filepath.Join(lockDir, lockFileName) - err := os.MkdirAll(lockDir, 0755) - if err != nil { - slog.Error("Failed to create lock directory", "error", err) - } - - lock := &RepositoryLock{ - flock: flock.New(lockFile), + if err := os.MkdirAll(lockDir, 0o755); err != nil { + slog.Error("Failed to create lock directory", "path", lockDir, "error", err) } + lock := &RepositoryLock{flock: flock.New(lockFile)} rlm.locks[lockType] = lock return lock } From 493773af468fc76cc229bf5f81d3ab4a64837db9 Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 15:03:56 -0700 Subject: [PATCH 5/8] fix(repository): set repo-local git identity in fork (bare) repo; cover initial + worktree commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows runners often lack a global git identity. Our tests set identity in the source repo, but commits happen in the fork (bare) repo/worktrees, so worktree commits failed with “Author identity unknown”. Approach: - During `ensureFork` (under `LockTypeForkRepo`), ensure the fork repo has `user.name` and `user.email`. Copy from source if present; otherwise set a local fallback (`container-use` / `container-use@local`). Scope: - Repo‑local only (no global config). Covers the initial empty commit and all subsequent worktree commits. Tests pin both behaviors (copy vs. fallback). Signed-off-by: Guillaume de Rouville --- repository/git.go | 46 +++++++++++++++++++++++++++++++++- repository/git_test.go | 53 ++++++++++++++++++++++++++++++++++++++++ repository/repository.go | 11 ++++++--- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/repository/git.go b/repository/git.go index 37f6d700..d69cff7d 100644 --- a/repository/git.go +++ b/repository/git.go @@ -78,8 +78,9 @@ func getContainerUseRemote(ctx context.Context, repo string) (string, error) { } return "", err } + cuRemote = strings.TrimSpace(cuRemote) - return strings.TrimSpace(cuRemote), nil + return cuRemote, nil } func (r *Repository) WorktreePath(id string) (string, error) { @@ -472,6 +473,49 @@ func (r *Repository) addNonBinaryFiles(ctx context.Context, worktreePath string) return nil } +// fork worktrees may run on machines without a global identity (Windows CI). +// Setting it up at the bare repo ensures consistent authorship for all worktrees +func (r *Repository) ensureForkIdentity(ctx context.Context) error { + has := func(key string) bool { + v, err := RunGitCommand(ctx, r.forkRepoPath, "config", "--get", key) + return err == nil && strings.TrimSpace(v) != "" + } + + needName := !has("user.name") + needEmail := !has("user.email") + if !needName && !needEmail { + return nil + } + + // Prefer identity from the user's source repo, else fallback. + name := "" + email := "" + if v, err := RunGitCommand(ctx, r.userRepoPath, "config", "--get", "user.name"); err == nil { + name = strings.TrimSpace(v) + } + if v, err := RunGitCommand(ctx, r.userRepoPath, "config", "--get", "user.email"); err == nil { + email = strings.TrimSpace(v) + } + if name == "" { + name = "container-use" + } + if email == "" { + email = "container-use@local" + } + + if needName { + if _, err := RunGitCommand(ctx, r.forkRepoPath, "config", "user.name", name); err != nil { + return err + } + } + if needEmail { + if _, err := RunGitCommand(ctx, r.forkRepoPath, "config", "user.email", email); err != nil { + return err + } + } + return nil +} + func (r *Repository) shouldSkipFile(fileName string) bool { skipExtensions := []string{ ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", diff --git a/repository/git_test.go b/repository/git_test.go index 45acec79..39f98b74 100644 --- a/repository/git_test.go +++ b/repository/git_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" @@ -172,6 +173,58 @@ func TestCommitWorktreeChanges(t *testing.T) { }) } +func TestEnsureForkSetsIdentityWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", t.TempDir()) + } + + ctx := context.Background() + src := t.TempDir() + base := t.TempDir() + + _, err := RunGitCommand(ctx, src, "init") + require.NoError(t, err) + + r, err := OpenWithBasePath(ctx, src, base) + require.NoError(t, err) + + name, err := RunGitCommand(ctx, r.forkRepoPath, "config", "--get", "user.name") + require.NoError(t, err) + email, err := RunGitCommand(ctx, r.forkRepoPath, "config", "--get", "user.email") + require.NoError(t, err) + + assert.NotEmpty(t, strings.TrimSpace(name)) + assert.NotEmpty(t, strings.TrimSpace(email)) +} + +func TestEnsureForkCopiesIdentityFromSourceRepo(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", t.TempDir()) + } + + ctx := context.Background() + src := t.TempDir() + base := t.TempDir() + + _, err := RunGitCommand(ctx, src, "init") + require.NoError(t, err) + _, err = RunGitCommand(ctx, src, "config", "user.name", "Source User") + require.NoError(t, err) + _, err = RunGitCommand(ctx, src, "config", "user.email", "source@example.com") + require.NoError(t, err) + + r, err := OpenWithBasePath(ctx, src, base) + require.NoError(t, err) + + name, _ := RunGitCommand(ctx, r.forkRepoPath, "config", "--get", "user.name") + email, _ := RunGitCommand(ctx, r.forkRepoPath, "config", "--get", "user.email") + + assert.Equal(t, "Source User", strings.TrimSpace(name)) + assert.Equal(t, "source@example.com", strings.TrimSpace(email)) +} + // Test helper functions func writeFile(t *testing.T, dir, name, content string) { t.Helper() diff --git a/repository/repository.go b/repository/repository.go index 24f2afa6..fcf7a63d 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -141,7 +141,8 @@ func (r *Repository) ensureFork(ctx context.Context) error { os.RemoveAll(r.forkRepoPath) return err } - return nil + + return r.ensureForkIdentity(ctx) }) } @@ -152,12 +153,16 @@ func (r *Repository) ensureUserRemote(ctx context.Context) error { if !errors.Is(err, os.ErrNotExist) { return err } - _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "add", containerUseRemote, r.forkRepoPath) + // Convert Windows paths to file:// URLs for git remotes + remotePath := r.forkRepoPath + _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "add", containerUseRemote, remotePath) return err } if currentForkPath != r.forkRepoPath { - _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "set-url", containerUseRemote, r.forkRepoPath) + // Convert Windows paths to file:// URLs for git remotes + remotePath := r.forkRepoPath + _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "set-url", containerUseRemote, remotePath) return err } From 067b7a10c4dd7a5b5a54ce042a41614e1a3372a2 Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 15:07:20 -0700 Subject: [PATCH 6/8] build/release(windows): publish .zip; add Chocolatey package; pass creds through pipeline Windows users expect .zip artifacts and often install via Chocolatey. Changes: - GoReleaser: publish Windows artifacts as `.zip`. - Chocolatey: add package metadata and enable publish via `CHOCOLATEY_API_KEY`. - Dagger pipeline + release workflow: forward GitHub + Chocolatey creds. - Fix recipe to reference the archive ID (not the build ID). This aligns distribution with Windows norms and makes the package pipeline turnkey once credentials are present. Signed-off-by: Guillaume de Rouville --- .dagger/main.go | 15 +++++++++++---- .github/workflows/release.yml | 3 ++- .goreleaser.yaml | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.dagger/main.go b/.dagger/main.go index 8fb464f2..4fb39708 100644 --- a/.dagger/main.go +++ b/.dagger/main.go @@ -52,6 +52,9 @@ func (m *ContainerUse) Release(ctx context.Context, // GitHub org name for package publishing, set only if testing release process on a personal fork //+default="dagger" githubOrgName string, + // Chocolatey API key for Windows package publishing + //+optional + chocolateyApiKey *dagger.Secret, ) (string, error) { // Create custom container with nix package for nix-hash binary customContainer := dag.Container(). @@ -59,13 +62,17 @@ func (m *ContainerUse) Release(ctx context.Context, WithExec([]string{"apk", "add", "nix"}) // Use custom container with Goreleaser - return dag.Goreleaser(m.Source, dagger.GoreleaserOpts{ + gr := dag.Goreleaser(m.Source, dagger.GoreleaserOpts{ Container: customContainer, }). WithSecretVariable("GITHUB_TOKEN", githubToken). - WithEnvVariable("GH_ORG_NAME", githubOrgName). - Release(). - Run(ctx) + WithEnvVariable("GH_ORG_NAME", githubOrgName) + + if chocolateyApiKey != nil { + gr = gr.WithSecretVariable("CHOCOLATEY_API_KEY", chocolateyApiKey) + } + + return gr.Release().Run(ctx) } // Test runs the test suite diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea479003..69eab10b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ jobs: with: version: "latest" verb: call - args: release --version "${GITHUB_REF#refs/tags/}" --github-token env:RELEASE_GITHUB_TOKEN --github-org-name=${{ github.repository_owner }} + args: release --version "${GITHUB_REF#refs/tags/}" --github-token env:RELEASE_GITHUB_TOKEN --github-org-name=${{ github.repository_owner }} --chocolatey-api-key env:CHOCOLATEY_API_KEY env: RELEASE_GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} + CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b60de259..d3b54e66 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -34,6 +34,9 @@ archives: ids: - container-use name_template: "{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] files: - README.md - LICENSE @@ -131,6 +134,37 @@ nix: --fish <($out/bin/cu completion fish) \ --zsh <($out/bin/cu completion zsh) +# chocolateys: +# - name: container-use +# ids: +# - container-use-archive +# owners: Dagger +# title: Container Use +# authors: Dagger Team +# project_url: https://github.com/dagger/container-use +# url_template: "https://github.com/dagger/container-use/releases/download/{{ .Tag }}/{{ .ArtifactName }}" +# icon_url: https://raw.githubusercontent.com/dagger/container-use/main/docs/images/dagger-icon.png +# copyright: 2025 Dagger, Inc. +# license_url: https://github.com/dagger/container-use/blob/main/LICENSE +# require_license_acceptance: false +# project_source_url: https://github.com/dagger/container-use +# docs_url: https://container-use.com/ +# bug_tracker_url: https://github.com/dagger/container-use/issues +# tags: "container docker mcp agent development cli devtools" +# summary: Containerized environments for coding agents +# description: | +# Container Use provides isolated, containerized environments for AI coding agents using Docker and Git. + +# Features: +# - Isolated environments for AI agents to work safely +# - Git-based version control for all changes +# - Docker containers for consistent development environments +# - Model Context Protocol (MCP) integration +# - Support for multiple popular AI coding assistants +# release_notes: "https://github.com/dagger/container-use/releases/tag/{{ .Tag }}" +# api_key: "{{ .Env.CHOCOLATEY_API_KEY }}" +# skip_publish: auto + checksum: name_template: "checksums.txt" From f9ceb52e272d707fd3dde9944809a5e44a60e93c Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 15:09:00 -0700 Subject: [PATCH 7/8] feat(windows/installer): add hardened PowerShell installer; update docs; relax runtime regex for CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We ship a first‑party Windows installer that’s safe on PS 5.1 and PS 7+ and robust on GitHub runners. Installer: - Validates parameters; forces TLS 1.2; uses `UseBasicParsing` only on PS < 6. - Verifies archive SHA256 against `checksums.txt`. - Installs to a configurable dir, creates `cu.exe`, and updates PATH (User) without duplicates. - Supports `-Repo` for fork testing. Docs: - Replace the multi‑step PowerShell instructions with a one‑liner that works on PS 5.1 and PS 7+. Tests: - Allow “unknown” container runtime version in CI where the runner doesn’t report a version string. Signed-off-by: Guillaume de Rouville --- cmd/container-use/version_test.go | 2 +- docs/quickstart.mdx | 23 ++- install.ps1 | 307 ++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 install.ps1 diff --git a/cmd/container-use/version_test.go b/cmd/container-use/version_test.go index ac80f608..eb8a9bb4 100644 --- a/cmd/container-use/version_test.go +++ b/cmd/container-use/version_test.go @@ -46,7 +46,7 @@ func TestVersionCommand(t *testing.T) { // Container runtime output should show one of the supported runtimes // This handles: "Docker 24.0.5", "Podman 4.3.1", "Docker 24.0.5 (daemon not running)", or "not found" - assert.Regexp(t, `Container Runtime: ((Docker|Podman|nerdctl|finch) [\d\.]+(v[\d\.]+)?(\s+\(daemon not running\))?|not found)`, output) + assert.Regexp(t, `Container Runtime: ((Docker|Podman|nerdctl|finch) ([\d\.]+(v[\d\.]+)?|unknown)(\s+\(daemon not running\))?|not found)`, output) }, }, { diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 18c48e45..e0a284d1 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -20,7 +20,28 @@ Make sure you have [Docker](https://www.docker.com/get-started) and Git installe - + + + Native Windows support requires Docker Desktop + + + + ```powershell + $u='https://raw.githubusercontent.com/dagger/container-use/main/install.ps1';$d=Join-Path $env:TEMP 'install-container-use.ps1';$o=@{};if((Get-Command Invoke-WebRequest).Parameters.ContainsKey('UseBasicParsing')){$o.UseBasicParsing=$true};Invoke-WebRequest -Uri $u -OutFile $d @o;& $d -AddToPath + ``` + + + + ```powershell + # Available after first release with Windows support + choco install container-use + ``` + + + + + + ```sh curl -fsSL https://raw.githubusercontent.com/dagger/container-use/main/install.sh | bash diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..ec5bc24f --- /dev/null +++ b/install.ps1 @@ -0,0 +1,307 @@ +#Requires -Version 5.0 +<# +.Description + Download and install container-use. + +.PARAMETER Version + Version of container-use to install (e.g., v0.4.0). Defaults to latest. + Also supports a 7-40 char git commit or "latest". + +.PARAMETER DownloadPath + Temporary download location seed. The script will place artifacts in the same directory + as this temp file. Defaults to a system temp file. + +.PARAMETER InstallPath + Installation directory. Defaults to $env:USERPROFILE\container-use + +.PARAMETER AddToPath + If set, add the installation directory to the user's PATH (no elevation required). + +.PARAMETER Repo + (Advanced) GitHub "owner/repo" to install from. Defaults to dagger/container-use. + Useful for testing a fork without editing the script. + +.EXAMPLE + .\install.ps1 + Install latest version with default settings. + +.EXAMPLE + .\install.ps1 -InstallPath "C:\tools\container-use" + Install to C:\tools\container-use. + +.EXAMPLE + .\install.ps1 -Version v0.4.0 + Install specified version v0.4.0. + +.EXAMPLE + .\install.ps1 -AddToPath + Install and add to PATH. + +.EXAMPLE + # Install from a fork's releases for testing + .\install.ps1 -Repo "grouville/container-use" -Version v0.3.5-test -AddToPath +#> + +Param ( + [Parameter(Mandatory = $false)] + [ValidatePattern('^(latest|v?\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?|[a-f0-9]{7,40})$')] + [string]$Version = "latest", + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$DownloadPath = [System.IO.Path]::GetTempFileName(), + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$InstallPath = "$env:USERPROFILE\container-use", + + [Parameter(Mandatory = $false)] + [switch]$AddToPath = $false, + + [Parameter(Mandatory = $false)] + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$Repo = "dagger/container-use" +) + +# --------------------------------------------------------------------------------- +# Container Use Installation Utility for Windows +# Hardened for PS 5.1 and PS 7+; secure downloads; robust PATH handling +# --------------------------------------------------------------------------------- + +$ErrorActionPreference = "Stop" + +# Ensure TLS 1.2 (older Windows can default to TLS 1.0/1.1) +try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } + +# Configuration +$REPO = $Repo +$BINARY_NAME = "container-use" + +# Conditionally supply -UseBasicParsing (PS < 6 supports it; PS 7+ removed it) +function New-WebArgs { + param([Parameter(Mandatory=$true)] [string]$Uri) + $args = @{ Uri = $Uri } + if ($PSVersionTable.PSVersion.Major -lt 6) { $args.UseBasicParsing = $true } + return $args +} + +# Safely append to PATH (User by default), avoiding dupes and empty tails +function Add-PathEntry { + param( + [Parameter(Mandatory=$true)] [string]$PathToAdd, + [Parameter(Mandatory=$false)] [ValidateSet('User','Machine')] [string]$Scope = 'User' + ) + $cur = [Environment]::GetEnvironmentVariable('Path', $Scope) + if (-not $cur) { $cur = '' } + $parts = @($cur -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + @() + + if ($parts -notcontains $PathToAdd) { + $newPath = ($parts + $PathToAdd) -join ';' + [Environment]::SetEnvironmentVariable('Path', $newPath, $Scope) + Write-Host "Added to $Scope PATH. Restart your terminal to pick it up." -ForegroundColor Green + } else { + Write-Host "Path already contains $PathToAdd" -ForegroundColor Green + } +} + +function Get-ProcessorArchitecture { + # Map to Go arch names we ship: amd64, arm64 + $arch = $env:PROCESSOR_ARCHITECTURE + switch ($arch) { + 'AMD64' { return 'amd64' } + 'ARM64' { return 'arm64' } + default { throw "Unsupported architecture: $arch (supported: AMD64, ARM64)" } + } +} + +function Find-LatestVersion { + try { + $release = Invoke-RestMethod @((New-WebArgs "https://api.github.com/repos/$REPO/releases/latest")) -UserAgent "PowerShell" + return $release.tag_name + } catch { + throw "Failed to fetch latest release: $_" + } +} + +function Get-DownloadUrl { + Param ( + [Parameter(Mandatory = $true)] [string]$Version, + [Parameter(Mandatory = $true)] [string]$Arch + ) + # GoReleaser uses the tag (with 'v') in archive names + "https://github.com/$REPO/releases/download/$Version/container-use_${Version}_windows_${Arch}.zip" +} + +function Get-ChecksumUrl { + Param ([Parameter(Mandatory = $true)] [string]$Version) + "https://github.com/$REPO/releases/download/$Version/checksums.txt" +} + +function Get-Checksum { + Param ( + [Parameter(Mandatory = $true)] [string]$Version, + [Parameter(Mandatory = $true)] [string]$Arch + ) + $checksumUrl = Get-ChecksumUrl -Version $Version + $target = "container-use_${Version}_windows_${Arch}.zip" + + try { + $response = Invoke-RestMethod @((New-WebArgs $checksumUrl)) -UserAgent "PowerShell" + $checksums = $response -split "`n" + + foreach ($line in $checksums) { + if ($line -match [regex]::Escape($target)) { + return ($line -split ' ' | Select-Object -First 1) + } + } + throw "Checksum not found for $target" + } catch { + throw "Failed to fetch or parse checksums: $_" + } +} + +function Compare-Checksum { + Param ( + [Parameter(Mandatory = $true)] [string]$FilePath, + [Parameter(Mandatory = $true)] [string]$ExpectedChecksum + ) + $hash = (Get-FileHash -Path $FilePath -Algorithm SHA256).Hash + if ($hash.ToUpperInvariant() -ne $ExpectedChecksum.ToUpperInvariant()) { + Remove-Item -Path $FilePath -Force + throw "Checksum mismatch. Expected: $ExpectedChecksum, Got: $hash" + } +} + +function Get-InstallPath { + if (-not (Test-Path $InstallPath)) { + New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null + } + return (Get-Item -Path $InstallPath).FullName +} + +function Test-Dependencies { + Write-Host "Checking dependencies..." -ForegroundColor Blue + + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Host " Docker is required but not installed." -ForegroundColor Red + Write-Host " Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/" -ForegroundColor Yellow + return $false + } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Host " Git is required but not installed." -ForegroundColor Red + Write-Host " Install Git: https://git-scm.com/download/win" -ForegroundColor Yellow + return $false + } + + try { docker version *>$null } catch { Write-Host " Docker is installed but not responding." -ForegroundColor Red; return $false } + try { git version *>$null } catch { Write-Host " Git is installed but not responding." -ForegroundColor Red; return $false } + + Write-Host " Docker is installed" -ForegroundColor Green + Write-Host " Git is installed" -ForegroundColor Green + return $true +} + +function Install-ContainerUse { + Write-Host "" + Write-Host "Container Use Installer for Windows" -ForegroundColor Cyan + Write-Host "===================================" -ForegroundColor Cyan + Write-Host "" + + if (-not (Test-Dependencies)) { + throw "Missing required dependencies" + } + + $targetVersion = $Version + if ($targetVersion -eq "latest") { + Write-Host "Finding latest version..." -ForegroundColor Blue + $targetVersion = Find-LatestVersion + Write-Host "Latest version: $targetVersion" -ForegroundColor Green + } + + $arch = Get-ProcessorArchitecture + Write-Host "Architecture: $arch" -ForegroundColor Blue + + $downloadUrl = Get-DownloadUrl -Version $targetVersion -Arch $arch + + $zipName = "container-use_${targetVersion}_windows_${arch}.zip" + $zipPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($DownloadPath), $zipName) + + Write-Host "Downloading from $downloadUrl..." -ForegroundColor Blue + try { + Invoke-WebRequest @((New-WebArgs $downloadUrl)) -OutFile $zipPath + Write-Host "Downloaded successfully" -ForegroundColor Green + } catch { + throw "Failed to download: $_" + } + + Write-Host "Verifying checksum..." -ForegroundColor Blue + try { + $expectedChecksum = Get-Checksum -Version $targetVersion -Arch $arch + Compare-Checksum -FilePath $zipPath -ExpectedChecksum $expectedChecksum + Write-Host "Checksum verified" -ForegroundColor Green + } catch { + throw "Checksum verification failed: $_" + } + + $tempExtractPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "container-use-extract-$(Get-Random)") + Write-Host "Extracting..." -ForegroundColor Blue + try { + Expand-Archive -Path $zipPath -DestinationPath $tempExtractPath -Force + } catch { + throw "Failed to extract: $_" + } finally { + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + } + + $installFullPath = Get-InstallPath + + $exePath = Join-Path $tempExtractPath "container-use.exe" + if (-not (Test-Path $exePath)) { + throw "container-use.exe not found in archive" + } + + $destPath = Join-Path $installFullPath "container-use.exe" + Copy-Item -Path $exePath -Destination $destPath -Force + Write-Host "Installed to $destPath" -ForegroundColor Green + + # Create cu.exe alias for convenience + $cuPath = Join-Path $installFullPath "cu.exe" + Copy-Item -Path $destPath -Destination $cuPath -Force + Write-Host "Created cu.exe alias" -ForegroundColor Green + + # Cleanup + Remove-Item $tempExtractPath -Recurse -Force -ErrorAction SilentlyContinue + + # PATH update on request + if ($AddToPath) { + Add-PathEntry -PathToAdd $installFullPath -Scope 'User' + } else { + Write-Host "" + Write-Host "To add container-use to your PATH, run:" -ForegroundColor Yellow + Write-Host " [Environment]::SetEnvironmentVariable('Path', `$env:Path + ';$installFullPath', [EnvironmentVariableTarget]::User)" -ForegroundColor White + Write-Host "Or run this script again with -AddToPath" -ForegroundColor Yellow + } + + # Verify installation + Write-Host "" + Write-Host "Verifying installation..." -ForegroundColor Blue + try { + $versionOutput = (& $destPath version 2>&1 | Out-String).Trim() + Write-Host "container-use is ready! Version: $versionOutput" -ForegroundColor Green + } catch { + Write-Host "container-use installed but couldn't verify version" -ForegroundColor Yellow + } + + Write-Host "" + Write-Host "Installation complete!" -ForegroundColor Green + Write-Host "Run 'container-use --help' to get started" -ForegroundColor Cyan +} + +# Main execution +try { + Install-ContainerUse +} catch { + Write-Host "" + Write-Host "Installation failed: $_" -ForegroundColor Red + exit 1 +} From 82d6f153972af8c416fb436669d5fc1857c170dc Mon Sep 17 00:00:00 2001 From: Guillaume de Rouville Date: Mon, 18 Aug 2025 17:31:27 -0700 Subject: [PATCH 8/8] fix: leftover from another way to fix the test isolation Signed-off-by: Guillaume de Rouville --- cmd/container-use/stdio_test.go | 2 -- repository/flock.go | 4 +++- repository/git.go | 3 +-- repository/repository.go | 8 ++------ 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/container-use/stdio_test.go b/cmd/container-use/stdio_test.go index c28cf867..3b620e38 100644 --- a/cmd/container-use/stdio_test.go +++ b/cmd/container-use/stdio_test.go @@ -70,7 +70,6 @@ func NewMCPServerProcess(t *testing.T, testName string) *MCPServerProcess { require.NoError(t, err, "Failed to initialize MCP client") server := &MCPServerProcess{ - // cmd is nil because the MCP client manages the child process itself. cmd: nil, client: mcpClient, repoDir: repoDir, @@ -105,7 +104,6 @@ func serverSandboxEnv(baseEnv []string) ([]string, string) { return env, root } -// Make RPCs fail fast instead of hanging for 10m. const rpcTimeout = 120 * time.Second func ctxRPC() (context.Context, context.CancelFunc) { diff --git a/repository/flock.go b/repository/flock.go index 1a8de2b1..d1adc7a8 100644 --- a/repository/flock.go +++ b/repository/flock.go @@ -69,7 +69,9 @@ func (rlm *RepositoryLockManager) GetLock(lockType LockType) *RepositoryLock { slog.Error("Failed to create lock directory", "path", lockDir, "error", err) } - lock := &RepositoryLock{flock: flock.New(lockFile)} + lock := &RepositoryLock{ + flock: flock.New(lockFile), + } rlm.locks[lockType] = lock return lock } diff --git a/repository/git.go b/repository/git.go index d69cff7d..928b78fc 100644 --- a/repository/git.go +++ b/repository/git.go @@ -78,9 +78,8 @@ func getContainerUseRemote(ctx context.Context, repo string) (string, error) { } return "", err } - cuRemote = strings.TrimSpace(cuRemote) - return cuRemote, nil + return strings.TrimSpace(cuRemote), nil } func (r *Repository) WorktreePath(id string) (string, error) { diff --git a/repository/repository.go b/repository/repository.go index fcf7a63d..be35ecbd 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -153,16 +153,12 @@ func (r *Repository) ensureUserRemote(ctx context.Context) error { if !errors.Is(err, os.ErrNotExist) { return err } - // Convert Windows paths to file:// URLs for git remotes - remotePath := r.forkRepoPath - _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "add", containerUseRemote, remotePath) + _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "add", containerUseRemote, r.forkRepoPath) return err } if currentForkPath != r.forkRepoPath { - // Convert Windows paths to file:// URLs for git remotes - remotePath := r.forkRepoPath - _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "set-url", containerUseRemote, remotePath) + _, err := RunGitCommand(ctx, r.userRepoPath, "remote", "set-url", containerUseRemote, r.forkRepoPath) return err }