@anhlp/alp-core 0.0.1-beta.3 → 0.0.1-beta.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/dist/backup.js +103 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/migrations.js +1 -1
- package/dist/user-worktree.d.ts +29 -0
- package/dist/user-worktree.js +242 -0
- package/dist/validation.js +3 -2
- package/package.json +2 -2
- package/src/backup.js +100 -5
- package/src/index.js +1 -0
- package/src/migrations.js +1 -1
- package/src/user-worktree.js +259 -0
- package/src/validation.js +4 -2
package/dist/backup.js
CHANGED
|
@@ -13,10 +13,15 @@ import { STATE_SCHEMA, assertStateSupported, readStateSchema } from "./state-sch
|
|
|
13
13
|
import { withTaskLock } from "./tasks.js";
|
|
14
14
|
var BACKUP_FORMAT = 1;
|
|
15
15
|
var BACKUP_EXTENSION = ".alpbackup";
|
|
16
|
-
var
|
|
16
|
+
var RESULT_LEDGER = "state/assignment-results.json";
|
|
17
|
+
var RESULT_MARKER = `${RESULT_LEDGER}.initialized`;
|
|
18
|
+
var DESTINATIONS = "worktree-destinations.json";
|
|
19
|
+
var QUARANTINED_DESTINATIONS = "state/worktree-destinations.quarantined.json";
|
|
20
|
+
var STATE_UNITS = [RESULT_LEDGER, RESULT_MARKER, "state/schema.json", "state/sessions", "state/timeline", "state/receipts", "state/recall.json", "state/pause.json", "state/trust.json", "state/web-workspaces.json"];
|
|
17
21
|
var CLEARED_UNITS = ["state/live.json", "state/migration.json"];
|
|
18
22
|
var HOME_UNITS = [
|
|
19
23
|
...STATE_UNITS,
|
|
24
|
+
DESTINATIONS,
|
|
20
25
|
"settings.json",
|
|
21
26
|
"library.json",
|
|
22
27
|
"role-skills.json",
|
|
@@ -378,6 +383,98 @@ async function holdTaskLocks(roots, work) {
|
|
|
378
383
|
clearInterval(touch);
|
|
379
384
|
}
|
|
380
385
|
}
|
|
386
|
+
async function prepareWorkspaceRestore(home, root, units, warnings) {
|
|
387
|
+
const relevant = [RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, "state/sessions", "state/web-workspaces.json"];
|
|
388
|
+
const restorable = units.filter((unit) => unit.path !== QUARANTINED_DESTINATIONS);
|
|
389
|
+
if (!units.some((unit) => relevant.includes(unit.path))) return restorable;
|
|
390
|
+
for (const base of [home, root]) {
|
|
391
|
+
const info = await lstat(path.join(base, "state")).catch((error) => {
|
|
392
|
+
if (error.code === "ENOENT") return void 0;
|
|
393
|
+
throw error;
|
|
394
|
+
});
|
|
395
|
+
if (info && !info.isDirectory()) throw invalid("workspace state must be a real directory");
|
|
396
|
+
}
|
|
397
|
+
const result = restorable.filter((unit) => ![RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, "state/web-workspaces.json"].includes(unit.path));
|
|
398
|
+
async function regular(base, unit, declared) {
|
|
399
|
+
if (declared && declared.kind === "absent") return void 0;
|
|
400
|
+
const file = path.join(base, unit);
|
|
401
|
+
const info = await lstat(file).catch((error) => {
|
|
402
|
+
if (error.code === "ENOENT") return void 0;
|
|
403
|
+
throw error;
|
|
404
|
+
});
|
|
405
|
+
if (!info) {
|
|
406
|
+
if (declared) throw invalid(`${unit} is declared but missing from the backup`);
|
|
407
|
+
return void 0;
|
|
408
|
+
}
|
|
409
|
+
if (!info.isFile()) throw invalid(`${unit} must be a regular file for a workspace restore`);
|
|
410
|
+
return readFile(file, "utf8");
|
|
411
|
+
}
|
|
412
|
+
const incoming = async (unit) => {
|
|
413
|
+
const declared = units.find((entry) => entry.path === unit);
|
|
414
|
+
return declared ? regular(root, unit, declared) : void 0;
|
|
415
|
+
};
|
|
416
|
+
const owners = /* @__PURE__ */ new Map();
|
|
417
|
+
let hasLedger = false;
|
|
418
|
+
for (const text of [await incoming(RESULT_LEDGER), await regular(home, RESULT_LEDGER)]) {
|
|
419
|
+
if (text === void 0) continue;
|
|
420
|
+
let value;
|
|
421
|
+
try {
|
|
422
|
+
value = JSON.parse(text);
|
|
423
|
+
} catch {
|
|
424
|
+
throw invalid("assignment result ledger is not readable JSON; repair it before restoring");
|
|
425
|
+
}
|
|
426
|
+
const absolute = (value2) => typeof value2 === "string" && path.isAbsolute(value2);
|
|
427
|
+
if (value?.version !== 1 || !Array.isArray(value.workspaces) || !value.workspaces.every(absolute) || !Array.isArray(value.results) || !value.results.every((entry) => entry && typeof entry.assignmentId === "string" && absolute(entry.checkout) && (entry.worktree === void 0 || absolute(entry.worktree))))
|
|
428
|
+
throw invalid("assignment result ledger has an unsupported or invalid shape");
|
|
429
|
+
hasLedger = true;
|
|
430
|
+
for (const entry of value.results) {
|
|
431
|
+
const owner = { assignmentId: entry.assignmentId, checkout: entry.checkout, ...entry.worktree === void 0 ? {} : { worktree: entry.worktree } };
|
|
432
|
+
const previous = owners.get(owner.assignmentId);
|
|
433
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(owner)) throw invalid("assignment result ownership conflicts with current data; reconcile it before restoring");
|
|
434
|
+
owners.set(owner.assignmentId, owner);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const markers = [await incoming(RESULT_MARKER), await regular(home, RESULT_MARKER)];
|
|
438
|
+
if (markers.some((text) => text !== void 0 && text !== "1\n")) throw invalid("assignment result initialized marker is invalid");
|
|
439
|
+
await mkdir(path.join(root, "state"), { recursive: true, mode: 448 });
|
|
440
|
+
if (hasLedger) await writeAtomic(path.join(root, RESULT_LEDGER), JSON.stringify({ version: 1, workspaces: [], results: [...owners.values()] }) + "\n");
|
|
441
|
+
result.push({ path: RESULT_LEDGER, kind: hasLedger ? "file" : "absent" });
|
|
442
|
+
const initialized = markers.some((text) => text !== void 0);
|
|
443
|
+
if (initialized) await writeAtomic(path.join(root, RESULT_MARKER), "1\n");
|
|
444
|
+
result.push({ path: RESULT_MARKER, kind: initialized ? "file" : "absent" });
|
|
445
|
+
const mappings = await incoming(DESTINATIONS);
|
|
446
|
+
if (mappings !== void 0) {
|
|
447
|
+
await writeAtomic(path.join(root, QUARANTINED_DESTINATIONS), mappings);
|
|
448
|
+
result.push({ path: QUARANTINED_DESTINATIONS, kind: "file" });
|
|
449
|
+
}
|
|
450
|
+
result.push({ path: DESTINATIONS, kind: "absent" });
|
|
451
|
+
const registryUnit = "state/web-workspaces.json";
|
|
452
|
+
const registryDeclaration = units.find((unit) => unit.path === registryUnit);
|
|
453
|
+
const registryText = registryDeclaration ? await incoming(registryUnit) : await regular(home, registryUnit);
|
|
454
|
+
if (registryText !== void 0) {
|
|
455
|
+
let registry;
|
|
456
|
+
try {
|
|
457
|
+
registry = JSON.parse(registryText);
|
|
458
|
+
} catch {
|
|
459
|
+
throw invalid("workspace registry is not readable JSON; repair it before restoring");
|
|
460
|
+
}
|
|
461
|
+
const epoch = `restore-${randomBytes(16).toString("hex")}`;
|
|
462
|
+
let revoked = false;
|
|
463
|
+
const revoke = (value) => {
|
|
464
|
+
if (!value || typeof value !== "object") return;
|
|
465
|
+
if (Object.hasOwn(value, "grantEpoch")) {
|
|
466
|
+
value.grantEpoch = epoch;
|
|
467
|
+
revoked = true;
|
|
468
|
+
}
|
|
469
|
+
for (const child of Object.values(value)) revoke(child);
|
|
470
|
+
};
|
|
471
|
+
revoke(registry);
|
|
472
|
+
await writeAtomic(path.join(root, registryUnit), revoked ? JSON.stringify(registry) + "\n" : registryText);
|
|
473
|
+
result.push({ path: registryUnit, kind: "file" });
|
|
474
|
+
} else if (registryDeclaration) result.push({ path: registryUnit, kind: "absent" });
|
|
475
|
+
if (hasLedger || initialized || mappings !== void 0 || await exists(path.join(home, DESTINATIONS))) warnings.push("Workspace restore retains assignment owners but clears checkout birth certification; old targets require an audited migration. Worktree destination mappings are inactive and require explicit host approval; archived mapping evidence is in state/worktree-destinations.quarantined.json. Historical checkout grants remain revoked after reapproval.");
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
381
478
|
async function restoreBackup({ home, file, projects = true, safety = true, clearLive = true, replaceNewer = false, by = "alp", beforeSwap }) {
|
|
382
479
|
const interrupted = await recoverInterruptedRestore(home);
|
|
383
480
|
try {
|
|
@@ -392,6 +489,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
392
489
|
const warnings = [];
|
|
393
490
|
const areaBase = (area) => area === "home" ? home : header.projects[Number(area.slice(8))].root;
|
|
394
491
|
const directories = [];
|
|
492
|
+
let homeUnits;
|
|
395
493
|
const journal = { version: 1, id, file: path.resolve(file), by, startedAt: (/* @__PURE__ */ new Date()).toISOString(), phase: "staging", units: [], staging: [], created: [], locks: [] };
|
|
396
494
|
const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + "\n");
|
|
397
495
|
const abandon = async () => {
|
|
@@ -443,6 +541,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
443
541
|
};
|
|
444
542
|
}
|
|
445
543
|
});
|
|
544
|
+
homeUnits = await prepareWorkspaceRestore(home, staging.get("home"), header.units, warnings);
|
|
446
545
|
for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 511 | 448).catch(() => {
|
|
447
546
|
});
|
|
448
547
|
} catch (error) {
|
|
@@ -463,8 +562,8 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
463
562
|
const plan = [];
|
|
464
563
|
for (const [area, root] of staging) {
|
|
465
564
|
const base = areaBase(area);
|
|
466
|
-
const units = area === "home" ?
|
|
467
|
-
const known = area === "home" ? HOME_UNITS : PROJECT_UNITS;
|
|
565
|
+
const units = area === "home" ? homeUnits : header.projects[Number(area.slice(8))].units;
|
|
566
|
+
const known = area === "home" ? [...HOME_UNITS, QUARANTINED_DESTINATIONS] : PROJECT_UNITS;
|
|
468
567
|
for (const unit of units) {
|
|
469
568
|
if (!known.includes(unit.path)) {
|
|
470
569
|
warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`);
|
|
@@ -489,7 +588,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
489
588
|
let safetyFile;
|
|
490
589
|
if (safety) {
|
|
491
590
|
try {
|
|
492
|
-
safetyFile = await automaticBackup(home, "pre-restore", { units: header.units.map((unit) => unit.path).filter((unit) => HOME_UNITS.includes(unit)), projects: roots, by });
|
|
591
|
+
safetyFile = await automaticBackup(home, "pre-restore", { units: [.../* @__PURE__ */ new Set([...header.units.map((unit) => unit.path), ...homeUnits.map((unit) => unit.path)])].filter((unit) => HOME_UNITS.includes(unit) || unit === QUARANTINED_DESTINATIONS), projects: roots, by });
|
|
493
592
|
} catch (error) {
|
|
494
593
|
await abandon();
|
|
495
594
|
throw new AlpError("RESTORE_SAFETY_BACKUP_FAILED", `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as user_settings from "./user-settings.js";
|
|
|
29
29
|
import * as validation from "./validation.js";
|
|
30
30
|
import * as verify from "./verify.js";
|
|
31
31
|
import * as workflow from "./workflow.js";
|
|
32
|
+
import * as user_worktree from "./user-worktree.js";
|
|
32
33
|
export {
|
|
33
34
|
adapter,
|
|
34
35
|
atomic,
|
|
@@ -57,6 +58,7 @@ export {
|
|
|
57
58
|
trust,
|
|
58
59
|
upgrade,
|
|
59
60
|
user_settings,
|
|
61
|
+
user_worktree,
|
|
60
62
|
validation,
|
|
61
63
|
verify,
|
|
62
64
|
workflow
|
package/dist/migrations.js
CHANGED
|
@@ -78,7 +78,7 @@ async function migrateState(home, { by = "alp", migrations = MIGRATIONS, target
|
|
|
78
78
|
throw new AlpError("STATE_MIGRATION_ROLLBACK_FAILED", `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
|
|
79
79
|
}
|
|
80
80
|
await rm(migrationFile(home), { force: true });
|
|
81
|
-
throw new AlpError("STATE_MIGRATION_FAILED", `state migration ${step.name} (schema ${step.from} \u2192 ${step.to}) failed: ${error?.message ?? error};
|
|
81
|
+
throw new AlpError("STATE_MIGRATION_FAILED", `state migration ${step.name} (schema ${step.from} \u2192 ${step.to}) failed: ${error?.message ?? error}; data was restored from ${backup} with workspace authority checks. See docs/alp/data-recovery.md`, { cause: error });
|
|
82
82
|
}
|
|
83
83
|
await writeStateSchema(home, step.to, by);
|
|
84
84
|
await rm(migrationFile(home), { force: true });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function repositoryIdentity(directory: any): Promise<{
|
|
2
|
+
gitDir: string;
|
|
3
|
+
commonDir: string;
|
|
4
|
+
branch: string | null;
|
|
5
|
+
}>;
|
|
6
|
+
/** User workspace primitives. Assignment cleanup's force/rm semantics must never be used here. */
|
|
7
|
+
export function userWorktreeGit({ executable, timeoutMs, maxBytes }?: {
|
|
8
|
+
executable?: string | undefined;
|
|
9
|
+
timeoutMs?: number | undefined;
|
|
10
|
+
maxBytes?: number | undefined;
|
|
11
|
+
}): {
|
|
12
|
+
facts: (source: any) => Promise<{
|
|
13
|
+
commonDir: string;
|
|
14
|
+
gitDir: string;
|
|
15
|
+
head: any;
|
|
16
|
+
branch: any;
|
|
17
|
+
}>;
|
|
18
|
+
inspect: (source: any, target: any, branch: any) => Promise<any>;
|
|
19
|
+
assertClean: (target: any) => Promise<void>;
|
|
20
|
+
checkoutFacts(directory: any): Promise<{
|
|
21
|
+
isGit: true;
|
|
22
|
+
repoRoot: any;
|
|
23
|
+
currentBranch: any;
|
|
24
|
+
remoteUrl: any;
|
|
25
|
+
isDirty: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
create(source: any, parent: any, name: any, branch: any, head: any): Promise<string>;
|
|
28
|
+
remove(source: any, target: any, branch: any): Promise<void>;
|
|
29
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// packages/core/src/user-worktree.js
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { lstat, realpath, open } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
async function control(file) {
|
|
7
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
8
|
+
try {
|
|
9
|
+
const info = await handle.stat();
|
|
10
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > 4096) throw new Error("Invalid Git administrative file");
|
|
11
|
+
const bytes = Buffer.alloc(4097);
|
|
12
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
13
|
+
if (bytesRead > 4096) throw new Error("Git administrative file exceeds limit");
|
|
14
|
+
return bytes.subarray(0, bytesRead).toString("utf8").replace(/\r?\n$/, "");
|
|
15
|
+
} finally {
|
|
16
|
+
await handle.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function repositoryIdentity(directory) {
|
|
20
|
+
if (await realpath(directory) !== directory) throw new Error("Repository root is not canonical");
|
|
21
|
+
const entry = path.join(directory, ".git");
|
|
22
|
+
const info = await lstat(entry);
|
|
23
|
+
let gitDir;
|
|
24
|
+
if (info.isDirectory()) gitDir = await realpath(entry);
|
|
25
|
+
else {
|
|
26
|
+
const value = await control(entry);
|
|
27
|
+
if (!value.startsWith("gitdir: ") || /[\r\n\0]/.test(value)) throw new Error("Invalid Git control link");
|
|
28
|
+
gitDir = await realpath(path.resolve(directory, value.slice(8)));
|
|
29
|
+
}
|
|
30
|
+
const commonEntry = path.join(gitDir, "commondir");
|
|
31
|
+
const hasCommon = await lstat(commonEntry).catch((error) => {
|
|
32
|
+
if (error.code === "ENOENT") return void 0;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
const commonDir = hasCommon ? await realpath(path.resolve(gitDir, await control(commonEntry))) : gitDir;
|
|
36
|
+
if (gitDir !== commonDir) {
|
|
37
|
+
if (path.dirname(gitDir) !== path.join(commonDir, "worktrees") || path.resolve(gitDir, await control(path.join(gitDir, "gitdir"))) !== entry)
|
|
38
|
+
throw new Error("Git worktree backlink changed");
|
|
39
|
+
}
|
|
40
|
+
const head = await control(path.join(gitDir, "HEAD"));
|
|
41
|
+
const branch = head.startsWith("ref: refs/heads/") ? head.slice(16) : null;
|
|
42
|
+
if (branch !== null && (!branch || /[\r\n\0]/.test(branch))) throw new Error("Invalid branch reference");
|
|
43
|
+
return { gitDir, commonDir, branch };
|
|
44
|
+
}
|
|
45
|
+
function userWorktreeGit({ executable = "/usr/bin/git", timeoutMs = 15e3, maxBytes = 2 * 1024 * 1024 } = {}) {
|
|
46
|
+
if (process.platform === "win32") throw new Error("User worktree operations are unavailable on this host");
|
|
47
|
+
if (!path.isAbsolute(executable)) throw new Error("Git executable must be host configured and absolute");
|
|
48
|
+
const disabled = [
|
|
49
|
+
"-c",
|
|
50
|
+
"core.hooksPath=/dev/null",
|
|
51
|
+
"-c",
|
|
52
|
+
"core.fsmonitor=false",
|
|
53
|
+
"-c",
|
|
54
|
+
"core.untrackedCache=false",
|
|
55
|
+
"-c",
|
|
56
|
+
"core.pager=cat",
|
|
57
|
+
"-c",
|
|
58
|
+
"maintenance.auto=false",
|
|
59
|
+
"-c",
|
|
60
|
+
"gc.auto=0",
|
|
61
|
+
"-c",
|
|
62
|
+
"protocol.allow=never",
|
|
63
|
+
"-c",
|
|
64
|
+
"submodule.recurse=false"
|
|
65
|
+
];
|
|
66
|
+
const env = {
|
|
67
|
+
PATH: "/usr/bin:/bin",
|
|
68
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
69
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
70
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
71
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
72
|
+
LANG: "C",
|
|
73
|
+
LC_ALL: "C"
|
|
74
|
+
};
|
|
75
|
+
async function raw(cwd, args, extra = []) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const child = spawn(executable, [...disabled, ...extra, ...args], {
|
|
78
|
+
cwd,
|
|
79
|
+
env,
|
|
80
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
detached: true
|
|
83
|
+
});
|
|
84
|
+
let size = 0;
|
|
85
|
+
let overflow = false;
|
|
86
|
+
const stdout = [];
|
|
87
|
+
const stderr = [];
|
|
88
|
+
const kill = () => {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(-child.pid, "SIGKILL");
|
|
91
|
+
} catch {
|
|
92
|
+
child.kill("SIGKILL");
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
overflow = true;
|
|
97
|
+
kill();
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
const collect = (sink) => (chunk) => {
|
|
100
|
+
size += chunk.length;
|
|
101
|
+
if (size > maxBytes) {
|
|
102
|
+
overflow = true;
|
|
103
|
+
kill();
|
|
104
|
+
} else sink.push(chunk);
|
|
105
|
+
};
|
|
106
|
+
child.stdout.on("data", collect(stdout));
|
|
107
|
+
child.stderr.on("data", collect(stderr));
|
|
108
|
+
child.once("error", (error) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
reject(error);
|
|
111
|
+
});
|
|
112
|
+
child.once("close", (code) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (overflow) reject(new Error("Git operation exceeded its host limit"));
|
|
115
|
+
else
|
|
116
|
+
resolve({
|
|
117
|
+
code,
|
|
118
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
119
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function filterOverrides(cwd) {
|
|
125
|
+
const filters = await raw(cwd, [
|
|
126
|
+
"config",
|
|
127
|
+
"--null",
|
|
128
|
+
"--name-only",
|
|
129
|
+
"--get-regexp",
|
|
130
|
+
"^filter\\..*\\.(smudge|clean|process|required)$"
|
|
131
|
+
]);
|
|
132
|
+
if (filters.code !== 0 && filters.code !== 1) throw new Error("Cannot inspect repository filters");
|
|
133
|
+
const neutral = filters.stdout.split("\0").filter(Boolean).flatMap((key) => ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`]);
|
|
134
|
+
return neutral;
|
|
135
|
+
}
|
|
136
|
+
async function run(cwd, args, extra = []) {
|
|
137
|
+
return raw(cwd, args, [...await filterOverrides(cwd), ...extra]);
|
|
138
|
+
}
|
|
139
|
+
async function checked(cwd, args, extra = []) {
|
|
140
|
+
const result = await run(cwd, args, extra);
|
|
141
|
+
if (result.code !== 0) throw new Error("Git workspace operation refused");
|
|
142
|
+
return result.stdout.trim();
|
|
143
|
+
}
|
|
144
|
+
async function directory(value) {
|
|
145
|
+
const canonical = await realpath(value);
|
|
146
|
+
if (canonical !== value || !(await lstat(value)).isDirectory())
|
|
147
|
+
throw new Error("Workspace paths must be canonical directories");
|
|
148
|
+
return canonical;
|
|
149
|
+
}
|
|
150
|
+
async function facts(source) {
|
|
151
|
+
await directory(source);
|
|
152
|
+
const top = await checked(source, ["rev-parse", "--show-toplevel"]);
|
|
153
|
+
if (await realpath(top) !== source) throw new Error("Workspace must be a repository top level");
|
|
154
|
+
const commonDir = await realpath(
|
|
155
|
+
await checked(source, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
|
156
|
+
);
|
|
157
|
+
const identity = await repositoryIdentity(source);
|
|
158
|
+
if (identity.commonDir !== commonDir) throw new Error("Repository identity changed");
|
|
159
|
+
const head = await checked(source, ["rev-parse", "--verify", "HEAD"]);
|
|
160
|
+
const branch = await run(source, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
161
|
+
return { commonDir, gitDir: identity.gitDir, head, branch: branch.code === 0 ? branch.stdout.trim() : null };
|
|
162
|
+
}
|
|
163
|
+
async function list(source) {
|
|
164
|
+
const result = await checked(source, ["worktree", "list", "--porcelain", "-z"]);
|
|
165
|
+
return result.split("\0\0").filter(Boolean).map(
|
|
166
|
+
(block) => Object.fromEntries(
|
|
167
|
+
block.split("\0").filter(Boolean).map((line) => {
|
|
168
|
+
const i = line.indexOf(" ");
|
|
169
|
+
return i < 0 ? [line, true] : [line.slice(0, i), line.slice(i + 1)];
|
|
170
|
+
})
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
async function inspect(source, target, branch) {
|
|
175
|
+
const row = (await list(source)).find((row2) => row2.worktree === target);
|
|
176
|
+
if (!row) return void 0;
|
|
177
|
+
await directory(target);
|
|
178
|
+
if (row.branch !== `refs/heads/${branch}` || row.locked || row.prunable)
|
|
179
|
+
throw new Error("Managed worktree identity changed");
|
|
180
|
+
const origin = await facts(source);
|
|
181
|
+
const destination = await facts(target);
|
|
182
|
+
if (origin.commonDir !== destination.commonDir) throw new Error("Managed worktree repository changed");
|
|
183
|
+
return row;
|
|
184
|
+
}
|
|
185
|
+
async function assertClean(target) {
|
|
186
|
+
const flags = await checked(target, ["ls-files", "-v", "-z"]);
|
|
187
|
+
if (flags.split("\0").some((row) => row && (row[0] === "S" || /[a-z]/.test(row[0]))))
|
|
188
|
+
throw new Error("Worktree has hidden index state");
|
|
189
|
+
if ((await checked(target, ["ls-files", "--stage", "-z"])).split("\0").some((row) => row.startsWith("160000 ")))
|
|
190
|
+
throw new Error("Worktree has submodules");
|
|
191
|
+
if ((await checked(target, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"])).length)
|
|
192
|
+
throw new Error("Worktree contains uncommitted or ignored files");
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
facts,
|
|
196
|
+
inspect,
|
|
197
|
+
assertClean,
|
|
198
|
+
async checkoutFacts(directory2) {
|
|
199
|
+
const canonical = await realpath(directory2);
|
|
200
|
+
const repoRoot = await checked(canonical, ["rev-parse", "--show-toplevel"]);
|
|
201
|
+
const branch = await run(canonical, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
202
|
+
const remote = await run(canonical, ["config", "--get", "remote.origin.url"]);
|
|
203
|
+
const status = await checked(canonical, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
204
|
+
return {
|
|
205
|
+
isGit: (
|
|
206
|
+
/** @type {const} */
|
|
207
|
+
true
|
|
208
|
+
),
|
|
209
|
+
repoRoot,
|
|
210
|
+
currentBranch: branch.code === 0 ? branch.stdout.trim() : null,
|
|
211
|
+
remoteUrl: remote.code === 0 ? remote.stdout.trim() || null : null,
|
|
212
|
+
isDirty: !!status
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
async create(source, parent, name, branch, head) {
|
|
216
|
+
await directory(parent);
|
|
217
|
+
if (!/^wks_[0-9a-f]{16}$/.test(name) || branch !== `alp/workspace/${name}` || !/^[0-9a-f]{40,64}$/.test(head))
|
|
218
|
+
throw new Error("Invalid generated worktree identity");
|
|
219
|
+
const target = path.join(parent, name);
|
|
220
|
+
if (await lstat(target).catch((error) => {
|
|
221
|
+
if (error.code === "ENOENT") return void 0;
|
|
222
|
+
throw error;
|
|
223
|
+
}))
|
|
224
|
+
throw new Error("Worktree destination already exists");
|
|
225
|
+
await checked(source, ["worktree", "add", "--quiet", "--no-checkout", "-b", branch, target, head]);
|
|
226
|
+
await checked(target, ["reset", "--hard", "--no-recurse-submodules", head]);
|
|
227
|
+
if (!await inspect(source, target, branch)) throw new Error("Worktree creation needs reconciliation");
|
|
228
|
+
return target;
|
|
229
|
+
},
|
|
230
|
+
async remove(source, target, branch) {
|
|
231
|
+
if (!await inspect(source, target, branch)) throw new Error("Managed worktree is missing");
|
|
232
|
+
await assertClean(target);
|
|
233
|
+
await checked(source, ["worktree", "remove", target], await filterOverrides(target));
|
|
234
|
+
if ((await list(source)).some((row) => row.worktree === target))
|
|
235
|
+
throw new Error("Worktree removal needs reconciliation");
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
repositoryIdentity,
|
|
241
|
+
userWorktreeGit
|
|
242
|
+
};
|
package/dist/validation.js
CHANGED
|
@@ -147,8 +147,9 @@ function validateUserSettings(settings, source) {
|
|
|
147
147
|
}
|
|
148
148
|
if (settings.web !== void 0) {
|
|
149
149
|
check(object(settings.web), "web must be an object");
|
|
150
|
-
const extra = Object.keys(settings.web).find((field) => !["enabled", "port", "relay"].includes(field));
|
|
151
|
-
check(!extra, `unsupported web field '${extra}'; use enabled, port or
|
|
150
|
+
const extra = Object.keys(settings.web).find((field) => !["enabled", "port", "relay", "permissions"].includes(field));
|
|
151
|
+
check(!extra, `unsupported web field '${extra}'; use enabled, port, relay or permissions`);
|
|
152
|
+
if (settings.web.permissions !== void 0) check(Array.isArray(settings.web.permissions) && settings.web.permissions.every((entry) => typeof entry === "string" && /^[a-z][a-z.]*$/.test(entry)), "web.permissions must be a list of permission names, such as files.read");
|
|
152
153
|
const relay = settings.web.relay;
|
|
153
154
|
if (relay !== void 0) {
|
|
154
155
|
check(object(relay), "web.relay must be an object");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anhlp/alp-core",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ALP core module",
|
|
6
6
|
"files": [
|
|
@@ -284,7 +284,7 @@
|
|
|
284
284
|
"node": ">=22.12"
|
|
285
285
|
},
|
|
286
286
|
"dependencies": {
|
|
287
|
-
"@anhlp/alp-protocol": "0.0.1-beta.
|
|
287
|
+
"@anhlp/alp-protocol": "0.0.1-beta.4"
|
|
288
288
|
},
|
|
289
289
|
"scripts": {
|
|
290
290
|
"build": "node ../../scripts/build-workspace.mjs core",
|
package/src/backup.js
CHANGED
|
@@ -34,7 +34,12 @@ export const BACKUP_FORMAT = 1;
|
|
|
34
34
|
export const BACKUP_EXTENSION = '.alpbackup';
|
|
35
35
|
|
|
36
36
|
/** alpd's state: sessions, their timelines and receipts, and the runtime's durable state. */
|
|
37
|
-
|
|
37
|
+
const RESULT_LEDGER = 'state/assignment-results.json';
|
|
38
|
+
const RESULT_MARKER = `${RESULT_LEDGER}.initialized`;
|
|
39
|
+
const DESTINATIONS = 'worktree-destinations.json';
|
|
40
|
+
// Evidence only: the daemon never loads mapping authority from this path.
|
|
41
|
+
const QUARANTINED_DESTINATIONS = 'state/worktree-destinations.quarantined.json';
|
|
42
|
+
export const STATE_UNITS = [RESULT_LEDGER, RESULT_MARKER, 'state/schema.json', 'state/sessions', 'state/timeline', 'state/receipts', 'state/recall.json', 'state/pause.json', 'state/trust.json', 'state/web-workspaces.json'];
|
|
38
43
|
/**
|
|
39
44
|
* Live work (running assignments alpd continues after a restart) and the journal of an
|
|
40
45
|
* unfinished migration are never backed up: a restore of the state removes them, so restored
|
|
@@ -43,7 +48,7 @@ export const STATE_UNITS = ['state/schema.json', 'state/sessions', 'state/timeli
|
|
|
43
48
|
export const CLEARED_UNITS = ['state/live.json', 'state/migration.json'];
|
|
44
49
|
/** Everything a backup of $ALP_HOME holds: the state, the user's settings and library, boards and assignment logs. */
|
|
45
50
|
export const HOME_UNITS = [
|
|
46
|
-
...STATE_UNITS,
|
|
51
|
+
...STATE_UNITS, DESTINATIONS,
|
|
47
52
|
'settings.json', 'library.json', 'role-skills.json', 'lessons.md',
|
|
48
53
|
'agents', 'skills', 'teams', 'hooks', 'mcp', 'providers', 'formulas',
|
|
49
54
|
'boards', 'runs',
|
|
@@ -396,6 +401,94 @@ async function holdTaskLocks(roots, work) {
|
|
|
396
401
|
finally { clearInterval(touch); }
|
|
397
402
|
}
|
|
398
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Restore ownership evidence, never checkout birth certification or host authority.
|
|
406
|
+
* Paths in an archive cannot establish the identity of an old or moved checkout. Keep
|
|
407
|
+
* every retained owner (including work after the snapshot), but require audited adoption
|
|
408
|
+
* for old targets. The host must explicitly approve mappings again; archived bytes are
|
|
409
|
+
* kept privately as evidence only. All replacements join the ordinary rollback journal.
|
|
410
|
+
*/
|
|
411
|
+
async function prepareWorkspaceRestore(home, root, units, warnings) {
|
|
412
|
+
const relevant = [RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, 'state/sessions', 'state/web-workspaces.json'];
|
|
413
|
+
// Only the transform may stage quarantine; archive-declared copies are never activated.
|
|
414
|
+
const restorable = units.filter(unit => unit.path !== QUARANTINED_DESTINATIONS);
|
|
415
|
+
if (!units.some(unit => relevant.includes(unit.path))) return restorable;
|
|
416
|
+
for (const base of [home, root]) {
|
|
417
|
+
const info = await lstat(path.join(base, 'state')).catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
|
|
418
|
+
if (info && !info.isDirectory()) throw invalid('workspace state must be a real directory');
|
|
419
|
+
}
|
|
420
|
+
const result = restorable.filter(unit => ![RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, 'state/web-workspaces.json'].includes(unit.path));
|
|
421
|
+
async function regular(base, unit, declared) {
|
|
422
|
+
if (declared && declared.kind === 'absent') return undefined;
|
|
423
|
+
const file = path.join(base, unit);
|
|
424
|
+
const info = await lstat(file).catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
|
|
425
|
+
if (!info) {
|
|
426
|
+
if (declared) throw invalid(`${unit} is declared but missing from the backup`);
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
if (!info.isFile()) throw invalid(`${unit} must be a regular file for a workspace restore`);
|
|
430
|
+
return readFile(file, 'utf8');
|
|
431
|
+
}
|
|
432
|
+
const incoming = async unit => {
|
|
433
|
+
const declared = units.find(entry => entry.path === unit);
|
|
434
|
+
return declared ? regular(root, unit, declared) : undefined;
|
|
435
|
+
};
|
|
436
|
+
const owners = new Map();
|
|
437
|
+
let hasLedger = false;
|
|
438
|
+
for (const text of [await incoming(RESULT_LEDGER), await regular(home, RESULT_LEDGER)]) {
|
|
439
|
+
if (text === undefined) continue;
|
|
440
|
+
let value;
|
|
441
|
+
try { value = JSON.parse(text); } catch { throw invalid('assignment result ledger is not readable JSON; repair it before restoring'); }
|
|
442
|
+
const absolute = value => typeof value === 'string' && path.isAbsolute(value);
|
|
443
|
+
if (value?.version !== 1 || !Array.isArray(value.workspaces) || !value.workspaces.every(absolute) ||
|
|
444
|
+
!Array.isArray(value.results) || !value.results.every(entry => entry && typeof entry.assignmentId === 'string' &&
|
|
445
|
+
absolute(entry.checkout) && (entry.worktree === undefined || absolute(entry.worktree))))
|
|
446
|
+
throw invalid('assignment result ledger has an unsupported or invalid shape');
|
|
447
|
+
hasLedger = true;
|
|
448
|
+
for (const entry of value.results) {
|
|
449
|
+
const owner = { assignmentId: entry.assignmentId, checkout: entry.checkout, ...(entry.worktree === undefined ? {} : { worktree: entry.worktree }) };
|
|
450
|
+
const previous = owners.get(owner.assignmentId);
|
|
451
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(owner)) throw invalid('assignment result ownership conflicts with current data; reconcile it before restoring');
|
|
452
|
+
owners.set(owner.assignmentId, owner);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const markers = [await incoming(RESULT_MARKER), await regular(home, RESULT_MARKER)];
|
|
456
|
+
if (markers.some(text => text !== undefined && text !== '1\n')) throw invalid('assignment result initialized marker is invalid');
|
|
457
|
+
await mkdir(path.join(root, 'state'), { recursive: true, mode: 0o700 });
|
|
458
|
+
if (hasLedger) await writeAtomic(path.join(root, RESULT_LEDGER), JSON.stringify({ version: 1, workspaces: [], results: [...owners.values()] }) + '\n');
|
|
459
|
+
result.push({ path: RESULT_LEDGER, kind: hasLedger ? 'file' : 'absent' });
|
|
460
|
+
const initialized = markers.some(text => text !== undefined);
|
|
461
|
+
if (initialized) await writeAtomic(path.join(root, RESULT_MARKER), '1\n');
|
|
462
|
+
result.push({ path: RESULT_MARKER, kind: initialized ? 'file' : 'absent' });
|
|
463
|
+
const mappings = await incoming(DESTINATIONS);
|
|
464
|
+
if (mappings !== undefined) {
|
|
465
|
+
await writeAtomic(path.join(root, QUARANTINED_DESTINATIONS), mappings);
|
|
466
|
+
result.push({ path: QUARANTINED_DESTINATIONS, kind: 'file' });
|
|
467
|
+
}
|
|
468
|
+
result.push({ path: DESTINATIONS, kind: 'absent' });
|
|
469
|
+
const registryUnit = 'state/web-workspaces.json';
|
|
470
|
+
const registryDeclaration = units.find(unit => unit.path === registryUnit);
|
|
471
|
+
const registryText = registryDeclaration ? await incoming(registryUnit) : await regular(home, registryUnit);
|
|
472
|
+
if (registryText !== undefined) {
|
|
473
|
+
let registry;
|
|
474
|
+
try { registry = JSON.parse(registryText); } catch { throw invalid('workspace registry is not readable JSON; repair it before restoring'); }
|
|
475
|
+
// Reapproval of the same mapping ID must not reactivate historical token grants.
|
|
476
|
+
// Rotate epochs in rows, pending native intents and cached receipt results alike.
|
|
477
|
+
const epoch = `restore-${randomBytes(16).toString('hex')}`;
|
|
478
|
+
let revoked = false;
|
|
479
|
+
const revoke = value => {
|
|
480
|
+
if (!value || typeof value !== 'object') return;
|
|
481
|
+
if (Object.hasOwn(value, 'grantEpoch')) { value.grantEpoch = epoch; revoked = true; }
|
|
482
|
+
for (const child of Object.values(value)) revoke(child);
|
|
483
|
+
};
|
|
484
|
+
revoke(registry);
|
|
485
|
+
await writeAtomic(path.join(root, registryUnit), revoked ? JSON.stringify(registry) + '\n' : registryText);
|
|
486
|
+
result.push({ path: registryUnit, kind: 'file' });
|
|
487
|
+
} else if (registryDeclaration) result.push({ path: registryUnit, kind: 'absent' });
|
|
488
|
+
if (hasLedger || initialized || mappings !== undefined || await exists(path.join(home, DESTINATIONS))) warnings.push('Workspace restore retains assignment owners but clears checkout birth certification; old targets require an audited migration. Worktree destination mappings are inactive and require explicit host approval; archived mapping evidence is in state/worktree-destinations.quarantined.json. Historical checkout grants remain revoked after reapproval.');
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
399
492
|
/**
|
|
400
493
|
* Restores a backup over `home` and, with `projects`, over the projects it holds whose
|
|
401
494
|
* roots exist. The current data is checked first: a home in a state schema newer than
|
|
@@ -419,6 +512,7 @@ export async function restoreBackup({ home, file, projects = true, safety = true
|
|
|
419
512
|
const warnings = [];
|
|
420
513
|
const areaBase = area => area === 'home' ? home : header.projects[Number(area.slice(8))].root;
|
|
421
514
|
const directories = [];
|
|
515
|
+
let homeUnits;
|
|
422
516
|
// The journal exists from the first staged file on, so a crash at any point leaves nothing behind.
|
|
423
517
|
const journal = { version: 1, id, file: path.resolve(file), by, startedAt: new Date().toISOString(), phase: 'staging', units: [], staging: [], created: [], locks: [] };
|
|
424
518
|
const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + '\n');
|
|
@@ -452,6 +546,7 @@ export async function restoreBackup({ home, file, projects = true, safety = true
|
|
|
452
546
|
};
|
|
453
547
|
},
|
|
454
548
|
});
|
|
549
|
+
homeUnits = await prepareWorkspaceRestore(home, staging.get('home'), header.units, warnings);
|
|
455
550
|
// Directory modes last, deepest first, once nothing more is written into them.
|
|
456
551
|
for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 0o777 | 0o700).catch(() => {});
|
|
457
552
|
} catch (error) {
|
|
@@ -472,8 +567,8 @@ export async function restoreBackup({ home, file, projects = true, safety = true
|
|
|
472
567
|
const plan = [];
|
|
473
568
|
for (const [area, root] of staging) {
|
|
474
569
|
const base = areaBase(area);
|
|
475
|
-
const units = area === 'home' ?
|
|
476
|
-
const known = area === 'home' ? HOME_UNITS : PROJECT_UNITS;
|
|
570
|
+
const units = area === 'home' ? homeUnits : header.projects[Number(area.slice(8))].units;
|
|
571
|
+
const known = area === 'home' ? [...HOME_UNITS, QUARANTINED_DESTINATIONS] : PROJECT_UNITS;
|
|
477
572
|
for (const unit of units) {
|
|
478
573
|
if (!known.includes(unit.path)) { warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`); continue; }
|
|
479
574
|
const target = path.join(base, ...unit.path.split('/'));
|
|
@@ -497,7 +592,7 @@ export async function restoreBackup({ home, file, projects = true, safety = true
|
|
|
497
592
|
let safetyFile;
|
|
498
593
|
if (safety) {
|
|
499
594
|
try {
|
|
500
|
-
safetyFile = await automaticBackup(home, 'pre-restore', { units: header.units.map(unit => unit.path).filter(unit => HOME_UNITS.includes(unit)), projects: roots, by });
|
|
595
|
+
safetyFile = await automaticBackup(home, 'pre-restore', { units: [...new Set([...header.units.map(unit => unit.path), ...homeUnits.map(unit => unit.path)])].filter(unit => HOME_UNITS.includes(unit) || unit === QUARANTINED_DESTINATIONS), projects: roots, by });
|
|
501
596
|
} catch (error) {
|
|
502
597
|
await abandon();
|
|
503
598
|
throw new AlpError('RESTORE_SAFETY_BACKUP_FAILED', `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
|
package/src/index.js
CHANGED
package/src/migrations.js
CHANGED
|
@@ -98,7 +98,7 @@ export async function migrateState(home, { by = 'alp', migrations = MIGRATIONS,
|
|
|
98
98
|
throw new AlpError('STATE_MIGRATION_ROLLBACK_FAILED', `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
|
|
99
99
|
}
|
|
100
100
|
await rm(migrationFile(home), { force: true });
|
|
101
|
-
throw new AlpError('STATE_MIGRATION_FAILED', `state migration ${step.name} (schema ${step.from} → ${step.to}) failed: ${error?.message ?? error};
|
|
101
|
+
throw new AlpError('STATE_MIGRATION_FAILED', `state migration ${step.name} (schema ${step.from} → ${step.to}) failed: ${error?.message ?? error}; data was restored from ${backup} with workspace authority checks. See docs/alp/data-recovery.md`, { cause: error });
|
|
102
102
|
}
|
|
103
103
|
await writeStateSchema(home, step.to, by);
|
|
104
104
|
await rm(migrationFile(home), { force: true });
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lstat, realpath, open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
/** Bounded, no-follow administrative reads; no Git process is needed to re-check a grant. */
|
|
7
|
+
async function control(file) {
|
|
8
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
9
|
+
try {
|
|
10
|
+
const info = await handle.stat();
|
|
11
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > 4096) throw new Error('Invalid Git administrative file');
|
|
12
|
+
const bytes = Buffer.alloc(4097);
|
|
13
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
14
|
+
if (bytesRead > 4096) throw new Error('Git administrative file exceeds limit');
|
|
15
|
+
return bytes
|
|
16
|
+
.subarray(0, bytesRead)
|
|
17
|
+
.toString('utf8')
|
|
18
|
+
.replace(/\r?\n$/, '');
|
|
19
|
+
} finally {
|
|
20
|
+
await handle.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function repositoryIdentity(directory) {
|
|
24
|
+
if ((await realpath(directory)) !== directory) throw new Error('Repository root is not canonical');
|
|
25
|
+
const entry = path.join(directory, '.git');
|
|
26
|
+
const info = await lstat(entry);
|
|
27
|
+
let gitDir;
|
|
28
|
+
if (info.isDirectory()) gitDir = await realpath(entry);
|
|
29
|
+
else {
|
|
30
|
+
const value = await control(entry);
|
|
31
|
+
if (!value.startsWith('gitdir: ') || /[\r\n\0]/.test(value)) throw new Error('Invalid Git control link');
|
|
32
|
+
gitDir = await realpath(path.resolve(directory, value.slice(8)));
|
|
33
|
+
}
|
|
34
|
+
const commonEntry = path.join(gitDir, 'commondir');
|
|
35
|
+
const hasCommon = await lstat(commonEntry).catch((error) => {
|
|
36
|
+
if (error.code === 'ENOENT') return undefined;
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
const commonDir = hasCommon ? await realpath(path.resolve(gitDir, await control(commonEntry))) : gitDir;
|
|
40
|
+
if (gitDir !== commonDir) {
|
|
41
|
+
if (
|
|
42
|
+
path.dirname(gitDir) !== path.join(commonDir, 'worktrees') ||
|
|
43
|
+
path.resolve(gitDir, await control(path.join(gitDir, 'gitdir'))) !== entry
|
|
44
|
+
)
|
|
45
|
+
throw new Error('Git worktree backlink changed');
|
|
46
|
+
}
|
|
47
|
+
const head = await control(path.join(gitDir, 'HEAD'));
|
|
48
|
+
const branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : null;
|
|
49
|
+
if (branch !== null && (!branch || /[\r\n\0]/.test(branch))) throw new Error('Invalid branch reference');
|
|
50
|
+
return { gitDir, commonDir, branch };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** User workspace primitives. Assignment cleanup's force/rm semantics must never be used here. */
|
|
54
|
+
export function userWorktreeGit({ executable = '/usr/bin/git', timeoutMs = 15_000, maxBytes = 2 * 1024 * 1024 } = {}) {
|
|
55
|
+
if (process.platform === 'win32') throw new Error('User worktree operations are unavailable on this host');
|
|
56
|
+
if (!path.isAbsolute(executable)) throw new Error('Git executable must be host configured and absolute');
|
|
57
|
+
const disabled = [
|
|
58
|
+
'-c',
|
|
59
|
+
'core.hooksPath=/dev/null',
|
|
60
|
+
'-c',
|
|
61
|
+
'core.fsmonitor=false',
|
|
62
|
+
'-c',
|
|
63
|
+
'core.untrackedCache=false',
|
|
64
|
+
'-c',
|
|
65
|
+
'core.pager=cat',
|
|
66
|
+
'-c',
|
|
67
|
+
'maintenance.auto=false',
|
|
68
|
+
'-c',
|
|
69
|
+
'gc.auto=0',
|
|
70
|
+
'-c',
|
|
71
|
+
'protocol.allow=never',
|
|
72
|
+
'-c',
|
|
73
|
+
'submodule.recurse=false'
|
|
74
|
+
];
|
|
75
|
+
// No inherited Git context, PATH or user/system config. Repository config is data, never a program.
|
|
76
|
+
const env = {
|
|
77
|
+
PATH: '/usr/bin:/bin',
|
|
78
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
79
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
80
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
81
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
82
|
+
LANG: 'C',
|
|
83
|
+
LC_ALL: 'C'
|
|
84
|
+
};
|
|
85
|
+
async function raw(cwd, args, extra = []) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const child = spawn(executable, [...disabled, ...extra, ...args], {
|
|
88
|
+
cwd,
|
|
89
|
+
env,
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
windowsHide: true,
|
|
92
|
+
detached: true
|
|
93
|
+
});
|
|
94
|
+
let size = 0;
|
|
95
|
+
let overflow = false;
|
|
96
|
+
const stdout = [];
|
|
97
|
+
const stderr = [];
|
|
98
|
+
const kill = () => {
|
|
99
|
+
try {
|
|
100
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
101
|
+
} catch {
|
|
102
|
+
child.kill('SIGKILL');
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
overflow = true;
|
|
107
|
+
kill();
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
const collect = (sink) => (chunk) => {
|
|
110
|
+
size += chunk.length;
|
|
111
|
+
if (size > maxBytes) {
|
|
112
|
+
overflow = true;
|
|
113
|
+
kill();
|
|
114
|
+
} else sink.push(chunk);
|
|
115
|
+
};
|
|
116
|
+
child.stdout.on('data', collect(stdout));
|
|
117
|
+
child.stderr.on('data', collect(stderr));
|
|
118
|
+
child.once('error', (error) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
reject(error);
|
|
121
|
+
});
|
|
122
|
+
child.once('close', (code) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (overflow) reject(new Error('Git operation exceeded its host limit'));
|
|
125
|
+
else
|
|
126
|
+
resolve({
|
|
127
|
+
code,
|
|
128
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
129
|
+
stderr: Buffer.concat(stderr).toString('utf8')
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function filterOverrides(cwd) {
|
|
135
|
+
// Status/removal may run clean/process filters as well as creation's smudge filter.
|
|
136
|
+
const filters = await raw(cwd, [
|
|
137
|
+
'config',
|
|
138
|
+
'--null',
|
|
139
|
+
'--name-only',
|
|
140
|
+
'--get-regexp',
|
|
141
|
+
'^filter\\..*\\.(smudge|clean|process|required)$'
|
|
142
|
+
]);
|
|
143
|
+
if (filters.code !== 0 && filters.code !== 1) throw new Error('Cannot inspect repository filters');
|
|
144
|
+
const neutral = filters.stdout
|
|
145
|
+
.split('\0')
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.flatMap((key) => ['-c', `${key}=${key.endsWith('.required') ? 'false' : ''}`]);
|
|
148
|
+
return neutral;
|
|
149
|
+
}
|
|
150
|
+
async function run(cwd, args, extra = []) {
|
|
151
|
+
return raw(cwd, args, [...(await filterOverrides(cwd)), ...extra]);
|
|
152
|
+
}
|
|
153
|
+
async function checked(cwd, args, extra = []) {
|
|
154
|
+
const result = await run(cwd, args, extra);
|
|
155
|
+
if (result.code !== 0) throw new Error('Git workspace operation refused');
|
|
156
|
+
return result.stdout.trim();
|
|
157
|
+
}
|
|
158
|
+
async function directory(value) {
|
|
159
|
+
const canonical = await realpath(value);
|
|
160
|
+
if (canonical !== value || !(await lstat(value)).isDirectory())
|
|
161
|
+
throw new Error('Workspace paths must be canonical directories');
|
|
162
|
+
return canonical;
|
|
163
|
+
}
|
|
164
|
+
async function facts(source) {
|
|
165
|
+
await directory(source);
|
|
166
|
+
const top = await checked(source, ['rev-parse', '--show-toplevel']);
|
|
167
|
+
if ((await realpath(top)) !== source) throw new Error('Workspace must be a repository top level');
|
|
168
|
+
const commonDir = await realpath(
|
|
169
|
+
await checked(source, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
170
|
+
);
|
|
171
|
+
const identity = await repositoryIdentity(source);
|
|
172
|
+
if (identity.commonDir !== commonDir) throw new Error('Repository identity changed');
|
|
173
|
+
const head = await checked(source, ['rev-parse', '--verify', 'HEAD']);
|
|
174
|
+
const branch = await run(source, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
175
|
+
return { commonDir, gitDir: identity.gitDir, head, branch: branch.code === 0 ? branch.stdout.trim() : null };
|
|
176
|
+
}
|
|
177
|
+
async function list(source) {
|
|
178
|
+
const result = await checked(source, ['worktree', 'list', '--porcelain', '-z']);
|
|
179
|
+
return result
|
|
180
|
+
.split('\0\0')
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.map((block) =>
|
|
183
|
+
Object.fromEntries(
|
|
184
|
+
block
|
|
185
|
+
.split('\0')
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.map((line) => {
|
|
188
|
+
const i = line.indexOf(' ');
|
|
189
|
+
return i < 0 ? [line, true] : [line.slice(0, i), line.slice(i + 1)];
|
|
190
|
+
})
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
async function inspect(source, target, branch) {
|
|
195
|
+
const row = (await list(source)).find((row) => row.worktree === target);
|
|
196
|
+
if (!row) return undefined;
|
|
197
|
+
await directory(target);
|
|
198
|
+
if (row.branch !== `refs/heads/${branch}` || row.locked || row.prunable)
|
|
199
|
+
throw new Error('Managed worktree identity changed');
|
|
200
|
+
const origin = await facts(source);
|
|
201
|
+
const destination = await facts(target);
|
|
202
|
+
if (origin.commonDir !== destination.commonDir) throw new Error('Managed worktree repository changed');
|
|
203
|
+
return row;
|
|
204
|
+
}
|
|
205
|
+
async function assertClean(target) {
|
|
206
|
+
const flags = await checked(target, ['ls-files', '-v', '-z']);
|
|
207
|
+
if (flags.split('\0').some((row) => row && (row[0] === 'S' || /[a-z]/.test(row[0]))))
|
|
208
|
+
throw new Error('Worktree has hidden index state');
|
|
209
|
+
if ((await checked(target, ['ls-files', '--stage', '-z'])).split('\0').some((row) => row.startsWith('160000 ')))
|
|
210
|
+
throw new Error('Worktree has submodules');
|
|
211
|
+
// Ignored data is user data too. Never force a delete or fall back to recursive rm.
|
|
212
|
+
if ((await checked(target, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored'])).length)
|
|
213
|
+
throw new Error('Worktree contains uncommitted or ignored files');
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
facts,
|
|
217
|
+
inspect,
|
|
218
|
+
assertClean,
|
|
219
|
+
async checkoutFacts(directory) {
|
|
220
|
+
const canonical = await realpath(directory);
|
|
221
|
+
const repoRoot = await checked(canonical, ['rev-parse', '--show-toplevel']);
|
|
222
|
+
const branch = await run(canonical, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
223
|
+
const remote = await run(canonical, ['config', '--get', 'remote.origin.url']);
|
|
224
|
+
const status = await checked(canonical, ['status', '--porcelain=v1', '--untracked-files=all']);
|
|
225
|
+
return {
|
|
226
|
+
isGit: /** @type {const} */ (true),
|
|
227
|
+
repoRoot,
|
|
228
|
+
currentBranch: branch.code === 0 ? branch.stdout.trim() : null,
|
|
229
|
+
remoteUrl: remote.code === 0 ? remote.stdout.trim() || null : null,
|
|
230
|
+
isDirty: !!status
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
async create(source, parent, name, branch, head) {
|
|
234
|
+
await directory(parent);
|
|
235
|
+
if (!/^wks_[0-9a-f]{16}$/.test(name) || branch !== `alp/workspace/${name}` || !/^[0-9a-f]{40,64}$/.test(head))
|
|
236
|
+
throw new Error('Invalid generated worktree identity');
|
|
237
|
+
const target = path.join(parent, name);
|
|
238
|
+
if (
|
|
239
|
+
await lstat(target).catch((error) => {
|
|
240
|
+
if (error.code === 'ENOENT') return undefined;
|
|
241
|
+
throw error;
|
|
242
|
+
})
|
|
243
|
+
)
|
|
244
|
+
throw new Error('Worktree destination already exists');
|
|
245
|
+
await checked(source, ['worktree', 'add', '--quiet', '--no-checkout', '-b', branch, target, head]);
|
|
246
|
+
// Conditional gitdir/onbranch config is resolved in the new checkout before materialization.
|
|
247
|
+
await checked(target, ['reset', '--hard', '--no-recurse-submodules', head]);
|
|
248
|
+
if (!(await inspect(source, target, branch))) throw new Error('Worktree creation needs reconciliation');
|
|
249
|
+
return target;
|
|
250
|
+
},
|
|
251
|
+
async remove(source, target, branch) {
|
|
252
|
+
if (!(await inspect(source, target, branch))) throw new Error('Managed worktree is missing');
|
|
253
|
+
await assertClean(target);
|
|
254
|
+
await checked(source, ['worktree', 'remove', target], await filterOverrides(target));
|
|
255
|
+
if ((await list(source)).some((row) => row.worktree === target))
|
|
256
|
+
throw new Error('Worktree removal needs reconciliation');
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
package/src/validation.js
CHANGED
|
@@ -167,8 +167,10 @@ export function validateUserSettings(settings, source) {
|
|
|
167
167
|
}
|
|
168
168
|
if (settings.web !== undefined) {
|
|
169
169
|
check(object(settings.web), 'web must be an object');
|
|
170
|
-
const extra = Object.keys(settings.web).find(field => !['enabled', 'port', 'relay'].includes(field));
|
|
171
|
-
check(!extra, `unsupported web field '${extra}'; use enabled, port or
|
|
170
|
+
const extra = Object.keys(settings.web).find(field => !['enabled', 'port', 'relay', 'permissions'].includes(field));
|
|
171
|
+
check(!extra, `unsupported web field '${extra}'; use enabled, port, relay or permissions`);
|
|
172
|
+
// Gateway permissions `alp web` adds to its default grant; alp-web checks each name against the gateway's list.
|
|
173
|
+
if (settings.web.permissions !== undefined) check(Array.isArray(settings.web.permissions) && settings.web.permissions.every(entry => typeof entry === 'string' && /^[a-z][a-z.]*$/.test(entry)), 'web.permissions must be a list of permission names, such as files.read');
|
|
172
174
|
// The relay that lets Paseo's app reach alpd from another device (ALPD §64).
|
|
173
175
|
const relay = settings.web.relay;
|
|
174
176
|
if (relay !== undefined) {
|