pi-extmgr 0.1.21 → 0.1.23

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/src/ui/unified.ts CHANGED
@@ -14,22 +14,14 @@ import {
14
14
  matchesKey,
15
15
  Key,
16
16
  } from "@mariozechner/pi-tui";
17
- import type {
18
- UnifiedItem,
19
- State,
20
- UnifiedAction,
21
- InstalledPackage,
22
- PackageExtensionEntry,
23
- } from "../types/index.js";
17
+ import type { UnifiedItem, State, UnifiedAction, InstalledPackage } from "../types/index.js";
24
18
  import {
25
19
  discoverExtensions,
26
20
  removeLocalExtension,
27
21
  setExtensionState,
28
22
  } from "../extensions/discovery.js";
29
23
  import { getInstalledPackages } from "../packages/discovery.js";
30
- import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
31
24
  import {
32
- showPackageActions,
33
25
  updatePackageWithOutcome,
34
26
  removePackageWithOutcome,
35
27
  updatePackagesWithOutcome,
@@ -51,6 +43,7 @@ import { updateExtmgrStatus } from "../utils/status.js";
51
43
  import { parseChoiceByLabel } from "../utils/command.js";
52
44
  import { getPackageSourceKind } from "../utils/package-source.js";
53
45
  import { UI } from "../constants.js";
46
+ import { configurePackageExtensions } from "./package-config.js";
54
47
 
55
48
  // Type guard for SettingsList with selectedIndex
56
49
  interface SelectableList {
@@ -87,16 +80,15 @@ async function showInteractiveOnce(
87
80
  ctx: ExtensionCommandContext,
88
81
  pi: ExtensionAPI
89
82
  ): Promise<boolean> {
90
- // Load local extensions and installed packages, then discover package entrypoints.
83
+ // Load local extensions and installed packages.
91
84
  const [localEntries, installedPackages] = await Promise.all([
92
85
  discoverExtensions(ctx.cwd),
93
86
  getInstalledPackages(ctx, pi),
94
87
  ]);
95
- const packageExtensions = await discoverPackageExtensions(installedPackages, ctx.cwd);
96
88
 
97
- // Build unified items list
89
+ // Build unified items list.
98
90
  const knownUpdates = getKnownUpdates(ctx);
99
- const items = buildUnifiedItems(localEntries, installedPackages, packageExtensions, knownUpdates);
91
+ const items = buildUnifiedItems(localEntries, installedPackages, knownUpdates);
100
92
 
101
93
  // If nothing found, show quick actions
102
94
  if (items.length === 0) {
@@ -112,7 +104,7 @@ async function showInteractiveOnce(
112
104
  return true;
113
105
  }
114
106
 
115
- // Staged changes tracking for toggleable rows (local + package extensions)
107
+ // Staged changes tracking for local extensions.
116
108
  const staged = new Map<string, State>();
117
109
  const byId = new Map(items.map((item) => [item.id, item]));
118
110
 
@@ -126,7 +118,7 @@ async function showInteractiveOnce(
126
118
  new Text(
127
119
  theme.fg(
128
120
  "muted",
129
- `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle extensions • Enter/A actions • u update pkg • x remove selected`
121
+ `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected`
130
122
  ),
131
123
  2,
132
124
  0
@@ -150,7 +142,7 @@ async function showInteractiveOnce(
150
142
  getSettingsListTheme(),
151
143
  (id: string, newValue: string) => {
152
144
  const item = byId.get(id);
153
- if (!item || (item.type !== "local" && item.type !== "package-extension")) return;
145
+ if (!item || item.type !== "local") return;
154
146
 
155
147
  const state = newValue as State;
156
148
  staged.set(id, state);
@@ -236,6 +228,10 @@ async function showInteractiveOnce(
236
228
  done({ type: "action", itemId: selectedId, action: "details" });
237
229
  return;
238
230
  }
231
+ if (data === "c" || data === "C") {
232
+ done({ type: "action", itemId: selectedId, action: "configure" });
233
+ return;
234
+ }
239
235
  }
240
236
 
241
237
  if (selectedId && selectedItem?.type === "local") {
@@ -266,35 +262,25 @@ async function showInteractiveOnce(
266
262
  return await handleUnifiedAction(result, items, staged, byId, ctx, pi);
267
263
  }
268
264
 
265
+ function normalizePathForDuplicateCheck(value: string): string {
266
+ const normalized = value.replace(/\\/g, "/");
267
+ const looksWindowsPath =
268
+ /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\");
269
+
270
+ return looksWindowsPath ? normalized.toLowerCase() : normalized;
271
+ }
272
+
269
273
  export function buildUnifiedItems(
270
274
  localEntries: Awaited<ReturnType<typeof discoverExtensions>>,
271
275
  installedPackages: InstalledPackage[],
272
- packageExtensions: PackageExtensionEntry[],
273
276
  knownUpdates: Set<string>
274
277
  ): UnifiedItem[] {
275
278
  const items: UnifiedItem[] = [];
276
279
  const localPaths = new Set<string>();
277
280
 
278
- const packageExtensionGroups = new Map<string, PackageExtensionEntry[]>();
279
- for (const entry of packageExtensions) {
280
- const key = `${entry.packageScope}:${entry.packageSource.toLowerCase()}`;
281
- const group = packageExtensionGroups.get(key) ?? [];
282
- group.push(entry);
283
- packageExtensionGroups.set(key, group);
284
- }
285
-
286
- const visiblePackageExtensions = packageExtensions.filter((entry) => {
287
- const key = `${entry.packageScope}:${entry.packageSource.toLowerCase()}`;
288
- const group = packageExtensionGroups.get(key) ?? [];
289
-
290
- // Avoid duplicate-looking rows for packages that expose a single enabled extension entrypoint.
291
- // Keep extension rows when there are multiple entrypoints, or when an entry is disabled so it can be re-enabled.
292
- return group.length > 1 || entry.state === "disabled";
293
- });
294
-
295
281
  // Add local extensions
296
282
  for (const entry of localEntries) {
297
- localPaths.add(entry.activePath?.toLowerCase() ?? "");
283
+ localPaths.add(normalizePathForDuplicateCheck(entry.activePath));
298
284
  items.push({
299
285
  type: "local",
300
286
  id: entry.id,
@@ -308,39 +294,28 @@ export function buildUnifiedItems(
308
294
  });
309
295
  }
310
296
 
311
- for (const entry of visiblePackageExtensions) {
312
- items.push({
313
- type: "package-extension",
314
- id: entry.id,
315
- displayName: entry.displayName,
316
- summary: entry.summary,
317
- scope: entry.packageScope,
318
- state: entry.state,
319
- originalState: entry.state,
320
- packageSource: entry.packageSource,
321
- extensionPath: entry.extensionPath,
322
- });
323
- }
324
-
325
297
  for (const pkg of installedPackages) {
326
- const pkgSourceLower = pkg.source.toLowerCase();
327
- const pkgResolvedLower = pkg.resolvedPath?.toLowerCase() ?? "";
298
+ const pkgSourceNormalized = normalizePathForDuplicateCheck(pkg.source);
299
+ const pkgResolvedNormalized = pkg.resolvedPath
300
+ ? normalizePathForDuplicateCheck(pkg.resolvedPath)
301
+ : "";
328
302
 
329
303
  let isDuplicate = false;
330
304
  for (const localPath of localPaths) {
331
- if (pkgSourceLower === localPath || pkgResolvedLower === localPath) {
305
+ if (pkgSourceNormalized === localPath || pkgResolvedNormalized === localPath) {
332
306
  isDuplicate = true;
333
307
  break;
334
308
  }
335
309
  if (
336
- pkgResolvedLower &&
337
- (localPath.startsWith(pkgResolvedLower + "/") || pkgResolvedLower.startsWith(localPath))
310
+ pkgResolvedNormalized &&
311
+ (localPath.startsWith(`${pkgResolvedNormalized}/`) ||
312
+ pkgResolvedNormalized.startsWith(localPath))
338
313
  ) {
339
314
  isDuplicate = true;
340
315
  break;
341
316
  }
342
- const localDir = localPath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
343
- if (pkgResolvedLower && pkgResolvedLower.replace(/\\/g, "/") === localDir) {
317
+ const localDir = localPath.split("/").slice(0, -1).join("/");
318
+ if (pkgResolvedNormalized && pkgResolvedNormalized === localDir) {
344
319
  isDuplicate = true;
345
320
  break;
346
321
  }
@@ -365,8 +340,7 @@ export function buildUnifiedItems(
365
340
  items.sort((a, b) => {
366
341
  const rank = (type: UnifiedItem["type"]): number => {
367
342
  if (type === "local") return 0;
368
- if (type === "package") return 1;
369
- return 2;
343
+ return 1;
370
344
  };
371
345
 
372
346
  const diff = rank(a.type) - rank(b.type);
@@ -383,7 +357,7 @@ function buildSettingsItems(
383
357
  theme: Theme
384
358
  ): SettingItem[] {
385
359
  return items.map((item) => {
386
- if (item.type === "local" || item.type === "package-extension") {
360
+ if (item.type === "local") {
387
361
  const currentState = staged.get(item.id) ?? item.state!;
388
362
  const changed = staged.has(item.id) && staged.get(item.id) !== item.originalState;
389
363
  return {
@@ -411,34 +385,19 @@ function formatUnifiedItemLabel(
411
385
  ): string {
412
386
  if (item.type === "local") {
413
387
  const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
414
- const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
388
+ const scopeIcon = getScopeIcon(theme, item.scope);
415
389
  const changeMarker = getChangeMarker(theme, changed);
416
390
  const name = theme.bold(item.displayName);
417
391
  const summary = theme.fg("dim", item.summary);
418
392
  return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
419
393
  }
420
394
 
421
- if (item.type === "package-extension") {
422
- const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
423
- const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
424
- const sourceKind = getPackageSourceKind(item.packageSource ?? "");
425
- const pkgIcon = getPackageIcon(
426
- theme,
427
- sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
428
- );
429
- const sourceLabel = sourceKind === "unknown" ? "package" : `${sourceKind} package`;
430
- const changeMarker = getChangeMarker(theme, changed);
431
- const name = theme.bold(item.displayName);
432
- const summary = theme.fg("dim", `${item.summary} • ${sourceLabel}`);
433
- return `${statusIcon} ${pkgIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
434
- }
435
-
436
395
  const sourceKind = getPackageSourceKind(item.source ?? "");
437
396
  const pkgIcon = getPackageIcon(
438
397
  theme,
439
398
  sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
440
399
  );
441
- const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
400
+ const scopeIcon = getScopeIcon(theme, item.scope);
442
401
  const name = theme.bold(item.displayName);
443
402
  const version = item.version ? theme.fg("dim", `@${item.version}`) : "";
444
403
  const updateBadge = item.updateAvailable ? ` ${theme.fg("warning", "[update]")}` : "";
@@ -468,7 +427,7 @@ function formatUnifiedItemLabel(
468
427
  }
469
428
 
470
429
  function getToggleItemsForApply(items: UnifiedItem[]): UnifiedItem[] {
471
- return items.filter((item) => item.type === "local" || item.type === "package-extension");
430
+ return items.filter((item) => item.type === "local");
472
431
  }
473
432
 
474
433
  async function applyToggleChangesFromManager(
@@ -479,7 +438,7 @@ async function applyToggleChangesFromManager(
479
438
  options?: { promptReload?: boolean }
480
439
  ): Promise<{ changed: number; reloaded: boolean }> {
481
440
  const toggleItems = getToggleItemsForApply(items);
482
- const apply = await applyStagedChanges(toggleItems, staged, pi, ctx.cwd);
441
+ const apply = await applyStagedChanges(toggleItems, staged, pi);
483
442
 
484
443
  if (apply.errors.length > 0) {
485
444
  ctx.ui.notify(
@@ -489,7 +448,7 @@ async function applyToggleChangesFromManager(
489
448
  } else if (apply.changed === 0) {
490
449
  ctx.ui.notify("No changes to apply.", "info");
491
450
  } else {
492
- ctx.ui.notify(`Applied ${apply.changed} extension change(s).`, "info");
451
+ ctx.ui.notify(`Applied ${apply.changed} local extension change(s).`, "info");
493
452
  }
494
453
 
495
454
  if (apply.changed > 0) {
@@ -498,7 +457,7 @@ async function applyToggleChangesFromManager(
498
457
  if (shouldPromptReload) {
499
458
  const shouldReload = await ctx.ui.confirm(
500
459
  "Reload Required",
501
- "Extensions changed. Reload pi now?"
460
+ "Local extensions changed. Reload pi now?"
502
461
  );
503
462
 
504
463
  if (shouldReload) {
@@ -570,6 +529,34 @@ const QUICK_DESTINATION_LABELS: Record<QuickDestination, string> = {
570
529
  help: "Help",
571
530
  };
572
531
 
532
+ const PACKAGE_ACTION_OPTIONS = {
533
+ configure: "Configure extensions",
534
+ update: "Update package",
535
+ remove: "Remove package",
536
+ details: "View details",
537
+ back: "Back to manager",
538
+ } as const;
539
+
540
+ type PackageActionKey = keyof typeof PACKAGE_ACTION_OPTIONS;
541
+
542
+ type PackageActionSelection = Exclude<PackageActionKey, "back"> | "cancel";
543
+
544
+ async function promptPackageActionSelection(
545
+ pkg: InstalledPackage,
546
+ ctx: ExtensionCommandContext
547
+ ): Promise<PackageActionSelection> {
548
+ const selection = parseChoiceByLabel(
549
+ PACKAGE_ACTION_OPTIONS,
550
+ await ctx.ui.select(pkg.name, Object.values(PACKAGE_ACTION_OPTIONS))
551
+ );
552
+
553
+ if (!selection || selection === "back") {
554
+ return "cancel";
555
+ }
556
+
557
+ return selection;
558
+ }
559
+
573
560
  async function navigateWithPendingGuard(
574
561
  destination: QuickDestination,
575
562
  items: UnifiedItem[],
@@ -754,20 +741,29 @@ async function handleUnifiedAction(
754
741
  return false;
755
742
  }
756
743
 
757
- if (item.type === "package-extension") {
758
- return false;
759
- }
760
-
761
744
  const pkg: InstalledPackage = {
762
745
  source: item.source!,
763
746
  name: item.displayName,
764
747
  ...(item.version ? { version: item.version } : {}),
765
- scope: item.scope as "global" | "project",
748
+ scope: item.scope,
766
749
  ...(item.description ? { description: item.description } : {}),
767
750
  ...(item.size !== undefined ? { size: item.size } : {}),
768
751
  };
769
752
 
770
- switch (result.action) {
753
+ const selection =
754
+ !result.action || result.action === "menu"
755
+ ? await promptPackageActionSelection(pkg, ctx)
756
+ : result.action;
757
+
758
+ if (selection === "cancel") {
759
+ return false;
760
+ }
761
+
762
+ switch (selection) {
763
+ case "configure": {
764
+ const outcome = await configurePackageExtensions(pkg, ctx, pi);
765
+ return outcome.reloaded;
766
+ }
771
767
  case "update": {
772
768
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
773
769
  return outcome.reloaded;
@@ -784,11 +780,6 @@ async function handleUnifiedAction(
784
780
  );
785
781
  return false;
786
782
  }
787
- case "menu":
788
- default: {
789
- const exitManager = await showPackageActions(pkg, ctx, pi);
790
- return exitManager;
791
- }
792
783
  }
793
784
  }
794
785
 
@@ -799,37 +790,23 @@ async function handleUnifiedAction(
799
790
  async function applyStagedChanges(
800
791
  items: UnifiedItem[],
801
792
  staged: Map<string, State>,
802
- pi: ExtensionAPI,
803
- cwd: string
793
+ pi: ExtensionAPI
804
794
  ) {
805
795
  let changed = 0;
806
796
  const errors: string[] = [];
807
797
 
808
798
  for (const item of items) {
809
- if ((item.type !== "local" && item.type !== "package-extension") || !item.originalState) {
799
+ if (item.type !== "local" || !item.originalState || !item.activePath || !item.disabledPath) {
810
800
  continue;
811
801
  }
812
802
 
813
803
  const target = staged.get(item.id) ?? item.originalState;
814
804
  if (target === item.originalState) continue;
815
805
 
816
- let result: { ok: true } | { ok: false; error: string };
817
- if (item.type === "local") {
818
- if (!item.activePath || !item.disabledPath) continue;
819
- result = await setExtensionState(
820
- { activePath: item.activePath, disabledPath: item.disabledPath },
821
- target
822
- );
823
- } else {
824
- if (!item.packageSource || !item.extensionPath) continue;
825
- result = await setPackageExtensionState(
826
- item.packageSource,
827
- item.extensionPath,
828
- item.scope as "global" | "project",
829
- target,
830
- cwd
831
- );
832
- }
806
+ const result = await setExtensionState(
807
+ { activePath: item.activePath, disabledPath: item.disabledPath },
808
+ target
809
+ );
833
810
 
834
811
  if (result.ok) {
835
812
  changed++;
@@ -18,6 +18,7 @@ import {
18
18
  type AutoUpdateConfig,
19
19
  } from "./settings.js";
20
20
  import { parseNpmSource } from "./format.js";
21
+ import { execNpm } from "./npm-exec.js";
21
22
  import { TIMEOUTS } from "../constants.js";
22
23
 
23
24
  import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
@@ -134,9 +135,8 @@ async function checkPackageUpdate(
134
135
  if (!pkgName) return false;
135
136
 
136
137
  try {
137
- const res = await pi.exec("npm", ["view", pkgName, "version", "--json"], {
138
+ const res = await execNpm(pi, ["view", pkgName, "version", "--json"], ctx, {
138
139
  timeout: TIMEOUTS.npmView,
139
- cwd: ctx.cwd,
140
140
  });
141
141
 
142
142
  if (res.code !== 0) return false;
@@ -3,7 +3,83 @@
3
3
  */
4
4
 
5
5
  export function tokenizeArgs(input: string): string[] {
6
- return input.trim().split(/\s+/).filter(Boolean);
6
+ const tokens: string[] = [];
7
+ let current = "";
8
+ let inSingleQuote = false;
9
+ let inDoubleQuote = false;
10
+ let tokenStarted = false;
11
+
12
+ const pushCurrent = () => {
13
+ if (tokenStarted) {
14
+ tokens.push(current);
15
+ current = "";
16
+ tokenStarted = false;
17
+ }
18
+ };
19
+
20
+ for (let i = 0; i < input.length; i++) {
21
+ const char = input[i]!;
22
+ const next = input[i + 1];
23
+
24
+ if (inSingleQuote) {
25
+ if (char === "'") {
26
+ inSingleQuote = false;
27
+ } else {
28
+ current += char;
29
+ }
30
+ continue;
31
+ }
32
+
33
+ if (inDoubleQuote) {
34
+ if (char === '"') {
35
+ inDoubleQuote = false;
36
+ continue;
37
+ }
38
+
39
+ if (char === "\\" && next === '"') {
40
+ current += next;
41
+ i++;
42
+ continue;
43
+ }
44
+
45
+ current += char;
46
+ continue;
47
+ }
48
+
49
+ if (/\s/.test(char)) {
50
+ pushCurrent();
51
+ continue;
52
+ }
53
+
54
+ if (char === "'") {
55
+ inSingleQuote = true;
56
+ tokenStarted = true;
57
+ continue;
58
+ }
59
+
60
+ if (char === '"') {
61
+ inDoubleQuote = true;
62
+ tokenStarted = true;
63
+ continue;
64
+ }
65
+
66
+ if (char === "\\" && (next === '"' || next === "'" || /\s/.test(next ?? ""))) {
67
+ tokenStarted = true;
68
+ if (next) {
69
+ current += next;
70
+ i++;
71
+ } else {
72
+ current += char;
73
+ }
74
+ continue;
75
+ }
76
+
77
+ tokenStarted = true;
78
+ current += char;
79
+ }
80
+
81
+ pushCurrent();
82
+ return tokens;
7
83
  }
8
84
 
9
85
  export function splitCommandArgs(input: string): { subcommand: string; args: string[] } {
@@ -59,6 +59,9 @@ export function formatBytes(bytes: number): string {
59
59
 
60
60
  const GIT_PATTERNS = {
61
61
  gitPrefix: /^git:/,
62
+ gitPlusHttpPrefix: /^git\+https?:\/\//,
63
+ gitPlusSshPrefix: /^git\+ssh:\/\//,
64
+ gitPlusGitPrefix: /^git\+git:\/\//,
62
65
  httpPrefix: /^https?:\/\//,
63
66
  sshPrefix: /^ssh:\/\//,
64
67
  gitProtoPrefix: /^git:\/\//,
@@ -78,6 +81,9 @@ const LOCAL_PATH_PATTERNS = {
78
81
  function isGitLikeSource(source: string): boolean {
79
82
  return (
80
83
  GIT_PATTERNS.gitPrefix.test(source) ||
84
+ GIT_PATTERNS.gitPlusHttpPrefix.test(source) ||
85
+ GIT_PATTERNS.gitPlusSshPrefix.test(source) ||
86
+ GIT_PATTERNS.gitPlusGitPrefix.test(source) ||
81
87
  GIT_PATTERNS.httpPrefix.test(source) ||
82
88
  GIT_PATTERNS.sshPrefix.test(source) ||
83
89
  GIT_PATTERNS.gitProtoPrefix.test(source) ||
@@ -97,22 +103,36 @@ function isLocalPathSource(source: string): boolean {
97
103
  );
98
104
  }
99
105
 
106
+ function unwrapQuotedSource(source: string): string {
107
+ const trimmed = source.trim();
108
+ if (trimmed.length < 2) return trimmed;
109
+
110
+ const first = trimmed[0];
111
+ const last = trimmed[trimmed.length - 1];
112
+
113
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
114
+ return trimmed.slice(1, -1).trim();
115
+ }
116
+
117
+ return trimmed;
118
+ }
119
+
100
120
  export function isPackageSource(str: string): boolean {
101
- const source = str.trim();
121
+ const source = unwrapQuotedSource(str);
102
122
  if (!source) return false;
103
123
 
104
124
  return source.startsWith("npm:") || isGitLikeSource(source) || isLocalPathSource(source);
105
125
  }
106
126
 
107
127
  export function normalizePackageSource(source: string): string {
108
- const trimmed = source.trim();
128
+ const trimmed = unwrapQuotedSource(source);
109
129
  if (!trimmed) return trimmed;
110
130
 
111
131
  if (GIT_PATTERNS.gitSsh.test(trimmed)) {
112
132
  return `git:${trimmed}`;
113
133
  }
114
134
 
115
- if (isPackageSource(trimmed)) {
135
+ if (trimmed.startsWith("npm:") || isGitLikeSource(trimmed) || isLocalPathSource(trimmed)) {
116
136
  return trimmed;
117
137
  }
118
138
 
@@ -0,0 +1,47 @@
1
+ import path from "node:path";
2
+ import { execPath, platform } from "node:process";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+
5
+ interface NpmCommandResolutionOptions {
6
+ platform?: NodeJS.Platform;
7
+ nodeExecPath?: string;
8
+ }
9
+
10
+ interface NpmExecOptions {
11
+ timeout: number;
12
+ }
13
+
14
+ function getNpmCliPath(nodeExecPath: string, runtimePlatform: NodeJS.Platform): string {
15
+ const pathImpl = runtimePlatform === "win32" ? path.win32 : path;
16
+ return pathImpl.join(pathImpl.dirname(nodeExecPath), "node_modules", "npm", "bin", "npm-cli.js");
17
+ }
18
+
19
+ export function resolveNpmCommand(
20
+ npmArgs: string[],
21
+ options?: NpmCommandResolutionOptions
22
+ ): { command: string; args: string[] } {
23
+ const runtimePlatform = options?.platform ?? platform;
24
+
25
+ if (runtimePlatform === "win32") {
26
+ const nodeBinary = options?.nodeExecPath ?? execPath;
27
+ return {
28
+ command: nodeBinary,
29
+ args: [getNpmCliPath(nodeBinary, runtimePlatform), ...npmArgs],
30
+ };
31
+ }
32
+
33
+ return { command: "npm", args: npmArgs };
34
+ }
35
+
36
+ export async function execNpm(
37
+ pi: ExtensionAPI,
38
+ npmArgs: string[],
39
+ ctx: { cwd: string },
40
+ options: NpmExecOptions
41
+ ): Promise<{ code: number; stdout: string; stderr: string; killed: boolean }> {
42
+ const resolved = resolveNpmCommand(npmArgs);
43
+ return pi.exec(resolved.command, resolved.args, {
44
+ timeout: options.timeout,
45
+ cwd: ctx.cwd,
46
+ });
47
+ }
@@ -18,6 +18,10 @@ export function getPackageSourceKind(source: string): PackageSourceKind {
18
18
 
19
19
  if (
20
20
  normalized.startsWith("git:") ||
21
+ normalized.startsWith("git+http://") ||
22
+ normalized.startsWith("git+https://") ||
23
+ normalized.startsWith("git+ssh://") ||
24
+ normalized.startsWith("git+git://") ||
21
25
  normalized.startsWith("http://") ||
22
26
  normalized.startsWith("https://") ||
23
27
  normalized.startsWith("ssh://") ||
@@ -43,6 +47,14 @@ export function getPackageSourceKind(source: string): PackageSourceKind {
43
47
  return "unknown";
44
48
  }
45
49
 
50
+ export function normalizeLocalSourceIdentity(source: string): string {
51
+ const normalized = source.replace(/\\/g, "/");
52
+ const looksWindowsPath =
53
+ /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || source.includes("\\");
54
+
55
+ return looksWindowsPath ? normalized.toLowerCase() : normalized;
56
+ }
57
+
46
58
  export function splitGitRepoAndRef(gitSpec: string): { repo: string; ref?: string | undefined } {
47
59
  const lastAt = gitSpec.lastIndexOf("@");
48
60
  if (lastAt <= 0) {