@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,162 @@
1
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import {
5
+ listMappings,
6
+ getMapping,
7
+ upsertMapping,
8
+ removeMapping,
9
+ _resetForTest,
10
+ } from "./linear-project-manager.js";
11
+
12
+ let tempDir: string;
13
+ let filePath: string;
14
+
15
+ beforeEach(() => {
16
+ tempDir = mkdtempSync(join(tmpdir(), "linear-project-manager-test-"));
17
+ filePath = join(tempDir, "linear-projects.json");
18
+ _resetForTest(filePath);
19
+ });
20
+
21
+ afterEach(() => {
22
+ rmSync(tempDir, { recursive: true, force: true });
23
+ _resetForTest();
24
+ });
25
+
26
+ describe("linear-project-manager", () => {
27
+ it("returns empty list when file is missing", () => {
28
+ expect(listMappings()).toEqual([]);
29
+ });
30
+
31
+ it("returns null for unknown repo root", () => {
32
+ expect(getMapping("/unknown/repo")).toBeNull();
33
+ });
34
+
35
+ it("upsert creates a new mapping", () => {
36
+ const mapping = upsertMapping("/home/user/project", {
37
+ projectId: "proj-uuid-1",
38
+ projectName: "My Feature",
39
+ });
40
+
41
+ expect(mapping.repoRoot).toBe("/home/user/project");
42
+ expect(mapping.projectId).toBe("proj-uuid-1");
43
+ expect(mapping.projectName).toBe("My Feature");
44
+ expect(mapping.createdAt).toBeGreaterThan(0);
45
+ expect(mapping.updatedAt).toBe(mapping.createdAt);
46
+ });
47
+
48
+ it("upsert updates an existing mapping", () => {
49
+ const first = upsertMapping("/home/user/project", {
50
+ projectId: "proj-uuid-1",
51
+ projectName: "My Feature",
52
+ });
53
+
54
+ const second = upsertMapping("/home/user/project", {
55
+ projectId: "proj-uuid-2",
56
+ projectName: "New Feature",
57
+ });
58
+
59
+ expect(second.repoRoot).toBe("/home/user/project");
60
+ expect(second.projectId).toBe("proj-uuid-2");
61
+ expect(second.projectName).toBe("New Feature");
62
+ // createdAt should be preserved from the first mapping
63
+ expect(second.createdAt).toBe(first.createdAt);
64
+ expect(second.updatedAt).toBeGreaterThanOrEqual(first.updatedAt);
65
+
66
+ // Should still be only one mapping
67
+ expect(listMappings()).toHaveLength(1);
68
+ });
69
+
70
+ it("getMapping retrieves a stored mapping", () => {
71
+ upsertMapping("/home/user/project", {
72
+ projectId: "proj-uuid-1",
73
+ projectName: "My Feature",
74
+ });
75
+
76
+ const mapping = getMapping("/home/user/project");
77
+ expect(mapping).not.toBeNull();
78
+ expect(mapping!.projectName).toBe("My Feature");
79
+ });
80
+
81
+ it("removeMapping deletes an entry and returns true", () => {
82
+ upsertMapping("/home/user/project", {
83
+ projectId: "proj-uuid-1",
84
+ projectName: "My Feature",
85
+ });
86
+
87
+ expect(removeMapping("/home/user/project")).toBe(true);
88
+ expect(listMappings()).toHaveLength(0);
89
+ expect(getMapping("/home/user/project")).toBeNull();
90
+ });
91
+
92
+ it("removeMapping returns false for unknown repo", () => {
93
+ expect(removeMapping("/unknown/repo")).toBe(false);
94
+ });
95
+
96
+ it("normalizes trailing slashes on repoRoot", () => {
97
+ upsertMapping("/home/user/project/", {
98
+ projectId: "proj-uuid-1",
99
+ projectName: "My Feature",
100
+ });
101
+
102
+ // Should find it without trailing slash
103
+ expect(getMapping("/home/user/project")).not.toBeNull();
104
+ expect(getMapping("/home/user/project/")).not.toBeNull();
105
+
106
+ // Should be only one mapping
107
+ expect(listMappings()).toHaveLength(1);
108
+ expect(listMappings()[0].repoRoot).toBe("/home/user/project");
109
+ });
110
+
111
+ it("persists to disk and survives reload", () => {
112
+ upsertMapping("/home/user/project", {
113
+ projectId: "proj-uuid-1",
114
+ projectName: "My Feature",
115
+ });
116
+
117
+ // Verify file written to disk
118
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
119
+ expect(raw).toHaveLength(1);
120
+ expect(raw[0].projectName).toBe("My Feature");
121
+
122
+ // Reset and reload from disk
123
+ _resetForTest(filePath);
124
+ const mapping = getMapping("/home/user/project");
125
+ expect(mapping).not.toBeNull();
126
+ expect(mapping!.projectName).toBe("My Feature");
127
+ });
128
+
129
+ it("handles corrupt JSON file gracefully", () => {
130
+ writeFileSync(filePath, "not-json", "utf-8");
131
+ _resetForTest(filePath);
132
+
133
+ expect(listMappings()).toEqual([]);
134
+ });
135
+
136
+ it("handles non-array JSON file gracefully", () => {
137
+ writeFileSync(filePath, JSON.stringify({ foo: "bar" }), "utf-8");
138
+ _resetForTest(filePath);
139
+
140
+ expect(listMappings()).toEqual([]);
141
+ });
142
+
143
+ it("manages multiple mappings", () => {
144
+ upsertMapping("/repo/alpha", {
145
+ projectId: "p1",
146
+ projectName: "Alpha Project",
147
+ });
148
+ upsertMapping("/repo/beta", {
149
+ projectId: "p2",
150
+ projectName: "Beta Project",
151
+ });
152
+
153
+ expect(listMappings()).toHaveLength(2);
154
+ expect(getMapping("/repo/alpha")!.projectName).toBe("Alpha Project");
155
+ expect(getMapping("/repo/beta")!.projectName).toBe("Beta Project");
156
+
157
+ removeMapping("/repo/alpha");
158
+ expect(listMappings()).toHaveLength(1);
159
+ expect(getMapping("/repo/alpha")).toBeNull();
160
+ expect(getMapping("/repo/beta")!.projectName).toBe("Beta Project");
161
+ });
162
+ });
@@ -0,0 +1,111 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { COMPANION_HOME } from "./paths.js";
9
+
10
+ export interface LinearProjectMapping {
11
+ /** Normalized git repo root path (the key) */
12
+ repoRoot: string;
13
+ /** Linear project UUID */
14
+ projectId: string;
15
+ /** Human-readable project name */
16
+ projectName: string;
17
+ /** When the mapping was created */
18
+ createdAt: number;
19
+ /** When the mapping was last updated */
20
+ updatedAt: number;
21
+ }
22
+
23
+ const DEFAULT_PATH = join(COMPANION_HOME, "linear-projects.json");
24
+
25
+ let loaded = false;
26
+ let filePath = DEFAULT_PATH;
27
+ let mappings: LinearProjectMapping[] = [];
28
+
29
+ function normalizeRoot(root: string): string {
30
+ return root.replace(/\/+$/, "") || "/";
31
+ }
32
+
33
+ function ensureLoaded(): void {
34
+ if (loaded) return;
35
+ try {
36
+ if (existsSync(filePath)) {
37
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
38
+ if (Array.isArray(raw)) {
39
+ mappings = raw.filter(
40
+ (m: unknown): m is LinearProjectMapping =>
41
+ typeof m === "object" &&
42
+ m !== null &&
43
+ typeof (m as LinearProjectMapping).repoRoot === "string" &&
44
+ typeof (m as LinearProjectMapping).projectId === "string",
45
+ );
46
+ } else {
47
+ mappings = [];
48
+ }
49
+ }
50
+ } catch {
51
+ mappings = [];
52
+ }
53
+ loaded = true;
54
+ }
55
+
56
+ function persist(): void {
57
+ mkdirSync(dirname(filePath), { recursive: true });
58
+ writeFileSync(filePath, JSON.stringify(mappings, null, 2), "utf-8");
59
+ }
60
+
61
+ export function listMappings(): LinearProjectMapping[] {
62
+ ensureLoaded();
63
+ return [...mappings];
64
+ }
65
+
66
+ export function getMapping(repoRoot: string): LinearProjectMapping | null {
67
+ ensureLoaded();
68
+ const key = normalizeRoot(repoRoot);
69
+ return mappings.find((m) => m.repoRoot === key) ?? null;
70
+ }
71
+
72
+ export function upsertMapping(
73
+ repoRoot: string,
74
+ data: { projectId: string; projectName: string },
75
+ ): LinearProjectMapping {
76
+ ensureLoaded();
77
+ const key = normalizeRoot(repoRoot);
78
+ const now = Date.now();
79
+ const existing = mappings.find((m) => m.repoRoot === key);
80
+ if (existing) {
81
+ existing.projectId = data.projectId;
82
+ existing.projectName = data.projectName;
83
+ existing.updatedAt = now;
84
+ } else {
85
+ mappings.push({
86
+ repoRoot: key,
87
+ projectId: data.projectId,
88
+ projectName: data.projectName,
89
+ createdAt: now,
90
+ updatedAt: now,
91
+ });
92
+ }
93
+ persist();
94
+ return mappings.find((m) => m.repoRoot === key)!;
95
+ }
96
+
97
+ export function removeMapping(repoRoot: string): boolean {
98
+ ensureLoaded();
99
+ const key = normalizeRoot(repoRoot);
100
+ const idx = mappings.findIndex((m) => m.repoRoot === key);
101
+ if (idx === -1) return false;
102
+ mappings.splice(idx, 1);
103
+ persist();
104
+ return true;
105
+ }
106
+
107
+ export function _resetForTest(customPath?: string): void {
108
+ loaded = false;
109
+ filePath = customPath || DEFAULT_PATH;
110
+ mappings = [];
111
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildLinearSystemPrompt, buildLinearOAuthSystemPrompt } from "./linear-prompt-builder.js";
3
+
4
+ describe("buildLinearSystemPrompt", () => {
5
+ const connection = {
6
+ workspaceName: "Acme Corp",
7
+ viewerName: "Jane Doe",
8
+ viewerEmail: "jane@acme.com",
9
+ };
10
+
11
+ const issue = {
12
+ identifier: "ENG-42",
13
+ title: "Fix login redirect",
14
+ stateName: "In Progress",
15
+ teamName: "Engineering",
16
+ url: "https://linear.app/acme/issue/ENG-42",
17
+ };
18
+
19
+ it("includes workspace info and API instructions", () => {
20
+ // Verifies the core prompt contains workspace context and API usage guidance
21
+ const prompt = buildLinearSystemPrompt(connection);
22
+ expect(prompt).toContain("LINEAR_API_KEY");
23
+ expect(prompt).toContain("Acme Corp");
24
+ expect(prompt).toContain("Jane Doe");
25
+ expect(prompt).toContain("jane@acme.com");
26
+ expect(prompt).toContain("https://api.linear.app/graphql");
27
+ expect(prompt).toContain("Authorization: Bearer $LINEAR_API_KEY");
28
+ });
29
+
30
+ it("includes issue context when provided", () => {
31
+ // When a Linear issue is linked, the prompt should include issue details
32
+ const prompt = buildLinearSystemPrompt(connection, issue);
33
+ expect(prompt).toContain("ENG-42");
34
+ expect(prompt).toContain("Fix login redirect");
35
+ expect(prompt).toContain("In Progress");
36
+ expect(prompt).toContain("Engineering");
37
+ expect(prompt).toContain("https://linear.app/acme/issue/ENG-42");
38
+ });
39
+
40
+ it("omits issue section when no issue provided", () => {
41
+ // Without an issue, the prompt should only contain workspace + API info
42
+ const prompt = buildLinearSystemPrompt(connection);
43
+ expect(prompt).not.toContain("Linked issue:");
44
+ expect(prompt).not.toContain("Issue URL:");
45
+ });
46
+
47
+ it("includes common operations guidance", () => {
48
+ // The prompt should tell the agent what it can do with the Linear API
49
+ const prompt = buildLinearSystemPrompt(connection);
50
+ expect(prompt).toContain("add comments");
51
+ expect(prompt).toContain("transition issue status");
52
+ expect(prompt).toContain("read issue details");
53
+ });
54
+
55
+ it("returns a multi-line string with newlines", () => {
56
+ // The prompt must be multi-line for readability in the system prompt
57
+ const prompt = buildLinearSystemPrompt(connection, issue);
58
+ const lines = prompt.split("\n");
59
+ expect(lines.length).toBeGreaterThan(3);
60
+ });
61
+ });
62
+
63
+ describe("buildLinearOAuthSystemPrompt", () => {
64
+ it("includes OAuth token guidance for app-scoped Linear access", () => {
65
+ const prompt = buildLinearOAuthSystemPrompt({ name: "Enrich" });
66
+
67
+ expect(prompt).toContain("LINEAR_OAUTH_ACCESS_TOKEN");
68
+ expect(prompt).toContain('Connected Linear OAuth app: "Enrich"');
69
+ expect(prompt).toContain("actor=app");
70
+ expect(prompt).toContain("https://api.linear.app/graphql");
71
+ expect(prompt).toContain("Authorization: Bearer $LINEAR_OAUTH_ACCESS_TOKEN");
72
+ expect(prompt).toContain("LINEAR_API_KEY");
73
+ });
74
+ });
@@ -0,0 +1,61 @@
1
+ // ─── Linear System Prompt Builder ─────────────────────────────────────────────
2
+ //
3
+ // Builds a system prompt snippet that tells Claude Code / Codex about the
4
+ // LINEAR_API_KEY environment variable and the linked Linear issue context.
5
+ // This is injected via the `initialize` control request's `appendSystemPrompt`
6
+ // field (Claude Code) or the `instructions` field in `thread/start` (Codex).
7
+
8
+ interface LinearConnectionContext {
9
+ workspaceName: string;
10
+ viewerName: string;
11
+ viewerEmail: string;
12
+ }
13
+
14
+ interface LinearIssueContext {
15
+ identifier: string;
16
+ title: string;
17
+ stateName: string;
18
+ teamName: string;
19
+ url: string;
20
+ }
21
+
22
+ export function buildLinearSystemPrompt(
23
+ connection: LinearConnectionContext,
24
+ issue?: LinearIssueContext,
25
+ ): string {
26
+ const lines = [
27
+ "You have access to the Linear API via the LINEAR_API_KEY environment variable.",
28
+ `Connected workspace: "${connection.workspaceName}" (viewer: ${connection.viewerName}, ${connection.viewerEmail})`,
29
+ ];
30
+ if (issue) {
31
+ lines.push(
32
+ `Linked issue: ${issue.identifier} — "${issue.title}" (status: ${issue.stateName}, team: ${issue.teamName})`,
33
+ );
34
+ lines.push(`Issue URL: ${issue.url}`);
35
+ }
36
+ lines.push("");
37
+ lines.push(
38
+ "You can use this key to call the Linear GraphQL API at https://api.linear.app/graphql.",
39
+ );
40
+ lines.push(
41
+ "Use the Authorization header: `Authorization: Bearer $LINEAR_API_KEY`",
42
+ );
43
+ lines.push(
44
+ "Common operations: add comments, transition issue status, read issue details, update issue fields.",
45
+ );
46
+ return lines.join("\n");
47
+ }
48
+
49
+ export function buildLinearOAuthSystemPrompt(connection: { name: string }): string {
50
+ const lines = [
51
+ "You have access to the Linear GraphQL API via the LINEAR_OAUTH_ACCESS_TOKEN environment variable.",
52
+ `Connected Linear OAuth app: "${connection.name}"`,
53
+ "This token was authorized with `actor=app`, so Linear mutations run as the installed app rather than as the installing user.",
54
+ "",
55
+ "Call the Linear GraphQL API at https://api.linear.app/graphql.",
56
+ "Use the Authorization header: `Authorization: Bearer $LINEAR_OAUTH_ACCESS_TOKEN`",
57
+ "For compatibility with existing tooling, the same token is also available as `LINEAR_API_KEY`.",
58
+ "Common operations: read issue details, add comments, transition issue status, update issue fields, and create follow-up issues.",
59
+ ];
60
+ return lines.join("\n");
61
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // Redirect COMPANION_HOME to a temp directory so tests don't touch real config
7
+ const TEST_HOME = join(tmpdir(), `linear-staging-test-${Date.now()}`);
8
+ process.env.COMPANION_HOME = TEST_HOME;
9
+
10
+ // Import after setting env var so the module picks up the test directory
11
+ const staging = await import("./linear-staging.js");
12
+
13
+ describe("linear-staging", () => {
14
+ beforeEach(() => {
15
+ mkdirSync(TEST_HOME, { recursive: true });
16
+ });
17
+
18
+ afterEach(() => {
19
+ try {
20
+ rmSync(TEST_HOME, { recursive: true, force: true });
21
+ } catch { /* ok */ }
22
+ });
23
+
24
+ describe("createSlot", () => {
25
+ it("creates a slot and returns a hex ID", () => {
26
+ const id = staging.createSlot({
27
+ clientId: "cid",
28
+ clientSecret: "csecret",
29
+ webhookSecret: "wsecret",
30
+ });
31
+ expect(id).toMatch(/^[0-9a-f]{32}$/);
32
+ });
33
+
34
+ it("creates the staging directory and JSON file", () => {
35
+ const id = staging.createSlot({
36
+ clientId: "cid",
37
+ clientSecret: "csecret",
38
+ webhookSecret: "wsecret",
39
+ });
40
+ const files = readdirSync(join(TEST_HOME, "staging"));
41
+ expect(files).toContain(`${id}.json`);
42
+ });
43
+ });
44
+
45
+ describe("getSlot", () => {
46
+ it("returns the slot with matching credentials", () => {
47
+ const id = staging.createSlot({
48
+ clientId: "my-client",
49
+ clientSecret: "my-secret",
50
+ webhookSecret: "my-webhook",
51
+ });
52
+ const slot = staging.getSlot(id);
53
+ expect(slot).not.toBeNull();
54
+ expect(slot!.clientId).toBe("my-client");
55
+ expect(slot!.clientSecret).toBe("my-secret");
56
+ expect(slot!.webhookSecret).toBe("my-webhook");
57
+ expect(slot!.accessToken).toBe("");
58
+ expect(slot!.refreshToken).toBe("");
59
+ });
60
+
61
+ it("returns null for a non-existent slot", () => {
62
+ expect(staging.getSlot("nonexistent")).toBeNull();
63
+ });
64
+ });
65
+
66
+ describe("updateSlotTokens", () => {
67
+ it("updates the access and refresh tokens", () => {
68
+ const id = staging.createSlot({
69
+ clientId: "cid",
70
+ clientSecret: "csecret",
71
+ webhookSecret: "wsecret",
72
+ });
73
+
74
+ const updated = staging.updateSlotTokens(id, {
75
+ accessToken: "at_123",
76
+ refreshToken: "rt_456",
77
+ });
78
+ expect(updated).toBe(true);
79
+
80
+ const slot = staging.getSlot(id);
81
+ expect(slot!.accessToken).toBe("at_123");
82
+ expect(slot!.refreshToken).toBe("rt_456");
83
+ // Original credentials are preserved
84
+ expect(slot!.clientId).toBe("cid");
85
+ });
86
+
87
+ it("returns false for a non-existent slot", () => {
88
+ expect(staging.updateSlotTokens("nope", { accessToken: "a", refreshToken: "r" })).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe("consumeSlot", () => {
93
+ it("returns the slot and deletes it", () => {
94
+ const id = staging.createSlot({
95
+ clientId: "cid",
96
+ clientSecret: "csecret",
97
+ webhookSecret: "wsecret",
98
+ });
99
+
100
+ const slot = staging.consumeSlot(id);
101
+ expect(slot).not.toBeNull();
102
+ expect(slot!.clientId).toBe("cid");
103
+
104
+ // Slot is gone after consuming
105
+ expect(staging.getSlot(id)).toBeNull();
106
+ });
107
+
108
+ it("returns null for a non-existent slot", () => {
109
+ expect(staging.consumeSlot("nonexistent")).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("deleteSlot", () => {
114
+ it("deletes an existing slot", () => {
115
+ const id = staging.createSlot({
116
+ clientId: "cid",
117
+ clientSecret: "csecret",
118
+ webhookSecret: "wsecret",
119
+ });
120
+ expect(staging.deleteSlot(id)).toBe(true);
121
+ expect(staging.getSlot(id)).toBeNull();
122
+ });
123
+
124
+ it("returns false for a non-existent slot", () => {
125
+ expect(staging.deleteSlot("nonexistent")).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("multiple slots", () => {
130
+ it("supports multiple concurrent staging slots", () => {
131
+ // Validates that multiple wizards can run in parallel
132
+ const id1 = staging.createSlot({
133
+ clientId: "client-A",
134
+ clientSecret: "secret-A",
135
+ webhookSecret: "webhook-A",
136
+ });
137
+ const id2 = staging.createSlot({
138
+ clientId: "client-B",
139
+ clientSecret: "secret-B",
140
+ webhookSecret: "webhook-B",
141
+ });
142
+
143
+ expect(id1).not.toBe(id2);
144
+
145
+ const slot1 = staging.getSlot(id1);
146
+ const slot2 = staging.getSlot(id2);
147
+ expect(slot1!.clientId).toBe("client-A");
148
+ expect(slot2!.clientId).toBe("client-B");
149
+
150
+ // Consuming one doesn't affect the other
151
+ staging.consumeSlot(id1);
152
+ expect(staging.getSlot(id1)).toBeNull();
153
+ expect(staging.getSlot(id2)).not.toBeNull();
154
+ });
155
+ });
156
+
157
+ describe("TTL / expiry", () => {
158
+ // Slots have a 30-minute TTL. After that window, getSlot should treat
159
+ // them as expired and return null (also cleaning up the file).
160
+ it("getSlot returns null for a slot whose createdAt is older than 30 minutes", () => {
161
+ const id = staging.createSlot({
162
+ clientId: "cid",
163
+ clientSecret: "csecret",
164
+ webhookSecret: "wsecret",
165
+ });
166
+
167
+ // Backdate createdAt to 31 minutes ago so it exceeds the 30-min TTL
168
+ const filePath = join(TEST_HOME, "staging", `${id}.json`);
169
+ const slot = JSON.parse(readFileSync(filePath, "utf-8"));
170
+ slot.createdAt = Date.now() - 31 * 60 * 1000;
171
+ writeFileSync(filePath, JSON.stringify(slot, null, 2));
172
+
173
+ // The slot should now be treated as expired
174
+ expect(staging.getSlot(id)).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe("pruneExpired", () => {
179
+ // pruneExpired should remove all slot files whose createdAt exceeds
180
+ // the 30-minute TTL, leaving fresh slots untouched.
181
+ it("removes stale files from the staging directory", () => {
182
+ // Create two slots: one will be backdated (expired), one stays fresh
183
+ const expiredId = staging.createSlot({
184
+ clientId: "old-client",
185
+ clientSecret: "old-secret",
186
+ webhookSecret: "old-webhook",
187
+ });
188
+ const freshId = staging.createSlot({
189
+ clientId: "new-client",
190
+ clientSecret: "new-secret",
191
+ webhookSecret: "new-webhook",
192
+ });
193
+
194
+ // Backdate the first slot to 31 minutes ago
195
+ const expiredPath = join(TEST_HOME, "staging", `${expiredId}.json`);
196
+ const expiredSlot = JSON.parse(readFileSync(expiredPath, "utf-8"));
197
+ expiredSlot.createdAt = Date.now() - 31 * 60 * 1000;
198
+ writeFileSync(expiredPath, JSON.stringify(expiredSlot, null, 2));
199
+
200
+ // Run pruneExpired explicitly
201
+ staging.pruneExpired();
202
+
203
+ // The expired slot file should be gone
204
+ const remaining = readdirSync(join(TEST_HOME, "staging"));
205
+ expect(remaining).not.toContain(`${expiredId}.json`);
206
+
207
+ // The fresh slot should still be present
208
+ expect(remaining).toContain(`${freshId}.json`);
209
+ });
210
+ });
211
+
212
+ describe("updateSlotTokens on expired slot", () => {
213
+ // updateSlotTokens delegates to getSlot internally, so if the slot is
214
+ // expired it should return false and not persist any token update.
215
+ it("returns false when the slot has expired", () => {
216
+ const id = staging.createSlot({
217
+ clientId: "cid",
218
+ clientSecret: "csecret",
219
+ webhookSecret: "wsecret",
220
+ });
221
+
222
+ // Backdate createdAt to 31 minutes ago
223
+ const filePath = join(TEST_HOME, "staging", `${id}.json`);
224
+ const slot = JSON.parse(readFileSync(filePath, "utf-8"));
225
+ slot.createdAt = Date.now() - 31 * 60 * 1000;
226
+ writeFileSync(filePath, JSON.stringify(slot, null, 2));
227
+
228
+ // Attempting to update tokens on an expired slot should fail
229
+ const result = staging.updateSlotTokens(id, {
230
+ accessToken: "at_new",
231
+ refreshToken: "rt_new",
232
+ });
233
+ expect(result).toBe(false);
234
+ });
235
+ });
236
+
237
+ describe("path traversal protection", () => {
238
+ // The internal slotPath helper validates IDs against /^[0-9a-f]{32}$/.
239
+ // Any ID that doesn't match (e.g. containing "../") is rejected.
240
+ // Public functions that wrap slotPath in try/catch safely return
241
+ // null/false instead of throwing, but the key invariant is that
242
+ // no file outside the staging directory is ever accessed.
243
+
244
+ const maliciousIds = [
245
+ "../settings",
246
+ "../../etc/passwd",
247
+ "../staging/legit",
248
+ "a".repeat(31) + "/", // wrong length + slash
249
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0", // uppercase hex — regex requires lowercase
250
+ ];
251
+
252
+ it("getSlot safely rejects malicious IDs (returns null)", () => {
253
+ // getSlot wraps slotPath in a try/catch, so the invalid-ID error
254
+ // is caught and the function returns null — no file access occurs.
255
+ for (const id of maliciousIds) {
256
+ expect(staging.getSlot(id)).toBeNull();
257
+ }
258
+ });
259
+
260
+ it("deleteSlot safely rejects malicious IDs (returns false)", () => {
261
+ // deleteSlot wraps unlinkSync(slotPath(id)) in a try/catch,
262
+ // so the invalid-ID error causes it to return false.
263
+ for (const id of maliciousIds) {
264
+ expect(staging.deleteSlot(id)).toBe(false);
265
+ }
266
+ });
267
+
268
+ it("consumeSlot safely rejects malicious IDs (returns null)", () => {
269
+ // consumeSlot delegates to getSlot first, which returns null
270
+ // for invalid IDs, so consumeSlot returns null immediately.
271
+ for (const id of maliciousIds) {
272
+ expect(staging.consumeSlot(id)).toBeNull();
273
+ }
274
+ });
275
+ });
276
+ });