straylight-ai 0.1.0
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/bin/cli.js +39 -0
- package/bin/mcp-shim.js +85 -0
- package/dist/commands/setup.d.ts +9 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +61 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/start.d.ts +6 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +33 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +40 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.d.ts +6 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +25 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/docker.d.ts +42 -0
- package/dist/docker.d.ts.map +1 -0
- package/dist/docker.js +126 -0
- package/dist/docker.js.map +1 -0
- package/dist/health.d.ts +22 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +44 -0
- package/dist/health.js.map +1 -0
- package/dist/mcp-register.d.ts +19 -0
- package/dist/mcp-register.d.ts.map +1 -0
- package/dist/mcp-register.js +59 -0
- package/dist/mcp-register.js.map +1 -0
- package/dist/open.d.ts +6 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +70 -0
- package/dist/open.js.map +1 -0
- package/package.json +39 -0
- package/src/__tests__/commands.test.ts +272 -0
- package/src/__tests__/docker.test.ts +139 -0
- package/src/__tests__/health.test.ts +107 -0
- package/src/__tests__/mcp-register.test.ts +123 -0
- package/src/__tests__/open.test.ts +61 -0
- package/src/commands/setup.ts +72 -0
- package/src/commands/start.ts +44 -0
- package/src/commands/status.ts +51 -0
- package/src/commands/stop.ts +31 -0
- package/src/docker.ts +102 -0
- package/src/health.ts +55 -0
- package/src/mcp-register.ts +62 -0
- package/src/open.ts +33 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isClaudeAvailable = isClaudeAvailable;
|
|
4
|
+
exports.registerMCP = registerMCP;
|
|
5
|
+
exports.manualRegistrationInstructions = manualRegistrationInstructions;
|
|
6
|
+
const child_process_1 = require("child_process");
|
|
7
|
+
/** Name to use when registering the MCP server */
|
|
8
|
+
const MCP_SERVER_NAME = "straylight-ai";
|
|
9
|
+
/**
|
|
10
|
+
* Check whether the Claude Code CLI is available on PATH.
|
|
11
|
+
*/
|
|
12
|
+
function isClaudeAvailable() {
|
|
13
|
+
try {
|
|
14
|
+
(0, child_process_1.execSync)("claude --version", { stdio: "pipe" });
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Register the Straylight-AI MCP server with Claude Code.
|
|
23
|
+
*
|
|
24
|
+
* Runs:
|
|
25
|
+
* claude mcp add straylight-ai --transport stdio -- npx straylight-ai mcp
|
|
26
|
+
*
|
|
27
|
+
* @returns true on success, false if claude is unavailable or the command fails.
|
|
28
|
+
*/
|
|
29
|
+
async function registerMCP() {
|
|
30
|
+
if (!isClaudeAvailable()) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const result = (0, child_process_1.spawnSync)("claude", [
|
|
34
|
+
"mcp",
|
|
35
|
+
"add",
|
|
36
|
+
MCP_SERVER_NAME,
|
|
37
|
+
"--transport",
|
|
38
|
+
"stdio",
|
|
39
|
+
"--",
|
|
40
|
+
"npx",
|
|
41
|
+
"straylight-ai",
|
|
42
|
+
"mcp",
|
|
43
|
+
], { stdio: "pipe" });
|
|
44
|
+
return result.status === 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build the manual registration instructions string for users who do not have
|
|
48
|
+
* Claude Code installed.
|
|
49
|
+
*/
|
|
50
|
+
function manualRegistrationInstructions() {
|
|
51
|
+
return [
|
|
52
|
+
"To register the MCP server manually, run:",
|
|
53
|
+
"",
|
|
54
|
+
" claude mcp add straylight-ai --transport stdio -- npx straylight-ai mcp",
|
|
55
|
+
"",
|
|
56
|
+
"Or add it directly to your Claude Code settings.",
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=mcp-register.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-register.js","sourceRoot":"","sources":["../src/mcp-register.ts"],"names":[],"mappings":";;AAQA,8CAOC;AAUD,kCAsBC;AAMD,wEAQC;AA7DD,iDAAoD;AAEpD,kDAAkD;AAClD,MAAM,eAAe,GAAG,eAAe,CAAC;AAExC;;GAEG;AACH,SAAgB,iBAAiB;IAC/B,IAAI,CAAC;QACH,IAAA,wBAAQ,EAAC,kBAAkB,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,WAAW;IAC/B,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC;QACzB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,yBAAS,EACtB,QAAQ,EACR;QACE,KAAK;QACL,KAAK;QACL,eAAe;QACf,aAAa;QACb,OAAO;QACP,IAAI;QACJ,KAAK;QACL,eAAe;QACf,KAAK;KACN,EACD,EAAE,KAAK,EAAE,MAAM,EAAE,CAClB,CAAC;IAEF,OAAO,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC;AAC7B,CAAC;AAED;;;GAGG;AACH,SAAgB,8BAA8B;IAC5C,OAAO;QACL,2CAA2C;QAC3C,EAAE;QACF,2EAA2E;QAC3E,EAAE;QACF,kDAAkD;KACnD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
|
package/dist/open.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open.d.ts","sourceRoot":"","sources":["../src/open.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB5D"}
|
package/dist/open.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.openBrowser = openBrowser;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
/**
|
|
40
|
+
* Open the given URL in the system's default browser.
|
|
41
|
+
* Best-effort: failure is silent to avoid blocking the setup flow.
|
|
42
|
+
*/
|
|
43
|
+
async function openBrowser(url) {
|
|
44
|
+
const platform = os.platform();
|
|
45
|
+
let command;
|
|
46
|
+
if (platform === "darwin") {
|
|
47
|
+
command = "open";
|
|
48
|
+
}
|
|
49
|
+
else if (platform === "win32") {
|
|
50
|
+
command = "start";
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
command = "xdg-open";
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
try {
|
|
57
|
+
const child = (0, child_process_1.spawn)(command, [url], {
|
|
58
|
+
stdio: "ignore",
|
|
59
|
+
detached: true,
|
|
60
|
+
});
|
|
61
|
+
child.unref();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Best-effort: ignore errors opening the browser
|
|
65
|
+
}
|
|
66
|
+
// Resolve immediately; we don't wait for the browser to finish
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=open.js.map
|
package/dist/open.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open.js","sourceRoot":"","sources":["../src/open.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAOA,kCAyBC;AAhCD,iDAAsC;AACtC,uCAAyB;AAEzB;;;GAGG;AACI,KAAK,UAAU,WAAW,CAAC,GAAW;IAC3C,MAAM,QAAQ,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC;IAC/B,IAAI,OAAe,CAAC;IAEpB,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC1B,OAAO,GAAG,MAAM,CAAC;IACnB,CAAC;SAAM,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QAChC,OAAO,GAAG,OAAO,CAAC;IACpB,CAAC;SAAM,CAAC;QACN,OAAO,GAAG,UAAU,CAAC;IACvB,CAAC;IAED,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,OAAO,EAAE,CAAC,GAAG,CAAC,EAAE;gBAClC,KAAK,EAAE,QAAQ;gBACf,QAAQ,EAAE,IAAI;aACf,CAAC,CAAC;YACH,KAAK,CAAC,KAAK,EAAE,CAAC;QAChB,CAAC;QAAC,MAAM,CAAC;YACP,iDAAiD;QACnD,CAAC;QACD,+DAA+D;QAC/D,OAAO,EAAE,CAAC;IACZ,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "straylight-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-knowledge credential proxy for AI agents",
|
|
5
|
+
"bin": {
|
|
6
|
+
"straylight-ai": "bin/cli.js",
|
|
7
|
+
"straylight-mcp": "bin/mcp-shim.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"prepare": "tsc",
|
|
12
|
+
"prepublishOnly": "npm run build",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"test:coverage": "vitest run --coverage"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"dist/",
|
|
23
|
+
"src/"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"ai",
|
|
28
|
+
"credentials",
|
|
29
|
+
"proxy",
|
|
30
|
+
"claude"
|
|
31
|
+
],
|
|
32
|
+
"license": "Apache-2.0",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
36
|
+
"typescript": "^5.7.0",
|
|
37
|
+
"vitest": "^3.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock all external dependencies
|
|
4
|
+
vi.mock("child_process", () => ({
|
|
5
|
+
execSync: vi.fn(),
|
|
6
|
+
spawnSync: vi.fn(),
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("../docker.js", () => ({
|
|
11
|
+
detectRuntime: vi.fn(),
|
|
12
|
+
getContainerStatus: vi.fn(),
|
|
13
|
+
isContainerRunning: vi.fn(),
|
|
14
|
+
buildRunCommand: vi.fn(),
|
|
15
|
+
buildStartCommand: vi.fn(),
|
|
16
|
+
buildStopCommand: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("../health.js", () => ({
|
|
20
|
+
waitForHealth: vi.fn(),
|
|
21
|
+
checkHealth: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../mcp-register.js", () => ({
|
|
25
|
+
registerMCP: vi.fn(),
|
|
26
|
+
isClaudeAvailable: vi.fn(),
|
|
27
|
+
manualRegistrationInstructions: vi.fn().mockReturnValue("Manual instructions"),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock open (browser opening)
|
|
31
|
+
vi.mock("../open.js", () => ({
|
|
32
|
+
openBrowser: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { execSync } from "child_process";
|
|
36
|
+
import {
|
|
37
|
+
detectRuntime,
|
|
38
|
+
getContainerStatus,
|
|
39
|
+
isContainerRunning,
|
|
40
|
+
buildRunCommand,
|
|
41
|
+
buildStartCommand,
|
|
42
|
+
buildStopCommand,
|
|
43
|
+
} from "../docker.js";
|
|
44
|
+
import { waitForHealth, checkHealth } from "../health.js";
|
|
45
|
+
import { registerMCP, isClaudeAvailable } from "../mcp-register.js";
|
|
46
|
+
import { openBrowser } from "../open.js";
|
|
47
|
+
|
|
48
|
+
import { runSetup } from "../commands/setup.js";
|
|
49
|
+
import { runStart } from "../commands/start.js";
|
|
50
|
+
import { runStop } from "../commands/stop.js";
|
|
51
|
+
import { runStatus } from "../commands/status.js";
|
|
52
|
+
|
|
53
|
+
const mockDetectRuntime = vi.mocked(detectRuntime);
|
|
54
|
+
const mockGetContainerStatus = vi.mocked(getContainerStatus);
|
|
55
|
+
const mockIsContainerRunning = vi.mocked(isContainerRunning);
|
|
56
|
+
const mockBuildRunCommand = vi.mocked(buildRunCommand);
|
|
57
|
+
const mockBuildStartCommand = vi.mocked(buildStartCommand);
|
|
58
|
+
const mockBuildStopCommand = vi.mocked(buildStopCommand);
|
|
59
|
+
const mockWaitForHealth = vi.mocked(waitForHealth);
|
|
60
|
+
const mockCheckHealth = vi.mocked(checkHealth);
|
|
61
|
+
const mockRegisterMCP = vi.mocked(registerMCP);
|
|
62
|
+
const mockIsClaudeAvailable = vi.mocked(isClaudeAvailable);
|
|
63
|
+
const mockOpenBrowser = vi.mocked(openBrowser);
|
|
64
|
+
const mockExecSync = vi.mocked(execSync);
|
|
65
|
+
|
|
66
|
+
const HEALTH_URL = "http://localhost:9470/api/v1/health";
|
|
67
|
+
const HEALTH_RESPONSE = { status: "ok", version: "0.1.0" };
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.resetAllMocks();
|
|
71
|
+
// Default: docker available, claude not available
|
|
72
|
+
mockDetectRuntime.mockReturnValue("docker");
|
|
73
|
+
mockBuildRunCommand.mockReturnValue(
|
|
74
|
+
"docker run -d --name straylight-ai -p 9470:9470 ghcr.io/aj-geddes/straylight-ai:latest"
|
|
75
|
+
);
|
|
76
|
+
mockBuildStartCommand.mockReturnValue("docker start straylight-ai");
|
|
77
|
+
mockBuildStopCommand.mockReturnValue("docker stop straylight-ai");
|
|
78
|
+
mockWaitForHealth.mockResolvedValue(HEALTH_RESPONSE);
|
|
79
|
+
mockCheckHealth.mockResolvedValue(HEALTH_RESPONSE);
|
|
80
|
+
mockRegisterMCP.mockResolvedValue(false);
|
|
81
|
+
mockIsClaudeAvailable.mockReturnValue(false);
|
|
82
|
+
mockOpenBrowser.mockResolvedValue(undefined);
|
|
83
|
+
mockExecSync.mockReturnValue(Buffer.from(""));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.restoreAllMocks();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("runSetup", () => {
|
|
91
|
+
it("errors when no container runtime is found", async () => {
|
|
92
|
+
mockDetectRuntime.mockReturnValue(null);
|
|
93
|
+
await expect(runSetup()).rejects.toThrow(/docker|podman/i);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("creates and starts container when not found", async () => {
|
|
97
|
+
mockGetContainerStatus.mockResolvedValue("not_found");
|
|
98
|
+
|
|
99
|
+
await runSetup();
|
|
100
|
+
|
|
101
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
102
|
+
expect.stringContaining("docker run"),
|
|
103
|
+
expect.any(Object)
|
|
104
|
+
);
|
|
105
|
+
expect(mockWaitForHealth).toHaveBeenCalledWith(HEALTH_URL, 30_000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("starts stopped container without re-creating it", async () => {
|
|
109
|
+
mockGetContainerStatus.mockResolvedValue("stopped");
|
|
110
|
+
|
|
111
|
+
await runSetup();
|
|
112
|
+
|
|
113
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
114
|
+
expect.stringContaining("docker start"),
|
|
115
|
+
expect.any(Object)
|
|
116
|
+
);
|
|
117
|
+
// Should NOT call docker run
|
|
118
|
+
const calls = mockExecSync.mock.calls.map((c) => c[0] as string);
|
|
119
|
+
expect(calls.some((c) => c.includes("docker run"))).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips container creation when already running", async () => {
|
|
123
|
+
mockGetContainerStatus.mockResolvedValue("running");
|
|
124
|
+
|
|
125
|
+
await runSetup();
|
|
126
|
+
|
|
127
|
+
// Should not call docker run or docker start
|
|
128
|
+
const calls = mockExecSync.mock.calls.map((c) => c[0] as string);
|
|
129
|
+
expect(calls.some((c) => c.includes("docker run"))).toBe(false);
|
|
130
|
+
expect(calls.some((c) => c.includes("docker start"))).toBe(false);
|
|
131
|
+
// Should still check health
|
|
132
|
+
expect(mockWaitForHealth).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("registers MCP when claude is available", async () => {
|
|
136
|
+
mockGetContainerStatus.mockResolvedValue("not_found");
|
|
137
|
+
mockRegisterMCP.mockResolvedValue(true);
|
|
138
|
+
|
|
139
|
+
await runSetup();
|
|
140
|
+
|
|
141
|
+
expect(mockRegisterMCP).toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("opens browser after health check passes", async () => {
|
|
145
|
+
mockGetContainerStatus.mockResolvedValue("not_found");
|
|
146
|
+
|
|
147
|
+
await runSetup();
|
|
148
|
+
|
|
149
|
+
expect(mockOpenBrowser).toHaveBeenCalledWith("http://localhost:9470");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("is idempotent: running twice does not create duplicate containers", async () => {
|
|
153
|
+
// First call: not found -> create
|
|
154
|
+
mockGetContainerStatus.mockResolvedValueOnce("not_found");
|
|
155
|
+
await runSetup();
|
|
156
|
+
const firstCallCount = mockExecSync.mock.calls.filter((c) =>
|
|
157
|
+
(c[0] as string).includes("docker run")
|
|
158
|
+
).length;
|
|
159
|
+
|
|
160
|
+
vi.resetAllMocks();
|
|
161
|
+
mockDetectRuntime.mockReturnValue("docker");
|
|
162
|
+
mockBuildRunCommand.mockReturnValue(
|
|
163
|
+
"docker run -d --name straylight-ai -p 9470:9470 ghcr.io/aj-geddes/straylight-ai:latest"
|
|
164
|
+
);
|
|
165
|
+
mockBuildStartCommand.mockReturnValue("docker start straylight-ai");
|
|
166
|
+
mockWaitForHealth.mockResolvedValue(HEALTH_RESPONSE);
|
|
167
|
+
mockRegisterMCP.mockResolvedValue(false);
|
|
168
|
+
mockOpenBrowser.mockResolvedValue(undefined);
|
|
169
|
+
mockExecSync.mockReturnValue(Buffer.from(""));
|
|
170
|
+
|
|
171
|
+
// Second call: already running -> skip create
|
|
172
|
+
mockGetContainerStatus.mockResolvedValueOnce("running");
|
|
173
|
+
await runSetup();
|
|
174
|
+
const secondCallCount = mockExecSync.mock.calls.filter((c) =>
|
|
175
|
+
(c[0] as string).includes("docker run")
|
|
176
|
+
).length;
|
|
177
|
+
|
|
178
|
+
expect(firstCallCount).toBe(1);
|
|
179
|
+
expect(secondCallCount).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("runStart", () => {
|
|
184
|
+
it("errors when no container runtime is found", async () => {
|
|
185
|
+
mockDetectRuntime.mockReturnValue(null);
|
|
186
|
+
await expect(runStart()).rejects.toThrow(/docker|podman/i);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("starts stopped container", async () => {
|
|
190
|
+
mockGetContainerStatus.mockResolvedValue("stopped");
|
|
191
|
+
|
|
192
|
+
await runStart();
|
|
193
|
+
|
|
194
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
195
|
+
expect.stringContaining("docker start"),
|
|
196
|
+
expect.any(Object)
|
|
197
|
+
);
|
|
198
|
+
expect(mockWaitForHealth).toHaveBeenCalledWith(HEALTH_URL, 30_000);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not restart already-running container", async () => {
|
|
202
|
+
mockGetContainerStatus.mockResolvedValue("running");
|
|
203
|
+
|
|
204
|
+
await runStart();
|
|
205
|
+
|
|
206
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("errors when container does not exist", async () => {
|
|
210
|
+
mockGetContainerStatus.mockResolvedValue("not_found");
|
|
211
|
+
await expect(runStart()).rejects.toThrow(/not found|setup/i);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("runStop", () => {
|
|
216
|
+
it("errors when no container runtime is found", async () => {
|
|
217
|
+
mockDetectRuntime.mockReturnValue(null);
|
|
218
|
+
await expect(runStop()).rejects.toThrow(/docker|podman/i);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("stops running container", async () => {
|
|
222
|
+
mockIsContainerRunning.mockResolvedValue(true);
|
|
223
|
+
|
|
224
|
+
await runStop();
|
|
225
|
+
|
|
226
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
227
|
+
expect.stringContaining("docker stop"),
|
|
228
|
+
expect.any(Object)
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("skips stop when container is not running", async () => {
|
|
233
|
+
mockIsContainerRunning.mockResolvedValue(false);
|
|
234
|
+
|
|
235
|
+
await runStop();
|
|
236
|
+
|
|
237
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("runStatus", () => {
|
|
242
|
+
it("errors when no container runtime is found", async () => {
|
|
243
|
+
mockDetectRuntime.mockReturnValue(null);
|
|
244
|
+
await expect(runStatus()).rejects.toThrow(/docker|podman/i);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("returns status with health info when container is running", async () => {
|
|
248
|
+
mockGetContainerStatus.mockResolvedValue("running");
|
|
249
|
+
|
|
250
|
+
const result = await runStatus();
|
|
251
|
+
|
|
252
|
+
expect(result.containerStatus).toBe("running");
|
|
253
|
+
expect(mockCheckHealth).toHaveBeenCalledWith(HEALTH_URL);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns status without health check when container is stopped", async () => {
|
|
257
|
+
mockGetContainerStatus.mockResolvedValue("stopped");
|
|
258
|
+
|
|
259
|
+
const result = await runStatus();
|
|
260
|
+
|
|
261
|
+
expect(result.containerStatus).toBe("stopped");
|
|
262
|
+
expect(mockCheckHealth).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns not_found status when container does not exist", async () => {
|
|
266
|
+
mockGetContainerStatus.mockResolvedValue("not_found");
|
|
267
|
+
|
|
268
|
+
const result = await runStatus();
|
|
269
|
+
|
|
270
|
+
expect(result.containerStatus).toBe("not_found");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock child_process before importing the module under test
|
|
4
|
+
vi.mock("child_process", () => ({
|
|
5
|
+
execSync: vi.fn(),
|
|
6
|
+
spawn: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import {
|
|
11
|
+
detectRuntime,
|
|
12
|
+
getContainerStatus,
|
|
13
|
+
isContainerRunning,
|
|
14
|
+
buildRunCommand,
|
|
15
|
+
buildStartCommand,
|
|
16
|
+
buildStopCommand,
|
|
17
|
+
} from "../docker.js";
|
|
18
|
+
|
|
19
|
+
const mockExecSync = vi.mocked(execSync);
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.resetAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("detectRuntime", () => {
|
|
30
|
+
it("returns docker when docker is available", () => {
|
|
31
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
32
|
+
const cmdStr = cmd as string;
|
|
33
|
+
if (cmdStr.includes("docker")) return Buffer.from("Docker version 24.0");
|
|
34
|
+
throw new Error("not found");
|
|
35
|
+
});
|
|
36
|
+
expect(detectRuntime()).toBe("docker");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns podman when only podman is available", () => {
|
|
40
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
41
|
+
const cmdStr = cmd as string;
|
|
42
|
+
if (cmdStr.includes("docker")) throw new Error("not found");
|
|
43
|
+
if (cmdStr.includes("podman")) return Buffer.from("podman version 4.0");
|
|
44
|
+
throw new Error("not found");
|
|
45
|
+
});
|
|
46
|
+
expect(detectRuntime()).toBe("podman");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns null when neither docker nor podman is available", () => {
|
|
50
|
+
mockExecSync.mockImplementation(() => {
|
|
51
|
+
throw new Error("command not found");
|
|
52
|
+
});
|
|
53
|
+
expect(detectRuntime()).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getContainerStatus", () => {
|
|
58
|
+
it("returns running when container is running", async () => {
|
|
59
|
+
mockExecSync.mockReturnValue(Buffer.from("running\n"));
|
|
60
|
+
const status = await getContainerStatus("docker");
|
|
61
|
+
expect(status).toBe("running");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns stopped when container is exited", async () => {
|
|
65
|
+
mockExecSync.mockReturnValue(Buffer.from("exited\n"));
|
|
66
|
+
const status = await getContainerStatus("docker");
|
|
67
|
+
expect(status).toBe("stopped");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns not_found when container does not exist", async () => {
|
|
71
|
+
mockExecSync.mockImplementation(() => {
|
|
72
|
+
throw new Error("No such container");
|
|
73
|
+
});
|
|
74
|
+
const status = await getContainerStatus("docker");
|
|
75
|
+
expect(status).toBe("not_found");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isContainerRunning", () => {
|
|
80
|
+
it("returns true when container status is running", async () => {
|
|
81
|
+
mockExecSync.mockReturnValue(Buffer.from("running\n"));
|
|
82
|
+
expect(await isContainerRunning("docker")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns false when container status is stopped", async () => {
|
|
86
|
+
mockExecSync.mockReturnValue(Buffer.from("exited\n"));
|
|
87
|
+
expect(await isContainerRunning("docker")).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns false when container does not exist", async () => {
|
|
91
|
+
mockExecSync.mockImplementation(() => {
|
|
92
|
+
throw new Error("No such container");
|
|
93
|
+
});
|
|
94
|
+
expect(await isContainerRunning("docker")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("buildRunCommand", () => {
|
|
99
|
+
it("builds a valid docker run command with required flags", () => {
|
|
100
|
+
const cmd = buildRunCommand("docker");
|
|
101
|
+
expect(cmd).toContain("docker run");
|
|
102
|
+
expect(cmd).toContain("-d");
|
|
103
|
+
expect(cmd).toContain("--name straylight-ai");
|
|
104
|
+
expect(cmd).toContain("-p 9470:9470");
|
|
105
|
+
expect(cmd).toContain("/data");
|
|
106
|
+
expect(cmd).toContain("--restart unless-stopped");
|
|
107
|
+
expect(cmd).toContain("ghcr.io/aj-geddes/straylight-ai:latest");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("builds a valid podman run command", () => {
|
|
111
|
+
const cmd = buildRunCommand("podman");
|
|
112
|
+
expect(cmd).toContain("podman run");
|
|
113
|
+
expect(cmd).toContain("--name straylight-ai");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("buildStartCommand", () => {
|
|
118
|
+
it("builds a docker start command", () => {
|
|
119
|
+
const cmd = buildStartCommand("docker");
|
|
120
|
+
expect(cmd).toBe("docker start straylight-ai");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("builds a podman start command", () => {
|
|
124
|
+
const cmd = buildStartCommand("podman");
|
|
125
|
+
expect(cmd).toBe("podman start straylight-ai");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("buildStopCommand", () => {
|
|
130
|
+
it("builds a docker stop command", () => {
|
|
131
|
+
const cmd = buildStopCommand("docker");
|
|
132
|
+
expect(cmd).toBe("docker stop straylight-ai");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("builds a podman stop command", () => {
|
|
136
|
+
const cmd = buildStopCommand("podman");
|
|
137
|
+
expect(cmd).toBe("podman stop straylight-ai");
|
|
138
|
+
});
|
|
139
|
+
});
|