@hellcoder/companion 0.96.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.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import * as cronStore from "../cron-store.js";
|
|
3
|
+
import type { CronScheduler } from "../cron-scheduler.js";
|
|
4
|
+
|
|
5
|
+
export function registerCronRoutes(
|
|
6
|
+
api: Hono,
|
|
7
|
+
cronScheduler?: CronScheduler,
|
|
8
|
+
): void {
|
|
9
|
+
api.get("/cron/jobs", (c) => {
|
|
10
|
+
const jobs = cronStore.listJobs();
|
|
11
|
+
const enriched = jobs.map((j) => ({
|
|
12
|
+
...j,
|
|
13
|
+
nextRunAt: cronScheduler?.getNextRunTime(j.id)?.getTime() ?? null,
|
|
14
|
+
}));
|
|
15
|
+
return c.json(enriched);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
api.get("/cron/jobs/:id", (c) => {
|
|
19
|
+
const job = cronStore.getJob(c.req.param("id"));
|
|
20
|
+
if (!job) return c.json({ error: "Job not found" }, 404);
|
|
21
|
+
return c.json({
|
|
22
|
+
...job,
|
|
23
|
+
nextRunAt: cronScheduler?.getNextRunTime(job.id)?.getTime() ?? null,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
api.post("/cron/jobs", async (c) => {
|
|
28
|
+
const body = await c.req.json().catch(() => ({}));
|
|
29
|
+
try {
|
|
30
|
+
const job = cronStore.createJob({
|
|
31
|
+
name: body.name || "",
|
|
32
|
+
prompt: body.prompt || "",
|
|
33
|
+
schedule: body.schedule || "",
|
|
34
|
+
recurring: body.recurring ?? true,
|
|
35
|
+
backendType: body.backendType || "claude",
|
|
36
|
+
model: body.model || "",
|
|
37
|
+
cwd: body.cwd || "",
|
|
38
|
+
envSlug: body.envSlug,
|
|
39
|
+
enabled: body.enabled ?? true,
|
|
40
|
+
permissionMode: body.permissionMode || "bypassPermissions",
|
|
41
|
+
codexInternetAccess: body.codexInternetAccess,
|
|
42
|
+
});
|
|
43
|
+
if (job.enabled) cronScheduler?.scheduleJob(job);
|
|
44
|
+
return c.json(job, 201);
|
|
45
|
+
} catch (e: unknown) {
|
|
46
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
api.put("/cron/jobs/:id", async (c) => {
|
|
51
|
+
const id = c.req.param("id");
|
|
52
|
+
const body = await c.req.json().catch(() => ({}));
|
|
53
|
+
try {
|
|
54
|
+
// Only allow user-editable fields — prevent tampering with internal tracking
|
|
55
|
+
const allowed: Record<string, unknown> = {};
|
|
56
|
+
for (const key of ["name", "prompt", "schedule", "recurring", "backendType", "model", "cwd", "envSlug", "enabled", "permissionMode", "codexInternetAccess"] as const) {
|
|
57
|
+
if (key in body) allowed[key] = body[key];
|
|
58
|
+
}
|
|
59
|
+
const job = cronStore.updateJob(id, allowed);
|
|
60
|
+
if (!job) return c.json({ error: "Job not found" }, 404);
|
|
61
|
+
// Stop the old timer (id may differ from job.id after a rename)
|
|
62
|
+
if (job.id !== id) cronScheduler?.stopJob(id);
|
|
63
|
+
cronScheduler?.scheduleJob(job);
|
|
64
|
+
return c.json(job);
|
|
65
|
+
} catch (e: unknown) {
|
|
66
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
api.delete("/cron/jobs/:id", (c) => {
|
|
71
|
+
const id = c.req.param("id");
|
|
72
|
+
cronScheduler?.stopJob(id);
|
|
73
|
+
const deleted = cronStore.deleteJob(id);
|
|
74
|
+
if (!deleted) return c.json({ error: "Job not found" }, 404);
|
|
75
|
+
return c.json({ ok: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
api.post("/cron/jobs/:id/toggle", (c) => {
|
|
79
|
+
const id = c.req.param("id");
|
|
80
|
+
const job = cronStore.getJob(id);
|
|
81
|
+
if (!job) return c.json({ error: "Job not found" }, 404);
|
|
82
|
+
const updated = cronStore.updateJob(id, { enabled: !job.enabled });
|
|
83
|
+
if (updated?.enabled) {
|
|
84
|
+
cronScheduler?.scheduleJob(updated);
|
|
85
|
+
} else {
|
|
86
|
+
cronScheduler?.stopJob(id);
|
|
87
|
+
}
|
|
88
|
+
return c.json(updated);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
api.post("/cron/jobs/:id/run", (c) => {
|
|
92
|
+
const id = c.req.param("id");
|
|
93
|
+
const job = cronStore.getJob(id);
|
|
94
|
+
if (!job) return c.json({ error: "Job not found" }, 404);
|
|
95
|
+
cronScheduler?.executeJobManually(id);
|
|
96
|
+
return c.json({ ok: true, message: "Job triggered" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
api.get("/cron/jobs/:id/executions", (c) => {
|
|
100
|
+
const id = c.req.param("id");
|
|
101
|
+
return c.json(cronScheduler?.getExecutions(id) ?? []);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock env-manager ──────────────────────────────────────────────────────
|
|
4
|
+
vi.mock("../env-manager.js", () => ({
|
|
5
|
+
listEnvs: vi.fn(() => []),
|
|
6
|
+
getEnv: vi.fn(() => null),
|
|
7
|
+
createEnv: vi.fn(),
|
|
8
|
+
updateEnv: vi.fn(),
|
|
9
|
+
deleteEnv: vi.fn(() => false),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// ─── Mock container-manager ────────────────────────────────────────────────
|
|
13
|
+
vi.mock("../container-manager.js", () => ({
|
|
14
|
+
containerManager: {
|
|
15
|
+
checkDocker: vi.fn(() => true),
|
|
16
|
+
buildImageStreaming: vi.fn(async () => ({ success: true, log: "Built" })),
|
|
17
|
+
buildImage: vi.fn(() => "ok"),
|
|
18
|
+
imageExists: vi.fn(() => false),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// ─── Mock image-pull-manager ───────────────────────────────────────────────
|
|
23
|
+
vi.mock("../image-pull-manager.js", () => ({
|
|
24
|
+
imagePullManager: {
|
|
25
|
+
getState: vi.fn((tag: string) => ({ image: tag, status: "idle", progress: [] })),
|
|
26
|
+
pull: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// ─── Mock node:fs (only existsSync is used by the route) ──────────────────
|
|
31
|
+
vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) }));
|
|
32
|
+
|
|
33
|
+
import { Hono } from "hono";
|
|
34
|
+
import * as envManager from "../env-manager.js";
|
|
35
|
+
import { containerManager } from "../container-manager.js";
|
|
36
|
+
import { imagePullManager } from "../image-pull-manager.js";
|
|
37
|
+
import { existsSync } from "node:fs";
|
|
38
|
+
import { registerEnvRoutes } from "./env-routes.js";
|
|
39
|
+
|
|
40
|
+
// ─── Test setup ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
let app: Hono;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
|
|
47
|
+
app = new Hono();
|
|
48
|
+
const api = new Hono();
|
|
49
|
+
registerEnvRoutes(api, { webDir: "/fake/web" });
|
|
50
|
+
app.route("/api", api);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** Minimal env fixture matching the CompanionEnv shape. */
|
|
56
|
+
function makeEnv(overrides: Record<string, unknown> = {}) {
|
|
57
|
+
return {
|
|
58
|
+
name: "Test Env",
|
|
59
|
+
slug: "test-env",
|
|
60
|
+
variables: { FOO: "bar" },
|
|
61
|
+
createdAt: 1000,
|
|
62
|
+
updatedAt: 2000,
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
68
|
+
// GET /api/envs
|
|
69
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
describe("GET /api/envs", () => {
|
|
72
|
+
it("returns an empty list when no environments exist", async () => {
|
|
73
|
+
vi.mocked(envManager.listEnvs).mockReturnValue([]);
|
|
74
|
+
|
|
75
|
+
const res = await app.request("/api/envs");
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
expect(await res.json()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns a list of environments", async () => {
|
|
82
|
+
const envs = [makeEnv(), makeEnv({ slug: "second", name: "Second" })];
|
|
83
|
+
vi.mocked(envManager.listEnvs).mockReturnValue(envs as any);
|
|
84
|
+
|
|
85
|
+
const res = await app.request("/api/envs");
|
|
86
|
+
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
const json = await res.json();
|
|
89
|
+
expect(json).toHaveLength(2);
|
|
90
|
+
expect(json[0].slug).toBe("test-env");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns 500 when listEnvs throws", async () => {
|
|
94
|
+
vi.mocked(envManager.listEnvs).mockImplementation(() => {
|
|
95
|
+
throw new Error("disk failure");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const res = await app.request("/api/envs");
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(500);
|
|
101
|
+
const json = await res.json();
|
|
102
|
+
expect(json.error).toBe("disk failure");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
107
|
+
// GET /api/envs/:slug
|
|
108
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
describe("GET /api/envs/:slug", () => {
|
|
111
|
+
it("returns the environment when it exists", async () => {
|
|
112
|
+
const env = makeEnv();
|
|
113
|
+
vi.mocked(envManager.getEnv).mockReturnValue(env as any);
|
|
114
|
+
|
|
115
|
+
const res = await app.request("/api/envs/test-env");
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
expect(await res.json()).toEqual(env);
|
|
119
|
+
expect(envManager.getEnv).toHaveBeenCalledWith("test-env");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns 404 when the environment does not exist", async () => {
|
|
123
|
+
vi.mocked(envManager.getEnv).mockReturnValue(null as any);
|
|
124
|
+
|
|
125
|
+
const res = await app.request("/api/envs/missing");
|
|
126
|
+
|
|
127
|
+
expect(res.status).toBe(404);
|
|
128
|
+
const json = await res.json();
|
|
129
|
+
expect(json.error).toMatch(/not found/i);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
134
|
+
// POST /api/envs
|
|
135
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
136
|
+
|
|
137
|
+
describe("POST /api/envs", () => {
|
|
138
|
+
it("creates a new environment and returns 201", async () => {
|
|
139
|
+
const created = makeEnv();
|
|
140
|
+
vi.mocked(envManager.createEnv).mockReturnValue(created as any);
|
|
141
|
+
|
|
142
|
+
const res = await app.request("/api/envs", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "Content-Type": "application/json" },
|
|
145
|
+
body: JSON.stringify({ name: "Test Env", variables: { FOO: "bar" } }),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.status).toBe(201);
|
|
149
|
+
expect(await res.json()).toEqual(created);
|
|
150
|
+
// Verify createEnv was called with the correct arguments (name + variables only)
|
|
151
|
+
expect(envManager.createEnv).toHaveBeenCalledWith(
|
|
152
|
+
"Test Env",
|
|
153
|
+
{ FOO: "bar" },
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns 400 when createEnv throws a validation error", async () => {
|
|
158
|
+
vi.mocked(envManager.createEnv).mockImplementation(() => {
|
|
159
|
+
throw new Error("Name is required");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const res = await app.request("/api/envs", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({}),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(res.status).toBe(400);
|
|
169
|
+
const json = await res.json();
|
|
170
|
+
expect(json.error).toBe("Name is required");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
175
|
+
// PUT /api/envs/:slug
|
|
176
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
177
|
+
|
|
178
|
+
describe("PUT /api/envs/:slug", () => {
|
|
179
|
+
it("updates an existing environment", async () => {
|
|
180
|
+
const updated = makeEnv({ name: "Updated" });
|
|
181
|
+
vi.mocked(envManager.updateEnv).mockReturnValue(updated as any);
|
|
182
|
+
|
|
183
|
+
const res = await app.request("/api/envs/test-env", {
|
|
184
|
+
method: "PUT",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({ name: "Updated" }),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
expect(await res.json()).toEqual(updated);
|
|
191
|
+
expect(envManager.updateEnv).toHaveBeenCalledWith(
|
|
192
|
+
"test-env",
|
|
193
|
+
expect.objectContaining({ name: "Updated" }),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns 404 when the environment does not exist", async () => {
|
|
198
|
+
vi.mocked(envManager.updateEnv).mockReturnValue(null as any);
|
|
199
|
+
|
|
200
|
+
const res = await app.request("/api/envs/missing", {
|
|
201
|
+
method: "PUT",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify({ name: "X" }),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(res.status).toBe(404);
|
|
207
|
+
const json = await res.json();
|
|
208
|
+
expect(json.error).toMatch(/not found/i);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("returns 400 when updateEnv throws", async () => {
|
|
212
|
+
vi.mocked(envManager.updateEnv).mockImplementation(() => {
|
|
213
|
+
throw new Error("Invalid slug");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const res = await app.request("/api/envs/test-env", {
|
|
217
|
+
method: "PUT",
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
body: JSON.stringify({ name: "" }),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(res.status).toBe(400);
|
|
223
|
+
const json = await res.json();
|
|
224
|
+
expect(json.error).toBe("Invalid slug");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
229
|
+
// DELETE /api/envs/:slug
|
|
230
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
231
|
+
|
|
232
|
+
describe("DELETE /api/envs/:slug", () => {
|
|
233
|
+
it("deletes an environment and returns ok", async () => {
|
|
234
|
+
vi.mocked(envManager.deleteEnv).mockReturnValue(true);
|
|
235
|
+
|
|
236
|
+
const res = await app.request("/api/envs/test-env", { method: "DELETE" });
|
|
237
|
+
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
240
|
+
expect(envManager.deleteEnv).toHaveBeenCalledWith("test-env");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns 404 when the environment does not exist", async () => {
|
|
244
|
+
vi.mocked(envManager.deleteEnv).mockReturnValue(false);
|
|
245
|
+
|
|
246
|
+
const res = await app.request("/api/envs/missing", { method: "DELETE" });
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(404);
|
|
249
|
+
const json = await res.json();
|
|
250
|
+
expect(json.error).toMatch(/not found/i);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
255
|
+
// POST /api/docker/build-base
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
describe("POST /api/docker/build-base", () => {
|
|
259
|
+
it("builds the base image successfully when Docker and Dockerfile exist", async () => {
|
|
260
|
+
vi.mocked(containerManager.checkDocker).mockReturnValue(true);
|
|
261
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
262
|
+
vi.mocked(containerManager.buildImage).mockReturnValue("build log");
|
|
263
|
+
|
|
264
|
+
const res = await app.request("/api/docker/build-base", { method: "POST" });
|
|
265
|
+
|
|
266
|
+
expect(res.status).toBe(200);
|
|
267
|
+
const json = await res.json();
|
|
268
|
+
expect(json.success).toBe(true);
|
|
269
|
+
expect(json.log).toBe("build log");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns 503 when Docker is not available", async () => {
|
|
273
|
+
vi.mocked(containerManager.checkDocker).mockReturnValue(false);
|
|
274
|
+
|
|
275
|
+
const res = await app.request("/api/docker/build-base", { method: "POST" });
|
|
276
|
+
|
|
277
|
+
expect(res.status).toBe(503);
|
|
278
|
+
const json = await res.json();
|
|
279
|
+
expect(json.error).toMatch(/docker/i);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns 404 when the base Dockerfile does not exist on disk", async () => {
|
|
283
|
+
vi.mocked(containerManager.checkDocker).mockReturnValue(true);
|
|
284
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
285
|
+
|
|
286
|
+
const res = await app.request("/api/docker/build-base", { method: "POST" });
|
|
287
|
+
|
|
288
|
+
expect(res.status).toBe(404);
|
|
289
|
+
const json = await res.json();
|
|
290
|
+
expect(json.error).toMatch(/dockerfile/i);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("returns 500 when buildImage throws", async () => {
|
|
294
|
+
vi.mocked(containerManager.checkDocker).mockReturnValue(true);
|
|
295
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
296
|
+
vi.mocked(containerManager.buildImage).mockImplementation(() => {
|
|
297
|
+
throw new Error("out of disk space");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const res = await app.request("/api/docker/build-base", { method: "POST" });
|
|
301
|
+
|
|
302
|
+
expect(res.status).toBe(500);
|
|
303
|
+
const json = await res.json();
|
|
304
|
+
expect(json.success).toBe(false);
|
|
305
|
+
expect(json.error).toBe("out of disk space");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
310
|
+
// GET /api/docker/base-image
|
|
311
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
312
|
+
|
|
313
|
+
describe("GET /api/docker/base-image", () => {
|
|
314
|
+
it("returns exists: false when the base image is not built", async () => {
|
|
315
|
+
vi.mocked(containerManager.imageExists).mockReturnValue(false);
|
|
316
|
+
|
|
317
|
+
const res = await app.request("/api/docker/base-image");
|
|
318
|
+
|
|
319
|
+
expect(res.status).toBe(200);
|
|
320
|
+
const json = await res.json();
|
|
321
|
+
expect(json.exists).toBe(false);
|
|
322
|
+
expect(json.image).toBe("the-companion:latest");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("returns exists: true when the base image is present", async () => {
|
|
326
|
+
vi.mocked(containerManager.imageExists).mockReturnValue(true);
|
|
327
|
+
|
|
328
|
+
const res = await app.request("/api/docker/base-image");
|
|
329
|
+
|
|
330
|
+
expect(res.status).toBe(200);
|
|
331
|
+
const json = await res.json();
|
|
332
|
+
expect(json.exists).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
337
|
+
// GET /api/images/:tag/status
|
|
338
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
339
|
+
|
|
340
|
+
describe("GET /api/images/:tag/status", () => {
|
|
341
|
+
it("returns idle state for a tag that has not been pulled", async () => {
|
|
342
|
+
const state = { image: "node:20", status: "idle", progress: [] };
|
|
343
|
+
vi.mocked(imagePullManager.getState).mockReturnValue(state as any);
|
|
344
|
+
|
|
345
|
+
const res = await app.request("/api/images/node%3A20/status");
|
|
346
|
+
|
|
347
|
+
expect(res.status).toBe(200);
|
|
348
|
+
const json = await res.json();
|
|
349
|
+
expect(json.image).toBe("node:20");
|
|
350
|
+
expect(json.status).toBe("idle");
|
|
351
|
+
expect(imagePullManager.getState).toHaveBeenCalledWith("node:20");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
356
|
+
// POST /api/images/:tag/pull
|
|
357
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
358
|
+
|
|
359
|
+
describe("POST /api/images/:tag/pull", () => {
|
|
360
|
+
it("starts pulling an image and returns ok with the current state", async () => {
|
|
361
|
+
vi.mocked(containerManager.checkDocker).mockReturnValue(true);
|
|
362
|
+
const state = { image: "node:20", status: "pulling", progress: [] };
|
|
363
|
+
vi.mocked(imagePullManager.getState).mockReturnValue(state as any);
|
|
364
|
+
|
|
365
|
+
const res = await app.request("/api/images/node%3A20/pull", { method: "POST" });
|
|
366
|
+
|
|
367
|
+
expect(res.status).toBe(200);
|
|
368
|
+
const json = await res.json();
|
|
369
|
+
expect(json.ok).toBe(true);
|
|
370
|
+
expect(json.state.status).toBe("pulling");
|
|
371
|
+
expect(imagePullManager.pull).toHaveBeenCalledWith("node:20");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("returns 503 when Docker is not available", async () => {
|
|
375
|
+
vi.mocked(containerManager.checkDocker).mockReturnValue(false);
|
|
376
|
+
|
|
377
|
+
const res = await app.request("/api/images/node%3A20/pull", { method: "POST" });
|
|
378
|
+
|
|
379
|
+
expect(res.status).toBe(503);
|
|
380
|
+
const json = await res.json();
|
|
381
|
+
expect(json.error).toMatch(/docker/i);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { Hono } from "hono";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as envManager from "../env-manager.js";
|
|
5
|
+
import { containerManager } from "../container-manager.js";
|
|
6
|
+
import { imagePullManager } from "../image-pull-manager.js";
|
|
7
|
+
|
|
8
|
+
export function registerEnvRoutes(
|
|
9
|
+
api: Hono,
|
|
10
|
+
options: { webDir: string },
|
|
11
|
+
): void {
|
|
12
|
+
api.get("/envs", (c) => {
|
|
13
|
+
try {
|
|
14
|
+
return c.json(envManager.listEnvs());
|
|
15
|
+
} catch (e: unknown) {
|
|
16
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
api.get("/envs/:slug", (c) => {
|
|
21
|
+
const env = envManager.getEnv(c.req.param("slug"));
|
|
22
|
+
if (!env) return c.json({ error: "Environment not found" }, 404);
|
|
23
|
+
return c.json(env);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
api.post("/envs", async (c) => {
|
|
27
|
+
const body = await c.req.json().catch(() => ({}));
|
|
28
|
+
try {
|
|
29
|
+
const env = envManager.createEnv(body.name, body.variables || {});
|
|
30
|
+
return c.json(env, 201);
|
|
31
|
+
} catch (e: unknown) {
|
|
32
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
api.put("/envs/:slug", async (c) => {
|
|
37
|
+
const slug = c.req.param("slug");
|
|
38
|
+
const body = await c.req.json().catch(() => ({}));
|
|
39
|
+
try {
|
|
40
|
+
const env = envManager.updateEnv(slug, {
|
|
41
|
+
name: body.name,
|
|
42
|
+
variables: body.variables,
|
|
43
|
+
});
|
|
44
|
+
if (!env) return c.json({ error: "Environment not found" }, 404);
|
|
45
|
+
return c.json(env);
|
|
46
|
+
} catch (e: unknown) {
|
|
47
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
api.delete("/envs/:slug", (c) => {
|
|
52
|
+
try {
|
|
53
|
+
const deleted = envManager.deleteEnv(c.req.param("slug"));
|
|
54
|
+
if (!deleted) return c.json({ error: "Environment not found" }, 404);
|
|
55
|
+
return c.json({ ok: true });
|
|
56
|
+
} catch (e: unknown) {
|
|
57
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
api.post("/docker/build-base", async (c) => {
|
|
62
|
+
if (!containerManager.checkDocker()) return c.json({ error: "Docker is not available" }, 503);
|
|
63
|
+
const dockerfilePath = join(options.webDir, "docker", "Dockerfile.the-companion");
|
|
64
|
+
if (!existsSync(dockerfilePath)) {
|
|
65
|
+
return c.json({ error: "Base Dockerfile not found at " + dockerfilePath }, 404);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const log = containerManager.buildImage(dockerfilePath, "the-companion:latest");
|
|
69
|
+
return c.json({ success: true, log });
|
|
70
|
+
} catch (e: unknown) {
|
|
71
|
+
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
api.get("/docker/base-image", (c) => {
|
|
76
|
+
const exists = containerManager.imageExists("the-companion:latest");
|
|
77
|
+
return c.json({ exists, image: "the-companion:latest" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
api.get("/images/:tag/status", (c) => {
|
|
81
|
+
const tag = decodeURIComponent(c.req.param("tag"));
|
|
82
|
+
if (!tag) return c.json({ error: "Image tag is required" }, 400);
|
|
83
|
+
return c.json(imagePullManager.getState(tag));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
api.post("/images/:tag/pull", (c) => {
|
|
87
|
+
const tag = decodeURIComponent(c.req.param("tag"));
|
|
88
|
+
if (!tag) return c.json({ error: "Image tag is required" }, 400);
|
|
89
|
+
if (!containerManager.checkDocker()) {
|
|
90
|
+
return c.json({ error: "Docker is not available" }, 503);
|
|
91
|
+
}
|
|
92
|
+
imagePullManager.pull(tag);
|
|
93
|
+
return c.json({ ok: true, state: imagePullManager.getState(tag) });
|
|
94
|
+
});
|
|
95
|
+
}
|