pi-extmgr 0.1.23 → 0.1.25

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.
@@ -9,6 +9,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
9
9
  import type { InstalledPackage, PackageExtensionEntry, Scope, State } from "../types/index.js";
10
10
  import { parseNpmSource } from "../utils/format.js";
11
11
  import { fileExists, readSummary } from "../utils/fs.js";
12
+ import { resolveNpmCommand } from "../utils/npm-exec.js";
12
13
 
13
14
  interface PackageSettingsObject {
14
15
  source: string;
@@ -19,6 +20,14 @@ interface SettingsFile {
19
20
  packages?: (string | PackageSettingsObject)[];
20
21
  }
21
22
 
23
+ export interface PackageManifest {
24
+ name?: string;
25
+ dependencies?: Record<string, string>;
26
+ pi?: {
27
+ extensions?: unknown;
28
+ };
29
+ }
30
+
22
31
  const execFileAsync = promisify(execFile);
23
32
  let globalNpmRootCache: string | null | undefined;
24
33
 
@@ -50,7 +59,8 @@ async function getGlobalNpmRoot(): Promise<string | undefined> {
50
59
  }
51
60
 
52
61
  try {
53
- const { stdout } = await execFileAsync("npm", ["root", "-g"], {
62
+ const npmCommand = resolveNpmCommand(["root", "-g"]);
63
+ const { stdout } = await execFileAsync(npmCommand.command, npmCommand.args, {
54
64
  timeout: 2_000,
55
65
  windowsHide: true,
56
66
  });
@@ -206,6 +216,125 @@ async function writeSettingsFile(path: string, settings: SettingsFile): Promise<
206
216
  }
207
217
  }
208
218
 
219
+ function findPackageSettingsIndex(
220
+ packages: SettingsFile["packages"] extends infer T ? NonNullable<T> : never,
221
+ normalizedSource: string
222
+ ): number {
223
+ return packages.findIndex((pkg) => {
224
+ if (typeof pkg === "string") {
225
+ return normalizeSource(pkg) === normalizedSource;
226
+ }
227
+ return normalizeSource(pkg.source) === normalizedSource;
228
+ });
229
+ }
230
+
231
+ function toPackageSettingsObject(
232
+ existing: string | PackageSettingsObject | undefined,
233
+ packageSource: string
234
+ ): PackageSettingsObject {
235
+ if (typeof existing === "string") {
236
+ return { source: existing, extensions: [] };
237
+ }
238
+
239
+ if (existing && typeof existing.source === "string") {
240
+ return {
241
+ source: existing.source,
242
+ extensions: Array.isArray(existing.extensions) ? [...existing.extensions] : [],
243
+ };
244
+ }
245
+
246
+ return { source: packageSource, extensions: [] };
247
+ }
248
+
249
+ function updateExtensionMarkers(
250
+ existingTokens: string[] | undefined,
251
+ changes: ReadonlyMap<string, State>
252
+ ): string[] {
253
+ const nextTokens: string[] = [];
254
+
255
+ for (const token of existingTokens ?? []) {
256
+ if (typeof token !== "string") {
257
+ continue;
258
+ }
259
+
260
+ if (token[0] !== "+" && token[0] !== "-") {
261
+ nextTokens.push(token);
262
+ continue;
263
+ }
264
+
265
+ const tokenPath = normalizeRelativePath(token.slice(1));
266
+ if (!changes.has(tokenPath)) {
267
+ nextTokens.push(token);
268
+ }
269
+ }
270
+
271
+ for (const [extensionPath, target] of Array.from(changes.entries()).sort((a, b) =>
272
+ a[0].localeCompare(b[0])
273
+ )) {
274
+ nextTokens.push(`${target === "enabled" ? "+" : "-"}${extensionPath}`);
275
+ }
276
+
277
+ return nextTokens;
278
+ }
279
+
280
+ export async function validatePackageExtensionSettings(
281
+ scope: Scope,
282
+ cwd: string
283
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
284
+ try {
285
+ await readSettingsFile(getSettingsPath(scope, cwd), { strict: true });
286
+ return { ok: true };
287
+ } catch (error) {
288
+ return {
289
+ ok: false,
290
+ error: error instanceof Error ? error.message : String(error),
291
+ };
292
+ }
293
+ }
294
+
295
+ export async function applyPackageExtensionStateChanges(
296
+ packageSource: string,
297
+ scope: Scope,
298
+ changes: readonly { extensionPath: string; target: State }[],
299
+ cwd: string
300
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
301
+ try {
302
+ if (changes.length === 0) {
303
+ return { ok: true };
304
+ }
305
+
306
+ const settingsPath = getSettingsPath(scope, cwd);
307
+ const settings = await readSettingsFile(settingsPath, { strict: true });
308
+ const normalizedSource = normalizeSource(packageSource);
309
+ const packages = [...(settings.packages ?? [])];
310
+ const index = findPackageSettingsIndex(packages, normalizedSource);
311
+ const packageEntry = toPackageSettingsObject(packages[index], packageSource);
312
+
313
+ const normalizedChanges = new Map<string, State>();
314
+ for (const change of changes) {
315
+ normalizedChanges.set(normalizeRelativePath(change.extensionPath), change.target);
316
+ }
317
+
318
+ packageEntry.extensions = updateExtensionMarkers(packageEntry.extensions, normalizedChanges);
319
+
320
+ if (index === -1) {
321
+ packages.push(packageEntry);
322
+ } else {
323
+ packages[index] = packageEntry;
324
+ }
325
+
326
+ settings.packages = packages;
327
+ await writeSettingsFile(settingsPath, settings);
328
+
329
+ return { ok: true };
330
+ } catch (error) {
331
+ return {
332
+ ok: false,
333
+ error: error instanceof Error ? error.message : String(error),
334
+ };
335
+ }
336
+ }
337
+
209
338
  function safeMatchesGlob(targetPath: string, pattern: string): boolean {
210
339
  try {
211
340
  return matchesGlob(targetPath, pattern);
@@ -405,20 +534,29 @@ async function resolveManifestExtensionEntries(
405
534
  return Array.from(selected).sort((a, b) => a.localeCompare(b));
406
535
  }
407
536
 
408
- export async function resolveManifestExtensionEntrypoints(
537
+ export async function readPackageManifest(
409
538
  packageRoot: string
410
- ): Promise<string[] | undefined> {
539
+ ): Promise<PackageManifest | undefined> {
411
540
  const packageJsonPath = join(packageRoot, "package.json");
412
541
 
413
- let parsed: { pi?: { extensions?: unknown } };
414
542
  try {
415
543
  const raw = await readFile(packageJsonPath, "utf8");
416
- parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
544
+ const parsed = JSON.parse(raw) as unknown;
545
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
546
+ return undefined;
547
+ }
548
+ return parsed as PackageManifest;
417
549
  } catch {
418
550
  return undefined;
419
551
  }
552
+ }
420
553
 
421
- const extensions = parsed.pi?.extensions;
554
+ export async function resolveManifestExtensionEntrypoints(
555
+ packageRoot: string,
556
+ manifest?: PackageManifest
557
+ ): Promise<string[] | undefined> {
558
+ const parsed = manifest ?? (await readPackageManifest(packageRoot));
559
+ const extensions = parsed?.pi?.extensions;
422
560
  if (!Array.isArray(extensions)) {
423
561
  return undefined;
424
562
  }
@@ -427,12 +565,35 @@ export async function resolveManifestExtensionEntrypoints(
427
565
  return resolveManifestExtensionEntries(packageRoot, entries);
428
566
  }
429
567
 
430
- async function discoverEntrypoints(packageRoot: string): Promise<string[]> {
431
- const manifestEntrypoints = await resolveManifestExtensionEntrypoints(packageRoot);
568
+ async function resolveConventionExtensionEntrypoints(packageRoot: string): Promise<string[]> {
569
+ const extensionsDir = join(packageRoot, "extensions");
570
+ return collectExtensionFilesFromDir(packageRoot, extensionsDir);
571
+ }
572
+
573
+ export async function discoverPackageExtensionEntrypoints(
574
+ packageRoot: string,
575
+ options?: {
576
+ allowConventionDirectory?: boolean;
577
+ allowRootIndexFallback?: boolean;
578
+ }
579
+ ): Promise<string[]> {
580
+ const manifest = await readPackageManifest(packageRoot);
581
+ const manifestEntrypoints = await resolveManifestExtensionEntrypoints(packageRoot, manifest);
432
582
  if (manifestEntrypoints !== undefined) {
433
583
  return manifestEntrypoints;
434
584
  }
435
585
 
586
+ if (options?.allowConventionDirectory !== false) {
587
+ const conventionEntrypoints = await resolveConventionExtensionEntrypoints(packageRoot);
588
+ if (conventionEntrypoints.length > 0) {
589
+ return conventionEntrypoints.sort((a, b) => a.localeCompare(b));
590
+ }
591
+ }
592
+
593
+ if (options?.allowRootIndexFallback === false) {
594
+ return [];
595
+ }
596
+
436
597
  const indexTs = join(packageRoot, "index.ts");
437
598
  if (await fileExists(indexTs)) {
438
599
  return ["index.ts"];
@@ -456,7 +617,7 @@ export async function discoverPackageExtensions(
456
617
  const packageRoot = await toPackageRoot(pkg, cwd);
457
618
  if (!packageRoot) continue;
458
619
 
459
- const extensionPaths = await discoverEntrypoints(packageRoot);
620
+ const extensionPaths = await discoverPackageExtensionEntrypoints(packageRoot);
460
621
  for (const extensionPath of extensionPaths) {
461
622
  const normalizedPath = normalizeRelativePath(extensionPath);
462
623
  const absolutePath = resolve(packageRoot, extensionPath);
@@ -490,58 +651,5 @@ export async function setPackageExtensionState(
490
651
  target: State,
491
652
  cwd: string
492
653
  ): Promise<{ ok: true } | { ok: false; error: string }> {
493
- try {
494
- const settingsPath = getSettingsPath(scope, cwd);
495
- const settings = await readSettingsFile(settingsPath, { strict: true });
496
-
497
- const normalizedSource = normalizeSource(packageSource);
498
- const normalizedPath = normalizeRelativePath(extensionPath);
499
- const marker = `${target === "enabled" ? "+" : "-"}${normalizedPath}`;
500
-
501
- const packages = [...(settings.packages ?? [])];
502
- let index = packages.findIndex((pkg) => {
503
- if (typeof pkg === "string") {
504
- return normalizeSource(pkg) === normalizedSource;
505
- }
506
- return normalizeSource(pkg.source) === normalizedSource;
507
- });
508
-
509
- let packageEntry: PackageSettingsObject;
510
- if (index === -1) {
511
- packageEntry = { source: packageSource, extensions: [marker] };
512
- packages.push(packageEntry);
513
- index = packages.length - 1;
514
- } else {
515
- const existing = packages[index];
516
- if (typeof existing === "string") {
517
- packageEntry = { source: existing, extensions: [] };
518
- } else if (existing && typeof existing.source === "string") {
519
- packageEntry = {
520
- source: existing.source,
521
- extensions: Array.isArray(existing.extensions) ? [...existing.extensions] : [],
522
- };
523
- } else {
524
- packageEntry = { source: packageSource, extensions: [] };
525
- }
526
-
527
- packageEntry.extensions = (packageEntry.extensions ?? []).filter((token) => {
528
- if (typeof token !== "string") return false;
529
- if (token[0] !== "+" && token[0] !== "-") return true;
530
- return normalizeRelativePath(token.slice(1)) !== normalizedPath;
531
- });
532
- packageEntry.extensions.push(marker);
533
- packages[index] = packageEntry;
534
- }
535
-
536
- settings.packages = packages;
537
-
538
- await writeSettingsFile(settingsPath, settings);
539
-
540
- return { ok: true };
541
- } catch (error) {
542
- return {
543
- ok: false,
544
- error: error instanceof Error ? error.message : String(error),
545
- };
546
- }
654
+ return applyPackageExtensionStateChanges(packageSource, scope, [{ extensionPath, target }], cwd);
547
655
  }
@@ -8,7 +8,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
9
  import { fileExists } from "../utils/fs.js";
10
10
  import { clearSearchCache, isSourceInstalled } from "./discovery.js";
11
- import { resolveManifestExtensionEntrypoints } from "./extensions.js";
11
+ import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
13
  import { logPackageInstall } from "../utils/history.js";
14
14
  import { clearUpdatesAvailable } from "../utils/settings.js";
@@ -17,6 +17,8 @@ import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.
17
17
  import { tryOperation } from "../utils/mode.js";
18
18
  import { updateExtmgrStatus } from "../utils/status.js";
19
19
  import { execNpm } from "../utils/npm-exec.js";
20
+ import { normalizePackageIdentity } from "../utils/package-source.js";
21
+ import { fetchWithTimeout } from "../utils/network.js";
20
22
  import { TIMEOUTS } from "../constants.js";
21
23
 
22
24
  export type InstallScope = "global" | "project";
@@ -72,20 +74,77 @@ function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo |
72
74
  return { owner, repo, branch, filePath };
73
75
  }
74
76
 
77
+ async function ensureTarAvailable(
78
+ pi: ExtensionAPI,
79
+ ctx: ExtensionCommandContext
80
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
81
+ const result = await pi.exec("tar", ["--version"], {
82
+ timeout: 5_000,
83
+ cwd: ctx.cwd,
84
+ });
85
+
86
+ if (result.code === 0) {
87
+ return { ok: true };
88
+ }
89
+
90
+ return {
91
+ ok: false,
92
+ error:
93
+ "Standalone local installs require the `tar` command on PATH. Install tar or use managed package install instead.",
94
+ };
95
+ }
96
+
75
97
  async function hasStandaloneEntrypoint(packageRoot: string): Promise<boolean> {
76
- const declared = await resolveManifestExtensionEntrypoints(packageRoot);
77
- if (declared !== undefined) {
78
- for (const path of declared) {
79
- if (await fileExists(join(packageRoot, path))) {
80
- return true;
81
- }
98
+ const entrypoints = await discoverPackageExtensionEntrypoints(packageRoot, {
99
+ allowConventionDirectory: false,
100
+ });
101
+
102
+ for (const path of entrypoints) {
103
+ if (await fileExists(join(packageRoot, path))) {
104
+ return true;
105
+ }
106
+ }
107
+
108
+ return false;
109
+ }
110
+
111
+ async function getStandaloneDependencyError(packageRoot: string): Promise<string | undefined> {
112
+ const manifest = await readPackageManifest(packageRoot);
113
+ const dependencies = manifest?.dependencies;
114
+ if (!dependencies || typeof dependencies !== "object") {
115
+ return undefined;
116
+ }
117
+
118
+ const missingDependencies: string[] = [];
119
+ for (const dependencyName of Object.keys(dependencies)) {
120
+ const dependencyPath = join(packageRoot, "node_modules", dependencyName);
121
+ if (!(await fileExists(dependencyPath))) {
122
+ missingDependencies.push(dependencyName);
82
123
  }
83
- return false;
84
124
  }
85
125
 
86
- return (
87
- (await fileExists(join(packageRoot, "index.ts"))) ||
88
- (await fileExists(join(packageRoot, "index.js")))
126
+ if (missingDependencies.length === 0) {
127
+ return undefined;
128
+ }
129
+
130
+ const packageName = manifest?.name ?? "This package";
131
+ return `${packageName} declares runtime dependencies that are not bundled for standalone install: ${missingDependencies.join(", ")}. Use managed install instead, or bundle dependencies in the package tarball.`;
132
+ }
133
+
134
+ async function cleanupStandaloneTempArtifacts(tempDir: string, extractDir?: string): Promise<void> {
135
+ const paths = [extractDir, tempDir].filter((path): path is string => Boolean(path));
136
+
137
+ await Promise.allSettled(
138
+ paths.map(async (path) => {
139
+ try {
140
+ await rm(path, { recursive: true, force: true });
141
+ } catch (error) {
142
+ console.warn(
143
+ `[extmgr] Failed to remove temporary standalone install artifact at ${path}:`,
144
+ error
145
+ );
146
+ }
147
+ })
89
148
  );
90
149
  }
91
150
 
@@ -150,7 +209,7 @@ export async function installPackage(
150
209
  clearSearchCache();
151
210
  logPackageInstall(pi, normalized, normalized, undefined, scope, true);
152
211
  success(ctx, `Installed ${normalized} (${scope})`);
153
- clearUpdatesAvailable(pi, ctx);
212
+ clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized)]);
154
213
 
155
214
  // Wait for the extension to be discoverable before reloading.
156
215
  // This prevents a race condition where ctx.reload() runs before
@@ -208,7 +267,7 @@ export async function installFromUrl(
208
267
  await mkdir(extensionDir, { recursive: true });
209
268
  notify(ctx, `Downloading ${fileName}...`, "info");
210
269
 
211
- const response = await fetch(url);
270
+ const response = await fetchWithTimeout(url, TIMEOUTS.packageInstall);
212
271
  if (!response.ok) {
213
272
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
214
273
  }
@@ -231,7 +290,6 @@ export async function installFromUrl(
231
290
  const { fileName: name, destPath } = result;
232
291
  logPackageInstall(pi, url, name, undefined, scope, true);
233
292
  success(ctx, `Installed ${name} to:\n${destPath}`);
234
- clearUpdatesAvailable(pi, ctx);
235
293
 
236
294
  const reloaded = await confirmReload(ctx, "Extension installed.");
237
295
  if (!reloaded) {
@@ -325,17 +383,33 @@ export async function installPackageLocally(
325
383
  }
326
384
  const { version, tarballUrl } = result;
327
385
 
386
+ const tarAvailability = await ensureTarAvailable(pi, ctx);
387
+ if (!tarAvailability.ok) {
388
+ notifyError(ctx, tarAvailability.error);
389
+ logPackageInstall(
390
+ pi,
391
+ `npm:${packageName}`,
392
+ packageName,
393
+ version,
394
+ scope,
395
+ false,
396
+ tarAvailability.error
397
+ );
398
+ void updateExtmgrStatus(ctx, pi);
399
+ return;
400
+ }
401
+
328
402
  // Download and extract
403
+ const tempDir = join(extensionDir, ".temp");
329
404
  const extractResult = await tryOperation(
330
405
  ctx,
331
406
  async () => {
332
- const tempDir = join(extensionDir, ".temp");
333
407
  await mkdir(tempDir, { recursive: true });
334
408
  const tarballPath = join(tempDir, `${packageName.replace(/[@/]/g, "-")}-${version}.tgz`);
335
409
 
336
410
  showProgress(ctx, "Downloading", `${packageName}@${version}`);
337
411
 
338
- const response = await fetch(tarballUrl);
412
+ const response = await fetchWithTimeout(tarballUrl, TIMEOUTS.packageInstall);
339
413
  if (!response.ok) {
340
414
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
341
415
  }
@@ -343,12 +417,13 @@ export async function installPackageLocally(
343
417
  const buffer = await response.arrayBuffer();
344
418
  await writeFile(tarballPath, new Uint8Array(buffer));
345
419
 
346
- return { tarballPath, tempDir };
420
+ return { tarballPath };
347
421
  },
348
422
  "Download failed"
349
423
  );
350
424
 
351
425
  if (!extractResult) {
426
+ await cleanupStandaloneTempArtifacts(tempDir);
352
427
  logPackageInstall(
353
428
  pi,
354
429
  `npm:${packageName}`,
@@ -361,7 +436,7 @@ export async function installPackageLocally(
361
436
  void updateExtmgrStatus(ctx, pi);
362
437
  return;
363
438
  }
364
- const { tarballPath, tempDir } = extractResult;
439
+ const { tarballPath } = extractResult;
365
440
 
366
441
  // Extract
367
442
  const extractDir = join(
@@ -390,17 +465,22 @@ export async function installPackageLocally(
390
465
  const hasEntrypoint = await hasStandaloneEntrypoint(extractDir);
391
466
  if (!hasEntrypoint) {
392
467
  throw new Error(
393
- `Package ${packageName} does not contain a runnable extension entrypoint (pi.extensions, index.ts, or index.js)`
468
+ `Package ${packageName} does not contain a runnable standalone extension entrypoint (manifest-declared entrypoint, index.ts, or index.js)`
394
469
  );
395
470
  }
396
471
 
472
+ const dependencyError = await getStandaloneDependencyError(extractDir);
473
+ if (dependencyError) {
474
+ throw new Error(dependencyError);
475
+ }
476
+
397
477
  return true;
398
478
  },
399
479
  "Extraction failed"
400
480
  );
401
481
 
402
482
  if (!extractSuccess) {
403
- await rm(extractDir, { recursive: true, force: true });
483
+ await cleanupStandaloneTempArtifacts(tempDir, extractDir);
404
484
  logPackageInstall(
405
485
  pi,
406
486
  `npm:${packageName}`,
@@ -429,7 +509,7 @@ export async function installPackageLocally(
429
509
  "Failed to copy extension"
430
510
  );
431
511
 
432
- await rm(extractDir, { recursive: true, force: true });
512
+ await cleanupStandaloneTempArtifacts(tempDir, extractDir);
433
513
 
434
514
  if (!destResult) {
435
515
  logPackageInstall(
@@ -448,7 +528,6 @@ export async function installPackageLocally(
448
528
  clearSearchCache();
449
529
  logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
450
530
  success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}`);
451
- clearUpdatesAvailable(pi, ctx);
452
531
 
453
532
  const reloaded = await confirmReload(ctx, "Extension installed.");
454
533
  if (!reloaded) {
@@ -10,12 +10,8 @@ import {
10
10
  isSourceInstalled,
11
11
  } from "./discovery.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
- import { formatInstalledPackageLabel, parseNpmSource } from "../utils/format.js";
14
- import {
15
- getPackageSourceKind,
16
- normalizeLocalSourceIdentity,
17
- splitGitRepoAndRef,
18
- } from "../utils/package-source.js";
13
+ import { formatInstalledPackageLabel } from "../utils/format.js";
14
+ import { normalizePackageIdentity } from "../utils/package-source.js";
19
15
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
20
16
  import { clearUpdatesAvailable } from "../utils/settings.js";
21
17
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -70,18 +66,19 @@ async function updatePackageInternal(
70
66
  return NO_PACKAGE_MUTATION_OUTCOME;
71
67
  }
72
68
 
69
+ const updateIdentity = normalizePackageIdentity(source);
73
70
  const stdout = res.stdout || "";
74
71
  if (isUpToDateOutput(stdout)) {
75
72
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
76
73
  logPackageUpdate(pi, source, source, undefined, true);
77
- clearUpdatesAvailable(pi, ctx);
74
+ clearUpdatesAvailable(pi, ctx, [updateIdentity]);
78
75
  void updateExtmgrStatus(ctx, pi);
79
76
  return NO_PACKAGE_MUTATION_OUTCOME;
80
77
  }
81
78
 
82
79
  logPackageUpdate(pi, source, source, undefined, true);
83
80
  success(ctx, `Updated ${source}`);
84
- clearUpdatesAvailable(pi, ctx);
81
+ clearUpdatesAvailable(pi, ctx, [updateIdentity]);
85
82
 
86
83
  const reloaded = await confirmReload(ctx, "Package updated.");
87
84
  if (!reloaded) {
@@ -156,29 +153,8 @@ export async function updatePackagesWithOutcome(
156
153
  return updatePackagesInternal(ctx, pi);
157
154
  }
158
155
 
159
- function packageIdentity(source: string, fallbackName?: string): string {
160
- const npm = parseNpmSource(source);
161
- if (npm?.name) {
162
- return `npm:${npm.name}`;
163
- }
164
-
165
- const sourceKind = getPackageSourceKind(source);
166
-
167
- if (sourceKind === "git") {
168
- const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
169
- const { repo } = splitGitRepoAndRef(gitSpec);
170
- return `git:${repo}`;
171
- }
172
-
173
- if (sourceKind === "local") {
174
- return `src:${normalizeLocalSourceIdentity(source)}`;
175
- }
176
-
177
- if (fallbackName) {
178
- return `name:${fallbackName}`;
179
- }
180
-
181
- return `src:${source}`;
156
+ function packageIdentity(source: string): string {
157
+ return normalizePackageIdentity(source);
182
158
  }
183
159
 
184
160
  async function getInstalledPackagesAllScopes(
@@ -325,9 +301,8 @@ async function removePackageInternal(
325
301
  pi: ExtensionAPI
326
302
  ): Promise<PackageMutationOutcome> {
327
303
  const installed = await getInstalledPackagesAllScopes(ctx, pi);
328
- const direct = installed.find((p) => p.source === source);
329
- const identity = packageIdentity(source, direct?.name);
330
- const matching = installed.filter((p) => packageIdentity(p.source, p.name) === identity);
304
+ const identity = packageIdentity(source);
305
+ const matching = installed.filter((p) => packageIdentity(p.source) === identity);
331
306
 
332
307
  const hasBothScopes =
333
308
  matching.some((pkg) => pkg.scope === "global") &&
@@ -369,12 +344,12 @@ async function removePackageInternal(
369
344
  .map((result) => result.target);
370
345
 
371
346
  const remaining = (await getInstalledPackagesAllScopes(ctx, pi)).filter(
372
- (p) => packageIdentity(p.source, p.name) === identity
347
+ (p) => packageIdentity(p.source) === identity
373
348
  );
374
349
  notifyRemovalSummary(source, remaining, failures, ctx);
375
350
 
376
- if (failures.length === 0) {
377
- clearUpdatesAvailable(pi, ctx);
351
+ if (failures.length === 0 && remaining.length === 0) {
352
+ clearUpdatesAvailable(pi, ctx, [identity]);
378
353
  }
379
354
 
380
355
  const successfulRemovalCount = successfulTargets.length;