@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.
- package/README.md +9 -7
- package/dist/cli.js +461 -302
- package/dist/commit-status.js +7 -3
- package/dist/config.js +42 -15
- package/dist/config.test.js +157 -0
- package/dist/docker/container-config.js +7 -5
- package/dist/docker/container-config.test.js +45 -2
- package/dist/docker/docker-socket.js +119 -0
- package/dist/docker/docker-socket.test.js +117 -0
- package/dist/output/cleanup.js +42 -6
- package/dist/output/cleanup.test.js +15 -0
- package/dist/runner/directory-setup.js +2 -3
- package/dist/runner/local-job.js +51 -19
- package/dist/runner/local-job.test.js +43 -0
- package/dist/runner/result-builder.js +2 -1
- package/dist/runner/workspace.js +3 -2
- package/dist/workflow/remote-workflow-fetch.js +110 -0
- package/dist/workflow/remote-workflow-fetch.test.js +310 -0
- package/dist/workflow/reusable-workflow.js +134 -0
- package/dist/workflow/reusable-workflow.test.js +655 -0
- package/dist/workflow/workflow-parser.js +33 -20
- package/dist/workflow/workflow-parser.test.js +95 -2
- package/package.json +2 -2
package/dist/commit-status.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
+
export function getFirstRemoteUrl(cwd) {
|
|
11
|
+
const exec = (cmd) => execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
10
12
|
try {
|
|
11
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
56
|
-
|
|
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/
|
|
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/
|
|
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
|
+
});
|