@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
});
|