pi-extmgr 0.1.26 → 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/src/ui/remote.ts CHANGED
@@ -18,6 +18,7 @@ import { installPackage, installPackageLocally } from "../packages/install.js";
18
18
  import { execNpm } from "../utils/npm-exec.js";
19
19
  import { notify } from "../utils/notify.js";
20
20
  import { requireCustomUI, runCustomUI } from "../utils/mode.js";
21
+ import { runTaskWithLoader } from "./async-task.js";
21
22
 
22
23
  interface PackageInfoCacheEntry {
23
24
  timestamp: number;
@@ -109,25 +110,44 @@ const PACKAGE_DETAILS_CHOICES = {
109
110
  back: "Back to results",
110
111
  } as const;
111
112
 
113
+ function createAbortError(): Error {
114
+ const error = new Error("Operation cancelled");
115
+ error.name = "AbortError";
116
+ return error;
117
+ }
118
+
119
+ function throwIfAborted(signal?: AbortSignal): void {
120
+ if (signal?.aborted) {
121
+ throw createAbortError();
122
+ }
123
+ }
124
+
112
125
  function formatCount(value: number | undefined): string {
113
126
  if (typeof value !== "number" || !Number.isFinite(value)) return "unknown";
114
127
  return new Intl.NumberFormat().format(value);
115
128
  }
116
129
 
117
- async function fetchWeeklyDownloads(packageName: string): Promise<number | undefined> {
130
+ async function fetchWeeklyDownloads(
131
+ packageName: string,
132
+ signal?: AbortSignal
133
+ ): Promise<number | undefined> {
118
134
  const controller = new AbortController();
119
135
  const timer = setTimeout(() => controller.abort(), TIMEOUTS.weeklyDownloads);
136
+ const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
120
137
 
121
138
  try {
122
139
  const encoded = encodeURIComponent(packageName);
123
140
  const res = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encoded}`, {
124
- signal: controller.signal,
141
+ signal: combinedSignal,
125
142
  });
126
143
 
127
144
  if (!res.ok) return undefined;
128
145
  const data = (await res.json()) as NpmDownloadsPoint;
129
146
  return typeof data.downloads === "number" ? data.downloads : undefined;
130
- } catch {
147
+ } catch (error) {
148
+ if (signal?.aborted && error instanceof Error && error.name === "AbortError") {
149
+ throw error;
150
+ }
131
151
  return undefined;
132
152
  } finally {
133
153
  clearTimeout(timer);
@@ -137,7 +157,8 @@ async function fetchWeeklyDownloads(packageName: string): Promise<number | undef
137
157
  async function buildPackageInfoText(
138
158
  packageName: string,
139
159
  ctx: ExtensionCommandContext,
140
- pi: ExtensionAPI
160
+ pi: ExtensionAPI,
161
+ signal?: AbortSignal
141
162
  ): Promise<string> {
142
163
  // Check cache first
143
164
  const cached = packageInfoCache.get(packageName);
@@ -148,10 +169,13 @@ async function buildPackageInfoText(
148
169
  const [infoRes, weeklyDownloads] = await Promise.all([
149
170
  execNpm(pi, ["view", packageName, "--json"], ctx, {
150
171
  timeout: TIMEOUTS.npmView,
172
+ ...(signal ? { signal } : {}),
151
173
  }),
152
- fetchWeeklyDownloads(packageName),
174
+ fetchWeeklyDownloads(packageName, signal),
153
175
  ]);
154
176
 
177
+ throwIfAborted(signal);
178
+
155
179
  if (infoRes.code !== 0) {
156
180
  throw new Error(infoRes.stderr || infoRes.stdout || `npm view failed (exit ${infoRes.code})`);
157
181
  }
@@ -179,7 +203,7 @@ async function buildPackageInfoText(
179
203
 
180
204
  const text = lines.join("\n");
181
205
 
182
- // Store in LRU cache
206
+ throwIfAborted(signal);
183
207
  packageInfoCache.set(packageName, { text });
184
208
 
185
209
  return text;
@@ -346,20 +370,34 @@ export async function browseRemotePackages(
346
370
  return;
347
371
  }
348
372
 
349
- // Check cache first
350
- let allPackages: NpmPackage[] = [];
373
+ let allPackages: NpmPackage[] | undefined;
351
374
 
352
- if (isCacheValid(query) && offset > 0) {
375
+ if (isCacheValid(query)) {
353
376
  const cache = getSearchCache();
354
- if (cache) allPackages = cache.results;
355
- } else {
356
- // Show searching notification
357
- ctx.ui.notify(`Searching npm for: ${truncate(query, 40)}...`, "info");
377
+ if (cache) {
378
+ allPackages = cache.results;
379
+ }
380
+ }
381
+
382
+ if (!allPackages) {
383
+ const results = await runTaskWithLoader(
384
+ ctx,
385
+ {
386
+ title: "Remote Packages",
387
+ message: `Searching npm for ${truncate(query, 40)}...`,
388
+ },
389
+ async ({ signal, setMessage }) => {
390
+ setMessage(`Searching npm for ${truncate(query, 40)}...`);
391
+ return searchNpmPackages(query, ctx, { signal });
392
+ }
393
+ );
358
394
 
359
- // Perform search
360
- allPackages = await searchNpmPackages(query, ctx, pi);
395
+ if (!results) {
396
+ notify(ctx, "Remote package search was cancelled.", "info");
397
+ return;
398
+ }
361
399
 
362
- // Cache results for pagination
400
+ allPackages = results;
363
401
  setSearchCache({
364
402
  query,
365
403
  results: allPackages,
@@ -451,7 +489,21 @@ async function showPackageDetails(
451
489
  return;
452
490
  case "viewInfo":
453
491
  try {
454
- const text = await buildPackageInfoText(packageName, ctx, pi);
492
+ const text = await runTaskWithLoader(
493
+ ctx,
494
+ {
495
+ title: packageName,
496
+ message: `Fetching package details for ${packageName}...`,
497
+ },
498
+ ({ signal }) => buildPackageInfoText(packageName, ctx, pi, signal)
499
+ );
500
+
501
+ if (!text) {
502
+ notify(ctx, `Loading ${packageName} details was cancelled.`, "info");
503
+ await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset);
504
+ return;
505
+ }
506
+
455
507
  ctx.ui.notify(text, "info");
456
508
  } catch (error) {
457
509
  const message = error instanceof Error ? error.message : String(error);
package/src/ui/unified.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from "../packages/management.js";
30
30
  import { showRemote } from "./remote.js";
31
31
  import { showHelp } from "./help.js";
32
+ import { runTaskWithLoader } from "./async-task.js";
32
33
  import { formatEntry as formatExtEntry, dynamicTruncate, formatBytes } from "../utils/format.js";
33
34
  import {
34
35
  getStatusIcon,
@@ -43,6 +44,7 @@ import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js
43
44
  import { updateExtmgrStatus } from "../utils/status.js";
44
45
  import { parseChoiceByLabel } from "../utils/command.js";
45
46
  import { notify } from "../utils/notify.js";
47
+ import { confirmReload } from "../utils/ui-helpers.js";
46
48
  import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js";
47
49
  import { hasCustomUI, runCustomUI } from "../utils/mode.js";
48
50
  import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
@@ -82,11 +84,46 @@ async function showInteractiveOnce(
82
84
  ctx: ExtensionCommandContext,
83
85
  pi: ExtensionAPI
84
86
  ): Promise<boolean> {
85
- // Load local extensions and installed packages.
86
- const [localEntries, installedPackages] = await Promise.all([
87
- discoverExtensions(ctx.cwd),
88
- getInstalledPackages(ctx, pi),
89
- ]);
87
+ const initialData = await runTaskWithLoader(
88
+ ctx,
89
+ {
90
+ title: "Extensions Manager",
91
+ message: "Loading extensions and packages...",
92
+ },
93
+ async ({ signal, setMessage }) => {
94
+ const localEntriesPromise = discoverExtensions(ctx.cwd);
95
+ const installedPackagesPromise = getInstalledPackages(
96
+ ctx,
97
+ pi,
98
+ (current, total) => {
99
+ if (total <= 0) {
100
+ return;
101
+ }
102
+ setMessage(`Loading package metadata... ${current}/${total}`);
103
+ },
104
+ signal
105
+ );
106
+
107
+ const [localEntries, installedPackages] = await Promise.all([
108
+ localEntriesPromise,
109
+ installedPackagesPromise,
110
+ ]);
111
+
112
+ return { localEntries, installedPackages };
113
+ }
114
+ );
115
+
116
+ if (!initialData) {
117
+ notify(
118
+ ctx,
119
+ "The unified extensions manager requires the full interactive TUI. Showing read-only local and installed package lists instead.",
120
+ "warning"
121
+ );
122
+ await showInteractiveFallback(ctx, pi);
123
+ return true;
124
+ }
125
+
126
+ const { localEntries, installedPackages } = initialData;
90
127
 
91
128
  // Build unified items list.
92
129
  const knownUpdates = getKnownUpdates(ctx);
@@ -498,7 +535,7 @@ async function applyToggleChangesFromManager(
498
535
  );
499
536
 
500
537
  if (shouldReload) {
501
- await (ctx as ExtensionCommandContext & { reload: () => Promise<void> }).reload();
538
+ await ctx.reload();
502
539
  return { changed: apply.changed, reloaded: true };
503
540
  }
504
541
  } else {
@@ -768,12 +805,8 @@ async function handleUnifiedAction(
768
805
  "info"
769
806
  );
770
807
 
771
- const shouldReload = await ctx.ui.confirm(
772
- "Reload Recommended",
773
- "Extension removed. Reload pi now?"
774
- );
775
- if (shouldReload) {
776
- await (ctx as ExtensionCommandContext & { reload: () => Promise<void> }).reload();
808
+ const reloaded = await confirmReload(ctx, "Extension removed.");
809
+ if (reloaded) {
777
810
  return true;
778
811
  }
779
812
 
@@ -6,8 +6,7 @@ import type {
6
6
  ExtensionCommandContext,
7
7
  ExtensionContext,
8
8
  } from "@mariozechner/pi-coding-agent";
9
- import type { InstalledPackage } from "../types/index.js";
10
- import { getInstalledPackages } from "../packages/discovery.js";
9
+ import { getPackageCatalog } from "../packages/catalog.js";
11
10
  import { notify } from "./notify.js";
12
11
  import {
13
12
  getAutoUpdateConfig,
@@ -17,21 +16,14 @@ import {
17
16
  parseDuration,
18
17
  type AutoUpdateConfig,
19
18
  } from "./settings.js";
20
- import { parseNpmSource } from "./format.js";
21
- import { execNpm } from "./npm-exec.js";
22
19
  import { normalizePackageIdentity } from "./package-source.js";
23
20
  import { logAutoUpdateConfig } from "./history.js";
24
- import { TIMEOUTS } from "../constants.js";
25
21
 
26
22
  import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
27
23
 
28
24
  // Context provider for safe session handling
29
25
  export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined;
30
26
 
31
- function getUpdateIdentity(pkg: InstalledPackage): string {
32
- return normalizePackageIdentity(pkg.source);
33
- }
34
-
35
27
  /**
36
28
  * Start auto-update background checker
37
29
  * Uses a context provider to avoid stale context issues when sessions switch
@@ -67,7 +59,10 @@ export function startAutoUpdateTimer(
67
59
  stopAutoUpdateTimer();
68
60
  return;
69
61
  }
70
- void checkForUpdates(pi, checkCtx, onUpdateAvailable);
62
+
63
+ void checkForUpdates(pi, checkCtx, onUpdateAvailable).catch((error) => {
64
+ console.warn("[extmgr] Auto-update check failed:", error);
65
+ });
71
66
  },
72
67
  { initialDelayMs }
73
68
  );
@@ -96,19 +91,9 @@ export async function checkForUpdates(
96
91
  ctx: ExtensionCommandContext | ExtensionContext,
97
92
  onUpdateAvailable?: (packages: string[]) => void
98
93
  ): Promise<string[]> {
99
- const packages = await getInstalledPackages(ctx, pi);
100
- const npmPackages = packages.filter((p) => p.source.startsWith("npm:"));
101
-
102
- const updatesAvailable: string[] = [];
103
- const updatedPackageNames: string[] = [];
104
-
105
- for (const pkg of npmPackages) {
106
- const hasUpdate = await checkPackageUpdate(pkg, ctx, pi);
107
- if (hasUpdate) {
108
- updatesAvailable.push(getUpdateIdentity(pkg));
109
- updatedPackageNames.push(pkg.name);
110
- }
111
- }
94
+ const updates = await getPackageCatalog(ctx.cwd).checkForAvailableUpdates();
95
+ const updatesAvailable = updates.map((update) => normalizePackageIdentity(update.source));
96
+ const updatedPackageNames = updates.map((update) => update.displayName);
112
97
 
113
98
  const checkedAt = Date.now();
114
99
  const config = getAutoUpdateConfig(ctx);
@@ -126,37 +111,6 @@ export async function checkForUpdates(
126
111
  return updatedPackageNames;
127
112
  }
128
113
 
129
- /**
130
- * Check if a specific package has updates available
131
- */
132
- async function checkPackageUpdate(
133
- pkg: InstalledPackage,
134
- ctx: ExtensionCommandContext | ExtensionContext,
135
- pi: ExtensionAPI
136
- ): Promise<boolean> {
137
- const parsed = parseNpmSource(pkg.source);
138
- const pkgName = parsed?.name;
139
- if (!pkgName) return false;
140
-
141
- try {
142
- const res = await execNpm(pi, ["view", pkgName, "version", "--json"], ctx, {
143
- timeout: TIMEOUTS.npmView,
144
- });
145
-
146
- if (res.code !== 0) return false;
147
-
148
- const latestVersion = JSON.parse(res.stdout) as string;
149
- const currentVersion = pkg.version;
150
-
151
- if (!currentVersion) return false;
152
-
153
- // Simple version comparison (assumes semver)
154
- return latestVersion !== currentVersion;
155
- } catch {
156
- return false;
157
- }
158
- }
159
-
160
114
  /**
161
115
  * Get status text for display
162
116
  */
@@ -1,11 +1,19 @@
1
- export async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
1
+ export async function fetchWithTimeout(
2
+ url: string,
3
+ timeoutMs: number,
4
+ signal?: AbortSignal
5
+ ): Promise<Response> {
2
6
  const controller = new AbortController();
3
7
  const timer = setTimeout(() => controller.abort(), timeoutMs);
8
+ const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
4
9
 
5
10
  try {
6
- return await fetch(url, { signal: controller.signal });
11
+ return await fetch(url, { signal: combinedSignal });
7
12
  } catch (error) {
8
13
  if (error instanceof Error && error.name === "AbortError") {
14
+ if (signal?.aborted) {
15
+ throw error;
16
+ }
9
17
  throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`);
10
18
  }
11
19
  throw error;
@@ -9,6 +9,7 @@ interface NpmCommandResolutionOptions {
9
9
 
10
10
  interface NpmExecOptions {
11
11
  timeout: number;
12
+ signal?: AbortSignal;
12
13
  }
13
14
 
14
15
  function getNpmCliPath(nodeExecPath: string, runtimePlatform: NodeJS.Platform): string {
@@ -43,5 +44,6 @@ export async function execNpm(
43
44
  return pi.exec(resolved.command, resolved.args, {
44
45
  timeout: options.timeout,
45
46
  cwd: ctx.cwd,
47
+ ...(options.signal ? { signal: options.signal } : {}),
46
48
  });
47
49
  }
@@ -100,3 +100,54 @@ export function splitGitRepoAndRef(gitSpec: string): { repo: string; ref?: strin
100
100
 
101
101
  return { repo: gitSpec.slice(0, lastAt), ref: tail };
102
102
  }
103
+
104
+ function extractGitPackageName(repoSpec: string): string {
105
+ if (repoSpec.startsWith("git@")) {
106
+ const afterColon = repoSpec.split(":").slice(1).join(":");
107
+ if (afterColon) {
108
+ const last = afterColon.split("/").pop() || afterColon;
109
+ return last.replace(/\.git$/i, "") || repoSpec;
110
+ }
111
+ }
112
+
113
+ try {
114
+ const url = new URL(repoSpec);
115
+ const last = url.pathname.split("/").filter(Boolean).pop();
116
+ if (last) {
117
+ return last.replace(/\.git$/i, "") || repoSpec;
118
+ }
119
+ } catch {
120
+ // Fall back to generic path splitting below.
121
+ }
122
+
123
+ const last = repoSpec.split(/[/:]/).filter(Boolean).pop();
124
+ return (last ? last.replace(/\.git$/i, "") : repoSpec) || repoSpec;
125
+ }
126
+
127
+ export function parsePackageNameAndVersion(fullSource: string): {
128
+ name: string;
129
+ version?: string | undefined;
130
+ } {
131
+ const parsedNpm = parseNpmSource(fullSource);
132
+ if (parsedNpm) {
133
+ return parsedNpm;
134
+ }
135
+
136
+ const sourceKind = getPackageSourceKind(fullSource);
137
+ if (sourceKind === "git") {
138
+ const gitSpec = stripGitSourcePrefix(fullSource);
139
+ const { repo } = splitGitRepoAndRef(gitSpec);
140
+ return { name: extractGitPackageName(repo) };
141
+ }
142
+
143
+ if (fullSource.includes("node_modules/")) {
144
+ const nmMatch = fullSource.match(/node_modules\/(.+)$/);
145
+ if (nmMatch?.[1]) {
146
+ return { name: nmMatch[1] };
147
+ }
148
+ }
149
+
150
+ const pathParts = fullSource.split(/[\\/]/);
151
+ const fileName = pathParts[pathParts.length - 1];
152
+ return { name: fileName || fullSource };
153
+ }
@@ -6,14 +6,16 @@ import type {
6
6
  ExtensionCommandContext,
7
7
  ExtensionContext,
8
8
  } from "@mariozechner/pi-coding-agent";
9
- import { getInstalledPackages } from "../packages/discovery.js";
9
+ import { getPackageCatalog, type PackageCatalog } from "../packages/catalog.js";
10
10
  import { getAutoUpdateStatus } from "./auto-update.js";
11
11
  import { normalizePackageIdentity } from "./package-source.js";
12
12
  import { getAutoUpdateConfigAsync, saveAutoUpdateConfig } from "./settings.js";
13
13
 
14
+ type CatalogInstalledPackages = Awaited<ReturnType<PackageCatalog["listInstalledPackages"]>>;
15
+
14
16
  function filterStaleUpdates(
15
17
  knownUpdates: string[],
16
- installedPackages: Awaited<ReturnType<typeof getInstalledPackages>>
18
+ installedPackages: CatalogInstalledPackages
17
19
  ): string[] {
18
20
  const installedIdentities = new Set(
19
21
  installedPackages.map((pkg) =>
@@ -34,7 +36,7 @@ export async function updateExtmgrStatus(
34
36
 
35
37
  try {
36
38
  const [packages, autoUpdateConfig] = await Promise.all([
37
- getInstalledPackages(ctx, pi),
39
+ getPackageCatalog(ctx.cwd).listInstalledPackages(),
38
40
  getAutoUpdateConfigAsync(ctx),
39
41
  ]);
40
42
  const statusParts: string[] = [];
@@ -21,7 +21,7 @@ export async function confirmReload(
21
21
  const confirmed = await ctx.ui.confirm("Reload Required", `${reason}\nReload pi now?`);
22
22
 
23
23
  if (confirmed) {
24
- await (ctx as ExtensionCommandContext & { reload: () => Promise<void> }).reload();
24
+ await ctx.reload();
25
25
  return true;
26
26
  }
27
27