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 +0 -1
- package/package.json +1 -1
- package/src/packages/discovery.ts +28 -0
- package/src/packages/extensions.ts +15 -5
- package/src/packages/install.ts +61 -9
- package/src/packages/management.ts +29 -11
- package/src/ui/unified.ts +3 -3
- package/src/utils/cache.ts +7 -8
- package/src/utils/retry.ts +49 -0
- package/src/utils/ui-helpers.ts +0 -26
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,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
|
|
42
|
+
return normalizePackageRootCandidate(pkg.resolvedPath);
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
if (pkg.source.startsWith("file://")) {
|
|
36
46
|
try {
|
|
37
|
-
return
|
|
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
|
|
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
|
|
71
|
+
return normalizePackageRootCandidate(join(homedir(), pkg.source.slice(2)));
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
return undefined;
|
package/src/packages/install.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Package installation logic
|
|
3
3
|
*/
|
|
4
|
-
import { mkdir, rm, writeFile,
|
|
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 {
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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}
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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({
|
|
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
|
|
446
|
+
return outcome.reloaded;
|
|
429
447
|
}
|
|
430
448
|
case "update": {
|
|
431
449
|
const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
|
|
432
|
-
return outcome.reloaded
|
|
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
|
|
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)}` : "";
|
package/src/utils/cache.ts
CHANGED
|
@@ -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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
+
}
|
package/src/utils/ui-helpers.ts
CHANGED
|
@@ -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
|
*/
|