supipowers 2.2.1 → 2.2.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.
@@ -7,6 +7,7 @@ import { resolveInstalledBridgeScriptPath } from "./runtime.js";
7
7
  import { getEventStore as getContextEventStore, getSessionId as getContextSessionId } from "../context-mode/hooks.js";
8
8
  import { buildCompactionCheckpoint, buildShutdownDiary } from "./session-summary.js";
9
9
  import { snapshotMempalaceInstall } from "./installer-helper.js";
10
+ import { buildMempalaceGuidance } from "./contract.js";
10
11
 
11
12
  export interface MempalaceHooksDeps {
12
13
  createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
@@ -162,12 +163,9 @@ function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: stri
162
163
  "# MemPalace memory",
163
164
  `- palace: ${resolved.palacePath}`,
164
165
  `- default wing: ${wing}`,
165
- ];
166
- if (resolved.hooks.searchGuidance) {
167
- lines.push(
168
- "- You **MUST** call `mempalace(action=\"search\", query=...)` before answering questions about prior decisions, people, projects, or past events. Skip only when the answer is fully derivable from the current turn or the active codebase.",
169
- );
170
- }
166
+ "",
167
+ ...buildMempalaceGuidance(resolved.hooks, "full"),
168
+ ].filter((line) => line.length > 0);
171
169
  if (excerpt.trim()) {
172
170
  lines.push("", "## Wake-up excerpt", excerpt.trim());
173
171
  }
@@ -175,19 +173,15 @@ function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: stri
175
173
  }
176
174
 
177
175
  /**
178
- * Compact one-line refresher injected on turns where we skip the full
179
- * wake-up dump. Keeps the model oriented (palace/wing) and re-asserts the
180
- * RFC-2119 search nudge in ~140 chars instead of ~870 tokens.
176
+ * Compact refresher injected on turns where we skip the full wake-up dump.
177
+ * Keeps the model oriented (palace/wing) and re-asserts the MemPalace
178
+ * read/write contract without paying for the wake-up excerpt.
181
179
  */
182
180
  function wakeUpRefresher(resolved: ResolvedMempalaceConfig, wing: string): string {
183
181
  const lines = [
184
182
  `# MemPalace memory: wing=${wing}`,
183
+ ...buildMempalaceGuidance(resolved.hooks, "refresher"),
185
184
  ];
186
- if (resolved.hooks.searchGuidance) {
187
- lines.push(
188
- "- You **MUST** call `mempalace(action=\"search\", query=...)` before answering past-fact questions; per-turn search results appear below when relevant.",
189
- );
190
- }
191
185
  return lines.join("\n");
192
186
  }
193
187
 
@@ -326,7 +320,8 @@ export function registerMempalaceHooks(
326
320
  const wakeUpEnabled = config.mempalace.hooks.wakeUp;
327
321
  const guidanceEnabled = config.mempalace.hooks.searchGuidance;
328
322
  const autoSearchEnabled = config.mempalace.hooks.autoSearchOnPrompt;
329
- if (!wakeUpEnabled && !guidanceEnabled && !autoSearchEnabled) return undefined;
323
+ const writeGuidanceEnabled = config.mempalace.hooks.writeGuidance;
324
+ if (!wakeUpEnabled && !guidanceEnabled && !writeGuidanceEnabled && !autoSearchEnabled) return undefined;
330
325
 
331
326
  const cwd = contextCwd(ctx);
332
327
  const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
@@ -15,6 +15,7 @@ import {
15
15
  snapshotMempalaceInstall,
16
16
  type MempalaceInstallSnapshot,
17
17
  } from "./installer-helper.js";
18
+ import { MEMPALACE_TOOL_DESCRIPTION } from "./contract.js";
18
19
 
19
20
  export interface MempalaceToolDeps {
20
21
  createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
@@ -130,8 +131,7 @@ export function registerMempalaceTool(
130
131
  platform.registerTool({
131
132
  name: "mempalace",
132
133
  label: "MemPalace",
133
- description:
134
- "MemPalace memory dispatcher. **MUST** call `search` before answering past-fact questions; write only on explicit user request.",
134
+ description: MEMPALACE_TOOL_DESCRIPTION,
135
135
  parameters: mempalaceToolParameters,
136
136
  async execute(_toolCallId: string, rawParams: unknown, _signal: AbortSignal, onUpdate: unknown, toolCtx: unknown) {
137
137
  try {
@@ -12,6 +12,10 @@ export const PINNED_UV_VERSION = "0.5.30";
12
12
 
13
13
  const UV_BASE_URL = "https://github.com/astral-sh/uv/releases/download";
14
14
 
15
+ const TAR_PREFLIGHT_TIMEOUT_MS = 3_000;
16
+ const TAR_REMEDIATION =
17
+ "Install GNU tar / BSD tar (built-in on macOS, Linux, and Windows 10+). On older Windows, install Git for Windows or run `/supi:memory setup` after adding tar to PATH.";
18
+
15
19
  export type UvPlatform =
16
20
  | "darwin-arm64"
17
21
  | "darwin-x64"
@@ -49,17 +53,22 @@ export function uvTargetFor(uvPlatform: UvPlatform): UvTarget {
49
53
  case "linux-arm64":
50
54
  return target("aarch64-unknown-linux-gnu", ".tar.gz", "uv");
51
55
  case "win32-x64":
52
- return target("x86_64-pc-windows-msvc", ".zip", "uv.exe");
56
+ return target("x86_64-pc-windows-msvc", ".zip", "uv.exe", "uv.exe");
53
57
  }
54
58
  }
55
59
 
56
- function target(triple: string, archiveSuffix: string, binary: string): UvTarget {
60
+ function target(
61
+ triple: string,
62
+ archiveSuffix: string,
63
+ binary: string,
64
+ archiveBinaryRelativePath = path.join(`uv-${triple}`, binary),
65
+ ): UvTarget {
57
66
  const archive = `uv-${triple}${archiveSuffix}`;
58
67
  return {
59
68
  triple,
60
69
  archive,
61
70
  binary,
62
- archiveBinaryRelativePath: path.join(`uv-${triple}`, binary),
71
+ archiveBinaryRelativePath,
63
72
  };
64
73
  }
65
74
 
@@ -113,6 +122,34 @@ function writeVersionStamp(managedBinDir: string, version: string): void {
113
122
  fs.writeFileSync(versionStampPath(managedBinDir), `${version}\n`, "utf8");
114
123
  }
115
124
 
125
+ function tarUnavailable(message: string): EnsureUvResult {
126
+ return {
127
+ ok: false,
128
+ error: {
129
+ code: "uv_extract_failed",
130
+ message,
131
+ remediation: TAR_REMEDIATION,
132
+ },
133
+ };
134
+ }
135
+
136
+ async function verifyTarAvailable(runner: ProcessRunner): Promise<EnsureUvResult | null> {
137
+ let result;
138
+ try {
139
+ result = await runner("tar", ["--version"], { timeoutMs: TAR_PREFLIGHT_TIMEOUT_MS });
140
+ } catch (error) {
141
+ return tarUnavailable(
142
+ `tar is required to extract the uv archive but could not be launched: ${error instanceof Error ? error.message : String(error)}`,
143
+ );
144
+ }
145
+
146
+ if (result.code !== 0) {
147
+ return tarUnavailable("tar is required to extract the uv archive but is not on PATH.");
148
+ }
149
+
150
+ return null;
151
+ }
152
+
116
153
  export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult> {
117
154
  const version = options.version ?? PINNED_UV_VERSION;
118
155
  const uvPlatform = options.platform === undefined ? detectUvPlatform() : options.platform;
@@ -134,6 +171,9 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
134
171
  return { ok: true, uvPath: managedPath, version, source: "cached" };
135
172
  }
136
173
 
174
+ const tarPreflight = await verifyTarAvailable(options.runner);
175
+ if (tarPreflight) return tarPreflight;
176
+
137
177
  const fetcher = options.fetcher ?? defaultFetcher;
138
178
  options.onProgress?.(`Downloading uv ${version} for ${targetSpec.triple}`);
139
179
 
@@ -210,7 +250,7 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
210
250
  message: `tar failed to extract uv archive (code ${extract.code}): ${
211
251
  extract.stderr.trim() || extract.stdout.trim() || "no output"
212
252
  }`,
213
- remediation: "Ensure tar is on PATH (built-in on macOS, Linux, and Windows 10+).",
253
+ remediation: TAR_REMEDIATION,
214
254
  },
215
255
  };
216
256
  }
@@ -226,11 +266,14 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
226
266
  };
227
267
  }
228
268
 
229
- // Replace any pre-existing managed binary atomically-ish.
230
- if (fs.existsSync(managedPath)) {
231
- try { fs.unlinkSync(managedPath); } catch { /* best effort */ }
269
+ // Replace any pre-existing managed binary atomically-ish. Windows uv zips place
270
+ // uv.exe at the archive root, which is already `managedPath` after extraction.
271
+ if (path.resolve(extractedBinary) !== path.resolve(managedPath)) {
272
+ if (fs.existsSync(managedPath)) {
273
+ try { fs.unlinkSync(managedPath); } catch { /* best effort */ }
274
+ }
275
+ fs.renameSync(extractedBinary, managedPath);
232
276
  }
233
- fs.renameSync(extractedBinary, managedPath);
234
277
  if (process.platform !== "win32") {
235
278
  fs.chmodSync(managedPath, 0o755);
236
279
  }
@@ -278,7 +278,7 @@ async function executeApproveFlow(
278
278
  debugLogger?.log("execution_handoff_new_session_cancelled", {
279
279
  planPath,
280
280
  });
281
- ctx.ui.notify("Session start cancelled. Plan saved; run /supi:plan again to execute.");
281
+ ctx.ui?.notify?.("Session start cancelled. Plan saved; run /supi:plan again to execute.");
282
282
  return;
283
283
  }
284
284
  platform.sendUserMessage(prompt);
@@ -298,7 +298,7 @@ async function executeApproveFlow(
298
298
  debugLogger?.log("execution_handoff_same_session_steer_sent", {
299
299
  planPath,
300
300
  });
301
- ctx.ui.notify("Plan approved — starting execution");
301
+ ctx.ui?.notify?.("Plan approved — starting execution");
302
302
  }
303
303
  }
304
304
 
@@ -408,25 +408,23 @@ export function registerPlanApprovalHook(platform: Platform): void {
408
408
  });
409
409
  } catch {}
410
410
  if (!ctx?.hasUI) {
411
- const message = [
412
- `Plan saved to \`${planPath}\`.`,
413
- "Interactive approval is unavailable in this runtime, so no execution was started.",
414
- `To continue manually, explicitly send: \`Execute the saved plan at ${planPath} step by step; verify each step before proceeding.\``,
415
- ].join("\n");
416
- debugLogger?.log("approval_flow_no_ui", {
411
+ debugLogger?.log("approval_flow_no_ui_auto_execute", {
417
412
  planName,
418
413
  planPath,
419
414
  });
420
- ctx?.ui?.notify?.("Plan saved; interactive approval is required before execution.", "warning");
421
- platform.sendMessage(
422
- {
423
- customType: "supi-plan-awaiting-interactive-approval",
424
- content: [{ type: "text", text: message }],
425
- display: true,
426
- },
427
- { deliverAs: "steer", triggerTurn: false },
428
- );
415
+ const executionNewSession = capturedNewSession;
416
+ const executionModel = capturedResolvedModel;
429
417
  cancelPlanTracking();
418
+ await executeApproveFlow(
419
+ platform,
420
+ ctx,
421
+ canonicalContent,
422
+ planPath,
423
+ executionNewSession,
424
+ executionModel,
425
+ debugLogger,
426
+ parsedPlan,
427
+ );
430
428
  return;
431
429
  }
432
430
 
@@ -129,11 +129,11 @@ function getAskRedirectReason(): string | null {
129
129
  */
130
130
  export function registerPlanningAskToolGuard(platform: Platform): void {
131
131
  platform.on("tool_call", (event) => {
132
- if (event.toolName === "resolve" && isPlanApprovalResolveInput(event.input) && isPlanningActive()) {
132
+ if (event.toolName === "resolve" && isResolveApplyInput(event.input) && isPlanningActive()) {
133
133
  return {
134
134
  block: true,
135
135
  reason:
136
- "Planning mode: /supi:plan uses a file-based approval hook. Do not call `resolve` with `extra.title` because it is OMP's native approval path and bypasses supipowers plan tracking.",
136
+ "Planning mode: /supi:plan uses a file-based approval hook. Native OMP plan approval is blocked because it bypasses supipowers plan tracking.",
137
137
  };
138
138
  }
139
139
 
@@ -149,13 +149,7 @@ export function registerPlanningAskToolGuard(platform: Platform): void {
149
149
  });
150
150
  }
151
151
 
152
- function isPlanApprovalResolveInput(input: unknown): boolean {
152
+ function isResolveApplyInput(input: unknown): boolean {
153
153
  if (input === null || typeof input !== "object" || Array.isArray(input)) return false;
154
- const candidate = input as { action?: unknown; extra?: unknown };
155
- if (candidate.action !== "apply") return false;
156
- const extra = candidate.extra;
157
- return extra !== null
158
- && typeof extra === "object"
159
- && !Array.isArray(extra)
160
- && typeof (extra as { title?: unknown }).title === "string";
154
+ return (input as { action?: unknown }).action === "apply";
161
155
  }
@@ -116,7 +116,8 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
116
116
  } else {
117
117
  console.log(`[dry-run] Would git tag -a ${tagName}`);
118
118
  }
119
- console.log(`[dry-run] Would git push origin HEAD --follow-tags`);
119
+ console.log(`[dry-run] Would git push origin HEAD`);
120
+ console.log(`[dry-run] Would git push origin ${tagName}`);
120
121
  for (const channel of channels) {
121
122
  console.log(`[dry-run] Would publish to channel: ${channel}`);
122
123
  }
@@ -199,8 +200,8 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
199
200
 
200
201
  let pushAttempt = 0;
201
202
  while (true) {
202
- progress("git-push", "active", pushAttempt === 0 ? "Pushing to origin" : "Retrying push after rebase");
203
- const gitPush = await exec("git", ["push", "origin", "HEAD", "--follow-tags"], { cwd });
203
+ progress("git-push", "active", pushAttempt === 0 ? "Pushing commit to origin" : "Retrying commit push after rebase");
204
+ const gitPush = await exec("git", ["push", "origin", "HEAD"], { cwd });
204
205
  if (gitPush.code === 0) {
205
206
  progress("git-push", "done");
206
207
  break;
@@ -228,6 +229,15 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
228
229
  }
229
230
  }
230
231
 
232
+ progress("git-push-tags", "active", `Pushing ${tagName} to origin`);
233
+ const gitPushTag = await exec("git", ["push", "origin", tagName], { cwd });
234
+ if (gitPushTag.code !== 0) {
235
+ const detail = gitPushTag.stderr || gitPushTag.stdout || `exit code ${gitPushTag.code}`;
236
+ progress("git-push-tags", "error", detail);
237
+ return { version, tagCreated: true, pushed: false, channels: [], error: `git push tag: ${detail}` };
238
+ }
239
+ progress("git-push-tags", "done");
240
+
231
241
  const channelResults: ReleaseResult["channels"] = [];
232
242
  for (const channel of channels) {
233
243
  progress(`publish-${channel}`, "active", `Publishing to ${channel}`);
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { normalizeLineEndings } from "../text.js";
3
+ import { ensureTrailingNewline, normalizeLineEndings } from "../text.js";
4
4
  import type { Plan, PlanTask, TaskComplexity, WorkspaceTarget } from "../types.js";
5
5
  import type { PlatformPaths } from "../platform/types.js";
6
6
  import { getProjectStatePath, getProjectTargetStatePath } from "../workspace/state-paths.js";
@@ -40,7 +40,7 @@ export function savePlan(paths: PlatformPaths, cwd: string, filename: string, co
40
40
  const dir = getPlansDir(paths, cwd);
41
41
  fs.mkdirSync(dir, { recursive: true });
42
42
  const filePath = path.join(dir, filename);
43
- fs.writeFileSync(filePath, content);
43
+ fs.writeFileSync(filePath, ensureTrailingNewline(normalizeLineEndings(content)));
44
44
  return filePath;
45
45
  }
46
46
 
package/src/text.ts CHANGED
@@ -1,6 +1,11 @@
1
- /** Normalizes Windows CRLF line endings to LF. */
1
+ /** Normalizes CRLF and lone CR line endings to LF for deterministic artifacts. */
2
2
  export function normalizeLineEndings(text: string): string {
3
- return text.replace(/\r\n/g, "\n");
3
+ return text.replace(/\r\n?/g, "\n");
4
+ }
5
+
6
+ /** Ensures text files have a trailing LF without changing already-terminated content. */
7
+ export function ensureTrailingNewline(text: string): string {
8
+ return text.endsWith("\n") ? text : `${text}\n`;
4
9
  }
5
10
 
6
11
  /** Removes a single outer markdown code fence wrapper when present. */
package/src/types.ts CHANGED
@@ -572,9 +572,11 @@ export interface MempalaceConfig {
572
572
  hooks: {
573
573
  wakeUp: boolean;
574
574
  searchGuidance: boolean;
575
+ writeGuidance: boolean;
575
576
  autoSearchOnPrompt: boolean;
576
577
  compactionCheckpoint: boolean;
577
578
  shutdownDiary: boolean;
579
+ postCommitReindex: boolean;
578
580
  };
579
581
  budgets: {
580
582
  wakeUpTokens: number;
@@ -1550,6 +1552,16 @@ export type HarnessStage =
1550
1552
  | "docs"
1551
1553
  | "validate";
1552
1554
 
1555
+ /** Progress event emitted by harness pipeline drivers and long-running stages. */
1556
+ export type HarnessPipelineProgressEvent =
1557
+ | { type: "stage-started"; stage: HarnessStage }
1558
+ | { type: "stage-progress"; stage: HarnessStage; detail: string }
1559
+ | { type: "stage-skipped"; stage: HarnessStage }
1560
+ | { type: "stage-completed"; stage: HarnessStage; detail?: string }
1561
+ | { type: "stage-blocked"; stage: HarnessStage; detail: string }
1562
+ | { type: "stage-failed"; stage: HarnessStage; detail: string }
1563
+ | { type: "awaiting-user"; stage: HarnessStage; detail?: string };
1564
+
1553
1565
  /** Operational status of a harness stage. Mirrors UltraPlanAuthoringStageStatus. */
1554
1566
  export type HarnessStageStatus =
1555
1567
  | "pending"
@@ -6,7 +6,7 @@ import type { Platform } from "../platform/types.js";
6
6
  * Resolution order:
7
7
  * 1. `$VISUAL`
8
8
  * 2. `$EDITOR`
9
- * 3. OS default opener (`open` on darwin, `start` on win32, `xdg-open` elsewhere)
9
+ * 3. OS default opener (`open` on darwin, `cmd /d /s /c start` on win32, `xdg-open` elsewhere)
10
10
  *
11
11
  * `platform.exec` blocks until the spawned editor process exits, which is what
12
12
  * the synthesize stage needs for its `$EDITOR` round-trip. For OS-default openers
@@ -18,7 +18,34 @@ import type { Platform } from "../platform/types.js";
18
18
  * without throwing. Callers that need to verify the user actually edited the file
19
19
  * should detect changes by comparing mtime / contents before and after.
20
20
  */
21
- export async function openInEditor(platform: Platform, filePath: string): Promise<void> {
21
+ export interface EditorInvocation {
22
+ command: string;
23
+ args: string[];
24
+ }
25
+
26
+
27
+ function quoteCmdArgument(arg: string): string {
28
+ return `"${arg.replace(/"/g, '""')}"`;
29
+ }
30
+
31
+ export function resolveDefaultEditorInvocation(
32
+ hostPlatform: NodeJS.Platform,
33
+ filePath: string,
34
+ ): EditorInvocation {
35
+ if (hostPlatform === "win32") {
36
+ return { command: "cmd", args: ["/d", "/s", "/c", `start "" ${quoteCmdArgument(filePath)}`] };
37
+ }
38
+
39
+ return hostPlatform === "darwin"
40
+ ? { command: "open", args: [filePath] }
41
+ : { command: "xdg-open", args: [filePath] };
42
+ }
43
+
44
+ export async function openInEditor(
45
+ platform: Platform,
46
+ filePath: string,
47
+ hostPlatform: NodeJS.Platform = process.platform,
48
+ ): Promise<void> {
22
49
  const editor = process.env.VISUAL || process.env.EDITOR;
23
50
  try {
24
51
  if (editor) {
@@ -28,9 +55,8 @@ export async function openInEditor(platform: Platform, filePath: string): Promis
28
55
  const args = [...tokens.slice(1), filePath];
29
56
  await platform.exec(cmd, args);
30
57
  } else {
31
- const cmd = process.platform === "darwin" ? "open"
32
- : process.platform === "win32" ? "start" : "xdg-open";
33
- await platform.exec(cmd, [filePath]);
58
+ const invocation = resolveDefaultEditorInvocation(hostPlatform, filePath);
59
+ await platform.exec(invocation.command, invocation.args);
34
60
  }
35
61
  } catch {
36
62
  // Editor open failed — non-fatal, file was still written
@@ -0,0 +1,106 @@
1
+ import { dirname, join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+
4
+ import type { ExecOptions, ExecResult } from "../platform/types.js";
5
+ import { findExecutable } from "./executable.js";
6
+
7
+ /**
8
+ * Cross-platform invocation for npm/npx that survives Windows `.cmd` shims.
9
+ *
10
+ * OMP's `platform.exec` is a thin wrapper over libuv's `uv_spawn`. On Windows
11
+ * that exposes two distinct bugs when the target is an npm-shipped CLI:
12
+ *
13
+ * 1. libuv does not consult `PATHEXT`, so spawning `"npm"` fails with
14
+ * `ENOENT: uv_spawn 'npm'` because the on-disk file is `npm.cmd`.
15
+ * 2. Even when callers resolve the absolute path, Node ≥18.20.2 hard-rejects
16
+ * spawning `.cmd`/`.bat` shims without `shell: true` (CVE-2024-27980).
17
+ * `ExecOptions` does not expose `shell`.
18
+ *
19
+ * Wrapping in `cmd.exe /d /s /c` is the canonical workaround, but only safe
20
+ * when the spawner sets `windowsVerbatimArguments: true` — Node's default
21
+ * CRT escaping double-quotes the command line and cmd's `/s` only strips one
22
+ * pair. We don't control the spawner, so we sidestep the whole problem by
23
+ * resolving the shim to the real `node <cli.js>` invocation, which is exactly
24
+ * what `npm.cmd` does internally. `node.exe` is a plain binary that libuv
25
+ * spawns without ceremony.
26
+ *
27
+ * Non-shim binaries (`bun`, `git`, `node`, `gh`, `rustup`, `go`, `pip`, …
28
+ * all ship as `.exe` on Windows) pass through untouched; POSIX always passes
29
+ * through.
30
+ */
31
+
32
+ export type ExecFn = (
33
+ cmd: string,
34
+ args: string[],
35
+ opts?: ExecOptions,
36
+ ) => Promise<ExecResult>;
37
+
38
+ interface ResolvedInvocation {
39
+ cmd: string;
40
+ prefixArgs: string[];
41
+ }
42
+
43
+ const NODE_SHIMS = new Set<string>(["npm", "npx"]);
44
+ const resolutionCache = new Map<string, ResolvedInvocation>();
45
+
46
+ function resolveNodeShim(command: string): ResolvedInvocation | null {
47
+ if (!NODE_SHIMS.has(command)) return null;
48
+
49
+ // node.exe is the executor; npm-cli.js / npx-cli.js sits next to it under
50
+ // node_modules/npm/bin/. We deliberately key off node's location (not the
51
+ // shim's) because `npm.cmd` can live in a user-global dir (e.g. nvm,
52
+ // %AppData%\npm) while the actual CLI bundle stays alongside node.
53
+ const nodeBin = findExecutable("node");
54
+ if (!nodeBin) return null;
55
+
56
+ const cliJs = join(
57
+ dirname(nodeBin),
58
+ "node_modules",
59
+ "npm",
60
+ "bin",
61
+ `${command}-cli.js`,
62
+ );
63
+ if (!existsSync(cliJs)) return null;
64
+
65
+ return { cmd: nodeBin, prefixArgs: [cliJs] };
66
+ }
67
+
68
+ function resolveInvocation(command: string): ResolvedInvocation {
69
+ if (process.platform !== "win32") {
70
+ return { cmd: command, prefixArgs: [] };
71
+ }
72
+ const cached = resolutionCache.get(command);
73
+ if (cached) return cached;
74
+
75
+ const resolved = resolveNodeShim(command) ?? { cmd: command, prefixArgs: [] };
76
+ resolutionCache.set(command, resolved);
77
+ return resolved;
78
+ }
79
+
80
+ /**
81
+ * Drop-in replacement for `platform.exec` callers that invoke npm/npx by name.
82
+ * Other commands pass through unchanged.
83
+ */
84
+ export function execCli(
85
+ exec: ExecFn,
86
+ command: string,
87
+ args: string[],
88
+ opts?: ExecOptions,
89
+ ): Promise<ExecResult> {
90
+ const resolved = resolveInvocation(command);
91
+ return exec(resolved.cmd, [...resolved.prefixArgs, ...args], opts);
92
+ }
93
+
94
+ /**
95
+ * Wrap an `ExecFn` so every call routes through `execCli`. Use when threading
96
+ * an exec callback into helpers that dispatch arbitrary tools by string name
97
+ * (e.g. the deps installer which splits install-command strings).
98
+ */
99
+ export function wrapExecForCli(exec: ExecFn): ExecFn {
100
+ return (cmd, args, opts) => execCli(exec, cmd, args, opts);
101
+ }
102
+
103
+ /** Clears cached CLI resolutions after PATH or tooling changes. */
104
+ export function clearExecCliResolutionCache(): void {
105
+ resolutionCache.clear();
106
+ }