@bigking67/pi-67 0.10.0 → 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,25 @@
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
+
15
+ ## [0.10.1]
16
+
17
+ - Falls back from `npm` to `npm.cmd` on Windows when checking npm registry state
18
+ or running `pi-67 self-update`, avoiding `spawnSync npm ENOENT` in PowerShell
19
+ environments where only the command shim is directly spawnable.
20
+ - Clarifies that global install is the normal daily path and `npx @latest` is
21
+ the always-fresh one-shot validation or recovery path.
22
+
3
23
  ## [0.10.0]
4
24
 
5
25
  - Initial npm manager CLI for pi-67.
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
@@ -55,6 +58,10 @@ If the local manager may be stale, run the latest npm package for one repair:
55
58
  npx -y @bigking67/pi-67@latest update --repair
56
59
  ```
57
60
 
61
+ Use `npm install -g @bigking67/pi-67` for normal daily operation. Use `npx -y
62
+ @bigking67/pi-67@latest ...` when you want a zero-install, always-fresh one-shot
63
+ check or repair before trusting the globally installed manager.
64
+
58
65
  ## Safety defaults
59
66
 
60
67
  `pi-67 update` preserves local runtime choices:
@@ -76,7 +83,9 @@ Runtime backups are first-class CLI state:
76
83
 
77
84
  ```bash
78
85
  pi-67 backups list
86
+ pi-67 backups list --include-legacy
79
87
  pi-67 backups inspect <backup-id-or-path>
88
+ pi-67 backups inspect <pre-update-id> --legacy
80
89
  pi-67 backups restore --from <backup-id-or-path> --dry-run
81
90
  pi-67 backups restore --from <backup-id-or-path> --yes
82
91
  ```
@@ -84,6 +93,11 @@ pi-67 backups restore --from <backup-id-or-path> --yes
84
93
  The restore command only restores preserved runtime files and writes a
85
94
  pre-restore backup before overwriting current local config.
86
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
+
87
101
  Theme changes are explicit only:
88
102
 
89
103
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.0",
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,12 +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
+ import { commandCandidatesForPlatform } from "../src/lib/shell-runner.mjs";
7
8
  import { readExtensionRegistry, validateExtensionRegistry } from "../src/lib/extension-registry.mjs";
8
9
  import { buildPlanDecisions, classifyGitShort } from "../src/lib/update-plan.mjs";
9
10
  import {
10
11
  beginUpdateLifecycle,
12
+ inspectLegacyConflictBackup,
11
13
  inspectRuntimeBackup,
14
+ listLegacyConflictBackups,
12
15
  listRuntimeBackups,
13
16
  restoreRuntimeBackup,
14
17
  } from "../src/lib/update-safety.mjs";
@@ -25,7 +28,9 @@ for (const file of files.filter((item) => item.endsWith(".mjs"))) {
25
28
  for (const file of files.filter((item) => item.endsWith(".json"))) {
26
29
  JSON.parse(fs.readFileSync(file, "utf8"));
27
30
  }
31
+ await runNpmRegistrySelfTests();
28
32
  runPublishTargetSelfTests();
33
+ runShellRunnerSelfTests();
29
34
  runExtensionRegistrySelfTests();
30
35
  runUpdatePlanSelfTests();
31
36
  runUpdateSafetySelfTests();
@@ -92,6 +97,64 @@ function runPublishTargetSelfTests() {
92
97
  );
93
98
  }
94
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
+
143
+ function runShellRunnerSelfTests() {
144
+ assert(
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",
147
+ );
148
+ assert(
149
+ JSON.stringify(commandCandidatesForPlatform("git", "win32")) === JSON.stringify(["git"]),
150
+ "Windows non-npm execution should keep the requested command",
151
+ );
152
+ assert(
153
+ JSON.stringify(commandCandidatesForPlatform("npm", "darwin")) === JSON.stringify(["npm"]),
154
+ "POSIX npm execution should keep the requested command",
155
+ );
156
+ }
157
+
95
158
  function runExtensionRegistrySelfTests() {
96
159
  const registry = readExtensionRegistry(path.join(root, "src", "data", "extension-registry.json"));
97
160
  const manifest = extensionRegistryTestManifest();
@@ -331,6 +394,18 @@ function runUpdateSafetySelfTests() {
331
394
  fs.readFileSync(path.join(agentDir, "settings.json"), "utf8").includes("\"dark\""),
332
395
  "runtime backup restore must recover the backed up file content",
333
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
+ );
334
409
  fs.rmSync(tmpRoot, { recursive: true, force: true });
335
410
  }
336
411
 
@@ -396,6 +471,17 @@ function deepMerge(base, overrides) {
396
471
  return next;
397
472
  }
398
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
+
399
485
  function assert(condition, message) {
400
486
  if (!condition) throw new Error(message);
401
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) {
@@ -7,7 +7,7 @@ export function runCommand(command, args = [], options = {}) {
7
7
  info(`DRY-RUN (${cwd}) ${[command, ...args].join(" ")}`);
8
8
  return { status: 0, stdout: "", stderr: "" };
9
9
  }
10
- const result = spawnSync(command, args, {
10
+ const { result } = spawnWithFallback(command, args, {
11
11
  cwd,
12
12
  stdio: options.stdio || "inherit",
13
13
  env: { ...process.env, ...(options.env || {}) },
@@ -24,7 +24,7 @@ export function runCommand(command, args = [], options = {}) {
24
24
  }
25
25
 
26
26
  export function captureCommand(command, args = [], options = {}) {
27
- const result = spawnSync(command, args, {
27
+ const { result } = spawnWithFallback(command, args, {
28
28
  cwd: options.cwd || process.cwd(),
29
29
  env: { ...process.env, ...(options.env || {}) },
30
30
  encoding: "utf8",
@@ -38,3 +38,37 @@ export function captureCommand(command, args = [], options = {}) {
38
38
  error: result.error ? result.error.message : "",
39
39
  };
40
40
  }
41
+
42
+ export function commandCandidatesForPlatform(command, platform = process.platform) {
43
+ if (platform === "win32" && command === "npm") {
44
+ return ["npm", "npm.cmd", "cmd.exe"];
45
+ }
46
+ return [command];
47
+ }
48
+
49
+ function spawnWithFallback(command, args, options) {
50
+ let lastResult;
51
+ for (const candidate of commandInvocationsForPlatform(command, args)) {
52
+ const result = spawnSync(candidate.command, candidate.args, options);
53
+ lastResult = result;
54
+ if (!isRetryableSpawnFailure(command, result)) return { command: candidate.command, result };
55
+ }
56
+ return { command, result: lastResult };
57
+ }
58
+
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;
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";