pi-extmgr 0.1.18 → 0.1.20
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 +1 -1
- package/src/index.ts +6 -3
- package/src/packages/discovery.ts +12 -7
- package/src/packages/extensions.ts +15 -5
- package/src/packages/install.ts +40 -41
- package/src/packages/management.ts +16 -14
- package/src/utils/cache.ts +7 -8
- package/src/utils/ui-helpers.ts +0 -26
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
stopAutoUpdateTimer,
|
|
18
18
|
type ContextProvider,
|
|
19
19
|
} from "./utils/auto-update.js";
|
|
20
|
-
import { hydrateAutoUpdateConfig } from "./utils/settings.js";
|
|
20
|
+
import { hydrateAutoUpdateConfig, getAutoUpdateConfig } from "./utils/settings.js";
|
|
21
21
|
import {
|
|
22
22
|
getExtensionsAutocompleteItems,
|
|
23
23
|
resolveCommand,
|
|
@@ -72,8 +72,11 @@ export default function extensionsManager(pi: ExtensionAPI) {
|
|
|
72
72
|
|
|
73
73
|
if (!ctx.hasUI) return;
|
|
74
74
|
|
|
75
|
-
const
|
|
76
|
-
|
|
75
|
+
const config = getAutoUpdateConfig(ctx);
|
|
76
|
+
if (config.enabled && config.intervalMs > 0) {
|
|
77
|
+
const getCtx: ContextProvider = () => ctx;
|
|
78
|
+
startAutoUpdateTimer(pi, getCtx, createAutoUpdateNotificationHandler(ctx));
|
|
79
|
+
}
|
|
77
80
|
|
|
78
81
|
setImmediate(() => {
|
|
79
82
|
updateStatusBar(ctx).catch((err) => {
|
|
@@ -238,23 +238,28 @@ export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
/**
|
|
241
|
-
* Check
|
|
242
|
-
*
|
|
241
|
+
* Check whether a specific package source is installed.
|
|
242
|
+
* Matches on normalized package source and optional scope.
|
|
243
243
|
*/
|
|
244
244
|
export async function isSourceInstalled(
|
|
245
245
|
source: string,
|
|
246
246
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
247
|
-
pi: ExtensionAPI
|
|
247
|
+
pi: ExtensionAPI,
|
|
248
|
+
options?: { scope?: "global" | "project" }
|
|
248
249
|
): Promise<boolean> {
|
|
249
250
|
try {
|
|
250
251
|
const res = await pi.exec("pi", ["list"], { timeout: TIMEOUTS.listPackages, cwd: ctx.cwd });
|
|
251
252
|
if (res.code !== 0) return false;
|
|
252
253
|
|
|
253
|
-
const
|
|
254
|
-
const
|
|
254
|
+
const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
|
|
255
|
+
const expected = normalizeSourceIdentity(source);
|
|
255
256
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
});
|
|
258
263
|
} catch {
|
|
259
264
|
return false;
|
|
260
265
|
}
|
|
@@ -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,11 +1,12 @@
|
|
|
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 { fileExists } from "../utils/fs.js";
|
|
9
10
|
import { clearSearchCache, isSourceInstalled } from "./discovery.js";
|
|
10
11
|
import { waitForCondition } from "../utils/retry.js";
|
|
11
12
|
import { logPackageInstall } from "../utils/history.js";
|
|
@@ -67,6 +68,37 @@ function safeExtractGithubMatch(
|
|
|
67
68
|
return { owner, repo, branch, filePath };
|
|
68
69
|
}
|
|
69
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
|
+
|
|
70
102
|
export async function installPackage(
|
|
71
103
|
source: string,
|
|
72
104
|
ctx: ExtensionCommandContext,
|
|
@@ -134,7 +166,7 @@ export async function installPackage(
|
|
|
134
166
|
// This prevents a race condition where ctx.reload() runs before
|
|
135
167
|
// settings.json or extension files are fully flushed to disk.
|
|
136
168
|
notify(ctx, "Waiting for extension to be ready...", "info");
|
|
137
|
-
const isReady = await waitForCondition(() => isSourceInstalled(normalized, ctx, pi), {
|
|
169
|
+
const isReady = await waitForCondition(() => isSourceInstalled(normalized, ctx, pi, { scope }), {
|
|
138
170
|
maxAttempts: 10,
|
|
139
171
|
delayMs: 100,
|
|
140
172
|
backoff: "exponential",
|
|
@@ -211,22 +243,6 @@ export async function installFromUrl(
|
|
|
211
243
|
success(ctx, `Installed ${name} to:\n${destPath}`);
|
|
212
244
|
clearUpdatesAvailable(pi, ctx);
|
|
213
245
|
|
|
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
|
-
|
|
230
246
|
const reloaded = await confirmReload(ctx, "Extension installed.");
|
|
231
247
|
if (!reloaded) {
|
|
232
248
|
void updateExtmgrStatus(ctx, pi);
|
|
@@ -382,12 +398,11 @@ export async function installPackageLocally(
|
|
|
382
398
|
throw new Error(`Extraction failed: ${extractRes.stderr || extractRes.stdout}`);
|
|
383
399
|
}
|
|
384
400
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
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
|
+
);
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
return true;
|
|
@@ -443,25 +458,9 @@ export async function installPackageLocally(
|
|
|
443
458
|
|
|
444
459
|
clearSearchCache();
|
|
445
460
|
logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
|
|
446
|
-
success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}
|
|
461
|
+
success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}`);
|
|
447
462
|
clearUpdatesAvailable(pi, ctx);
|
|
448
463
|
|
|
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
|
-
|
|
465
464
|
const reloaded = await confirmReload(ctx, "Extension installed.");
|
|
466
465
|
if (!reloaded) {
|
|
467
466
|
void updateExtmgrStatus(ctx, pi);
|
|
@@ -338,23 +338,25 @@ async function removePackageInternal(
|
|
|
338
338
|
clearUpdatesAvailable(pi, ctx);
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
// Wait for
|
|
342
|
-
// This prevents a race condition where the removal hasn't flushed to disk yet.
|
|
341
|
+
// Wait for selected targets to disappear from their target scopes before reloading.
|
|
343
342
|
if (failures.length === 0 && targets.length > 0) {
|
|
344
343
|
notify(ctx, "Waiting for removal to complete...", "info");
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
);
|
|
354
357
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
+
if (!isRemoved) {
|
|
359
|
+
notify(ctx, "Extension may still be active. Restart pi manually if needed.", "warning");
|
|
358
360
|
}
|
|
359
361
|
}
|
|
360
362
|
|
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
|
|
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
|
*/
|