pi-extmgr 0.1.17 → 0.1.19

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
@@ -142,7 +142,6 @@ Examples:
142
142
  - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
143
143
  - **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.
144
144
  - **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
145
- - **Remove requires restart**: After removing a package, you need to fully restart Pi (not just a reload) for it to be completely unloaded.
146
145
 
147
146
  ## License
148
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -237,6 +237,34 @@ export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
237
237
  return parseInstalledPackagesOutputInternal(text, { dedupeBySource: true });
238
238
  }
239
239
 
240
+ /**
241
+ * Check whether a specific package source is installed.
242
+ * Matches on normalized package source and optional scope.
243
+ */
244
+ export async function isSourceInstalled(
245
+ source: string,
246
+ ctx: ExtensionCommandContext | ExtensionContext,
247
+ pi: ExtensionAPI,
248
+ options?: { scope?: "global" | "project" }
249
+ ): Promise<boolean> {
250
+ try {
251
+ const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
252
+ if (res.code !== 0) return false;
253
+
254
+ const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
255
+ const expected = normalizeSourceIdentity(source);
256
+
257
+ return installed.some((pkg) => {
258
+ if (normalizeSourceIdentity(pkg.source) !== expected) {
259
+ return false;
260
+ }
261
+ return options?.scope ? pkg.scope === options.scope : true;
262
+ });
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
240
268
  export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
241
269
  return parseInstalledPackagesOutputInternal(text, { dedupeBySource: false });
242
270
  }
@@ -27,14 +27,24 @@ function normalizeSource(source: string): string {
27
27
  .trim();
28
28
  }
29
29
 
30
+ function normalizePackageRootCandidate(candidate: string): string {
31
+ const resolved = resolve(candidate);
32
+
33
+ if (/(?:^|[\\/])package\.json$/i.test(resolved) || /\.(?:[cm]?[jt]s)$/i.test(resolved)) {
34
+ return dirname(resolved);
35
+ }
36
+
37
+ return resolved;
38
+ }
39
+
30
40
  function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
31
41
  if (pkg.resolvedPath) {
32
- return resolve(pkg.resolvedPath);
42
+ return normalizePackageRootCandidate(pkg.resolvedPath);
33
43
  }
34
44
 
35
45
  if (pkg.source.startsWith("file://")) {
36
46
  try {
37
- return resolve(fileURLToPath(pkg.source));
47
+ return normalizePackageRootCandidate(fileURLToPath(pkg.source));
38
48
  } catch {
39
49
  return undefined;
40
50
  }
@@ -45,7 +55,7 @@ function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
45
55
  /^[a-zA-Z]:[\\/]/.test(pkg.source) ||
46
56
  pkg.source.startsWith("\\\\")
47
57
  ) {
48
- return resolve(pkg.source);
58
+ return normalizePackageRootCandidate(pkg.source);
49
59
  }
50
60
 
51
61
  if (
@@ -54,11 +64,11 @@ function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
54
64
  pkg.source.startsWith(".\\") ||
55
65
  pkg.source.startsWith("..\\")
56
66
  ) {
57
- return resolve(cwd, pkg.source);
67
+ return normalizePackageRootCandidate(resolve(cwd, pkg.source));
58
68
  }
59
69
 
60
70
  if (pkg.source.startsWith("~/")) {
61
- return resolve(join(homedir(), pkg.source.slice(2)));
71
+ return normalizePackageRootCandidate(join(homedir(), pkg.source.slice(2)));
62
72
  }
63
73
 
64
74
  return undefined;
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Package installation logic
3
3
  */
4
- import { mkdir, rm, writeFile, access, cp } from "node:fs/promises";
4
+ import { mkdir, rm, writeFile, cp, readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
- import { clearSearchCache } from "./discovery.js";
9
+ import { fileExists } from "../utils/fs.js";
10
+ import { clearSearchCache, isSourceInstalled } from "./discovery.js";
11
+ import { waitForCondition } from "../utils/retry.js";
10
12
  import { logPackageInstall } from "../utils/history.js";
11
13
  import { clearUpdatesAvailable } from "../utils/settings.js";
12
14
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -66,6 +68,37 @@ function safeExtractGithubMatch(
66
68
  return { owner, repo, branch, filePath };
67
69
  }
68
70
 
71
+ function normalizeRelativePath(value: string): string {
72
+ return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
73
+ }
74
+
75
+ async function hasStandaloneEntrypoint(packageRoot: string): Promise<boolean> {
76
+ try {
77
+ const manifestPath = join(packageRoot, "package.json");
78
+ const raw = await readFile(manifestPath, "utf8");
79
+ const parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
80
+ const declared = parsed.pi?.extensions;
81
+
82
+ if (Array.isArray(declared) && declared.length > 0) {
83
+ for (const entry of declared) {
84
+ if (typeof entry !== "string" || !entry.trim()) continue;
85
+ const candidate = join(packageRoot, normalizeRelativePath(entry));
86
+ if (await fileExists(candidate)) {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+ } catch {
93
+ // Ignore invalid/missing manifest and fall back to conventional entrypoints.
94
+ }
95
+
96
+ return (
97
+ (await fileExists(join(packageRoot, "index.ts"))) ||
98
+ (await fileExists(join(packageRoot, "index.js")))
99
+ );
100
+ }
101
+
69
102
  export async function installPackage(
70
103
  source: string,
71
104
  ctx: ExtensionCommandContext,
@@ -129,6 +162,24 @@ export async function installPackage(
129
162
  success(ctx, `Installed ${normalized} (${scope})`);
130
163
  clearUpdatesAvailable(pi, ctx);
131
164
 
165
+ // Wait for the extension to be discoverable before reloading.
166
+ // This prevents a race condition where ctx.reload() runs before
167
+ // settings.json or extension files are fully flushed to disk.
168
+ notify(ctx, "Waiting for extension to be ready...", "info");
169
+ const isReady = await waitForCondition(() => isSourceInstalled(normalized, ctx, pi, { scope }), {
170
+ maxAttempts: 10,
171
+ delayMs: 100,
172
+ backoff: "exponential",
173
+ });
174
+
175
+ if (!isReady) {
176
+ notify(
177
+ ctx,
178
+ "Extension may not be immediately available. Reload pi manually if needed.",
179
+ "warning"
180
+ );
181
+ }
182
+
132
183
  const reloaded = await confirmReload(ctx, "Package installed.");
133
184
  if (!reloaded) {
134
185
  void updateExtmgrStatus(ctx, pi);
@@ -191,6 +242,7 @@ export async function installFromUrl(
191
242
  logPackageInstall(pi, url, name, undefined, scope, true);
192
243
  success(ctx, `Installed ${name} to:\n${destPath}`);
193
244
  clearUpdatesAvailable(pi, ctx);
245
+
194
246
  const reloaded = await confirmReload(ctx, "Extension installed.");
195
247
  if (!reloaded) {
196
248
  void updateExtmgrStatus(ctx, pi);
@@ -346,12 +398,11 @@ export async function installPackageLocally(
346
398
  throw new Error(`Extraction failed: ${extractRes.stderr || extractRes.stdout}`);
347
399
  }
348
400
 
349
- // Verify index.ts exists
350
- const indexPath = join(extractDir, "index.ts");
351
- try {
352
- await access(indexPath);
353
- } catch {
354
- throw new Error(`Package ${packageName} does not have an index.ts file`);
401
+ const hasEntrypoint = await hasStandaloneEntrypoint(extractDir);
402
+ if (!hasEntrypoint) {
403
+ throw new Error(
404
+ `Package ${packageName} does not contain a runnable extension entrypoint (pi.extensions, index.ts, or index.js)`
405
+ );
355
406
  }
356
407
 
357
408
  return true;
@@ -407,8 +458,9 @@ export async function installPackageLocally(
407
458
 
408
459
  clearSearchCache();
409
460
  logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
410
- success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}/index.ts`);
461
+ success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}`);
411
462
  clearUpdatesAvailable(pi, ctx);
463
+
412
464
  const reloaded = await confirmReload(ctx, "Extension installed.");
413
465
  if (!reloaded) {
414
466
  void updateExtmgrStatus(ctx, pi);
@@ -7,7 +7,9 @@ import {
7
7
  getInstalledPackages,
8
8
  clearSearchCache,
9
9
  parseInstalledPackagesOutputAllScopes,
10
+ isSourceInstalled,
10
11
  } from "./discovery.js";
12
+ import { waitForCondition } from "../utils/retry.js";
11
13
  import { formatInstalledPackageLabel, formatBytes, parseNpmSource } from "../utils/format.js";
12
14
  import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
13
15
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
@@ -16,7 +18,6 @@ import { notify, error as notifyError, success } from "../utils/notify.js";
16
18
  import {
17
19
  confirmAction,
18
20
  confirmReload,
19
- confirmRestart,
20
21
  showProgress,
21
22
  formatListOutput,
22
23
  } from "../utils/ui-helpers.js";
@@ -26,12 +27,10 @@ import { TIMEOUTS, UI } from "../constants.js";
26
27
 
27
28
  export interface PackageMutationOutcome {
28
29
  reloaded: boolean;
29
- restartRequested: boolean;
30
30
  }
31
31
 
32
32
  const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
33
33
  reloaded: false,
34
- restartRequested: false,
35
34
  };
36
35
 
37
36
  function packageMutationOutcome(
@@ -339,15 +338,34 @@ async function removePackageInternal(
339
338
  clearUpdatesAvailable(pi, ctx);
340
339
  }
341
340
 
342
- const restartRequested = await confirmRestart(
343
- ctx,
344
- `Removal complete.\n\n⚠️ Extensions/prompts/skills/themes from removed packages are fully unloaded after restarting pi.`
345
- );
346
- if (!restartRequested) {
341
+ // Wait for selected targets to disappear from their target scopes before reloading.
342
+ if (failures.length === 0 && targets.length > 0) {
343
+ notify(ctx, "Waiting for removal to complete...", "info");
344
+ const isRemoved = await waitForCondition(
345
+ async () => {
346
+ const installedChecks = await Promise.all(
347
+ targets.map((target) =>
348
+ isSourceInstalled(target.source, ctx, pi, {
349
+ scope: target.scope,
350
+ })
351
+ )
352
+ );
353
+ return installedChecks.every((installedInScope) => !installedInScope);
354
+ },
355
+ { maxAttempts: 10, delayMs: 100, backoff: "exponential" }
356
+ );
357
+
358
+ if (!isRemoved) {
359
+ notify(ctx, "Extension may still be active. Restart pi manually if needed.", "warning");
360
+ }
361
+ }
362
+
363
+ const reloaded = await confirmReload(ctx, "Removal complete.");
364
+ if (!reloaded) {
347
365
  void updateExtmgrStatus(ctx, pi);
348
366
  }
349
367
 
350
- return packageMutationOutcome({ restartRequested });
368
+ return packageMutationOutcome({ reloaded });
351
369
  }
352
370
 
353
371
  export async function removePackage(
@@ -425,11 +443,11 @@ export async function showPackageActions(
425
443
  switch (action) {
426
444
  case "remove": {
427
445
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
428
- return outcome.reloaded || outcome.restartRequested;
446
+ return outcome.reloaded;
429
447
  }
430
448
  case "update": {
431
449
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
432
- return outcome.reloaded || outcome.restartRequested;
450
+ return outcome.reloaded;
433
451
  }
434
452
  case "details": {
435
453
  const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
package/src/ui/unified.ts CHANGED
@@ -650,7 +650,7 @@ async function navigateWithPendingGuard(
650
650
  return "done";
651
651
  case "update-all": {
652
652
  const outcome = await updatePackagesWithOutcome(ctx, pi);
653
- return outcome.reloaded || outcome.restartRequested ? "exit" : "done";
653
+ return outcome.reloaded ? "exit" : "done";
654
654
  }
655
655
  case "auto-update":
656
656
  await promptAutoUpdateWizard(pi, ctx, (packages) => {
@@ -819,11 +819,11 @@ async function handleUnifiedAction(
819
819
  switch (result.action) {
820
820
  case "update": {
821
821
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
822
- return outcome.reloaded || outcome.restartRequested;
822
+ return outcome.reloaded;
823
823
  }
824
824
  case "remove": {
825
825
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
826
- return outcome.reloaded || outcome.restartRequested;
826
+ return outcome.reloaded;
827
827
  }
828
828
  case "details": {
829
829
  const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { NpmPackage, InstalledPackage } from "../types/index.js";
8
8
  import { CACHE_LIMITS } from "../constants.js";
9
+ import { parseNpmSource } from "./format.js";
9
10
 
10
11
  const CACHE_DIR = process.env.PI_EXTMGR_CACHE_DIR
11
12
  ? process.env.PI_EXTMGR_CACHE_DIR
@@ -365,14 +366,12 @@ export async function getPackageDescriptions(
365
366
  const cache = await loadCache();
366
367
 
367
368
  for (const pkg of packages) {
368
- if (pkg.source.startsWith("npm:")) {
369
- const pkgName = pkg.source.slice(4).split("@")[0];
370
- if (pkgName) {
371
- const cached = cache.packages.get(pkgName);
372
- if (cached?.description && isCacheValid(cached.timestamp)) {
373
- descriptions.set(pkg.source, cached.description);
374
- }
375
- }
369
+ const npmSource = parseNpmSource(pkg.source);
370
+ if (!npmSource?.name) continue;
371
+
372
+ const cached = cache.packages.get(npmSource.name);
373
+ if (cached?.description && isCacheValid(cached.timestamp)) {
374
+ descriptions.set(pkg.source, cached.description);
376
375
  }
377
376
  }
378
377
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Retry utilities for async operations
3
+ */
4
+
5
+ export interface RetryOptions {
6
+ maxAttempts?: number;
7
+ delayMs?: number;
8
+ backoff?: "fixed" | "linear" | "exponential";
9
+ }
10
+
11
+ export async function retryWithBackoff<T>(
12
+ operation: () => Promise<T | undefined>,
13
+ options: RetryOptions = {}
14
+ ): Promise<T | undefined> {
15
+ const { maxAttempts = 5, delayMs = 100, backoff = "exponential" } = options;
16
+
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18
+ const result = await operation();
19
+ if (result !== undefined) {
20
+ return result;
21
+ }
22
+
23
+ if (attempt < maxAttempts) {
24
+ const delay =
25
+ backoff === "exponential"
26
+ ? delayMs * Math.pow(2, attempt - 1)
27
+ : backoff === "linear"
28
+ ? delayMs * attempt
29
+ : delayMs;
30
+ await new Promise((resolve) => setTimeout(resolve, delay));
31
+ }
32
+ }
33
+
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Wait for a condition to be true with timeout
39
+ */
40
+ export async function waitForCondition(
41
+ condition: () => Promise<boolean> | boolean,
42
+ options: RetryOptions = {}
43
+ ): Promise<boolean> {
44
+ const result = await retryWithBackoff(async () => {
45
+ const value = await condition();
46
+ return value ? true : undefined;
47
+ }, options);
48
+ return result === true;
49
+ }
@@ -28,32 +28,6 @@ export async function confirmReload(
28
28
  return false;
29
29
  }
30
30
 
31
- /**
32
- * Confirm and trigger shutdown (for actions requiring full restart)
33
- * Returns true if shutdown was triggered
34
- */
35
- export async function confirmRestart(
36
- ctx: ExtensionCommandContext,
37
- reason: string
38
- ): Promise<boolean> {
39
- if (!ctx.hasUI) {
40
- notify(ctx, `⚠️ ${reason}\nFull restart required to complete. Exit and restart pi manually.`);
41
- return false;
42
- }
43
-
44
- const confirmed = await ctx.ui.confirm(
45
- "Restart Required",
46
- `${reason}\nPackage removed. Commands may still work until you restart pi. Exit now?`
47
- );
48
-
49
- if (confirmed) {
50
- ctx.shutdown();
51
- return true;
52
- }
53
-
54
- return false;
55
- }
56
-
57
31
  /**
58
32
  * Confirm action with timeout
59
33
  */