pi-forge 0.0.0 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -4
  3. package/bin/pi-forge.mjs +37 -0
  4. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
  5. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
  6. package/dist/client/assets/index-B-529kgJ.css +32 -0
  7. package/dist/client/assets/index-BzKzxXFs.js +392 -0
  8. package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
  9. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
  10. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
  11. package/dist/client/icons/icon-192.png +0 -0
  12. package/dist/client/icons/icon-512.png +0 -0
  13. package/dist/client/icons/icon-maskable-512.png +0 -0
  14. package/dist/client/icons/icon.svg +9 -0
  15. package/dist/client/index.html +24 -0
  16. package/dist/client/manifest.webmanifest +1 -0
  17. package/dist/client/offline.html +142 -0
  18. package/dist/client/sw.js +3 -0
  19. package/dist/client/sw.js.map +1 -0
  20. package/dist/client/workbox-6d7155ed.js +3 -0
  21. package/dist/client/workbox-6d7155ed.js.map +1 -0
  22. package/dist/server/agent-resource-loader.js +126 -0
  23. package/dist/server/agent-resource-loader.js.map +1 -0
  24. package/dist/server/attachment-converters.js +96 -0
  25. package/dist/server/attachment-converters.js.map +1 -0
  26. package/dist/server/auth.js +209 -0
  27. package/dist/server/auth.js.map +1 -0
  28. package/dist/server/compaction-history.js +106 -0
  29. package/dist/server/compaction-history.js.map +1 -0
  30. package/dist/server/concurrency.js +49 -0
  31. package/dist/server/concurrency.js.map +1 -0
  32. package/dist/server/config-export.js +220 -0
  33. package/dist/server/config-export.js.map +1 -0
  34. package/dist/server/config-manager.js +528 -0
  35. package/dist/server/config-manager.js.map +1 -0
  36. package/dist/server/config.js +326 -0
  37. package/dist/server/config.js.map +1 -0
  38. package/dist/server/conversion-worker.mjs +90 -0
  39. package/dist/server/diagnostics.js +137 -0
  40. package/dist/server/diagnostics.js.map +1 -0
  41. package/dist/server/extensions-discovery.js +147 -0
  42. package/dist/server/extensions-discovery.js.map +1 -0
  43. package/dist/server/file-manager.js +734 -0
  44. package/dist/server/file-manager.js.map +1 -0
  45. package/dist/server/file-references.js +215 -0
  46. package/dist/server/file-references.js.map +1 -0
  47. package/dist/server/file-searcher.js +385 -0
  48. package/dist/server/file-searcher.js.map +1 -0
  49. package/dist/server/git-runner.js +684 -0
  50. package/dist/server/git-runner.js.map +1 -0
  51. package/dist/server/index.js +468 -0
  52. package/dist/server/index.js.map +1 -0
  53. package/dist/server/mcp/config.js +133 -0
  54. package/dist/server/mcp/config.js.map +1 -0
  55. package/dist/server/mcp/manager.js +351 -0
  56. package/dist/server/mcp/manager.js.map +1 -0
  57. package/dist/server/mcp/tool-bridge.js +173 -0
  58. package/dist/server/mcp/tool-bridge.js.map +1 -0
  59. package/dist/server/project-manager.js +301 -0
  60. package/dist/server/project-manager.js.map +1 -0
  61. package/dist/server/pty-manager.js +354 -0
  62. package/dist/server/pty-manager.js.map +1 -0
  63. package/dist/server/routes/_schemas.js +73 -0
  64. package/dist/server/routes/_schemas.js.map +1 -0
  65. package/dist/server/routes/auth.js +164 -0
  66. package/dist/server/routes/auth.js.map +1 -0
  67. package/dist/server/routes/config.js +1163 -0
  68. package/dist/server/routes/config.js.map +1 -0
  69. package/dist/server/routes/control.js +464 -0
  70. package/dist/server/routes/control.js.map +1 -0
  71. package/dist/server/routes/exec.js +217 -0
  72. package/dist/server/routes/exec.js.map +1 -0
  73. package/dist/server/routes/files.js +847 -0
  74. package/dist/server/routes/files.js.map +1 -0
  75. package/dist/server/routes/git.js +837 -0
  76. package/dist/server/routes/git.js.map +1 -0
  77. package/dist/server/routes/health.js +97 -0
  78. package/dist/server/routes/health.js.map +1 -0
  79. package/dist/server/routes/mcp.js +300 -0
  80. package/dist/server/routes/mcp.js.map +1 -0
  81. package/dist/server/routes/projects.js +259 -0
  82. package/dist/server/routes/projects.js.map +1 -0
  83. package/dist/server/routes/prompt.js +496 -0
  84. package/dist/server/routes/prompt.js.map +1 -0
  85. package/dist/server/routes/sessions.js +783 -0
  86. package/dist/server/routes/sessions.js.map +1 -0
  87. package/dist/server/routes/stream.js +69 -0
  88. package/dist/server/routes/stream.js.map +1 -0
  89. package/dist/server/routes/terminal.js +335 -0
  90. package/dist/server/routes/terminal.js.map +1 -0
  91. package/dist/server/session-registry.js +1197 -0
  92. package/dist/server/session-registry.js.map +1 -0
  93. package/dist/server/skill-overrides.js +151 -0
  94. package/dist/server/skill-overrides.js.map +1 -0
  95. package/dist/server/skills-export.js +257 -0
  96. package/dist/server/skills-export.js.map +1 -0
  97. package/dist/server/sse-bridge.js +220 -0
  98. package/dist/server/sse-bridge.js.map +1 -0
  99. package/dist/server/tool-overrides.js +277 -0
  100. package/dist/server/tool-overrides.js.map +1 -0
  101. package/dist/server/turn-diff-builder.js +280 -0
  102. package/dist/server/turn-diff-builder.js.map +1 -0
  103. package/package.json +53 -12
@@ -0,0 +1,734 @@
1
+ import { mkdir, open as fsOpen, readFile as fsReadFile, readdir, realpath, rename as fsRename, rm, rmdir, stat, unlink, writeFile as fsWriteFile, } from "node:fs/promises";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { once } from "node:events";
4
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { create as tarCreate } from "tar";
7
+ /**
8
+ * Filesystem operations bounded by a per-call project root.
9
+ *
10
+ * The route layer passes a `rootPath` (the project's absolute path) to each
11
+ * function. Every public function resolves the input path and asserts it is
12
+ * inside that root before touching disk; otherwise it throws
13
+ * `PathOutsideRootError`. Routes catch that and return 403.
14
+ *
15
+ * This is the ONLY module that should call `fs.*` for filesystem
16
+ * operations rooted in a project. Route handlers must not import `node:fs`
17
+ * directly — every disk write/read goes through here so the path-traversal
18
+ * checks can't be skipped.
19
+ */
20
+ /* ----------------------------- errors ----------------------------- */
21
+ export class PathOutsideRootError extends Error {
22
+ constructor(target, root) {
23
+ super(`path outside project root: ${target} (root=${root})`);
24
+ this.name = "PathOutsideRootError";
25
+ }
26
+ }
27
+ export class NotFoundError extends Error {
28
+ constructor(path) {
29
+ super(`not found: ${path}`);
30
+ this.name = "NotFoundError";
31
+ }
32
+ }
33
+ export class NotAFileError extends Error {
34
+ constructor(path) {
35
+ super(`not a file: ${path}`);
36
+ this.name = "NotAFileError";
37
+ }
38
+ }
39
+ export class FileTooLargeError extends Error {
40
+ size;
41
+ limit;
42
+ constructor(path, size, limit) {
43
+ super(`file too large: ${path} (${size} > ${limit})`);
44
+ this.name = "FileTooLargeError";
45
+ this.size = size;
46
+ this.limit = limit;
47
+ }
48
+ }
49
+ export class DirectoryNotEmptyError extends Error {
50
+ constructor(path) {
51
+ super(`directory not empty: ${path}`);
52
+ this.name = "DirectoryNotEmptyError";
53
+ }
54
+ }
55
+ export class InvalidNameError extends Error {
56
+ constructor(message = "invalid file name") {
57
+ super(message);
58
+ this.name = "InvalidNameError";
59
+ }
60
+ }
61
+ export class ChecksumMismatchError extends Error {
62
+ target;
63
+ expected;
64
+ actual;
65
+ constructor(target, expected, actual) {
66
+ super(`checksum mismatch at ${target} (expected ${expected}, got ${actual})`);
67
+ this.name = "ChecksumMismatchError";
68
+ this.target = target;
69
+ this.expected = expected;
70
+ this.actual = actual;
71
+ }
72
+ }
73
+ export class TargetExistsError extends Error {
74
+ constructor(path) {
75
+ super(`target already exists: ${path}`);
76
+ this.name = "TargetExistsError";
77
+ }
78
+ }
79
+ /* ----------------------------- limits ----------------------------- */
80
+ /**
81
+ * Hard cap on a single read. The editor would not give a useful experience
82
+ * for anything larger, and the JSON encoding of a multi-MB file blows past
83
+ * Fastify's default body limit on the round-trip back. Mirrors the
84
+ * `CLAUDE.md` 5 MB ceiling.
85
+ */
86
+ export const MAX_READ_BYTES = 5 * 1024 * 1024;
87
+ /**
88
+ * Directory names skipped by `getTree`. Same set as pi's session-discovery
89
+ * + a few editor-specific ones. Hidden dotfiles below the root are NOT
90
+ * skipped (a `.env` should still appear), but `.git` itself is — the
91
+ * editor has no use for the object database, and walking it dwarfs every
92
+ * other dir.
93
+ */
94
+ const TREE_SKIP_DIRS = new Set([
95
+ "node_modules",
96
+ ".git",
97
+ "dist",
98
+ "build",
99
+ "__pycache__",
100
+ ".next",
101
+ ".nuxt",
102
+ "coverage",
103
+ ".vite",
104
+ ".turbo",
105
+ ".cache",
106
+ ]);
107
+ /**
108
+ * Re-export the directory exclusion list so file-searcher.ts can
109
+ * apply the same filter when ripgrep is unavailable. Keeping a
110
+ * single source of truth here avoids drift between the file-tree
111
+ * view and the in-process search results.
112
+ */
113
+ export const SEARCH_SKIP_DIRS = TREE_SKIP_DIRS;
114
+ const DEFAULT_TREE_DEPTH = 6;
115
+ /* ----------------------------- guards ----------------------------- */
116
+ /**
117
+ * Resolve `target` and assert it is inside `root` (or equal to it). Returns
118
+ * the resolved absolute path on success; throws PathOutsideRootError on a
119
+ * traversal attempt. Use this on every entry point — never trust route
120
+ * input. `relative()` returning a path that starts with `..` is the
121
+ * canonical post-resolution traversal signal.
122
+ *
123
+ * NOTE: this is a LEXICAL check only (`resolve()` doesn't follow
124
+ * symlinks). For ops that touch disk, prefer `resolveAndCheck` which
125
+ * additionally `realpath`s the target so a symlink-out-of-root can't
126
+ * sneak past.
127
+ *
128
+ * NUL bytes are rejected here too: `fs.*` APIs throw a non-Error.code
129
+ * shape ("string contains null bytes") for paths containing `\0`,
130
+ * which falls through `mapError` to a 500. We turn it into a 403
131
+ * `path_not_allowed` instead so the wire shape matches every other
132
+ * traversal attempt.
133
+ */
134
+ export function assertInsideRoot(target, root) {
135
+ if (target.includes("\0"))
136
+ throw new PathOutsideRootError(target, root);
137
+ const resolvedTarget = resolve(target);
138
+ const resolvedRoot = resolve(root);
139
+ if (resolvedTarget === resolvedRoot)
140
+ return resolvedTarget;
141
+ const rel = relative(resolvedRoot, resolvedTarget);
142
+ if (rel.length === 0 || rel.startsWith("..") || rel.startsWith(`..${sep}`)) {
143
+ throw new PathOutsideRootError(target, root);
144
+ }
145
+ return resolvedTarget;
146
+ }
147
+ /**
148
+ * Lexical-check + realpath-resolve `target`, ensuring the FINAL
149
+ * (symlink-followed) path is still inside `root`. This is what
150
+ * disk-touching ops should use — `assertInsideRoot` alone misses
151
+ * symlinks (a symlink inside the project pointing OUT escapes the
152
+ * lexical check).
153
+ *
154
+ * Handles both existing and not-yet-existing targets in one pass:
155
+ * walks UP from the target until it finds a path that exists,
156
+ * realpaths that ancestor, and verifies it's inside realpath(root).
157
+ * If any ancestor along the way is a symlink that escapes, we catch
158
+ * it. For non-existent leaf paths (creates), the caller still passes
159
+ * the lexical absolute path to the eventual `fs.*` call — the safety
160
+ * guarantee is on the parent chain, not the target itself.
161
+ *
162
+ * Returns the lexically-resolved absolute path on success, which is
163
+ * what the caller passes to fs ops.
164
+ *
165
+ * TOCTOU: between this check and the eventual `fs.*` call, an attacker
166
+ * could swap a real dir for a symlink. In a single-tenant model where
167
+ * attacker = user, this is acceptable; the SDK ships under the same
168
+ * threat model.
169
+ */
170
+ async function verifyPathSafe(target, root) {
171
+ // Lexical pre-check (cheap, fails fast — also handles NUL byte
172
+ // rejection so fs.* doesn't throw a non-Error.code shape that
173
+ // mapError would surface as a 500).
174
+ assertInsideRoot(target, root);
175
+ const realRoot = await realpath(root);
176
+ const lexicalTarget = resolve(target);
177
+ let cursor = lexicalTarget;
178
+ while (true) {
179
+ try {
180
+ const real = await realpath(cursor);
181
+ assertInsideRoot(real, realRoot);
182
+ return lexicalTarget;
183
+ }
184
+ catch (err) {
185
+ const code = err.code;
186
+ if (code !== "ENOENT")
187
+ throw err;
188
+ const parent = dirname(cursor);
189
+ if (parent === cursor) {
190
+ // Walked up to filesystem root and every ancestor ENOENT'd.
191
+ // Shouldn't happen in practice (root itself exists at startup
192
+ // — index.ts mkdir's it).
193
+ throw new PathOutsideRootError(target, root);
194
+ }
195
+ cursor = parent;
196
+ }
197
+ }
198
+ }
199
+ /**
200
+ * File-name validation for create / rename targets. Rejects empty strings,
201
+ * path separators (a "name" must be a single segment), and the `.` / `..`
202
+ * special entries. Trailing whitespace is stripped, but interior spaces and
203
+ * dots are allowed (e.g. ".env", "tsconfig.json", "my file.txt").
204
+ */
205
+ function validateName(name) {
206
+ const trimmed = name.trim();
207
+ if (trimmed.length === 0 ||
208
+ trimmed.includes("/") ||
209
+ trimmed.includes("\\") ||
210
+ trimmed.includes("\0") ||
211
+ trimmed === "." ||
212
+ trimmed === "..") {
213
+ throw new InvalidNameError();
214
+ }
215
+ return trimmed;
216
+ }
217
+ export async function getTree(rootPath, opts = {}) {
218
+ const root = resolve(rootPath);
219
+ // Verify root exists + is a directory; the caller already filtered by
220
+ // project, so this is a sanity check, not a security check.
221
+ const st = await stat(root).catch(() => undefined);
222
+ if (!st?.isDirectory()) {
223
+ throw new NotFoundError(root);
224
+ }
225
+ const maxDepth = opts.maxDepth ?? DEFAULT_TREE_DEPTH;
226
+ return walk(root, root, "", 0, maxDepth);
227
+ }
228
+ async function walk(dir, root, relPath, depth, maxDepth) {
229
+ const name = relPath === "" ? "" : (relPath.split(sep).pop() ?? "");
230
+ const node = {
231
+ name,
232
+ path: relPath,
233
+ type: "directory",
234
+ children: [],
235
+ };
236
+ if (depth >= maxDepth) {
237
+ node.truncated = true;
238
+ delete node.children;
239
+ return node;
240
+ }
241
+ let entries;
242
+ try {
243
+ entries = await readdir(dir, { withFileTypes: true });
244
+ }
245
+ catch {
246
+ // unreadable subtree — surface as truncated rather than throwing the
247
+ // whole tree request away. The route still gets a useful response.
248
+ node.truncated = true;
249
+ delete node.children;
250
+ return node;
251
+ }
252
+ // Sort: directories first, then files; within each, case-insensitive.
253
+ entries.sort((a, b) => {
254
+ const da = a.isDirectory() ? 0 : 1;
255
+ const db = b.isDirectory() ? 0 : 1;
256
+ if (da !== db)
257
+ return da - db;
258
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
259
+ });
260
+ for (const ent of entries) {
261
+ if (ent.isDirectory() && TREE_SKIP_DIRS.has(ent.name))
262
+ continue;
263
+ const childRel = relPath === "" ? ent.name : `${relPath}${sep}${ent.name}`;
264
+ const childAbs = join(dir, ent.name);
265
+ if (ent.isDirectory()) {
266
+ const sub = await walk(childAbs, root, childRel, depth + 1, maxDepth);
267
+ node.children?.push(sub);
268
+ }
269
+ else if (ent.isFile()) {
270
+ node.children?.push({
271
+ name: ent.name,
272
+ path: childRel,
273
+ type: "file",
274
+ });
275
+ }
276
+ // Symlinks, sockets, fifos: skip silently. We don't follow symlinks —
277
+ // that's how a malicious project would escape its own root via a
278
+ // symlink to /etc.
279
+ }
280
+ return node;
281
+ }
282
+ /**
283
+ * Flat list of every file under `root` (recursive) used by the
284
+ * chat-input `@` autocomplete. Skips the same directories `getTree`
285
+ * does. Returns POSIX-style paths RELATIVE to `root` so a single
286
+ * project's listing transports / sorts predictably; the caller joins
287
+ * back to `root` when actually reading a file.
288
+ *
289
+ * No max-depth — `@` completion is meant to find anything in the
290
+ * tree. The skip-list keeps `node_modules` etc. out, so the walk
291
+ * terminates in a reasonable time on real projects. For a 50k-file
292
+ * monorepo this is probably tens of milliseconds; cache at the
293
+ * caller (or per-project) if it shows up in profiles.
294
+ */
295
+ export async function listAllFiles(rootPath) {
296
+ const root = resolve(rootPath);
297
+ const st = await stat(root).catch(() => undefined);
298
+ if (!st?.isDirectory())
299
+ throw new NotFoundError(root);
300
+ const out = [];
301
+ await walkFlat(root, root, "", out);
302
+ return out;
303
+ }
304
+ async function walkFlat(dir, root, relPath, out) {
305
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
306
+ for (const entry of entries) {
307
+ const name = entry.name;
308
+ if (entry.isDirectory()) {
309
+ if (TREE_SKIP_DIRS.has(name))
310
+ continue;
311
+ await walkFlat(join(dir, name), root, relPath === "" ? name : `${relPath}/${name}`, out);
312
+ }
313
+ else if (entry.isFile()) {
314
+ out.push(relPath === "" ? name : `${relPath}/${name}`);
315
+ }
316
+ // Symlinks are intentionally skipped — file-manager's read path
317
+ // realpaths to defeat sym-out-of-root, but the listing surface
318
+ // doesn't need to invite the failure mode.
319
+ }
320
+ }
321
+ export async function readFile(absPath, root) {
322
+ const resolved = await verifyPathSafe(absPath, root);
323
+ const st = await stat(resolved).catch(() => undefined);
324
+ if (st === undefined)
325
+ throw new NotFoundError(resolved);
326
+ if (!st.isFile())
327
+ throw new NotAFileError(resolved);
328
+ if (st.size > MAX_READ_BYTES)
329
+ throw new FileTooLargeError(resolved, st.size, MAX_READ_BYTES);
330
+ const buf = await fsReadFile(resolved);
331
+ const binary = looksBinary(buf);
332
+ return {
333
+ path: resolved,
334
+ content: binary ? "" : buf.toString("utf8"),
335
+ size: st.size,
336
+ language: detectLanguage(resolved),
337
+ binary,
338
+ };
339
+ }
340
+ /**
341
+ * NUL-byte heuristic for binary detection — same approach git uses. Avoids
342
+ * trying to UTF-8-decode (and corrupt) images, archives, and compiled
343
+ * binaries that the editor can't render anyway.
344
+ */
345
+ function looksBinary(buf) {
346
+ const limit = Math.min(buf.length, 8000);
347
+ for (let i = 0; i < limit; i++) {
348
+ if (buf[i] === 0)
349
+ return true;
350
+ }
351
+ return false;
352
+ }
353
+ export async function checkFileReference(absPath, root) {
354
+ const resolved = await verifyPathSafe(absPath, root);
355
+ const st = await stat(resolved).catch(() => undefined);
356
+ if (st === undefined)
357
+ throw new NotFoundError(resolved);
358
+ if (!st.isFile())
359
+ throw new NotAFileError(resolved);
360
+ // Real partial read — open the file and read just the first 8 KB
361
+ // for the NUL-byte heuristic. Avoids slurping the whole file just
362
+ // to peek at its prefix.
363
+ const fh = await fsOpen(resolved, "r");
364
+ try {
365
+ const buf = Buffer.alloc(8000);
366
+ const { bytesRead } = await fh.read(buf, 0, 8000, 0);
367
+ return {
368
+ path: resolved,
369
+ size: st.size,
370
+ binary: looksBinary(buf.subarray(0, bytesRead)),
371
+ };
372
+ }
373
+ finally {
374
+ await fh.close();
375
+ }
376
+ }
377
+ /* ----------------------------- write ----------------------------- */
378
+ export async function writeFile(absPath, root, content) {
379
+ const resolved = await verifyPathSafe(absPath, root);
380
+ // Recursively mkdir the parent so writes to a brand-new nested path
381
+ // succeed (`/foo/bar/baz.ts` works even if `/foo/bar` doesn't exist).
382
+ // Safe AFTER verifyPathSafe: the deepest existing ancestor was
383
+ // proven inside `root`, so any dirs we create are under it.
384
+ await mkdir(dirname(resolved), { recursive: true });
385
+ // Atomic-ish write. tmp + rename keeps a partially-written file from
386
+ // ever existing under the target name; same pattern config-manager and
387
+ // project-manager use.
388
+ const tmp = `${resolved}.${randomUUID()}.tmp`;
389
+ await fsWriteFile(tmp, content, "utf8");
390
+ await fsRename(tmp, resolved);
391
+ }
392
+ /**
393
+ * Open a download stream for `absPath`. For a regular file: a plain
394
+ * read stream + the size for the Content-Length header. For a
395
+ * directory: a streamed gzip-tar of the directory contents (filename
396
+ * is `<dir>.tar.gz`, no Content-Length because we're streaming).
397
+ *
398
+ * Skips the same noise dirs as the file tree (node_modules, .git,
399
+ * dist, build, etc.) so a "download project" doesn't ship hundreds
400
+ * of MB of generated artefacts.
401
+ */
402
+ export async function downloadStream(absPath, root) {
403
+ const resolved = await verifyPathSafe(absPath, root);
404
+ const st = await stat(resolved).catch(() => undefined);
405
+ if (st === undefined)
406
+ throw new NotFoundError(resolved);
407
+ if (st.isFile()) {
408
+ return {
409
+ kind: "file",
410
+ filename: basename(resolved),
411
+ size: st.size,
412
+ stream: createReadStream(resolved),
413
+ };
414
+ }
415
+ if (st.isDirectory()) {
416
+ const dirName = basename(resolved).length > 0 ? basename(resolved) : "project";
417
+ // tar's `cwd` is the parent — entries inside the archive are
418
+ // prefixed with `<dirName>/...` so unpacking creates a real
419
+ // top-level directory instead of dumping files into the user's
420
+ // Downloads folder.
421
+ const stream = tarCreate({
422
+ gzip: true,
423
+ cwd: dirname(resolved),
424
+ portable: true,
425
+ // Explicitly preserve symlinks AS symlinks rather than dereferencing
426
+ // them. The default in tar@7 is already false, but state it here
427
+ // so a future major bump or copy/paste can't silently flip the
428
+ // behavior — a project containing a symlink to /etc/passwd would
429
+ // otherwise silently archive that file's contents.
430
+ follow: false,
431
+ filter: (path) => {
432
+ for (const part of path.split(/[/\\]/)) {
433
+ if (TREE_SKIP_DIRS.has(part))
434
+ return false;
435
+ }
436
+ return true;
437
+ },
438
+ }, [dirName]);
439
+ return { kind: "directory", filename: `${dirName}.tar.gz`, stream };
440
+ }
441
+ throw new NotFoundError(resolved);
442
+ }
443
+ /**
444
+ * Stream `source` into `<parentAbsPath>/<name>`, computing SHA-256 as
445
+ * bytes flow. Atomic via tmp-file + rename. The temp file lives in the
446
+ * same directory as the target so the rename is on the same filesystem
447
+ * (cross-fs renames silently fall back to copy+unlink and break the
448
+ * "either old or new — never half" invariant we rely on elsewhere).
449
+ *
450
+ * `expectedSha256` (lowercase hex) is verified BEFORE the swap-in:
451
+ * mismatched uploads never become visible under the target name. The
452
+ * tmp file is unlinked on any error path so we don't leak debris into
453
+ * the project tree.
454
+ *
455
+ * `name` must be a basename (no path separators, no `..`); use the
456
+ * caller's separate `parentAbsPath` to land in nested directories.
457
+ */
458
+ export async function writeFileBytes(parentAbsPath, name, root, source, opts) {
459
+ const parent = await verifyPathSafe(parentAbsPath, root);
460
+ const trimmed = validateName(name);
461
+ const target = await verifyPathSafe(join(parent, trimmed), root);
462
+ const existing = await stat(target).catch(() => undefined);
463
+ if (existing !== undefined) {
464
+ if (opts?.overwrite !== true)
465
+ throw new TargetExistsError(target);
466
+ if (!existing.isFile())
467
+ throw new InvalidNameError("target is a directory");
468
+ }
469
+ await mkdir(parent, { recursive: true });
470
+ const tmp = `${target}.${randomUUID()}.upload.tmp`;
471
+ const hash = createHash("sha256");
472
+ let size = 0;
473
+ const out = createWriteStream(tmp);
474
+ try {
475
+ for await (const chunk of source) {
476
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
477
+ hash.update(buf);
478
+ size += buf.byteLength;
479
+ if (!out.write(buf))
480
+ await once(out, "drain");
481
+ }
482
+ out.end();
483
+ await once(out, "close");
484
+ }
485
+ catch (err) {
486
+ out.destroy();
487
+ await unlink(tmp).catch(() => undefined);
488
+ throw err;
489
+ }
490
+ const actual = hash.digest("hex");
491
+ const expected = opts?.expectedSha256?.toLowerCase();
492
+ if (expected !== undefined && expected !== actual) {
493
+ await unlink(tmp).catch(() => undefined);
494
+ throw new ChecksumMismatchError(target, expected, actual);
495
+ }
496
+ await fsRename(tmp, target);
497
+ return { path: target, size, sha256: actual };
498
+ }
499
+ /* ----------------------------- mkdir ----------------------------- */
500
+ export async function makeDirectory(parentAbsPath, root, name) {
501
+ const trimmed = validateName(name);
502
+ const parent = await verifyPathSafe(parentAbsPath, root);
503
+ const target = await verifyPathSafe(join(parent, trimmed), root);
504
+ // recursive:false — surface "already exists" as a real conflict so the
505
+ // UI can prompt the user instead of silently no-op'ing.
506
+ const exists = await stat(target).catch(() => undefined);
507
+ if (exists !== undefined)
508
+ throw new TargetExistsError(target);
509
+ await mkdir(target, { recursive: false });
510
+ return target;
511
+ }
512
+ /* ----------------------------- rename / move ----------------------------- */
513
+ export async function renameEntry(absPath, root, newName) {
514
+ const resolved = await verifyPathSafe(absPath, root);
515
+ const trimmed = validateName(newName);
516
+ const target = await verifyPathSafe(join(dirname(resolved), trimmed), root);
517
+ const st = await stat(resolved).catch(() => undefined);
518
+ if (st === undefined)
519
+ throw new NotFoundError(resolved);
520
+ if (resolved === target)
521
+ return target;
522
+ // Case-only rename on a case-insensitive filesystem (macOS HFS+/APFS,
523
+ // Windows NTFS in default mode): `Foo.ts` → `foo.ts` resolves to the
524
+ // SAME inode, so a stat on the target still finds the source file
525
+ // and we'd 409 with "target_exists" even though the user is just
526
+ // rewriting the casing of their own file. Detect this — same path,
527
+ // different case — and route through a tmp-name two-step rename.
528
+ //
529
+ // The tmp name uses `crypto.randomUUID()` for collision resistance
530
+ // against another process racing to create the same path. There IS
531
+ // a TOCTOU window between our rename(resolved → tmp) and rename(
532
+ // tmp → target) where another process could create `target`; POSIX
533
+ // rename atomically replaces it. Single-tenant by design so the
534
+ // attacker = user, but we still stat the target right before the
535
+ // second rename and bail with TargetExistsError if a squatter
536
+ // appeared.
537
+ if (resolved.toLowerCase() === target.toLowerCase()) {
538
+ const tmp = `${resolved}.casefix-${randomUUID()}`;
539
+ await fsRename(resolved, tmp);
540
+ try {
541
+ // Recheck the target now that source is at `tmp` — on a
542
+ // case-insensitive FS the original `stat(target)` above would
543
+ // have hit the same inode as source, so this is the first
544
+ // honest "is target empty?" check.
545
+ const squatter = await stat(target).catch(() => undefined);
546
+ if (squatter !== undefined)
547
+ throw new TargetExistsError(target);
548
+ await fsRename(tmp, target);
549
+ }
550
+ catch (err) {
551
+ // Best-effort rollback: if the second rename fails (or the
552
+ // squatter check trips), put the file back under its original
553
+ // name. If THAT fails too, surface the original error — the
554
+ // file is at `tmp` and the user can recover via the file
555
+ // browser.
556
+ await fsRename(tmp, resolved).catch(() => undefined);
557
+ throw err;
558
+ }
559
+ return target;
560
+ }
561
+ const exists = await stat(target).catch(() => undefined);
562
+ if (exists !== undefined)
563
+ throw new TargetExistsError(target);
564
+ await fsRename(resolved, target);
565
+ return target;
566
+ }
567
+ export async function moveEntry(srcAbsPath, destAbsPath, root) {
568
+ const src = await verifyPathSafe(srcAbsPath, root);
569
+ const dest = await verifyPathSafe(destAbsPath, root);
570
+ const st = await stat(src).catch(() => undefined);
571
+ if (st === undefined)
572
+ throw new NotFoundError(src);
573
+ // Forbid moving a directory under itself — a classic foot-gun.
574
+ if (st.isDirectory()) {
575
+ const rel = relative(src, dest);
576
+ if (rel === "" || (!rel.startsWith("..") && !rel.startsWith(`..${sep}`))) {
577
+ throw new InvalidNameError("cannot move a directory into itself");
578
+ }
579
+ }
580
+ const exists = await stat(dest).catch(() => undefined);
581
+ if (exists !== undefined)
582
+ throw new TargetExistsError(dest);
583
+ await mkdir(dirname(dest), { recursive: true });
584
+ await fsRename(src, dest);
585
+ return dest;
586
+ }
587
+ /* ----------------------------- delete ----------------------------- */
588
+ export async function deleteEntry(absPath, root, opts) {
589
+ const resolved = await verifyPathSafe(absPath, root);
590
+ // Defense in depth: never let a delete reach the project root itself
591
+ // even if it slips past assertInsideRoot's "equal-to-root" allowance.
592
+ if (resolved === resolve(root)) {
593
+ throw new PathOutsideRootError(absPath, root);
594
+ }
595
+ const st = await stat(resolved).catch(() => undefined);
596
+ if (st === undefined)
597
+ throw new NotFoundError(resolved);
598
+ if (st.isDirectory()) {
599
+ // Empty dirs are always safe to remove. Non-empty dirs require an
600
+ // explicit `recursive: true` from the caller — the route plumbs
601
+ // this from a `?recursive=true` query param which the UI sets only
602
+ // after a second confirmation prompt. Without the flag, a non-
603
+ // empty dir surfaces DirectoryNotEmptyError so the UI can prompt.
604
+ if (opts?.recursive === true) {
605
+ await rm(resolved, { recursive: true, force: false });
606
+ return;
607
+ }
608
+ try {
609
+ await rmdir(resolved);
610
+ }
611
+ catch (err) {
612
+ if (err.code === "ENOTEMPTY") {
613
+ throw new DirectoryNotEmptyError(resolved);
614
+ }
615
+ throw err;
616
+ }
617
+ }
618
+ else {
619
+ await rm(resolved, { force: false });
620
+ }
621
+ }
622
+ /* ----------------------------- language detection ----------------------------- */
623
+ const LANG_BY_EXT = {
624
+ ".ts": "typescript",
625
+ ".tsx": "typescript",
626
+ ".js": "javascript",
627
+ ".jsx": "javascript",
628
+ ".mjs": "javascript",
629
+ ".cjs": "javascript",
630
+ ".py": "python",
631
+ ".pyi": "python",
632
+ ".rs": "rust",
633
+ ".cpp": "cpp",
634
+ ".cc": "cpp",
635
+ ".cxx": "cpp",
636
+ ".c": "c",
637
+ ".h": "c",
638
+ ".hpp": "cpp",
639
+ ".java": "java",
640
+ ".kt": "kotlin",
641
+ ".kts": "kotlin",
642
+ ".go": "go",
643
+ ".rb": "ruby",
644
+ ".php": "php",
645
+ ".cs": "csharp",
646
+ ".swift": "swift",
647
+ ".css": "css",
648
+ ".scss": "scss",
649
+ ".sass": "scss",
650
+ ".less": "css",
651
+ ".html": "html",
652
+ ".htm": "html",
653
+ ".xml": "xml",
654
+ ".svg": "xml",
655
+ ".plist": "xml",
656
+ ".json": "json",
657
+ ".jsonc": "json",
658
+ ".yaml": "yaml",
659
+ ".yml": "yaml",
660
+ ".toml": "toml",
661
+ ".md": "markdown",
662
+ ".markdown": "markdown",
663
+ ".sh": "shell",
664
+ ".bash": "shell",
665
+ ".zsh": "shell",
666
+ ".fish": "shell",
667
+ ".sql": "sql",
668
+ ".dockerfile": "dockerfile",
669
+ // Templating
670
+ ".jinja": "jinja2",
671
+ ".jinja2": "jinja2",
672
+ ".j2": "jinja2",
673
+ // Config / properties
674
+ ".env": "properties",
675
+ ".ini": "properties",
676
+ ".cfg": "properties",
677
+ ".conf": "properties",
678
+ ".properties": "properties",
679
+ ".toml.lock": "toml",
680
+ // Scripting / data
681
+ ".lua": "lua",
682
+ ".pl": "perl",
683
+ ".pm": "perl",
684
+ ".r": "r",
685
+ ".ps1": "powershell",
686
+ ".psm1": "powershell",
687
+ // Diff / patch
688
+ ".diff": "diff",
689
+ ".patch": "diff",
690
+ // JVM / functional
691
+ ".clj": "clojure",
692
+ ".cljs": "clojure",
693
+ ".cljc": "clojure",
694
+ ".edn": "clojure",
695
+ ".scala": "scala",
696
+ ".sc": "scala",
697
+ ".groovy": "groovy",
698
+ ".gradle": "groovy",
699
+ ".hs": "haskell",
700
+ ".ml": "ocaml",
701
+ ".mli": "ocaml",
702
+ // Schema / IDL
703
+ ".graphql": "graphql",
704
+ ".gql": "graphql",
705
+ ".proto": "protobuf",
706
+ // Build
707
+ ".cmake": "cmake",
708
+ ".mk": "makefile",
709
+ };
710
+ function detectLanguage(absPath) {
711
+ const base = absPath.split(sep).pop() ?? absPath;
712
+ // Basename-first checks: dotfiles and conventionally-named files
713
+ // don't carry a useful extension, so map them by exact name.
714
+ if (base === "Dockerfile" || base.endsWith(".Dockerfile"))
715
+ return "dockerfile";
716
+ if (base === "Makefile" || base === "makefile" || base === "GNUmakefile")
717
+ return "makefile";
718
+ if (base === "nginx.conf")
719
+ return "nginx";
720
+ if (base === ".env" || base.startsWith(".env."))
721
+ return "properties";
722
+ if (base === ".gitignore" ||
723
+ base === ".dockerignore" ||
724
+ base === ".npmignore" ||
725
+ base === ".prettierignore" ||
726
+ base === ".eslintignore") {
727
+ return "properties";
728
+ }
729
+ if (base === "CMakeLists.txt")
730
+ return "cmake";
731
+ const ext = extname(base).toLowerCase();
732
+ return LANG_BY_EXT[ext] ?? "plaintext";
733
+ }
734
+ //# sourceMappingURL=file-manager.js.map