wiki-viewer 1.5.2 → 1.7.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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/build-manifest.json +9 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js +15 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js +5 -5
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0p12-6~._.js → [root-of-the-server]__094q5bj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0w47wil._.js → [root-of-the-server]__0km8suj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__12qerfy._.js → [root-of-the-server]__0mzo03s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0e7si9p._.js → [root-of-the-server]__0rzo40z._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t5mg.n._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0zi3nkn._.js → [root-of-the-server]__106uw_c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0qhdf-p._.js → [root-of-the-server]__12800b1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_download_route_actions_0rd6p-3.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_00650~e._.js → 0~w0_katex_dist_katex_mjs_0b8ra51._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0jyyo2t._.js → [externals]__05-uq57._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0m6xq-c._.js → [externals]__0btd01s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06mzg2t._.js → [root-of-the-server]__00lk0r~._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12ahxbl._.js → [root-of-the-server]__0ytvlzs._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +7 -7
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0v_nwsz._.js → node_modules__pnpm_0-x6zex._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0tkiej3._.js → node_modules__pnpm_012nt.s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~2~c30._.js → node_modules__pnpm_07rxv2x._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gbgliv._.js → node_modules__pnpm_0a4su30._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_10413dq._.js → node_modules__pnpm_0ajzar9._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zzzsrj._.js → node_modules__pnpm_0bum3-3._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wffzo0._.js → node_modules__pnpm_0c71i8a._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_099cv.b._.js → node_modules__pnpm_0cbsnwf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f_uzgy._.js → node_modules__pnpm_0cxwhfd._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03ij5.g._.js → node_modules__pnpm_0dboa_e._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0iojvye._.js → node_modules__pnpm_0de8xt-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02gx8km._.js → node_modules__pnpm_0ezm2us._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m5-azh._.js → node_modules__pnpm_0hobcbs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~.6dgb._.js → node_modules__pnpm_0hrbz84._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0rebktf._.js → node_modules__pnpm_0i.pw_m._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03v21p-._.js → node_modules__pnpm_0il8j.j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0cwpt9p._.js → node_modules__pnpm_0j3mvgg._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0fixtms._.js → node_modules__pnpm_0j53hti._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0li2vzs._.js → node_modules__pnpm_0kwc-vs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_05qsply._.js → node_modules__pnpm_0lm8e2c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_08ibh0w._.js → node_modules__pnpm_0mdo4i3._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_06pgebo._.js → node_modules__pnpm_0o.c1z8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gofu~q._.js → node_modules__pnpm_0rw91-f._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0n1kfcp._.js → node_modules__pnpm_0xrmzh1._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02mew~s._.js → node_modules__pnpm_0yq5z.h._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xxwmv2._.js → node_modules__pnpm_0z~h2ow._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f9fpy5._.js → node_modules__pnpm_0~ln0by._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wouz19._.js → node_modules__pnpm_11e~j49._.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/{17~r3.8awh3qg.js → 0vwkqw_rofm-c.js} +7 -7
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/packages/wiki-viewer-mcp/bench-e2e.mjs +166 -0
- package/.next/standalone/packages/wiki-viewer-mcp/bench-latency.mjs +87 -0
- package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
- package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +36 -7
- package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +50 -2
- package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
- package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
- package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
- package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +63 -5
- package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
- package/.next/standalone/pnpm-lock.yaml +3 -3
- package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
- package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
- package/.next/standalone/src/app/page.tsx +31 -1
- package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
- package/.next/standalone/src/tests/proof/bench-write.ts +179 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_ssgManifest.js +0 -0
|
@@ -27,8 +27,35 @@ import {
|
|
|
27
27
|
import * as z from "zod";
|
|
28
28
|
|
|
29
29
|
import { parseArgs } from "node:util";
|
|
30
|
-
import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError } from "./http-client.js";
|
|
30
|
+
import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError, PatchUnsupportedError, MatchCountError } from "./http-client.js";
|
|
31
31
|
import * as stateCache from "./state-cache.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Enable pooled HTTP keep-alive for the global fetch the client uses.
|
|
35
|
+
*
|
|
36
|
+
* Without this, every tool call opens a fresh connection and pays a full
|
|
37
|
+
* TCP + TLS handshake (~3 RTT) before the request even goes out. Over a WAN
|
|
38
|
+
* link that turns a sub-100ms write into 300ms-1s+, and multiplies across the
|
|
39
|
+
* GET+PUT an edit performs. Reusing connections removes that per-call penalty.
|
|
40
|
+
*
|
|
41
|
+
* Wrapped in try/catch + dynamic import so a missing/edge undici never breaks
|
|
42
|
+
* startup; it just falls back to default (non-pooled) fetch.
|
|
43
|
+
*/
|
|
44
|
+
async function enableKeepAlive(): Promise<void> {
|
|
45
|
+
try {
|
|
46
|
+
const { Agent, setGlobalDispatcher } = await import("undici");
|
|
47
|
+
setGlobalDispatcher(
|
|
48
|
+
new Agent({
|
|
49
|
+
keepAliveTimeout: 30_000,
|
|
50
|
+
keepAliveMaxTimeout: 60_000,
|
|
51
|
+
connections: 16,
|
|
52
|
+
pipelining: 1,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
// undici unavailable — default fetch still works, just without pooling.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
32
59
|
import {
|
|
33
60
|
register,
|
|
34
61
|
RegisterScope,
|
|
@@ -287,17 +314,47 @@ export function createServer(client: WikiViewerClient): Server {
|
|
|
287
314
|
const block = checkCollabBlock(path);
|
|
288
315
|
if (block) return err(block);
|
|
289
316
|
|
|
290
|
-
//
|
|
317
|
+
// Best path: server-side PATCH str-replace — sends only {find,replace}
|
|
318
|
+
// (~hundreds of bytes) instead of the whole file. One small request.
|
|
319
|
+
// Requires a known sha (If-Match); use cached sha if present, else the
|
|
320
|
+
// server's 412-recover path handles it. Falls back to read+PUT if the
|
|
321
|
+
// server has no PATCH route (older version).
|
|
322
|
+
const cachedForPatch = stateCache.get(path);
|
|
323
|
+
if (cachedForPatch?.collabState !== "active") {
|
|
324
|
+
try {
|
|
325
|
+
const r = await client.patchFile(path, find, replace, {
|
|
326
|
+
ifMatch: cachedForPatch?.sha256,
|
|
327
|
+
});
|
|
328
|
+
return ok(
|
|
329
|
+
`Edited: ${path}\nReplaced ${JSON.stringify(find)} → ${JSON.stringify(replace)}\nNew sha256: ${r.sha256}`,
|
|
330
|
+
);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
if (e instanceof MatchCountError) {
|
|
333
|
+
return err(
|
|
334
|
+
`edit_file: expected to replace exactly 1 occurrence of ${JSON.stringify(find)} in "${path}", ` +
|
|
335
|
+
`but found ${e.found}. Re-read the file or make the search string unique.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (e instanceof CollabActiveError) {
|
|
339
|
+
return err(collabActiveMessage(path, e.snapshotUrl));
|
|
340
|
+
}
|
|
341
|
+
if (e instanceof IfMatchError) {
|
|
342
|
+
// sha was stale — fall through to read+retry below.
|
|
343
|
+
} else if (!(e instanceof PatchUnsupportedError)) {
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
// PatchUnsupportedError or stale If-Match → fall back to read+PUT.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Fallback path: read → transform → PUT with If-Match.
|
|
291
351
|
const readResult = await client.readFile(path);
|
|
292
352
|
if (readResult.text === null) {
|
|
293
353
|
return err(`edit_file: "${path}" appears to be binary — cannot do text replacement.`);
|
|
294
354
|
}
|
|
295
|
-
|
|
296
|
-
// Check collab state from fresh read
|
|
297
355
|
if (readResult.collabState === "active") {
|
|
298
356
|
return err(collabActiveMessage(path, readResult.collabSnapshot));
|
|
299
357
|
}
|
|
300
|
-
|
|
301
358
|
if (!readResult.text.includes(find)) {
|
|
302
359
|
return err(
|
|
303
360
|
`edit_file: string not found in "${path}".\n` +
|
|
@@ -433,6 +490,7 @@ function buildJsonSchema(val: AnyZodVal): Record<string, unknown> {
|
|
|
433
490
|
// ─── Entrypoints ─────────────────────────────────────────────────────────────
|
|
434
491
|
|
|
435
492
|
async function main() {
|
|
493
|
+
await enableKeepAlive();
|
|
436
494
|
const client = createClient();
|
|
437
495
|
const server = createServer(client);
|
|
438
496
|
const transport = new StdioServerTransport();
|
|
@@ -15,8 +15,18 @@ export interface PathState {
|
|
|
15
15
|
collabSnapshot: string | null;
|
|
16
16
|
/** When this cache entry was last updated (ms) */
|
|
17
17
|
fetchedAt: number;
|
|
18
|
+
/**
|
|
19
|
+
* Last-known text body, paired with `sha256`. Only set for text files from a
|
|
20
|
+
* read. Lets edit_file skip the GET round-trip: it transforms this cached
|
|
21
|
+
* text and PUTs with If-Match=sha256; if the file changed server-side the
|
|
22
|
+
* PUT 412s and the caller re-reads. Capped by MAX_CACHED_BODY_BYTES.
|
|
23
|
+
*/
|
|
24
|
+
body?: string;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
/** Don't cache bodies larger than this (avoid unbounded memory). */
|
|
28
|
+
export const MAX_CACHED_BODY_BYTES = 256 * 1024;
|
|
29
|
+
|
|
20
30
|
const cache = new Map<string, PathState>();
|
|
21
31
|
|
|
22
32
|
export function set(path: string, state: PathState): void {
|
|
@@ -20,6 +20,9 @@ importers:
|
|
|
20
20
|
chokidar:
|
|
21
21
|
specifier: ^5.0.0
|
|
22
22
|
version: 5.0.0
|
|
23
|
+
jszip:
|
|
24
|
+
specifier: ^3.10.1
|
|
25
|
+
version: 3.10.1
|
|
23
26
|
kysely:
|
|
24
27
|
specifier: 0.28.5
|
|
25
28
|
version: 0.28.5
|
|
@@ -204,9 +207,6 @@ importers:
|
|
|
204
207
|
highlight.js:
|
|
205
208
|
specifier: ^11.11.1
|
|
206
209
|
version: 11.11.1
|
|
207
|
-
jszip:
|
|
208
|
-
specifier: ^3.10.1
|
|
209
|
-
version: 3.10.1
|
|
210
210
|
lowlight:
|
|
211
211
|
specifier: ^3.3.0
|
|
212
212
|
version: 3.3.0
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
isMarkdown,
|
|
25
25
|
atomicWrite,
|
|
26
26
|
ensureParentDir,
|
|
27
|
+
looksLikeBinary,
|
|
27
28
|
} from "@/lib/proof/raw-fs";
|
|
28
29
|
import { computeCollabState } from "@/lib/proof/collab-state";
|
|
29
30
|
|
|
@@ -125,16 +126,28 @@ export async function GET(
|
|
|
125
126
|
});
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
// ── PUT
|
|
129
|
+
// ── Shared mutation path (PUT + PATCH) ────────────────────────────────────────
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Single code path for all Tier-1 byte mutations so PUT and PATCH cannot drift
|
|
133
|
+
* on auth/scope/If-Match/R6/atomicWrite/reconcile/audit.
|
|
134
|
+
*
|
|
135
|
+
* `computeNewBody` receives the existing file buffer (or null if absent) and
|
|
136
|
+
* returns the new bytes, or an error response to short-circuit (e.g. PATCH
|
|
137
|
+
* match-count mismatch). `op` is the audit verb ("put" | "patch").
|
|
138
|
+
*/
|
|
139
|
+
async function applyMutation(
|
|
131
140
|
req: Request,
|
|
132
|
-
|
|
141
|
+
segments: string[],
|
|
142
|
+
opts: {
|
|
143
|
+
op: "put" | "patch";
|
|
144
|
+
allowCreate: boolean;
|
|
145
|
+
computeNewBody: (existing: Buffer | null) => Buffer | NextResponse;
|
|
146
|
+
},
|
|
133
147
|
): Promise<NextResponse> {
|
|
134
148
|
const auth = await checkAuth(req);
|
|
135
149
|
if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
|
|
136
150
|
|
|
137
|
-
const { path: segments } = await params;
|
|
138
151
|
const relPath = rel(segments);
|
|
139
152
|
const url = new URL(req.url);
|
|
140
153
|
const mkdirs = url.searchParams.get("mkdirs") === "true";
|
|
@@ -150,56 +163,52 @@ export async function PUT(
|
|
|
150
163
|
if (!scope.ok) return errJson(scope.code, scope.message, 403);
|
|
151
164
|
|
|
152
165
|
const ifMatch = req.headers.get("if-match");
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
// Check existing file
|
|
157
|
-
let existingSha: string | undefined;
|
|
158
|
-
let existed = false;
|
|
159
|
-
try {
|
|
160
|
-
const existing = await readFile(absPath);
|
|
161
|
-
existingSha = sha256ofBuf(existing);
|
|
162
|
-
existed = true;
|
|
163
|
-
} catch (e) {
|
|
164
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
165
|
-
}
|
|
166
|
+
const ifCollabMatch = req.headers.get("if-collab-match");
|
|
167
|
+
const rootDir = getRootDir();
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
// The whole read-existing → precondition → compute → write → reconcile → audit
|
|
170
|
+
// sequence runs inside the mutex for .md (so R6 and reconcile are atomic with
|
|
171
|
+
// the write); non-.md runs it without the mutex.
|
|
172
|
+
const doMutation = async (): Promise<NextResponse> => {
|
|
173
|
+
// Read existing
|
|
174
|
+
let existingBuf: Buffer | null = null;
|
|
175
|
+
let existingSha: string | undefined;
|
|
176
|
+
try {
|
|
177
|
+
existingBuf = await readFile(absPath);
|
|
178
|
+
existingSha = sha256ofBuf(existingBuf);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
175
181
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
182
|
+
const existed = existingBuf !== null;
|
|
183
|
+
|
|
184
|
+
if (existed) {
|
|
185
|
+
// Overwrite: If-Match required by default (unless ?force=true)
|
|
186
|
+
if (!force && !ifMatch) {
|
|
187
|
+
return errJson(
|
|
188
|
+
"PRECONDITION_REQUIRED",
|
|
189
|
+
"If-Match header required for overwrites (use ?force=true to bypass with audit)",
|
|
190
|
+
412,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (ifMatch && extractShaHex(ifMatch) !== extractShaHex(existingSha!)) {
|
|
180
194
|
return errJson("PRECONDITION_FAILED", "If-Match sha256 mismatch", 412);
|
|
181
195
|
}
|
|
196
|
+
} else {
|
|
197
|
+
if (!opts.allowCreate) {
|
|
198
|
+
return errJson("NOT_FOUND", "File not found", 404);
|
|
199
|
+
}
|
|
200
|
+
const parentOk = await ensureParentDir(absPath, mkdirs);
|
|
201
|
+
if (!parentOk) {
|
|
202
|
+
return errJson(
|
|
203
|
+
"PARENT_NOT_FOUND",
|
|
204
|
+
"Parent directory does not exist (use ?mkdirs=true to create)",
|
|
205
|
+
400,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
182
208
|
}
|
|
183
|
-
} else {
|
|
184
|
-
// Create: ensure parent dir exists
|
|
185
|
-
const parentOk = await ensureParentDir(absPath, mkdirs);
|
|
186
|
-
if (!parentOk) {
|
|
187
|
-
return errJson(
|
|
188
|
-
"PARENT_NOT_FOUND",
|
|
189
|
-
"Parent directory does not exist (use ?mkdirs=true to create)",
|
|
190
|
-
400,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
209
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const ifCollabMatch = req.headers.get("if-collab-match");
|
|
198
|
-
|
|
199
|
-
if (isMarkdown(relPath)) {
|
|
200
|
-
// R1: acquire shared mutex; R2: reconcile eagerly inside it
|
|
201
|
-
return await withFileMutex(relPath, async () => {
|
|
202
|
-
// R6: atomic collab-state check (closes TOCTOU race)
|
|
210
|
+
// R6: for .md, re-check collab state atomically (inside mutex)
|
|
211
|
+
if (isMarkdown(relPath)) {
|
|
203
212
|
const { state, revision, snapshotUrl } = await computeCollabState(rootDir, relPath);
|
|
204
213
|
if (state === "active") {
|
|
205
214
|
const matchVal = ifCollabMatch?.trim();
|
|
@@ -216,9 +225,17 @@ export async function PUT(
|
|
|
216
225
|
);
|
|
217
226
|
}
|
|
218
227
|
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Compute new bytes (PUT: request body; PATCH: str-replace on existing)
|
|
231
|
+
const computed = opts.computeNewBody(existingBuf);
|
|
232
|
+
if (computed instanceof NextResponse) return computed;
|
|
233
|
+
const bodyBuf = computed;
|
|
234
|
+
const newSha = sha256ofBuf(bodyBuf);
|
|
219
235
|
|
|
220
|
-
|
|
236
|
+
await atomicWrite(absPath, bodyBuf);
|
|
221
237
|
|
|
238
|
+
if (isMarkdown(relPath)) {
|
|
222
239
|
const content = bodyBuf.toString("utf-8");
|
|
223
240
|
const sidecar = (await readSidecar(rootDir, relPath)) ?? emptySidecar(relPath);
|
|
224
241
|
await reconcileSidecar({
|
|
@@ -230,44 +247,119 @@ export async function PUT(
|
|
|
230
247
|
eventType: "file.rawWritten",
|
|
231
248
|
fingerprint: newSha,
|
|
232
249
|
});
|
|
250
|
+
}
|
|
233
251
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
252
|
+
writeAuditRow({
|
|
253
|
+
agentId: auth.agent.id,
|
|
254
|
+
op: opts.op,
|
|
255
|
+
path: relPath,
|
|
256
|
+
oldSha: existingSha,
|
|
257
|
+
newSha,
|
|
258
|
+
forced: force,
|
|
259
|
+
});
|
|
242
260
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
261
|
+
const st = await stat(absPath);
|
|
262
|
+
return NextResponse.json({
|
|
263
|
+
path: relPath,
|
|
264
|
+
sha256: newSha,
|
|
265
|
+
size: st.size,
|
|
266
|
+
mtime: st.mtime.toISOString(),
|
|
267
|
+
created: !existed,
|
|
251
268
|
});
|
|
252
|
-
}
|
|
269
|
+
};
|
|
253
270
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
271
|
+
return isMarkdown(relPath) ? withFileMutex(relPath, doMutation) : doMutation();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── PUT ──────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export async function PUT(
|
|
277
|
+
req: Request,
|
|
278
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
279
|
+
): Promise<NextResponse> {
|
|
280
|
+
const { path: segments } = await params;
|
|
281
|
+
const bodyBuf = Buffer.from(await req.arrayBuffer());
|
|
282
|
+
return applyMutation(req, segments, {
|
|
258
283
|
op: "put",
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
newSha,
|
|
262
|
-
forced: force,
|
|
284
|
+
allowCreate: true,
|
|
285
|
+
computeNewBody: () => bodyBuf,
|
|
263
286
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── PATCH ─────────────────────────────────────────────────────────────────────
|
|
290
|
+
// Server-side str-replace so agents send only the change, not the whole file.
|
|
291
|
+
// Strict: exact substring (no regex), text/UTF-8 only, If-Match required,
|
|
292
|
+
// expectedOccurrences must match exactly (default 1). Shares applyMutation, so
|
|
293
|
+
// lock / R6 / reconcile / audit behave identically to PUT.
|
|
294
|
+
|
|
295
|
+
const MAX_PATCH_STRING = 1_000_000; // 1MB cap on find/replace strings
|
|
296
|
+
|
|
297
|
+
export async function PATCH(
|
|
298
|
+
req: Request,
|
|
299
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
300
|
+
): Promise<NextResponse> {
|
|
301
|
+
const { path: segments } = await params;
|
|
302
|
+
|
|
303
|
+
let body: { find?: unknown; replace?: unknown; expectedOccurrences?: unknown };
|
|
304
|
+
try {
|
|
305
|
+
body = (await req.json()) as typeof body;
|
|
306
|
+
} catch {
|
|
307
|
+
return errJson("INVALID_PAYLOAD", "Body must be JSON {find, replace, expectedOccurrences?}", 400);
|
|
308
|
+
}
|
|
309
|
+
const find = body.find;
|
|
310
|
+
const replace = body.replace;
|
|
311
|
+
if (typeof find !== "string" || typeof replace !== "string") {
|
|
312
|
+
return errJson("INVALID_PAYLOAD", "find and replace must be strings", 400);
|
|
313
|
+
}
|
|
314
|
+
if (find.length === 0) {
|
|
315
|
+
return errJson("INVALID_PAYLOAD", "find must not be empty", 400);
|
|
316
|
+
}
|
|
317
|
+
if (find.length > MAX_PATCH_STRING || replace.length > MAX_PATCH_STRING) {
|
|
318
|
+
return errJson("PAYLOAD_TOO_LARGE", "find/replace exceeds 1MB limit", 413);
|
|
319
|
+
}
|
|
320
|
+
let expected = 1;
|
|
321
|
+
if (body.expectedOccurrences !== undefined) {
|
|
322
|
+
if (typeof body.expectedOccurrences !== "number" || !Number.isInteger(body.expectedOccurrences) || body.expectedOccurrences < 1) {
|
|
323
|
+
return errJson("INVALID_PAYLOAD", "expectedOccurrences must be a positive integer", 400);
|
|
324
|
+
}
|
|
325
|
+
expected = body.expectedOccurrences;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return applyMutation(req, segments, {
|
|
329
|
+
op: "patch",
|
|
330
|
+
allowCreate: false, // patch only edits existing files
|
|
331
|
+
computeNewBody: (existing) => {
|
|
332
|
+
if (existing === null) return errJson("NOT_FOUND", "File not found", 404);
|
|
333
|
+
if (looksLikeBinary(existing)) {
|
|
334
|
+
return errJson("UNSUPPORTED", "Cannot patch binary / non-text file", 415);
|
|
335
|
+
}
|
|
336
|
+
let text: string;
|
|
337
|
+
try {
|
|
338
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(existing);
|
|
339
|
+
} catch {
|
|
340
|
+
return errJson("UNSUPPORTED", "File is not valid UTF-8", 415);
|
|
341
|
+
}
|
|
342
|
+
// Count exact occurrences (no regex).
|
|
343
|
+
let count = 0;
|
|
344
|
+
let idx = text.indexOf(find);
|
|
345
|
+
while (idx !== -1) {
|
|
346
|
+
count++;
|
|
347
|
+
idx = text.indexOf(find, idx + find.length);
|
|
348
|
+
}
|
|
349
|
+
if (count !== expected) {
|
|
350
|
+
return NextResponse.json(
|
|
351
|
+
{
|
|
352
|
+
error: "MATCH_COUNT_MISMATCH",
|
|
353
|
+
message: `Expected ${expected} occurrence(s) of find, found ${count}. Re-read the file or adjust expectedOccurrences.`,
|
|
354
|
+
found: count,
|
|
355
|
+
expected,
|
|
356
|
+
},
|
|
357
|
+
{ status: 422 },
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
// Replace all (count == expected) occurrences, literally.
|
|
361
|
+
return Buffer.from(text.split(find).join(replace), "utf-8");
|
|
362
|
+
},
|
|
271
363
|
});
|
|
272
364
|
}
|
|
273
365
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import JSZip from "jszip";
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
import { requireUser } from "@/lib/auth/server";
|
|
8
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
9
|
+
|
|
10
|
+
// Skip noise that should never end up in a downloaded archive.
|
|
11
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".proof"]);
|
|
12
|
+
|
|
13
|
+
function contentDisposition(filename: string): string {
|
|
14
|
+
// RFC 5987: ASCII fallback + UTF-8 encoded form for non-ASCII names.
|
|
15
|
+
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "'");
|
|
16
|
+
const encoded = encodeURIComponent(filename);
|
|
17
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function addDirToZip(
|
|
21
|
+
zip: JSZip,
|
|
22
|
+
absDir: string,
|
|
23
|
+
zipPrefix: string,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const names = await readdir(absDir);
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
28
|
+
const abs = path.join(absDir, name);
|
|
29
|
+
const info = await stat(abs);
|
|
30
|
+
const zipPath = zipPrefix ? `${zipPrefix}/${name}` : name;
|
|
31
|
+
if (info.isDirectory()) {
|
|
32
|
+
await addDirToZip(zip, abs, zipPath);
|
|
33
|
+
} else if (info.isFile()) {
|
|
34
|
+
zip.file(zipPath, await readFile(abs));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function GET(request: Request) {
|
|
40
|
+
const auth = await requireUser(request);
|
|
41
|
+
if (!auth.ok)
|
|
42
|
+
return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
43
|
+
|
|
44
|
+
const { searchParams } = new URL(request.url);
|
|
45
|
+
const rel = searchParams.get("path") ?? "";
|
|
46
|
+
const target = safeRootPath(rel);
|
|
47
|
+
if (!target)
|
|
48
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
49
|
+
|
|
50
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
51
|
+
try {
|
|
52
|
+
info = await stat(target);
|
|
53
|
+
} catch {
|
|
54
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const baseName = path.basename(target) || "download";
|
|
58
|
+
|
|
59
|
+
if (info.isFile()) {
|
|
60
|
+
const webStream = Readable.toWeb(
|
|
61
|
+
createReadStream(target),
|
|
62
|
+
) as ReadableStream;
|
|
63
|
+
return new Response(webStream, {
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/octet-stream",
|
|
66
|
+
"Content-Length": String(info.size),
|
|
67
|
+
"Content-Disposition": contentDisposition(baseName),
|
|
68
|
+
"Cache-Control": "private, no-store",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (info.isDirectory()) {
|
|
74
|
+
const zip = new JSZip();
|
|
75
|
+
try {
|
|
76
|
+
await addDirToZip(zip, target, "");
|
|
77
|
+
} catch {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ error: "Failed to read folder" },
|
|
80
|
+
{ status: 500 },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const buffer = await zip.generateAsync({
|
|
84
|
+
type: "nodebuffer",
|
|
85
|
+
compression: "DEFLATE",
|
|
86
|
+
compressionOptions: { level: 6 },
|
|
87
|
+
});
|
|
88
|
+
return new Response(new Uint8Array(buffer), {
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/zip",
|
|
91
|
+
"Content-Length": String(buffer.length),
|
|
92
|
+
"Content-Disposition": contentDisposition(`${baseName}.zip`),
|
|
93
|
+
"Cache-Control": "private, no-store",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return NextResponse.json({ error: "Unsupported path" }, { status: 400 });
|
|
99
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Check,
|
|
7
7
|
ChevronDown,
|
|
8
8
|
ChevronRight,
|
|
9
|
+
Download,
|
|
9
10
|
File,
|
|
10
11
|
FilePlus,
|
|
11
12
|
FileText,
|
|
@@ -272,6 +273,7 @@ export default function Page() {
|
|
|
272
273
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
273
274
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
274
275
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
276
|
+
const [fileRevision, setFileRevision] = useState(0);
|
|
275
277
|
const [fileLoading, setFileLoading] = useState(false);
|
|
276
278
|
const [editing, setEditing] = useState(false);
|
|
277
279
|
const [editContent, setEditContent] = useState("");
|
|
@@ -411,6 +413,7 @@ export default function Page() {
|
|
|
411
413
|
if (res.ok) {
|
|
412
414
|
const d: { content: string } = await res.json();
|
|
413
415
|
setFileContent(d.content);
|
|
416
|
+
setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
|
|
414
417
|
}
|
|
415
418
|
} catch {
|
|
416
419
|
/* ignore */
|
|
@@ -536,6 +539,7 @@ export default function Page() {
|
|
|
536
539
|
setEditing(false);
|
|
537
540
|
setSaveError(null);
|
|
538
541
|
setFileContent(null);
|
|
542
|
+
setFileRevision(0);
|
|
539
543
|
const kind = viewerKindFor(node.name, node.type);
|
|
540
544
|
if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
|
|
541
545
|
setFileLoading(true);
|
|
@@ -546,6 +550,7 @@ export default function Page() {
|
|
|
546
550
|
if (res.ok) {
|
|
547
551
|
const d: { content: string } = await res.json();
|
|
548
552
|
setFileContent(d.content);
|
|
553
|
+
setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
|
|
549
554
|
}
|
|
550
555
|
} catch {
|
|
551
556
|
/* ignore */
|
|
@@ -624,9 +629,15 @@ export default function Page() {
|
|
|
624
629
|
const res = await fetch("/api/wiki/content", {
|
|
625
630
|
method: "PUT",
|
|
626
631
|
headers: { "Content-Type": "application/json" },
|
|
627
|
-
body: JSON.stringify({
|
|
632
|
+
body: JSON.stringify({
|
|
633
|
+
path: openFile.path,
|
|
634
|
+
content: editContent,
|
|
635
|
+
baseRevision: fileRevision,
|
|
636
|
+
}),
|
|
628
637
|
});
|
|
629
638
|
if (res.ok) {
|
|
639
|
+
const d: { revision?: number } = await res.json();
|
|
640
|
+
if (typeof d.revision === "number") setFileRevision(d.revision);
|
|
630
641
|
setFileContent(editContent);
|
|
631
642
|
setEditing(false);
|
|
632
643
|
} else {
|
|
@@ -732,6 +743,16 @@ export default function Page() {
|
|
|
732
743
|
}
|
|
733
744
|
}
|
|
734
745
|
|
|
746
|
+
function handleDownload(node: TreeNode) {
|
|
747
|
+
const url = `/api/wiki/download?path=${encodeURIComponent(node.path)}`;
|
|
748
|
+
const a = document.createElement("a");
|
|
749
|
+
a.href = url;
|
|
750
|
+
a.download = node.type === "file" ? node.name : `${node.name}.zip`;
|
|
751
|
+
document.body.appendChild(a);
|
|
752
|
+
a.click();
|
|
753
|
+
a.remove();
|
|
754
|
+
}
|
|
755
|
+
|
|
735
756
|
async function handleDelete() {
|
|
736
757
|
if (!deletingPath) return;
|
|
737
758
|
await fetch("/api/wiki", {
|
|
@@ -946,6 +967,15 @@ export default function Page() {
|
|
|
946
967
|
</Button>
|
|
947
968
|
</>
|
|
948
969
|
)}
|
|
970
|
+
<Button
|
|
971
|
+
size="sm"
|
|
972
|
+
variant="ghost"
|
|
973
|
+
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
974
|
+
title={node.type === "file" ? "Download" : "Download as zip"}
|
|
975
|
+
onClick={() => handleDownload(node)}
|
|
976
|
+
>
|
|
977
|
+
<Download className="h-3 w-3" />
|
|
978
|
+
</Button>
|
|
949
979
|
<Button
|
|
950
980
|
size="sm"
|
|
951
981
|
variant="ghost"
|