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 +0 -1
- package/package.json +1 -1
- package/src/packages/discovery.ts +23 -0
- package/src/packages/install.ts +54 -1
- package/src/packages/management.ts +27 -11
- package/src/ui/unified.ts +3 -3
- package/src/utils/retry.ts +49 -0
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
|
@@ -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
|
}
|
package/src/packages/install.ts
CHANGED
|
@@ -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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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({
|
|
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
|
|
444
|
+
return outcome.reloaded;
|
|
429
445
|
}
|
|
430
446
|
case "update": {
|
|
431
447
|
const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
|
|
432
|
-
return outcome.reloaded
|
|
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
|
|
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
|
|
822
|
+
return outcome.reloaded;
|
|
823
823
|
}
|
|
824
824
|
case "remove": {
|
|
825
825
|
const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
|
|
826
|
-
return outcome.reloaded
|
|
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
|
+
}
|