@bigking67/pi-67 0.10.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.
- package/CHANGELOG.md +49 -0
- package/README.md +182 -0
- package/bin/pi-67.mjs +13 -0
- package/package.json +47 -0
- package/schemas/pi67-distro-manifest.schema.json +130 -0
- package/schemas/pi67-extension-registry.schema.json +110 -0
- package/schemas/pi67-publish-check.schema.json +122 -0
- package/schemas/pi67-state.schema.json +31 -0
- package/schemas/pi67-update-plan.schema.json +141 -0
- package/scripts/check.mjs +401 -0
- package/src/cli.mjs +108 -0
- package/src/commands/backups.mjs +124 -0
- package/src/commands/doctor.mjs +21 -0
- package/src/commands/extensions.mjs +184 -0
- package/src/commands/external.mjs +63 -0
- package/src/commands/install.mjs +48 -0
- package/src/commands/manifest.mjs +74 -0
- package/src/commands/publish-check.mjs +366 -0
- package/src/commands/report.mjs +20 -0
- package/src/commands/self-update.mjs +16 -0
- package/src/commands/skills.mjs +49 -0
- package/src/commands/smoke.mjs +18 -0
- package/src/commands/status.mjs +21 -0
- package/src/commands/themes.mjs +69 -0
- package/src/commands/update.mjs +138 -0
- package/src/commands/version.mjs +51 -0
- package/src/commands/xtalpi.mjs +69 -0
- package/src/data/distro-manifest.json +85 -0
- package/src/data/extension-registry.json +147 -0
- package/src/lib/args.mjs +87 -0
- package/src/lib/config-json.mjs +20 -0
- package/src/lib/distro-manifest.mjs +131 -0
- package/src/lib/distro-scripts.mjs +27 -0
- package/src/lib/extension-registry.mjs +250 -0
- package/src/lib/external-repos.mjs +71 -0
- package/src/lib/git.mjs +55 -0
- package/src/lib/npm-registry.mjs +288 -0
- package/src/lib/output.mjs +35 -0
- package/src/lib/paths.mjs +79 -0
- package/src/lib/platform.mjs +27 -0
- package/src/lib/shell-runner.mjs +40 -0
- package/src/lib/skill-policy.mjs +85 -0
- package/src/lib/state-store.mjs +31 -0
- package/src/lib/theme-policy.mjs +34 -0
- package/src/lib/update-plan.mjs +312 -0
- package/src/lib/update-safety.mjs +310 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gitStatus, remoteHead } from "./git.mjs";
|
|
4
|
+
import { currentTheme, hasTheme, listThemes } from "./theme-policy.mjs";
|
|
5
|
+
import { readJsonFileIfExists } from "./config-json.mjs";
|
|
6
|
+
import { listExternal } from "./external-repos.mjs";
|
|
7
|
+
import { inventorySkills } from "./skill-policy.mjs";
|
|
8
|
+
import { readCliPackageJson, readTextIfExists } from "./paths.mjs";
|
|
9
|
+
import { npmLatestVersion } from "./npm-registry.mjs";
|
|
10
|
+
import { buildDistroManifest } from "./distro-manifest.mjs";
|
|
11
|
+
import { PRESERVED_RUNTIME_FILES } from "./update-safety.mjs";
|
|
12
|
+
|
|
13
|
+
export function buildUpdatePlan(ctx, options = {}) {
|
|
14
|
+
const versionFile = path.join(ctx.repoRoot, "VERSION");
|
|
15
|
+
const settings = readJsonFileIfExists(path.join(ctx.agentDir, "settings.json")) || {};
|
|
16
|
+
const theme = currentTheme(ctx);
|
|
17
|
+
const themes = listThemes(ctx);
|
|
18
|
+
const git = fs.existsSync(ctx.repoRoot) ? gitStatus(ctx.repoRoot) : { isRepo: false };
|
|
19
|
+
const remote = !options.noRemote && git?.isRepo ? remoteHead(ctx.repoRoot) : { skipped: true };
|
|
20
|
+
const skills = inventorySkills(ctx);
|
|
21
|
+
const external = listExternal(ctx);
|
|
22
|
+
const manifest = buildDistroManifest(ctx);
|
|
23
|
+
const requiredScripts = [
|
|
24
|
+
"pi67-update.sh",
|
|
25
|
+
"pi67-update.ps1",
|
|
26
|
+
"pi67-doctor.sh",
|
|
27
|
+
"pi67-doctor.ps1",
|
|
28
|
+
"pi67-smoke.sh",
|
|
29
|
+
"pi67-smoke.ps1",
|
|
30
|
+
"pi67-report.sh",
|
|
31
|
+
"pi67-report.ps1",
|
|
32
|
+
];
|
|
33
|
+
const scriptStatus = Object.fromEntries(requiredScripts.map((name) => [
|
|
34
|
+
name,
|
|
35
|
+
fs.existsSync(path.join(ctx.repoRoot, "scripts", name)),
|
|
36
|
+
]));
|
|
37
|
+
const pkg = readCliPackageJson();
|
|
38
|
+
const managerRegistry = npmLatestVersion(pkg.name, {
|
|
39
|
+
currentVersion: pkg.version,
|
|
40
|
+
noRemote: options.noRemote,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const recommendations = [];
|
|
44
|
+
if (managerRegistry.outdated) {
|
|
45
|
+
recommendations.push(`Update pi-67 manager: npm install -g ${pkg.name}@latest`);
|
|
46
|
+
recommendations.push(`Always-fresh one-shot: npx -y ${pkg.name}@latest update --repair`);
|
|
47
|
+
}
|
|
48
|
+
if (!fs.existsSync(ctx.repoRoot)) {
|
|
49
|
+
recommendations.push("Run: pi-67 install");
|
|
50
|
+
} else if (!git?.isRepo) {
|
|
51
|
+
recommendations.push("Agent dir exists but is not a git checkout; inspect before installing.");
|
|
52
|
+
} else if (git.dirty) {
|
|
53
|
+
recommendations.push("Resolve or commit local changes before pi-67 update.");
|
|
54
|
+
} else {
|
|
55
|
+
recommendations.push("Run: pi-67 update");
|
|
56
|
+
}
|
|
57
|
+
if (skills.summary.conflicts > 0) {
|
|
58
|
+
recommendations.push("Run: pi-67 skills inventory and resolve differing global skills manually.");
|
|
59
|
+
}
|
|
60
|
+
if (theme && !hasTheme(ctx, theme)) {
|
|
61
|
+
recommendations.push(`Current theme is missing: ${theme}. Run: pi-67 themes list`);
|
|
62
|
+
}
|
|
63
|
+
if (manifest.summary.userManagedRuntimePackages > 0) {
|
|
64
|
+
recommendations.push("User-managed Pi runtime packages detected; pi-67 will report them but not overwrite them by default.");
|
|
65
|
+
}
|
|
66
|
+
const decisions = buildPlanDecisions({
|
|
67
|
+
ctx,
|
|
68
|
+
git,
|
|
69
|
+
managerRegistry,
|
|
70
|
+
manifest,
|
|
71
|
+
skills,
|
|
72
|
+
external,
|
|
73
|
+
scriptStatus,
|
|
74
|
+
theme,
|
|
75
|
+
themeInstalled: theme ? hasTheme(ctx, theme) : false,
|
|
76
|
+
strictSharedSkills: Boolean(options.strictSharedSkills),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
schema: "pi67.update-plan.v1",
|
|
81
|
+
createdAt: new Date().toISOString(),
|
|
82
|
+
manager: {
|
|
83
|
+
package: pkg.name,
|
|
84
|
+
version: pkg.version,
|
|
85
|
+
registry: managerRegistry,
|
|
86
|
+
},
|
|
87
|
+
paths: {
|
|
88
|
+
agentDir: ctx.agentDir,
|
|
89
|
+
repoRoot: ctx.repoRoot,
|
|
90
|
+
skillsDir: ctx.skillsDir,
|
|
91
|
+
packagesDir: ctx.packagesDir,
|
|
92
|
+
},
|
|
93
|
+
distro: {
|
|
94
|
+
version: readTextIfExists(versionFile).trim(),
|
|
95
|
+
},
|
|
96
|
+
git,
|
|
97
|
+
remote,
|
|
98
|
+
settings: {
|
|
99
|
+
defaultProvider: settings.defaultProvider || "",
|
|
100
|
+
defaultModel: settings.defaultModel || "",
|
|
101
|
+
defaultThinkingLevel: settings.defaultThinkingLevel || "",
|
|
102
|
+
theme,
|
|
103
|
+
themeInstalled: theme ? hasTheme(ctx, theme) : false,
|
|
104
|
+
themesAvailable: themes.length,
|
|
105
|
+
},
|
|
106
|
+
policy: {
|
|
107
|
+
userConfigPolicy: manifest.ownership?.userManaged || "preserve user-managed runtime files",
|
|
108
|
+
preservedRuntimeFiles: PRESERVED_RUNTIME_FILES,
|
|
109
|
+
themePolicy: manifest.theme?.policy || "",
|
|
110
|
+
sharedSkillsPolicy: manifest.sharedSkills?.policy || "",
|
|
111
|
+
externalDirtyPolicy: manifest.externalReposPolicy?.dirtyRepo || "",
|
|
112
|
+
},
|
|
113
|
+
scripts: scriptStatus,
|
|
114
|
+
manifest: manifest.summary,
|
|
115
|
+
skills: skills.summary,
|
|
116
|
+
external,
|
|
117
|
+
actions: decisions.actions,
|
|
118
|
+
blocked: decisions.blocked,
|
|
119
|
+
warnings: decisions.warnings,
|
|
120
|
+
recommendations,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildPlanDecisions(context) {
|
|
125
|
+
const actions = [];
|
|
126
|
+
const blocked = [];
|
|
127
|
+
const warnings = [];
|
|
128
|
+
const preservedRuntimeFiles = [
|
|
129
|
+
...new Set([...(context.manifest.runtimeFiles?.preserve || []), ...PRESERVED_RUNTIME_FILES]),
|
|
130
|
+
];
|
|
131
|
+
const dirty = classifyGitShort(context.git?.short || "");
|
|
132
|
+
|
|
133
|
+
if (context.managerRegistry.outdated) {
|
|
134
|
+
actions.push({
|
|
135
|
+
id: "pi67-manager",
|
|
136
|
+
kind: "npm-manager",
|
|
137
|
+
operation: "self-update",
|
|
138
|
+
writes: ["global npm package @bigking67/pi-67"],
|
|
139
|
+
preserves: ["upstream pi binary"],
|
|
140
|
+
risk: "low",
|
|
141
|
+
reason: "npm registry has a newer pi-67 manager version",
|
|
142
|
+
explicitCommand: "pi-67 self-update",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!context.git?.isRepo) {
|
|
147
|
+
blocked.push({
|
|
148
|
+
id: "repo-root",
|
|
149
|
+
kind: "distro",
|
|
150
|
+
reason: "repo root is not a git checkout; install/update needs operator inspection first",
|
|
151
|
+
recovery: "pi-67 install",
|
|
152
|
+
});
|
|
153
|
+
} else if (context.git.dirty && dirty.unsafeTracked.length > 0) {
|
|
154
|
+
blocked.push({
|
|
155
|
+
id: "repo-root",
|
|
156
|
+
kind: "distro",
|
|
157
|
+
reason: `repo has non-runtime local changes; pi-67 update blocks by default to avoid overwriting local work: ${dirty.unsafeTracked.join(", ")}`,
|
|
158
|
+
recovery: "commit/stash intentional changes or rerun the script-level updater with an explicit dirty override",
|
|
159
|
+
});
|
|
160
|
+
} else if (context.git.dirty && dirty.preservedRuntime.length > 0) {
|
|
161
|
+
actions.push({
|
|
162
|
+
id: "user-runtime-config",
|
|
163
|
+
kind: "runtime-config",
|
|
164
|
+
operation: "backup-and-restore-during-update",
|
|
165
|
+
writes: ["~/.pi/pi67/backups/<timestamp>-update"],
|
|
166
|
+
preserves: dirty.preservedRuntime,
|
|
167
|
+
risk: "low",
|
|
168
|
+
reason: "only user-owned runtime config files are dirty; update snapshots and restores them instead of overwriting",
|
|
169
|
+
});
|
|
170
|
+
warnings.push(`dirty user runtime config will be preserved across update: ${dirty.preservedRuntime.join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
if (context.git?.dirty && dirty.untracked.length > 0) {
|
|
173
|
+
warnings.push(`untracked files are present and will be preserved unless Git reports a path collision: ${dirty.untracked.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const item of context.manifest.localExtensions || []) {
|
|
177
|
+
if (item.owner === "pi67-managed" && !item.exists) {
|
|
178
|
+
actions.push({
|
|
179
|
+
id: item.name,
|
|
180
|
+
kind: "local-extension",
|
|
181
|
+
operation: "repair",
|
|
182
|
+
writes: [item.path],
|
|
183
|
+
preserves: preservedRuntimeFiles,
|
|
184
|
+
risk: "low",
|
|
185
|
+
reason: "required pi-67 managed local extension is missing",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const missingScripts = Object.entries(context.scriptStatus)
|
|
191
|
+
.filter(([, exists]) => !exists)
|
|
192
|
+
.map(([name]) => `scripts/${name}`);
|
|
193
|
+
if (missingScripts.length > 0) {
|
|
194
|
+
actions.push({
|
|
195
|
+
id: "distro-scripts",
|
|
196
|
+
kind: "distro",
|
|
197
|
+
operation: "repair",
|
|
198
|
+
writes: missingScripts,
|
|
199
|
+
preserves: preservedRuntimeFiles,
|
|
200
|
+
risk: "low",
|
|
201
|
+
reason: "required cross-platform helper scripts are missing",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (context.theme && !context.themeInstalled) {
|
|
206
|
+
actions.push({
|
|
207
|
+
id: "pi-curated-themes",
|
|
208
|
+
kind: "theme-package",
|
|
209
|
+
operation: "verify-or-install-assets",
|
|
210
|
+
writes: ["npm/node_modules/@victor-software-house/pi-curated-themes"],
|
|
211
|
+
preserves: ["settings.json.theme"],
|
|
212
|
+
risk: "low",
|
|
213
|
+
reason: `current theme ${context.theme} is selected but theme assets are missing`,
|
|
214
|
+
});
|
|
215
|
+
warnings.push(`theme ${context.theme} is not installed; update may install assets but will not change the selected theme`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (context.skills.summary.missing > 0) {
|
|
219
|
+
actions.push({
|
|
220
|
+
id: "shared-skills",
|
|
221
|
+
kind: "skill-pack",
|
|
222
|
+
operation: "copy-missing-only",
|
|
223
|
+
writes: [`${context.ctx.skillsDir}/<missing-skill>`],
|
|
224
|
+
preserves: [`${context.ctx.skillsDir}/<existing-different-skill>`],
|
|
225
|
+
risk: "low",
|
|
226
|
+
reason: `${context.skills.summary.missing} shared skills are missing from the active skills root`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (context.skills.summary.conflicts > 0) {
|
|
230
|
+
const conflictMessage = `${context.skills.summary.conflicts} shared skills differ from the bundled baseline`;
|
|
231
|
+
if (context.strictSharedSkills) {
|
|
232
|
+
blocked.push({
|
|
233
|
+
id: "shared-skills",
|
|
234
|
+
kind: "skill-pack",
|
|
235
|
+
reason: `${conflictMessage}; strict mode blocks instead of overwriting`,
|
|
236
|
+
recovery: "inspect with pi-67 skills inventory, then sync or resolve manually",
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
warnings.push(`${conflictMessage}; default update preserves existing different skills`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const repo of context.external) {
|
|
244
|
+
if (!repo.exists) {
|
|
245
|
+
warnings.push(`external repo ${repo.name} is missing; install is explicit via pi-67 external install ${repo.name}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!repo.git?.isRepo) {
|
|
249
|
+
blocked.push({
|
|
250
|
+
id: repo.name,
|
|
251
|
+
kind: "external-repo",
|
|
252
|
+
reason: `${repo.path} exists but is not a git repo`,
|
|
253
|
+
recovery: `inspect the path before running pi-67 external update ${repo.name}`,
|
|
254
|
+
});
|
|
255
|
+
} else if (repo.git.dirty) {
|
|
256
|
+
blocked.push({
|
|
257
|
+
id: repo.name,
|
|
258
|
+
kind: "external-repo",
|
|
259
|
+
reason: `external repo is dirty and will not be updated destructively: ${repo.path}`,
|
|
260
|
+
recovery: `commit/stash the external repo or skip pi-67 external update ${repo.name}`,
|
|
261
|
+
});
|
|
262
|
+
} else if (!repo.git.branch) {
|
|
263
|
+
blocked.push({
|
|
264
|
+
id: repo.name,
|
|
265
|
+
kind: "external-repo",
|
|
266
|
+
reason: `external repo is detached and will not be updated destructively: ${repo.path}`,
|
|
267
|
+
recovery: `checkout a branch before pi-67 external update ${repo.name}`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { actions, blocked, warnings };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function classifyGitShort(short) {
|
|
276
|
+
const preserved = new Set(PRESERVED_RUNTIME_FILES);
|
|
277
|
+
const result = {
|
|
278
|
+
preservedRuntime: [],
|
|
279
|
+
unsafeTracked: [],
|
|
280
|
+
untracked: [],
|
|
281
|
+
};
|
|
282
|
+
for (const line of String(short || "").split(/\r?\n/)) {
|
|
283
|
+
if (!line.trim()) continue;
|
|
284
|
+
const status = line.startsWith("??") ? "??" : line.slice(0, 2);
|
|
285
|
+
const file = parseStatusPath(line);
|
|
286
|
+
if (!file) continue;
|
|
287
|
+
if (status === "??") {
|
|
288
|
+
result.untracked.push(file);
|
|
289
|
+
} else if (preserved.has(file)) {
|
|
290
|
+
result.preservedRuntime.push(file);
|
|
291
|
+
} else {
|
|
292
|
+
result.unsafeTracked.push(file);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
result.preservedRuntime = [...new Set(result.preservedRuntime)].sort();
|
|
296
|
+
result.unsafeTracked = [...new Set(result.unsafeTracked)].sort();
|
|
297
|
+
result.untracked = [...new Set(result.untracked)].sort();
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseStatusPath(line) {
|
|
302
|
+
let file = "";
|
|
303
|
+
if (line.length >= 3 && line[2] === " ") {
|
|
304
|
+
file = line.slice(3).trim();
|
|
305
|
+
} else {
|
|
306
|
+
file = line.replace(/^[ MARCUD?!]{1,2}\s+/, "").trim();
|
|
307
|
+
}
|
|
308
|
+
const arrow = " -> ";
|
|
309
|
+
if (file.includes(arrow)) file = file.slice(file.indexOf(arrow) + arrow.length);
|
|
310
|
+
if (file.startsWith('"') && file.endsWith('"')) file = file.slice(1, -1);
|
|
311
|
+
return file.replace(/\\/g, "/");
|
|
312
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { CliError, info } from "./output.mjs";
|
|
6
|
+
|
|
7
|
+
export const PRESERVED_RUNTIME_FILES = [
|
|
8
|
+
"settings.json",
|
|
9
|
+
"models.json",
|
|
10
|
+
"auth.json",
|
|
11
|
+
"mcp.json",
|
|
12
|
+
"image-gen.json",
|
|
13
|
+
"settings.json.theme",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export function beginUpdateLifecycle(ctx, options = {}) {
|
|
19
|
+
const operation = options.operation || "update";
|
|
20
|
+
const dryRun = Boolean(options.dryRun);
|
|
21
|
+
const lockPath = path.join(ctx.stateDir, "locks", "update.lock");
|
|
22
|
+
const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-${operation}`);
|
|
23
|
+
|
|
24
|
+
if (dryRun) {
|
|
25
|
+
info(`DRY-RUN would acquire update lock: ${lockPath}`);
|
|
26
|
+
info(`DRY-RUN would snapshot preserved runtime files into: ${backupDir}`);
|
|
27
|
+
return {
|
|
28
|
+
lockPath,
|
|
29
|
+
backupDir,
|
|
30
|
+
backedUp: [],
|
|
31
|
+
release() {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
acquireLock(lockPath, { operation });
|
|
36
|
+
let released = false;
|
|
37
|
+
try {
|
|
38
|
+
const backedUp = createRuntimeBackup(ctx, backupDir, {
|
|
39
|
+
operation,
|
|
40
|
+
plan: options.plan,
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
lockPath,
|
|
44
|
+
backupDir,
|
|
45
|
+
backedUp,
|
|
46
|
+
release() {
|
|
47
|
+
if (released) return;
|
|
48
|
+
released = true;
|
|
49
|
+
releaseLock(lockPath);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
releaseLock(lockPath);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createRuntimeBackup(ctx, backupDir, options = {}) {
|
|
59
|
+
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
60
|
+
const filesDir = path.join(backupDir, "files");
|
|
61
|
+
fs.mkdirSync(filesDir, { recursive: true, mode: 0o700 });
|
|
62
|
+
|
|
63
|
+
const preserved = [];
|
|
64
|
+
const backedUp = [];
|
|
65
|
+
for (const rel of PRESERVED_RUNTIME_FILES) {
|
|
66
|
+
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
|
+
const target = path.join(filesDir, rel.replace(/[\\/]/g, "__"));
|
|
75
|
+
fs.copyFileSync(source, target);
|
|
76
|
+
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
|
+
}
|
|
86
|
+
|
|
87
|
+
if (options.plan) {
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(backupDir, "update-plan.json"),
|
|
90
|
+
`${JSON.stringify(options.plan, null, 2)}\n`,
|
|
91
|
+
{ mode: 0o600 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
fs.writeFileSync(
|
|
95
|
+
path.join(backupDir, "backup-manifest.json"),
|
|
96
|
+
`${JSON.stringify({
|
|
97
|
+
schema: "pi67.update-backup.v1",
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
operation: options.operation || "update",
|
|
100
|
+
agentDir: ctx.agentDir,
|
|
101
|
+
repoRoot: ctx.repoRoot,
|
|
102
|
+
files: preserved,
|
|
103
|
+
}, null, 2)}\n`,
|
|
104
|
+
{ mode: 0o600 },
|
|
105
|
+
);
|
|
106
|
+
return backedUp;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listRuntimeBackups(ctx) {
|
|
110
|
+
const backupsDir = path.join(ctx.stateDir, "backups");
|
|
111
|
+
if (!fs.existsSync(backupsDir)) return [];
|
|
112
|
+
return fs.readdirSync(backupsDir, { withFileTypes: true })
|
|
113
|
+
.filter((entry) => entry.isDirectory())
|
|
114
|
+
.map((entry) => backupSummary(path.join(backupsDir, entry.name)))
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.sort((left, right) => String(right.createdAt || right.id).localeCompare(String(left.createdAt || left.id)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function inspectRuntimeBackup(ctx, input) {
|
|
120
|
+
const backupDir = resolveBackupDir(ctx, input);
|
|
121
|
+
const summary = backupSummary(backupDir);
|
|
122
|
+
if (!summary) {
|
|
123
|
+
throw new CliError(`backup manifest not found or unreadable: ${backupDir}`);
|
|
124
|
+
}
|
|
125
|
+
return summary;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function restoreRuntimeBackup(ctx, input, options = {}) {
|
|
129
|
+
const backup = inspectRuntimeBackup(ctx, input);
|
|
130
|
+
const dryRun = Boolean(options.dryRun);
|
|
131
|
+
const preRestoreBackupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-pre-restore`);
|
|
132
|
+
const preRestoreBackedUp = dryRun
|
|
133
|
+
? []
|
|
134
|
+
: createRuntimeBackup(ctx, preRestoreBackupDir, { operation: "pre-restore" });
|
|
135
|
+
const restored = [];
|
|
136
|
+
const removed = [];
|
|
137
|
+
const missing = [];
|
|
138
|
+
const skipped = [];
|
|
139
|
+
|
|
140
|
+
for (const item of backup.files) {
|
|
141
|
+
const rel = String(item.path || "").replace(/\\/g, "/");
|
|
142
|
+
if (!PRESERVED_RUNTIME_FILES.includes(rel)) {
|
|
143
|
+
skipped.push({ path: rel, reason: "not a preserved runtime file" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const source = path.join(backup.path, "files", safeBackupName(rel));
|
|
147
|
+
const target = path.join(ctx.agentDir, rel);
|
|
148
|
+
if (item.exists === false) {
|
|
149
|
+
if (!dryRun && fs.existsSync(target)) {
|
|
150
|
+
fs.rmSync(target, { force: true });
|
|
151
|
+
}
|
|
152
|
+
removed.push({ path: rel, target, reason: "missing-at-backup-time" });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!fs.existsSync(source)) {
|
|
156
|
+
missing.push({ path: rel, source });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!dryRun) {
|
|
160
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
161
|
+
fs.copyFileSync(source, target);
|
|
162
|
+
chmodPrivate(target);
|
|
163
|
+
}
|
|
164
|
+
restored.push({ path: rel, source, target });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
schema: "pi67.backup-restore.v1",
|
|
169
|
+
createdAt: new Date().toISOString(),
|
|
170
|
+
dryRun,
|
|
171
|
+
backup,
|
|
172
|
+
preRestoreBackupDir: dryRun ? "" : preRestoreBackupDir,
|
|
173
|
+
preRestoreBackedUp,
|
|
174
|
+
restored,
|
|
175
|
+
removed,
|
|
176
|
+
missing,
|
|
177
|
+
skipped,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function acquireLock(lockPath, data) {
|
|
182
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
183
|
+
const payload = {
|
|
184
|
+
schema: "pi67.update-lock.v1",
|
|
185
|
+
pid: process.pid,
|
|
186
|
+
createdAt: new Date().toISOString(),
|
|
187
|
+
...data,
|
|
188
|
+
};
|
|
189
|
+
try {
|
|
190
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
191
|
+
fs.writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`);
|
|
192
|
+
fs.closeSync(fd);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error.code !== "EEXIST") throw error;
|
|
195
|
+
if (isStaleLock(lockPath)) {
|
|
196
|
+
fs.unlinkSync(lockPath);
|
|
197
|
+
return acquireLock(lockPath, data);
|
|
198
|
+
}
|
|
199
|
+
throw new CliError(`another pi-67 update appears to be running; lock exists: ${lockPath}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function releaseLock(lockPath) {
|
|
204
|
+
try {
|
|
205
|
+
fs.unlinkSync(lockPath);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error.code !== "ENOENT") throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isStaleLock(lockPath) {
|
|
212
|
+
try {
|
|
213
|
+
const stat = fs.statSync(lockPath);
|
|
214
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_AFTER_MS) return true;
|
|
215
|
+
const data = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
216
|
+
if (Number.isInteger(data.pid) && data.pid > 0) {
|
|
217
|
+
try {
|
|
218
|
+
process.kill(data.pid, 0);
|
|
219
|
+
return false;
|
|
220
|
+
} catch {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sha256File(file) {
|
|
231
|
+
const hash = crypto.createHash("sha256");
|
|
232
|
+
hash.update(fs.readFileSync(file));
|
|
233
|
+
return hash.digest("hex");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function backupSummary(backupDir) {
|
|
237
|
+
const manifestPath = ["backup-manifest.json", "manifest.json"]
|
|
238
|
+
.map((name) => path.join(backupDir, name))
|
|
239
|
+
.find((file) => fs.existsSync(file));
|
|
240
|
+
if (!manifestPath) return null;
|
|
241
|
+
try {
|
|
242
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
243
|
+
const files = Array.isArray(manifest.files)
|
|
244
|
+
? manifest.files.map((item) => ({
|
|
245
|
+
path: item.path,
|
|
246
|
+
exists: item.exists !== false,
|
|
247
|
+
bytes: item.bytes,
|
|
248
|
+
sha256: item.sha256,
|
|
249
|
+
}))
|
|
250
|
+
: (Array.isArray(manifest.paths) ? manifest.paths.map((item) => ({ path: item })) : []);
|
|
251
|
+
const existingFileCount = files.filter((item) => item.exists !== false).length;
|
|
252
|
+
return {
|
|
253
|
+
id: path.basename(backupDir),
|
|
254
|
+
path: backupDir,
|
|
255
|
+
manifestPath,
|
|
256
|
+
schema: manifest.schema || "",
|
|
257
|
+
createdAt: manifest.createdAt || "",
|
|
258
|
+
operation: manifest.operation || inferBackupOperation(path.basename(backupDir)),
|
|
259
|
+
agentDir: manifest.agentDir || "",
|
|
260
|
+
repoRoot: manifest.repoRoot || "",
|
|
261
|
+
fileCount: existingFileCount,
|
|
262
|
+
preservedCount: files.length,
|
|
263
|
+
files,
|
|
264
|
+
};
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function resolveBackupDir(ctx, input) {
|
|
271
|
+
if (!input) throw new CliError("backup id/path is required", 2);
|
|
272
|
+
const expanded = expandHome(String(input));
|
|
273
|
+
if (path.isAbsolute(expanded) || expanded.includes("/") || expanded.includes("\\")) {
|
|
274
|
+
return path.resolve(expanded);
|
|
275
|
+
}
|
|
276
|
+
return path.join(ctx.stateDir, "backups", expanded);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function inferBackupOperation(id) {
|
|
280
|
+
if (id.endsWith("-update")) return "update";
|
|
281
|
+
if (id.endsWith("-repair")) return "repair";
|
|
282
|
+
if (id.endsWith("-themes-set")) return "themes-set";
|
|
283
|
+
if (id.endsWith("-pre-restore")) return "pre-restore";
|
|
284
|
+
if (id.startsWith("pre-update-runtime-")) return "pre-update-runtime";
|
|
285
|
+
return "unknown";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function expandHome(input) {
|
|
289
|
+
if (input === "~") return os.homedir();
|
|
290
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
291
|
+
return path.join(os.homedir(), input.slice(2));
|
|
292
|
+
}
|
|
293
|
+
return input;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function safeBackupName(rel) {
|
|
297
|
+
return rel.replace(/[\\/]/g, "__");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function chmodPrivate(file) {
|
|
301
|
+
try {
|
|
302
|
+
fs.chmodSync(file, 0o600);
|
|
303
|
+
} catch {
|
|
304
|
+
// Best-effort on filesystems that do not support POSIX mode bits.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function timestamp() {
|
|
309
|
+
return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
310
|
+
}
|