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.
Files changed (48) hide show
  1. package/bin/cli.js +39 -0
  2. package/bin/mcp-shim.js +85 -0
  3. package/dist/commands/setup.d.ts +9 -0
  4. package/dist/commands/setup.d.ts.map +1 -0
  5. package/dist/commands/setup.js +61 -0
  6. package/dist/commands/setup.js.map +1 -0
  7. package/dist/commands/start.d.ts +6 -0
  8. package/dist/commands/start.d.ts.map +1 -0
  9. package/dist/commands/start.js +33 -0
  10. package/dist/commands/start.js.map +1 -0
  11. package/dist/commands/status.d.ts +11 -0
  12. package/dist/commands/status.d.ts.map +1 -0
  13. package/dist/commands/status.js +40 -0
  14. package/dist/commands/status.js.map +1 -0
  15. package/dist/commands/stop.d.ts +6 -0
  16. package/dist/commands/stop.d.ts.map +1 -0
  17. package/dist/commands/stop.js +25 -0
  18. package/dist/commands/stop.js.map +1 -0
  19. package/dist/docker.d.ts +42 -0
  20. package/dist/docker.d.ts.map +1 -0
  21. package/dist/docker.js +126 -0
  22. package/dist/docker.js.map +1 -0
  23. package/dist/health.d.ts +22 -0
  24. package/dist/health.d.ts.map +1 -0
  25. package/dist/health.js +44 -0
  26. package/dist/health.js.map +1 -0
  27. package/dist/mcp-register.d.ts +19 -0
  28. package/dist/mcp-register.d.ts.map +1 -0
  29. package/dist/mcp-register.js +59 -0
  30. package/dist/mcp-register.js.map +1 -0
  31. package/dist/open.d.ts +6 -0
  32. package/dist/open.d.ts.map +1 -0
  33. package/dist/open.js +70 -0
  34. package/dist/open.js.map +1 -0
  35. package/package.json +39 -0
  36. package/src/__tests__/commands.test.ts +272 -0
  37. package/src/__tests__/docker.test.ts +139 -0
  38. package/src/__tests__/health.test.ts +107 -0
  39. package/src/__tests__/mcp-register.test.ts +123 -0
  40. package/src/__tests__/open.test.ts +61 -0
  41. package/src/commands/setup.ts +72 -0
  42. package/src/commands/start.ts +44 -0
  43. package/src/commands/status.ts +51 -0
  44. package/src/commands/stop.ts +31 -0
  45. package/src/docker.ts +102 -0
  46. package/src/health.ts +55 -0
  47. package/src/mcp-register.ts +62 -0
  48. package/src/open.ts +33 -0
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // Mock global fetch
4
+ const mockFetch = vi.fn();
5
+ vi.stubGlobal("fetch", mockFetch);
6
+
7
+ import { waitForHealth, checkHealth } from "../health.js";
8
+
9
+ beforeEach(() => {
10
+ vi.resetAllMocks();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ describe("checkHealth", () => {
18
+ it("returns health response when endpoint is healthy", async () => {
19
+ const healthData = { status: "ok", version: "0.1.0" };
20
+ mockFetch.mockResolvedValueOnce({
21
+ ok: true,
22
+ json: async () => healthData,
23
+ });
24
+
25
+ const result = await checkHealth("http://localhost:9470/api/v1/health");
26
+ expect(result).toEqual(healthData);
27
+ });
28
+
29
+ it("throws when fetch returns non-ok status", async () => {
30
+ mockFetch.mockResolvedValueOnce({
31
+ ok: false,
32
+ status: 503,
33
+ });
34
+
35
+ await expect(
36
+ checkHealth("http://localhost:9470/api/v1/health")
37
+ ).rejects.toThrow();
38
+ });
39
+
40
+ it("throws when fetch rejects (connection refused)", async () => {
41
+ mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
42
+
43
+ await expect(
44
+ checkHealth("http://localhost:9470/api/v1/health")
45
+ ).rejects.toThrow("ECONNREFUSED");
46
+ });
47
+ });
48
+
49
+ describe("waitForHealth", () => {
50
+ it("resolves immediately when health check passes on first attempt", async () => {
51
+ const healthData = { status: "ok", version: "0.1.0" };
52
+ mockFetch.mockResolvedValue({
53
+ ok: true,
54
+ json: async () => healthData,
55
+ });
56
+
57
+ const result = await waitForHealth(
58
+ "http://localhost:9470/api/v1/health",
59
+ 30_000
60
+ );
61
+ expect(result).toEqual(healthData);
62
+ expect(mockFetch).toHaveBeenCalledTimes(1);
63
+ });
64
+
65
+ it("retries until health check passes within timeout", async () => {
66
+ const healthData = { status: "ok", version: "0.1.0" };
67
+ // Fail 2 times, then succeed
68
+ mockFetch
69
+ .mockRejectedValueOnce(new Error("ECONNREFUSED"))
70
+ .mockRejectedValueOnce(new Error("ECONNREFUSED"))
71
+ .mockResolvedValue({
72
+ ok: true,
73
+ json: async () => healthData,
74
+ });
75
+
76
+ // Use fake timers so we don't actually wait 2 seconds
77
+ vi.useFakeTimers();
78
+ const promise = waitForHealth(
79
+ "http://localhost:9470/api/v1/health",
80
+ 30_000
81
+ );
82
+ // Advance past the sleep intervals
83
+ await vi.runAllTimersAsync();
84
+ const result = await promise;
85
+ vi.useRealTimers();
86
+
87
+ expect(result).toEqual(healthData);
88
+ expect(mockFetch).toHaveBeenCalledTimes(3);
89
+ });
90
+
91
+ it("rejects when health check never passes within timeout", async () => {
92
+ mockFetch.mockRejectedValue(new Error("ECONNREFUSED"));
93
+
94
+ vi.useFakeTimers();
95
+
96
+ // Attach rejection handler before advancing timers so the rejection
97
+ // is never unhandled.
98
+ const promise = waitForHealth("http://localhost:9470/api/v1/health", 1_000);
99
+ const rejection = expect(promise).rejects.toThrow(/timed out|timeout/i);
100
+
101
+ // Advance time past the deadline and flush all microtasks
102
+ await vi.advanceTimersByTimeAsync(2_000);
103
+ vi.useRealTimers();
104
+
105
+ await rejection;
106
+ });
107
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ vi.mock("child_process", () => ({
4
+ execSync: vi.fn(),
5
+ spawnSync: vi.fn(),
6
+ }));
7
+
8
+ import { execSync, spawnSync } from "child_process";
9
+ import {
10
+ registerMCP,
11
+ isClaudeAvailable,
12
+ manualRegistrationInstructions,
13
+ } from "../mcp-register.js";
14
+
15
+ const mockExecSync = vi.mocked(execSync);
16
+ const mockSpawnSync = vi.mocked(spawnSync);
17
+
18
+ beforeEach(() => {
19
+ vi.resetAllMocks();
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ describe("isClaudeAvailable", () => {
27
+ it("returns true when claude CLI is available", () => {
28
+ mockExecSync.mockReturnValue(Buffer.from("claude version 1.0.0"));
29
+ expect(isClaudeAvailable()).toBe(true);
30
+ });
31
+
32
+ it("returns false when claude CLI is not found", () => {
33
+ mockExecSync.mockImplementation(() => {
34
+ throw new Error("command not found: claude");
35
+ });
36
+ expect(isClaudeAvailable()).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe("registerMCP", () => {
41
+ it("returns true and registers MCP when claude is available", async () => {
42
+ // First call: claude --version succeeds
43
+ mockExecSync.mockReturnValue(Buffer.from("claude version 1.0.0"));
44
+ // spawnSync for the mcp add command
45
+ mockSpawnSync.mockReturnValue({
46
+ status: 0,
47
+ stdout: Buffer.from(""),
48
+ stderr: Buffer.from(""),
49
+ pid: 1234,
50
+ output: [],
51
+ signal: null,
52
+ });
53
+
54
+ const result = await registerMCP();
55
+ expect(result).toBe(true);
56
+ expect(mockSpawnSync).toHaveBeenCalledWith(
57
+ "claude",
58
+ expect.arrayContaining(["mcp", "add"]),
59
+ expect.any(Object)
60
+ );
61
+ });
62
+
63
+ it("returns false when claude CLI is not available", async () => {
64
+ mockExecSync.mockImplementation(() => {
65
+ throw new Error("command not found: claude");
66
+ });
67
+
68
+ const result = await registerMCP();
69
+ expect(result).toBe(false);
70
+ expect(mockSpawnSync).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("returns false when mcp add command fails", async () => {
74
+ mockExecSync.mockReturnValue(Buffer.from("claude version 1.0.0"));
75
+ mockSpawnSync.mockReturnValue({
76
+ status: 1,
77
+ stdout: Buffer.from(""),
78
+ stderr: Buffer.from("error: already exists"),
79
+ pid: 1234,
80
+ output: [],
81
+ signal: null,
82
+ });
83
+
84
+ const result = await registerMCP();
85
+ expect(result).toBe(false);
86
+ });
87
+
88
+ it("includes correct arguments in the registration command", async () => {
89
+ mockExecSync.mockReturnValue(Buffer.from("claude version 1.0.0"));
90
+ mockSpawnSync.mockReturnValue({
91
+ status: 0,
92
+ stdout: Buffer.from(""),
93
+ stderr: Buffer.from(""),
94
+ pid: 1234,
95
+ output: [],
96
+ signal: null,
97
+ });
98
+
99
+ await registerMCP();
100
+
101
+ const args = mockSpawnSync.mock.calls[0][1] as string[];
102
+ expect(args).toContain("mcp");
103
+ expect(args).toContain("add");
104
+ expect(args).toContain("straylight-ai");
105
+ expect(args).toContain("--transport");
106
+ expect(args).toContain("stdio");
107
+ });
108
+ });
109
+
110
+ describe("manualRegistrationInstructions", () => {
111
+ it("returns a non-empty string with the registration command", () => {
112
+ const instructions = manualRegistrationInstructions();
113
+ expect(typeof instructions).toBe("string");
114
+ expect(instructions.length).toBeGreaterThan(0);
115
+ });
116
+
117
+ it("includes the claude mcp add command", () => {
118
+ const instructions = manualRegistrationInstructions();
119
+ expect(instructions).toContain("claude mcp add");
120
+ expect(instructions).toContain("straylight-ai");
121
+ expect(instructions).toContain("--transport stdio");
122
+ });
123
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ vi.mock("child_process", () => ({
4
+ spawn: vi.fn(),
5
+ }));
6
+
7
+ import { spawn } from "child_process";
8
+ import { openBrowser } from "../open.js";
9
+
10
+ const mockSpawn = vi.mocked(spawn);
11
+
12
+ /** Creates a minimal EventEmitter-like mock child process. */
13
+ function makeChildMock() {
14
+ return {
15
+ unref: vi.fn(),
16
+ on: vi.fn(),
17
+ };
18
+ }
19
+
20
+ beforeEach(() => {
21
+ vi.resetAllMocks();
22
+ });
23
+
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ describe("openBrowser", () => {
29
+ it("calls spawn with the URL and detached option", async () => {
30
+ const child = makeChildMock();
31
+ // Return typed mock - cast needed because spawn returns full ChildProcess
32
+ mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
33
+
34
+ await openBrowser("http://localhost:9470");
35
+
36
+ expect(mockSpawn).toHaveBeenCalledWith(
37
+ expect.any(String),
38
+ ["http://localhost:9470"],
39
+ expect.objectContaining({ detached: true })
40
+ );
41
+ });
42
+
43
+ it("calls unref on the child process to detach it", async () => {
44
+ const child = makeChildMock();
45
+ mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
46
+
47
+ await openBrowser("http://localhost:9470");
48
+
49
+ expect(child.unref).toHaveBeenCalled();
50
+ });
51
+
52
+ it("resolves without error even when spawn fails", async () => {
53
+ // spawn throws synchronously
54
+ mockSpawn.mockImplementation(() => {
55
+ throw new Error("spawn ENOENT");
56
+ });
57
+
58
+ // Should not throw (best-effort open)
59
+ await expect(openBrowser("http://localhost:9470")).resolves.toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,72 @@
1
+ import { execSync } from "child_process";
2
+ import {
3
+ detectRuntime,
4
+ getContainerStatus,
5
+ buildRunCommand,
6
+ buildStartCommand,
7
+ } from "../docker.js";
8
+ import { waitForHealth } from "../health.js";
9
+ import { registerMCP, manualRegistrationInstructions } from "../mcp-register.js";
10
+ import { openBrowser } from "../open.js";
11
+
12
+ const HEALTH_URL = "http://localhost:9470/api/v1/health";
13
+ const HEALTH_TIMEOUT_MS = 30_000;
14
+ const UI_URL = "http://localhost:9470";
15
+
16
+ /**
17
+ * Full bootstrap: pull image if needed, create/start container, wait for
18
+ * health check, register MCP server, and open the browser.
19
+ *
20
+ * This operation is idempotent: calling it when the container is already
21
+ * running will skip the create/start steps and go straight to health + open.
22
+ */
23
+ export async function runSetup(): Promise<void> {
24
+ const runtime = detectRuntime();
25
+ if (!runtime) {
26
+ throw new Error(
27
+ "Neither Docker nor Podman was found on your PATH.\n" +
28
+ "Install Docker Desktop: https://docs.docker.com/get-docker/\n" +
29
+ "or Podman: https://podman.io/getting-started/installation"
30
+ );
31
+ }
32
+
33
+ console.log(`Using container runtime: ${runtime}`);
34
+
35
+ const status = await getContainerStatus(runtime);
36
+
37
+ if (status === "not_found") {
38
+ console.log("Creating and starting Straylight-AI container...");
39
+ execSync(buildRunCommand(runtime), { stdio: "inherit" });
40
+ } else if (status === "stopped") {
41
+ console.log("Starting existing Straylight-AI container...");
42
+ execSync(buildStartCommand(runtime), { stdio: "inherit" });
43
+ } else {
44
+ console.log("Straylight-AI container is already running.");
45
+ }
46
+
47
+ console.log("Waiting for Straylight-AI to be ready...");
48
+ await waitForHealth(HEALTH_URL, HEALTH_TIMEOUT_MS);
49
+ console.log("Straylight-AI is ready.");
50
+
51
+ const registered = await registerMCP();
52
+ if (registered) {
53
+ console.log("MCP server registered with Claude Code.");
54
+ } else {
55
+ console.log(manualRegistrationInstructions());
56
+ }
57
+
58
+ await openBrowser(UI_URL);
59
+
60
+ console.log(
61
+ [
62
+ "",
63
+ "Straylight-AI is running at http://localhost:9470",
64
+ "",
65
+ "Next steps:",
66
+ " 1. Open http://localhost:9470 in your browser",
67
+ " 2. Add your service credentials via the Web UI",
68
+ " 3. Use Claude Code with the straylight-ai MCP server",
69
+ "",
70
+ ].join("\n")
71
+ );
72
+ }
@@ -0,0 +1,44 @@
1
+ import { execSync } from "child_process";
2
+ import {
3
+ detectRuntime,
4
+ getContainerStatus,
5
+ buildStartCommand,
6
+ } from "../docker.js";
7
+ import { waitForHealth } from "../health.js";
8
+
9
+ const HEALTH_URL = "http://localhost:9470/api/v1/health";
10
+ const HEALTH_TIMEOUT_MS = 30_000;
11
+
12
+ /**
13
+ * Start an existing stopped container and wait for health.
14
+ * Errors if the container does not exist (use `setup` instead).
15
+ */
16
+ export async function runStart(): Promise<void> {
17
+ const runtime = detectRuntime();
18
+ if (!runtime) {
19
+ throw new Error(
20
+ "Neither Docker nor Podman was found on your PATH.\n" +
21
+ "Install Docker Desktop: https://docs.docker.com/get-docker/"
22
+ );
23
+ }
24
+
25
+ const status = await getContainerStatus(runtime);
26
+
27
+ if (status === "not_found") {
28
+ throw new Error(
29
+ "Straylight-AI container not found. Run `npx straylight-ai setup` first."
30
+ );
31
+ }
32
+
33
+ if (status === "running") {
34
+ console.log("Straylight-AI is already running.");
35
+ return;
36
+ }
37
+
38
+ console.log("Starting Straylight-AI...");
39
+ execSync(buildStartCommand(runtime), { stdio: "inherit" });
40
+
41
+ console.log("Waiting for Straylight-AI to be ready...");
42
+ await waitForHealth(HEALTH_URL, HEALTH_TIMEOUT_MS);
43
+ console.log("Straylight-AI is ready at http://localhost:9470");
44
+ }
@@ -0,0 +1,51 @@
1
+ import { detectRuntime, getContainerStatus } from "../docker.js";
2
+ import { checkHealth, HealthResponse } from "../health.js";
3
+
4
+ const HEALTH_URL = "http://localhost:9470/api/v1/health";
5
+
6
+ /** Result returned by runStatus */
7
+ export interface StatusResult {
8
+ containerStatus: "running" | "stopped" | "not_found";
9
+ health?: HealthResponse;
10
+ }
11
+
12
+ /**
13
+ * Check the container status and, if running, fetch and display health info.
14
+ */
15
+ export async function runStatus(): Promise<StatusResult> {
16
+ const runtime = detectRuntime();
17
+ if (!runtime) {
18
+ throw new Error(
19
+ "Neither Docker nor Podman was found on your PATH.\n" +
20
+ "Install Docker Desktop: https://docs.docker.com/get-docker/"
21
+ );
22
+ }
23
+
24
+ const containerStatus = await getContainerStatus(runtime);
25
+
26
+ const result: StatusResult = { containerStatus };
27
+
28
+ if (containerStatus === "running") {
29
+ try {
30
+ result.health = await checkHealth(HEALTH_URL);
31
+ console.log("Straylight-AI is running.");
32
+ console.log(` Status: ${result.health.status}`);
33
+ if (result.health.version) {
34
+ console.log(` Version: ${result.health.version}`);
35
+ }
36
+ console.log(" URL: http://localhost:9470");
37
+ } catch {
38
+ console.log("Straylight-AI container is running but not yet healthy.");
39
+ }
40
+ } else if (containerStatus === "stopped") {
41
+ console.log(
42
+ 'Straylight-AI container is stopped. Run `npx straylight-ai start` to start it.'
43
+ );
44
+ } else {
45
+ console.log(
46
+ 'Straylight-AI is not installed. Run `npx straylight-ai setup` to get started.'
47
+ );
48
+ }
49
+
50
+ return result;
51
+ }
@@ -0,0 +1,31 @@
1
+ import { execSync } from "child_process";
2
+ import {
3
+ detectRuntime,
4
+ isContainerRunning,
5
+ buildStopCommand,
6
+ } from "../docker.js";
7
+
8
+ /**
9
+ * Stop the running Straylight-AI container.
10
+ * No-op if the container is not currently running.
11
+ */
12
+ export async function runStop(): Promise<void> {
13
+ const runtime = detectRuntime();
14
+ if (!runtime) {
15
+ throw new Error(
16
+ "Neither Docker nor Podman was found on your PATH.\n" +
17
+ "Install Docker Desktop: https://docs.docker.com/get-docker/"
18
+ );
19
+ }
20
+
21
+ const running = await isContainerRunning(runtime);
22
+
23
+ if (!running) {
24
+ console.log("Straylight-AI is not currently running.");
25
+ return;
26
+ }
27
+
28
+ console.log("Stopping Straylight-AI...");
29
+ execSync(buildStopCommand(runtime), { stdio: "inherit" });
30
+ console.log("Straylight-AI stopped.");
31
+ }
package/src/docker.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { execSync } from "child_process";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ /** Name of the managed container */
6
+ export const CONTAINER_NAME = "straylight-ai";
7
+
8
+ /** Docker image to pull and run */
9
+ export const CONTAINER_IMAGE = "ghcr.io/aj-geddes/straylight-ai:latest";
10
+
11
+ /** Host port mapped to container port 9470 */
12
+ export const CONTAINER_PORT = 9470;
13
+
14
+ /** Host data directory mounted at /data inside the container */
15
+ export const DATA_DIR = path.join(os.homedir(), ".straylight-ai", "data");
16
+
17
+ /** Container status values */
18
+ export type ContainerStatus = "running" | "stopped" | "not_found";
19
+
20
+ /** Supported container runtimes */
21
+ export type Runtime = "docker" | "podman";
22
+
23
+ /**
24
+ * Detect whether docker or podman is available on the host.
25
+ * Returns the first available runtime, or null if neither is found.
26
+ */
27
+ export function detectRuntime(): Runtime | null {
28
+ for (const runtime of ["docker", "podman"] as Runtime[]) {
29
+ try {
30
+ execSync(`${runtime} --version`, { stdio: "pipe" });
31
+ return runtime;
32
+ } catch {
33
+ // try the next one
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Get the status of the straylight-ai container.
41
+ */
42
+ export async function getContainerStatus(
43
+ runtime: string
44
+ ): Promise<ContainerStatus> {
45
+ try {
46
+ const output = execSync(
47
+ `${runtime} inspect --format "{{.State.Status}}" ${CONTAINER_NAME}`,
48
+ { stdio: "pipe" }
49
+ )
50
+ .toString()
51
+ .trim()
52
+ .replace(/^"|"$/g, ""); // strip surrounding quotes if present
53
+
54
+ if (output === "running") return "running";
55
+ return "stopped";
56
+ } catch {
57
+ return "not_found";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns true when the container is currently running.
63
+ */
64
+ export async function isContainerRunning(runtime: string): Promise<boolean> {
65
+ return (await getContainerStatus(runtime)) === "running";
66
+ }
67
+
68
+ /**
69
+ * Build the docker/podman run command string.
70
+ */
71
+ export function buildRunCommand(runtime: string): string {
72
+ return [
73
+ `${runtime} run`,
74
+ "-d",
75
+ `--name ${CONTAINER_NAME}`,
76
+ `-p ${CONTAINER_PORT}:${CONTAINER_PORT}`,
77
+ `-v ${DATA_DIR}:/data`,
78
+ "--restart unless-stopped",
79
+ CONTAINER_IMAGE,
80
+ ].join(" ");
81
+ }
82
+
83
+ /**
84
+ * Build the docker/podman start command string.
85
+ */
86
+ export function buildStartCommand(runtime: string): string {
87
+ return `${runtime} start ${CONTAINER_NAME}`;
88
+ }
89
+
90
+ /**
91
+ * Build the docker/podman stop command string.
92
+ */
93
+ export function buildStopCommand(runtime: string): string {
94
+ return `${runtime} stop ${CONTAINER_NAME}`;
95
+ }
96
+
97
+ /**
98
+ * Pull the container image.
99
+ */
100
+ export function pullImage(runtime: string): void {
101
+ execSync(`${runtime} pull ${CONTAINER_IMAGE}`, { stdio: "inherit" });
102
+ }
package/src/health.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** Response shape from the health endpoint */
2
+ export interface HealthResponse {
3
+ status: string;
4
+ version?: string;
5
+ [key: string]: unknown;
6
+ }
7
+
8
+ /** Interval between health-check attempts in milliseconds */
9
+ const POLL_INTERVAL_MS = 1_000;
10
+
11
+ /**
12
+ * Perform a single health check against the given URL.
13
+ * Throws if the endpoint is unreachable or returns a non-ok HTTP status.
14
+ */
15
+ export async function checkHealth(url: string): Promise<HealthResponse> {
16
+ const response = await fetch(url);
17
+ if (!response.ok) {
18
+ throw new Error(`Health check failed with HTTP ${response.status}`);
19
+ }
20
+ return (await response.json()) as HealthResponse;
21
+ }
22
+
23
+ /**
24
+ * Poll the health endpoint until it returns a successful response or the
25
+ * timeout elapses.
26
+ *
27
+ * @param url Full URL of the health endpoint.
28
+ * @param timeoutMs Maximum time to wait in milliseconds.
29
+ * @returns The first successful HealthResponse.
30
+ * @throws Error when the timeout is reached without a successful response.
31
+ */
32
+ export async function waitForHealth(
33
+ url: string,
34
+ timeoutMs: number
35
+ ): Promise<HealthResponse> {
36
+ const deadline = Date.now() + timeoutMs;
37
+
38
+ while (Date.now() < deadline) {
39
+ try {
40
+ return await checkHealth(url);
41
+ } catch {
42
+ // Not ready yet — wait before retrying
43
+ await sleep(POLL_INTERVAL_MS);
44
+ }
45
+ }
46
+
47
+ throw new Error(
48
+ `Health check timed out after ${timeoutMs}ms. ` +
49
+ `Is the container running at ${url}?`
50
+ );
51
+ }
52
+
53
+ function sleep(ms: number): Promise<void> {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }