@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,552 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
4
+
5
+ const mockExecSync = vi.hoisted(() => vi.fn());
6
+ const mockExistsSync = vi.hoisted(() => vi.fn((_path: string) => false));
7
+ const mockReaddirSync = vi.hoisted(() => vi.fn((_path: string) => [] as string[]));
8
+ const mockHomedir = vi.hoisted(() => vi.fn(() => "/home/testuser"));
9
+
10
+ vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
11
+
12
+ vi.mock("node:fs", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("node:fs")>();
14
+ return {
15
+ ...actual,
16
+ existsSync: mockExistsSync,
17
+ readdirSync: mockReaddirSync,
18
+ };
19
+ });
20
+
21
+ vi.mock("node:os", async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import("node:os")>();
23
+ return {
24
+ ...actual,
25
+ homedir: mockHomedir,
26
+ };
27
+ });
28
+
29
+ // ─── Import after mocks ─────────────────────────────────────────────────────
30
+
31
+ import {
32
+ captureUserShellPath,
33
+ buildFallbackPath,
34
+ getEnrichedPath,
35
+ resolveBinary,
36
+ getServicePath,
37
+ _resetPathCache,
38
+ } from "./path-resolver.js";
39
+
40
+ // ─── Helpers ────────────────────────────────────────────────────────────────
41
+
42
+ const originalEnv = { ...process.env };
43
+
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ _resetPathCache();
47
+ process.env = { ...originalEnv };
48
+ });
49
+
50
+ afterEach(() => {
51
+ process.env = originalEnv;
52
+ });
53
+
54
+ // ─── captureUserShellPath ───────────────────────────────────────────────────
55
+
56
+ describe("captureUserShellPath", () => {
57
+ it("extracts PATH from login shell output using sentinel markers", () => {
58
+ mockExecSync.mockReturnValueOnce(
59
+ "___PATH_START___/usr/bin:/home/testuser/.nvm/versions/node/v20/bin:/home/testuser/.cargo/bin___PATH_END___\n",
60
+ );
61
+
62
+ const result = captureUserShellPath();
63
+ expect(result).toBe(
64
+ "/usr/bin:/home/testuser/.nvm/versions/node/v20/bin:/home/testuser/.cargo/bin",
65
+ );
66
+ });
67
+
68
+ it("handles noisy shell output (MOTD, warnings) before and after PATH", () => {
69
+ mockExecSync.mockReturnValueOnce(
70
+ "Last login: Mon Jan 1\nWelcome!\n___PATH_START___/usr/local/bin:/usr/bin___PATH_END___\nbye\n",
71
+ );
72
+
73
+ const result = captureUserShellPath();
74
+ expect(result).toBe("/usr/local/bin:/usr/bin");
75
+ });
76
+
77
+ it("falls back to buildFallbackPath when shell sourcing fails", () => {
78
+ mockExecSync.mockImplementationOnce(() => {
79
+ throw new Error("shell failed");
80
+ });
81
+ // buildFallbackPath needs existsSync to return true for some dirs
82
+ mockExistsSync.mockImplementation((p: string) =>
83
+ p === "/usr/bin" || p === "/bin",
84
+ );
85
+
86
+ const result = captureUserShellPath();
87
+ expect(result).toContain("/usr/bin");
88
+ expect(result).toContain("/bin");
89
+ });
90
+
91
+ it("falls back when shell output contains no sentinel markers", () => {
92
+ mockExecSync.mockReturnValueOnce("some garbage output\n");
93
+ mockExistsSync.mockImplementation((p: string) => p === "/usr/bin");
94
+
95
+ const result = captureUserShellPath();
96
+ // Should fall back to buildFallbackPath
97
+ expect(result).toContain("/usr/bin");
98
+ });
99
+
100
+ it("uses $SHELL env var for the shell command", () => {
101
+ process.env.SHELL = "/bin/zsh";
102
+ mockExecSync.mockReturnValueOnce(
103
+ "___PATH_START___/usr/bin___PATH_END___\n",
104
+ );
105
+
106
+ captureUserShellPath();
107
+
108
+ expect(mockExecSync).toHaveBeenCalledWith(
109
+ expect.stringContaining("/bin/zsh"),
110
+ expect.any(Object),
111
+ );
112
+ });
113
+
114
+ it("defaults to /bin/bash when $SHELL is not set", () => {
115
+ delete process.env.SHELL;
116
+ mockExecSync.mockReturnValueOnce(
117
+ "___PATH_START___/usr/bin___PATH_END___\n",
118
+ );
119
+
120
+ captureUserShellPath();
121
+
122
+ expect(mockExecSync).toHaveBeenCalledWith(
123
+ expect.stringContaining("/bin/bash"),
124
+ expect.any(Object),
125
+ );
126
+ });
127
+ });
128
+
129
+ // ─── buildFallbackPath ──────────────────────────────────────────────────────
130
+
131
+ describe("buildFallbackPath", () => {
132
+ it("includes standard system paths when they exist", () => {
133
+ mockExistsSync.mockImplementation((p: string) =>
134
+ ["/usr/local/bin", "/usr/bin", "/bin"].includes(p as string),
135
+ );
136
+
137
+ const result = buildFallbackPath();
138
+ expect(result).toContain("/usr/local/bin");
139
+ expect(result).toContain("/usr/bin");
140
+ expect(result).toContain("/bin");
141
+ });
142
+
143
+ it("includes ~/.local/bin for claude CLI", () => {
144
+ mockExistsSync.mockImplementation((p: string) =>
145
+ p === "/home/testuser/.local/bin" || p === "/usr/bin",
146
+ );
147
+
148
+ const result = buildFallbackPath();
149
+ expect(result).toContain("/home/testuser/.local/bin");
150
+ });
151
+
152
+ it("includes ~/.bun/bin", () => {
153
+ mockExistsSync.mockImplementation((p: string) =>
154
+ p === "/home/testuser/.bun/bin" || p === "/usr/bin",
155
+ );
156
+
157
+ const result = buildFallbackPath();
158
+ expect(result).toContain("/home/testuser/.bun/bin");
159
+ });
160
+
161
+ it("includes ~/.cargo/bin for Rust tools", () => {
162
+ mockExistsSync.mockImplementation((p: string) =>
163
+ p === "/home/testuser/.cargo/bin" || p === "/usr/bin",
164
+ );
165
+
166
+ const result = buildFallbackPath();
167
+ expect(result).toContain("/home/testuser/.cargo/bin");
168
+ });
169
+
170
+ it("probes nvm versions directory and includes all version bins", () => {
171
+ // Ensure NVM_DIR is not set so the code falls back to ~/.nvm
172
+ delete process.env.NVM_DIR;
173
+ mockExistsSync.mockImplementation((p: string) => {
174
+ if (p === "/home/testuser/.nvm/versions/node") return true;
175
+ if (p.includes(".nvm/versions/node/v") && p.endsWith("/bin")) return true;
176
+ if (p === "/usr/bin") return true;
177
+ return false;
178
+ });
179
+ mockReaddirSync.mockReturnValue(["v18.20.0", "v22.17.0"] as any);
180
+
181
+ const result = buildFallbackPath();
182
+ expect(result).toContain("/home/testuser/.nvm/versions/node/v18.20.0/bin");
183
+ expect(result).toContain("/home/testuser/.nvm/versions/node/v22.17.0/bin");
184
+ });
185
+
186
+ it("uses NVM_DIR env var when set", () => {
187
+ process.env.NVM_DIR = "/custom/nvm";
188
+ mockExistsSync.mockImplementation((p: string) => {
189
+ if (p === "/custom/nvm/versions/node") return true;
190
+ if (p.includes("/custom/nvm/versions/node/v") && p.endsWith("/bin"))
191
+ return true;
192
+ return false;
193
+ });
194
+ mockReaddirSync.mockReturnValue(["v20.0.0"] as any);
195
+
196
+ const result = buildFallbackPath();
197
+ expect(result).toContain("/custom/nvm/versions/node/v20.0.0/bin");
198
+ });
199
+
200
+ it("excludes directories that don't exist", () => {
201
+ mockExistsSync.mockReturnValue(false);
202
+
203
+ const result = buildFallbackPath();
204
+ expect(result).toBe("");
205
+ });
206
+
207
+ it("deduplicates PATH entries", () => {
208
+ mockExistsSync.mockReturnValue(true);
209
+ mockReaddirSync.mockReturnValue([] as any);
210
+
211
+ const result = buildFallbackPath();
212
+ const dirs = result.split(":");
213
+ expect(dirs.length).toBe(new Set(dirs).size);
214
+ });
215
+
216
+ describe("Windows support", () => {
217
+ const originalPlatform = process.platform;
218
+
219
+ beforeEach(() => {
220
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
221
+ });
222
+
223
+ afterEach(() => {
224
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
225
+ });
226
+
227
+ it("uses semicolon as PATH separator on win32", () => {
228
+ mockExistsSync.mockImplementation((p: string) =>
229
+ ["/usr/local/bin", "/usr/bin"].includes(p as string),
230
+ );
231
+
232
+ const result = buildFallbackPath();
233
+ // Should use ; not : on Windows
234
+ expect(result).toContain(";");
235
+ expect(result).not.toContain(":");
236
+ });
237
+ });
238
+ });
239
+
240
+ // ─── getEnrichedPath ────────────────────────────────────────────────────────
241
+
242
+ describe("getEnrichedPath", () => {
243
+ it("merges user shell PATH with current process PATH", () => {
244
+ process.env.PATH = "/usr/bin:/bin";
245
+ mockExecSync.mockImplementation((cmd: string) => {
246
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
247
+ return "___PATH_START___/usr/bin:/home/testuser/.cargo/bin___PATH_END___\n";
248
+ }
249
+ return "";
250
+ });
251
+
252
+ const result = getEnrichedPath();
253
+ expect(result).toContain("/home/testuser/.cargo/bin");
254
+ expect(result).toContain("/usr/bin");
255
+ expect(result).toContain("/bin");
256
+ });
257
+
258
+ it("deduplicates entries from both PATHs", () => {
259
+ process.env.PATH = "/usr/bin:/bin:/usr/local/bin";
260
+ mockExecSync.mockImplementation((cmd: string) => {
261
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
262
+ return "___PATH_START___/usr/bin:/usr/local/bin:/home/testuser/.volta/bin___PATH_END___\n";
263
+ }
264
+ return "";
265
+ });
266
+
267
+ const result = getEnrichedPath();
268
+ const dirs = result.split(":");
269
+ expect(dirs.length).toBe(new Set(dirs).size);
270
+ // /usr/bin should appear exactly once
271
+ expect(dirs.filter((d) => d === "/usr/bin").length).toBe(1);
272
+ });
273
+
274
+ it("caches the result after first call", () => {
275
+ process.env.PATH = "/usr/bin";
276
+ mockExecSync.mockImplementation((cmd: string) => {
277
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
278
+ return "___PATH_START___/usr/bin___PATH_END___\n";
279
+ }
280
+ return "";
281
+ });
282
+
283
+ const first = getEnrichedPath();
284
+ mockExecSync.mockClear();
285
+ const second = getEnrichedPath();
286
+
287
+ expect(first).toBe(second);
288
+ // execSync should NOT be called again (result was cached)
289
+ expect(mockExecSync).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("gives user shell PATH precedence over process PATH", () => {
293
+ // User's shell has /opt/homebrew/bin first, process PATH has /usr/bin first
294
+ process.env.PATH = "/usr/bin:/bin";
295
+ mockExecSync.mockImplementation((cmd: string) => {
296
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
297
+ return "___PATH_START___/opt/homebrew/bin:/usr/bin___PATH_END___\n";
298
+ }
299
+ return "";
300
+ });
301
+
302
+ const result = getEnrichedPath();
303
+ const dirs = result.split(":");
304
+ expect(dirs.indexOf("/opt/homebrew/bin")).toBeLessThan(
305
+ dirs.indexOf("/bin"),
306
+ );
307
+ });
308
+
309
+ describe("Windows support", () => {
310
+ const originalPlatform = process.platform;
311
+
312
+ beforeEach(() => {
313
+ _resetPathCache(); // ensure no cross-contamination from non-Windows tests
314
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
315
+ });
316
+
317
+ afterEach(() => {
318
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
319
+ });
320
+
321
+ it("splits and joins PATH with semicolons on win32", () => {
322
+ process.env.PATH = "C:\\Windows\\System32;C:\\Windows";
323
+ mockExecSync.mockImplementation((cmd: string) => {
324
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
325
+ return "___PATH_START___C:\\Users\\me\\AppData\\Roaming\\npm;C:\\Windows\\System32___PATH_END___\n";
326
+ }
327
+ return "";
328
+ });
329
+
330
+ const result = getEnrichedPath();
331
+ // Should use ; as separator and contain all directories
332
+ expect(result).toContain("C:\\Users\\me\\AppData\\Roaming\\npm");
333
+ expect(result).toContain("C:\\Windows\\System32");
334
+ expect(result).toContain("C:\\Windows");
335
+ // Should be semicolon-separated
336
+ const dirs = result.split(";");
337
+ expect(dirs.length).toBeGreaterThanOrEqual(3);
338
+ // C:\Windows\System32 should appear exactly once (deduplication)
339
+ expect(dirs.filter((d) => d === "C:\\Windows\\System32").length).toBe(1);
340
+ });
341
+ });
342
+ });
343
+
344
+ // ─── resolveBinary ──────────────────────────────────────────────────────────
345
+
346
+ describe("resolveBinary", () => {
347
+ beforeEach(() => {
348
+ // Seed getEnrichedPath cache to avoid shell-sourcing side effects
349
+ process.env.PATH = "/usr/bin:/bin";
350
+ mockExecSync.mockImplementation((cmd: string) => {
351
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
352
+ return "___PATH_START___/usr/bin:/usr/local/bin___PATH_END___\n";
353
+ }
354
+ throw new Error("not found");
355
+ });
356
+ });
357
+
358
+ it("returns absolute path when binary is found via which", () => {
359
+ _resetPathCache();
360
+ mockExecSync.mockImplementation((cmd: string) => {
361
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
362
+ return "___PATH_START___/usr/bin___PATH_END___\n";
363
+ }
364
+ if (typeof cmd === "string" && cmd.startsWith("which claude")) {
365
+ return "/home/testuser/.local/bin/claude\n";
366
+ }
367
+ throw new Error("not found");
368
+ });
369
+
370
+ expect(resolveBinary("claude")).toBe("/home/testuser/.local/bin/claude");
371
+ });
372
+
373
+ it("returns null when binary is not found anywhere", () => {
374
+ _resetPathCache();
375
+ mockExecSync.mockImplementation((cmd: string) => {
376
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
377
+ return "___PATH_START___/usr/bin___PATH_END___\n";
378
+ }
379
+ throw new Error("not found");
380
+ });
381
+
382
+ expect(resolveBinary("nonexistent")).toBeNull();
383
+ });
384
+
385
+ it("passes enriched PATH to which command", () => {
386
+ _resetPathCache();
387
+ mockExecSync.mockImplementation((cmd: string, opts?: any) => {
388
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
389
+ return "___PATH_START___/usr/bin:/home/testuser/.special/bin___PATH_END___\n";
390
+ }
391
+ if (typeof cmd === "string" && cmd.startsWith("which")) {
392
+ // Verify enriched PATH is passed in env
393
+ expect(opts?.env?.PATH).toContain("/home/testuser/.special/bin");
394
+ return "/home/testuser/.special/bin/mytool\n";
395
+ }
396
+ throw new Error("not found");
397
+ });
398
+
399
+ resolveBinary("mytool");
400
+ });
401
+
402
+ it("returns the path directly when given an absolute path that exists", () => {
403
+ mockExistsSync.mockReturnValue(true);
404
+ expect(resolveBinary("/opt/bin/claude")).toBe("/opt/bin/claude");
405
+ });
406
+
407
+ it("returns null when given an absolute path that does not exist", () => {
408
+ mockExistsSync.mockReturnValue(false);
409
+ expect(resolveBinary("/nonexistent/claude")).toBeNull();
410
+ });
411
+
412
+ describe("Windows support", () => {
413
+ const originalPlatform = process.platform;
414
+
415
+ beforeEach(() => {
416
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
417
+ });
418
+
419
+ afterEach(() => {
420
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
421
+ });
422
+
423
+ it("accepts Windows absolute paths like C:\\... on win32", () => {
424
+ mockExistsSync.mockReturnValue(true);
425
+ expect(resolveBinary("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd")).toBe(
426
+ "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd",
427
+ );
428
+ });
429
+
430
+ it("returns null for a non-existent Windows absolute path", () => {
431
+ mockExistsSync.mockReturnValue(false);
432
+ expect(resolveBinary("D:\\nonexistent\\claude.cmd")).toBeNull();
433
+ });
434
+
435
+ it("prefers 'where' over 'which' on Windows when both succeed", () => {
436
+ _resetPathCache();
437
+ mockExecSync.mockImplementation((cmd: string) => {
438
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
439
+ return "___PATH_START___/usr/bin___PATH_END___\n";
440
+ }
441
+ // 'where' succeeds with a native Win32 path
442
+ if (typeof cmd === "string" && cmd.startsWith("where")) {
443
+ return "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd\r\nC:\\Users\\me\\AppData\\Roaming\\npm\\claude\r\n";
444
+ }
445
+ // 'which' also succeeds but returns a POSIX-style path (Git Bash)
446
+ if (typeof cmd === "string" && cmd.startsWith("which")) {
447
+ return "/c/Users/me/AppData/Roaming/npm/claude";
448
+ }
449
+ throw new Error("not found");
450
+ });
451
+
452
+ // Should return the 'where' result (native Win32 path), not the 'which' POSIX path
453
+ expect(resolveBinary("claude")).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd");
454
+ });
455
+
456
+ it("falls back to 'which' when 'where' fails on Windows", () => {
457
+ _resetPathCache();
458
+ mockExecSync.mockImplementation((cmd: string) => {
459
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
460
+ return "___PATH_START___/usr/bin___PATH_END___\n";
461
+ }
462
+ // 'where' fails
463
+ if (typeof cmd === "string" && cmd.startsWith("where")) {
464
+ throw new Error("not found");
465
+ }
466
+ // 'which' succeeds (Git Bash fallback)
467
+ if (typeof cmd === "string" && cmd.startsWith("which")) {
468
+ return "/c/Users/me/AppData/Roaming/npm/claude";
469
+ }
470
+ throw new Error("not found");
471
+ });
472
+
473
+ expect(resolveBinary("claude")).toBe("/c/Users/me/AppData/Roaming/npm/claude");
474
+ });
475
+
476
+ it("prefers .cmd result from 'where' output with multiple lines", () => {
477
+ _resetPathCache();
478
+ mockExecSync.mockImplementation((cmd: string) => {
479
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
480
+ return "___PATH_START___/usr/bin___PATH_END___\n";
481
+ }
482
+ if (typeof cmd === "string" && cmd.startsWith("which")) {
483
+ throw new Error("not found");
484
+ }
485
+ if (typeof cmd === "string" && cmd.startsWith("where")) {
486
+ return "C:\\Program Files\\nodejs\\node\r\nC:\\Users\\me\\AppData\\Roaming\\npm\\node.cmd\r\n";
487
+ }
488
+ throw new Error("not found");
489
+ });
490
+
491
+ expect(resolveBinary("node")).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\node.cmd");
492
+ });
493
+
494
+ it("returns first line from 'where' when no .cmd match exists", () => {
495
+ _resetPathCache();
496
+ mockExecSync.mockImplementation((cmd: string) => {
497
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
498
+ return "___PATH_START___/usr/bin___PATH_END___\n";
499
+ }
500
+ if (typeof cmd === "string" && cmd.startsWith("which")) {
501
+ throw new Error("not found");
502
+ }
503
+ if (typeof cmd === "string" && cmd.startsWith("where")) {
504
+ return "C:\\Program Files\\nodejs\\node.exe\r\n";
505
+ }
506
+ throw new Error("not found");
507
+ });
508
+
509
+ expect(resolveBinary("node")).toBe("C:\\Program Files\\nodejs\\node.exe");
510
+ });
511
+ });
512
+ });
513
+
514
+ // ─── getServicePath ─────────────────────────────────────────────────────────
515
+
516
+ describe("getServicePath", () => {
517
+ it("returns the same value as getEnrichedPath", () => {
518
+ process.env.PATH = "/usr/bin";
519
+ mockExecSync.mockImplementation((cmd: string) => {
520
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
521
+ return "___PATH_START___/usr/bin:/opt/homebrew/bin___PATH_END___\n";
522
+ }
523
+ return "";
524
+ });
525
+
526
+ expect(getServicePath()).toBe(getEnrichedPath());
527
+ });
528
+ });
529
+
530
+ // ─── _resetPathCache ────────────────────────────────────────────────────────
531
+
532
+ describe("_resetPathCache", () => {
533
+ it("clears the cached PATH so next call re-computes", () => {
534
+ process.env.PATH = "/usr/bin";
535
+ let callCount = 0;
536
+ mockExecSync.mockImplementation((cmd: string) => {
537
+ if (typeof cmd === "string" && cmd.includes("-lic")) {
538
+ callCount++;
539
+ return `___PATH_START___/usr/bin:/call-${callCount}___PATH_END___\n`;
540
+ }
541
+ return "";
542
+ });
543
+
544
+ const first = getEnrichedPath();
545
+ _resetPathCache();
546
+ const second = getEnrichedPath();
547
+
548
+ expect(first).not.toBe(second);
549
+ expect(first).toContain("/call-1");
550
+ expect(second).toContain("/call-2");
551
+ });
552
+ });