@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,148 @@
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 type { CronJob, CronJobCreateInput } from "./cron-types.js";
11
+ import { COMPANION_HOME } from "./paths.js";
12
+
13
+ // ─── Paths ──────────────────────────────────────────────────────────────────
14
+
15
+ const CRON_DIR = join(COMPANION_HOME, "cron");
16
+
17
+ function ensureDir(): void {
18
+ mkdirSync(CRON_DIR, { recursive: true });
19
+ }
20
+
21
+ function filePath(id: string): string {
22
+ return join(CRON_DIR, `${id}.json`);
23
+ }
24
+
25
+ // ─── Helpers ────────────────────────────────────────────────────────────────
26
+
27
+ function slugify(name: string): string {
28
+ return name
29
+ .toLowerCase()
30
+ .replace(/\s+/g, "-")
31
+ .replace(/[^a-z0-9-]/g, "")
32
+ .replace(/-+/g, "-")
33
+ .replace(/^-|-$/g, "");
34
+ }
35
+
36
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
37
+
38
+ export function listJobs(): CronJob[] {
39
+ ensureDir();
40
+ try {
41
+ const files = readdirSync(CRON_DIR).filter((f) => f.endsWith(".json"));
42
+ const jobs: CronJob[] = [];
43
+ for (const file of files) {
44
+ try {
45
+ const raw = readFileSync(join(CRON_DIR, file), "utf-8");
46
+ jobs.push(JSON.parse(raw));
47
+ } catch {
48
+ // Skip corrupt files
49
+ }
50
+ }
51
+ jobs.sort((a, b) => a.name.localeCompare(b.name));
52
+ return jobs;
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ export function getJob(id: string): CronJob | null {
59
+ ensureDir();
60
+ try {
61
+ const raw = readFileSync(filePath(id), "utf-8");
62
+ return JSON.parse(raw) as CronJob;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export function createJob(data: CronJobCreateInput): CronJob {
69
+ if (!data.name || !data.name.trim()) throw new Error("Job name is required");
70
+ if (!data.prompt || !data.prompt.trim()) throw new Error("Job prompt is required");
71
+ if (!data.schedule || !data.schedule.trim()) throw new Error("Job schedule is required");
72
+ if (!data.cwd || !data.cwd.trim()) throw new Error("Job working directory is required");
73
+
74
+ const id = slugify(data.name.trim());
75
+ if (!id) throw new Error("Job name must contain alphanumeric characters");
76
+
77
+ ensureDir();
78
+ if (existsSync(filePath(id))) {
79
+ throw new Error(`A job with a similar name already exists ("${id}")`);
80
+ }
81
+
82
+ const now = Date.now();
83
+ const job: CronJob = {
84
+ ...data,
85
+ id,
86
+ name: data.name.trim(),
87
+ prompt: data.prompt.trim(),
88
+ schedule: data.schedule.trim(),
89
+ cwd: data.cwd.trim(),
90
+ createdAt: now,
91
+ updatedAt: now,
92
+ consecutiveFailures: 0,
93
+ totalRuns: 0,
94
+ };
95
+ writeFileSync(filePath(id), JSON.stringify(job, null, 2), "utf-8");
96
+ return job;
97
+ }
98
+
99
+ export function updateJob(
100
+ id: string,
101
+ updates: Partial<CronJob>,
102
+ ): CronJob | null {
103
+ ensureDir();
104
+ const existing = getJob(id);
105
+ if (!existing) return null;
106
+
107
+ const newName = updates.name?.trim() || existing.name;
108
+ const newId = slugify(newName);
109
+ if (!newId) throw new Error("Job name must contain alphanumeric characters");
110
+
111
+ // If name changed, check for slug collision with a different job
112
+ if (newId !== id && existsSync(filePath(newId))) {
113
+ throw new Error(`A job with a similar name already exists ("${newId}")`);
114
+ }
115
+
116
+ const job: CronJob = {
117
+ ...existing,
118
+ ...updates,
119
+ id: newId,
120
+ name: newName,
121
+ updatedAt: Date.now(),
122
+ // Preserve immutable fields
123
+ createdAt: existing.createdAt,
124
+ };
125
+
126
+ // If id changed, delete old file
127
+ if (newId !== id) {
128
+ try {
129
+ unlinkSync(filePath(id));
130
+ } catch {
131
+ /* ok */
132
+ }
133
+ }
134
+
135
+ writeFileSync(filePath(newId), JSON.stringify(job, null, 2), "utf-8");
136
+ return job;
137
+ }
138
+
139
+ export function deleteJob(id: string): boolean {
140
+ ensureDir();
141
+ if (!existsSync(filePath(id))) return false;
142
+ try {
143
+ unlinkSync(filePath(id));
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
@@ -0,0 +1,63 @@
1
+ // ─── Cron Job Types ────────────────────────────────────────────────────────
2
+
3
+ export interface CronJob {
4
+ /** Unique slug-based ID (derived from name) */
5
+ id: string;
6
+ /** Human-readable job name */
7
+ name: string;
8
+ /** The prompt to send when the job fires */
9
+ prompt: string;
10
+ /** Cron expression (e.g. "0 8 * * *") or ISO datetime string for one-shot */
11
+ schedule: string;
12
+ /** true = recurring cron, false = one-shot at a specific time */
13
+ recurring: boolean;
14
+ /** Backend to use */
15
+ backendType: "claude" | "codex";
16
+ /** Model to use (e.g. "claude-sonnet-4-6") */
17
+ model: string;
18
+ /** Working directory for the session */
19
+ cwd: string;
20
+ /** Optional environment slug (references ~/.companion/envs/) */
21
+ envSlug?: string;
22
+ /** Whether the job is currently enabled */
23
+ enabled: boolean;
24
+ /** Permission mode — defaults to "bypassPermissions" for autonomy */
25
+ permissionMode: string;
26
+ /** Codex-only: enable internet access */
27
+ codexInternetAccess?: boolean;
28
+
29
+ // ── Tracking ──
30
+ createdAt: number;
31
+ updatedAt: number;
32
+ /** Last time this job was triggered */
33
+ lastRunAt?: number;
34
+ /** Session ID of the last execution */
35
+ lastSessionId?: string;
36
+ /** Number of consecutive failures */
37
+ consecutiveFailures: number;
38
+ /** Total number of runs */
39
+ totalRuns: number;
40
+ }
41
+
42
+ export interface CronJobExecution {
43
+ /** The session ID created for this execution */
44
+ sessionId: string;
45
+ /** The job ID that triggered this */
46
+ jobId: string;
47
+ /** When the execution started */
48
+ startedAt: number;
49
+ /** When the execution completed (result received) */
50
+ completedAt?: number;
51
+ /** Whether the execution succeeded */
52
+ success?: boolean;
53
+ /** Error message if it failed */
54
+ error?: string;
55
+ /** Cost in USD */
56
+ costUsd?: number;
57
+ }
58
+
59
+ /** Input for creating a cron job (without auto-generated fields) */
60
+ export type CronJobCreateInput = Omit<
61
+ CronJob,
62
+ "id" | "createdAt" | "updatedAt" | "consecutiveFailures" | "totalRuns" | "lastRunAt" | "lastSessionId"
63
+ >;
@@ -0,0 +1,268 @@
1
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ let tempDir: string;
6
+ let envManager: typeof import("./env-manager.js");
7
+
8
+ const mockHomedir = vi.hoisted(() => {
9
+ let dir = "";
10
+ return {
11
+ get: () => dir,
12
+ set: (d: string) => {
13
+ dir = d;
14
+ },
15
+ };
16
+ });
17
+
18
+ vi.mock("node:os", async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import("node:os")>();
20
+ return {
21
+ ...actual,
22
+ homedir: () => mockHomedir.get(),
23
+ };
24
+ });
25
+
26
+ beforeEach(async () => {
27
+ tempDir = mkdtempSync(join(tmpdir(), "env-test-"));
28
+ mockHomedir.set(tempDir);
29
+ vi.resetModules();
30
+ envManager = await import("./env-manager.js");
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(tempDir, { recursive: true, force: true });
35
+ });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helper to get the envs directory path used by the module
39
+ // ---------------------------------------------------------------------------
40
+ function envsDir(): string {
41
+ return join(tempDir, ".companion", "envs");
42
+ }
43
+
44
+ // ===========================================================================
45
+ // Slugification (tested indirectly via createEnv)
46
+ // ===========================================================================
47
+ describe("slugification via createEnv", () => {
48
+ it("converts spaces to hyphens and lowercases", async () => {
49
+ const env = envManager.createEnv("My App");
50
+ expect(env.slug).toBe("my-app");
51
+ });
52
+
53
+ it("strips special characters", async () => {
54
+ const env = envManager.createEnv("Hello World! @#$%");
55
+ expect(env.slug).toBe("hello-world");
56
+ });
57
+
58
+ it("collapses consecutive hyphens", async () => {
59
+ const env = envManager.createEnv("a --- b");
60
+ expect(env.slug).toBe("a-b");
61
+ });
62
+
63
+ it("trims leading and trailing hyphens", async () => {
64
+ const env = envManager.createEnv(" -cool env- ");
65
+ expect(env.slug).toBe("cool-env");
66
+ });
67
+
68
+ it("throws when name is empty string", () => {
69
+ expect(() => envManager.createEnv("")).toThrow("Environment name is required");
70
+ });
71
+
72
+ it("throws when name is only whitespace", () => {
73
+ expect(() => envManager.createEnv(" ")).toThrow("Environment name is required");
74
+ });
75
+
76
+ it("throws when name contains no alphanumeric characters", () => {
77
+ expect(() => envManager.createEnv("@#$%^&")).toThrow(
78
+ "Environment name must contain alphanumeric characters",
79
+ );
80
+ });
81
+ });
82
+
83
+ // ===========================================================================
84
+ // listEnvs
85
+ // ===========================================================================
86
+ describe("listEnvs", () => {
87
+ it("returns empty array when no envs exist", () => {
88
+ const result = envManager.listEnvs();
89
+ expect(result).toEqual([]);
90
+ });
91
+
92
+ it("returns envs sorted alphabetically by name", () => {
93
+ envManager.createEnv("Zebra");
94
+ envManager.createEnv("Alpha");
95
+ envManager.createEnv("Mango");
96
+
97
+ const result = envManager.listEnvs();
98
+ expect(result.map((e) => e.name)).toEqual(["Alpha", "Mango", "Zebra"]);
99
+ });
100
+
101
+ it("skips corrupt JSON files", () => {
102
+ // Create a valid env first
103
+ envManager.createEnv("Valid");
104
+
105
+ // Write a corrupt file directly into the envs directory
106
+ writeFileSync(join(envsDir(), "corrupt.json"), "NOT VALID JSON{{{", "utf-8");
107
+
108
+ const result = envManager.listEnvs();
109
+ expect(result).toHaveLength(1);
110
+ expect(result[0].name).toBe("Valid");
111
+ });
112
+ });
113
+
114
+ // ===========================================================================
115
+ // getEnv
116
+ // ===========================================================================
117
+ describe("getEnv", () => {
118
+ it("returns the env when it exists", () => {
119
+ envManager.createEnv("My Service", { PORT: "3000" });
120
+
121
+ const result = envManager.getEnv("my-service");
122
+ expect(result).not.toBeNull();
123
+ expect(result!.name).toBe("My Service");
124
+ expect(result!.slug).toBe("my-service");
125
+ expect(result!.variables).toEqual({ PORT: "3000" });
126
+ });
127
+
128
+ it("returns null when the env does not exist", () => {
129
+ const result = envManager.getEnv("nonexistent");
130
+ expect(result).toBeNull();
131
+ });
132
+ });
133
+
134
+ // ===========================================================================
135
+ // createEnv
136
+ // ===========================================================================
137
+ describe("createEnv", () => {
138
+ it("returns an env with correct structure and timestamps", () => {
139
+ const before = Date.now();
140
+ const env = envManager.createEnv("Production", { NODE_ENV: "production" });
141
+ const after = Date.now();
142
+
143
+ expect(env.name).toBe("Production");
144
+ expect(env.slug).toBe("production");
145
+ expect(env.variables).toEqual({ NODE_ENV: "production" });
146
+ expect(env.createdAt).toBeGreaterThanOrEqual(before);
147
+ expect(env.createdAt).toBeLessThanOrEqual(after);
148
+ expect(env.updatedAt).toBe(env.createdAt);
149
+ });
150
+
151
+ it("persists the env to disk as JSON", () => {
152
+ envManager.createEnv("Disk Check");
153
+
154
+ const raw = readFileSync(join(envsDir(), "disk-check.json"), "utf-8");
155
+ const parsed = JSON.parse(raw);
156
+ expect(parsed.name).toBe("Disk Check");
157
+ expect(parsed.slug).toBe("disk-check");
158
+ });
159
+
160
+ it("defaults variables to empty object", () => {
161
+ const env = envManager.createEnv("No Vars");
162
+ expect(env.variables).toEqual({});
163
+ });
164
+
165
+ it("throws when creating a duplicate slug", () => {
166
+ envManager.createEnv("My App");
167
+ expect(() => envManager.createEnv("My App")).toThrow(
168
+ 'An environment with a similar name already exists ("my-app")',
169
+ );
170
+ });
171
+
172
+ it("trims the name before saving", () => {
173
+ const env = envManager.createEnv(" Spaced Out ");
174
+ expect(env.name).toBe("Spaced Out");
175
+ expect(env.slug).toBe("spaced-out");
176
+ });
177
+ });
178
+
179
+ // ===========================================================================
180
+ // updateEnv
181
+ // ===========================================================================
182
+ describe("updateEnv", () => {
183
+ it("updates name and variables", () => {
184
+ envManager.createEnv("Original", { KEY: "old" });
185
+
186
+ const updated = envManager.updateEnv("original", {
187
+ name: "Renamed",
188
+ variables: { KEY: "new" },
189
+ });
190
+
191
+ expect(updated).not.toBeNull();
192
+ expect(updated!.name).toBe("Renamed");
193
+ expect(updated!.slug).toBe("renamed");
194
+ expect(updated!.variables).toEqual({ KEY: "new" });
195
+ });
196
+
197
+ it("renames the file on disk when slug changes", () => {
198
+ envManager.createEnv("Old Name");
199
+
200
+ envManager.updateEnv("old-name", { name: "New Name" });
201
+
202
+ // Old file should be gone, new file should exist
203
+ const oldPath = join(envsDir(), "old-name.json");
204
+ const newPath = join(envsDir(), "new-name.json");
205
+
206
+ expect(() => readFileSync(oldPath, "utf-8")).toThrow();
207
+ const parsed = JSON.parse(readFileSync(newPath, "utf-8"));
208
+ expect(parsed.name).toBe("New Name");
209
+ expect(parsed.slug).toBe("new-name");
210
+ });
211
+
212
+ it("throws on slug collision during rename", () => {
213
+ envManager.createEnv("Alpha");
214
+ envManager.createEnv("Beta");
215
+
216
+ expect(() => envManager.updateEnv("alpha", { name: "Beta" })).toThrow(
217
+ 'An environment with a similar name already exists ("beta")',
218
+ );
219
+ });
220
+
221
+ it("returns null for a non-existent slug", () => {
222
+ const result = envManager.updateEnv("ghost", { name: "New" });
223
+ expect(result).toBeNull();
224
+ });
225
+
226
+ it("preserves createdAt and advances updatedAt", async () => {
227
+ const env = envManager.createEnv("Timestamps");
228
+ const originalCreatedAt = env.createdAt;
229
+
230
+ // Small delay to ensure Date.now() advances
231
+ await new Promise((r) => setTimeout(r, 10));
232
+
233
+ const updated = envManager.updateEnv("timestamps", { variables: { A: "1" } });
234
+
235
+ expect(updated).not.toBeNull();
236
+ expect(updated!.createdAt).toBe(originalCreatedAt);
237
+ expect(updated!.updatedAt).toBeGreaterThan(originalCreatedAt);
238
+ });
239
+
240
+ it("keeps existing variables when only name is updated", () => {
241
+ envManager.createEnv("Keep Vars", { SECRET: "abc" });
242
+
243
+ const updated = envManager.updateEnv("keep-vars", { name: "Kept Vars" });
244
+ expect(updated!.variables).toEqual({ SECRET: "abc" });
245
+ });
246
+ });
247
+
248
+ // Docker-related tests (getEffectiveImage, updateBuildStatus, createEnv with docker options)
249
+ // have been moved to sandbox-manager.test.ts as part of the sandbox/environment separation.
250
+
251
+ // ===========================================================================
252
+ // deleteEnv
253
+ // ===========================================================================
254
+ describe("deleteEnv", () => {
255
+ it("deletes an existing env and returns true", () => {
256
+ envManager.createEnv("To Delete");
257
+ const result = envManager.deleteEnv("to-delete");
258
+ expect(result).toBe(true);
259
+
260
+ // Confirm it is gone
261
+ expect(envManager.getEnv("to-delete")).toBeNull();
262
+ });
263
+
264
+ it("returns false when the env does not exist", () => {
265
+ const result = envManager.deleteEnv("missing");
266
+ expect(result).toBe(false);
267
+ });
268
+ });
@@ -0,0 +1,161 @@
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 { COMPANION_HOME } from "./paths.js";
11
+
12
+ // ─── Types ──────────────────────────────────────────────────────────────────
13
+
14
+ export interface CompanionEnv {
15
+ name: string;
16
+ slug: string;
17
+ variables: Record<string, string>;
18
+
19
+ createdAt: number;
20
+ updatedAt: number;
21
+ }
22
+
23
+ /** Fields that can be updated via the update API */
24
+ export interface EnvUpdateFields {
25
+ name?: string;
26
+ variables?: Record<string, string>;
27
+ }
28
+
29
+ // ─── Paths ──────────────────────────────────────────────────────────────────
30
+
31
+ const ENVS_DIR = join(COMPANION_HOME, "envs");
32
+
33
+ function ensureDir(): void {
34
+ mkdirSync(ENVS_DIR, { recursive: true });
35
+ }
36
+
37
+ /** Validate that a slug contains only safe characters (prevents path traversal) */
38
+ function validateSlug(slug: string): void {
39
+ if (!/^[a-z0-9-]+$/.test(slug)) {
40
+ throw new Error("Invalid slug: must contain only lowercase alphanumeric characters and hyphens");
41
+ }
42
+ }
43
+
44
+ function filePath(slug: string): string {
45
+ validateSlug(slug);
46
+ return join(ENVS_DIR, `${slug}.json`);
47
+ }
48
+
49
+ // ─── Helpers ────────────────────────────────────────────────────────────────
50
+
51
+ function slugify(name: string): string {
52
+ return name
53
+ .toLowerCase()
54
+ .replace(/\s+/g, "-")
55
+ .replace(/[^a-z0-9-]/g, "")
56
+ .replace(/-+/g, "-")
57
+ .replace(/^-|-$/g, "");
58
+ }
59
+
60
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
61
+
62
+ export function listEnvs(): CompanionEnv[] {
63
+ ensureDir();
64
+ try {
65
+ const files = readdirSync(ENVS_DIR).filter((f) => f.endsWith(".json"));
66
+ const envs: CompanionEnv[] = [];
67
+ for (const file of files) {
68
+ try {
69
+ const raw = readFileSync(join(ENVS_DIR, file), "utf-8");
70
+ envs.push(JSON.parse(raw));
71
+ } catch {
72
+ // Skip corrupt files
73
+ }
74
+ }
75
+ envs.sort((a, b) => a.name.localeCompare(b.name));
76
+ return envs;
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ export function getEnv(slug: string): CompanionEnv | null {
83
+ ensureDir();
84
+ try {
85
+ const raw = readFileSync(filePath(slug), "utf-8");
86
+ return JSON.parse(raw) as CompanionEnv;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function createEnv(
93
+ name: string,
94
+ variables: Record<string, string> = {},
95
+ ): CompanionEnv {
96
+ if (!name || !name.trim()) throw new Error("Environment name is required");
97
+ const slug = slugify(name.trim());
98
+ if (!slug) throw new Error("Environment name must contain alphanumeric characters");
99
+
100
+ ensureDir();
101
+ if (existsSync(filePath(slug))) {
102
+ throw new Error(`An environment with a similar name already exists ("${slug}")`);
103
+ }
104
+
105
+ const now = Date.now();
106
+ const env: CompanionEnv = {
107
+ name: name.trim(),
108
+ slug,
109
+ variables,
110
+ createdAt: now,
111
+ updatedAt: now,
112
+ };
113
+
114
+ writeFileSync(filePath(slug), JSON.stringify(env, null, 2), "utf-8");
115
+ return env;
116
+ }
117
+
118
+ export function updateEnv(
119
+ slug: string,
120
+ updates: EnvUpdateFields,
121
+ ): CompanionEnv | null {
122
+ ensureDir();
123
+ const existing = getEnv(slug);
124
+ if (!existing) return null;
125
+
126
+ const newName = updates.name?.trim() || existing.name;
127
+ const newSlug = slugify(newName);
128
+ if (!newSlug) throw new Error("Environment name must contain alphanumeric characters");
129
+
130
+ // If name changed, check for slug collision with a different env
131
+ if (newSlug !== slug && existsSync(filePath(newSlug))) {
132
+ throw new Error(`An environment with a similar name already exists ("${newSlug}")`);
133
+ }
134
+
135
+ const env: CompanionEnv = {
136
+ ...existing,
137
+ name: newName,
138
+ slug: newSlug,
139
+ variables: updates.variables ?? existing.variables,
140
+ updatedAt: Date.now(),
141
+ };
142
+
143
+ // If slug changed, delete old file
144
+ if (newSlug !== slug) {
145
+ try { unlinkSync(filePath(slug)); } catch { /* ok */ }
146
+ }
147
+
148
+ writeFileSync(filePath(newSlug), JSON.stringify(env, null, 2), "utf-8");
149
+ return env;
150
+ }
151
+
152
+ export function deleteEnv(slug: string): boolean {
153
+ ensureDir();
154
+ if (!existsSync(filePath(slug))) return false;
155
+ try {
156
+ unlinkSync(filePath(slug));
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
@@ -0,0 +1,64 @@
1
+ // Typed event map for the Companion internal event bus.
2
+ // Each key is a namespaced event name; values are the payload passed to handlers.
3
+
4
+ import type { BrowserIncomingMessage } from "./session-types.js";
5
+ import type { CodexAdapter } from "./codex-adapter.js";
6
+ import type { SessionPhase } from "./session-state-machine.js";
7
+
8
+ export interface CompanionEventMap {
9
+ // ── Session lifecycle ──────────────────────────────────────────────
10
+
11
+ /** CLI reported its internal session ID (used for --resume). */
12
+ "session:cli-id-received": { sessionId: string; cliSessionId: string };
13
+
14
+ /** CLI/Codex process exited. */
15
+ "session:exited": { sessionId: string; exitCode: number | null };
16
+
17
+ /** CLI WebSocket disconnected and a browser needs a relaunch. */
18
+ "session:relaunch-needed": { sessionId: string };
19
+
20
+ /** Idle-kill threshold reached with no connected browsers. */
21
+ "session:idle-kill": { sessionId: string };
22
+
23
+ /** First non-error turn completed (triggers auto-naming). */
24
+ "session:first-turn-completed": {
25
+ sessionId: string;
26
+ firstUserMessage: string;
27
+ };
28
+
29
+ /** Git info resolved for a session (branch and cwd known). */
30
+ "session:git-info-ready": { sessionId: string; cwd: string; branch: string };
31
+
32
+ /** Session phase changed (formal state machine transition). */
33
+ "session:phase-changed": {
34
+ sessionId: string;
35
+ from: SessionPhase;
36
+ to: SessionPhase;
37
+ trigger: string;
38
+ };
39
+
40
+ // ── Backend integration ────────────────────────────────────────────
41
+
42
+ /** Codex adapter created and ready to be attached to WsBridge. */
43
+ "backend:codex-adapter-created": {
44
+ sessionId: string;
45
+ adapter: CodexAdapter;
46
+ };
47
+
48
+ // ── Per-session messages (high volume) ─────────────────────────────
49
+
50
+ /** An assistant message was processed and broadcast to browsers. */
51
+ "message:assistant": {
52
+ sessionId: string;
53
+ message: BrowserIncomingMessage;
54
+ };
55
+
56
+ /** A stream event was processed and broadcast to browsers. */
57
+ "message:stream_event": {
58
+ sessionId: string;
59
+ message: BrowserIncomingMessage;
60
+ };
61
+
62
+ /** A result (turn completion) was processed and broadcast to browsers. */
63
+ "message:result": { sessionId: string; message: BrowserIncomingMessage };
64
+ }