@bigking67/pi-67 0.10.1 → 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,29 @@
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
+
15
+ ## [0.10.2]
16
+
17
+ - Reads `Manager latest` through the npm registry HTTP API instead of spawning
18
+ local `npm` / `npm.cmd`, fixing Windows environments where command shims fail
19
+ with `spawnSync npm.cmd EINVAL`.
20
+ - Adds a final Windows `cmd.exe /d /s /c npm.cmd ...` fallback for explicit npm
21
+ operations such as `pi-67 self-update`.
22
+ - Adds read-only visibility for legacy PowerShell
23
+ `~/.pi/agent-backups/pre-update-*` conflict snapshots through
24
+ `pi-67 backups list --include-legacy` and
25
+ `pi-67 backups inspect --legacy <pre-update-id>`.
26
+
3
27
  ## [0.10.1]
4
28
 
5
29
  - Falls back from `npm` to `npm.cmd` on Windows when checking npm registry state
package/README.md CHANGED
@@ -39,7 +39,10 @@ pi-67 update --repair
39
39
  ```
40
40
 
41
41
  `pi-67 update --check` reports whether the npm manager is outdated. Updating the
42
- manager itself is explicit:
42
+ manager itself is explicit. The latest-version check reads the npm registry
43
+ HTTP API directly and does not depend on spawning local `npm` / `npm.cmd`.
44
+ Explicit npm operations such as `self-update` still use npm, with Windows
45
+ fallback through `cmd.exe /d /s /c npm.cmd ...`:
43
46
 
44
47
  ```bash
45
48
  pi-67 self-update
@@ -75,12 +78,17 @@ Before a real `update` or `repair`, the npm manager acquires
75
78
  `~/.pi/pi67/backups/<timestamp>-update/`. This makes the public `npx -y
76
79
  @bigking67/pi-67@latest update --repair` path safe even for in-place checkouts
77
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.
78
84
 
79
85
  Runtime backups are first-class CLI state:
80
86
 
81
87
  ```bash
82
88
  pi-67 backups list
89
+ pi-67 backups list --include-legacy
83
90
  pi-67 backups inspect <backup-id-or-path>
91
+ pi-67 backups inspect <pre-update-id> --legacy
84
92
  pi-67 backups restore --from <backup-id-or-path> --dry-run
85
93
  pi-67 backups restore --from <backup-id-or-path> --yes
86
94
  ```
@@ -88,6 +96,12 @@ pi-67 backups restore --from <backup-id-or-path> --yes
88
96
  The restore command only restores preserved runtime files and writes a
89
97
  pre-restore backup before overwriting current local config.
90
98
 
99
+ Legacy PowerShell `~/.pi/agent-backups/pre-update-*` directories are read-only
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/`.
104
+
91
105
  Theme changes are explicit only:
92
106
 
93
107
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.1",
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
@@ -3,13 +3,15 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
- import { npmPublishTargetStatus } from "../src/lib/npm-registry.mjs";
6
+ import { npmLatestVersion, npmPublishTargetStatus, npmRegistryPackageUrl } from "../src/lib/npm-registry.mjs";
7
7
  import { commandCandidatesForPlatform } from "../src/lib/shell-runner.mjs";
8
8
  import { readExtensionRegistry, validateExtensionRegistry } from "../src/lib/extension-registry.mjs";
9
9
  import { buildPlanDecisions, classifyGitShort } from "../src/lib/update-plan.mjs";
10
10
  import {
11
11
  beginUpdateLifecycle,
12
+ inspectLegacyConflictBackup,
12
13
  inspectRuntimeBackup,
14
+ listLegacyConflictBackups,
13
15
  listRuntimeBackups,
14
16
  restoreRuntimeBackup,
15
17
  } from "../src/lib/update-safety.mjs";
@@ -26,6 +28,7 @@ for (const file of files.filter((item) => item.endsWith(".mjs"))) {
26
28
  for (const file of files.filter((item) => item.endsWith(".json"))) {
27
29
  JSON.parse(fs.readFileSync(file, "utf8"));
28
30
  }
31
+ await runNpmRegistrySelfTests();
29
32
  runPublishTargetSelfTests();
30
33
  runShellRunnerSelfTests();
31
34
  runExtensionRegistrySelfTests();
@@ -94,10 +97,53 @@ function runPublishTargetSelfTests() {
94
97
  );
95
98
  }
96
99
 
100
+ async function runNpmRegistrySelfTests() {
101
+ assert(
102
+ npmRegistryPackageUrl("@bigking67/pi-67") === "https://registry.npmjs.org/@bigking67%2Fpi-67/latest",
103
+ "scoped npm package registry URL must use the direct registry endpoint",
104
+ );
105
+ const current = await npmLatestVersion("@example/pkg", {
106
+ currentVersion: "0.9.0",
107
+ fetchImpl: async () => jsonResponse(200, { version: "1.2.3" }),
108
+ });
109
+ assert(current.ok && current.latestVersion === "1.2.3" && current.outdated, "direct registry latest lookup must parse version payloads");
110
+
111
+ const missing = await npmLatestVersion("@example/missing", {
112
+ fetchImpl: async () => jsonResponse(404, { error: "not found" }),
113
+ });
114
+ assert(missing.message === "not published on npm registry yet", "direct registry 404 must classify unpublished packages");
115
+
116
+ const malformed = await npmLatestVersion("@example/bad", {
117
+ fetchImpl: async () => jsonResponse(200, { name: "@example/bad" }),
118
+ });
119
+ assert(!malformed.ok && malformed.message === "npm registry returned no version", "direct registry malformed payloads must fail closed");
120
+
121
+ const invalidJson = await npmLatestVersion("@example/invalid-json", {
122
+ fetchImpl: async () => ({
123
+ ok: true,
124
+ status: 200,
125
+ statusText: "OK",
126
+ async json() {
127
+ throw new SyntaxError("Unexpected token");
128
+ },
129
+ }),
130
+ });
131
+ assert(invalidJson.message === "npm registry returned invalid JSON", "direct registry invalid JSON must be classified");
132
+
133
+ const timeout = await npmLatestVersion("@example/slow", {
134
+ fetchImpl: async () => {
135
+ const error = new Error("The operation was aborted");
136
+ error.name = "AbortError";
137
+ throw error;
138
+ },
139
+ });
140
+ assert(timeout.message === "npm registry lookup timed out", "direct registry aborts must classify as timeouts");
141
+ }
142
+
97
143
  function runShellRunnerSelfTests() {
98
144
  assert(
99
- JSON.stringify(commandCandidatesForPlatform("npm", "win32")) === JSON.stringify(["npm", "npm.cmd"]),
100
- "Windows npm execution must fall back to npm.cmd",
145
+ JSON.stringify(commandCandidatesForPlatform("npm", "win32")) === JSON.stringify(["npm", "npm.cmd", "cmd.exe"]),
146
+ "Windows npm execution must fall back through npm.cmd and cmd.exe",
101
147
  );
102
148
  assert(
103
149
  JSON.stringify(commandCandidatesForPlatform("git", "win32")) === JSON.stringify(["git"]),
@@ -324,6 +370,20 @@ function runUpdateSafetySelfTests() {
324
370
  listRuntimeBackups(ctx).some((item) => item.path === lifecycle.backupDir),
325
371
  "update lifecycle backups must be listable",
326
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
+ );
327
387
  assert(
328
388
  inspectRuntimeBackup(ctx, lifecycle.backupDir).fileCount >= 2,
329
389
  "update lifecycle backups must be inspectable",
@@ -348,6 +408,18 @@ function runUpdateSafetySelfTests() {
348
408
  fs.readFileSync(path.join(agentDir, "settings.json"), "utf8").includes("\"dark\""),
349
409
  "runtime backup restore must recover the backed up file content",
350
410
  );
411
+ const legacyRoot = path.join(path.dirname(stateDir), "agent-backups", "pre-update-20260707-235901");
412
+ fs.mkdirSync(legacyRoot, { recursive: true });
413
+ fs.writeFileSync(path.join(legacyRoot, "local.diff"), "diff --git a/settings.json b/settings.json\n");
414
+ fs.writeFileSync(path.join(legacyRoot, "settings.json"), "{\"theme\":\"legacy\"}\n");
415
+ assert(
416
+ listLegacyConflictBackups(ctx).some((item) => item.id === "pre-update-20260707-235901" && item.hasLocalDiff),
417
+ "legacy PowerShell conflict backups must be listable",
418
+ );
419
+ assert(
420
+ inspectLegacyConflictBackup(ctx, "pre-update-20260707-235901").files.some((item) => item.name === "local.diff"),
421
+ "legacy PowerShell conflict backups must be inspectable",
422
+ );
351
423
  fs.rmSync(tmpRoot, { recursive: true, force: true });
352
424
  }
353
425
 
@@ -413,6 +485,17 @@ function deepMerge(base, overrides) {
413
485
  return next;
414
486
  }
415
487
 
488
+ function jsonResponse(status, payload) {
489
+ return {
490
+ ok: status >= 200 && status < 300,
491
+ status,
492
+ statusText: status === 404 ? "Not Found" : "OK",
493
+ async json() {
494
+ return payload;
495
+ },
496
+ };
497
+ }
498
+
416
499
  function assert(condition, message) {
417
500
  if (!condition) throw new Error(message);
418
501
  }
@@ -1,6 +1,9 @@
1
+ import path from "node:path";
1
2
  import { parseCommandOptions } from "../lib/args.mjs";
2
3
  import {
4
+ inspectLegacyConflictBackup,
3
5
  inspectRuntimeBackup,
6
+ listLegacyConflictBackups,
4
7
  listRuntimeBackups,
5
8
  restoreRuntimeBackup,
6
9
  } from "../lib/update-safety.mjs";
@@ -20,14 +23,16 @@ export async function backupsCommand(ctx, argv) {
20
23
 
21
24
  function listCommand(ctx, argv) {
22
25
  const { options } = parseCommandOptions(argv, {
23
- bools: ["json"],
26
+ bools: ["json", "include-legacy"],
24
27
  });
25
28
  const backups = listRuntimeBackups(ctx);
29
+ const legacyConflictBackups = listLegacyConflictBackups(ctx);
26
30
  if (ctx.json || options.json) {
27
31
  printJson({
28
32
  schema: "pi67.backups-list.v1",
29
33
  createdAt: new Date().toISOString(),
30
34
  backups,
35
+ legacyConflictBackups: options.includeLegacy ? legacyConflictBackups : undefined,
31
36
  });
32
37
  return;
33
38
  }
@@ -35,21 +40,32 @@ function listCommand(ctx, argv) {
35
40
  keyValue("Backups dir", `${ctx.stateDir}/backups`);
36
41
  if (backups.length === 0) {
37
42
  warn("no runtime backups found");
38
- return;
43
+ } else {
44
+ for (const item of backups) {
45
+ info(`${item.id}: ${item.operation}; files=${item.fileCount}; created=${item.createdAt || "unknown"}`);
46
+ }
39
47
  }
40
- for (const item of backups) {
41
- info(`${item.id}: ${item.operation}; files=${item.fileCount}; created=${item.createdAt || "unknown"}`);
48
+ if (legacyConflictBackups.length > 0) {
49
+ section("Legacy conflict backups");
50
+ keyValue("Backups dir", path.join(path.dirname(ctx.stateDir), "agent-backups"));
51
+ if (!options.includeLegacy) {
52
+ warn(`${legacyConflictBackups.length} legacy pre-update conflict backups found; rerun with --include-legacy to list them.`);
53
+ } else {
54
+ for (const item of legacyConflictBackups) {
55
+ info(`${item.id}: ${item.operation}; files=${item.fileCount}; bytes=${item.totalBytes}; created=${item.createdAt || "unknown"}`);
56
+ }
57
+ }
42
58
  }
43
59
  }
44
60
 
45
61
  function inspectCommand(ctx, argv) {
46
62
  const { options, positionals } = parseCommandOptions(argv, {
47
- bools: ["json"],
63
+ bools: ["json", "legacy"],
48
64
  });
49
- const backup = inspectRuntimeBackup(ctx, positionals[0]);
65
+ const backup = options.legacy ? inspectLegacyConflictBackup(ctx, positionals[0]) : inspectRuntimeBackup(ctx, positionals[0]);
50
66
  if (ctx.json || options.json) {
51
67
  printJson({
52
- schema: "pi67.backup-inspect.v1",
68
+ schema: options.legacy ? "pi67.legacy-conflict-backup-inspect.v1" : "pi67.backup-inspect.v1",
53
69
  createdAt: new Date().toISOString(),
54
70
  backup,
55
71
  });
@@ -60,8 +76,13 @@ function inspectCommand(ctx, argv) {
60
76
  keyValue("Path", backup.path);
61
77
  keyValue("Created", backup.createdAt || "unknown");
62
78
  keyValue("Operation", backup.operation);
79
+ if (backup.reason) keyValue("Reason", backup.reason);
63
80
  keyValue("Files", `${backup.fileCount}${backup.preservedCount === undefined ? "" : ` present / ${backup.preservedCount} preserved slots`}`);
64
81
  for (const file of backup.files) {
82
+ if (file.name) {
83
+ info(`${file.name}${file.bytes === undefined ? "" : ` bytes=${file.bytes}`}`);
84
+ continue;
85
+ }
65
86
  if (file.exists === false) {
66
87
  info(`${file.path} missing-at-backup-time`);
67
88
  } else {
@@ -111,13 +132,15 @@ function printBackupsHelp() {
111
132
  process.stdout.write(`pi-67 backups - inspect and restore preserved runtime backups
112
133
 
113
134
  Usage:
114
- pi-67 backups list [--json]
115
- pi-67 backups inspect <backup-id-or-path> [--json]
135
+ pi-67 backups list [--include-legacy] [--json]
136
+ pi-67 backups inspect <backup-id-or-path> [--legacy] [--json]
116
137
  pi-67 backups restore --from <backup-id-or-path> [--dry-run] [--yes] [--json]
117
138
 
118
139
  Examples:
119
140
  pi-67 backups list
141
+ pi-67 backups list --include-legacy
120
142
  pi-67 backups inspect 20260707T120000Z-update
143
+ pi-67 backups inspect pre-update-20260707-235901 --legacy
121
144
  pi-67 backups restore --from 20260707T120000Z-update --dry-run
122
145
  pi-67 backups restore --from 20260707T120000Z-update --yes
123
146
  `);
@@ -37,7 +37,7 @@ function list(ctx, argv) {
37
37
  }
38
38
  }
39
39
 
40
- function doctor(ctx, argv) {
40
+ async function doctor(ctx, argv) {
41
41
  const { options } = parseCommandOptions(argv, {
42
42
  bools: ["json", "strict-shared-skills", "no-remote"],
43
43
  });
@@ -46,7 +46,7 @@ function doctor(ctx, argv) {
46
46
  manifest,
47
47
  requiredIds: REQUIRED_EXTENSION_REGISTRY_IDS,
48
48
  });
49
- const updatePlan = buildUpdatePlan(ctx, {
49
+ const updatePlan = await buildUpdatePlan(ctx, {
50
50
  noRemote: ctx.noRemote || options.noRemote,
51
51
  strictSharedSkills: options.strictSharedSkills,
52
52
  });
@@ -120,11 +120,11 @@ function inspect(ctx, argv) {
120
120
  for (const smoke of entry.smoke) info(smoke);
121
121
  }
122
122
 
123
- function plan(ctx, argv) {
123
+ async function plan(ctx, argv) {
124
124
  const { options } = parseCommandOptions(argv, {
125
125
  bools: ["json", "strict-shared-skills", "no-remote"],
126
126
  });
127
- const updatePlan = buildUpdatePlan(ctx, {
127
+ const updatePlan = await buildUpdatePlan(ctx, {
128
128
  noRemote: ctx.noRemote || options.noRemote,
129
129
  strictSharedSkills: options.strictSharedSkills,
130
130
  });
@@ -18,7 +18,7 @@ export async function publishCheckCommand(ctx, argv) {
18
18
  const noRemote = ctx.noRemote || options.noRemote;
19
19
  const noPack = options.noPack;
20
20
  const strict = options.strict;
21
- const report = buildPublishCheck(ctx, {
21
+ const report = await buildPublishCheck(ctx, {
22
22
  noRemote,
23
23
  noPack,
24
24
  allowFirstPublish: options.allowFirstPublish,
@@ -35,14 +35,14 @@ export async function publishCheckCommand(ctx, argv) {
35
35
  }
36
36
  }
37
37
 
38
- function buildPublishCheck(ctx, options) {
38
+ async function buildPublishCheck(ctx, options) {
39
39
  const pkg = readCliPackageJson();
40
40
  const rootPackage = readJsonIfExists(path.join(ctx.repoRoot, "package.json")) || {};
41
41
  const versionFile = readTextIfExists(path.join(ctx.repoRoot, "VERSION")).trim();
42
42
  const workflowFile = path.join(ctx.repoRoot, ".github", "workflows", "npm-publish.yml");
43
43
  const workflow = workflowCheck(workflowFile);
44
44
  const git = fs.existsSync(ctx.repoRoot) ? gitStatus(ctx.repoRoot) : { isRepo: false };
45
- const registry = npmLatestVersion(pkg.name, {
45
+ const registry = await npmLatestVersion(pkg.name, {
46
46
  currentVersion: pkg.version,
47
47
  noRemote: options.noRemote,
48
48
  timeoutMs: 10000,
@@ -6,7 +6,7 @@ export async function statusCommand(ctx, argv) {
6
6
  const { options } = parseCommandOptions(argv, {
7
7
  bools: ["json", "no-remote"],
8
8
  });
9
- const plan = buildUpdatePlan(ctx, { noRemote: ctx.noRemote || options.noRemote });
9
+ const plan = await buildUpdatePlan(ctx, { noRemote: ctx.noRemote || options.noRemote });
10
10
  if (ctx.json || options.json) {
11
11
  printJson(plan);
12
12
  return;
@@ -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}`);
@@ -27,7 +27,7 @@ export async function updateCommand(ctx, argv) {
27
27
  const dryRun = ctx.dryRun || options.dryRun;
28
28
  const json = ctx.json || options.json;
29
29
  if (options.check) {
30
- const plan = buildUpdatePlan(ctx, {
30
+ const plan = await buildUpdatePlan(ctx, {
31
31
  noRemote: ctx.noRemote || options.noRemote,
32
32
  strictSharedSkills: options.strictSharedSkills,
33
33
  });
@@ -39,7 +39,7 @@ export async function updateCommand(ctx, argv) {
39
39
  return;
40
40
  }
41
41
 
42
- const plan = buildUpdatePlan(ctx, {
42
+ const plan = await buildUpdatePlan(ctx, {
43
43
  noRemote: ctx.noRemote || options.noRemote,
44
44
  strictSharedSkills: options.strictSharedSkills,
45
45
  });
@@ -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()
@@ -1,23 +1,22 @@
1
1
  import { captureCommand } from "./shell-runner.mjs";
2
2
 
3
- export function npmLatestVersion(packageName, options = {}) {
3
+ const DEFAULT_REGISTRY_BASE_URL = "https://registry.npmjs.org";
4
+
5
+ export async function npmLatestVersion(packageName, options = {}) {
4
6
  if (options.noRemote) {
5
7
  return { skipped: true, ok: false, latestVersion: "", outdated: false, message: "remote checks skipped" };
6
8
  }
7
- const result = captureCommand("npm", ["view", packageName, "version", "--json"], {
8
- timeoutMs: options.timeoutMs || 8000,
9
- });
9
+ const result = await npmRegistryLatestPayload(packageName, options);
10
10
  if (!result.ok) {
11
- const rawMessage = result.stderr || result.error || "npm registry lookup failed";
12
11
  return {
13
12
  skipped: false,
14
13
  ok: false,
15
14
  latestVersion: "",
16
15
  outdated: false,
17
- message: rawMessage.includes("E404") ? "not published on npm registry yet" : compactMessage(rawMessage),
16
+ message: result.message,
18
17
  };
19
18
  }
20
- const latestVersion = parseNpmVersion(result.stdout);
19
+ const latestVersion = parseNpmLatestPayload(result.payload);
21
20
  return {
22
21
  skipped: false,
23
22
  ok: Boolean(latestVersion),
@@ -27,6 +26,55 @@ export function npmLatestVersion(packageName, options = {}) {
27
26
  };
28
27
  }
29
28
 
29
+ export function npmRegistryPackageUrl(packageName, options = {}) {
30
+ const baseUrl = String(options.registryBaseUrl || DEFAULT_REGISTRY_BASE_URL).replace(/\/+$/, "");
31
+ const encodedPackage = encodeURIComponent(String(packageName || "")).replace(/^%40/, "@");
32
+ return `${baseUrl}/${encodedPackage}/latest`;
33
+ }
34
+
35
+ async function npmRegistryLatestPayload(packageName, options = {}) {
36
+ const timeoutMs = options.timeoutMs || 8000;
37
+ const controller = new AbortController();
38
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
39
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
40
+ if (typeof fetchImpl !== "function") {
41
+ clearTimeout(timeout);
42
+ return {
43
+ ok: false,
44
+ message: "npm registry lookup failed: fetch unavailable in this Node runtime",
45
+ };
46
+ }
47
+ try {
48
+ const response = await fetchImpl(npmRegistryPackageUrl(packageName, options), {
49
+ signal: controller.signal,
50
+ headers: {
51
+ accept: "application/json",
52
+ "user-agent": "pi-67-manager",
53
+ },
54
+ });
55
+ if (!response.ok) {
56
+ return {
57
+ ok: false,
58
+ message: response.status === 404
59
+ ? "not published on npm registry yet"
60
+ : `npm registry lookup failed: HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`,
61
+ };
62
+ }
63
+ try {
64
+ return { ok: true, payload: await response.json() };
65
+ } catch {
66
+ return { ok: false, message: "npm registry returned invalid JSON" };
67
+ }
68
+ } catch (error) {
69
+ return {
70
+ ok: false,
71
+ message: registryErrorMessage(error),
72
+ };
73
+ } finally {
74
+ clearTimeout(timeout);
75
+ }
76
+ }
77
+
30
78
  export function npmPackageScopeStatus(packageName, options = {}) {
31
79
  const scope = packageScope(packageName);
32
80
  if (!scope) {
@@ -228,15 +276,14 @@ function packageScope(packageName) {
228
276
  return match ? match[1] : "";
229
277
  }
230
278
 
231
- function parseNpmVersion(value) {
232
- const trimmed = String(value || "").trim();
233
- if (!trimmed) return "";
234
- try {
235
- const parsed = JSON.parse(trimmed);
236
- return typeof parsed === "string" ? parsed : "";
237
- } catch {
238
- return trimmed.replace(/^"|"$/g, "");
239
- }
279
+ function parseNpmLatestPayload(payload) {
280
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "";
281
+ return typeof payload.version === "string" ? payload.version.trim() : "";
282
+ }
283
+
284
+ function registryErrorMessage(error) {
285
+ if (error?.name === "AbortError") return "npm registry lookup timed out";
286
+ return `npm registry lookup failed: ${compactMessage(error?.message || error || "unknown error")}`;
240
287
  }
241
288
 
242
289
  function parseJsonObject(value) {
@@ -41,21 +41,34 @@ export function captureCommand(command, args = [], options = {}) {
41
41
 
42
42
  export function commandCandidatesForPlatform(command, platform = process.platform) {
43
43
  if (platform === "win32" && command === "npm") {
44
- return ["npm", "npm.cmd"];
44
+ return ["npm", "npm.cmd", "cmd.exe"];
45
45
  }
46
46
  return [command];
47
47
  }
48
48
 
49
49
  function spawnWithFallback(command, args, options) {
50
50
  let lastResult;
51
- for (const candidate of commandCandidatesForPlatform(command)) {
52
- const result = spawnSync(candidate, args, options);
51
+ for (const candidate of commandInvocationsForPlatform(command, args)) {
52
+ const result = spawnSync(candidate.command, candidate.args, options);
53
53
  lastResult = result;
54
- if (!isCommandNotFound(result)) return { command: candidate, result };
54
+ if (!isRetryableSpawnFailure(command, result)) return { command: candidate.command, result };
55
55
  }
56
56
  return { command, result: lastResult };
57
57
  }
58
58
 
59
- function isCommandNotFound(result) {
60
- return result?.error?.code === "ENOENT";
59
+ function commandInvocationsForPlatform(command, args, platform = process.platform) {
60
+ if (platform === "win32" && command === "npm") {
61
+ return [
62
+ { command: "npm", args },
63
+ { command: "npm.cmd", args },
64
+ { command: process.env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", "npm.cmd", ...args] },
65
+ ];
66
+ }
67
+ return [{ command, args }];
68
+ }
69
+
70
+ function isRetryableSpawnFailure(command, result, platform = process.platform) {
71
+ if (result?.error?.code === "ENOENT") return true;
72
+ if (platform === "win32" && command === "npm" && result?.error?.code === "EINVAL") return true;
73
+ return false;
61
74
  }
@@ -10,7 +10,7 @@ import { npmLatestVersion } from "./npm-registry.mjs";
10
10
  import { buildDistroManifest } from "./distro-manifest.mjs";
11
11
  import { PRESERVED_RUNTIME_FILES } from "./update-safety.mjs";
12
12
 
13
- export function buildUpdatePlan(ctx, options = {}) {
13
+ export async function buildUpdatePlan(ctx, options = {}) {
14
14
  const versionFile = path.join(ctx.repoRoot, "VERSION");
15
15
  const settings = readJsonFileIfExists(path.join(ctx.agentDir, "settings.json")) || {};
16
16
  const theme = currentTheme(ctx);
@@ -35,7 +35,7 @@ export function buildUpdatePlan(ctx, options = {}) {
35
35
  fs.existsSync(path.join(ctx.repoRoot, "scripts", name)),
36
36
  ]));
37
37
  const pkg = readCliPackageJson();
38
- const managerRegistry = npmLatestVersion(pkg.name, {
38
+ const managerRegistry = await npmLatestVersion(pkg.name, {
39
39
  currentVersion: pkg.version,
40
40
  noRemote: options.noRemote,
41
41
  });
@@ -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) {
@@ -116,6 +125,16 @@ export function listRuntimeBackups(ctx) {
116
125
  .sort((left, right) => String(right.createdAt || right.id).localeCompare(String(left.createdAt || left.id)));
117
126
  }
118
127
 
128
+ export function listLegacyConflictBackups(ctx) {
129
+ const root = legacyConflictBackupsDir(ctx);
130
+ if (!fs.existsSync(root)) return [];
131
+ return fs.readdirSync(root, { withFileTypes: true })
132
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("pre-update-"))
133
+ .map((entry) => legacyConflictBackupSummary(path.join(root, entry.name)))
134
+ .filter(Boolean)
135
+ .sort((left, right) => String(right.createdAt || right.id).localeCompare(String(left.createdAt || left.id)));
136
+ }
137
+
119
138
  export function inspectRuntimeBackup(ctx, input) {
120
139
  const backupDir = resolveBackupDir(ctx, input);
121
140
  const summary = backupSummary(backupDir);
@@ -125,6 +144,15 @@ export function inspectRuntimeBackup(ctx, input) {
125
144
  return summary;
126
145
  }
127
146
 
147
+ export function inspectLegacyConflictBackup(ctx, input) {
148
+ const backupDir = resolveLegacyConflictBackupDir(ctx, input);
149
+ const summary = legacyConflictBackupSummary(backupDir, { includeFiles: true });
150
+ if (!summary) {
151
+ throw new CliError(`legacy conflict backup not found or unreadable: ${backupDir}`);
152
+ }
153
+ return summary;
154
+ }
155
+
128
156
  export function restoreRuntimeBackup(ctx, input, options = {}) {
129
157
  const backup = inspectRuntimeBackup(ctx, input);
130
158
  const dryRun = Boolean(options.dryRun);
@@ -227,6 +255,39 @@ function isStaleLock(lockPath) {
227
255
  return false;
228
256
  }
229
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
+
230
291
  function sha256File(file) {
231
292
  const hash = crypto.createHash("sha256");
232
293
  hash.update(fs.readFileSync(file));
@@ -267,6 +328,46 @@ function backupSummary(backupDir) {
267
328
  }
268
329
  }
269
330
 
331
+ function legacyConflictBackupsDir(ctx) {
332
+ return path.join(path.dirname(ctx.stateDir), "agent-backups");
333
+ }
334
+
335
+ function legacyConflictBackupSummary(backupDir, options = {}) {
336
+ try {
337
+ if (!fs.existsSync(backupDir) || !fs.statSync(backupDir).isDirectory()) return null;
338
+ const entries = fs.readdirSync(backupDir, { withFileTypes: true })
339
+ .filter((entry) => entry.isFile())
340
+ .map((entry) => {
341
+ const file = path.join(backupDir, entry.name);
342
+ const stat = fs.statSync(file);
343
+ return {
344
+ name: entry.name,
345
+ path: file,
346
+ bytes: stat.size,
347
+ mtime: stat.mtime.toISOString(),
348
+ };
349
+ });
350
+ const stat = fs.statSync(backupDir);
351
+ const totalBytes = entries.reduce((sum, item) => sum + item.bytes, 0);
352
+ const localDiff = entries.find((item) => item.name === "local.diff");
353
+ return {
354
+ id: path.basename(backupDir),
355
+ path: backupDir,
356
+ schema: "pi67.legacy-conflict-backup.v1",
357
+ createdAt: stat.mtime.toISOString(),
358
+ operation: "known-migration-conflict",
359
+ reason: "PowerShell updater preserved known migration conflict files before restoring them from HEAD",
360
+ fileCount: entries.length,
361
+ totalBytes,
362
+ hasLocalDiff: Boolean(localDiff),
363
+ localDiffPath: localDiff?.path || "",
364
+ files: options.includeFiles ? entries : undefined,
365
+ };
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+
270
371
  function resolveBackupDir(ctx, input) {
271
372
  if (!input) throw new CliError("backup id/path is required", 2);
272
373
  const expanded = expandHome(String(input));
@@ -276,6 +377,15 @@ function resolveBackupDir(ctx, input) {
276
377
  return path.join(ctx.stateDir, "backups", expanded);
277
378
  }
278
379
 
380
+ function resolveLegacyConflictBackupDir(ctx, input) {
381
+ if (!input) throw new CliError("legacy backup id/path is required", 2);
382
+ const expanded = expandHome(String(input));
383
+ if (path.isAbsolute(expanded) || expanded.includes("/") || expanded.includes("\\")) {
384
+ return path.resolve(expanded);
385
+ }
386
+ return path.join(legacyConflictBackupsDir(ctx), expanded);
387
+ }
388
+
279
389
  function inferBackupOperation(id) {
280
390
  if (id.endsWith("-update")) return "update";
281
391
  if (id.endsWith("-repair")) return "repair";