@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.
- package/bin/alphaclaw.js +82 -3
- package/lib/public/css/explorer.css +385 -9
- package/lib/public/css/theme.css +1 -1
- package/lib/public/js/app.js +102 -8
- package/lib/public/js/components/channels.js +1 -0
- package/lib/public/js/components/file-tree.js +74 -38
- package/lib/public/js/components/file-viewer/constants.js +6 -0
- package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
- package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
- package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
- package/lib/public/js/components/file-viewer/index.js +164 -0
- package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
- package/lib/public/js/components/file-viewer/media-preview.js +44 -0
- package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
- package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
- package/lib/public/js/components/file-viewer/status-banners.js +59 -0
- package/lib/public/js/components/file-viewer/storage.js +58 -0
- package/lib/public/js/components/file-viewer/toolbar.js +77 -0
- package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
- package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
- package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
- package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
- package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
- package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
- package/lib/public/js/components/file-viewer/utils.js +11 -0
- package/lib/public/js/components/gateway.js +95 -48
- package/lib/public/js/components/icons.js +26 -0
- package/lib/public/js/components/sidebar-git-panel.js +219 -31
- package/lib/public/js/components/sidebar.js +1 -1
- package/lib/public/js/components/usage-tab.js +4 -1
- package/lib/public/js/components/watchdog-tab.js +6 -2
- package/lib/public/js/lib/api.js +31 -0
- package/lib/public/js/lib/browse-file-policies.js +34 -0
- package/lib/scripts/git +40 -0
- package/lib/scripts/git-askpass +6 -0
- package/lib/server/constants.js +8 -0
- package/lib/server/helpers.js +18 -5
- package/lib/server/internal-files-migration.js +93 -0
- package/lib/server/onboarding/cron.js +6 -4
- package/lib/server/onboarding/index.js +7 -0
- package/lib/server/onboarding/openclaw.js +6 -1
- package/lib/server/routes/browse/constants.js +51 -0
- package/lib/server/routes/browse/file-helpers.js +43 -0
- package/lib/server/routes/browse/git.js +131 -0
- package/lib/server/routes/browse/index.js +572 -0
- package/lib/server/routes/browse/path-utils.js +53 -0
- package/lib/server/routes/browse/sqlite.js +140 -0
- package/lib/server/routes/pairings.js +8 -2
- package/lib/server/routes/proxy.js +11 -5
- package/lib/server/routes/system.js +5 -1
- package/lib/server.js +7 -0
- package/lib/setup/core-prompts/TOOLS.md +0 -4
- package/package.json +1 -1
- package/lib/public/js/components/file-viewer.js +0 -864
- 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
|
+
};
|