pi-extmgr 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Package discovery and listing
3
3
  */
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
4
6
  import type {
5
7
  ExtensionAPI,
6
8
  ExtensionCommandContext,
@@ -10,7 +12,7 @@ import type { InstalledPackage, NpmPackage, SearchCache } from "../types/index.j
10
12
  import { CACHE_TTL, TIMEOUTS } from "../constants.js";
11
13
  import { readSummary } from "../utils/fs.js";
12
14
  import { parseNpmSource } from "../utils/format.js";
13
- import { splitGitRepoAndRef } from "../utils/package-source.js";
15
+ import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
14
16
 
15
17
  let searchCache: SearchCache | null = null;
16
18
 
@@ -280,8 +282,9 @@ function parsePackageNameAndVersion(fullSource: string): {
280
282
  return parsedNpm;
281
283
  }
282
284
 
283
- if (fullSource.startsWith("git:")) {
284
- const gitSpec = fullSource.slice(4);
285
+ const sourceKind = getPackageSourceKind(fullSource);
286
+ if (sourceKind === "git") {
287
+ const gitSpec = fullSource.startsWith("git:") ? fullSource.slice(4) : fullSource;
285
288
  const { repo } = splitGitRepoAndRef(gitSpec);
286
289
  return { name: extractGitPackageName(repo) };
287
290
  }
@@ -298,6 +301,45 @@ function parsePackageNameAndVersion(fullSource: string): {
298
301
  return { name: fileName || fullSource };
299
302
  }
300
303
 
304
+ async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<void> {
305
+ if (!pkg.resolvedPath) return;
306
+
307
+ const manifestPath = /(?:^|[\\/])package\.json$/i.test(pkg.resolvedPath)
308
+ ? pkg.resolvedPath
309
+ : join(pkg.resolvedPath, "package.json");
310
+
311
+ try {
312
+ const raw = await readFile(manifestPath, "utf8");
313
+ const manifest = JSON.parse(raw) as {
314
+ name?: unknown;
315
+ version?: unknown;
316
+ description?: unknown;
317
+ };
318
+
319
+ if (!pkg.version && typeof manifest.version === "string" && manifest.version.trim()) {
320
+ pkg.version = manifest.version.trim();
321
+ }
322
+
323
+ if (
324
+ !pkg.description &&
325
+ typeof manifest.description === "string" &&
326
+ manifest.description.trim()
327
+ ) {
328
+ pkg.description = manifest.description.trim();
329
+ }
330
+
331
+ if (
332
+ (!pkg.name || pkg.name === pkg.source) &&
333
+ typeof manifest.name === "string" &&
334
+ manifest.name.trim()
335
+ ) {
336
+ pkg.name = manifest.name.trim();
337
+ }
338
+ } catch {
339
+ // ignore
340
+ }
341
+ }
342
+
301
343
  /**
302
344
  * Fetch package size from npm view
303
345
  */
@@ -356,7 +398,8 @@ async function addPackageMetadata(
356
398
 
357
399
  await Promise.all(
358
400
  batch.map(async (pkg) => {
359
- // Skip if already has description from cache
401
+ await hydratePackageFromResolvedPath(pkg);
402
+
360
403
  const needsDescription = !pkg.description;
361
404
  const needsSize = pkg.size === undefined && pkg.source.startsWith("npm:");
362
405
 
@@ -364,7 +407,6 @@ async function addPackageMetadata(
364
407
 
365
408
  try {
366
409
  if (pkg.source.endsWith(".ts") || pkg.source.endsWith(".js")) {
367
- // For local files, read description from file
368
410
  if (needsDescription) {
369
411
  pkg.description = await readSummary(pkg.source);
370
412
  }
@@ -373,13 +415,11 @@ async function addPackageMetadata(
373
415
  const pkgName = parsed?.name;
374
416
 
375
417
  if (pkgName) {
376
- // Get description
377
418
  if (needsDescription) {
378
419
  const cached = await getCachedPackage(pkgName);
379
420
  if (cached?.description) {
380
421
  pkg.description = cached.description;
381
422
  } else {
382
- // Fetch from npm and cache it
383
423
  const res = await pi.exec("npm", ["view", pkgName, "description", "--json"], {
384
424
  timeout: TIMEOUTS.npmView,
385
425
  cwd: ctx.cwd,
@@ -389,7 +429,6 @@ async function addPackageMetadata(
389
429
  const desc = JSON.parse(res.stdout) as string;
390
430
  if (typeof desc === "string" && desc) {
391
431
  pkg.description = desc;
392
- // Cache the description
393
432
  await setCachedPackage(pkgName, {
394
433
  name: pkgName,
395
434
  description: desc,
@@ -402,7 +441,6 @@ async function addPackageMetadata(
402
441
  }
403
442
  }
404
443
 
405
- // Get size
406
444
  if (needsSize) {
407
445
  pkg.size = await fetchPackageSize(pkgName, ctx, pi);
408
446
  }
@@ -23,11 +23,27 @@ import { requireUI } from "../utils/mode.js";
23
23
  import { updateExtmgrStatus } from "../utils/status.js";
24
24
  import { TIMEOUTS, UI } from "../constants.js";
25
25
 
26
- export async function updatePackage(
26
+ export interface PackageMutationOutcome {
27
+ reloaded: boolean;
28
+ restartRequested: boolean;
29
+ }
30
+
31
+ const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
32
+ reloaded: false,
33
+ restartRequested: false,
34
+ };
35
+
36
+ function packageMutationOutcome(
37
+ overrides: Partial<PackageMutationOutcome>
38
+ ): PackageMutationOutcome {
39
+ return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides };
40
+ }
41
+
42
+ async function updatePackageInternal(
27
43
  source: string,
28
44
  ctx: ExtensionCommandContext,
29
45
  pi: ExtensionAPI
30
- ): Promise<void> {
46
+ ): Promise<PackageMutationOutcome> {
31
47
  showProgress(ctx, "Updating", source);
32
48
 
33
49
  const res = await pi.exec("pi", ["update", source], {
@@ -40,28 +56,29 @@ export async function updatePackage(
40
56
  logPackageUpdate(pi, source, source, undefined, undefined, false, errorMsg);
41
57
  notifyError(ctx, errorMsg);
42
58
  void updateExtmgrStatus(ctx, pi);
43
- return;
59
+ return NO_PACKAGE_MUTATION_OUTCOME;
44
60
  }
45
61
 
46
62
  const stdout = res.stdout || "";
47
63
  if (stdout.includes("already up to date") || stdout.includes("pinned")) {
48
64
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
49
65
  logPackageUpdate(pi, source, source, undefined, undefined, true);
50
- } else {
51
- logPackageUpdate(pi, source, source, undefined, undefined, true);
52
- success(ctx, `Updated ${source}`);
53
66
  void updateExtmgrStatus(ctx, pi);
54
- await confirmReload(ctx, "Package updated.");
55
- return;
67
+ return NO_PACKAGE_MUTATION_OUTCOME;
56
68
  }
57
69
 
70
+ logPackageUpdate(pi, source, source, undefined, undefined, true);
71
+ success(ctx, `Updated ${source}`);
58
72
  void updateExtmgrStatus(ctx, pi);
73
+
74
+ const reloaded = await confirmReload(ctx, "Package updated.");
75
+ return packageMutationOutcome({ reloaded });
59
76
  }
60
77
 
61
- export async function updatePackages(
78
+ async function updatePackagesInternal(
62
79
  ctx: ExtensionCommandContext,
63
80
  pi: ExtensionAPI
64
- ): Promise<void> {
81
+ ): Promise<PackageMutationOutcome> {
65
82
  showProgress(ctx, "Updating", "all packages");
66
83
 
67
84
  const res = await pi.exec("pi", ["update"], { timeout: 300000, cwd: ctx.cwd });
@@ -69,20 +86,51 @@ export async function updatePackages(
69
86
  if (res.code !== 0) {
70
87
  notifyError(ctx, `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
71
88
  void updateExtmgrStatus(ctx, pi);
72
- return;
89
+ return NO_PACKAGE_MUTATION_OUTCOME;
73
90
  }
74
91
 
75
92
  const stdout = res.stdout || "";
76
93
  if (stdout.includes("already up to date") || stdout.trim() === "") {
77
94
  notify(ctx, "All packages are already up to date.", "info");
78
- } else {
79
- success(ctx, "Packages updated");
80
95
  void updateExtmgrStatus(ctx, pi);
81
- await confirmReload(ctx, "Packages updated.");
82
- return;
96
+ return NO_PACKAGE_MUTATION_OUTCOME;
83
97
  }
84
98
 
99
+ success(ctx, "Packages updated");
85
100
  void updateExtmgrStatus(ctx, pi);
101
+
102
+ const reloaded = await confirmReload(ctx, "Packages updated.");
103
+ return packageMutationOutcome({ reloaded });
104
+ }
105
+
106
+ export async function updatePackage(
107
+ source: string,
108
+ ctx: ExtensionCommandContext,
109
+ pi: ExtensionAPI
110
+ ): Promise<void> {
111
+ await updatePackageInternal(source, ctx, pi);
112
+ }
113
+
114
+ export async function updatePackageWithOutcome(
115
+ source: string,
116
+ ctx: ExtensionCommandContext,
117
+ pi: ExtensionAPI
118
+ ): Promise<PackageMutationOutcome> {
119
+ return updatePackageInternal(source, ctx, pi);
120
+ }
121
+
122
+ export async function updatePackages(
123
+ ctx: ExtensionCommandContext,
124
+ pi: ExtensionAPI
125
+ ): Promise<void> {
126
+ await updatePackagesInternal(ctx, pi);
127
+ }
128
+
129
+ export async function updatePackagesWithOutcome(
130
+ ctx: ExtensionCommandContext,
131
+ pi: ExtensionAPI
132
+ ): Promise<PackageMutationOutcome> {
133
+ return updatePackagesInternal(ctx, pi);
86
134
  }
87
135
 
88
136
  function packageIdentity(source: string, fallbackName?: string): string {
@@ -234,11 +282,11 @@ function notifyRemovalSummary(
234
282
  }
235
283
  }
236
284
 
237
- export async function removePackage(
285
+ async function removePackageInternal(
238
286
  source: string,
239
287
  ctx: ExtensionCommandContext,
240
288
  pi: ExtensionAPI
241
- ): Promise<void> {
289
+ ): Promise<PackageMutationOutcome> {
242
290
  const installed = await getInstalledPackagesAllScopes(ctx, pi);
243
291
  const direct = installed.find((p) => p.source === source);
244
292
  const identity = packageIdentity(source, direct?.name);
@@ -251,13 +299,13 @@ export async function removePackage(
251
299
 
252
300
  if (scopeChoice === "cancel") {
253
301
  notify(ctx, "Removal cancelled.", "info");
254
- return;
302
+ return NO_PACKAGE_MUTATION_OUTCOME;
255
303
  }
256
304
 
257
305
  const targets = buildRemovalTargets(matching, source, ctx.hasUI, scopeChoice);
258
306
  if (targets.length === 0) {
259
307
  notify(ctx, "Nothing to remove.", "info");
260
- return;
308
+ return NO_PACKAGE_MUTATION_OUTCOME;
261
309
  }
262
310
 
263
311
  const confirmed = await confirmAction(
@@ -268,7 +316,7 @@ export async function removePackage(
268
316
  );
269
317
  if (!confirmed) {
270
318
  notify(ctx, "Removal cancelled.", "info");
271
- return;
319
+ return NO_PACKAGE_MUTATION_OUTCOME;
272
320
  }
273
321
 
274
322
  const failures = await executeRemovalTargets(targets, ctx, pi);
@@ -281,10 +329,28 @@ export async function removePackage(
281
329
 
282
330
  void updateExtmgrStatus(ctx, pi);
283
331
 
284
- await confirmRestart(
332
+ const restartRequested = await confirmRestart(
285
333
  ctx,
286
334
  `Removal complete.\n\n⚠️ Extensions/prompts/skills/themes from removed packages are fully unloaded after restarting pi.`
287
335
  );
336
+
337
+ return packageMutationOutcome({ restartRequested });
338
+ }
339
+
340
+ export async function removePackage(
341
+ source: string,
342
+ ctx: ExtensionCommandContext,
343
+ pi: ExtensionAPI
344
+ ): Promise<void> {
345
+ await removePackageInternal(source, ctx, pi);
346
+ }
347
+
348
+ export async function removePackageWithOutcome(
349
+ source: string,
350
+ ctx: ExtensionCommandContext,
351
+ pi: ExtensionAPI
352
+ ): Promise<PackageMutationOutcome> {
353
+ return removePackageInternal(source, ctx, pi);
288
354
  }
289
355
 
290
356
  export async function promptRemove(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
@@ -344,12 +410,14 @@ export async function showPackageActions(
344
410
  : "back";
345
411
 
346
412
  switch (action) {
347
- case "remove":
348
- await removePackage(pkg.source, ctx, pi);
349
- return false;
350
- case "update":
351
- await updatePackage(pkg.source, ctx, pi);
352
- return false;
413
+ case "remove": {
414
+ const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
415
+ return outcome.reloaded || outcome.restartRequested;
416
+ }
417
+ case "update": {
418
+ const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
419
+ return outcome.reloaded || outcome.restartRequested;
420
+ }
353
421
  case "details": {
354
422
  const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
355
423
  notify(
package/src/ui/unified.ts CHANGED
@@ -30,9 +30,9 @@ import { getInstalledPackages } from "../packages/discovery.js";
30
30
  import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
31
31
  import {
32
32
  showPackageActions,
33
- updatePackage,
34
- removePackage,
35
- updatePackages,
33
+ updatePackageWithOutcome,
34
+ removePackageWithOutcome,
35
+ updatePackagesWithOutcome,
36
36
  } from "../packages/management.js";
37
37
  import { showRemote } from "./remote.js";
38
38
  import { showHelp } from "./help.js";
@@ -48,6 +48,7 @@ import { logExtensionToggle } from "../utils/history.js";
48
48
  import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js";
49
49
  import { updateExtmgrStatus } from "../utils/status.js";
50
50
  import { parseChoiceByLabel } from "../utils/command.js";
51
+ import { getPackageSourceKind } from "../utils/package-source.js";
51
52
  import { UI } from "../constants.js";
52
53
 
53
54
  // Type guard for SettingsList with selectedIndex
@@ -268,7 +269,7 @@ async function showInteractiveOnce(
268
269
  return await handleUnifiedAction(result, items, staged, byId, ctx, pi);
269
270
  }
270
271
 
271
- function buildUnifiedItems(
272
+ export function buildUnifiedItems(
272
273
  localEntries: Awaited<ReturnType<typeof discoverExtensions>>,
273
274
  installedPackages: InstalledPackage[],
274
275
  packageExtensions: PackageExtensionEntry[],
@@ -277,7 +278,22 @@ function buildUnifiedItems(
277
278
  const items: UnifiedItem[] = [];
278
279
  const localPaths = new Set<string>();
279
280
 
280
- const packageSourcesWithExtensions = new Set<string>();
281
+ const packageExtensionGroups = new Map<string, PackageExtensionEntry[]>();
282
+ for (const entry of packageExtensions) {
283
+ const key = `${entry.packageScope}:${entry.packageSource.toLowerCase()}`;
284
+ const group = packageExtensionGroups.get(key) ?? [];
285
+ group.push(entry);
286
+ packageExtensionGroups.set(key, group);
287
+ }
288
+
289
+ const visiblePackageExtensions = packageExtensions.filter((entry) => {
290
+ const key = `${entry.packageScope}:${entry.packageSource.toLowerCase()}`;
291
+ const group = packageExtensionGroups.get(key) ?? [];
292
+
293
+ // Avoid duplicate-looking rows for packages that expose a single enabled extension entrypoint.
294
+ // Keep extension rows when there are multiple entrypoints, or when an entry is disabled so it can be re-enabled.
295
+ return group.length > 1 || entry.state === "disabled";
296
+ });
281
297
 
282
298
  // Add local extensions
283
299
  for (const entry of localEntries) {
@@ -295,8 +311,7 @@ function buildUnifiedItems(
295
311
  });
296
312
  }
297
313
 
298
- for (const entry of packageExtensions) {
299
- packageSourcesWithExtensions.add(entry.packageSource.toLowerCase());
314
+ for (const entry of visiblePackageExtensions) {
300
315
  items.push({
301
316
  type: "package-extension",
302
317
  id: entry.id,
@@ -314,8 +329,6 @@ function buildUnifiedItems(
314
329
  const pkgSourceLower = pkg.source.toLowerCase();
315
330
  const pkgResolvedLower = pkg.resolvedPath?.toLowerCase() ?? "";
316
331
 
317
- if (packageSourcesWithExtensions.has(pkgSourceLower)) continue;
318
-
319
332
  let isDuplicate = false;
320
333
  for (const localPath of localPaths) {
321
334
  if (pkgSourceLower === localPath || pkgResolvedLower === localPath) {
@@ -355,7 +368,7 @@ function buildUnifiedItems(
355
368
  items.sort((a, b) => {
356
369
  const rank = (type: UnifiedItem["type"]): number => {
357
370
  if (type === "local") return 0;
358
- if (type === "package-extension") return 1;
371
+ if (type === "package") return 1;
359
372
  return 2;
360
373
  };
361
374
 
@@ -427,7 +440,7 @@ function formatUnifiedItemLabel(
427
440
  theme: Theme,
428
441
  changed = false
429
442
  ): string {
430
- if (item.type === "local" || item.type === "package-extension") {
443
+ if (item.type === "local") {
431
444
  const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
432
445
  const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
433
446
  const changeMarker = getChangeMarker(theme, changed);
@@ -436,7 +449,26 @@ function formatUnifiedItemLabel(
436
449
  return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
437
450
  }
438
451
 
439
- const pkgIcon = getPackageIcon(theme, item.source?.startsWith("npm:") ? "npm" : "git");
452
+ if (item.type === "package-extension") {
453
+ const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
454
+ const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
455
+ const sourceKind = getPackageSourceKind(item.packageSource ?? "");
456
+ const pkgIcon = getPackageIcon(
457
+ theme,
458
+ sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
459
+ );
460
+ const sourceLabel = sourceKind === "unknown" ? "package" : `${sourceKind} package`;
461
+ const changeMarker = getChangeMarker(theme, changed);
462
+ const name = theme.bold(item.displayName);
463
+ const summary = theme.fg("dim", `${item.summary} • ${sourceLabel}`);
464
+ return `${statusIcon} ${pkgIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
465
+ }
466
+
467
+ const sourceKind = getPackageSourceKind(item.source ?? "");
468
+ const pkgIcon = getPackageIcon(
469
+ theme,
470
+ sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
471
+ );
440
472
  const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
441
473
  const name = theme.bold(item.displayName);
442
474
  const version = item.version ? theme.fg("dim", `@${item.version}`) : "";
@@ -449,9 +481,9 @@ function formatUnifiedItemLabel(
449
481
  // Reserved space: icon (2) + scope (3) + name (~25) + version (~10) + separator (3) = ~43 chars
450
482
  if (item.description) {
451
483
  infoParts.push(dynamicTruncate(item.description, 43));
452
- } else if (item.source?.startsWith("npm:")) {
484
+ } else if (sourceKind === "npm") {
453
485
  infoParts.push("npm");
454
- } else if (item.source?.startsWith("git:")) {
486
+ } else if (sourceKind === "git") {
455
487
  infoParts.push("git");
456
488
  } else {
457
489
  infoParts.push("local");
@@ -616,9 +648,10 @@ async function navigateWithPendingGuard(
616
648
  case "browse":
617
649
  await showRemote("", ctx, pi);
618
650
  return "done";
619
- case "update-all":
620
- await updatePackages(ctx, pi);
621
- return "done";
651
+ case "update-all": {
652
+ const outcome = await updatePackagesWithOutcome(ctx, pi);
653
+ return outcome.reloaded || outcome.restartRequested ? "exit" : "done";
654
+ }
622
655
  case "auto-update":
623
656
  await promptAutoUpdateWizard(pi, ctx, (packages) => {
624
657
  ctx.ui.notify(
@@ -784,12 +817,14 @@ async function handleUnifiedAction(
784
817
  };
785
818
 
786
819
  switch (result.action) {
787
- case "update":
788
- await updatePackage(pkg.source, ctx, pi);
789
- return false;
790
- case "remove":
791
- await removePackage(pkg.source, ctx, pi);
792
- return false;
820
+ case "update": {
821
+ const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
822
+ return outcome.reloaded || outcome.restartRequested;
823
+ }
824
+ case "remove": {
825
+ const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
826
+ return outcome.reloaded || outcome.restartRequested;
827
+ }
793
828
  case "details": {
794
829
  const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
795
830
  ctx.ui.notify(
@@ -2,6 +2,44 @@
2
2
  * Package source parsing helpers shared across discovery/management flows.
3
3
  */
4
4
 
5
+ export type PackageSourceKind = "npm" | "git" | "local" | "unknown";
6
+
7
+ function sanitizeSource(source: string): string {
8
+ return source
9
+ .trim()
10
+ .replace(/\s+\((filtered|pinned)\)$/i, "")
11
+ .trim();
12
+ }
13
+
14
+ export function getPackageSourceKind(source: string): PackageSourceKind {
15
+ const normalized = sanitizeSource(source);
16
+
17
+ if (normalized.startsWith("npm:")) return "npm";
18
+
19
+ if (
20
+ normalized.startsWith("git:") ||
21
+ normalized.startsWith("http://") ||
22
+ normalized.startsWith("https://") ||
23
+ normalized.startsWith("ssh://") ||
24
+ /^git@[^\s:]+:.+/.test(normalized)
25
+ ) {
26
+ return "git";
27
+ }
28
+
29
+ if (
30
+ normalized.startsWith("/") ||
31
+ normalized.startsWith("./") ||
32
+ normalized.startsWith("../") ||
33
+ normalized.startsWith("~/") ||
34
+ /^[a-zA-Z]:[\\/]/.test(normalized) ||
35
+ normalized.startsWith("\\\\")
36
+ ) {
37
+ return "local";
38
+ }
39
+
40
+ return "unknown";
41
+ }
42
+
5
43
  export function splitGitRepoAndRef(gitSpec: string): { repo: string; ref?: string | undefined } {
6
44
  const lastAt = gitSpec.lastIndexOf("@");
7
45
  if (lastAt <= 0) {