pi-extmgr 0.1.17 → 0.1.18

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.18",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -237,6 +237,29 @@ export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
237
237
  return parseInstalledPackagesOutputInternal(text, { dedupeBySource: true });
238
238
  }
239
239
 
240
+ /**
241
+ * Check if a specific source is installed by running `pi list` and checking output.
242
+ * Returns true if found, false if not found.
243
+ */
244
+ export async function isSourceInstalled(
245
+ source: string,
246
+ ctx: ExtensionCommandContext | ExtensionContext,
247
+ pi: ExtensionAPI
248
+ ): Promise<boolean> {
249
+ try {
250
+ const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
251
+ if (res.code !== 0) return false;
252
+
253
+ const normalized = source.toLowerCase().replace(/\\/g, "/");
254
+ const stdout = res.stdout || "";
255
+
256
+ // Check if the source appears in the output (case-insensitive, normalized paths)
257
+ return stdout.toLowerCase().replace(/\\/g, "/").includes(normalized);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
240
263
  export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
241
264
  return parseInstalledPackagesOutputInternal(text, { dedupeBySource: false });
242
265
  }
@@ -6,7 +6,8 @@ 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 { clearSearchCache, isSourceInstalled } from "./discovery.js";
10
+ import { waitForCondition } from "../utils/retry.js";
10
11
  import { logPackageInstall } from "../utils/history.js";
11
12
  import { clearUpdatesAvailable } from "../utils/settings.js";
12
13
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -129,6 +130,24 @@ export async function installPackage(
129
130
  success(ctx, `Installed ${normalized} (${scope})`);
130
131
  clearUpdatesAvailable(pi, ctx);
131
132
 
133
+ // Wait for the extension to be discoverable before reloading.
134
+ // This prevents a race condition where ctx.reload() runs before
135
+ // settings.json or extension files are fully flushed to disk.
136
+ notify(ctx, "Waiting for extension to be ready...", "info");
137
+ const isReady = await waitForCondition(() => isSourceInstalled(normalized, ctx, pi), {
138
+ maxAttempts: 10,
139
+ delayMs: 100,
140
+ backoff: "exponential",
141
+ });
142
+
143
+ if (!isReady) {
144
+ notify(
145
+ ctx,
146
+ "Extension may not be immediately available. Reload pi manually if needed.",
147
+ "warning"
148
+ );
149
+ }
150
+
132
151
  const reloaded = await confirmReload(ctx, "Package installed.");
133
152
  if (!reloaded) {
134
153
  void updateExtmgrStatus(ctx, pi);
@@ -191,6 +210,23 @@ export async function installFromUrl(
191
210
  logPackageInstall(pi, url, name, undefined, scope, true);
192
211
  success(ctx, `Installed ${name} to:\n${destPath}`);
193
212
  clearUpdatesAvailable(pi, ctx);
213
+
214
+ // Wait for the extension file to be fully written and discoverable
215
+ notify(ctx, "Waiting for extension to be ready...", "info");
216
+ const isReady = await waitForCondition(() => isSourceInstalled(name, ctx, pi), {
217
+ maxAttempts: 10,
218
+ delayMs: 100,
219
+ backoff: "exponential",
220
+ });
221
+
222
+ if (!isReady) {
223
+ notify(
224
+ ctx,
225
+ "Extension may not be immediately available. Reload pi manually if needed.",
226
+ "warning"
227
+ );
228
+ }
229
+
194
230
  const reloaded = await confirmReload(ctx, "Extension installed.");
195
231
  if (!reloaded) {
196
232
  void updateExtmgrStatus(ctx, pi);
@@ -409,6 +445,23 @@ export async function installPackageLocally(
409
445
  logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
410
446
  success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}/index.ts`);
411
447
  clearUpdatesAvailable(pi, ctx);
448
+
449
+ // Wait for the extension to be discoverable before reloading
450
+ notify(ctx, "Waiting for extension to be ready...", "info");
451
+ const isReady = await waitForCondition(() => isSourceInstalled(`npm:${packageName}`, ctx, pi), {
452
+ maxAttempts: 10,
453
+ delayMs: 100,
454
+ backoff: "exponential",
455
+ });
456
+
457
+ if (!isReady) {
458
+ notify(
459
+ ctx,
460
+ "Extension may not be immediately available. Reload pi manually if needed.",
461
+ "warning"
462
+ );
463
+ }
464
+
412
465
  const reloaded = await confirmReload(ctx, "Extension installed.");
413
466
  if (!reloaded) {
414
467
  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,32 @@ 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 the extension to be fully removed from pi list before restarting.
342
+ // This prevents a race condition where the removal hasn't flushed to disk yet.
343
+ if (failures.length === 0 && targets.length > 0) {
344
+ notify(ctx, "Waiting for removal to complete...", "info");
345
+ const mainTarget = targets[0];
346
+ if (mainTarget) {
347
+ const isRemoved = await waitForCondition(
348
+ async () => {
349
+ const stillInstalled = await isSourceInstalled(mainTarget.source, ctx, pi);
350
+ return !stillInstalled;
351
+ },
352
+ { maxAttempts: 10, delayMs: 100, backoff: "exponential" }
353
+ );
354
+
355
+ if (!isRemoved) {
356
+ notify(ctx, "Extension may still be active. Restart pi manually if needed.", "warning");
357
+ }
358
+ }
359
+ }
360
+
361
+ const reloaded = await confirmReload(ctx, "Removal complete.");
362
+ if (!reloaded) {
347
363
  void updateExtmgrStatus(ctx, pi);
348
364
  }
349
365
 
350
- return packageMutationOutcome({ restartRequested });
366
+ return packageMutationOutcome({ reloaded });
351
367
  }
352
368
 
353
369
  export async function removePackage(
@@ -425,11 +441,11 @@ export async function showPackageActions(
425
441
  switch (action) {
426
442
  case "remove": {
427
443
  const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
428
- return outcome.reloaded || outcome.restartRequested;
444
+ return outcome.reloaded;
429
445
  }
430
446
  case "update": {
431
447
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
432
- return outcome.reloaded || outcome.restartRequested;
448
+ return outcome.reloaded;
433
449
  }
434
450
  case "details": {
435
451
  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)}` : "";
@@ -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
+ }