@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,378 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
let tempDir: string;
|
|
6
|
+
let sandboxManager: typeof import("./sandbox-manager.js");
|
|
7
|
+
|
|
8
|
+
// Redirect homedir() to a temporary directory so the module writes to an
|
|
9
|
+
// isolated location instead of the real ~/.companion/sandboxes/.
|
|
10
|
+
const mockHomedir = vi.hoisted(() => {
|
|
11
|
+
let dir = "";
|
|
12
|
+
return {
|
|
13
|
+
get: () => dir,
|
|
14
|
+
set: (d: string) => {
|
|
15
|
+
dir = d;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
homedir: () => mockHomedir.get(),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
tempDir = mkdtempSync(join(tmpdir(), "sandbox-test-"));
|
|
30
|
+
mockHomedir.set(tempDir);
|
|
31
|
+
// Reset the module so module-level constants (SANDBOXES_DIR) pick up
|
|
32
|
+
// the new homedir value.
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
sandboxManager = await import("./sandbox-manager.js");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Helper to get the sandboxes directory path used by the module
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
function sandboxesDir(): string {
|
|
45
|
+
return join(tempDir, ".companion", "sandboxes");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ===========================================================================
|
|
49
|
+
// Slugification (tested indirectly via createSandbox)
|
|
50
|
+
// ===========================================================================
|
|
51
|
+
describe("slugification via createSandbox", () => {
|
|
52
|
+
it("converts spaces to hyphens and lowercases", () => {
|
|
53
|
+
// Validates that human-readable names are transformed into URL-safe slugs
|
|
54
|
+
const sandbox = sandboxManager.createSandbox("My Project");
|
|
55
|
+
expect(sandbox.slug).toBe("my-project");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("strips special characters", () => {
|
|
59
|
+
// Non-alphanumeric characters (except hyphens) should be removed
|
|
60
|
+
const sandbox = sandboxManager.createSandbox("Hello World! @#$%");
|
|
61
|
+
expect(sandbox.slug).toBe("hello-world");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("collapses consecutive hyphens", () => {
|
|
65
|
+
// Multiple spaces or hyphens in a row should become a single hyphen
|
|
66
|
+
const sandbox = sandboxManager.createSandbox("a --- b");
|
|
67
|
+
expect(sandbox.slug).toBe("a-b");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("trims leading and trailing hyphens", () => {
|
|
71
|
+
// Slugs should not start or end with a hyphen
|
|
72
|
+
const sandbox = sandboxManager.createSandbox(" -cool sandbox- ");
|
|
73
|
+
expect(sandbox.slug).toBe("cool-sandbox");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws when name is empty string", () => {
|
|
77
|
+
// An empty name is not a valid sandbox identifier
|
|
78
|
+
expect(() => sandboxManager.createSandbox("")).toThrow(
|
|
79
|
+
"Sandbox name is required",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws when name is only whitespace", () => {
|
|
84
|
+
// Whitespace-only names should be rejected just like empty strings
|
|
85
|
+
expect(() => sandboxManager.createSandbox(" ")).toThrow(
|
|
86
|
+
"Sandbox name is required",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws when name contains no alphanumeric characters", () => {
|
|
91
|
+
// Names like "@#$" produce an empty slug which is invalid
|
|
92
|
+
expect(() => sandboxManager.createSandbox("@#$%^&")).toThrow(
|
|
93
|
+
"Sandbox name must contain alphanumeric characters",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
// listSandboxes
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
describe("listSandboxes", () => {
|
|
102
|
+
it("returns empty array when no sandboxes exist", () => {
|
|
103
|
+
// A fresh installation should have no sandboxes
|
|
104
|
+
const result = sandboxManager.listSandboxes();
|
|
105
|
+
expect(result).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns sandboxes sorted alphabetically by name", () => {
|
|
109
|
+
// Ensures deterministic ordering regardless of creation order
|
|
110
|
+
sandboxManager.createSandbox("Zebra");
|
|
111
|
+
sandboxManager.createSandbox("Alpha");
|
|
112
|
+
sandboxManager.createSandbox("Mango");
|
|
113
|
+
|
|
114
|
+
const result = sandboxManager.listSandboxes();
|
|
115
|
+
expect(result.map((s) => s.name)).toEqual(["Alpha", "Mango", "Zebra"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("skips corrupt JSON files gracefully", () => {
|
|
119
|
+
// The module should be resilient to manually-edited or corrupted files
|
|
120
|
+
sandboxManager.createSandbox("Valid");
|
|
121
|
+
|
|
122
|
+
// Write a corrupt file directly into the sandboxes directory
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(sandboxesDir(), "corrupt.json"),
|
|
125
|
+
"NOT VALID JSON{{{",
|
|
126
|
+
"utf-8",
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const result = sandboxManager.listSandboxes();
|
|
130
|
+
expect(result).toHaveLength(1);
|
|
131
|
+
expect(result[0].name).toBe("Valid");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("ignores non-JSON files in the sandboxes directory", () => {
|
|
135
|
+
// Only .json files should be loaded; other files (e.g. .bak) are ignored
|
|
136
|
+
sandboxManager.createSandbox("Real");
|
|
137
|
+
|
|
138
|
+
writeFileSync(
|
|
139
|
+
join(sandboxesDir(), "notes.txt"),
|
|
140
|
+
"some random notes",
|
|
141
|
+
"utf-8",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const result = sandboxManager.listSandboxes();
|
|
145
|
+
expect(result).toHaveLength(1);
|
|
146
|
+
expect(result[0].name).toBe("Real");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ===========================================================================
|
|
151
|
+
// getSandbox
|
|
152
|
+
// ===========================================================================
|
|
153
|
+
describe("getSandbox", () => {
|
|
154
|
+
it("returns the sandbox when it exists", () => {
|
|
155
|
+
// Validates round-trip: create then retrieve by slug
|
|
156
|
+
sandboxManager.createSandbox("My Service", {
|
|
157
|
+
initScript: "npm install",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = sandboxManager.getSandbox("my-service");
|
|
161
|
+
expect(result).not.toBeNull();
|
|
162
|
+
expect(result!.name).toBe("My Service");
|
|
163
|
+
expect(result!.slug).toBe("my-service");
|
|
164
|
+
expect(result!.initScript).toBe("npm install");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns null when the sandbox does not exist", () => {
|
|
168
|
+
// Querying a non-existent slug should return null, not throw
|
|
169
|
+
const result = sandboxManager.getSandbox("nonexistent");
|
|
170
|
+
expect(result).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ===========================================================================
|
|
175
|
+
// createSandbox
|
|
176
|
+
// ===========================================================================
|
|
177
|
+
describe("createSandbox", () => {
|
|
178
|
+
it("returns a sandbox with correct structure and timestamps", () => {
|
|
179
|
+
// Validates the shape of the returned object and that timestamps
|
|
180
|
+
// fall within the expected range
|
|
181
|
+
const before = Date.now();
|
|
182
|
+
const sandbox = sandboxManager.createSandbox("Production", {
|
|
183
|
+
initScript: "apt-get update",
|
|
184
|
+
});
|
|
185
|
+
const after = Date.now();
|
|
186
|
+
|
|
187
|
+
expect(sandbox.name).toBe("Production");
|
|
188
|
+
expect(sandbox.slug).toBe("production");
|
|
189
|
+
expect(sandbox.initScript).toBe("apt-get update");
|
|
190
|
+
expect(sandbox.createdAt).toBeGreaterThanOrEqual(before);
|
|
191
|
+
expect(sandbox.createdAt).toBeLessThanOrEqual(after);
|
|
192
|
+
expect(sandbox.updatedAt).toBe(sandbox.createdAt);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("persists the sandbox to disk as JSON", () => {
|
|
196
|
+
// The file must be readable and parseable outside the module
|
|
197
|
+
sandboxManager.createSandbox("Disk Check");
|
|
198
|
+
|
|
199
|
+
const raw = readFileSync(
|
|
200
|
+
join(sandboxesDir(), "disk-check.json"),
|
|
201
|
+
"utf-8",
|
|
202
|
+
);
|
|
203
|
+
const parsed = JSON.parse(raw);
|
|
204
|
+
expect(parsed.name).toBe("Disk Check");
|
|
205
|
+
expect(parsed.slug).toBe("disk-check");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("omits initScript when not provided", () => {
|
|
209
|
+
// Optional fields should not be present if not supplied
|
|
210
|
+
const sandbox = sandboxManager.createSandbox("Bare");
|
|
211
|
+
expect(sandbox.initScript).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("includes initScript when provided", () => {
|
|
215
|
+
const sandbox = sandboxManager.createSandbox("With Init", {
|
|
216
|
+
initScript: "echo hello",
|
|
217
|
+
});
|
|
218
|
+
expect(sandbox.initScript).toBe("echo hello");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("throws when creating a duplicate slug", () => {
|
|
222
|
+
// Duplicate detection prevents accidental overwrites
|
|
223
|
+
sandboxManager.createSandbox("My App");
|
|
224
|
+
expect(() => sandboxManager.createSandbox("My App")).toThrow(
|
|
225
|
+
'A sandbox with a similar name already exists ("my-app")',
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("detects duplicates even with different casing or spacing", () => {
|
|
230
|
+
// "My App" and "my app" both slugify to "my-app"
|
|
231
|
+
sandboxManager.createSandbox("My App");
|
|
232
|
+
expect(() => sandboxManager.createSandbox("my app")).toThrow(
|
|
233
|
+
'A sandbox with a similar name already exists ("my-app")',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("trims the name before saving", () => {
|
|
238
|
+
// Leading/trailing whitespace in the name should be stripped
|
|
239
|
+
const sandbox = sandboxManager.createSandbox(" Spaced Out ");
|
|
240
|
+
expect(sandbox.name).toBe("Spaced Out");
|
|
241
|
+
expect(sandbox.slug).toBe("spaced-out");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ===========================================================================
|
|
246
|
+
// updateSandbox
|
|
247
|
+
// ===========================================================================
|
|
248
|
+
describe("updateSandbox", () => {
|
|
249
|
+
it("updates name and renames slug accordingly", () => {
|
|
250
|
+
// When the name changes, the slug and on-disk filename should update too
|
|
251
|
+
sandboxManager.createSandbox("Original");
|
|
252
|
+
|
|
253
|
+
const updated = sandboxManager.updateSandbox("original", {
|
|
254
|
+
name: "Renamed",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(updated).not.toBeNull();
|
|
258
|
+
expect(updated!.name).toBe("Renamed");
|
|
259
|
+
expect(updated!.slug).toBe("renamed");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("updates initScript field", () => {
|
|
263
|
+
sandboxManager.createSandbox("Configurable");
|
|
264
|
+
|
|
265
|
+
const updated = sandboxManager.updateSandbox("configurable", {
|
|
266
|
+
initScript: "pip install flask",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(updated).not.toBeNull();
|
|
270
|
+
expect(updated!.initScript).toBe("pip install flask");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("renames the file on disk when slug changes", () => {
|
|
274
|
+
// The old file should be removed and a new one created
|
|
275
|
+
sandboxManager.createSandbox("Old Name");
|
|
276
|
+
|
|
277
|
+
sandboxManager.updateSandbox("old-name", { name: "New Name" });
|
|
278
|
+
|
|
279
|
+
const oldPath = join(sandboxesDir(), "old-name.json");
|
|
280
|
+
const newPath = join(sandboxesDir(), "new-name.json");
|
|
281
|
+
|
|
282
|
+
expect(() => readFileSync(oldPath, "utf-8")).toThrow();
|
|
283
|
+
const parsed = JSON.parse(readFileSync(newPath, "utf-8"));
|
|
284
|
+
expect(parsed.name).toBe("New Name");
|
|
285
|
+
expect(parsed.slug).toBe("new-name");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("throws on slug collision during rename", () => {
|
|
289
|
+
// Renaming to a name that would collide with another sandbox is not allowed
|
|
290
|
+
sandboxManager.createSandbox("Alpha");
|
|
291
|
+
sandboxManager.createSandbox("Beta");
|
|
292
|
+
|
|
293
|
+
expect(() =>
|
|
294
|
+
sandboxManager.updateSandbox("alpha", { name: "Beta" }),
|
|
295
|
+
).toThrow('A sandbox with a similar name already exists ("beta")');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns null for a non-existent slug", () => {
|
|
299
|
+
// Updating a sandbox that does not exist should return null
|
|
300
|
+
const result = sandboxManager.updateSandbox("ghost", { name: "New" });
|
|
301
|
+
expect(result).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("preserves createdAt and advances updatedAt", async () => {
|
|
305
|
+
// createdAt should be immutable; updatedAt should reflect the latest change
|
|
306
|
+
const sandbox = sandboxManager.createSandbox("Timestamps");
|
|
307
|
+
const originalCreatedAt = sandbox.createdAt;
|
|
308
|
+
|
|
309
|
+
// Small delay to ensure Date.now() advances
|
|
310
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
311
|
+
|
|
312
|
+
const updated = sandboxManager.updateSandbox("timestamps", {
|
|
313
|
+
initScript: "echo updated",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(updated).not.toBeNull();
|
|
317
|
+
expect(updated!.createdAt).toBe(originalCreatedAt);
|
|
318
|
+
expect(updated!.updatedAt).toBeGreaterThan(originalCreatedAt);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("keeps existing fields when only a subset is updated", () => {
|
|
322
|
+
// Fields not included in the update payload should remain unchanged
|
|
323
|
+
sandboxManager.createSandbox("Partial", {
|
|
324
|
+
initScript: "echo setup",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const updated = sandboxManager.updateSandbox("partial", {
|
|
328
|
+
name: "Partial Updated",
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(updated!.initScript).toBe("echo setup");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("allows same-slug update without collision error", () => {
|
|
335
|
+
// Updating a sandbox without changing the name should not trigger
|
|
336
|
+
// the duplicate slug check
|
|
337
|
+
sandboxManager.createSandbox("Stable");
|
|
338
|
+
|
|
339
|
+
const updated = sandboxManager.updateSandbox("stable", {
|
|
340
|
+
initScript: "echo stable",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(updated).not.toBeNull();
|
|
344
|
+
expect(updated!.slug).toBe("stable");
|
|
345
|
+
expect(updated!.initScript).toBe("echo stable");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ===========================================================================
|
|
350
|
+
// deleteSandbox
|
|
351
|
+
// ===========================================================================
|
|
352
|
+
describe("deleteSandbox", () => {
|
|
353
|
+
it("deletes an existing sandbox and returns true", () => {
|
|
354
|
+
sandboxManager.createSandbox("To Delete");
|
|
355
|
+
const result = sandboxManager.deleteSandbox("to-delete");
|
|
356
|
+
expect(result).toBe(true);
|
|
357
|
+
|
|
358
|
+
// Confirm it is gone
|
|
359
|
+
expect(sandboxManager.getSandbox("to-delete")).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("returns false when the sandbox does not exist", () => {
|
|
363
|
+
// Deleting a non-existent sandbox should be a no-op that returns false
|
|
364
|
+
const result = sandboxManager.deleteSandbox("missing");
|
|
365
|
+
expect(result).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("does not affect other sandboxes", () => {
|
|
369
|
+
// Deleting one sandbox should leave others intact
|
|
370
|
+
sandboxManager.createSandbox("Keep");
|
|
371
|
+
sandboxManager.createSandbox("Remove");
|
|
372
|
+
|
|
373
|
+
sandboxManager.deleteSandbox("remove");
|
|
374
|
+
|
|
375
|
+
expect(sandboxManager.getSandbox("keep")).not.toBeNull();
|
|
376
|
+
expect(sandboxManager.listSandboxes()).toHaveLength(1);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface CompanionSandbox {
|
|
15
|
+
name: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
/** Shell script to run inside the container before the CLI session starts */
|
|
18
|
+
initScript?: string;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fields that can be updated via the update API */
|
|
24
|
+
export interface SandboxUpdateFields {
|
|
25
|
+
name?: string;
|
|
26
|
+
initScript?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const COMPANION_DIR = join(homedir(), ".companion");
|
|
32
|
+
const SANDBOXES_DIR = join(COMPANION_DIR, "sandboxes");
|
|
33
|
+
|
|
34
|
+
function ensureDir(): void {
|
|
35
|
+
mkdirSync(SANDBOXES_DIR, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Validate that a slug contains only safe characters (prevents path traversal) */
|
|
39
|
+
function validateSlug(slug: string): void {
|
|
40
|
+
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
41
|
+
throw new Error("Invalid slug: must contain only lowercase alphanumeric characters and hyphens");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function filePath(slug: string): string {
|
|
46
|
+
validateSlug(slug);
|
|
47
|
+
return join(SANDBOXES_DIR, `${slug}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function slugify(name: string): string {
|
|
53
|
+
return name
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/\s+/g, "-")
|
|
56
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
57
|
+
.replace(/-+/g, "-")
|
|
58
|
+
.replace(/^-|-$/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export function listSandboxes(): CompanionSandbox[] {
|
|
64
|
+
ensureDir();
|
|
65
|
+
try {
|
|
66
|
+
const files = readdirSync(SANDBOXES_DIR).filter((f) => f.endsWith(".json"));
|
|
67
|
+
const sandboxes: CompanionSandbox[] = [];
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(join(SANDBOXES_DIR, file), "utf-8");
|
|
71
|
+
sandboxes.push(JSON.parse(raw));
|
|
72
|
+
} catch {
|
|
73
|
+
// Skip corrupt files
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
sandboxes.sort((a, b) => a.name.localeCompare(b.name));
|
|
77
|
+
return sandboxes;
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getSandbox(slug: string): CompanionSandbox | null {
|
|
84
|
+
ensureDir();
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(filePath(slug), "utf-8");
|
|
87
|
+
return JSON.parse(raw) as CompanionSandbox;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createSandbox(
|
|
94
|
+
name: string,
|
|
95
|
+
opts?: { initScript?: string },
|
|
96
|
+
): CompanionSandbox {
|
|
97
|
+
if (!name || !name.trim()) throw new Error("Sandbox name is required");
|
|
98
|
+
const slug = slugify(name.trim());
|
|
99
|
+
if (!slug) throw new Error("Sandbox name must contain alphanumeric characters");
|
|
100
|
+
|
|
101
|
+
ensureDir();
|
|
102
|
+
if (existsSync(filePath(slug))) {
|
|
103
|
+
throw new Error(`A sandbox with a similar name already exists ("${slug}")`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const sandbox: CompanionSandbox = {
|
|
108
|
+
name: name.trim(),
|
|
109
|
+
slug,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Apply optional fields if provided
|
|
115
|
+
if (opts) {
|
|
116
|
+
if (opts.initScript !== undefined) sandbox.initScript = opts.initScript;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
writeFileSync(filePath(slug), JSON.stringify(sandbox, null, 2), "utf-8");
|
|
120
|
+
return sandbox;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function updateSandbox(
|
|
124
|
+
slug: string,
|
|
125
|
+
updates: SandboxUpdateFields,
|
|
126
|
+
): CompanionSandbox | null {
|
|
127
|
+
ensureDir();
|
|
128
|
+
const existing = getSandbox(slug);
|
|
129
|
+
if (!existing) return null;
|
|
130
|
+
|
|
131
|
+
const newName = updates.name?.trim() || existing.name;
|
|
132
|
+
const newSlug = slugify(newName);
|
|
133
|
+
if (!newSlug) throw new Error("Sandbox name must contain alphanumeric characters");
|
|
134
|
+
|
|
135
|
+
// If name changed, check for slug collision with a different sandbox
|
|
136
|
+
if (newSlug !== slug && existsSync(filePath(newSlug))) {
|
|
137
|
+
throw new Error(`A sandbox with a similar name already exists ("${newSlug}")`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sandbox: CompanionSandbox = {
|
|
141
|
+
...existing,
|
|
142
|
+
name: newName,
|
|
143
|
+
slug: newSlug,
|
|
144
|
+
updatedAt: Date.now(),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Apply field updates (only override if explicitly provided)
|
|
148
|
+
if (updates.initScript !== undefined) sandbox.initScript = updates.initScript;
|
|
149
|
+
|
|
150
|
+
// If slug changed, delete old file
|
|
151
|
+
if (newSlug !== slug) {
|
|
152
|
+
try { unlinkSync(filePath(slug)); } catch { /* ok */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
writeFileSync(filePath(newSlug), JSON.stringify(sandbox, null, 2), "utf-8");
|
|
156
|
+
return sandbox;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function deleteSandbox(slug: string): boolean {
|
|
160
|
+
ensureDir();
|
|
161
|
+
if (!existsSync(filePath(slug))) return false;
|
|
162
|
+
try {
|
|
163
|
+
unlinkSync(filePath(slug));
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|