@bigking67/pi-67 0.10.1 → 0.10.2

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.2]
4
+
5
+ - Reads `Manager latest` through the npm registry HTTP API instead of spawning
6
+ local `npm` / `npm.cmd`, fixing Windows environments where command shims fail
7
+ with `spawnSync npm.cmd EINVAL`.
8
+ - Adds a final Windows `cmd.exe /d /s /c npm.cmd ...` fallback for explicit npm
9
+ operations such as `pi-67 self-update`.
10
+ - Adds read-only visibility for legacy PowerShell
11
+ `~/.pi/agent-backups/pre-update-*` conflict snapshots through
12
+ `pi-67 backups list --include-legacy` and
13
+ `pi-67 backups inspect --legacy <pre-update-id>`.
14
+
3
15
  ## [0.10.1]
4
16
 
5
17
  - 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
@@ -80,7 +83,9 @@ Runtime backups are first-class CLI state:
80
83
 
81
84
  ```bash
82
85
  pi-67 backups list
86
+ pi-67 backups list --include-legacy
83
87
  pi-67 backups inspect <backup-id-or-path>
88
+ pi-67 backups inspect <pre-update-id> --legacy
84
89
  pi-67 backups restore --from <backup-id-or-path> --dry-run
85
90
  pi-67 backups restore --from <backup-id-or-path> --yes
86
91
  ```
@@ -88,6 +93,11 @@ pi-67 backups restore --from <backup-id-or-path> --yes
88
93
  The restore command only restores preserved runtime files and writes a
89
94
  pre-restore backup before overwriting current local config.
90
95
 
96
+ 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
+
91
101
  Theme changes are explicit only:
92
102
 
93
103
  ```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.2",
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"]),
@@ -348,6 +394,18 @@ function runUpdateSafetySelfTests() {
348
394
  fs.readFileSync(path.join(agentDir, "settings.json"), "utf8").includes("\"dark\""),
349
395
  "runtime backup restore must recover the backed up file content",
350
396
  );
397
+ const legacyRoot = path.join(path.dirname(stateDir), "agent-backups", "pre-update-20260707-235901");
398
+ fs.mkdirSync(legacyRoot, { recursive: true });
399
+ fs.writeFileSync(path.join(legacyRoot, "local.diff"), "diff --git a/settings.json b/settings.json\n");
400
+ fs.writeFileSync(path.join(legacyRoot, "settings.json"), "{\"theme\":\"legacy\"}\n");
401
+ assert(
402
+ listLegacyConflictBackups(ctx).some((item) => item.id === "pre-update-20260707-235901" && item.hasLocalDiff),
403
+ "legacy PowerShell conflict backups must be listable",
404
+ );
405
+ assert(
406
+ inspectLegacyConflictBackup(ctx, "pre-update-20260707-235901").files.some((item) => item.name === "local.diff"),
407
+ "legacy PowerShell conflict backups must be inspectable",
408
+ );
351
409
  fs.rmSync(tmpRoot, { recursive: true, force: true });
352
410
  }
353
411
 
@@ -413,6 +471,17 @@ function deepMerge(base, overrides) {
413
471
  return next;
414
472
  }
415
473
 
474
+ function jsonResponse(status, payload) {
475
+ return {
476
+ ok: status >= 200 && status < 300,
477
+ status,
478
+ statusText: status === 404 ? "Not Found" : "OK",
479
+ async json() {
480
+ return payload;
481
+ },
482
+ };
483
+ }
484
+
416
485
  function assert(condition, message) {
417
486
  if (!condition) throw new Error(message);
418
487
  }
@@ -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;
@@ -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
  });
@@ -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
  });
@@ -116,6 +116,16 @@ export function listRuntimeBackups(ctx) {
116
116
  .sort((left, right) => String(right.createdAt || right.id).localeCompare(String(left.createdAt || left.id)));
117
117
  }
118
118
 
119
+ export function listLegacyConflictBackups(ctx) {
120
+ const root = legacyConflictBackupsDir(ctx);
121
+ if (!fs.existsSync(root)) return [];
122
+ return fs.readdirSync(root, { withFileTypes: true })
123
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("pre-update-"))
124
+ .map((entry) => legacyConflictBackupSummary(path.join(root, entry.name)))
125
+ .filter(Boolean)
126
+ .sort((left, right) => String(right.createdAt || right.id).localeCompare(String(left.createdAt || left.id)));
127
+ }
128
+
119
129
  export function inspectRuntimeBackup(ctx, input) {
120
130
  const backupDir = resolveBackupDir(ctx, input);
121
131
  const summary = backupSummary(backupDir);
@@ -125,6 +135,15 @@ export function inspectRuntimeBackup(ctx, input) {
125
135
  return summary;
126
136
  }
127
137
 
138
+ export function inspectLegacyConflictBackup(ctx, input) {
139
+ const backupDir = resolveLegacyConflictBackupDir(ctx, input);
140
+ const summary = legacyConflictBackupSummary(backupDir, { includeFiles: true });
141
+ if (!summary) {
142
+ throw new CliError(`legacy conflict backup not found or unreadable: ${backupDir}`);
143
+ }
144
+ return summary;
145
+ }
146
+
128
147
  export function restoreRuntimeBackup(ctx, input, options = {}) {
129
148
  const backup = inspectRuntimeBackup(ctx, input);
130
149
  const dryRun = Boolean(options.dryRun);
@@ -267,6 +286,46 @@ function backupSummary(backupDir) {
267
286
  }
268
287
  }
269
288
 
289
+ function legacyConflictBackupsDir(ctx) {
290
+ return path.join(path.dirname(ctx.stateDir), "agent-backups");
291
+ }
292
+
293
+ function legacyConflictBackupSummary(backupDir, options = {}) {
294
+ try {
295
+ if (!fs.existsSync(backupDir) || !fs.statSync(backupDir).isDirectory()) return null;
296
+ const entries = fs.readdirSync(backupDir, { withFileTypes: true })
297
+ .filter((entry) => entry.isFile())
298
+ .map((entry) => {
299
+ const file = path.join(backupDir, entry.name);
300
+ const stat = fs.statSync(file);
301
+ return {
302
+ name: entry.name,
303
+ path: file,
304
+ bytes: stat.size,
305
+ mtime: stat.mtime.toISOString(),
306
+ };
307
+ });
308
+ const stat = fs.statSync(backupDir);
309
+ const totalBytes = entries.reduce((sum, item) => sum + item.bytes, 0);
310
+ const localDiff = entries.find((item) => item.name === "local.diff");
311
+ return {
312
+ id: path.basename(backupDir),
313
+ path: backupDir,
314
+ schema: "pi67.legacy-conflict-backup.v1",
315
+ createdAt: stat.mtime.toISOString(),
316
+ operation: "known-migration-conflict",
317
+ reason: "PowerShell updater preserved known migration conflict files before restoring them from HEAD",
318
+ fileCount: entries.length,
319
+ totalBytes,
320
+ hasLocalDiff: Boolean(localDiff),
321
+ localDiffPath: localDiff?.path || "",
322
+ files: options.includeFiles ? entries : undefined,
323
+ };
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+
270
329
  function resolveBackupDir(ctx, input) {
271
330
  if (!input) throw new CliError("backup id/path is required", 2);
272
331
  const expanded = expandHome(String(input));
@@ -276,6 +335,15 @@ function resolveBackupDir(ctx, input) {
276
335
  return path.join(ctx.stateDir, "backups", expanded);
277
336
  }
278
337
 
338
+ function resolveLegacyConflictBackupDir(ctx, input) {
339
+ if (!input) throw new CliError("legacy backup id/path is required", 2);
340
+ const expanded = expandHome(String(input));
341
+ if (path.isAbsolute(expanded) || expanded.includes("/") || expanded.includes("\\")) {
342
+ return path.resolve(expanded);
343
+ }
344
+ return path.join(legacyConflictBackupsDir(ctx), expanded);
345
+ }
346
+
279
347
  function inferBackupOperation(id) {
280
348
  if (id.endsWith("-update")) return "update";
281
349
  if (id.endsWith("-repair")) return "repair";