@chrysb/alphaclaw 0.3.4-beta.0 → 0.3.5-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,32 @@ const { execFile } = require("child_process");
4
4
  const kDefaultTreeDepth = 10;
5
5
  const kIgnoredDirectoryNames = new Set([
6
6
  ".git",
7
+ ".alphaclaw",
7
8
  "node_modules",
8
9
  ".cache",
9
10
  "dist",
10
11
  "build",
11
12
  ]);
13
+ const kLockedBrowsePaths = new Set([
14
+ "hooks/bootstrap/agents.md",
15
+ "hooks/bootstrap/tools.md",
16
+ ".alphaclaw/hourly-git-sync.sh",
17
+ ".alphaclaw/.cli-device-auto-approved",
18
+ ]);
19
+
20
+ const matchesPolicyPath = (policyPathSet, normalizedPath) => {
21
+ const safeNormalizedPath = String(normalizedPath || "").trim();
22
+ if (!safeNormalizedPath) return false;
23
+ for (const policyPath of policyPathSet) {
24
+ if (
25
+ safeNormalizedPath === policyPath ||
26
+ safeNormalizedPath.endsWith(`/${policyPath}`)
27
+ ) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ };
12
33
 
13
34
  const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
14
35
  const kRootResolved = path.resolve(kRootDir);
@@ -25,6 +46,14 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
25
46
  return rawPath.replace(/\\/g, "/").replace(/^\/+/, "");
26
47
  };
27
48
 
49
+ const normalizePolicyPath = (inputPath) =>
50
+ String(inputPath || "")
51
+ .replace(/\\/g, "/")
52
+ .replace(/^\.\/+/, "")
53
+ .replace(/^\/+/, "")
54
+ .trim()
55
+ .toLowerCase();
56
+
28
57
  const resolveSafePath = (inputPath) => {
29
58
  const relativePath = normalizeRelativePath(inputPath);
30
59
  const absolutePath = path.resolve(kRootResolved, relativePath);
@@ -88,42 +117,48 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
88
117
  return { type: "folder", name: nodeName, path: nodePath, children };
89
118
  };
90
119
 
91
- const runGitSync = (message, relativeFilePath) =>
120
+ const runGitCommand = (args) =>
92
121
  new Promise((resolve) => {
93
- const syncArgs = ["git-sync", "-m", message];
94
- if (relativeFilePath) {
95
- syncArgs.push("--file", String(relativeFilePath));
96
- }
97
122
  execFile(
98
- "alphaclaw",
99
- syncArgs,
100
- { timeout: 20000, cwd: kRootResolved },
101
- (error) => {
123
+ "git",
124
+ args,
125
+ { timeout: 10000, cwd: kRootResolved },
126
+ (error, stdout, stderr) => {
102
127
  if (error) {
103
128
  return resolve({
104
129
  ok: false,
105
- error: error.message || "alphaclaw git-sync failed",
130
+ error: String(stderr || stdout || error.message || "git command failed").trim(),
106
131
  });
107
132
  }
108
- return resolve({ ok: true });
133
+ return resolve({ ok: true, stdout: String(stdout || "") });
109
134
  },
110
135
  );
111
136
  });
112
137
 
113
- const runGitCommand = (args) =>
138
+ const runGitCommandWithExitCode = (args) =>
114
139
  new Promise((resolve) => {
115
140
  execFile(
116
141
  "git",
117
142
  args,
118
143
  { timeout: 10000, cwd: kRootResolved },
119
144
  (error, stdout, stderr) => {
120
- if (error) {
145
+ const safeStdout = String(stdout || "");
146
+ const safeStderr = String(stderr || "");
147
+ if (!error) {
121
148
  return resolve({
122
- ok: false,
123
- error: String(stderr || stdout || error.message || "git command failed").trim(),
149
+ ok: true,
150
+ stdout: safeStdout,
151
+ stderr: safeStderr,
152
+ exitCode: 0,
124
153
  });
125
154
  }
126
- return resolve({ ok: true, stdout: String(stdout || "") });
155
+ return resolve({
156
+ ok: false,
157
+ stdout: safeStdout,
158
+ stderr: safeStderr,
159
+ exitCode: Number.isInteger(error.code) ? error.code : 1,
160
+ error: String(error.message || "git command failed"),
161
+ });
127
162
  },
128
163
  );
129
164
  });
@@ -138,6 +173,16 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
138
173
  .trim();
139
174
  };
140
175
 
176
+ const normalizeChangedPath = (rawPath) => {
177
+ const value = String(rawPath || "").trim();
178
+ if (!value) return "";
179
+ if (value.includes(" -> ")) {
180
+ const segments = value.split(" -> ");
181
+ return String(segments[segments.length - 1] || "").trim();
182
+ }
183
+ return value;
184
+ };
185
+
141
186
  app.get("/api/browse/tree", (req, res) => {
142
187
  const depthValue = Number.parseInt(String(req.query.depth || ""), 10);
143
188
  const depth = Number.isFinite(depthValue) && depthValue > 0 ? depthValue : kDefaultTreeDepth;
@@ -200,12 +245,48 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
200
245
  .filter(Boolean);
201
246
  const branchLine = statusLines.find((line) => line.startsWith("##")) || "";
202
247
  const branch = branchLine.replace(/^##\s*/, "").split("...")[0] || "unknown";
248
+ const diffNumstatResult = await runGitCommand(["diff", "--numstat", "HEAD"]);
249
+ const diffStatsByPath = new Map();
250
+ if (diffNumstatResult.ok) {
251
+ diffNumstatResult.stdout
252
+ .split("\n")
253
+ .map((line) => line.trim())
254
+ .filter(Boolean)
255
+ .forEach((line) => {
256
+ const [addedRaw = "", deletedRaw = "", rawPath = ""] = line.split("\t");
257
+ const normalizedPath = normalizeChangedPath(rawPath);
258
+ if (!normalizedPath) return;
259
+ const addedLines = Number.parseInt(addedRaw, 10);
260
+ const deletedLines = Number.parseInt(deletedRaw, 10);
261
+ diffStatsByPath.set(normalizedPath, {
262
+ addedLines: Number.isFinite(addedLines) ? addedLines : null,
263
+ deletedLines: Number.isFinite(deletedLines) ? deletedLines : null,
264
+ });
265
+ });
266
+ }
203
267
  const changedFiles = statusLines
204
268
  .filter((line) => !line.startsWith("##"))
205
- .map((line) => ({
206
- status: line.slice(0, 2).trim() || "M",
207
- path: line.slice(3).trim(),
208
- }));
269
+ .map((line) => {
270
+ const rawStatus = line.slice(0, 2);
271
+ const pathValue = normalizeChangedPath(line.slice(3));
272
+ const stats = diffStatsByPath.get(pathValue) || {
273
+ addedLines: null,
274
+ deletedLines: null,
275
+ };
276
+ const statusKind =
277
+ rawStatus === "??" || rawStatus.includes("A")
278
+ ? "U"
279
+ : rawStatus.includes("D")
280
+ ? "D"
281
+ : "M";
282
+ return {
283
+ status: rawStatus.trim() || "M",
284
+ statusKind,
285
+ path: pathValue,
286
+ addedLines: stats.addedLines,
287
+ deletedLines: stats.deletedLines,
288
+ };
289
+ });
209
290
 
210
291
  let repoSlug = envRepoSlug;
211
292
  if (!repoSlug) {
@@ -248,7 +329,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
248
329
  branch,
249
330
  isDirty: changedFiles.length > 0,
250
331
  changedFilesCount: changedFiles.length,
251
- changedFiles: changedFiles.slice(0, 8),
332
+ changedFiles: changedFiles.slice(0, 16),
252
333
  commits,
253
334
  });
254
335
  } catch (error) {
@@ -259,12 +340,143 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
259
340
  }
260
341
  });
261
342
 
343
+ app.get("/api/browse/git-diff", async (req, res) => {
344
+ const resolvedPath = resolveSafePath(req.query.path);
345
+ if (!resolvedPath.ok) {
346
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
347
+ }
348
+ const relativePath = String(resolvedPath.relativePath || "").trim();
349
+ if (!relativePath) {
350
+ return res.status(400).json({ ok: false, error: "path is required" });
351
+ }
352
+
353
+ try {
354
+ const statusResult = await runGitCommandWithExitCode([
355
+ "status",
356
+ "--porcelain",
357
+ "--",
358
+ relativePath,
359
+ ]);
360
+ if (!statusResult.ok && /not a git repository/i.test(statusResult.stderr || "")) {
361
+ return res.status(400).json({ ok: false, error: "No git repo at this root" });
362
+ }
363
+ const statusLines = statusResult.stdout
364
+ .split("\n")
365
+ .map((line) => line.trim())
366
+ .filter(Boolean);
367
+ const isUntracked = statusLines.some((line) => line.startsWith("??"));
368
+
369
+ const diffResult = isUntracked
370
+ ? await runGitCommandWithExitCode([
371
+ "diff",
372
+ "--no-index",
373
+ "--",
374
+ "/dev/null",
375
+ resolvedPath.absolutePath,
376
+ ])
377
+ : await runGitCommandWithExitCode(["diff", "HEAD", "--", relativePath]);
378
+
379
+ const untrackedAllowedFailure =
380
+ isUntracked && diffResult.exitCode === 1 && diffResult.stdout;
381
+ if (!diffResult.ok && !untrackedAllowedFailure) {
382
+ return res.status(500).json({
383
+ ok: false,
384
+ error: diffResult.stderr || diffResult.error || "Could not load file diff",
385
+ });
386
+ }
387
+
388
+ const content = String(diffResult.stdout || "")
389
+ .replaceAll(resolvedPath.absolutePath, relativePath)
390
+ .trimEnd();
391
+ return res.json({
392
+ ok: true,
393
+ path: relativePath,
394
+ content,
395
+ });
396
+ } catch (error) {
397
+ return res
398
+ .status(500)
399
+ .json({ ok: false, error: error.message || "Could not load file diff" });
400
+ }
401
+ });
402
+
403
+ app.post("/api/browse/git-sync", async (req, res) => {
404
+ try {
405
+ const commitMessageRaw = String(req.body?.message || "").trim();
406
+ const commitMessage = commitMessageRaw || "sync changes";
407
+ const statusResult = await runGitCommand(["status", "--porcelain"]);
408
+ if (!statusResult.ok) {
409
+ if (/not a git repository/i.test(statusResult.error || "")) {
410
+ return res
411
+ .status(400)
412
+ .json({ ok: false, error: "No git repo at this root" });
413
+ }
414
+ return res.status(500).json({
415
+ ok: false,
416
+ error: statusResult.error || "Could not read git status",
417
+ });
418
+ }
419
+ const hasChanges = statusResult.stdout
420
+ .split("\n")
421
+ .map((line) => line.trim())
422
+ .filter(Boolean).length > 0;
423
+ if (!hasChanges) {
424
+ return res.json({
425
+ ok: true,
426
+ committed: false,
427
+ message: "No changes to sync",
428
+ });
429
+ }
430
+ const addResult = await runGitCommand(["add", "-A"]);
431
+ if (!addResult.ok) {
432
+ return res.status(500).json({
433
+ ok: false,
434
+ error: addResult.error || "Could not stage changes",
435
+ });
436
+ }
437
+ const commitResult = await runGitCommand(["commit", "-m", commitMessage]);
438
+ if (!commitResult.ok) {
439
+ if (/nothing to commit/i.test(commitResult.error || "")) {
440
+ return res.json({
441
+ ok: true,
442
+ committed: false,
443
+ message: "No changes to sync",
444
+ });
445
+ }
446
+ return res.status(500).json({
447
+ ok: false,
448
+ error: commitResult.error || "Could not commit changes",
449
+ });
450
+ }
451
+ const shortHashResult = await runGitCommand(["rev-parse", "--short", "HEAD"]);
452
+ const shortHash = shortHashResult.ok ? String(shortHashResult.stdout || "").trim() : "";
453
+ return res.json({
454
+ ok: true,
455
+ committed: true,
456
+ shortHash,
457
+ message: `Committed ${shortHash || "changes"}`,
458
+ });
459
+ } catch (error) {
460
+ return res.status(500).json({
461
+ ok: false,
462
+ error: error.message || "Could not sync changes",
463
+ });
464
+ }
465
+ });
466
+
262
467
  app.put("/api/browse/write", async (req, res) => {
263
468
  const { path: targetPath, content } = req.body || {};
264
469
  const resolvedPath = resolveSafePath(targetPath);
265
470
  if (!resolvedPath.ok) {
266
471
  return res.status(400).json({ ok: false, error: resolvedPath.error });
267
472
  }
473
+ const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath);
474
+ if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) {
475
+ return res.status(403).json({
476
+ ok: false,
477
+ error: "This file is managed by Alpha Claw and cannot be edited.",
478
+ });
479
+ }
268
480
  if (typeof content !== "string") {
269
481
  return res.status(400).json({ ok: false, error: "content must be a string" });
270
482
  }
@@ -275,16 +487,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
275
487
  return res.status(400).json({ ok: false, error: "Path is not a file" });
276
488
  }
277
489
  fs.writeFileSync(resolvedPath.absolutePath, content, "utf8");
278
- const fileName = path.basename(resolvedPath.absolutePath);
279
- const syncResult = await runGitSync(
280
- `Edit ${fileName} via UI`,
281
- resolvedPath.relativePath,
282
- );
283
490
  return res.json({
284
491
  ok: true,
285
492
  path: resolvedPath.relativePath,
286
- synced: syncResult.ok,
287
- syncError: syncResult.ok ? undefined : syncResult.error,
288
493
  });
289
494
  } catch (error) {
290
495
  return res.status(500).json({ ok: false, error: error.message || "Could not save file" });
@@ -1,15 +1,21 @@
1
1
  const fs = require("fs");
2
2
  const { OPENCLAW_DIR } = require("../constants");
3
+ const { buildManagedPaths } = require("../internal-files-migration");
3
4
 
4
5
  const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openclawDir = OPENCLAW_DIR }) => {
5
6
  let pairingCache = { pending: [], ts: 0 };
6
7
  const PAIRING_CACHE_TTL = 10000;
7
- const kCliAutoApproveMarkerPath = `${openclawDir}/.cli-device-auto-approved`;
8
+ const {
9
+ cliDeviceAutoApprovedPath: kCliAutoApproveMarkerPath,
10
+ internalDir: kManagedFilesDir,
11
+ } = buildManagedPaths({
12
+ openclawDir,
13
+ });
8
14
 
9
15
  const hasCliAutoApproveMarker = () => fsModule.existsSync(kCliAutoApproveMarkerPath);
10
16
 
11
17
  const writeCliAutoApproveMarker = () => {
12
- fsModule.mkdirSync(openclawDir, { recursive: true });
18
+ fsModule.mkdirSync(kManagedFilesDir, { recursive: true });
13
19
  fsModule.writeFileSync(
14
20
  kCliAutoApproveMarkerPath,
15
21
  JSON.stringify({ approvedAt: new Date().toISOString() }, null, 2),
@@ -1,3 +1,5 @@
1
+ const { buildManagedPaths } = require("../internal-files-migration");
2
+
1
3
  const registerSystemRoutes = ({
2
4
  app,
3
5
  fs,
@@ -35,7 +37,9 @@ const registerSystemRoutes = ({
35
37
  kSystemVars.has(key) || kEnvVarsReservedForUserInput.has(key);
36
38
  const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync";
37
39
  const kSystemCronConfigPath = `${OPENCLAW_DIR}/cron/system-sync.json`;
38
- const kSystemCronScriptPath = `${OPENCLAW_DIR}/hourly-git-sync.sh`;
40
+ const { hourlyGitSyncPath: kSystemCronScriptPath } = buildManagedPaths({
41
+ openclawDir: OPENCLAW_DIR,
42
+ });
39
43
  const kDefaultSystemCronSchedule = "0 * * * *";
40
44
  const isValidCronSchedule = (value) =>
41
45
  typeof value === "string" && /^(\S+\s+){4}\S+$/.test(value.trim());
package/lib/server.js CHANGED
@@ -76,6 +76,9 @@ const {
76
76
  installControlUiSkill,
77
77
  syncBootstrapPromptFiles,
78
78
  } = require("./server/onboarding/workspace");
79
+ const {
80
+ migrateManagedInternalFiles,
81
+ } = require("./server/internal-files-migration");
79
82
  const { createTelegramApi } = require("./server/telegram-api");
80
83
  const { createDiscordApi } = require("./server/discord-api");
81
84
  const { createWatchdogNotifier } = require("./server/watchdog-notify");
@@ -100,6 +103,10 @@ const { PORT, GATEWAY_URL, kTrustProxyHops, SETUP_API_PREFIXES } = constants;
100
103
 
101
104
  startEnvWatcher();
102
105
  attachGatewaySignalHandlers();
106
+ migrateManagedInternalFiles({
107
+ fs,
108
+ openclawDir: constants.OPENCLAW_DIR,
109
+ });
103
110
 
104
111
  const app = express();
105
112
  app.set("trust proxy", kTrustProxyHops);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.3.4-beta.0",
3
+ "version": "0.3.5-beta.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },