@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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. 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
+ }