diff --git a/src/devcontainerManager.test.ts b/src/devcontainerManager.test.ts index 7306184..7db794c 100644 --- a/src/devcontainerManager.test.ts +++ b/src/devcontainerManager.test.ts @@ -33,9 +33,11 @@ vi.mock("child_process", async () => { // Import after mocking import * as fs from "fs"; +import { exec } from "child_process"; const mockExistsSync = fs.existsSync as ReturnType; const mockReadFileSync = fs.readFileSync as ReturnType; +const mockExec = exec as unknown as ReturnType; let manager: DevcontainerManager; @@ -215,6 +217,73 @@ describe("stopDevcontainer", () => { }); }); +// --------------------------------------------------------------------------- +// startDevcontainer – PATH augmentation +// --------------------------------------------------------------------------- + +describe("startDevcontainer PATH handling", () => { + beforeEach(() => { + mockExistsSync.mockImplementation((p: unknown) => { + return String(p) === "/home/user/project/.devcontainer/devcontainer.json"; + }); + }); + + it("prepends bin directory to PATH when devcontainerCliPath is absolute", async () => { + __setMockConfig({ + "opencode-devcontainer.devcontainerCliPath": "/usr/local/nvm/versions/node/v20/bin/devcontainer", + }); + + // Make exec call succeed with a container ID + mockExec.mockImplementation( + ( + cmd: string, + opts: Record, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + if (cmd.includes("devcontainer up")) { + // Verify that the env.PATH starts with the binary's directory + const env = opts.env as Record; + expect(env).toBeDefined(); + expect(env.PATH).toMatch(/^\/usr\/local\/nvm\/versions\/node\/v20\/bin/); + cb(null, JSON.stringify({ containerId: "abc123" }), ""); + } else { + cb(null, "", ""); + } + } + ); + + await manager.startDevcontainer(); + expect(mockExec).toHaveBeenCalled(); + }); + + it("does not modify PATH when devcontainerCliPath is a bare command", async () => { + __setMockConfig({ + "opencode-devcontainer.devcontainerCliPath": "devcontainer", + }); + + const originalPath = process.env.PATH; + + mockExec.mockImplementation( + ( + cmd: string, + opts: Record, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + if (cmd.includes("devcontainer up")) { + const env = opts.env as Record; + expect(env.PATH).toBe(originalPath); + cb(null, JSON.stringify({ containerId: "abc123" }), ""); + } else { + cb(null, "", ""); + } + } + ); + + await manager.startDevcontainer(); + expect(mockExec).toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // dispose // --------------------------------------------------------------------------- diff --git a/src/devcontainerManager.ts b/src/devcontainerManager.ts index 671a1fd..21fa571 100644 --- a/src/devcontainerManager.ts +++ b/src/devcontainerManager.ts @@ -282,8 +282,21 @@ export class DevcontainerManager { command: string, options?: { cwd?: string } ): Promise<{ stdout: string; stderr: string }> { + // When the command binary is an absolute path, ensure its parent directory + // is on PATH. This fixes environments where node is managed by nvm and the + // devcontainer CLI (a #!/usr/bin/env node script) lives in the same bin + // directory as the node binary, but that directory isn't on the default + // PATH inherited by child processes (common when VS Code is launched from + // the OS application launcher rather than a terminal). + const env = { ...process.env }; + const binaryPath = command.split(" ")[0]; + if (path.isAbsolute(binaryPath)) { + const binDir = path.dirname(binaryPath); + env.PATH = binDir + path.delimiter + (env.PATH || ""); + } + return new Promise((resolve, reject) => { - exec(command, { cwd: options?.cwd, timeout: 120000 }, (error, stdout, stderr) => { + exec(command, { cwd: options?.cwd, timeout: 120000, env }, (error, stdout, stderr) => { if (error) { reject(new Error(`Command failed: ${command}\n${stderr || error.message}`)); return;