@chrysb/alphaclaw 0.3.4 → 0.3.5-beta.1

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 (55) hide show
  1. package/bin/alphaclaw.js +82 -3
  2. package/lib/public/css/explorer.css +385 -9
  3. package/lib/public/css/theme.css +1 -1
  4. package/lib/public/js/app.js +102 -8
  5. package/lib/public/js/components/channels.js +1 -0
  6. package/lib/public/js/components/file-tree.js +74 -38
  7. package/lib/public/js/components/file-viewer/constants.js +6 -0
  8. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  9. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  10. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  11. package/lib/public/js/components/file-viewer/index.js +164 -0
  12. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  13. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  14. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  15. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  16. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  17. package/lib/public/js/components/file-viewer/storage.js +58 -0
  18. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  19. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  20. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  21. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  24. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  25. package/lib/public/js/components/file-viewer/utils.js +11 -0
  26. package/lib/public/js/components/gateway.js +95 -48
  27. package/lib/public/js/components/icons.js +26 -0
  28. package/lib/public/js/components/sidebar-git-panel.js +219 -31
  29. package/lib/public/js/components/sidebar.js +1 -1
  30. package/lib/public/js/components/usage-tab.js +4 -1
  31. package/lib/public/js/components/watchdog-tab.js +6 -2
  32. package/lib/public/js/lib/api.js +31 -0
  33. package/lib/public/js/lib/browse-file-policies.js +34 -0
  34. package/lib/scripts/git +40 -0
  35. package/lib/scripts/git-askpass +6 -0
  36. package/lib/server/constants.js +8 -0
  37. package/lib/server/helpers.js +18 -5
  38. package/lib/server/internal-files-migration.js +93 -0
  39. package/lib/server/onboarding/cron.js +6 -4
  40. package/lib/server/onboarding/index.js +7 -0
  41. package/lib/server/onboarding/openclaw.js +6 -1
  42. package/lib/server/routes/browse/constants.js +51 -0
  43. package/lib/server/routes/browse/file-helpers.js +43 -0
  44. package/lib/server/routes/browse/git.js +131 -0
  45. package/lib/server/routes/browse/index.js +572 -0
  46. package/lib/server/routes/browse/path-utils.js +53 -0
  47. package/lib/server/routes/browse/sqlite.js +140 -0
  48. package/lib/server/routes/pairings.js +8 -2
  49. package/lib/server/routes/proxy.js +11 -5
  50. package/lib/server/routes/system.js +5 -1
  51. package/lib/server.js +7 -0
  52. package/lib/setup/core-prompts/TOOLS.md +0 -4
  53. package/package.json +1 -1
  54. package/lib/public/js/components/file-viewer.js +0 -864
  55. package/lib/server/routes/browse.js +0 -295
@@ -0,0 +1,572 @@
1
+ const path = require("path");
2
+ const { kLockedBrowsePaths } = require("../../constants");
3
+ const {
4
+ kDefaultTreeDepth,
5
+ kIgnoredDirectoryNames,
6
+ kCommitHistoryLimit,
7
+ } = require("./constants");
8
+ const {
9
+ normalizePolicyPath,
10
+ resolveSafePath,
11
+ toRelativePath,
12
+ matchesPolicyPath,
13
+ } = require("./path-utils");
14
+ const {
15
+ isLikelyBinaryFile,
16
+ getImageMimeType,
17
+ getAudioMimeType,
18
+ isSqliteFilePath,
19
+ } = require("./file-helpers");
20
+ const { readSqliteSummary, readSqliteTableData } = require("./sqlite");
21
+ const {
22
+ runGitCommand,
23
+ runGitCommandWithExitCode,
24
+ parseGithubRepoSlug,
25
+ normalizeChangedPath,
26
+ parseBranchTracking,
27
+ } = require("./git");
28
+
29
+ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
30
+ const kRootResolved = path.resolve(kRootDir);
31
+ const kRootWithSep = `${kRootResolved}${path.sep}`;
32
+ const kRootDisplayName = "kRootDir/.openclaw";
33
+
34
+ if (!fs.existsSync(kRootResolved)) {
35
+ fs.mkdirSync(kRootResolved, { recursive: true });
36
+ }
37
+
38
+ const buildTreeNode = (absolutePath, depthRemaining) => {
39
+ const stats = fs.statSync(absolutePath);
40
+ const nodeName = path.basename(absolutePath);
41
+ const nodePath = toRelativePath(absolutePath, kRootResolved);
42
+
43
+ if (!stats.isDirectory()) {
44
+ return { type: "file", name: nodeName, path: nodePath };
45
+ }
46
+
47
+ if (depthRemaining <= 0) {
48
+ return { type: "folder", name: nodeName, path: nodePath, children: [] };
49
+ }
50
+
51
+ const children = fs
52
+ .readdirSync(absolutePath, { withFileTypes: true })
53
+ .filter((entry) => {
54
+ if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) {
55
+ return false;
56
+ }
57
+ return entry.isDirectory() || entry.isFile();
58
+ })
59
+ .map((entry) =>
60
+ buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1),
61
+ )
62
+ .sort((leftNode, rightNode) => {
63
+ if (leftNode.type !== rightNode.type) {
64
+ return leftNode.type === "folder" ? -1 : 1;
65
+ }
66
+ return leftNode.name.localeCompare(rightNode.name);
67
+ });
68
+
69
+ return { type: "folder", name: nodeName, path: nodePath, children };
70
+ };
71
+
72
+ app.get("/api/browse/tree", (req, res) => {
73
+ const depthValue = Number.parseInt(String(req.query.depth || ""), 10);
74
+ const depth =
75
+ Number.isFinite(depthValue) && depthValue > 0
76
+ ? depthValue
77
+ : kDefaultTreeDepth;
78
+ try {
79
+ const tree = buildTreeNode(kRootResolved, depth);
80
+ return res.json({ ok: true, root: tree });
81
+ } catch (error) {
82
+ return res.status(500).json({
83
+ ok: false,
84
+ error: error.message || "Could not build file tree",
85
+ });
86
+ }
87
+ });
88
+
89
+ app.get("/api/browse/read", (req, res) => {
90
+ const resolvedPath = resolveSafePath(
91
+ req.query.path,
92
+ kRootResolved,
93
+ kRootWithSep,
94
+ kRootDisplayName,
95
+ );
96
+ if (!resolvedPath.ok) {
97
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
98
+ }
99
+
100
+ try {
101
+ const stats = fs.statSync(resolvedPath.absolutePath);
102
+ if (!stats.isFile()) {
103
+ return res.status(400).json({ ok: false, error: "Path is not a file" });
104
+ }
105
+ if (isSqliteFilePath(resolvedPath.absolutePath)) {
106
+ const sqliteSummary = readSqliteSummary(resolvedPath.absolutePath);
107
+ return res.json({
108
+ ok: true,
109
+ path: resolvedPath.relativePath,
110
+ kind: "sqlite",
111
+ sqliteSummary,
112
+ content: "",
113
+ });
114
+ }
115
+ const audioMimeType = getAudioMimeType(resolvedPath.absolutePath);
116
+ if (audioMimeType) {
117
+ const audioBytes = fs.readFileSync(resolvedPath.absolutePath);
118
+ const audioDataUrl = `data:${audioMimeType};base64,${audioBytes.toString("base64")}`;
119
+ return res.json({
120
+ ok: true,
121
+ path: resolvedPath.relativePath,
122
+ kind: "audio",
123
+ mimeType: audioMimeType,
124
+ audioDataUrl,
125
+ content: "",
126
+ });
127
+ }
128
+ if (isLikelyBinaryFile(fs, resolvedPath.absolutePath)) {
129
+ const imageMimeType = getImageMimeType(resolvedPath.absolutePath);
130
+ if (!imageMimeType) {
131
+ return res
132
+ .status(400)
133
+ .json({ ok: false, error: "Binary files are not editable" });
134
+ }
135
+ const imageBytes = fs.readFileSync(resolvedPath.absolutePath);
136
+ const imageDataUrl = `data:${imageMimeType};base64,${imageBytes.toString("base64")}`;
137
+ return res.json({
138
+ ok: true,
139
+ path: resolvedPath.relativePath,
140
+ kind: "image",
141
+ mimeType: imageMimeType,
142
+ imageDataUrl,
143
+ content: "",
144
+ });
145
+ }
146
+ const content = fs.readFileSync(resolvedPath.absolutePath, "utf8");
147
+ return res.json({
148
+ ok: true,
149
+ path: resolvedPath.relativePath,
150
+ kind: "text",
151
+ content,
152
+ });
153
+ } catch (error) {
154
+ return res
155
+ .status(500)
156
+ .json({ ok: false, error: error.message || "Could not read file" });
157
+ }
158
+ });
159
+
160
+ app.get("/api/browse/git-summary", async (req, res) => {
161
+ try {
162
+ const envRepoSlug = parseGithubRepoSlug(
163
+ process.env.GITHUB_WORKSPACE_REPO || "",
164
+ );
165
+ const statusResult = await runGitCommand(
166
+ ["status", "--porcelain", "--branch"],
167
+ kRootResolved,
168
+ );
169
+ if (!statusResult.ok) {
170
+ if (/not a git repository/i.test(statusResult.error || "")) {
171
+ return res.json({
172
+ ok: true,
173
+ isRepo: false,
174
+ repoPath: kRootResolved,
175
+ });
176
+ }
177
+ return res.status(500).json({
178
+ ok: false,
179
+ error: statusResult.error || "Could not read git status",
180
+ });
181
+ }
182
+
183
+ const statusLines = statusResult.stdout
184
+ .split("\n")
185
+ .map((line) => line.trimEnd())
186
+ .filter(Boolean);
187
+ const branchLine = statusLines.find((line) => line.startsWith("##")) || "";
188
+ const branchTracking = parseBranchTracking(branchLine);
189
+ const branch = branchTracking.branch;
190
+ const diffNumstatResult = await runGitCommand(
191
+ ["diff", "--numstat", "HEAD"],
192
+ kRootResolved,
193
+ );
194
+ const diffStatsByPath = new Map();
195
+ if (diffNumstatResult.ok) {
196
+ diffNumstatResult.stdout
197
+ .split("\n")
198
+ .map((line) => line.trim())
199
+ .filter(Boolean)
200
+ .forEach((line) => {
201
+ const [addedRaw = "", deletedRaw = "", rawPath = ""] =
202
+ line.split("\t");
203
+ const normalizedPath = normalizeChangedPath(rawPath);
204
+ if (!normalizedPath) return;
205
+ const addedLines = Number.parseInt(addedRaw, 10);
206
+ const deletedLines = Number.parseInt(deletedRaw, 10);
207
+ diffStatsByPath.set(normalizedPath, {
208
+ addedLines: Number.isFinite(addedLines) ? addedLines : null,
209
+ deletedLines: Number.isFinite(deletedLines) ? deletedLines : null,
210
+ });
211
+ });
212
+ }
213
+ const changedFiles = statusLines
214
+ .filter((line) => !line.startsWith("##"))
215
+ .map((line) => {
216
+ const rawStatus = line.slice(0, 2);
217
+ const pathValue = normalizeChangedPath(line.slice(3));
218
+ const stats = diffStatsByPath.get(pathValue) || {
219
+ addedLines: null,
220
+ deletedLines: null,
221
+ };
222
+ const statusKind =
223
+ rawStatus === "??" || rawStatus.includes("A")
224
+ ? "U"
225
+ : rawStatus.includes("D")
226
+ ? "D"
227
+ : "M";
228
+ return {
229
+ status: rawStatus.trim() || "M",
230
+ statusKind,
231
+ path: pathValue,
232
+ addedLines: stats.addedLines,
233
+ deletedLines: stats.deletedLines,
234
+ };
235
+ });
236
+
237
+ let repoSlug = envRepoSlug;
238
+ if (!repoSlug) {
239
+ const remoteResult = await runGitCommand(
240
+ ["remote", "get-url", "origin"],
241
+ kRootResolved,
242
+ );
243
+ if (remoteResult.ok) {
244
+ repoSlug = parseGithubRepoSlug(remoteResult.stdout || "");
245
+ }
246
+ }
247
+ const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : "";
248
+
249
+ const logResult = await runGitCommand(
250
+ [
251
+ "log",
252
+ "--pretty=format:%H%x09%h%x09%s%x09%ct",
253
+ "-n",
254
+ String(kCommitHistoryLimit),
255
+ ],
256
+ kRootResolved,
257
+ );
258
+ const commits = logResult.ok
259
+ ? logResult.stdout
260
+ .split("\n")
261
+ .map((line) => line.trim())
262
+ .filter(Boolean)
263
+ .map((line) => {
264
+ const [hash = "", shortHash = "", message = "", unixTs = "0"] =
265
+ line.split("\t");
266
+ return {
267
+ hash,
268
+ shortHash,
269
+ message,
270
+ timestamp: Number.parseInt(unixTs, 10) || 0,
271
+ url: repoSlug && hash ? `${repoUrl}/commit/${hash}` : "",
272
+ };
273
+ })
274
+ : [];
275
+
276
+ return res.json({
277
+ ok: true,
278
+ isRepo: true,
279
+ repoPath: kRootResolved,
280
+ repoSlug,
281
+ repoUrl,
282
+ branch,
283
+ upstreamBranch: branchTracking.upstreamBranch,
284
+ hasUpstream: branchTracking.hasUpstream,
285
+ upstreamGone: branchTracking.upstreamGone,
286
+ aheadCount: branchTracking.aheadCount,
287
+ behindCount: branchTracking.behindCount,
288
+ syncState: branchTracking.syncState,
289
+ isDirty: changedFiles.length > 0,
290
+ changedFilesCount: changedFiles.length,
291
+ changedFiles: changedFiles.slice(0, 16),
292
+ commits,
293
+ });
294
+ } catch (error) {
295
+ return res.status(500).json({
296
+ ok: false,
297
+ error: error.message || "Could not build git summary",
298
+ });
299
+ }
300
+ });
301
+
302
+ app.get("/api/browse/sqlite-table", (req, res) => {
303
+ const resolvedPath = resolveSafePath(
304
+ req.query.path,
305
+ kRootResolved,
306
+ kRootWithSep,
307
+ kRootDisplayName,
308
+ );
309
+ if (!resolvedPath.ok) {
310
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
311
+ }
312
+ if (!isSqliteFilePath(resolvedPath.absolutePath)) {
313
+ return res.status(400).json({ ok: false, error: "Path is not a sqlite file" });
314
+ }
315
+ const tableName = String(req.query.table || "").trim();
316
+ const limit = req.query.limit;
317
+ const offset = req.query.offset;
318
+ const sqliteResult = readSqliteTableData(
319
+ resolvedPath.absolutePath,
320
+ tableName,
321
+ limit,
322
+ offset,
323
+ );
324
+ if (!sqliteResult.ok) {
325
+ return res.status(400).json({
326
+ ok: false,
327
+ error: sqliteResult.error || "Could not read sqlite table",
328
+ });
329
+ }
330
+ return res.json({
331
+ ok: true,
332
+ path: resolvedPath.relativePath,
333
+ table: sqliteResult.table,
334
+ columns: sqliteResult.columns,
335
+ rows: sqliteResult.rows,
336
+ limit: sqliteResult.limit,
337
+ offset: sqliteResult.offset,
338
+ totalRows: sqliteResult.totalRows,
339
+ });
340
+ });
341
+
342
+ app.get("/api/browse/git-diff", async (req, res) => {
343
+ const resolvedPath = resolveSafePath(
344
+ req.query.path,
345
+ kRootResolved,
346
+ kRootWithSep,
347
+ kRootDisplayName,
348
+ );
349
+ if (!resolvedPath.ok) {
350
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
351
+ }
352
+ const relativePath = String(resolvedPath.relativePath || "").trim();
353
+ if (!relativePath) {
354
+ return res.status(400).json({ ok: false, error: "path is required" });
355
+ }
356
+
357
+ try {
358
+ const statusResult = await runGitCommandWithExitCode(
359
+ ["status", "--porcelain", "--", relativePath],
360
+ kRootResolved,
361
+ );
362
+ if (
363
+ !statusResult.ok &&
364
+ /not a git repository/i.test(statusResult.stderr || "")
365
+ ) {
366
+ return res.status(400).json({ ok: false, error: "No git repo at this root" });
367
+ }
368
+ const statusLines = statusResult.stdout
369
+ .split("\n")
370
+ .map((line) => line.trim())
371
+ .filter(Boolean);
372
+ const isUntracked = statusLines.some((line) => line.startsWith("??"));
373
+
374
+ const diffResult = isUntracked
375
+ ? await runGitCommandWithExitCode(
376
+ ["diff", "--no-index", "--", "/dev/null", resolvedPath.absolutePath],
377
+ kRootResolved,
378
+ )
379
+ : await runGitCommandWithExitCode(
380
+ ["diff", "HEAD", "--", relativePath],
381
+ kRootResolved,
382
+ );
383
+
384
+ const untrackedAllowedFailure =
385
+ isUntracked && diffResult.exitCode === 1 && diffResult.stdout;
386
+ if (!diffResult.ok && !untrackedAllowedFailure) {
387
+ return res.status(500).json({
388
+ ok: false,
389
+ error: diffResult.stderr || diffResult.error || "Could not load file diff",
390
+ });
391
+ }
392
+
393
+ const content = String(diffResult.stdout || "")
394
+ .replaceAll(resolvedPath.absolutePath, relativePath)
395
+ .trimEnd();
396
+ return res.json({
397
+ ok: true,
398
+ path: relativePath,
399
+ content,
400
+ });
401
+ } catch (error) {
402
+ return res.status(500).json({
403
+ ok: false,
404
+ error: error.message || "Could not load file diff",
405
+ });
406
+ }
407
+ });
408
+
409
+ app.post("/api/browse/git-sync", async (req, res) => {
410
+ try {
411
+ const commitMessageRaw = String(req.body?.message || "").trim();
412
+ const commitMessage = commitMessageRaw || "sync changes";
413
+ const statusResult = await runGitCommand(
414
+ ["status", "--porcelain", "--branch"],
415
+ kRootResolved,
416
+ );
417
+ if (!statusResult.ok) {
418
+ if (/not a git repository/i.test(statusResult.error || "")) {
419
+ return res.status(400).json({ ok: false, error: "No git repo at this root" });
420
+ }
421
+ return res.status(500).json({
422
+ ok: false,
423
+ error: statusResult.error || "Could not read git status",
424
+ });
425
+ }
426
+ const statusLines = statusResult.stdout
427
+ .split("\n")
428
+ .map((line) => line.trimEnd())
429
+ .filter(Boolean);
430
+ const branchLine = statusLines.find((line) => line.startsWith("##")) || "";
431
+ const branchTracking = parseBranchTracking(branchLine);
432
+ const hasChanges =
433
+ statusLines
434
+ .filter((line) => !line.startsWith("##"))
435
+ .map((line) => line.trim())
436
+ .filter(Boolean).length > 0;
437
+ let committed = false;
438
+ let pushed = false;
439
+ let shortHash = "";
440
+ if (!hasChanges) {
441
+ const hasAheadCommits =
442
+ branchTracking.hasUpstream && branchTracking.aheadCount > 0;
443
+ if (!hasAheadCommits) {
444
+ return res.json({
445
+ ok: true,
446
+ committed: false,
447
+ pushed: false,
448
+ message: "No changes to sync",
449
+ });
450
+ }
451
+ }
452
+ if (hasChanges) {
453
+ const addResult = await runGitCommand(["add", "-A"], kRootResolved);
454
+ if (!addResult.ok) {
455
+ return res.status(500).json({
456
+ ok: false,
457
+ error: addResult.error || "Could not stage changes",
458
+ });
459
+ }
460
+ const commitResult = await runGitCommand(
461
+ ["commit", "-m", commitMessage],
462
+ kRootResolved,
463
+ );
464
+ if (!commitResult.ok) {
465
+ if (/nothing to commit/i.test(commitResult.error || "")) {
466
+ return res.json({
467
+ ok: true,
468
+ committed: false,
469
+ pushed: false,
470
+ message: "No changes to sync",
471
+ });
472
+ }
473
+ return res.status(500).json({
474
+ ok: false,
475
+ error: commitResult.error || "Could not commit changes",
476
+ });
477
+ }
478
+ committed = true;
479
+ const shortHashResult = await runGitCommand(
480
+ ["rev-parse", "--short", "HEAD"],
481
+ kRootResolved,
482
+ );
483
+ shortHash = shortHashResult.ok
484
+ ? String(shortHashResult.stdout || "").trim()
485
+ : "";
486
+ }
487
+ const shouldPush = branchTracking.hasUpstream
488
+ ? branchTracking.aheadCount > 0 || committed
489
+ : committed;
490
+ if (shouldPush) {
491
+ const pushArgs = branchTracking.hasUpstream
492
+ ? ["push"]
493
+ : ["push", "-u", "origin", "HEAD"];
494
+ const pushResult = await runGitCommand(pushArgs, kRootResolved);
495
+ if (pushResult.ok) {
496
+ pushed = true;
497
+ } else {
498
+ return res.json({
499
+ ok: true,
500
+ committed,
501
+ pushed: false,
502
+ shortHash,
503
+ message: committed
504
+ ? `Committed ${shortHash || "changes"} locally; push failed`
505
+ : "Could not push commits",
506
+ pushError: pushResult.error || "Could not push commits",
507
+ });
508
+ }
509
+ }
510
+ const syncMessage = pushed
511
+ ? committed
512
+ ? `Committed and pushed ${shortHash || "changes"}`
513
+ : "Pushed local commits"
514
+ : committed
515
+ ? `Committed ${shortHash || "changes"}`
516
+ : "No changes to sync";
517
+ return res.json({
518
+ ok: true,
519
+ committed,
520
+ pushed,
521
+ shortHash,
522
+ message: syncMessage,
523
+ });
524
+ } catch (error) {
525
+ return res.status(500).json({
526
+ ok: false,
527
+ error: error.message || "Could not sync changes",
528
+ });
529
+ }
530
+ });
531
+
532
+ app.put("/api/browse/write", async (req, res) => {
533
+ const { path: targetPath, content } = req.body || {};
534
+ const resolvedPath = resolveSafePath(
535
+ targetPath,
536
+ kRootResolved,
537
+ kRootWithSep,
538
+ kRootDisplayName,
539
+ );
540
+ if (!resolvedPath.ok) {
541
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
542
+ }
543
+ const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath);
544
+ if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) {
545
+ return res.status(403).json({
546
+ ok: false,
547
+ error: "This file is managed by AlphaClaw and cannot be edited.",
548
+ });
549
+ }
550
+ if (typeof content !== "string") {
551
+ return res.status(400).json({ ok: false, error: "content must be a string" });
552
+ }
553
+
554
+ try {
555
+ const stats = fs.statSync(resolvedPath.absolutePath);
556
+ if (!stats.isFile()) {
557
+ return res.status(400).json({ ok: false, error: "Path is not a file" });
558
+ }
559
+ fs.writeFileSync(resolvedPath.absolutePath, content, "utf8");
560
+ return res.json({
561
+ ok: true,
562
+ path: resolvedPath.relativePath,
563
+ });
564
+ } catch (error) {
565
+ return res
566
+ .status(500)
567
+ .json({ ok: false, error: error.message || "Could not save file" });
568
+ }
569
+ });
570
+ };
571
+
572
+ module.exports = { registerBrowseRoutes };
@@ -0,0 +1,53 @@
1
+ const path = require("path");
2
+
3
+ const normalizeRelativePath = (inputPath) => {
4
+ const rawPath = String(inputPath || "").trim();
5
+ if (!rawPath) return "";
6
+ return rawPath.replace(/\\/g, "/").replace(/^\/+/, "");
7
+ };
8
+
9
+ const normalizePolicyPath = (inputPath) =>
10
+ String(inputPath || "")
11
+ .replace(/\\/g, "/")
12
+ .replace(/^\.\/+/, "")
13
+ .replace(/^\/+/, "")
14
+ .trim()
15
+ .toLowerCase();
16
+
17
+ const resolveSafePath = (inputPath, kRootResolved, kRootWithSep, kRootDisplayName) => {
18
+ const relativePath = normalizeRelativePath(inputPath);
19
+ const absolutePath = path.resolve(kRootResolved, relativePath);
20
+ const isInsideRoot =
21
+ absolutePath === kRootResolved || absolutePath.startsWith(kRootWithSep);
22
+ if (!isInsideRoot) {
23
+ return { ok: false, error: `Path must stay within ${kRootDisplayName}` };
24
+ }
25
+ return { ok: true, relativePath, absolutePath };
26
+ };
27
+
28
+ const toRelativePath = (absolutePath, kRootResolved) => {
29
+ const relative = path.relative(kRootResolved, absolutePath);
30
+ return relative === "" ? "" : relative.split(path.sep).join("/");
31
+ };
32
+
33
+ const matchesPolicyPath = (policyPathSet, normalizedPath) => {
34
+ const safeNormalizedPath = String(normalizedPath || "").trim();
35
+ if (!safeNormalizedPath) return false;
36
+ for (const policyPath of policyPathSet) {
37
+ if (
38
+ safeNormalizedPath === policyPath ||
39
+ safeNormalizedPath.endsWith(`/${policyPath}`)
40
+ ) {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ };
46
+
47
+ module.exports = {
48
+ normalizeRelativePath,
49
+ normalizePolicyPath,
50
+ resolveSafePath,
51
+ toRelativePath,
52
+ matchesPolicyPath,
53
+ };