@bigking67/pi-67 0.10.2 → 0.10.3

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.3]
4
+
5
+ - Deduplicates real update/repair runtime backups when preserved config files
6
+ are unchanged from the latest same-operation backup.
7
+ - Deduplicates direct PowerShell `scripts/pi67-update.ps1` dirty runtime-config
8
+ preservation backups under `~/.pi/pi67/backups/`.
9
+ - Removes the legacy PowerShell updater path that wrote new
10
+ `~/.pi/agent-backups/pre-update-*` snapshots; runtime preservation now stays
11
+ under `~/.pi/pi67/backups/`.
12
+ - Avoids writing a theme backup when `pi-67 themes set <name>` is already the
13
+ active theme.
14
+
3
15
  ## [0.10.2]
4
16
 
5
17
  - Reads `Manager latest` through the npm registry HTTP API instead of spawning
package/README.md CHANGED
@@ -78,6 +78,9 @@ Before a real `update` or `repair`, the npm manager acquires
78
78
  `~/.pi/pi67/backups/<timestamp>-update/`. This makes the public `npx -y
79
79
  @bigking67/pi-67@latest update --repair` path safe even for in-place checkouts
80
80
  where `settings.json` is tracked but user-owned.
81
+ If the preserved runtime files are unchanged from the latest same-operation
82
+ backup, the manager reuses that snapshot instead of writing another timestamped
83
+ directory.
81
84
 
82
85
  Runtime backups are first-class CLI state:
83
86
 
@@ -94,9 +97,10 @@ The restore command only restores preserved runtime files and writes a
94
97
  pre-restore backup before overwriting current local config.
95
98
 
96
99
  Legacy PowerShell `~/.pi/agent-backups/pre-update-*` directories are read-only
97
- known-conflict snapshots from older migration paths. They are listed only with
98
- `--include-legacy` and are intentionally separate from restorable runtime
99
- backups under `~/.pi/pi67/backups/`.
100
+ known-conflict snapshots from older migration paths; the normal updater no
101
+ longer writes new ones. They are listed only with `--include-legacy` and are
102
+ intentionally separate from restorable runtime backups under
103
+ `~/.pi/pi67/backups/`.
100
104
 
101
105
  Theme changes are explicit only:
102
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Cross-platform manager CLI for the pi-67 Pi Coding Agent distribution.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/check.mjs CHANGED
@@ -370,6 +370,20 @@ function runUpdateSafetySelfTests() {
370
370
  listRuntimeBackups(ctx).some((item) => item.path === lifecycle.backupDir),
371
371
  "update lifecycle backups must be listable",
372
372
  );
373
+ const backupCountBeforeDedupe = listRuntimeBackups(ctx).length;
374
+ const duplicateLifecycle = beginUpdateLifecycle({
375
+ agentDir,
376
+ repoRoot: agentDir,
377
+ stateDir,
378
+ }, {
379
+ operation: "test",
380
+ });
381
+ assert(duplicateLifecycle.backupSkipped, "unchanged update lifecycle snapshots must be deduplicated");
382
+ duplicateLifecycle.release();
383
+ assert(
384
+ listRuntimeBackups(ctx).length === backupCountBeforeDedupe,
385
+ "deduplicated update lifecycle snapshots must not create new backup directories",
386
+ );
373
387
  assert(
374
388
  inspectRuntimeBackup(ctx, lifecycle.backupDir).fileCount >= 2,
375
389
  "update lifecycle backups must be inspectable",
@@ -56,6 +56,10 @@ function setTheme(ctx, argv) {
56
56
  const settingsFile = path.join(ctx.agentDir, "settings.json");
57
57
  const settings = readJsonFileIfExists(settingsFile) || {};
58
58
  const previous = settings.theme || "";
59
+ if (previous === name) {
60
+ pass(`theme already set: ${name}`);
61
+ return;
62
+ }
59
63
  settings.theme = name;
60
64
  if (ctx.dryRun || options.dryRun) {
61
65
  info(`DRY-RUN set theme ${previous || "unset"} -> ${name}`);
@@ -50,6 +50,8 @@ export async function updateCommand(ctx, argv) {
50
50
  });
51
51
  if (!dryRun && lifecycle.backedUp.length > 0) {
52
52
  info(`Preserved runtime backup: ${lifecycle.backupDir}`);
53
+ } else if (!dryRun && lifecycle.backupSkipped) {
54
+ info(`Preserved runtime backup skipped: ${lifecycle.backupReason}`);
53
55
  }
54
56
 
55
57
  const args = isWindows()
@@ -35,14 +35,19 @@ export function beginUpdateLifecycle(ctx, options = {}) {
35
35
  acquireLock(lockPath, { operation });
36
36
  let released = false;
37
37
  try {
38
- const backedUp = createRuntimeBackup(ctx, backupDir, {
38
+ const backup = createRuntimeBackup(ctx, backupDir, {
39
39
  operation,
40
40
  plan: options.plan,
41
+ dedupe: true,
42
+ returnResult: true,
41
43
  });
42
44
  return {
43
45
  lockPath,
44
- backupDir,
45
- backedUp,
46
+ backupDir: backup.backupDir,
47
+ backedUp: backup.backedUp,
48
+ backupSkipped: backup.skipped,
49
+ backupReason: backup.reason || "",
50
+ reusedBackupDir: backup.reusedBackupDir || "",
46
51
  release() {
47
52
  if (released) return;
48
53
  released = true;
@@ -56,34 +61,37 @@ export function beginUpdateLifecycle(ctx, options = {}) {
56
61
  }
57
62
 
58
63
  export function createRuntimeBackup(ctx, backupDir, options = {}) {
64
+ const operation = options.operation || "update";
65
+ const preserved = collectPreservedRuntimeFiles(ctx);
66
+ if (options.dedupe) {
67
+ const equivalent = findEquivalentRuntimeBackup(ctx, preserved, operation);
68
+ if (equivalent) {
69
+ const result = {
70
+ backupDir: equivalent.path,
71
+ backedUp: [],
72
+ skipped: true,
73
+ reusedBackupDir: equivalent.path,
74
+ reason: `same preserved runtime snapshot already exists: ${equivalent.id}`,
75
+ };
76
+ return options.returnResult ? result : result.backedUp;
77
+ }
78
+ }
79
+
59
80
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
60
81
  const filesDir = path.join(backupDir, "files");
61
82
  fs.mkdirSync(filesDir, { recursive: true, mode: 0o700 });
62
83
 
63
- const preserved = [];
64
- const backedUp = [];
65
84
  for (const rel of PRESERVED_RUNTIME_FILES) {
85
+ const item = preserved.find((entry) => entry.path === rel);
86
+ if (!item?.exists) continue;
66
87
  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
88
  const target = path.join(filesDir, rel.replace(/[\\/]/g, "__"));
75
89
  fs.copyFileSync(source, target);
76
90
  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
91
  }
86
92
 
93
+ const backedUp = preserved.filter((item) => item.exists !== false);
94
+
87
95
  if (options.plan) {
88
96
  fs.writeFileSync(
89
97
  path.join(backupDir, "update-plan.json"),
@@ -96,14 +104,15 @@ export function createRuntimeBackup(ctx, backupDir, options = {}) {
96
104
  `${JSON.stringify({
97
105
  schema: "pi67.update-backup.v1",
98
106
  createdAt: new Date().toISOString(),
99
- operation: options.operation || "update",
107
+ operation,
100
108
  agentDir: ctx.agentDir,
101
109
  repoRoot: ctx.repoRoot,
102
110
  files: preserved,
103
111
  }, null, 2)}\n`,
104
112
  { mode: 0o600 },
105
113
  );
106
- return backedUp;
114
+ const result = { backupDir, backedUp, skipped: false, reusedBackupDir: "", reason: "" };
115
+ return options.returnResult ? result : backedUp;
107
116
  }
108
117
 
109
118
  export function listRuntimeBackups(ctx) {
@@ -246,6 +255,39 @@ function isStaleLock(lockPath) {
246
255
  return false;
247
256
  }
248
257
 
258
+ function collectPreservedRuntimeFiles(ctx) {
259
+ return PRESERVED_RUNTIME_FILES.map((rel) => {
260
+ const source = path.join(ctx.agentDir, rel);
261
+ if (!fs.existsSync(source)) {
262
+ return { path: rel, exists: false };
263
+ }
264
+ const stat = fs.statSync(source);
265
+ return {
266
+ path: rel,
267
+ exists: true,
268
+ bytes: stat.size,
269
+ sha256: sha256File(source),
270
+ };
271
+ });
272
+ }
273
+
274
+ function findEquivalentRuntimeBackup(ctx, preserved, operation) {
275
+ return listRuntimeBackups(ctx).find((backup) =>
276
+ backup.operation === operation && sameRuntimeFiles(backup.files, preserved));
277
+ }
278
+
279
+ function sameRuntimeFiles(left, right) {
280
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
281
+ const leftByPath = new Map(left.map((item) => [item.path, item]));
282
+ return right.every((rightItem) => {
283
+ const leftItem = leftByPath.get(rightItem.path);
284
+ if (!leftItem) return false;
285
+ if ((leftItem.exists !== false) !== (rightItem.exists !== false)) return false;
286
+ if (rightItem.exists === false) return true;
287
+ return leftItem.bytes === rightItem.bytes && leftItem.sha256 === rightItem.sha256;
288
+ });
289
+ }
290
+
249
291
  function sha256File(file) {
250
292
  const hash = crypto.createHash("sha256");
251
293
  hash.update(fs.readFileSync(file));