@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,1198 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { tmpdir, homedir } from "node:os";
5
+ import { execSync } from "node:child_process";
6
+ import { Hono } from "hono";
7
+ import { registerFsRoutes } from "./fs-routes.js";
8
+
9
+ /** Create a temp dir with symlinks resolved (macOS /var → /private/var) */
10
+ const mkRealTempDir = (prefix: string) => realpathSync(mkdtempSync(join(tmpdir(), prefix)));
11
+
12
+ // Create a Hono app with the fs routes for testing
13
+ let app: Hono;
14
+ let tempDir: string;
15
+
16
+ beforeEach(() => {
17
+ tempDir = mkRealTempDir("fs-raw-test-");
18
+ app = new Hono();
19
+ // Pass tempDir as an allowed base so test files are accessible
20
+ registerFsRoutes(app, { allowedBases: [tempDir] });
21
+ });
22
+
23
+ afterEach(() => {
24
+ try {
25
+ rmSync(tempDir, { recursive: true, force: true });
26
+ } catch {
27
+ // ignore cleanup errors
28
+ }
29
+ });
30
+
31
+ describe("GET /fs/raw", () => {
32
+ it("returns binary content with correct Content-Type for a PNG file", async () => {
33
+ // A .png file should be served with image/png MIME type and raw binary body
34
+ const filePath = join(tempDir, "test.png");
35
+ const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
36
+ writeFileSync(filePath, pngHeader);
37
+
38
+ const res = await app.request(`/fs/raw?path=${encodeURIComponent(filePath)}`);
39
+
40
+ expect(res.status).toBe(200);
41
+ expect(res.headers.get("Content-Type")).toMatch(/image\/png|application\/octet-stream/);
42
+ expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
43
+ const body = await res.arrayBuffer();
44
+ expect(body.byteLength).toBe(4);
45
+ });
46
+
47
+ it("returns 400 when path query parameter is missing", async () => {
48
+ const res = await app.request("/fs/raw");
49
+
50
+ expect(res.status).toBe(400);
51
+ const body = await res.json();
52
+ expect(body.error).toBe("path required");
53
+ });
54
+
55
+ it("returns 404 when file does not exist", async () => {
56
+ const fakePath = join(tempDir, "nonexistent.png");
57
+ const res = await app.request(`/fs/raw?path=${encodeURIComponent(fakePath)}`);
58
+
59
+ expect(res.status).toBe(404);
60
+ const body = await res.json();
61
+ expect(body.error).toBeTruthy();
62
+ });
63
+
64
+ it("returns 413 when file exceeds 10MB", async () => {
65
+ // Create a file just over the 10MB limit to trigger the size guard
66
+ const filePath = join(tempDir, "large.bin");
67
+ const buf = Buffer.alloc(10 * 1024 * 1024 + 1, 0);
68
+ writeFileSync(filePath, buf);
69
+
70
+ const res = await app.request(`/fs/raw?path=${encodeURIComponent(filePath)}`);
71
+
72
+ expect(res.status).toBe(413);
73
+ const body = await res.json();
74
+ expect(body.error).toMatch(/too large/i);
75
+ });
76
+
77
+ it("serves a JPEG file with correct MIME type", async () => {
78
+ // Verifies MIME detection works for different image extensions
79
+ const filePath = join(tempDir, "photo.jpg");
80
+ writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); // JPEG magic bytes
81
+
82
+ const res = await app.request(`/fs/raw?path=${encodeURIComponent(filePath)}`);
83
+
84
+ expect(res.status).toBe(200);
85
+ expect(res.headers.get("Content-Type")).toMatch(/image\/jpeg|application\/octet-stream/);
86
+ });
87
+
88
+ it("serves an SVG file with correct MIME type", async () => {
89
+ const filePath = join(tempDir, "icon.svg");
90
+ writeFileSync(filePath, '<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"/></svg>');
91
+
92
+ const res = await app.request(`/fs/raw?path=${encodeURIComponent(filePath)}`);
93
+
94
+ expect(res.status).toBe(200);
95
+ expect(res.headers.get("Content-Type")).toMatch(/image\/svg|application\/octet-stream/);
96
+ });
97
+ });
98
+
99
+ describe("path traversal protection", () => {
100
+ it("rejects /fs/read for paths outside allowed bases", async () => {
101
+ // Attempting to read /etc/passwd should be blocked by the path guard
102
+ const res = await app.request(`/fs/read?path=${encodeURIComponent("/etc/passwd")}`);
103
+
104
+ expect(res.status).toBe(403);
105
+ const body = await res.json();
106
+ expect(body.error).toMatch(/outside allowed/i);
107
+ });
108
+
109
+ it("rejects /fs/raw for paths outside allowed bases", async () => {
110
+ const res = await app.request(`/fs/raw?path=${encodeURIComponent("/etc/hosts")}`);
111
+
112
+ expect(res.status).toBe(403);
113
+ const body = await res.json();
114
+ expect(body.error).toMatch(/outside allowed/i);
115
+ });
116
+
117
+ it("rejects /fs/list for paths outside allowed bases", async () => {
118
+ const res = await app.request(`/fs/list?path=${encodeURIComponent("/etc")}`);
119
+
120
+ expect(res.status).toBe(403);
121
+ const body = await res.json();
122
+ expect(body.error).toMatch(/outside allowed/i);
123
+ });
124
+
125
+ it("rejects /fs/tree for paths outside allowed bases", async () => {
126
+ const res = await app.request(`/fs/tree?path=${encodeURIComponent("/etc")}`);
127
+
128
+ expect(res.status).toBe(403);
129
+ const body = await res.json();
130
+ expect(body.error).toMatch(/outside allowed/i);
131
+ });
132
+
133
+ it("rejects /fs/write for paths outside allowed bases", async () => {
134
+ const res = await app.request("/fs/write", {
135
+ method: "PUT",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ path: "/tmp/evil.txt", content: "pwned" }),
138
+ });
139
+
140
+ expect(res.status).toBe(403);
141
+ const body = await res.json();
142
+ expect(body.error).toMatch(/outside allowed/i);
143
+ });
144
+
145
+ it("rejects directory traversal with ../ sequences", async () => {
146
+ // Even if the path starts within allowed base, ../ could escape it
147
+ const traversalPath = join(tempDir, "..", "..", "etc", "passwd");
148
+ const res = await app.request(`/fs/read?path=${encodeURIComponent(traversalPath)}`);
149
+
150
+ expect(res.status).toBe(403);
151
+ const body = await res.json();
152
+ expect(body.error).toMatch(/outside allowed/i);
153
+ });
154
+
155
+ it("allows access to files within allowed bases", async () => {
156
+ // Files inside tempDir (our allowed base) should work fine
157
+ const filePath = join(tempDir, "allowed.txt");
158
+ writeFileSync(filePath, "hello");
159
+
160
+ const res = await app.request(`/fs/read?path=${encodeURIComponent(filePath)}`);
161
+
162
+ expect(res.status).toBe(200);
163
+ const body = await res.json();
164
+ expect(body.content).toBe("hello");
165
+ });
166
+ });
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // GET /fs/list — directory listing with sorting and error handling
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ describe("GET /fs/list", () => {
172
+ it("lists only directories (not files), sorted alphabetically", async () => {
173
+ // Create a mix of directories and files; only directories should be returned
174
+ mkdirSync(join(tempDir, "zulu"));
175
+ mkdirSync(join(tempDir, "alpha"));
176
+ mkdirSync(join(tempDir, "mike"));
177
+ writeFileSync(join(tempDir, "file.txt"), "not a dir");
178
+
179
+ const res = await app.request(`/fs/list?path=${encodeURIComponent(tempDir)}`);
180
+
181
+ expect(res.status).toBe(200);
182
+ const body = await res.json();
183
+ expect(body.path).toBe(tempDir);
184
+ expect(body.home).toBe(homedir());
185
+ // Should only contain directories, sorted alphabetically
186
+ expect(body.dirs.map((d: { name: string }) => d.name)).toEqual([
187
+ "alpha",
188
+ "mike",
189
+ "zulu",
190
+ ]);
191
+ // Each entry should include the full path
192
+ expect(body.dirs[0].path).toBe(join(tempDir, "alpha"));
193
+ });
194
+
195
+ it("excludes hidden directories (names starting with .)", async () => {
196
+ // Hidden directories like .git or .config should be excluded from listing
197
+ mkdirSync(join(tempDir, ".hidden"));
198
+ mkdirSync(join(tempDir, "visible"));
199
+
200
+ const res = await app.request(`/fs/list?path=${encodeURIComponent(tempDir)}`);
201
+
202
+ expect(res.status).toBe(200);
203
+ const body = await res.json();
204
+ expect(body.dirs.map((d: { name: string }) => d.name)).toEqual(["visible"]);
205
+ });
206
+
207
+ it("returns empty dirs array for an empty directory", async () => {
208
+ // A directory with no subdirectories should return an empty dirs array
209
+ const emptyDir = join(tempDir, "empty");
210
+ mkdirSync(emptyDir);
211
+
212
+ const res = await app.request(`/fs/list?path=${encodeURIComponent(emptyDir)}`);
213
+
214
+ expect(res.status).toBe(200);
215
+ const body = await res.json();
216
+ expect(body.dirs).toEqual([]);
217
+ });
218
+
219
+ it("returns 400 when the path does not exist", async () => {
220
+ // Attempting to list a nonexistent directory should return an error with dirs: []
221
+ const noDir = join(tempDir, "nonexistent");
222
+
223
+ const res = await app.request(`/fs/list?path=${encodeURIComponent(noDir)}`);
224
+
225
+ expect(res.status).toBe(400);
226
+ const body = await res.json();
227
+ expect(body.error).toBe("Cannot read directory");
228
+ expect(body.dirs).toEqual([]);
229
+ expect(body.home).toBe(homedir());
230
+ });
231
+ });
232
+
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+ // GET /fs/home — home directory and cwd logic
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ describe("GET /fs/home", () => {
237
+ it("returns the home directory", async () => {
238
+ // /fs/home should always include the real home directory
239
+ const res = await app.request("/fs/home");
240
+
241
+ expect(res.status).toBe(200);
242
+ const body = await res.json();
243
+ expect(body.home).toBe(homedir());
244
+ });
245
+
246
+ it("returns cwd different from home when running from a project directory", async () => {
247
+ // When cwd is not the home dir and not under the package root, it should
248
+ // be returned as the cwd (indicating a project directory context)
249
+ const originalPackageRoot = process.env.__COMPANION_PACKAGE_ROOT;
250
+ delete process.env.__COMPANION_PACKAGE_ROOT;
251
+
252
+ const res = await app.request("/fs/home");
253
+ const body = await res.json();
254
+
255
+ expect(res.status).toBe(200);
256
+ // cwd should be the actual process.cwd() since we're in the web/ dir (not home)
257
+ expect(body.cwd).toBeTruthy();
258
+
259
+ // Restore
260
+ if (originalPackageRoot !== undefined) {
261
+ process.env.__COMPANION_PACKAGE_ROOT = originalPackageRoot;
262
+ }
263
+ });
264
+
265
+ it("returns home as cwd when cwd equals home", async () => {
266
+ // When cwd === home, the route should return home for both fields.
267
+ // We can test this by using vi.spyOn to simulate cwd being home.
268
+ const originalCwd = process.cwd;
269
+ process.cwd = () => homedir();
270
+
271
+ const res = await app.request("/fs/home");
272
+ const body = await res.json();
273
+
274
+ expect(res.status).toBe(200);
275
+ expect(body.cwd).toBe(homedir());
276
+
277
+ process.cwd = originalCwd;
278
+ });
279
+
280
+ it("returns home as cwd when cwd is under the package root", async () => {
281
+ // When __COMPANION_PACKAGE_ROOT is set and cwd starts with it,
282
+ // the route treats cwd as the package install dir (not a real project)
283
+ // and returns home instead.
284
+ const originalCwd = process.cwd;
285
+ const originalPackageRoot = process.env.__COMPANION_PACKAGE_ROOT;
286
+
287
+ process.env.__COMPANION_PACKAGE_ROOT = "/fake/package/root";
288
+ process.cwd = () => "/fake/package/root/subdir";
289
+
290
+ const res = await app.request("/fs/home");
291
+ const body = await res.json();
292
+
293
+ expect(res.status).toBe(200);
294
+ expect(body.cwd).toBe(homedir());
295
+
296
+ process.cwd = originalCwd;
297
+ if (originalPackageRoot !== undefined) {
298
+ process.env.__COMPANION_PACKAGE_ROOT = originalPackageRoot;
299
+ } else {
300
+ delete process.env.__COMPANION_PACKAGE_ROOT;
301
+ }
302
+ });
303
+ });
304
+
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+ // GET /fs/tree — recursive tree building with depth limits and hidden exclusion
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ describe("GET /fs/tree", () => {
309
+ it("returns 400 when path query parameter is missing", async () => {
310
+ const res = await app.request("/fs/tree");
311
+
312
+ expect(res.status).toBe(400);
313
+ const body = await res.json();
314
+ expect(body.error).toBe("path required");
315
+ });
316
+
317
+ it("builds a tree with directories and files, sorted correctly", async () => {
318
+ // Tree should list directories before files, both sorted alphabetically
319
+ mkdirSync(join(tempDir, "src"));
320
+ writeFileSync(join(tempDir, "src", "index.ts"), "export {}");
321
+ writeFileSync(join(tempDir, "src", "app.ts"), "const app = 1");
322
+ mkdirSync(join(tempDir, "docs"));
323
+ writeFileSync(join(tempDir, "README.md"), "# Hello");
324
+
325
+ const res = await app.request(`/fs/tree?path=${encodeURIComponent(tempDir)}`);
326
+
327
+ expect(res.status).toBe(200);
328
+ const body = await res.json();
329
+ expect(body.path).toBe(tempDir);
330
+
331
+ // Top-level: directories first (docs, src), then files (README.md)
332
+ const names = body.tree.map((n: { name: string }) => n.name);
333
+ expect(names).toEqual(["docs", "src", "README.md"]);
334
+
335
+ // src directory should have children sorted alphabetically
336
+ const srcNode = body.tree.find((n: { name: string }) => n.name === "src");
337
+ expect(srcNode.type).toBe("directory");
338
+ expect(srcNode.children.map((c: { name: string }) => c.name)).toEqual([
339
+ "app.ts",
340
+ "index.ts",
341
+ ]);
342
+ });
343
+
344
+ it("excludes hidden files/directories and node_modules", async () => {
345
+ // .git, .hidden, and node_modules should all be excluded from the tree
346
+ mkdirSync(join(tempDir, ".git"));
347
+ mkdirSync(join(tempDir, "node_modules"));
348
+ mkdirSync(join(tempDir, "src"));
349
+ writeFileSync(join(tempDir, ".env"), "SECRET=x");
350
+
351
+ const res = await app.request(`/fs/tree?path=${encodeURIComponent(tempDir)}`);
352
+
353
+ expect(res.status).toBe(200);
354
+ const body = await res.json();
355
+ const names = body.tree.map((n: { name: string }) => n.name);
356
+ // Only "src" should be present — .git, node_modules, and .env should all be excluded
357
+ expect(names).toEqual(["src"]);
358
+ });
359
+
360
+ it("handles nested directory structures recursively", async () => {
361
+ // Create a 3-level deep directory structure
362
+ mkdirSync(join(tempDir, "a", "b", "c"), { recursive: true });
363
+ writeFileSync(join(tempDir, "a", "b", "c", "deep.txt"), "deep file");
364
+
365
+ const res = await app.request(`/fs/tree?path=${encodeURIComponent(tempDir)}`);
366
+
367
+ expect(res.status).toBe(200);
368
+ const body = await res.json();
369
+ // Navigate the tree: a -> b -> c -> deep.txt
370
+ const aNode = body.tree[0];
371
+ expect(aNode.name).toBe("a");
372
+ expect(aNode.type).toBe("directory");
373
+ const bNode = aNode.children[0];
374
+ expect(bNode.name).toBe("b");
375
+ const cNode = bNode.children[0];
376
+ expect(cNode.name).toBe("c");
377
+ expect(cNode.children[0].name).toBe("deep.txt");
378
+ expect(cNode.children[0].type).toBe("file");
379
+ });
380
+
381
+ it("returns an empty tree for an empty directory", async () => {
382
+ const emptyDir = join(tempDir, "empty");
383
+ mkdirSync(emptyDir);
384
+
385
+ const res = await app.request(`/fs/tree?path=${encodeURIComponent(emptyDir)}`);
386
+
387
+ expect(res.status).toBe(200);
388
+ const body = await res.json();
389
+ expect(body.tree).toEqual([]);
390
+ });
391
+ });
392
+
393
+ // ─────────────────────────────────────────────────────────────────────────────
394
+ // GET /fs/read — reading file contents
395
+ // ─────────────────────────────────────────────────────────────────────────────
396
+ describe("GET /fs/read", () => {
397
+ it("returns 400 when path query parameter is missing", async () => {
398
+ const res = await app.request("/fs/read");
399
+
400
+ expect(res.status).toBe(400);
401
+ const body = await res.json();
402
+ expect(body.error).toBe("path required");
403
+ });
404
+
405
+ it("reads a file and returns its content", async () => {
406
+ // Create a text file and verify it is read correctly
407
+ const filePath = join(tempDir, "hello.txt");
408
+ writeFileSync(filePath, "Hello, World!");
409
+
410
+ const res = await app.request(`/fs/read?path=${encodeURIComponent(filePath)}`);
411
+
412
+ expect(res.status).toBe(200);
413
+ const body = await res.json();
414
+ expect(body.path).toBe(filePath);
415
+ expect(body.content).toBe("Hello, World!");
416
+ });
417
+
418
+ it("returns 413 when file exceeds 2MB size limit", async () => {
419
+ // The read endpoint has a stricter 2MB limit compared to raw's 10MB
420
+ const filePath = join(tempDir, "bigfile.txt");
421
+ const buf = Buffer.alloc(2 * 1024 * 1024 + 1, "x");
422
+ writeFileSync(filePath, buf);
423
+
424
+ const res = await app.request(`/fs/read?path=${encodeURIComponent(filePath)}`);
425
+
426
+ expect(res.status).toBe(413);
427
+ const body = await res.json();
428
+ expect(body.error).toMatch(/too large/i);
429
+ });
430
+
431
+ it("returns 404 when file does not exist", async () => {
432
+ const fakePath = join(tempDir, "no-such-file.txt");
433
+ const res = await app.request(`/fs/read?path=${encodeURIComponent(fakePath)}`);
434
+
435
+ expect(res.status).toBe(404);
436
+ const body = await res.json();
437
+ expect(body.error).toBeTruthy();
438
+ });
439
+ });
440
+
441
+ // ─────────────────────────────────────────────────────────────────────────────
442
+ // PUT /fs/write — writing file contents
443
+ // ─────────────────────────────────────────────────────────────────────────────
444
+ describe("PUT /fs/write", () => {
445
+ it("writes content to a file and returns ok", async () => {
446
+ // Create a new file via the write endpoint
447
+ const filePath = join(tempDir, "written.txt");
448
+
449
+ const res = await app.request("/fs/write", {
450
+ method: "PUT",
451
+ headers: { "Content-Type": "application/json" },
452
+ body: JSON.stringify({ path: filePath, content: "written via API" }),
453
+ });
454
+
455
+ expect(res.status).toBe(200);
456
+ const body = await res.json();
457
+ expect(body.ok).toBe(true);
458
+ expect(body.path).toBe(filePath);
459
+
460
+ // Verify the file was actually written to disk
461
+ const actual = readFileSync(filePath, "utf-8");
462
+ expect(actual).toBe("written via API");
463
+ });
464
+
465
+ it("returns 400 when path is missing", async () => {
466
+ const res = await app.request("/fs/write", {
467
+ method: "PUT",
468
+ headers: { "Content-Type": "application/json" },
469
+ body: JSON.stringify({ content: "no path" }),
470
+ });
471
+
472
+ expect(res.status).toBe(400);
473
+ const body = await res.json();
474
+ expect(body.error).toBe("path and content required");
475
+ });
476
+
477
+ it("returns 400 when content is missing", async () => {
478
+ const res = await app.request("/fs/write", {
479
+ method: "PUT",
480
+ headers: { "Content-Type": "application/json" },
481
+ body: JSON.stringify({ path: join(tempDir, "x.txt") }),
482
+ });
483
+
484
+ expect(res.status).toBe(400);
485
+ const body = await res.json();
486
+ expect(body.error).toBe("path and content required");
487
+ });
488
+
489
+ it("returns 400 when content is not a string", async () => {
490
+ // content must be a string, not a number or object
491
+ const res = await app.request("/fs/write", {
492
+ method: "PUT",
493
+ headers: { "Content-Type": "application/json" },
494
+ body: JSON.stringify({ path: join(tempDir, "x.txt"), content: 123 }),
495
+ });
496
+
497
+ expect(res.status).toBe(400);
498
+ const body = await res.json();
499
+ expect(body.error).toBe("path and content required");
500
+ });
501
+
502
+ it("returns 500 when the target directory does not exist", async () => {
503
+ // Writing to a nonexistent parent directory should fail
504
+ const filePath = join(tempDir, "no", "such", "dir", "file.txt");
505
+
506
+ const res = await app.request("/fs/write", {
507
+ method: "PUT",
508
+ headers: { "Content-Type": "application/json" },
509
+ body: JSON.stringify({ path: filePath, content: "fail" }),
510
+ });
511
+
512
+ expect(res.status).toBe(500);
513
+ const body = await res.json();
514
+ expect(body.error).toBeTruthy();
515
+ });
516
+
517
+ it("handles malformed JSON body gracefully", async () => {
518
+ // Sending invalid JSON should not crash the server; it should return 400
519
+ const res = await app.request("/fs/write", {
520
+ method: "PUT",
521
+ headers: { "Content-Type": "application/json" },
522
+ body: "not json",
523
+ });
524
+
525
+ expect(res.status).toBe(400);
526
+ const body = await res.json();
527
+ expect(body.error).toBe("path and content required");
528
+ });
529
+ });
530
+
531
+ // ─────────────────────────────────────────────────────────────────────────────
532
+ // Git-related routes: /fs/diff and /fs/changed-files
533
+ // These require a temporary git repository.
534
+ // ─────────────────────────────────────────────────────────────────────────────
535
+ describe("git-related routes", () => {
536
+ let gitDir: string;
537
+ let gitApp: Hono;
538
+
539
+ beforeEach(() => {
540
+ // Create a fresh temp directory and initialize a git repo for each test
541
+ gitDir = mkRealTempDir("fs-git-test-");
542
+ execSync("git init", { cwd: gitDir });
543
+ execSync("git config user.email 'test@test.com'", { cwd: gitDir });
544
+ execSync("git config user.name 'Test'", { cwd: gitDir });
545
+
546
+ gitApp = new Hono();
547
+ registerFsRoutes(gitApp, { allowedBases: [gitDir] });
548
+ });
549
+
550
+ afterEach(() => {
551
+ try {
552
+ rmSync(gitDir, { recursive: true, force: true });
553
+ } catch {
554
+ // ignore cleanup errors
555
+ }
556
+ });
557
+
558
+ describe("GET /fs/diff", () => {
559
+ it("returns 400 when path query parameter is missing", async () => {
560
+ const res = await gitApp.request("/fs/diff");
561
+
562
+ expect(res.status).toBe(400);
563
+ const body = await res.json();
564
+ expect(body.error).toBe("path required");
565
+ });
566
+
567
+ it("returns diff for a modified tracked file against HEAD", async () => {
568
+ // Create a file, commit it, then modify it — diff should show the change
569
+ const filePath = join(gitDir, "file.txt");
570
+ writeFileSync(filePath, "line one\n");
571
+ execSync("git add .", { cwd: gitDir });
572
+ execSync('git commit -m "initial"', { cwd: gitDir });
573
+
574
+ // Modify the file
575
+ writeFileSync(filePath, "line one\nline two\n");
576
+
577
+ const res = await gitApp.request(`/fs/diff?path=${encodeURIComponent(filePath)}`);
578
+
579
+ expect(res.status).toBe(200);
580
+ const body = await res.json();
581
+ expect(body.path).toBe(resolve(filePath));
582
+ // The diff should contain the added line
583
+ expect(body.diff).toContain("+line two");
584
+ });
585
+
586
+ it("returns diff for an untracked file using /dev/null comparison", async () => {
587
+ // An untracked file should produce a diff showing all lines as additions
588
+ // First create an initial commit so HEAD exists
589
+ writeFileSync(join(gitDir, "initial.txt"), "init\n");
590
+ execSync("git add .", { cwd: gitDir });
591
+ execSync('git commit -m "initial"', { cwd: gitDir });
592
+
593
+ // Now create an untracked file
594
+ const newFile = join(gitDir, "untracked.txt");
595
+ writeFileSync(newFile, "brand new file\n");
596
+
597
+ const res = await gitApp.request(`/fs/diff?path=${encodeURIComponent(newFile)}`);
598
+
599
+ expect(res.status).toBe(200);
600
+ const body = await res.json();
601
+ // Untracked files get diffed against /dev/null, showing all content as additions
602
+ expect(body.diff).toContain("+brand new file");
603
+ });
604
+
605
+ it("returns empty diff for an unmodified committed file", async () => {
606
+ // A file that has been committed and not changed should have empty diff
607
+ const filePath = join(gitDir, "clean.txt");
608
+ writeFileSync(filePath, "clean\n");
609
+ execSync("git add .", { cwd: gitDir });
610
+ execSync('git commit -m "initial"', { cwd: gitDir });
611
+
612
+ const res = await gitApp.request(`/fs/diff?path=${encodeURIComponent(filePath)}`);
613
+
614
+ expect(res.status).toBe(200);
615
+ const body = await res.json();
616
+ expect(body.diff).toBe("");
617
+ });
618
+
619
+ it("returns diff with base=default-branch", async () => {
620
+ // When base=default-branch, diff should use the branch resolution logic.
621
+ // With a local 'main' branch (created by git init defaults), this should still work.
622
+ const filePath = join(gitDir, "feature.txt");
623
+ writeFileSync(filePath, "original\n");
624
+ execSync("git add .", { cwd: gitDir });
625
+ execSync('git commit -m "initial"', { cwd: gitDir });
626
+
627
+ // Modify the file
628
+ writeFileSync(filePath, "original\nchanged\n");
629
+
630
+ const res = await gitApp.request(
631
+ `/fs/diff?path=${encodeURIComponent(filePath)}&base=default-branch`
632
+ );
633
+
634
+ expect(res.status).toBe(200);
635
+ const body = await res.json();
636
+ // The diff against default branch should show the change
637
+ // (even if it falls through all bases, it returns empty diff gracefully)
638
+ expect(body.path).toBe(resolve(filePath));
639
+ });
640
+
641
+ it("returns empty diff gracefully for a non-git directory", async () => {
642
+ // A file outside any git repo should return an empty diff (caught by try/catch)
643
+ const nonGitDir = mkRealTempDir("fs-nogit-test-");
644
+ const nonGitApp = new Hono();
645
+ registerFsRoutes(nonGitApp, { allowedBases: [nonGitDir] });
646
+
647
+ const filePath = join(nonGitDir, "norepo.txt");
648
+ writeFileSync(filePath, "not in git\n");
649
+
650
+ const res = await nonGitApp.request(`/fs/diff?path=${encodeURIComponent(filePath)}`);
651
+
652
+ expect(res.status).toBe(200);
653
+ const body = await res.json();
654
+ // When not in a git repo, diff should be empty (outer catch returns { diff: "" })
655
+ expect(body.diff).toBe("");
656
+
657
+ rmSync(nonGitDir, { recursive: true, force: true });
658
+ });
659
+
660
+ it("handles untracked file in fresh repo with no HEAD", async () => {
661
+ // In a fresh git repo with no commits, HEAD doesn't exist.
662
+ // The diff route should handle this gracefully.
663
+ const freshDir = mkRealTempDir("fs-fresh-git-");
664
+ execSync("git init", { cwd: freshDir });
665
+ execSync("git config user.email 'test@test.com'", { cwd: freshDir });
666
+ execSync("git config user.name 'Test'", { cwd: freshDir });
667
+
668
+ const freshApp = new Hono();
669
+ registerFsRoutes(freshApp, { allowedBases: [freshDir] });
670
+
671
+ const filePath = join(freshDir, "first.txt");
672
+ writeFileSync(filePath, "first file ever\n");
673
+
674
+ const res = await freshApp.request(`/fs/diff?path=${encodeURIComponent(filePath)}`);
675
+
676
+ expect(res.status).toBe(200);
677
+ const body = await res.json();
678
+ // In a fresh repo, HEAD doesn't exist so the diff falls through to untracked handling
679
+ expect(body.diff).toContain("+first file ever");
680
+
681
+ rmSync(freshDir, { recursive: true, force: true });
682
+ });
683
+ });
684
+
685
+ describe("GET /fs/changed-files", () => {
686
+ it("returns 400 when cwd query parameter is missing", async () => {
687
+ const res = await gitApp.request("/fs/changed-files");
688
+
689
+ expect(res.status).toBe(400);
690
+ const body = await res.json();
691
+ expect(body.error).toBe("cwd required");
692
+ });
693
+
694
+ it("lists modified and untracked files", async () => {
695
+ // Create a file, commit it, then modify it and create a new untracked file
696
+ writeFileSync(join(gitDir, "tracked.txt"), "original\n");
697
+ execSync("git add .", { cwd: gitDir });
698
+ execSync('git commit -m "initial"', { cwd: gitDir });
699
+
700
+ // Modify the tracked file and create a new untracked file
701
+ writeFileSync(join(gitDir, "tracked.txt"), "modified\n");
702
+ writeFileSync(join(gitDir, "new.txt"), "brand new\n");
703
+
704
+ const res = await gitApp.request(
705
+ `/fs/changed-files?cwd=${encodeURIComponent(gitDir)}`
706
+ );
707
+
708
+ expect(res.status).toBe(200);
709
+ const body = await res.json();
710
+ const paths = body.files.map((f: { path: string }) => f.path);
711
+ const statuses = Object.fromEntries(
712
+ body.files.map((f: { path: string; status: string }) => [f.path, f.status])
713
+ );
714
+
715
+ // tracked.txt should show as Modified, new.txt as Added
716
+ expect(paths).toContain(join(gitDir, "tracked.txt"));
717
+ expect(paths).toContain(join(gitDir, "new.txt"));
718
+ expect(statuses[join(gitDir, "tracked.txt")]).toBe("M");
719
+ expect(statuses[join(gitDir, "new.txt")]).toBe("A");
720
+ });
721
+
722
+ it("lists only uncommitted changes when base=last-commit", async () => {
723
+ // With base=last-commit, only changes vs HEAD should be shown (not branch diff)
724
+ writeFileSync(join(gitDir, "base.txt"), "base\n");
725
+ execSync("git add .", { cwd: gitDir });
726
+ execSync('git commit -m "initial"', { cwd: gitDir });
727
+
728
+ writeFileSync(join(gitDir, "base.txt"), "modified\n");
729
+
730
+ const res = await gitApp.request(
731
+ `/fs/changed-files?cwd=${encodeURIComponent(gitDir)}&base=last-commit`
732
+ );
733
+
734
+ expect(res.status).toBe(200);
735
+ const body = await res.json();
736
+ expect(body.files.length).toBeGreaterThan(0);
737
+ const modifiedFile = body.files.find(
738
+ (f: { path: string }) => f.path === join(gitDir, "base.txt")
739
+ );
740
+ expect(modifiedFile).toBeTruthy();
741
+ expect(modifiedFile.status).toBe("M");
742
+ });
743
+
744
+ it("returns empty files array for a clean repo", async () => {
745
+ // A repo with no changes should return an empty files array
746
+ writeFileSync(join(gitDir, "clean.txt"), "clean\n");
747
+ execSync("git add .", { cwd: gitDir });
748
+ execSync('git commit -m "initial"', { cwd: gitDir });
749
+
750
+ const res = await gitApp.request(
751
+ `/fs/changed-files?cwd=${encodeURIComponent(gitDir)}`
752
+ );
753
+
754
+ expect(res.status).toBe(200);
755
+ const body = await res.json();
756
+ expect(body.files).toEqual([]);
757
+ });
758
+
759
+ it("returns empty files array for a non-git directory", async () => {
760
+ // A directory that is not a git repo should gracefully return empty files
761
+ const nonGitDir = mkRealTempDir("fs-nogit-changed-");
762
+ const nonGitApp = new Hono();
763
+ registerFsRoutes(nonGitApp, { allowedBases: [nonGitDir] });
764
+
765
+ const res = await nonGitApp.request(
766
+ `/fs/changed-files?cwd=${encodeURIComponent(nonGitDir)}`
767
+ );
768
+
769
+ expect(res.status).toBe(200);
770
+ const body = await res.json();
771
+ expect(body.files).toEqual([]);
772
+
773
+ rmSync(nonGitDir, { recursive: true, force: true });
774
+ });
775
+
776
+ it("includes staged files in the changed files list", async () => {
777
+ // Staged (but not yet committed) changes should appear in the list
778
+ writeFileSync(join(gitDir, "staged.txt"), "initial\n");
779
+ execSync("git add .", { cwd: gitDir });
780
+ execSync('git commit -m "initial"', { cwd: gitDir });
781
+
782
+ writeFileSync(join(gitDir, "staged.txt"), "updated\n");
783
+ execSync("git add staged.txt", { cwd: gitDir });
784
+
785
+ const res = await gitApp.request(
786
+ `/fs/changed-files?cwd=${encodeURIComponent(gitDir)}`
787
+ );
788
+
789
+ expect(res.status).toBe(200);
790
+ const body = await res.json();
791
+ const stagedFile = body.files.find(
792
+ (f: { path: string }) => f.path === join(gitDir, "staged.txt")
793
+ );
794
+ expect(stagedFile).toBeTruthy();
795
+ expect(stagedFile.status).toBe("M");
796
+ });
797
+
798
+ it("handles deleted files", async () => {
799
+ // Deleted tracked files should show up with status "D"
800
+ writeFileSync(join(gitDir, "doomed.txt"), "will be deleted\n");
801
+ execSync("git add .", { cwd: gitDir });
802
+ execSync('git commit -m "initial"', { cwd: gitDir });
803
+
804
+ execSync("git rm doomed.txt", { cwd: gitDir });
805
+
806
+ const res = await gitApp.request(
807
+ `/fs/changed-files?cwd=${encodeURIComponent(gitDir)}`
808
+ );
809
+
810
+ expect(res.status).toBe(200);
811
+ const body = await res.json();
812
+ const deletedFile = body.files.find(
813
+ (f: { path: string }) => f.path === join(gitDir, "doomed.txt")
814
+ );
815
+ expect(deletedFile).toBeTruthy();
816
+ expect(deletedFile.status).toBe("D");
817
+ });
818
+ });
819
+ });
820
+
821
+ // ─────────────────────────────────────────────────────────────────────────────
822
+ // GET /fs/claude-md — finding CLAUDE.md files
823
+ // ─────────────────────────────────────────────────────────────────────────────
824
+ describe("GET /fs/claude-md", () => {
825
+ let claudeDir: string;
826
+ let claudeApp: Hono;
827
+
828
+ beforeEach(() => {
829
+ claudeDir = mkRealTempDir("fs-claude-md-test-");
830
+ // Initialize a git repo so the walk-up logic stops at the repo root
831
+ execSync("git init", { cwd: claudeDir });
832
+ execSync("git config user.email 'test@test.com'", { cwd: claudeDir });
833
+ execSync("git config user.name 'Test'", { cwd: claudeDir });
834
+
835
+ claudeApp = new Hono();
836
+ registerFsRoutes(claudeApp, { allowedBases: [claudeDir] });
837
+ });
838
+
839
+ afterEach(() => {
840
+ try {
841
+ rmSync(claudeDir, { recursive: true, force: true });
842
+ } catch {
843
+ // ignore cleanup errors
844
+ }
845
+ });
846
+
847
+ it("returns 400 when cwd query parameter is missing", async () => {
848
+ const res = await claudeApp.request("/fs/claude-md");
849
+
850
+ expect(res.status).toBe(400);
851
+ const body = await res.json();
852
+ expect(body.error).toBe("cwd required");
853
+ });
854
+
855
+ it("finds CLAUDE.md at the project root", async () => {
856
+ // A CLAUDE.md at the root should be found
857
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "# Project Instructions");
858
+
859
+ const res = await claudeApp.request(
860
+ `/fs/claude-md?cwd=${encodeURIComponent(claudeDir)}`
861
+ );
862
+
863
+ expect(res.status).toBe(200);
864
+ const body = await res.json();
865
+ expect(body.cwd).toBe(resolve(claudeDir));
866
+ expect(body.files.length).toBe(1);
867
+ expect(body.files[0].path).toBe(join(claudeDir, "CLAUDE.md"));
868
+ expect(body.files[0].content).toBe("# Project Instructions");
869
+ });
870
+
871
+ it("finds CLAUDE.md inside .claude/ directory", async () => {
872
+ // CLAUDE.md can also live in a .claude/ subdirectory
873
+ mkdirSync(join(claudeDir, ".claude"));
874
+ writeFileSync(join(claudeDir, ".claude", "CLAUDE.md"), "# Hidden config");
875
+
876
+ const res = await claudeApp.request(
877
+ `/fs/claude-md?cwd=${encodeURIComponent(claudeDir)}`
878
+ );
879
+
880
+ expect(res.status).toBe(200);
881
+ const body = await res.json();
882
+ expect(body.files.length).toBe(1);
883
+ expect(body.files[0].path).toBe(join(claudeDir, ".claude", "CLAUDE.md"));
884
+ });
885
+
886
+ it("finds both root and .claude/ CLAUDE.md files", async () => {
887
+ // When both exist, both should be returned
888
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "# Root");
889
+ mkdirSync(join(claudeDir, ".claude"));
890
+ writeFileSync(join(claudeDir, ".claude", "CLAUDE.md"), "# Nested");
891
+
892
+ const res = await claudeApp.request(
893
+ `/fs/claude-md?cwd=${encodeURIComponent(claudeDir)}`
894
+ );
895
+
896
+ expect(res.status).toBe(200);
897
+ const body = await res.json();
898
+ expect(body.files.length).toBe(2);
899
+ const paths = body.files.map((f: { path: string }) => f.path);
900
+ expect(paths).toContain(join(claudeDir, "CLAUDE.md"));
901
+ expect(paths).toContain(join(claudeDir, ".claude", "CLAUDE.md"));
902
+ });
903
+
904
+ it("returns empty files array when no CLAUDE.md exists", async () => {
905
+ const res = await claudeApp.request(
906
+ `/fs/claude-md?cwd=${encodeURIComponent(claudeDir)}`
907
+ );
908
+
909
+ expect(res.status).toBe(200);
910
+ const body = await res.json();
911
+ expect(body.files).toEqual([]);
912
+ });
913
+
914
+ it("walks up from a subdirectory to find CLAUDE.md at the repo root", async () => {
915
+ // When cwd is a subdirectory, the walk-up logic should find CLAUDE.md in parent dirs
916
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "# Root level");
917
+ const subDir = join(claudeDir, "packages", "core");
918
+ mkdirSync(subDir, { recursive: true });
919
+
920
+ const res = await claudeApp.request(
921
+ `/fs/claude-md?cwd=${encodeURIComponent(subDir)}`
922
+ );
923
+
924
+ expect(res.status).toBe(200);
925
+ const body = await res.json();
926
+ expect(body.files.length).toBe(1);
927
+ expect(body.files[0].path).toBe(join(claudeDir, "CLAUDE.md"));
928
+ });
929
+ });
930
+
931
+ // ─────────────────────────────────────────────────────────────────────────────
932
+ // PUT /fs/claude-md — writing CLAUDE.md files with validation
933
+ // ─────────────────────────────────────────────────────────────────────────────
934
+ describe("PUT /fs/claude-md", () => {
935
+ it("writes CLAUDE.md to an existing directory", async () => {
936
+ const filePath = join(tempDir, "CLAUDE.md");
937
+
938
+ const res = await app.request("/fs/claude-md", {
939
+ method: "PUT",
940
+ headers: { "Content-Type": "application/json" },
941
+ body: JSON.stringify({ path: filePath, content: "# New Instructions" }),
942
+ });
943
+
944
+ expect(res.status).toBe(200);
945
+ const body = await res.json();
946
+ expect(body.ok).toBe(true);
947
+ expect(body.path).toBe(resolve(filePath));
948
+
949
+ // Verify file was actually written
950
+ const actual = readFileSync(resolve(filePath), "utf-8");
951
+ expect(actual).toBe("# New Instructions");
952
+ });
953
+
954
+ it("writes CLAUDE.md inside .claude/ directory, creating it if needed", async () => {
955
+ // The endpoint should create the .claude/ directory if it doesn't exist
956
+ const filePath = join(tempDir, ".claude", "CLAUDE.md");
957
+
958
+ const res = await app.request("/fs/claude-md", {
959
+ method: "PUT",
960
+ headers: { "Content-Type": "application/json" },
961
+ body: JSON.stringify({ path: filePath, content: "# Nested" }),
962
+ });
963
+
964
+ expect(res.status).toBe(200);
965
+ const body = await res.json();
966
+ expect(body.ok).toBe(true);
967
+
968
+ const actual = readFileSync(resolve(filePath), "utf-8");
969
+ expect(actual).toBe("# Nested");
970
+ });
971
+
972
+ it("returns 400 when path is missing", async () => {
973
+ const res = await app.request("/fs/claude-md", {
974
+ method: "PUT",
975
+ headers: { "Content-Type": "application/json" },
976
+ body: JSON.stringify({ content: "# No path" }),
977
+ });
978
+
979
+ expect(res.status).toBe(400);
980
+ const body = await res.json();
981
+ expect(body.error).toBe("path and content required");
982
+ });
983
+
984
+ it("returns 400 when content is missing", async () => {
985
+ const res = await app.request("/fs/claude-md", {
986
+ method: "PUT",
987
+ headers: { "Content-Type": "application/json" },
988
+ body: JSON.stringify({ path: join(tempDir, "CLAUDE.md") }),
989
+ });
990
+
991
+ expect(res.status).toBe(400);
992
+ const body = await res.json();
993
+ expect(body.error).toBe("path and content required");
994
+ });
995
+
996
+ it("returns 400 when filename is not CLAUDE.md", async () => {
997
+ // Only CLAUDE.md files can be written through this endpoint
998
+ const res = await app.request("/fs/claude-md", {
999
+ method: "PUT",
1000
+ headers: { "Content-Type": "application/json" },
1001
+ body: JSON.stringify({ path: join(tempDir, "README.md"), content: "hack" }),
1002
+ });
1003
+
1004
+ expect(res.status).toBe(400);
1005
+ const body = await res.json();
1006
+ expect(body.error).toBe("Can only write CLAUDE.md files");
1007
+ });
1008
+
1009
+ it("returns 400 for a CLAUDE.md path that is not in a standard location", async () => {
1010
+ // The path must end with /CLAUDE.md or /.claude/CLAUDE.md
1011
+ // A path like /some/random/place/CLAUDE.md that doesn't match either pattern
1012
+ // is rejected by the endsWith checks.
1013
+ // Actually, any path ending in /CLAUDE.md passes the endsWith check.
1014
+ // The only rejection is if base !== "CLAUDE.md" — so test a non-CLAUDE.md name.
1015
+ const res = await app.request("/fs/claude-md", {
1016
+ method: "PUT",
1017
+ headers: { "Content-Type": "application/json" },
1018
+ body: JSON.stringify({ path: join(tempDir, "notclaude.md"), content: "x" }),
1019
+ });
1020
+
1021
+ expect(res.status).toBe(400);
1022
+ });
1023
+
1024
+ it("handles malformed JSON body gracefully", async () => {
1025
+ const res = await app.request("/fs/claude-md", {
1026
+ method: "PUT",
1027
+ headers: { "Content-Type": "application/json" },
1028
+ body: "not json",
1029
+ });
1030
+
1031
+ expect(res.status).toBe(400);
1032
+ const body = await res.json();
1033
+ expect(body.error).toBe("path and content required");
1034
+ });
1035
+ });
1036
+
1037
+ // ─────────────────────────────────────────────────────────────────────────────
1038
+ // GET /fs/claude-config — full configuration endpoint
1039
+ // ─────────────────────────────────────────────────────────────────────────────
1040
+ describe("GET /fs/claude-config", () => {
1041
+ let configDir: string;
1042
+ let configApp: Hono;
1043
+
1044
+ beforeEach(() => {
1045
+ configDir = mkRealTempDir("fs-config-test-");
1046
+ // Initialize a git repo to define the project root
1047
+ execSync("git init", { cwd: configDir });
1048
+ execSync("git config user.email 'test@test.com'", { cwd: configDir });
1049
+ execSync("git config user.name 'Test'", { cwd: configDir });
1050
+
1051
+ configApp = new Hono();
1052
+ registerFsRoutes(configApp, { allowedBases: [configDir] });
1053
+ });
1054
+
1055
+ afterEach(() => {
1056
+ try {
1057
+ rmSync(configDir, { recursive: true, force: true });
1058
+ } catch {
1059
+ // ignore cleanup errors
1060
+ }
1061
+ });
1062
+
1063
+ it("returns 400 when cwd query parameter is missing", async () => {
1064
+ const res = await configApp.request("/fs/claude-config");
1065
+
1066
+ expect(res.status).toBe(400);
1067
+ const body = await res.json();
1068
+ expect(body.error).toBe("cwd required");
1069
+ });
1070
+
1071
+ it("returns complete config structure with project and user sections", async () => {
1072
+ // Even with no config files, the response should have the correct shape
1073
+ const res = await configApp.request(
1074
+ `/fs/claude-config?cwd=${encodeURIComponent(configDir)}`
1075
+ );
1076
+
1077
+ expect(res.status).toBe(200);
1078
+ const body = await res.json();
1079
+
1080
+ // Verify the top-level structure
1081
+ expect(body).toHaveProperty("project");
1082
+ expect(body).toHaveProperty("user");
1083
+ expect(body.project).toHaveProperty("root");
1084
+ expect(body.project).toHaveProperty("claudeMd");
1085
+ expect(body.project).toHaveProperty("settings");
1086
+ expect(body.project).toHaveProperty("settingsLocal");
1087
+ expect(body.project).toHaveProperty("commands");
1088
+ expect(body.user).toHaveProperty("root");
1089
+ expect(body.user).toHaveProperty("claudeMd");
1090
+ expect(body.user).toHaveProperty("skills");
1091
+ expect(body.user).toHaveProperty("agents");
1092
+ expect(body.user).toHaveProperty("settings");
1093
+ expect(body.user).toHaveProperty("commands");
1094
+ });
1095
+
1096
+ it("detects project-level CLAUDE.md files", async () => {
1097
+ writeFileSync(join(configDir, "CLAUDE.md"), "# Project config");
1098
+
1099
+ const res = await configApp.request(
1100
+ `/fs/claude-config?cwd=${encodeURIComponent(configDir)}`
1101
+ );
1102
+
1103
+ expect(res.status).toBe(200);
1104
+ const body = await res.json();
1105
+ expect(body.project.claudeMd.length).toBe(1);
1106
+ expect(body.project.claudeMd[0].content).toBe("# Project config");
1107
+ });
1108
+
1109
+ it("detects project-level settings.json and settings.local.json", async () => {
1110
+ // Create .claude/settings.json and .claude/settings.local.json in the project
1111
+ const claudeDir = join(configDir, ".claude");
1112
+ mkdirSync(claudeDir);
1113
+ writeFileSync(
1114
+ join(claudeDir, "settings.json"),
1115
+ JSON.stringify({ model: "claude-3" })
1116
+ );
1117
+ writeFileSync(
1118
+ join(claudeDir, "settings.local.json"),
1119
+ JSON.stringify({ local: true })
1120
+ );
1121
+
1122
+ const res = await configApp.request(
1123
+ `/fs/claude-config?cwd=${encodeURIComponent(configDir)}`
1124
+ );
1125
+
1126
+ expect(res.status).toBe(200);
1127
+ const body = await res.json();
1128
+ expect(body.project.settings).not.toBeNull();
1129
+ expect(body.project.settings.content).toContain("claude-3");
1130
+ expect(body.project.settingsLocal).not.toBeNull();
1131
+ expect(body.project.settingsLocal.content).toContain("local");
1132
+ });
1133
+
1134
+ it("detects project-level commands/*.md files", async () => {
1135
+ // Create .claude/commands/ with some .md command files
1136
+ const commandsDir = join(configDir, ".claude", "commands");
1137
+ mkdirSync(commandsDir, { recursive: true });
1138
+ writeFileSync(join(commandsDir, "deploy.md"), "# Deploy command");
1139
+ writeFileSync(join(commandsDir, "test.md"), "# Test command");
1140
+ writeFileSync(join(commandsDir, "not-a-command.txt"), "ignored");
1141
+
1142
+ const res = await configApp.request(
1143
+ `/fs/claude-config?cwd=${encodeURIComponent(configDir)}`
1144
+ );
1145
+
1146
+ expect(res.status).toBe(200);
1147
+ const body = await res.json();
1148
+ // Only .md files should be included, sorted alphabetically
1149
+ expect(body.project.commands.length).toBe(2);
1150
+ expect(body.project.commands[0].name).toBe("deploy");
1151
+ expect(body.project.commands[1].name).toBe("test");
1152
+ expect(body.project.commands[0].path).toBe(join(commandsDir, "deploy.md"));
1153
+ });
1154
+
1155
+ it("returns null for missing project settings", async () => {
1156
+ // When no .claude/settings.json exists, settings should be null
1157
+ const res = await configApp.request(
1158
+ `/fs/claude-config?cwd=${encodeURIComponent(configDir)}`
1159
+ );
1160
+
1161
+ expect(res.status).toBe(200);
1162
+ const body = await res.json();
1163
+ expect(body.project.settings).toBeNull();
1164
+ expect(body.project.settingsLocal).toBeNull();
1165
+ expect(body.project.commands).toEqual([]);
1166
+ });
1167
+
1168
+ it("sets project root to repo root when inside a git repo", async () => {
1169
+ // The project root should be the git repo root, not the cwd
1170
+ const subDir = join(configDir, "packages", "core");
1171
+ mkdirSync(subDir, { recursive: true });
1172
+
1173
+ const res = await configApp.request(
1174
+ `/fs/claude-config?cwd=${encodeURIComponent(subDir)}`
1175
+ );
1176
+
1177
+ expect(res.status).toBe(200);
1178
+ const body = await res.json();
1179
+ expect(body.project.root).toBe(resolve(configDir));
1180
+ });
1181
+
1182
+ it("uses cwd as project root when not in a git repo", async () => {
1183
+ // Without a git repo, project root falls back to cwd
1184
+ const nonGitDir = mkRealTempDir("fs-config-nogit-");
1185
+ const nonGitApp = new Hono();
1186
+ registerFsRoutes(nonGitApp, { allowedBases: [nonGitDir] });
1187
+
1188
+ const res = await nonGitApp.request(
1189
+ `/fs/claude-config?cwd=${encodeURIComponent(nonGitDir)}`
1190
+ );
1191
+
1192
+ expect(res.status).toBe(200);
1193
+ const body = await res.json();
1194
+ expect(body.project.root).toBe(resolve(nonGitDir));
1195
+
1196
+ rmSync(nonGitDir, { recursive: true, force: true });
1197
+ });
1198
+ });