@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,113 @@
1
+ /**
2
+ * Server-side TTL cache for Linear API responses.
3
+ *
4
+ * Prevents hitting Linear's 5000 requests/hour rate limit by caching
5
+ * read-only GraphQL responses with configurable TTLs. Concurrent identical
6
+ * requests are deduplicated — only one fetch is made and all callers share
7
+ * the same promise.
8
+ */
9
+
10
+ interface CacheEntry<T = unknown> {
11
+ data: T;
12
+ timestamp: number;
13
+ pending?: Promise<T>;
14
+ }
15
+
16
+ const MAX_ENTRIES = 500;
17
+ const EVICT_AGE_MS = 5 * 60 * 1000; // 5 minutes — sweep threshold
18
+
19
+ export class LinearCache {
20
+ private store = new Map<string, CacheEntry>();
21
+
22
+ /**
23
+ * Return cached data if fresh, otherwise execute `fetcher` and cache the result.
24
+ * Concurrent calls with the same key share a single in-flight request.
25
+ */
26
+ async getOrFetch<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
27
+ const existing = this.store.get(key) as CacheEntry<T> | undefined;
28
+
29
+ // Serve from cache if still fresh
30
+ if (existing && !existing.pending && Date.now() - existing.timestamp < ttlMs) {
31
+ return existing.data;
32
+ }
33
+
34
+ // Deduplicate: if a fetch is already in flight for this key, piggyback on it
35
+ if (existing?.pending) {
36
+ return existing.pending;
37
+ }
38
+
39
+ const pending = fetcher()
40
+ .then((data) => {
41
+ this.store.set(key, { data, timestamp: Date.now() });
42
+ this.maybeEvict();
43
+ return data;
44
+ })
45
+ .catch((err) => {
46
+ // On failure, remove the pending promise so the next call can retry.
47
+ // If we have stale data, keep it around (callers won't use it because
48
+ // timestamp is old, but a future getOrFetch will retry the fetch).
49
+ const entry = this.store.get(key);
50
+ if (entry?.pending === pending) {
51
+ delete entry.pending;
52
+ // If there was never any cached data, remove the entry entirely
53
+ if (entry.timestamp === 0) {
54
+ this.store.delete(key);
55
+ }
56
+ }
57
+ throw err;
58
+ });
59
+
60
+ // Store the in-flight promise
61
+ if (existing) {
62
+ existing.pending = pending;
63
+ } else {
64
+ this.store.set(key, { data: undefined as T, timestamp: 0, pending });
65
+ }
66
+
67
+ return pending;
68
+ }
69
+
70
+ /** Invalidate a specific key, or all keys that start with `keyOrPrefix`. */
71
+ invalidate(keyOrPrefix: string): void {
72
+ // Exact match first
73
+ if (this.store.has(keyOrPrefix)) {
74
+ this.store.delete(keyOrPrefix);
75
+ return;
76
+ }
77
+ // Prefix match
78
+ for (const k of this.store.keys()) {
79
+ if (k.startsWith(keyOrPrefix)) {
80
+ this.store.delete(k);
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Clear the entire cache (e.g. when the Linear API key changes). */
86
+ clear(): void {
87
+ this.store.clear();
88
+ }
89
+
90
+ /** Current number of cached entries. */
91
+ get size(): number {
92
+ return this.store.size;
93
+ }
94
+
95
+ /** Sweep stale entries when the store grows too large. */
96
+ private maybeEvict(): void {
97
+ if (this.store.size <= MAX_ENTRIES) return;
98
+ const now = Date.now();
99
+ for (const [k, entry] of this.store) {
100
+ if (now - entry.timestamp > EVICT_AGE_MS && !entry.pending) {
101
+ this.store.delete(k);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ /** Singleton cache instance used across all Linear route handlers. */
108
+ export const linearCache = new LinearCache();
109
+
110
+ /** Reset for test isolation — clears all cached data. */
111
+ export function _resetForTest(): void {
112
+ linearCache.clear();
113
+ }
@@ -0,0 +1,350 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import {
11
+ listConnections,
12
+ getConnection,
13
+ getDefaultConnection,
14
+ createConnection,
15
+ updateConnection,
16
+ deleteConnection,
17
+ resolveApiKey,
18
+ _resetForTest,
19
+ type LinearConnection,
20
+ } from "./linear-connections.js";
21
+
22
+ // Mock settings-manager to control legacy linearApiKey fallback
23
+ vi.mock("./settings-manager.js", () => ({
24
+ getSettings: vi.fn(() => ({
25
+ linearApiKey: "",
26
+ linearAutoTransition: false,
27
+ linearAutoTransitionStateId: "",
28
+ linearAutoTransitionStateName: "",
29
+ linearArchiveTransition: false,
30
+ linearArchiveTransitionStateId: "",
31
+ linearArchiveTransitionStateName: "",
32
+ })),
33
+ }));
34
+
35
+ import { getSettings } from "./settings-manager.js";
36
+
37
+ let tempDir: string;
38
+
39
+ beforeEach(() => {
40
+ tempDir = mkdtempSync(join(tmpdir(), "linear-connections-test-"));
41
+ _resetForTest(join(tempDir, "linear-connections.json"));
42
+ vi.mocked(getSettings).mockReturnValue({
43
+ linearApiKey: "",
44
+ linearAutoTransition: false,
45
+ linearAutoTransitionStateId: "",
46
+ linearAutoTransitionStateName: "",
47
+ linearArchiveTransition: false,
48
+ linearArchiveTransitionStateId: "",
49
+ linearArchiveTransitionStateName: "",
50
+ anthropicApiKey: "",
51
+ anthropicModel: "",
52
+ linearOAuthClientId: "",
53
+ linearOAuthClientSecret: "",
54
+ linearOAuthWebhookSecret: "",
55
+ linearOAuthAccessToken: "",
56
+ linearOAuthRefreshToken: "",
57
+ claudeCodeOAuthToken: "",
58
+ openaiApiKey: "",
59
+ onboardingCompleted: false,
60
+ aiValidationEnabled: false,
61
+ aiValidationAutoApprove: true,
62
+ aiValidationAutoDeny: false,
63
+ publicUrl: "",
64
+ updateChannel: "stable",
65
+ dockerAutoUpdate: false,
66
+ updatedAt: 0,
67
+ });
68
+ });
69
+
70
+ afterEach(() => {
71
+ rmSync(tempDir, { recursive: true, force: true });
72
+ });
73
+
74
+ describe("linear-connections", () => {
75
+ // ─── CRUD ────────────────────────────────────────────────────────────
76
+
77
+ it("listConnections returns empty array when no connections exist", () => {
78
+ expect(listConnections()).toEqual([]);
79
+ });
80
+
81
+ it("createConnection creates a connection and persists", () => {
82
+ const conn = createConnection({ name: "Work", apiKey: "lin_api_work123" });
83
+ expect(conn.name).toBe("Work");
84
+ expect(conn.apiKey).toBe("lin_api_work123");
85
+ expect(conn.id).toBeTruthy();
86
+ expect(conn.connected).toBe(false);
87
+ expect(conn.autoTransition).toBe(false);
88
+
89
+ // Verify persisted to disk
90
+ const raw = JSON.parse(readFileSync(join(tempDir, "linear-connections.json"), "utf-8"));
91
+ expect(raw).toHaveLength(1);
92
+ expect(raw[0].name).toBe("Work");
93
+ });
94
+
95
+ it("getConnection retrieves by id", () => {
96
+ const conn = createConnection({ name: "Test", apiKey: "lin_api_test" });
97
+ expect(getConnection(conn.id)).toEqual(conn);
98
+ expect(getConnection("nonexistent")).toBeNull();
99
+ });
100
+
101
+ it("getDefaultConnection returns the first connection", () => {
102
+ expect(getDefaultConnection()).toBeNull();
103
+ createConnection({ name: "First", apiKey: "lin_api_first" });
104
+ createConnection({ name: "Second", apiKey: "lin_api_second" });
105
+ expect(getDefaultConnection()?.name).toBe("First");
106
+ });
107
+
108
+ it("updateConnection updates fields and preserves others", () => {
109
+ const conn = createConnection({ name: "Original", apiKey: "lin_api_orig" });
110
+ const updated = updateConnection(conn.id, {
111
+ name: "Updated",
112
+ connected: true,
113
+ workspaceName: "My Workspace",
114
+ });
115
+ expect(updated).not.toBeNull();
116
+ expect(updated!.name).toBe("Updated");
117
+ expect(updated!.apiKey).toBe("lin_api_orig"); // unchanged
118
+ expect(updated!.connected).toBe(true);
119
+ expect(updated!.workspaceName).toBe("My Workspace");
120
+ });
121
+
122
+ it("updateConnection returns null for nonexistent id", () => {
123
+ expect(updateConnection("nope", { name: "x" })).toBeNull();
124
+ });
125
+
126
+ it("deleteConnection removes a connection and persists", () => {
127
+ const conn = createConnection({ name: "ToDelete", apiKey: "lin_api_del" });
128
+ expect(deleteConnection(conn.id)).toBe(true);
129
+ expect(getConnection(conn.id)).toBeNull();
130
+ expect(listConnections()).toEqual([]);
131
+ });
132
+
133
+ it("deleteConnection returns false for nonexistent id", () => {
134
+ expect(deleteConnection("nope")).toBe(false);
135
+ });
136
+
137
+ it("supports multiple connections", () => {
138
+ createConnection({ name: "A", apiKey: "lin_api_a" });
139
+ createConnection({ name: "B", apiKey: "lin_api_b" });
140
+ createConnection({ name: "C", apiKey: "lin_api_c" });
141
+ expect(listConnections()).toHaveLength(3);
142
+ });
143
+
144
+ it("trims name and apiKey on create and update", () => {
145
+ const conn = createConnection({ name: " Spaced ", apiKey: " lin_api_key " });
146
+ expect(conn.name).toBe("Spaced");
147
+ expect(conn.apiKey).toBe("lin_api_key");
148
+
149
+ const updated = updateConnection(conn.id, { name: " Updated " });
150
+ expect(updated!.name).toBe("Updated");
151
+ });
152
+
153
+ // ─── resolveApiKey ─────────────────────────────────────────────────
154
+
155
+ it("resolveApiKey returns specific connection when connectionId is provided", () => {
156
+ const conn = createConnection({ name: "Target", apiKey: "lin_api_target" });
157
+ const result = resolveApiKey(conn.id);
158
+ expect(result).toEqual({ apiKey: "lin_api_target", connectionId: conn.id });
159
+ });
160
+
161
+ it("resolveApiKey returns null for invalid connectionId", () => {
162
+ expect(resolveApiKey("nonexistent")).toBeNull();
163
+ });
164
+
165
+ it("resolveApiKey falls back to default connection when no connectionId", () => {
166
+ const conn = createConnection({ name: "Default", apiKey: "lin_api_default" });
167
+ const result = resolveApiKey();
168
+ expect(result).toEqual({ apiKey: "lin_api_default", connectionId: conn.id });
169
+ });
170
+
171
+ it("resolveApiKey falls back to legacy settings.linearApiKey", () => {
172
+ // No connections exist, but settings has a key
173
+ vi.mocked(getSettings).mockReturnValue({
174
+ linearApiKey: "lin_api_legacy",
175
+ linearAutoTransition: false,
176
+ linearAutoTransitionStateId: "",
177
+ linearAutoTransitionStateName: "",
178
+ linearArchiveTransition: false,
179
+ linearArchiveTransitionStateId: "",
180
+ linearArchiveTransitionStateName: "",
181
+ anthropicApiKey: "",
182
+ anthropicModel: "",
183
+ linearOAuthClientId: "",
184
+ linearOAuthClientSecret: "",
185
+ linearOAuthWebhookSecret: "",
186
+ linearOAuthAccessToken: "",
187
+ linearOAuthRefreshToken: "",
188
+ claudeCodeOAuthToken: "",
189
+ openaiApiKey: "",
190
+ onboardingCompleted: false,
191
+ aiValidationEnabled: false,
192
+ aiValidationAutoApprove: true,
193
+ aiValidationAutoDeny: false,
194
+ publicUrl: "",
195
+ updateChannel: "stable",
196
+ dockerAutoUpdate: false,
197
+ updatedAt: 0,
198
+ });
199
+ // Need to reset so migration runs with updated mock
200
+ _resetForTest(join(tempDir, "linear-connections-legacy.json"));
201
+ // Migration should create a connection from the legacy key
202
+ const result = resolveApiKey();
203
+ expect(result).not.toBeNull();
204
+ expect(result!.apiKey).toBe("lin_api_legacy");
205
+ });
206
+
207
+ it("resolveApiKey returns null when nothing configured", () => {
208
+ expect(resolveApiKey()).toBeNull();
209
+ });
210
+
211
+ // ─── Migration ─────────────────────────────────────────────────────
212
+
213
+ it("migrates from settings.linearApiKey on first load", () => {
214
+ vi.mocked(getSettings).mockReturnValue({
215
+ linearApiKey: "lin_api_migrated",
216
+ linearAutoTransition: true,
217
+ linearAutoTransitionStateId: "state-1",
218
+ linearAutoTransitionStateName: "In Progress",
219
+ linearArchiveTransition: false,
220
+ linearArchiveTransitionStateId: "",
221
+ linearArchiveTransitionStateName: "",
222
+ anthropicApiKey: "",
223
+ anthropicModel: "",
224
+ linearOAuthClientId: "",
225
+ linearOAuthClientSecret: "",
226
+ linearOAuthWebhookSecret: "",
227
+ linearOAuthAccessToken: "",
228
+ linearOAuthRefreshToken: "",
229
+ claudeCodeOAuthToken: "",
230
+ openaiApiKey: "",
231
+ onboardingCompleted: false,
232
+ aiValidationEnabled: false,
233
+ aiValidationAutoApprove: true,
234
+ aiValidationAutoDeny: false,
235
+ publicUrl: "",
236
+ updateChannel: "stable",
237
+ dockerAutoUpdate: false,
238
+ updatedAt: 0,
239
+ });
240
+ _resetForTest(join(tempDir, "linear-connections-migrate.json"));
241
+
242
+ const conns = listConnections();
243
+ expect(conns).toHaveLength(1);
244
+ expect(conns[0].name).toBe("Default");
245
+ expect(conns[0].apiKey).toBe("lin_api_migrated");
246
+ expect(conns[0].autoTransition).toBe(true);
247
+ expect(conns[0].autoTransitionStateId).toBe("state-1");
248
+ });
249
+
250
+ it("does not migrate when connections already exist on disk", () => {
251
+ // Pre-populate the file with an existing connection
252
+ writeFileSync(
253
+ join(tempDir, "linear-connections-existing.json"),
254
+ JSON.stringify([{
255
+ id: "existing-id",
256
+ name: "Existing",
257
+ apiKey: "lin_api_existing",
258
+ workspaceName: "",
259
+ workspaceId: "",
260
+ viewerName: "",
261
+ viewerEmail: "",
262
+ connected: false,
263
+ autoTransition: false,
264
+ autoTransitionStateId: "",
265
+ autoTransitionStateName: "",
266
+ archiveTransition: false,
267
+ archiveTransitionStateId: "",
268
+ archiveTransitionStateName: "",
269
+ createdAt: 1000,
270
+ updatedAt: 1000,
271
+ }]),
272
+ );
273
+
274
+ vi.mocked(getSettings).mockReturnValue({
275
+ linearApiKey: "lin_api_should_not_migrate",
276
+ linearAutoTransition: false,
277
+ linearAutoTransitionStateId: "",
278
+ linearAutoTransitionStateName: "",
279
+ linearArchiveTransition: false,
280
+ linearArchiveTransitionStateId: "",
281
+ linearArchiveTransitionStateName: "",
282
+ anthropicApiKey: "",
283
+ anthropicModel: "",
284
+ linearOAuthClientId: "",
285
+ linearOAuthClientSecret: "",
286
+ linearOAuthWebhookSecret: "",
287
+ linearOAuthAccessToken: "",
288
+ linearOAuthRefreshToken: "",
289
+ claudeCodeOAuthToken: "",
290
+ openaiApiKey: "",
291
+ onboardingCompleted: false,
292
+ aiValidationEnabled: false,
293
+ aiValidationAutoApprove: true,
294
+ aiValidationAutoDeny: false,
295
+ publicUrl: "",
296
+ updateChannel: "stable",
297
+ dockerAutoUpdate: false,
298
+ updatedAt: 0,
299
+ });
300
+
301
+ _resetForTest(join(tempDir, "linear-connections-existing.json"));
302
+ const conns = listConnections();
303
+ expect(conns).toHaveLength(1);
304
+ expect(conns[0].name).toBe("Existing");
305
+ });
306
+
307
+ // ─── Persistence ───────────────────────────────────────────────────
308
+
309
+ it("loads existing data from disk on first access", () => {
310
+ const existingConn = {
311
+ id: "loaded-id",
312
+ name: "Loaded",
313
+ apiKey: "lin_api_loaded",
314
+ workspaceName: "WS",
315
+ workspaceId: "ws-id",
316
+ viewerName: "User",
317
+ viewerEmail: "user@test.com",
318
+ connected: true,
319
+ autoTransition: false,
320
+ autoTransitionStateId: "",
321
+ autoTransitionStateName: "",
322
+ archiveTransition: false,
323
+ archiveTransitionStateId: "",
324
+ archiveTransitionStateName: "",
325
+ createdAt: 1000,
326
+ updatedAt: 2000,
327
+ };
328
+ writeFileSync(
329
+ join(tempDir, "linear-connections.json"),
330
+ JSON.stringify([existingConn]),
331
+ );
332
+ _resetForTest(join(tempDir, "linear-connections.json"));
333
+ const conn = getConnection("loaded-id");
334
+ expect(conn?.name).toBe("Loaded");
335
+ expect(conn?.workspaceName).toBe("WS");
336
+ });
337
+
338
+ it("handles corrupt JSON gracefully", () => {
339
+ writeFileSync(join(tempDir, "linear-connections.json"), "NOT VALID JSON");
340
+ _resetForTest(join(tempDir, "linear-connections.json"));
341
+ expect(listConnections()).toEqual([]);
342
+ });
343
+
344
+ it("creates parent directories if needed", () => {
345
+ const nestedPath = join(tempDir, "nested", "dir", "connections.json");
346
+ _resetForTest(nestedPath);
347
+ createConnection({ name: "Nested", apiKey: "lin_api_nested" });
348
+ expect(listConnections()).toHaveLength(1);
349
+ });
350
+ });
@@ -0,0 +1,231 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { randomUUID } from "node:crypto";
10
+ import { getSettings } from "./settings-manager.js";
11
+
12
+ // ─── Types ───────────────────────────────────────────────────────────────────
13
+
14
+ export interface LinearConnection {
15
+ id: string;
16
+ name: string;
17
+ apiKey: string;
18
+ workspaceName: string;
19
+ workspaceId: string;
20
+ viewerName: string;
21
+ viewerEmail: string;
22
+ connected: boolean;
23
+ autoTransition: boolean;
24
+ autoTransitionStateId: string;
25
+ autoTransitionStateName: string;
26
+ archiveTransition: boolean;
27
+ archiveTransitionStateId: string;
28
+ archiveTransitionStateName: string;
29
+ createdAt: number;
30
+ updatedAt: number;
31
+ }
32
+
33
+ // ─── Paths ───────────────────────────────────────────────────────────────────
34
+
35
+ const DEFAULT_PATH = join(homedir(), ".companion", "linear-connections.json");
36
+
37
+ // ─── Store ───────────────────────────────────────────────────────────────────
38
+
39
+ let connections: LinearConnection[] = [];
40
+ let loaded = false;
41
+ let filePath = DEFAULT_PATH;
42
+
43
+ function ensureLoaded(): void {
44
+ if (loaded) return;
45
+ try {
46
+ if (existsSync(filePath)) {
47
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
48
+ if (Array.isArray(raw)) {
49
+ connections = raw.filter(
50
+ (c: unknown): c is LinearConnection =>
51
+ typeof c === "object" &&
52
+ c !== null &&
53
+ typeof (c as LinearConnection).id === "string" &&
54
+ typeof (c as LinearConnection).apiKey === "string",
55
+ );
56
+ } else {
57
+ connections = [];
58
+ }
59
+ }
60
+ } catch {
61
+ connections = [];
62
+ }
63
+ loaded = true;
64
+
65
+ // Auto-migrate from settings.json if no connections exist
66
+ migrateFromSettings();
67
+ }
68
+
69
+ function persist(): void {
70
+ mkdirSync(dirname(filePath), { recursive: true });
71
+ writeFileSync(filePath, JSON.stringify(connections, null, 2), "utf-8");
72
+ }
73
+
74
+ // ─── Migration ───────────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * One-time migration: if no connections exist but settings.linearApiKey is set,
78
+ * create a "Default" connection from it.
79
+ */
80
+ function migrateFromSettings(): void {
81
+ if (connections.length > 0) return;
82
+
83
+ const settings = getSettings();
84
+ if (!settings.linearApiKey.trim()) return;
85
+
86
+ const now = Date.now();
87
+ connections.push({
88
+ id: randomUUID(),
89
+ name: "Default",
90
+ apiKey: settings.linearApiKey.trim(),
91
+ workspaceName: "",
92
+ workspaceId: "",
93
+ viewerName: "",
94
+ viewerEmail: "",
95
+ connected: false,
96
+ autoTransition: settings.linearAutoTransition,
97
+ autoTransitionStateId: settings.linearAutoTransitionStateId,
98
+ autoTransitionStateName: settings.linearAutoTransitionStateName,
99
+ archiveTransition: settings.linearArchiveTransition,
100
+ archiveTransitionStateId: settings.linearArchiveTransitionStateId,
101
+ archiveTransitionStateName: settings.linearArchiveTransitionStateName,
102
+ createdAt: now,
103
+ updatedAt: now,
104
+ });
105
+ persist();
106
+ }
107
+
108
+ // ─── Public API ──────────────────────────────────────────────────────────────
109
+
110
+ export function listConnections(): LinearConnection[] {
111
+ ensureLoaded();
112
+ return [...connections];
113
+ }
114
+
115
+ export function getConnection(id: string): LinearConnection | null {
116
+ ensureLoaded();
117
+ return connections.find((c) => c.id === id) ?? null;
118
+ }
119
+
120
+ /** Returns the first connection (used as default when no connectionId is specified). */
121
+ export function getDefaultConnection(): LinearConnection | null {
122
+ ensureLoaded();
123
+ return connections[0] ?? null;
124
+ }
125
+
126
+ export function createConnection(data: {
127
+ name: string;
128
+ apiKey: string;
129
+ }): LinearConnection {
130
+ ensureLoaded();
131
+ const now = Date.now();
132
+ const conn: LinearConnection = {
133
+ id: randomUUID(),
134
+ name: data.name.trim(),
135
+ apiKey: data.apiKey.trim(),
136
+ workspaceName: "",
137
+ workspaceId: "",
138
+ viewerName: "",
139
+ viewerEmail: "",
140
+ connected: false,
141
+ autoTransition: false,
142
+ autoTransitionStateId: "",
143
+ autoTransitionStateName: "",
144
+ archiveTransition: false,
145
+ archiveTransitionStateId: "",
146
+ archiveTransitionStateName: "",
147
+ createdAt: now,
148
+ updatedAt: now,
149
+ };
150
+ connections.push(conn);
151
+ persist();
152
+ return conn;
153
+ }
154
+
155
+ export function updateConnection(
156
+ id: string,
157
+ patch: Partial<Omit<LinearConnection, "id" | "createdAt">>,
158
+ ): LinearConnection | null {
159
+ ensureLoaded();
160
+ const conn = connections.find((c) => c.id === id);
161
+ if (!conn) return null;
162
+
163
+ if (patch.name !== undefined) conn.name = patch.name.trim();
164
+ if (patch.apiKey !== undefined) conn.apiKey = patch.apiKey.trim();
165
+ if (patch.workspaceName !== undefined) conn.workspaceName = patch.workspaceName;
166
+ if (patch.workspaceId !== undefined) conn.workspaceId = patch.workspaceId;
167
+ if (patch.viewerName !== undefined) conn.viewerName = patch.viewerName;
168
+ if (patch.viewerEmail !== undefined) conn.viewerEmail = patch.viewerEmail;
169
+ if (patch.connected !== undefined) conn.connected = patch.connected;
170
+ if (patch.autoTransition !== undefined) conn.autoTransition = patch.autoTransition;
171
+ if (patch.autoTransitionStateId !== undefined) conn.autoTransitionStateId = patch.autoTransitionStateId;
172
+ if (patch.autoTransitionStateName !== undefined) conn.autoTransitionStateName = patch.autoTransitionStateName;
173
+ if (patch.archiveTransition !== undefined) conn.archiveTransition = patch.archiveTransition;
174
+ if (patch.archiveTransitionStateId !== undefined) conn.archiveTransitionStateId = patch.archiveTransitionStateId;
175
+ if (patch.archiveTransitionStateName !== undefined) conn.archiveTransitionStateName = patch.archiveTransitionStateName;
176
+ conn.updatedAt = Date.now();
177
+
178
+ persist();
179
+ return conn;
180
+ }
181
+
182
+ export function deleteConnection(id: string): boolean {
183
+ ensureLoaded();
184
+ const idx = connections.findIndex((c) => c.id === id);
185
+ if (idx === -1) return false;
186
+ connections.splice(idx, 1);
187
+ persist();
188
+ return true;
189
+ }
190
+
191
+ /**
192
+ * Resolve a Linear API key from a connectionId.
193
+ * - If connectionId is provided, look up that specific connection.
194
+ * - Otherwise, fall back to the first connection.
195
+ * - As a last resort, fall back to the legacy settings.linearApiKey.
196
+ * Returns null if no API key can be found.
197
+ */
198
+ export function resolveApiKey(
199
+ connectionId?: string,
200
+ ): { apiKey: string; connectionId: string } | null {
201
+ ensureLoaded();
202
+
203
+ if (connectionId) {
204
+ const conn = connections.find((c) => c.id === connectionId);
205
+ if (conn?.apiKey.trim()) {
206
+ return { apiKey: conn.apiKey.trim(), connectionId: conn.id };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // Default to first connection
212
+ const defaultConn = connections[0];
213
+ if (defaultConn?.apiKey.trim()) {
214
+ return { apiKey: defaultConn.apiKey.trim(), connectionId: defaultConn.id };
215
+ }
216
+
217
+ // Legacy fallback: settings.linearApiKey
218
+ const settings = getSettings();
219
+ if (settings.linearApiKey.trim()) {
220
+ return { apiKey: settings.linearApiKey.trim(), connectionId: "legacy" };
221
+ }
222
+
223
+ return null;
224
+ }
225
+
226
+ /** Reset internal state and optionally set a custom file path (for testing). */
227
+ export function _resetForTest(customPath?: string): void {
228
+ connections = [];
229
+ loaded = false;
230
+ filePath = customPath || DEFAULT_PATH;
231
+ }