pi-extmgr 0.1.25 → 0.1.27

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/README.md CHANGED
@@ -38,10 +38,11 @@ Requires Node.js `>=22.5.0`.
38
38
  - npm search/browse with pagination
39
39
  - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
40
40
  - Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
41
+ - Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc`
41
42
  - **Auto-update**
42
43
  - Interactive wizard (`t` in manager, or `/extensions auto-update`)
43
44
  - Persistent schedule restored on startup and session switch
44
- - Background checks + status bar updates for installed npm packages
45
+ - Background checks + status bar updates for installed npm + git packages
45
46
  - **Operational visibility**
46
47
  - Session history (`/extensions history`)
47
48
  - Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches)
@@ -159,7 +160,7 @@ Examples:
159
160
  - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading
160
161
  - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, so it only accepts runnable standalone layouts (manifest-declared/root entrypoints), requires `tar` on `PATH`, and rejects packages whose runtime `dependencies` are not already bundled with the package contents
161
162
  - **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
162
- - **Auto-update coverage is npm-only today**: extmgr checks update availability for managed npm packages; git/local installs are not included in the background update badge yet.
163
+ - **Auto-update/update badges cover npm + git packages**: extmgr now uses pi's package manager APIs for structured update detection instead of parsing `pi list` output.
163
164
  - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
164
165
  - **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
165
166
  - **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -42,8 +42,8 @@
42
42
  "@mariozechner/pi-tui": "*"
43
43
  },
44
44
  "devDependencies": {
45
- "@mariozechner/pi-coding-agent": "^0.52.6",
46
- "@mariozechner/pi-tui": "^0.52.6",
45
+ "@mariozechner/pi-coding-agent": "^0.62.0",
46
+ "@mariozechner/pi-tui": "^0.62.0",
47
47
  "@types/node": "^22.13.10",
48
48
  "@typescript-eslint/eslint-plugin": "^8.42.0",
49
49
  "@typescript-eslint/parser": "^8.42.0",
package/src/constants.ts CHANGED
@@ -68,8 +68,6 @@ export type CacheLimitKey = keyof typeof CACHE_LIMITS;
68
68
  export const UI = {
69
69
  /** Maximum height for scrollable lists in terminal rows */
70
70
  maxListHeight: 16,
71
- /** Minimum number of items before enabling search functionality */
72
- searchThreshold: 8,
73
71
  /** Default confirmation dialog timeout: 30 seconds */
74
72
  confirmTimeout: 30_000,
75
73
  /** Extended confirmation timeout for destructive operations: 1 minute */
@@ -0,0 +1,162 @@
1
+ import {
2
+ DefaultPackageManager,
3
+ getAgentDir,
4
+ SettingsManager,
5
+ type PackageSource,
6
+ type ProgressEvent,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import type { InstalledPackage, Scope } from "../types/index.js";
9
+ import { normalizePackageIdentity, parsePackageNameAndVersion } from "../utils/package-source.js";
10
+
11
+ type PiScope = "user" | "project";
12
+ type PiPackageUpdate = Awaited<
13
+ ReturnType<DefaultPackageManager["checkForAvailableUpdates"]>
14
+ >[number];
15
+
16
+ export interface AvailablePackageUpdate {
17
+ source: string;
18
+ displayName: string;
19
+ type: "npm" | "git";
20
+ scope: Scope;
21
+ }
22
+
23
+ export interface PackageCatalog {
24
+ listInstalledPackages(options?: { dedupe?: boolean }): Promise<InstalledPackage[]>;
25
+ checkForAvailableUpdates(): Promise<AvailablePackageUpdate[]>;
26
+ install(source: string, scope: Scope, onProgress?: (event: ProgressEvent) => void): Promise<void>;
27
+ remove(source: string, scope: Scope, onProgress?: (event: ProgressEvent) => void): Promise<void>;
28
+ update(source?: string, onProgress?: (event: ProgressEvent) => void): Promise<void>;
29
+ }
30
+
31
+ type PackageCatalogFactory = (cwd: string) => PackageCatalog;
32
+
33
+ let packageCatalogFactory: PackageCatalogFactory = createDefaultPackageCatalog;
34
+
35
+ function toScope(scope: PiScope): Scope {
36
+ return scope === "project" ? "project" : "global";
37
+ }
38
+
39
+ function getPackageSource(pkg: PackageSource): string {
40
+ return typeof pkg === "string" ? pkg : pkg.source;
41
+ }
42
+
43
+ function createPackageRecord(
44
+ source: string,
45
+ scope: PiScope,
46
+ packageManager: DefaultPackageManager
47
+ ): InstalledPackage {
48
+ const resolvedPath = packageManager.getInstalledPath(source, scope);
49
+ const { name, version } = parsePackageNameAndVersion(source);
50
+
51
+ return {
52
+ source,
53
+ name,
54
+ scope: toScope(scope),
55
+ ...(version ? { version } : {}),
56
+ ...(resolvedPath ? { resolvedPath } : {}),
57
+ };
58
+ }
59
+
60
+ function dedupeInstalledPackages(packages: InstalledPackage[]): InstalledPackage[] {
61
+ const byIdentity = new Map<string, InstalledPackage>();
62
+
63
+ for (const pkg of packages) {
64
+ const identity = normalizePackageIdentity(
65
+ pkg.source,
66
+ pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
67
+ );
68
+
69
+ if (!byIdentity.has(identity)) {
70
+ byIdentity.set(identity, pkg);
71
+ }
72
+ }
73
+
74
+ return [...byIdentity.values()];
75
+ }
76
+
77
+ function setProgressCallback(
78
+ packageManager: DefaultPackageManager,
79
+ onProgress?: (event: ProgressEvent) => void
80
+ ): void {
81
+ packageManager.setProgressCallback(onProgress);
82
+ }
83
+
84
+ function createDefaultPackageCatalog(cwd: string): PackageCatalog {
85
+ const agentDir = getAgentDir();
86
+ const settingsManager = SettingsManager.create(cwd, agentDir);
87
+ const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
88
+
89
+ return {
90
+ listInstalledPackages(options) {
91
+ const projectPackages = (settingsManager.getProjectSettings().packages ?? []).map((pkg) =>
92
+ createPackageRecord(getPackageSource(pkg), "project", packageManager)
93
+ );
94
+ const globalPackages = (settingsManager.getGlobalSettings().packages ?? []).map((pkg) =>
95
+ createPackageRecord(getPackageSource(pkg), "user", packageManager)
96
+ );
97
+
98
+ const installed = [...projectPackages, ...globalPackages];
99
+ return Promise.resolve(
100
+ options?.dedupe === false ? installed : dedupeInstalledPackages(installed)
101
+ );
102
+ },
103
+
104
+ async checkForAvailableUpdates() {
105
+ const updates = await packageManager.checkForAvailableUpdates();
106
+ return updates.map((update: PiPackageUpdate) => ({
107
+ source: update.source,
108
+ displayName: update.displayName,
109
+ type: update.type,
110
+ scope: toScope(update.scope),
111
+ }));
112
+ },
113
+
114
+ async install(source, scope, onProgress) {
115
+ setProgressCallback(packageManager, onProgress);
116
+
117
+ try {
118
+ await packageManager.install(source, { local: scope === "project" });
119
+ packageManager.addSourceToSettings(source, { local: scope === "project" });
120
+ await settingsManager.flush();
121
+ } finally {
122
+ setProgressCallback(packageManager, undefined);
123
+ }
124
+ },
125
+
126
+ async remove(source, scope, onProgress) {
127
+ setProgressCallback(packageManager, onProgress);
128
+
129
+ try {
130
+ await packageManager.remove(source, { local: scope === "project" });
131
+ const removed = packageManager.removeSourceFromSettings(source, {
132
+ local: scope === "project",
133
+ });
134
+ await settingsManager.flush();
135
+
136
+ if (!removed) {
137
+ throw new Error(`No matching package found for ${source}`);
138
+ }
139
+ } finally {
140
+ setProgressCallback(packageManager, undefined);
141
+ }
142
+ },
143
+
144
+ async update(source, onProgress) {
145
+ setProgressCallback(packageManager, onProgress);
146
+
147
+ try {
148
+ await packageManager.update(source);
149
+ } finally {
150
+ setProgressCallback(packageManager, undefined);
151
+ }
152
+ },
153
+ };
154
+ }
155
+
156
+ export function getPackageCatalog(cwd: string): PackageCatalog {
157
+ return packageCatalogFactory(cwd);
158
+ }
159
+
160
+ export function setPackageCatalogFactory(factory?: PackageCatalogFactory): void {
161
+ packageCatalogFactory = factory ?? createDefaultPackageCatalog;
162
+ }
@@ -12,12 +12,8 @@ import type { InstalledPackage, NpmPackage, SearchCache } from "../types/index.j
12
12
  import { CACHE_TTL, TIMEOUTS } from "../constants.js";
13
13
  import { readSummary } from "../utils/fs.js";
14
14
  import { parseNpmSource } from "../utils/format.js";
15
- import {
16
- getPackageSourceKind,
17
- normalizePackageIdentity,
18
- splitGitRepoAndRef,
19
- stripGitSourcePrefix,
20
- } from "../utils/package-source.js";
15
+ import { normalizePackageIdentity } from "../utils/package-source.js";
16
+ import { getPackageCatalog } from "./catalog.js";
21
17
  import { execNpm } from "../utils/npm-exec.js";
22
18
  import { fetchWithTimeout } from "../utils/network.js";
23
19
 
@@ -41,6 +37,18 @@ interface NpmSearchResponse {
41
37
 
42
38
  let searchCache: SearchCache | null = null;
43
39
 
40
+ function createAbortError(): Error {
41
+ const error = new Error("Operation cancelled");
42
+ error.name = "AbortError";
43
+ return error;
44
+ }
45
+
46
+ function throwIfAborted(signal?: AbortSignal): void {
47
+ if (signal?.aborted) {
48
+ throw createAbortError();
49
+ }
50
+ }
51
+
44
52
  export function getSearchCache(): SearchCache | null {
45
53
  return searchCache;
46
54
  }
@@ -88,7 +96,8 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
88
96
 
89
97
  async function fetchNpmSearchPage(
90
98
  query: string,
91
- from: number
99
+ from: number,
100
+ signal?: AbortSignal
92
101
  ): Promise<{
93
102
  total: number;
94
103
  resultCount: number;
@@ -101,7 +110,8 @@ async function fetchNpmSearchPage(
101
110
  });
102
111
  const response = await fetchWithTimeout(
103
112
  `${NPM_SEARCH_API}?${params.toString()}`,
104
- TIMEOUTS.npmSearch
113
+ TIMEOUTS.npmSearch,
114
+ signal
105
115
  );
106
116
 
107
117
  if (!response.ok) {
@@ -120,13 +130,16 @@ async function fetchNpmSearchPage(
120
130
  };
121
131
  }
122
132
 
123
- export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmPackage[]> {
133
+ export async function fetchNpmRegistrySearchResults(
134
+ query: string,
135
+ signal?: AbortSignal
136
+ ): Promise<NpmPackage[]> {
124
137
  const packagesByName = new Map<string, NpmPackage>();
125
138
  let from = 0;
126
139
  let total = Infinity;
127
140
 
128
141
  while (from < total) {
129
- const page = await fetchNpmSearchPage(query, from);
142
+ const page = await fetchNpmSearchPage(query, from, signal);
130
143
  total = page.total;
131
144
 
132
145
  if (page.resultCount === 0) {
@@ -148,11 +161,10 @@ export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmP
148
161
  export async function searchNpmPackages(
149
162
  query: string,
150
163
  ctx: ExtensionCommandContext,
151
- _pi: ExtensionAPI
164
+ options?: { signal?: AbortSignal }
152
165
  ): Promise<NpmPackage[]> {
153
- // Check persistent cache first
154
166
  const cached = await getCachedSearch(query);
155
- if (cached && cached.length > 0) {
167
+ if (cached) {
156
168
  if (ctx.hasUI) {
157
169
  ctx.ui.notify(`Using ${cached.length} cached results`, "info");
158
170
  }
@@ -163,7 +175,7 @@ export async function searchNpmPackages(
163
175
  ctx.ui.notify(`Searching npm for "${query}"...`, "info");
164
176
  }
165
177
 
166
- const packages = await fetchNpmRegistrySearchResults(query);
178
+ const packages = await fetchNpmRegistrySearchResults(query, options?.signal);
167
179
 
168
180
  // Cache the results
169
181
  await setCachedSearch(query, packages);
@@ -174,31 +186,21 @@ export async function searchNpmPackages(
174
186
  export async function getInstalledPackages(
175
187
  ctx: ExtensionCommandContext | ExtensionContext,
176
188
  pi: ExtensionAPI,
177
- onProgress?: (current: number, total: number) => void
189
+ onProgress?: (current: number, total: number) => void,
190
+ signal?: AbortSignal
178
191
  ): Promise<InstalledPackage[]> {
179
- const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
180
- if (res.code !== 0) return [];
192
+ throwIfAborted(signal);
181
193
 
182
- const text = res.stdout || "";
183
- if (!text.trim() || /No packages installed/i.test(text)) {
194
+ const packages = await getPackageCatalog(ctx.cwd).listInstalledPackages();
195
+ if (packages.length === 0) {
184
196
  return [];
185
197
  }
186
198
 
187
- const packages = parseInstalledPackagesOutput(text);
188
-
189
- // Fetch metadata (descriptions and sizes) for packages in parallel
190
- await addPackageMetadata(packages, ctx, pi, onProgress);
191
-
199
+ await addPackageMetadata(packages, ctx, pi, onProgress, signal);
200
+ throwIfAborted(signal);
192
201
  return packages;
193
202
  }
194
203
 
195
- function sanitizeListSourceSuffix(source: string): string {
196
- return source
197
- .trim()
198
- .replace(/\s+\((filtered|pinned)\)$/i, "")
199
- .trim();
200
- }
201
-
202
204
  function getInstalledPackageIdentity(pkg: InstalledPackage): string {
203
205
  return normalizePackageIdentity(
204
206
  pkg.source,
@@ -206,224 +208,26 @@ function getInstalledPackageIdentity(pkg: InstalledPackage): string {
206
208
  );
207
209
  }
208
210
 
209
- function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
210
- if (scope === "global") {
211
- return (
212
- lowerTrimmed === "global" ||
213
- lowerTrimmed === "user" ||
214
- lowerTrimmed.startsWith("global packages") ||
215
- lowerTrimmed.startsWith("global:") ||
216
- lowerTrimmed.startsWith("user packages") ||
217
- lowerTrimmed.startsWith("user:")
218
- );
219
- }
220
-
221
- return (
222
- lowerTrimmed === "project" ||
223
- lowerTrimmed === "local" ||
224
- lowerTrimmed.startsWith("project packages") ||
225
- lowerTrimmed.startsWith("project:") ||
226
- lowerTrimmed.startsWith("local packages") ||
227
- lowerTrimmed.startsWith("local:")
228
- );
229
- }
230
-
231
- function looksLikePackageSource(source: string): boolean {
232
- return getPackageSourceKind(source) !== "unknown";
233
- }
234
-
235
- function parseResolvedPathLine(line: string): string | undefined {
236
- const resolvedMatch = line.match(/^resolved\s*:\s*(.+)$/i);
237
- if (resolvedMatch?.[1]) {
238
- return resolvedMatch[1].trim();
239
- }
240
-
241
- if (
242
- line.startsWith("/") ||
243
- line.startsWith("./") ||
244
- line.startsWith("../") ||
245
- line.startsWith(".\\") ||
246
- line.startsWith("..\\") ||
247
- line.startsWith("~/") ||
248
- line.startsWith("file://") ||
249
- /^[a-zA-Z]:[\\/]/.test(line) ||
250
- line.startsWith("\\\\")
251
- ) {
252
- return line;
253
- }
254
-
255
- return undefined;
256
- }
257
-
258
- function parseInstalledPackagesOutputInternal(text: string): InstalledPackage[] {
259
- const packages: InstalledPackage[] = [];
260
-
261
- const lines = text.split("\n");
262
- let currentScope: "global" | "project" = "global";
263
- let currentPackage: InstalledPackage | undefined;
264
-
265
- for (const rawLine of lines) {
266
- if (!rawLine.trim()) continue;
267
-
268
- const isIndented = /^(?:\t+|\s{4,})/.test(rawLine);
269
- const trimmed = rawLine.trim();
270
-
271
- if (isIndented && currentPackage) {
272
- const resolved = parseResolvedPathLine(trimmed);
273
- if (resolved) {
274
- currentPackage.resolvedPath = resolved;
275
- }
276
- continue;
277
- }
278
-
279
- const lowerTrimmed = trimmed.toLowerCase();
280
- if (isScopeHeader(lowerTrimmed, "global")) {
281
- currentScope = "global";
282
- currentPackage = undefined;
283
- continue;
284
- }
285
- if (isScopeHeader(lowerTrimmed, "project")) {
286
- currentScope = "project";
287
- currentPackage = undefined;
288
- continue;
289
- }
290
-
291
- const candidate = trimmed.replace(/^[-•]?\s*/, "").trim();
292
- if (!looksLikePackageSource(candidate)) continue;
293
-
294
- const source = sanitizeListSourceSuffix(candidate);
295
- const { name, version } = parsePackageNameAndVersion(source);
296
-
297
- const pkg: InstalledPackage = { source, name, scope: currentScope };
298
- if (version !== undefined) {
299
- pkg.version = version;
300
- }
301
- packages.push(pkg);
302
- currentPackage = pkg;
303
- }
304
-
305
- return packages;
306
- }
307
-
308
- function shouldReplaceInstalledPackage(
309
- current: InstalledPackage | undefined,
310
- candidate: InstalledPackage
311
- ): boolean {
312
- if (!current) {
313
- return true;
314
- }
315
-
316
- if (current.scope !== candidate.scope) {
317
- return candidate.scope === "project";
318
- }
319
-
320
- return false;
321
- }
322
-
323
- export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
324
- const parsed = parseInstalledPackagesOutputInternal(text);
325
- const deduped = new Map<string, InstalledPackage>();
326
-
327
- for (const pkg of parsed) {
328
- const identity = getInstalledPackageIdentity(pkg);
329
- const current = deduped.get(identity);
330
- if (shouldReplaceInstalledPackage(current, pkg)) {
331
- deduped.set(identity, pkg);
332
- }
333
- }
334
-
335
- return Array.from(deduped.values());
336
- }
337
-
338
- /**
339
- * Check whether a specific package source is installed.
340
- * Matches on normalized package source and optional scope.
341
- */
342
211
  export async function isSourceInstalled(
343
212
  source: string,
344
213
  ctx: ExtensionCommandContext | ExtensionContext,
345
- pi: ExtensionAPI,
346
214
  options?: { scope?: "global" | "project" }
347
215
  ): Promise<boolean> {
348
- try {
349
- const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
350
- if (res.code !== 0) return false;
351
-
352
- const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
353
- const expected = normalizePackageIdentity(source);
354
-
355
- return installed.some((pkg) => {
356
- if (getInstalledPackageIdentity(pkg) !== expected) {
357
- return false;
358
- }
359
- return options?.scope ? pkg.scope === options.scope : true;
360
- });
361
- } catch {
362
- return false;
363
- }
364
- }
365
-
366
- /**
367
- * parseInstalledPackagesOutputAllScopes returns the raw parsed entries from
368
- * parseInstalledPackagesOutputInternal without deduplication or scope merging.
369
- * Prefer parseInstalledPackagesOutput for user-facing lists, since it applies
370
- * deduplication and normalized scope selection.
371
- */
372
- export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
373
- return parseInstalledPackagesOutputInternal(text);
374
- }
375
-
376
- function extractGitPackageName(repoSpec: string): string {
377
- // git@github.com:user/repo(.git)
378
- if (repoSpec.startsWith("git@")) {
379
- const afterColon = repoSpec.split(":").slice(1).join(":");
380
- if (afterColon) {
381
- const last = afterColon.split("/").pop() || afterColon;
382
- return last.replace(/\.git$/i, "") || repoSpec;
383
- }
384
- }
216
+ const installed = await getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false });
217
+ const expected = normalizePackageIdentity(source);
385
218
 
386
- // https://..., ssh://..., git://...
387
- try {
388
- const url = new URL(repoSpec);
389
- const last = url.pathname.split("/").filter(Boolean).pop();
390
- if (last) {
391
- return last.replace(/\.git$/i, "") || repoSpec;
219
+ return installed.some((pkg) => {
220
+ if (getInstalledPackageIdentity(pkg) !== expected) {
221
+ return false;
392
222
  }
393
- } catch {
394
- // Fallback below
395
- }
396
-
397
- const last = repoSpec.split(/[/:]/).filter(Boolean).pop();
398
- return (last ? last.replace(/\.git$/i, "") : repoSpec) || repoSpec;
223
+ return options?.scope ? pkg.scope === options.scope : true;
224
+ });
399
225
  }
400
226
 
401
- function parsePackageNameAndVersion(fullSource: string): {
402
- name: string;
403
- version?: string | undefined;
404
- } {
405
- const parsedNpm = parseNpmSource(fullSource);
406
- if (parsedNpm) {
407
- return parsedNpm;
408
- }
409
-
410
- const sourceKind = getPackageSourceKind(fullSource);
411
- if (sourceKind === "git") {
412
- const gitSpec = stripGitSourcePrefix(fullSource);
413
- const { repo } = splitGitRepoAndRef(gitSpec);
414
- return { name: extractGitPackageName(repo) };
415
- }
416
-
417
- if (fullSource.includes("node_modules/")) {
418
- const nmMatch = fullSource.match(/node_modules\/(.+)$/);
419
- if (nmMatch?.[1]) {
420
- return { name: nmMatch[1] };
421
- }
422
- }
423
-
424
- const pathParts = fullSource.split(/[\\/]/);
425
- const fileName = pathParts[pathParts.length - 1];
426
- return { name: fileName || fullSource };
227
+ export async function getInstalledPackagesAllScopes(
228
+ ctx: ExtensionCommandContext | ExtensionContext
229
+ ): Promise<InstalledPackage[]> {
230
+ return getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false });
427
231
  }
428
232
 
429
233
  async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<void> {
@@ -471,7 +275,8 @@ async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<vo
471
275
  async function fetchPackageSize(
472
276
  pkgName: string,
473
277
  ctx: ExtensionCommandContext | ExtensionContext,
474
- pi: ExtensionAPI
278
+ pi: ExtensionAPI,
279
+ signal?: AbortSignal
475
280
  ): Promise<number | undefined> {
476
281
  // Check cache first
477
282
  const cachedSize = await getCachedPackageSize(pkgName);
@@ -481,6 +286,7 @@ async function fetchPackageSize(
481
286
  // Try to get unpacked size from npm view
482
287
  const res = await execNpm(pi, ["view", pkgName, "dist.unpackedSize", "--json"], ctx, {
483
288
  timeout: TIMEOUTS.npmView,
289
+ ...(signal ? { signal } : {}),
484
290
  });
485
291
  if (res.code === 0) {
486
292
  try {
@@ -503,25 +309,29 @@ async function addPackageMetadata(
503
309
  packages: InstalledPackage[],
504
310
  ctx: ExtensionCommandContext | ExtensionContext,
505
311
  pi: ExtensionAPI,
506
- onProgress?: (current: number, total: number) => void
312
+ onProgress?: (current: number, total: number) => void,
313
+ signal?: AbortSignal
507
314
  ): Promise<void> {
508
- // First, try to get descriptions from cache
315
+ throwIfAborted(signal);
316
+
509
317
  const cachedDescriptions = await getPackageDescriptions(packages);
510
318
  for (const [source, description] of cachedDescriptions) {
511
319
  const pkg = packages.find((p) => p.source === source);
512
320
  if (pkg) pkg.description = description;
513
321
  }
514
322
 
515
- // Process remaining packages in batches
516
323
  const batchSize = 5;
517
324
  for (let i = 0; i < packages.length; i += batchSize) {
325
+ throwIfAborted(signal);
326
+
518
327
  const batch = packages.slice(i, i + batchSize);
519
328
 
520
- // Report progress
521
329
  onProgress?.(i, packages.length);
522
330
 
523
331
  await Promise.all(
524
332
  batch.map(async (pkg) => {
333
+ throwIfAborted(signal);
334
+
525
335
  await hydratePackageFromResolvedPath(pkg);
526
336
 
527
337
  const needsDescription = !pkg.description;
@@ -546,6 +356,7 @@ async function addPackageMetadata(
546
356
  } else {
547
357
  const res = await execNpm(pi, ["view", pkgName, "description", "--json"], ctx, {
548
358
  timeout: TIMEOUTS.npmView,
359
+ ...(signal ? { signal } : {}),
549
360
  });
550
361
  if (res.code === 0) {
551
362
  try {
@@ -565,7 +376,7 @@ async function addPackageMetadata(
565
376
  }
566
377
 
567
378
  if (needsSize) {
568
- pkg.size = await fetchPackageSize(pkgName, ctx, pi);
379
+ pkg.size = await fetchPackageSize(pkgName, ctx, pi, signal);
569
380
  }
570
381
  }
571
382
  } else if (pkg.source.startsWith("git:")) {
@@ -578,8 +389,9 @@ async function addPackageMetadata(
578
389
  }
579
390
  })
580
391
  );
392
+
393
+ throwIfAborted(signal);
581
394
  }
582
395
 
583
- // Final progress update
584
396
  onProgress?.(packages.length, packages.length);
585
397
  }