@bigking67/pi-67 0.10.2 → 0.10.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/CHANGELOG.md +34 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/scripts/check.mjs +98 -0
- package/src/cli.mjs +6 -3
- package/src/commands/backups.mjs +187 -1
- package/src/commands/doctor.mjs +24 -0
- package/src/commands/extensions.mjs +6 -1
- package/src/commands/external.mjs +28 -0
- package/src/commands/install.mjs +22 -0
- package/src/commands/manifest.mjs +21 -0
- package/src/commands/publish-check.mjs +24 -0
- package/src/commands/report.mjs +22 -0
- package/src/commands/self-update.mjs +19 -0
- package/src/commands/skills.mjs +83 -4
- package/src/commands/smoke.mjs +21 -0
- package/src/commands/status.mjs +20 -0
- package/src/commands/themes.mjs +33 -0
- package/src/commands/update.mjs +49 -1
- package/src/commands/version.mjs +22 -1
- package/src/commands/xtalpi.mjs +81 -2
- package/src/lib/args.mjs +7 -1
- package/src/lib/skill-policy.mjs +130 -3
- package/src/lib/update-plan.mjs +52 -5
- package/src/lib/update-safety.mjs +85 -23
package/src/lib/skill-policy.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { CliError } from "./output.mjs";
|
|
4
5
|
|
|
5
6
|
export function inventorySkills(ctx) {
|
|
6
7
|
const sourceRoot = path.join(ctx.repoRoot, "shared-skills");
|
|
@@ -29,17 +30,105 @@ export function inventorySkills(ctx) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export function
|
|
33
|
+
export function diffSkill(ctx, name) {
|
|
33
34
|
const inventory = inventorySkills(ctx);
|
|
35
|
+
const entry = inventory.entries.find((item) => item.name === name);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
throw new CliError(`unknown shared skill: ${name}`, 2);
|
|
38
|
+
}
|
|
39
|
+
const sourceFiles = fileManifest(entry.source);
|
|
40
|
+
const targetFiles = entry.targetExists ? fileManifest(entry.target) : new Map();
|
|
41
|
+
const sourceNames = new Set(sourceFiles.keys());
|
|
42
|
+
const targetNames = new Set(targetFiles.keys());
|
|
43
|
+
const added = [...sourceNames].filter((rel) => !targetNames.has(rel)).sort();
|
|
44
|
+
const removed = [...targetNames].filter((rel) => !sourceNames.has(rel)).sort();
|
|
45
|
+
const modified = [...sourceNames]
|
|
46
|
+
.filter((rel) => targetNames.has(rel) && sourceFiles.get(rel).sha256 !== targetFiles.get(rel).sha256)
|
|
47
|
+
.sort();
|
|
48
|
+
return {
|
|
49
|
+
schema: "pi67.skills-diff.v1",
|
|
50
|
+
name,
|
|
51
|
+
source: entry.source,
|
|
52
|
+
target: entry.target,
|
|
53
|
+
sourceHash: entry.sourceHash,
|
|
54
|
+
targetExists: entry.targetExists,
|
|
55
|
+
targetHash: entry.targetHash,
|
|
56
|
+
identical: entry.identical,
|
|
57
|
+
conflict: entry.conflict,
|
|
58
|
+
diff: {
|
|
59
|
+
added,
|
|
60
|
+
removed,
|
|
61
|
+
modified,
|
|
62
|
+
sourceFileCount: sourceFiles.size,
|
|
63
|
+
targetFileCount: targetFiles.size,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planSkills(ctx, { names = [] } = {}) {
|
|
69
|
+
const inventory = inventorySkills(ctx);
|
|
70
|
+
const selected = normalizeNames(names, inventory);
|
|
71
|
+
const entries = selected.length === 0
|
|
72
|
+
? inventory.entries.filter((entry) => !entry.identical)
|
|
73
|
+
: inventory.entries.filter((entry) => selected.includes(entry.name) && !entry.identical);
|
|
74
|
+
const actions = entries.map((entry) => ({
|
|
75
|
+
name: entry.name,
|
|
76
|
+
source: entry.source,
|
|
77
|
+
target: entry.target,
|
|
78
|
+
targetExists: entry.targetExists,
|
|
79
|
+
conflict: entry.conflict,
|
|
80
|
+
action: entry.conflict ? "preserve-conflict" : "copy-missing",
|
|
81
|
+
reason: entry.conflict
|
|
82
|
+
? "target differs; default update preserves the existing global skill"
|
|
83
|
+
: "target is missing and can be copied safely",
|
|
84
|
+
}));
|
|
85
|
+
return {
|
|
86
|
+
schema: "pi67.skills-plan.v1",
|
|
87
|
+
sourceRoot: inventory.sourceRoot,
|
|
88
|
+
skillsDir: inventory.skillsDir,
|
|
89
|
+
selected,
|
|
90
|
+
summary: inventory.summary,
|
|
91
|
+
actions,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function syncSkills(ctx, { dryRun = false, names = [], yes = false } = {}) {
|
|
96
|
+
const inventory = inventorySkills(ctx);
|
|
97
|
+
const selected = normalizeNames(names, inventory);
|
|
98
|
+
const selectedSet = new Set(selected);
|
|
99
|
+
const targeted = selected.length > 0;
|
|
34
100
|
const actions = [];
|
|
35
101
|
fs.mkdirSync(ctx.skillsDir, { recursive: true });
|
|
36
102
|
for (const entry of inventory.entries) {
|
|
103
|
+
if (targeted && !selectedSet.has(entry.name)) continue;
|
|
37
104
|
if (entry.identical) {
|
|
38
105
|
actions.push({ name: entry.name, action: "skip", reason: "identical" });
|
|
39
106
|
continue;
|
|
40
107
|
}
|
|
41
108
|
if (entry.conflict) {
|
|
42
|
-
|
|
109
|
+
if (!targeted || !yes) {
|
|
110
|
+
actions.push({
|
|
111
|
+
name: entry.name,
|
|
112
|
+
action: "warn",
|
|
113
|
+
reason: targeted
|
|
114
|
+
? "target differs; rerun with --yes to replace this explicitly named skill after backup"
|
|
115
|
+
: "target differs; bulk conflict overwrite is intentionally blocked",
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-skills-sync`, entry.name);
|
|
120
|
+
actions.push({
|
|
121
|
+
name: entry.name,
|
|
122
|
+
action: dryRun ? "replace-dry-run" : "replace",
|
|
123
|
+
reason: "target differs and was explicitly named with --yes",
|
|
124
|
+
backupDir,
|
|
125
|
+
});
|
|
126
|
+
if (!dryRun) {
|
|
127
|
+
fs.mkdirSync(path.dirname(backupDir), { recursive: true, mode: 0o700 });
|
|
128
|
+
fs.cpSync(entry.target, backupDir, { recursive: true, force: true });
|
|
129
|
+
fs.rmSync(entry.target, { recursive: true, force: true });
|
|
130
|
+
fs.cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true });
|
|
131
|
+
}
|
|
43
132
|
continue;
|
|
44
133
|
}
|
|
45
134
|
actions.push({ name: entry.name, action: dryRun ? "copy-dry-run" : "copy", reason: "missing" });
|
|
@@ -47,7 +136,7 @@ export function syncSkills(ctx, { dryRun = false } = {}) {
|
|
|
47
136
|
fs.cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true });
|
|
48
137
|
}
|
|
49
138
|
}
|
|
50
|
-
return { ...inventory, actions };
|
|
139
|
+
return { ...inventory, selected, actions };
|
|
51
140
|
}
|
|
52
141
|
|
|
53
142
|
function listSkillDirs(root) {
|
|
@@ -73,6 +162,44 @@ function hashDir(root) {
|
|
|
73
162
|
return hash.digest("hex");
|
|
74
163
|
}
|
|
75
164
|
|
|
165
|
+
function fileManifest(root) {
|
|
166
|
+
const result = new Map();
|
|
167
|
+
if (!fs.existsSync(root)) return result;
|
|
168
|
+
const files = [];
|
|
169
|
+
walk(root, files);
|
|
170
|
+
for (const file of files.sort()) {
|
|
171
|
+
const rel = path.relative(root, file).replace(/\\/g, "/");
|
|
172
|
+
const stat = fs.statSync(file);
|
|
173
|
+
result.set(rel, {
|
|
174
|
+
path: rel,
|
|
175
|
+
bytes: stat.size,
|
|
176
|
+
sha256: sha256File(file),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sha256File(file) {
|
|
183
|
+
const hash = crypto.createHash("sha256");
|
|
184
|
+
hash.update(fs.readFileSync(file));
|
|
185
|
+
return hash.digest("hex");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeNames(names, inventory) {
|
|
189
|
+
const selected = [...new Set((names || []).filter(Boolean).map(String))];
|
|
190
|
+
const known = new Set(inventory.entries.map((entry) => entry.name));
|
|
191
|
+
for (const name of selected) {
|
|
192
|
+
if (!known.has(name)) {
|
|
193
|
+
throw new CliError(`unknown shared skill: ${name}`, 2);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return selected;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function timestamp() {
|
|
200
|
+
return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
201
|
+
}
|
|
202
|
+
|
|
76
203
|
function walk(dir, files) {
|
|
77
204
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
78
205
|
const full = path.join(dir, entry.name);
|
package/src/lib/update-plan.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { gitStatus, remoteHead } from "./git.mjs";
|
|
3
|
+
import { gitStatus, gitText, remoteHead } from "./git.mjs";
|
|
4
4
|
import { currentTheme, hasTheme, listThemes } from "./theme-policy.mjs";
|
|
5
5
|
import { readJsonFileIfExists } from "./config-json.mjs";
|
|
6
6
|
import { listExternal } from "./external-repos.mjs";
|
|
@@ -39,6 +39,8 @@ export async function buildUpdatePlan(ctx, options = {}) {
|
|
|
39
39
|
currentVersion: pkg.version,
|
|
40
40
|
noRemote: options.noRemote,
|
|
41
41
|
});
|
|
42
|
+
const dirtyClass = classifyGitShort(git?.short || "");
|
|
43
|
+
const benignRuntime = classifyBenignRuntimeDiff(ctx, dirtyClass.preservedRuntime);
|
|
42
44
|
|
|
43
45
|
const recommendations = [];
|
|
44
46
|
if (managerRegistry.outdated) {
|
|
@@ -49,8 +51,13 @@ export async function buildUpdatePlan(ctx, options = {}) {
|
|
|
49
51
|
recommendations.push("Run: pi-67 install");
|
|
50
52
|
} else if (!git?.isRepo) {
|
|
51
53
|
recommendations.push("Agent dir exists but is not a git checkout; inspect before installing.");
|
|
52
|
-
} else if (git.dirty) {
|
|
54
|
+
} else if (git.dirty && dirtyClass.unsafeTracked.length > 0) {
|
|
53
55
|
recommendations.push("Resolve or commit local changes before pi-67 update.");
|
|
56
|
+
} else if (git.dirty && benignRuntime.benign) {
|
|
57
|
+
recommendations.push("No manual action required for benign settings runtime markers; pi-67 can preserve them during update.");
|
|
58
|
+
recommendations.push("Optional: normalize local settings runtime marker if you want a clean git status.");
|
|
59
|
+
} else if (git.dirty) {
|
|
60
|
+
recommendations.push("No manual action required for user runtime config; pi-67 backs up/restores it during update.");
|
|
54
61
|
} else {
|
|
55
62
|
recommendations.push("Run: pi-67 update");
|
|
56
63
|
}
|
|
@@ -66,6 +73,7 @@ export async function buildUpdatePlan(ctx, options = {}) {
|
|
|
66
73
|
const decisions = buildPlanDecisions({
|
|
67
74
|
ctx,
|
|
68
75
|
git,
|
|
76
|
+
benignRuntime,
|
|
69
77
|
managerRegistry,
|
|
70
78
|
manifest,
|
|
71
79
|
skills,
|
|
@@ -129,6 +137,7 @@ export function buildPlanDecisions(context) {
|
|
|
129
137
|
...new Set([...(context.manifest.runtimeFiles?.preserve || []), ...PRESERVED_RUNTIME_FILES]),
|
|
130
138
|
];
|
|
131
139
|
const dirty = classifyGitShort(context.git?.short || "");
|
|
140
|
+
const benignRuntime = context.benignRuntime || { benign: false, reasons: [] };
|
|
132
141
|
|
|
133
142
|
if (context.managerRegistry.outdated) {
|
|
134
143
|
actions.push({
|
|
@@ -162,12 +171,20 @@ export function buildPlanDecisions(context) {
|
|
|
162
171
|
id: "user-runtime-config",
|
|
163
172
|
kind: "runtime-config",
|
|
164
173
|
operation: "backup-and-restore-during-update",
|
|
165
|
-
writes: ["~/.pi/pi67/backups
|
|
174
|
+
writes: ["~/.pi/pi67/backups/pre-update-runtime-*"],
|
|
166
175
|
preserves: dirty.preservedRuntime,
|
|
167
176
|
risk: "low",
|
|
168
|
-
reason:
|
|
177
|
+
reason: benignRuntime.benign
|
|
178
|
+
? `benign runtime marker only: ${benignRuntime.reasons.join("; ")}`
|
|
179
|
+
: "only user-owned runtime config files are dirty; update snapshots and restores them instead of overwriting",
|
|
180
|
+
benign: benignRuntime.benign,
|
|
181
|
+
benignReasons: benignRuntime.reasons,
|
|
169
182
|
});
|
|
170
|
-
warnings.push(
|
|
183
|
+
warnings.push(
|
|
184
|
+
benignRuntime.benign
|
|
185
|
+
? `benign user runtime marker will be preserved across update: ${dirty.preservedRuntime.join(", ")}`
|
|
186
|
+
: `dirty user runtime config will be preserved across update: ${dirty.preservedRuntime.join(", ")}`,
|
|
187
|
+
);
|
|
171
188
|
}
|
|
172
189
|
if (context.git?.dirty && dirty.untracked.length > 0) {
|
|
173
190
|
warnings.push(`untracked files are present and will be preserved unless Git reports a path collision: ${dirty.untracked.join(", ")}`);
|
|
@@ -298,6 +315,36 @@ export function classifyGitShort(short) {
|
|
|
298
315
|
return result;
|
|
299
316
|
}
|
|
300
317
|
|
|
318
|
+
export function classifyBenignRuntimeDiff(ctx, preservedRuntimePaths = []) {
|
|
319
|
+
const paths = [...new Set(preservedRuntimePaths)].sort();
|
|
320
|
+
if (paths.length !== 1 || paths[0] !== "settings.json") {
|
|
321
|
+
return { benign: false, reasons: [] };
|
|
322
|
+
}
|
|
323
|
+
const diff = gitText(ctx.repoRoot, ["diff", "--", "settings.json"]);
|
|
324
|
+
if (!diff) return { benign: false, reasons: [] };
|
|
325
|
+
const changed = diff.split(/\r?\n/).filter((line) =>
|
|
326
|
+
(line.startsWith("+") || line.startsWith("-")) &&
|
|
327
|
+
!line.startsWith("+++") &&
|
|
328
|
+
!line.startsWith("---"));
|
|
329
|
+
const meaningful = changed.filter((line) => {
|
|
330
|
+
const body = line.slice(1).trim();
|
|
331
|
+
return body !== "" && body !== "}";
|
|
332
|
+
});
|
|
333
|
+
const reasons = [];
|
|
334
|
+
const markerOnly = meaningful.length > 0 &&
|
|
335
|
+
meaningful.every((line) => /^[-+]\s*"lastChangelogVersion"\s*:/.test(line));
|
|
336
|
+
if (markerOnly) {
|
|
337
|
+
reasons.push("settings.json lastChangelogVersion runtime marker changed");
|
|
338
|
+
}
|
|
339
|
+
if (changed.some((line) => ["-", "+", "-}", "+}"].includes(line.trim()))) {
|
|
340
|
+
reasons.push("settings.json trailing newline state changed");
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
benign: markerOnly || (meaningful.length === 0 && reasons.length > 0),
|
|
344
|
+
reasons,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
301
348
|
function parseStatusPath(line) {
|
|
302
349
|
let file = "";
|
|
303
350
|
if (line.length >= 3 && line[2] === " ") {
|
|
@@ -18,16 +18,21 @@ const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1000;
|
|
|
18
18
|
export function beginUpdateLifecycle(ctx, options = {}) {
|
|
19
19
|
const operation = options.operation || "update";
|
|
20
20
|
const dryRun = Boolean(options.dryRun);
|
|
21
|
+
const backupRuntime = options.backupRuntime !== false;
|
|
21
22
|
const lockPath = path.join(ctx.stateDir, "locks", "update.lock");
|
|
22
23
|
const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-${operation}`);
|
|
23
24
|
|
|
24
25
|
if (dryRun) {
|
|
25
26
|
info(`DRY-RUN would acquire update lock: ${lockPath}`);
|
|
26
|
-
|
|
27
|
+
if (backupRuntime) {
|
|
28
|
+
info(`DRY-RUN would snapshot preserved runtime files into: ${backupDir}`);
|
|
29
|
+
}
|
|
27
30
|
return {
|
|
28
31
|
lockPath,
|
|
29
32
|
backupDir,
|
|
30
33
|
backedUp: [],
|
|
34
|
+
backupSkipped: !backupRuntime,
|
|
35
|
+
backupReason: backupRuntime ? "" : "runtime backup is delegated to the updater script when needed",
|
|
31
36
|
release() {},
|
|
32
37
|
};
|
|
33
38
|
}
|
|
@@ -35,14 +40,34 @@ export function beginUpdateLifecycle(ctx, options = {}) {
|
|
|
35
40
|
acquireLock(lockPath, { operation });
|
|
36
41
|
let released = false;
|
|
37
42
|
try {
|
|
38
|
-
|
|
43
|
+
if (!backupRuntime) {
|
|
44
|
+
return {
|
|
45
|
+
lockPath,
|
|
46
|
+
backupDir: "",
|
|
47
|
+
backedUp: [],
|
|
48
|
+
backupSkipped: true,
|
|
49
|
+
backupReason: "runtime backup is delegated to the updater script when needed",
|
|
50
|
+
reusedBackupDir: "",
|
|
51
|
+
release() {
|
|
52
|
+
if (released) return;
|
|
53
|
+
released = true;
|
|
54
|
+
releaseLock(lockPath);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const backup = createRuntimeBackup(ctx, backupDir, {
|
|
39
59
|
operation,
|
|
40
60
|
plan: options.plan,
|
|
61
|
+
dedupe: true,
|
|
62
|
+
returnResult: true,
|
|
41
63
|
});
|
|
42
64
|
return {
|
|
43
65
|
lockPath,
|
|
44
|
-
backupDir,
|
|
45
|
-
backedUp,
|
|
66
|
+
backupDir: backup.backupDir,
|
|
67
|
+
backedUp: backup.backedUp,
|
|
68
|
+
backupSkipped: backup.skipped,
|
|
69
|
+
backupReason: backup.reason || "",
|
|
70
|
+
reusedBackupDir: backup.reusedBackupDir || "",
|
|
46
71
|
release() {
|
|
47
72
|
if (released) return;
|
|
48
73
|
released = true;
|
|
@@ -56,34 +81,37 @@ export function beginUpdateLifecycle(ctx, options = {}) {
|
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
export function createRuntimeBackup(ctx, backupDir, options = {}) {
|
|
84
|
+
const operation = options.operation || "update";
|
|
85
|
+
const preserved = collectPreservedRuntimeFiles(ctx);
|
|
86
|
+
if (options.dedupe) {
|
|
87
|
+
const equivalent = findEquivalentRuntimeBackup(ctx, preserved, operation);
|
|
88
|
+
if (equivalent) {
|
|
89
|
+
const result = {
|
|
90
|
+
backupDir: equivalent.path,
|
|
91
|
+
backedUp: [],
|
|
92
|
+
skipped: true,
|
|
93
|
+
reusedBackupDir: equivalent.path,
|
|
94
|
+
reason: `same preserved runtime snapshot already exists: ${equivalent.id}`,
|
|
95
|
+
};
|
|
96
|
+
return options.returnResult ? result : result.backedUp;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
59
100
|
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
60
101
|
const filesDir = path.join(backupDir, "files");
|
|
61
102
|
fs.mkdirSync(filesDir, { recursive: true, mode: 0o700 });
|
|
62
103
|
|
|
63
|
-
const preserved = [];
|
|
64
|
-
const backedUp = [];
|
|
65
104
|
for (const rel of PRESERVED_RUNTIME_FILES) {
|
|
105
|
+
const item = preserved.find((entry) => entry.path === rel);
|
|
106
|
+
if (!item?.exists) continue;
|
|
66
107
|
const source = path.join(ctx.agentDir, rel);
|
|
67
|
-
if (!fs.existsSync(source)) {
|
|
68
|
-
preserved.push({
|
|
69
|
-
path: rel,
|
|
70
|
-
exists: false,
|
|
71
|
-
});
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
108
|
const target = path.join(filesDir, rel.replace(/[\\/]/g, "__"));
|
|
75
109
|
fs.copyFileSync(source, target);
|
|
76
110
|
chmodPrivate(target);
|
|
77
|
-
const item = {
|
|
78
|
-
path: rel,
|
|
79
|
-
exists: true,
|
|
80
|
-
bytes: fs.statSync(source).size,
|
|
81
|
-
sha256: sha256File(source),
|
|
82
|
-
};
|
|
83
|
-
preserved.push(item);
|
|
84
|
-
backedUp.push(item);
|
|
85
111
|
}
|
|
86
112
|
|
|
113
|
+
const backedUp = preserved.filter((item) => item.exists !== false);
|
|
114
|
+
|
|
87
115
|
if (options.plan) {
|
|
88
116
|
fs.writeFileSync(
|
|
89
117
|
path.join(backupDir, "update-plan.json"),
|
|
@@ -96,14 +124,15 @@ export function createRuntimeBackup(ctx, backupDir, options = {}) {
|
|
|
96
124
|
`${JSON.stringify({
|
|
97
125
|
schema: "pi67.update-backup.v1",
|
|
98
126
|
createdAt: new Date().toISOString(),
|
|
99
|
-
operation
|
|
127
|
+
operation,
|
|
100
128
|
agentDir: ctx.agentDir,
|
|
101
129
|
repoRoot: ctx.repoRoot,
|
|
102
130
|
files: preserved,
|
|
103
131
|
}, null, 2)}\n`,
|
|
104
132
|
{ mode: 0o600 },
|
|
105
133
|
);
|
|
106
|
-
|
|
134
|
+
const result = { backupDir, backedUp, skipped: false, reusedBackupDir: "", reason: "" };
|
|
135
|
+
return options.returnResult ? result : backedUp;
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
export function listRuntimeBackups(ctx) {
|
|
@@ -246,6 +275,39 @@ function isStaleLock(lockPath) {
|
|
|
246
275
|
return false;
|
|
247
276
|
}
|
|
248
277
|
|
|
278
|
+
function collectPreservedRuntimeFiles(ctx) {
|
|
279
|
+
return PRESERVED_RUNTIME_FILES.map((rel) => {
|
|
280
|
+
const source = path.join(ctx.agentDir, rel);
|
|
281
|
+
if (!fs.existsSync(source)) {
|
|
282
|
+
return { path: rel, exists: false };
|
|
283
|
+
}
|
|
284
|
+
const stat = fs.statSync(source);
|
|
285
|
+
return {
|
|
286
|
+
path: rel,
|
|
287
|
+
exists: true,
|
|
288
|
+
bytes: stat.size,
|
|
289
|
+
sha256: sha256File(source),
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function findEquivalentRuntimeBackup(ctx, preserved, operation) {
|
|
295
|
+
return listRuntimeBackups(ctx).find((backup) =>
|
|
296
|
+
backup.operation === operation && sameRuntimeFiles(backup.files, preserved));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sameRuntimeFiles(left, right) {
|
|
300
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
301
|
+
const leftByPath = new Map(left.map((item) => [item.path, item]));
|
|
302
|
+
return right.every((rightItem) => {
|
|
303
|
+
const leftItem = leftByPath.get(rightItem.path);
|
|
304
|
+
if (!leftItem) return false;
|
|
305
|
+
if ((leftItem.exists !== false) !== (rightItem.exists !== false)) return false;
|
|
306
|
+
if (rightItem.exists === false) return true;
|
|
307
|
+
return leftItem.bytes === rightItem.bytes && leftItem.sha256 === rightItem.sha256;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
249
311
|
function sha256File(file) {
|
|
250
312
|
const hash = crypto.createHash("sha256");
|
|
251
313
|
hash.update(fs.readFileSync(file));
|