pi-extmgr 0.1.18 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.18",
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",
@@ -238,23 +238,28 @@ export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
238
238
  }
239
239
 
240
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.
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 normalized = source.toLowerCase().replace(/\\/g, "/");
254
- const stdout = res.stdout || "";
254
+ const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
255
+ const expected = normalizeSourceIdentity(source);
255
256
 
256
- // Check if the source appears in the output (case-insensitive, normalized paths)
257
- return stdout.toLowerCase().replace(/\\/g, "/").includes(normalized);
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 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,11 +1,12 @@
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 { 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
- // Verify index.ts exists
386
- const indexPath = join(extractDir, "index.ts");
387
- try {
388
- await access(indexPath);
389
- } catch {
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}/index.ts`);
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 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.
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 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
- );
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
- if (!isRemoved) {
356
- notify(ctx, "Extension may still be active. Restart pi manually if needed.", "warning");
357
- }
358
+ if (!isRemoved) {
359
+ notify(ctx, "Extension may still be active. Restart pi manually if needed.", "warning");
358
360
  }
359
361
  }
360
362
 
@@ -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
 
@@ -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
  */