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.
- package/LICENSE +21 -0
- package/README.md +48 -4
- package/bin/pi-forge.mjs +37 -0
- package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
- package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
- package/dist/client/assets/index-B-529kgJ.css +32 -0
- package/dist/client/assets/index-BzKzxXFs.js +392 -0
- package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
- package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
- package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
- package/dist/client/icons/icon-192.png +0 -0
- package/dist/client/icons/icon-512.png +0 -0
- package/dist/client/icons/icon-maskable-512.png +0 -0
- package/dist/client/icons/icon.svg +9 -0
- package/dist/client/index.html +24 -0
- package/dist/client/manifest.webmanifest +1 -0
- package/dist/client/offline.html +142 -0
- package/dist/client/sw.js +3 -0
- package/dist/client/sw.js.map +1 -0
- package/dist/client/workbox-6d7155ed.js +3 -0
- package/dist/client/workbox-6d7155ed.js.map +1 -0
- package/dist/server/agent-resource-loader.js +126 -0
- package/dist/server/agent-resource-loader.js.map +1 -0
- package/dist/server/attachment-converters.js +96 -0
- package/dist/server/attachment-converters.js.map +1 -0
- package/dist/server/auth.js +209 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/compaction-history.js +106 -0
- package/dist/server/compaction-history.js.map +1 -0
- package/dist/server/concurrency.js +49 -0
- package/dist/server/concurrency.js.map +1 -0
- package/dist/server/config-export.js +220 -0
- package/dist/server/config-export.js.map +1 -0
- package/dist/server/config-manager.js +528 -0
- package/dist/server/config-manager.js.map +1 -0
- package/dist/server/config.js +326 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/conversion-worker.mjs +90 -0
- package/dist/server/diagnostics.js +137 -0
- package/dist/server/diagnostics.js.map +1 -0
- package/dist/server/extensions-discovery.js +147 -0
- package/dist/server/extensions-discovery.js.map +1 -0
- package/dist/server/file-manager.js +734 -0
- package/dist/server/file-manager.js.map +1 -0
- package/dist/server/file-references.js +215 -0
- package/dist/server/file-references.js.map +1 -0
- package/dist/server/file-searcher.js +385 -0
- package/dist/server/file-searcher.js.map +1 -0
- package/dist/server/git-runner.js +684 -0
- package/dist/server/git-runner.js.map +1 -0
- package/dist/server/index.js +468 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mcp/config.js +133 -0
- package/dist/server/mcp/config.js.map +1 -0
- package/dist/server/mcp/manager.js +351 -0
- package/dist/server/mcp/manager.js.map +1 -0
- package/dist/server/mcp/tool-bridge.js +173 -0
- package/dist/server/mcp/tool-bridge.js.map +1 -0
- package/dist/server/project-manager.js +301 -0
- package/dist/server/project-manager.js.map +1 -0
- package/dist/server/pty-manager.js +354 -0
- package/dist/server/pty-manager.js.map +1 -0
- package/dist/server/routes/_schemas.js +73 -0
- package/dist/server/routes/_schemas.js.map +1 -0
- package/dist/server/routes/auth.js +164 -0
- package/dist/server/routes/auth.js.map +1 -0
- package/dist/server/routes/config.js +1163 -0
- package/dist/server/routes/config.js.map +1 -0
- package/dist/server/routes/control.js +464 -0
- package/dist/server/routes/control.js.map +1 -0
- package/dist/server/routes/exec.js +217 -0
- package/dist/server/routes/exec.js.map +1 -0
- package/dist/server/routes/files.js +847 -0
- package/dist/server/routes/files.js.map +1 -0
- package/dist/server/routes/git.js +837 -0
- package/dist/server/routes/git.js.map +1 -0
- package/dist/server/routes/health.js +97 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/mcp.js +300 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/projects.js +259 -0
- package/dist/server/routes/projects.js.map +1 -0
- package/dist/server/routes/prompt.js +496 -0
- package/dist/server/routes/prompt.js.map +1 -0
- package/dist/server/routes/sessions.js +783 -0
- package/dist/server/routes/sessions.js.map +1 -0
- package/dist/server/routes/stream.js +69 -0
- package/dist/server/routes/stream.js.map +1 -0
- package/dist/server/routes/terminal.js +335 -0
- package/dist/server/routes/terminal.js.map +1 -0
- package/dist/server/session-registry.js +1197 -0
- package/dist/server/session-registry.js.map +1 -0
- package/dist/server/skill-overrides.js +151 -0
- package/dist/server/skill-overrides.js.map +1 -0
- package/dist/server/skills-export.js +257 -0
- package/dist/server/skills-export.js.map +1 -0
- package/dist/server/sse-bridge.js +220 -0
- package/dist/server/sse-bridge.js.map +1 -0
- package/dist/server/tool-overrides.js +277 -0
- package/dist/server/tool-overrides.js.map +1 -0
- package/dist/server/turn-diff-builder.js +280 -0
- package/dist/server/turn-diff-builder.js.map +1 -0
- 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
|