@redwoodjs/agent-ci 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,13 @@ import { execSync } from "child_process";
2
2
  import { config } from "./config.js";
3
3
  /**
4
4
  * Post a GitHub commit status via the `gh` CLI.
5
- * Silently skips if `gh` is not available on PATH.
5
+ * Only called when --commit-status is passed. Requires a GitHub token.
6
6
  */
7
- export function postCommitStatus(results, sha) {
7
+ export function postCommitStatus(results, sha, githubToken) {
8
+ if (!githubToken) {
9
+ console.warn("[Agent CI] --commit-status requires a GitHub token. Use --github-token or set AGENT_CI_GITHUB_TOKEN.");
10
+ return;
11
+ }
8
12
  // Check if gh CLI is available
9
13
  try {
10
14
  execSync("which gh", { stdio: "ignore" });
@@ -25,7 +29,7 @@ export function postCommitStatus(results, sha) {
25
29
  return;
26
30
  }
27
31
  const repo = config.GITHUB_REPO;
28
- if (repo === "unknown/unknown") {
32
+ if (!repo) {
29
33
  return;
30
34
  }
31
35
  const passed = results.filter((r) => r.succeeded).length;
package/dist/config.js CHANGED
@@ -3,28 +3,55 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { PROJECT_ROOT } from "./output/working-directory.js";
5
5
  /**
6
- * Derive `owner/repo` from the git remote URL.
7
- * Falls back to "unknown/unknown" if detection fails.
6
+ * Get the URL of the first git remote, preferring 'origin'.
7
+ * Uses `git remote get-url` which is scoped to the repo (unlike `git config`
8
+ * which can leak values from global/system config on CI runners).
8
9
  */
9
- function deriveGithubRepo() {
10
+ export function getFirstRemoteUrl(cwd) {
11
+ const exec = (cmd) => execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
10
12
  try {
11
- const remoteUrl = execSync("git remote get-url origin", {
12
- cwd: PROJECT_ROOT,
13
- encoding: "utf-8",
14
- }).trim();
15
- // Handles both SSH (git@github.com:owner/repo.git) and HTTPS URLs
16
- const match = remoteUrl.match(/[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
17
- if (match) {
18
- return match[1];
19
- }
13
+ return exec("git remote get-url origin") || null;
20
14
  }
21
15
  catch {
22
- // git not available or no remote configured
16
+ // origin doesn't exist fall back to the first listed remote
17
+ try {
18
+ const firstName = exec("git remote").split("\n")[0];
19
+ if (firstName) {
20
+ return exec(`git remote get-url ${firstName}`) || null;
21
+ }
22
+ }
23
+ catch { }
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Extract `owner/repo` from a git remote URL.
29
+ * Handles HTTPS, SSH (git@), and ssh:// URLs, with or without `.git` suffix.
30
+ */
31
+ export function parseRepoSlug(remoteUrl) {
32
+ const match = remoteUrl.match(/[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/);
33
+ return match ? match[1] : null;
34
+ }
35
+ /**
36
+ * Detect `owner/repo` from the git remote in the given directory.
37
+ * Throws if detection fails and no fallback is provided.
38
+ */
39
+ export function resolveRepoSlug(cwd, fallback) {
40
+ const remoteUrl = getFirstRemoteUrl(cwd);
41
+ if (remoteUrl) {
42
+ const slug = parseRepoSlug(remoteUrl);
43
+ if (slug) {
44
+ return slug;
45
+ }
46
+ }
47
+ if (fallback !== undefined) {
48
+ return fallback;
23
49
  }
24
- return "unknown/unknown";
50
+ throw new Error(`Could not detect GitHub repository from git remotes in ${cwd}. ` +
51
+ `Set the GITHUB_REPO environment variable (e.g. GITHUB_REPO=owner/repo).`);
25
52
  }
26
53
  export const config = {
27
- GITHUB_REPO: process.env.GITHUB_REPO || deriveGithubRepo(),
54
+ GITHUB_REPO: process.env.GITHUB_REPO,
28
55
  GITHUB_API_URL: process.env.GITHUB_API_URL || "http://localhost:8910",
29
56
  };
30
57
  /**
@@ -0,0 +1,157 @@
1
+ import { execSync } from "child_process";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { config, getFirstRemoteUrl, parseRepoSlug, resolveRepoSlug } from "./config.js";
7
+ describe("parseRepoSlug", () => {
8
+ it.each([
9
+ ["https://github.com/redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
10
+ ["https://github.com/redwoodjs/agent-ci", "redwoodjs/agent-ci"],
11
+ ["https://github.com/redwoodjs/agent-ci/", "redwoodjs/agent-ci"],
12
+ ["git@github.com:redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
13
+ ["git@github.com:redwoodjs/agent-ci", "redwoodjs/agent-ci"],
14
+ ["ssh://git@github.com/redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
15
+ ["ssh://git@github.com/redwoodjs/agent-ci", "redwoodjs/agent-ci"],
16
+ ["ssh://git@github.com:22/redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
17
+ ["https://github.example.com/redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
18
+ ["git@github.example.com:redwoodjs/agent-ci.git", "redwoodjs/agent-ci"],
19
+ ])("parses %s → %s", (url, expected) => {
20
+ expect(parseRepoSlug(url)).toBe(expected);
21
+ });
22
+ it("returns null for unparseable URLs", () => {
23
+ expect(parseRepoSlug("not-a-url")).toBeNull();
24
+ expect(parseRepoSlug("")).toBeNull();
25
+ });
26
+ });
27
+ describe("getFirstRemoteUrl", () => {
28
+ let tmpDir;
29
+ beforeEach(() => {
30
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-test-"));
31
+ execSync("git init", { cwd: tmpDir, stdio: "pipe" });
32
+ });
33
+ afterEach(() => {
34
+ fs.rmSync(tmpDir, { recursive: true, force: true });
35
+ });
36
+ it("returns origin URL when origin exists", () => {
37
+ execSync("git remote add origin https://github.com/test/repo.git", {
38
+ cwd: tmpDir,
39
+ stdio: "pipe",
40
+ });
41
+ expect(getFirstRemoteUrl(tmpDir)).toBe("https://github.com/test/repo.git");
42
+ });
43
+ it("falls back to first remote when origin does not exist", () => {
44
+ execSync("git remote add upstream https://github.com/test/upstream.git", {
45
+ cwd: tmpDir,
46
+ stdio: "pipe",
47
+ });
48
+ expect(getFirstRemoteUrl(tmpDir)).toBe("https://github.com/test/upstream.git");
49
+ });
50
+ it("prefers origin over other remotes", () => {
51
+ execSync("git remote add upstream https://github.com/test/upstream.git", {
52
+ cwd: tmpDir,
53
+ stdio: "pipe",
54
+ });
55
+ execSync("git remote add origin https://github.com/test/origin.git", {
56
+ cwd: tmpDir,
57
+ stdio: "pipe",
58
+ });
59
+ expect(getFirstRemoteUrl(tmpDir)).toBe("https://github.com/test/origin.git");
60
+ });
61
+ it("returns null when no remotes exist", () => {
62
+ expect(getFirstRemoteUrl(tmpDir)).toBeNull();
63
+ });
64
+ it("returns null for non-git directory", () => {
65
+ const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), "no-git-"));
66
+ try {
67
+ expect(getFirstRemoteUrl(nonGitDir)).toBeNull();
68
+ }
69
+ finally {
70
+ fs.rmSync(nonGitDir, { recursive: true, force: true });
71
+ }
72
+ });
73
+ });
74
+ describe("resolveRepoSlug", () => {
75
+ let tmpDir;
76
+ beforeEach(() => {
77
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-test-"));
78
+ execSync("git init", { cwd: tmpDir, stdio: "pipe" });
79
+ });
80
+ afterEach(() => {
81
+ fs.rmSync(tmpDir, { recursive: true, force: true });
82
+ });
83
+ it("detects owner/repo from remote URL", () => {
84
+ execSync("git remote add origin https://github.com/acme/widgets.git", {
85
+ cwd: tmpDir,
86
+ stdio: "pipe",
87
+ });
88
+ expect(resolveRepoSlug(tmpDir)).toBe("acme/widgets");
89
+ });
90
+ it("detects owner/repo from SSH remote", () => {
91
+ execSync("git remote add origin git@github.com:acme/widgets.git", {
92
+ cwd: tmpDir,
93
+ stdio: "pipe",
94
+ });
95
+ expect(resolveRepoSlug(tmpDir)).toBe("acme/widgets");
96
+ });
97
+ it("throws when no remotes exist and no fallback given", () => {
98
+ expect(() => resolveRepoSlug(tmpDir)).toThrow(/Could not detect GitHub repository/);
99
+ });
100
+ it("returns fallback when no remotes exist", () => {
101
+ expect(resolveRepoSlug(tmpDir, "org/fallback")).toBe("org/fallback");
102
+ });
103
+ it("throws for non-git directory without fallback", () => {
104
+ const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), "no-git-"));
105
+ try {
106
+ expect(() => resolveRepoSlug(nonGitDir)).toThrow(/Could not detect GitHub repository/);
107
+ }
108
+ finally {
109
+ fs.rmSync(nonGitDir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ it("uses non-origin remote when origin is absent", () => {
113
+ execSync("git remote add upstream https://github.com/acme/upstream.git", {
114
+ cwd: tmpDir,
115
+ stdio: "pipe",
116
+ });
117
+ expect(resolveRepoSlug(tmpDir)).toBe("acme/upstream");
118
+ });
119
+ });
120
+ describe("GITHUB_REPO env var override priority", () => {
121
+ let tmpDir;
122
+ let savedRepo;
123
+ beforeEach(() => {
124
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-test-"));
125
+ execSync("git init", { cwd: tmpDir, stdio: "pipe" });
126
+ savedRepo = config.GITHUB_REPO;
127
+ });
128
+ afterEach(() => {
129
+ config.GITHUB_REPO = savedRepo;
130
+ fs.rmSync(tmpDir, { recursive: true, force: true });
131
+ });
132
+ it("env var overrides auto-detection", () => {
133
+ execSync("git remote add origin https://github.com/detected/repo.git", {
134
+ cwd: tmpDir,
135
+ stdio: "pipe",
136
+ });
137
+ config.GITHUB_REPO = "override/from-env";
138
+ // Replicate cli.ts priority: env var ?? auto-detect
139
+ const result = config.GITHUB_REPO ?? resolveRepoSlug(tmpDir);
140
+ expect(result).toBe("override/from-env");
141
+ });
142
+ it("auto-detects when env var is not set", () => {
143
+ execSync("git remote add origin https://github.com/detected/repo.git", {
144
+ cwd: tmpDir,
145
+ stdio: "pipe",
146
+ });
147
+ config.GITHUB_REPO = undefined;
148
+ const result = config.GITHUB_REPO ?? resolveRepoSlug(tmpDir);
149
+ expect(result).toBe("detected/repo");
150
+ });
151
+ it("throws when neither env var nor remote is available", () => {
152
+ config.GITHUB_REPO = undefined;
153
+ expect(() => {
154
+ return config.GITHUB_REPO ?? resolveRepoSlug(tmpDir);
155
+ }).toThrow(/Could not detect GitHub repository/);
156
+ });
157
+ });
@@ -33,13 +33,13 @@ export function buildContainerEnv(opts) {
33
33
  * Build the Binds array for `docker.createContainer()`.
34
34
  */
35
35
  export function buildContainerBinds(opts) {
36
- const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, } = opts;
36
+ const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, dockerSocketPath = "/var/run/docker.sock", } = opts;
37
37
  const h = toHostPath;
38
38
  return [
39
39
  // When using a custom container, bind-mount the extracted runner
40
40
  ...(useDirectContainer ? [`${h(hostRunnerDir)}:/home/runner`] : []),
41
41
  `${h(hostWorkDir)}:/home/runner/_work`,
42
- "/var/run/docker.sock:/var/run/docker.sock",
42
+ `${dockerSocketPath}:/var/run/docker.sock`,
43
43
  `${h(shimsDir)}:/tmp/agent-ci-shims`,
44
44
  // Pause-on-failure IPC: signal files (paused, retry, abort)
45
45
  ...(signalsDir ? [`${h(signalsDir)}:/tmp/agent-ci-signals`] : []),
@@ -52,8 +52,10 @@ export function buildContainerBinds(opts) {
52
52
  `${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
53
53
  // Warm node_modules: mounted outside the workspace so actions/checkout can
54
54
  // delete the symlink without EBUSY. A symlink in the entrypoint points
55
- // workspace/node_modules → /tmp/warm-modules.
56
- `${h(warmModulesDir)}:/tmp/warm-modules`,
55
+ // workspace/node_modules → /tmp/node_modules.
56
+ // Mounted at /tmp/node_modules (not /tmp/warm-modules) so that TypeScript's
57
+ // upward @types walk from .pnpm realpath finds /tmp/node_modules/@types.
58
+ `${h(warmModulesDir)}:/tmp/node_modules`,
57
59
  ];
58
60
  }
59
61
  // ─── Container command ────────────────────────────────────────────────────────
@@ -87,7 +89,7 @@ export function buildContainerCmd(opts) {
87
89
  `REPO_NAME=$(basename $GITHUB_REPOSITORY)`,
88
90
  `WORKSPACE_PATH=/home/runner/_work/$REPO_NAME/$REPO_NAME`,
89
91
  `mkdir -p $WORKSPACE_PATH`,
90
- `ln -sfn /tmp/warm-modules $WORKSPACE_PATH/node_modules`,
92
+ `ln -sfn /tmp/node_modules $WORKSPACE_PATH/node_modules`,
91
93
  T("workspace-setup"),
92
94
  `echo "[agent-ci:boot] total: $(($(date +%s%3N)-BOOT_T0))ms"`,
93
95
  `echo "[agent-ci:boot] starting run.sh --once"`,
@@ -58,9 +58,9 @@ describe("buildContainerBinds", () => {
58
58
  useDirectContainer: false,
59
59
  });
60
60
  expect(binds).toContain("/tmp/work:/home/runner/_work");
61
- expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
61
+ expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock"); // default when dockerSocketPath is not set
62
62
  expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
63
- expect(binds).toContain("/tmp/warm:/tmp/warm-modules");
63
+ expect(binds).toContain("/tmp/warm:/tmp/node_modules");
64
64
  expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
65
65
  expect(binds).toContain("/tmp/npm:/home/runner/.npm");
66
66
  expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
@@ -101,6 +101,22 @@ describe("buildContainerBinds", () => {
101
101
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
102
102
  expect(binds.some((b) => b.includes(".bun"))).toBe(false);
103
103
  });
104
+ it("uses resolved dockerSocketPath for bind mount when provided", async () => {
105
+ const { buildContainerBinds } = await import("./container-config.js");
106
+ const binds = buildContainerBinds({
107
+ hostWorkDir: "/tmp/work",
108
+ shimsDir: "/tmp/shims",
109
+ diagDir: "/tmp/diag",
110
+ toolCacheDir: "/tmp/toolcache",
111
+ playwrightCacheDir: "/tmp/playwright",
112
+ warmModulesDir: "/tmp/warm",
113
+ hostRunnerDir: "/tmp/runner",
114
+ useDirectContainer: false,
115
+ dockerSocketPath: "/Users/test/.orbstack/run/docker.sock",
116
+ });
117
+ expect(binds).toContain("/Users/test/.orbstack/run/docker.sock:/var/run/docker.sock");
118
+ expect(binds).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
119
+ });
104
120
  it("includes runner bind mount for direct container", async () => {
105
121
  const { buildContainerBinds } = await import("./container-config.js");
106
122
  const binds = buildContainerBinds({
@@ -295,6 +311,33 @@ describe("resolveDockerExtraHosts", () => {
295
311
  expect(resolveDockerExtraHosts("10.10.10.10")).toBeUndefined();
296
312
  });
297
313
  });
314
+ // ── dockerSocketPath bind-mount ───────────────────────────────────────────────
315
+ describe("buildContainerBinds with dockerSocketPath", () => {
316
+ const baseOpts = {
317
+ hostWorkDir: "/tmp/work",
318
+ shimsDir: "/tmp/shims",
319
+ diagDir: "/tmp/diag",
320
+ toolCacheDir: "/tmp/toolcache",
321
+ playwrightCacheDir: "/tmp/playwright",
322
+ warmModulesDir: "/tmp/warm",
323
+ hostRunnerDir: "/tmp/runner",
324
+ useDirectContainer: false,
325
+ };
326
+ it("uses default /var/run/docker.sock when no dockerSocketPath is provided", async () => {
327
+ const { buildContainerBinds } = await import("./container-config.js");
328
+ const binds = buildContainerBinds(baseOpts);
329
+ expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
330
+ });
331
+ it("uses custom host socket path from dockerSocketPath", async () => {
332
+ const { buildContainerBinds } = await import("./container-config.js");
333
+ const binds = buildContainerBinds({
334
+ ...baseOpts,
335
+ dockerSocketPath: "/Users/foo/.docker/run/docker.sock",
336
+ });
337
+ expect(binds).toContain("/Users/foo/.docker/run/docker.sock:/var/run/docker.sock");
338
+ expect(binds).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
339
+ });
340
+ });
298
341
  // ── signalsDir bind-mount ─────────────────────────────────────────────────────
299
342
  describe("buildContainerBinds with signalsDir", () => {
300
343
  const baseOpts = {
@@ -0,0 +1,119 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { execSync } from "child_process";
5
+ import { debugRunner } from "../output/debug.js";
6
+ const DEFAULT_SOCKET = "/var/run/docker.sock";
7
+ /**
8
+ * Well-known Docker socket paths on macOS, checked in order.
9
+ * Linux distros almost always have /var/run/docker.sock directly.
10
+ */
11
+ const MACOS_PROVIDER_SOCKETS = [
12
+ path.join(os.homedir(), ".orbstack/run/docker.sock"),
13
+ path.join(os.homedir(), ".docker/run/docker.sock"),
14
+ path.join(os.homedir(), ".colima/default/docker.sock"),
15
+ path.join(os.homedir(), ".lima/docker/sock/docker.sock"),
16
+ ];
17
+ /**
18
+ * Try to extract the socket path from the active Docker context.
19
+ * Returns undefined if the command fails or the context uses a non-unix endpoint.
20
+ */
21
+ function socketFromDockerContext() {
22
+ try {
23
+ const json = execSync("docker context inspect", {
24
+ encoding: "utf8",
25
+ stdio: ["pipe", "pipe", "pipe"],
26
+ timeout: 5000,
27
+ });
28
+ const data = JSON.parse(json);
29
+ const host = data?.[0]?.Endpoints?.docker?.Host;
30
+ if (host && host.startsWith("unix://")) {
31
+ const socketPath = host.replace("unix://", "");
32
+ if (fs.existsSync(socketPath)) {
33
+ return socketPath;
34
+ }
35
+ debugRunner(`Docker context points to ${socketPath} but it does not exist`);
36
+ }
37
+ }
38
+ catch {
39
+ debugRunner("Could not inspect Docker context");
40
+ }
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Resolve the Docker daemon socket.
45
+ *
46
+ * Resolution order:
47
+ * 1. `DOCKER_HOST` env var (returned as-is for non-unix schemes)
48
+ * 2. Default socket `/var/run/docker.sock` (resolves symlinks)
49
+ * 3. Active Docker context (`docker context inspect`)
50
+ * 4. Well-known macOS provider sockets
51
+ *
52
+ * Throws with actionable guidance when no socket can be found.
53
+ */
54
+ export function resolveDockerSocket() {
55
+ // 1. Explicit DOCKER_HOST
56
+ const envHost = process.env.DOCKER_HOST?.trim();
57
+ if (envHost) {
58
+ if (envHost.startsWith("unix://")) {
59
+ const socketPath = envHost.replace("unix://", "");
60
+ const resolved = resolveIfExists(socketPath);
61
+ if (resolved) {
62
+ return { socketPath: resolved, uri: `unix://${resolved}` };
63
+ }
64
+ // The env var points to a non-existent socket — fall through to auto-detect
65
+ debugRunner(`DOCKER_HOST=${envHost} does not exist, trying auto-detection`);
66
+ }
67
+ else {
68
+ // Non-unix scheme (ssh://, tcp://, etc.) — cannot resolve a local path
69
+ // Return a sentinel; callers handle non-unix hosts separately.
70
+ return { socketPath: "", uri: envHost };
71
+ }
72
+ }
73
+ // 2. Default socket path (often a symlink on macOS)
74
+ const defaultResolved = resolveIfExists(DEFAULT_SOCKET);
75
+ if (defaultResolved) {
76
+ return { socketPath: defaultResolved, uri: `unix://${defaultResolved}` };
77
+ }
78
+ // 3. Docker context
79
+ const contextSocket = socketFromDockerContext();
80
+ if (contextSocket) {
81
+ return { socketPath: contextSocket, uri: `unix://${contextSocket}` };
82
+ }
83
+ // 4. Well-known macOS provider paths
84
+ if (process.platform === "darwin") {
85
+ for (const candidate of MACOS_PROVIDER_SOCKETS) {
86
+ if (fs.existsSync(candidate)) {
87
+ return { socketPath: candidate, uri: `unix://${candidate}` };
88
+ }
89
+ }
90
+ }
91
+ // Nothing found — give the user actionable guidance
92
+ const searched = [
93
+ DEFAULT_SOCKET,
94
+ ...(process.platform === "darwin" ? MACOS_PROVIDER_SOCKETS : []),
95
+ ];
96
+ const lines = [
97
+ "Could not find a Docker socket. Searched:",
98
+ ...searched.map((p) => ` - ${p}`),
99
+ "",
100
+ "To fix this, either:",
101
+ " • Set the DOCKER_HOST environment variable (e.g. DOCKER_HOST=unix:///path/to/docker.sock)",
102
+ ` • Create a symlink: ln -s /path/to/docker.sock ${DEFAULT_SOCKET}`,
103
+ " • Start your Docker provider (Docker Desktop, OrbStack, Colima, etc.)",
104
+ ];
105
+ throw new Error(lines.join("\n"));
106
+ }
107
+ /**
108
+ * If `socketPath` exists (following symlinks), return the real path.
109
+ * Returns undefined otherwise.
110
+ */
111
+ function resolveIfExists(socketPath) {
112
+ try {
113
+ // fs.realpathSync follows symlinks and throws if the target doesn't exist
114
+ return fs.realpathSync(socketPath);
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { execSync } from "node:child_process";
5
+ vi.mock("node:child_process", () => ({
6
+ execSync: vi.fn(),
7
+ }));
8
+ const mockedExecSync = vi.mocked(execSync);
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ delete process.env.DOCKER_HOST;
12
+ });
13
+ // Helper to dynamically import (fresh module each test via vi.resetModules)
14
+ async function importFresh() {
15
+ vi.resetModules();
16
+ return import("./docker-socket.js");
17
+ }
18
+ describe("resolveDockerSocket", () => {
19
+ // ── DOCKER_HOST set ──────────────────────────────────────────────────────
20
+ it("uses DOCKER_HOST when set to a unix socket that exists", async () => {
21
+ process.env.DOCKER_HOST = "unix:///tmp/test-docker.sock";
22
+ vi.spyOn(fs, "realpathSync").mockReturnValue("/tmp/test-docker.sock");
23
+ const { resolveDockerSocket } = await importFresh();
24
+ const result = resolveDockerSocket();
25
+ expect(result.socketPath).toBe("/tmp/test-docker.sock");
26
+ expect(result.uri).toBe("unix:///tmp/test-docker.sock");
27
+ });
28
+ it("returns non-unix DOCKER_HOST as-is (e.g. ssh://)", async () => {
29
+ process.env.DOCKER_HOST = "ssh://user@remote";
30
+ const { resolveDockerSocket } = await importFresh();
31
+ const result = resolveDockerSocket();
32
+ expect(result.socketPath).toBe("");
33
+ expect(result.uri).toBe("ssh://user@remote");
34
+ });
35
+ // ── Default socket path ────────────────────────────────────────────────
36
+ it("resolves /var/run/docker.sock symlink", async () => {
37
+ delete process.env.DOCKER_HOST;
38
+ vi.spyOn(fs, "realpathSync").mockImplementation((p) => {
39
+ if (String(p) === "/var/run/docker.sock") {
40
+ return "/Users/test/.orbstack/run/docker.sock";
41
+ }
42
+ throw new Error("ENOENT");
43
+ });
44
+ const { resolveDockerSocket } = await importFresh();
45
+ const result = resolveDockerSocket();
46
+ expect(result.socketPath).toBe("/Users/test/.orbstack/run/docker.sock");
47
+ expect(result.uri).toBe("unix:///Users/test/.orbstack/run/docker.sock");
48
+ });
49
+ // ── Docker context fallback ─────────────────────────────────────────────
50
+ it("falls back to docker context inspect when default socket missing", async () => {
51
+ delete process.env.DOCKER_HOST;
52
+ vi.spyOn(fs, "realpathSync").mockImplementation(() => {
53
+ throw new Error("ENOENT");
54
+ });
55
+ vi.spyOn(fs, "existsSync").mockImplementation((p) => {
56
+ return String(p) === "/Users/test/.docker/run/docker.sock";
57
+ });
58
+ mockedExecSync.mockReturnValue(JSON.stringify([
59
+ {
60
+ Endpoints: {
61
+ docker: { Host: "unix:///Users/test/.docker/run/docker.sock" },
62
+ },
63
+ },
64
+ ]));
65
+ const { resolveDockerSocket } = await importFresh();
66
+ const result = resolveDockerSocket();
67
+ expect(result.socketPath).toBe("/Users/test/.docker/run/docker.sock");
68
+ });
69
+ // ── macOS provider fallback ──────────────────────────────────────────────
70
+ it("checks well-known macOS provider sockets when context fails", async () => {
71
+ delete process.env.DOCKER_HOST;
72
+ vi.spyOn(fs, "realpathSync").mockImplementation(() => {
73
+ throw new Error("ENOENT");
74
+ });
75
+ mockedExecSync.mockImplementation(() => {
76
+ throw new Error("docker not found");
77
+ });
78
+ const home = os.homedir();
79
+ vi.spyOn(fs, "existsSync").mockImplementation((p) => {
80
+ return String(p) === `${home}/.docker/run/docker.sock`;
81
+ });
82
+ // Ensure we're on darwin for this path
83
+ vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
84
+ const { resolveDockerSocket } = await importFresh();
85
+ const result = resolveDockerSocket();
86
+ expect(result.socketPath).toBe(`${home}/.docker/run/docker.sock`);
87
+ });
88
+ // ── Reproduction: symlink missing → clear error ─────────────────────────
89
+ it("throws with actionable error when no socket is found", async () => {
90
+ delete process.env.DOCKER_HOST;
91
+ vi.spyOn(fs, "realpathSync").mockImplementation(() => {
92
+ throw new Error("ENOENT");
93
+ });
94
+ vi.spyOn(fs, "existsSync").mockReturnValue(false);
95
+ mockedExecSync.mockImplementation(() => {
96
+ throw new Error("docker not found");
97
+ });
98
+ vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
99
+ const { resolveDockerSocket } = await importFresh();
100
+ expect(() => resolveDockerSocket()).toThrow("Could not find a Docker socket");
101
+ expect(() => resolveDockerSocket()).toThrow("DOCKER_HOST");
102
+ expect(() => resolveDockerSocket()).toThrow("ln -s");
103
+ });
104
+ it("falls through when DOCKER_HOST points to non-existent socket", async () => {
105
+ process.env.DOCKER_HOST = "unix:///nonexistent/docker.sock";
106
+ vi.spyOn(fs, "realpathSync").mockImplementation(() => {
107
+ throw new Error("ENOENT");
108
+ });
109
+ vi.spyOn(fs, "existsSync").mockReturnValue(false);
110
+ mockedExecSync.mockImplementation(() => {
111
+ throw new Error("docker not found");
112
+ });
113
+ vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
114
+ const { resolveDockerSocket } = await importFresh();
115
+ expect(() => resolveDockerSocket()).toThrow("Could not find a Docker socket");
116
+ });
117
+ });