pi-extmgr 0.1.22 → 0.1.24

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.
@@ -8,7 +8,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
9
  import { fileExists } from "../utils/fs.js";
10
10
  import { clearSearchCache, isSourceInstalled } from "./discovery.js";
11
- import { resolveManifestExtensionEntrypoints } from "./extensions.js";
11
+ import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
13
  import { logPackageInstall } from "../utils/history.js";
14
14
  import { clearUpdatesAvailable } from "../utils/settings.js";
@@ -16,6 +16,8 @@ import { notify, error as notifyError, success } from "../utils/notify.js";
16
16
  import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
17
17
  import { tryOperation } from "../utils/mode.js";
18
18
  import { updateExtmgrStatus } from "../utils/status.js";
19
+ import { execNpm } from "../utils/npm-exec.js";
20
+ import { normalizePackageIdentity } from "../utils/package-source.js";
19
21
  import { TIMEOUTS } from "../constants.js";
20
22
 
21
23
  export type InstallScope = "global" | "project";
@@ -71,20 +73,93 @@ function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo |
71
73
  return { owner, repo, branch, filePath };
72
74
  }
73
75
 
76
+ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
79
+
80
+ try {
81
+ return await fetch(url, { signal: controller.signal });
82
+ } catch (error) {
83
+ if (error instanceof Error && error.name === "AbortError") {
84
+ throw new Error(`Download timed out after ${Math.ceil(timeoutMs / 1000)}s`);
85
+ }
86
+ throw error;
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ async function ensureTarAvailable(
93
+ pi: ExtensionAPI,
94
+ ctx: ExtensionCommandContext
95
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
96
+ const result = await pi.exec("tar", ["--version"], {
97
+ timeout: 5_000,
98
+ cwd: ctx.cwd,
99
+ });
100
+
101
+ if (result.code === 0) {
102
+ return { ok: true };
103
+ }
104
+
105
+ return {
106
+ ok: false,
107
+ error:
108
+ "Standalone local installs require the `tar` command on PATH. Install tar or use managed package install instead.",
109
+ };
110
+ }
111
+
74
112
  async function hasStandaloneEntrypoint(packageRoot: string): Promise<boolean> {
75
- const declared = await resolveManifestExtensionEntrypoints(packageRoot);
76
- if (declared !== undefined) {
77
- for (const path of declared) {
78
- if (await fileExists(join(packageRoot, path))) {
79
- return true;
80
- }
113
+ const entrypoints = await discoverPackageExtensionEntrypoints(packageRoot, {
114
+ allowConventionDirectory: false,
115
+ });
116
+
117
+ for (const path of entrypoints) {
118
+ if (await fileExists(join(packageRoot, path))) {
119
+ return true;
120
+ }
121
+ }
122
+
123
+ return false;
124
+ }
125
+
126
+ async function getStandaloneDependencyError(packageRoot: string): Promise<string | undefined> {
127
+ const manifest = await readPackageManifest(packageRoot);
128
+ const dependencies = manifest?.dependencies;
129
+ if (!dependencies || typeof dependencies !== "object") {
130
+ return undefined;
131
+ }
132
+
133
+ const missingDependencies: string[] = [];
134
+ for (const dependencyName of Object.keys(dependencies)) {
135
+ const dependencyPath = join(packageRoot, "node_modules", dependencyName);
136
+ if (!(await fileExists(dependencyPath))) {
137
+ missingDependencies.push(dependencyName);
81
138
  }
82
- return false;
83
139
  }
84
140
 
85
- return (
86
- (await fileExists(join(packageRoot, "index.ts"))) ||
87
- (await fileExists(join(packageRoot, "index.js")))
141
+ if (missingDependencies.length === 0) {
142
+ return undefined;
143
+ }
144
+
145
+ const packageName = manifest?.name ?? "This package";
146
+ return `${packageName} declares runtime dependencies that are not bundled for standalone install: ${missingDependencies.join(", ")}. Use managed install instead, or bundle dependencies in the package tarball.`;
147
+ }
148
+
149
+ async function cleanupStandaloneTempArtifacts(tempDir: string, extractDir?: string): Promise<void> {
150
+ const paths = [extractDir, tempDir].filter((path): path is string => Boolean(path));
151
+
152
+ await Promise.allSettled(
153
+ paths.map(async (path) => {
154
+ try {
155
+ await rm(path, { recursive: true, force: true });
156
+ } catch (error) {
157
+ console.warn(
158
+ `[extmgr] Failed to remove temporary standalone install artifact at ${path}:`,
159
+ error
160
+ );
161
+ }
162
+ })
88
163
  );
89
164
  }
90
165
 
@@ -149,7 +224,7 @@ export async function installPackage(
149
224
  clearSearchCache();
150
225
  logPackageInstall(pi, normalized, normalized, undefined, scope, true);
151
226
  success(ctx, `Installed ${normalized} (${scope})`);
152
- clearUpdatesAvailable(pi, ctx);
227
+ clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized)]);
153
228
 
154
229
  // Wait for the extension to be discoverable before reloading.
155
230
  // This prevents a race condition where ctx.reload() runs before
@@ -207,7 +282,7 @@ export async function installFromUrl(
207
282
  await mkdir(extensionDir, { recursive: true });
208
283
  notify(ctx, `Downloading ${fileName}...`, "info");
209
284
 
210
- const response = await fetch(url);
285
+ const response = await fetchWithTimeout(url, TIMEOUTS.packageInstall);
211
286
  if (!response.ok) {
212
287
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
213
288
  }
@@ -230,7 +305,6 @@ export async function installFromUrl(
230
305
  const { fileName: name, destPath } = result;
231
306
  logPackageInstall(pi, url, name, undefined, scope, true);
232
307
  success(ctx, `Installed ${name} to:\n${destPath}`);
233
- clearUpdatesAvailable(pi, ctx);
234
308
 
235
309
  const reloaded = await confirmReload(ctx, "Extension installed.");
236
310
  if (!reloaded) {
@@ -291,9 +365,8 @@ export async function installPackageLocally(
291
365
  await mkdir(extensionDir, { recursive: true });
292
366
  showProgress(ctx, "Fetching", packageName);
293
367
 
294
- const viewRes = await pi.exec("npm", ["view", packageName, "--json"], {
368
+ const viewRes = await execNpm(pi, ["view", packageName, "--json"], ctx, {
295
369
  timeout: TIMEOUTS.fetchPackageInfo,
296
- cwd: ctx.cwd,
297
370
  });
298
371
 
299
372
  if (viewRes.code !== 0) {
@@ -325,17 +398,33 @@ export async function installPackageLocally(
325
398
  }
326
399
  const { version, tarballUrl } = result;
327
400
 
401
+ const tarAvailability = await ensureTarAvailable(pi, ctx);
402
+ if (!tarAvailability.ok) {
403
+ notifyError(ctx, tarAvailability.error);
404
+ logPackageInstall(
405
+ pi,
406
+ `npm:${packageName}`,
407
+ packageName,
408
+ version,
409
+ scope,
410
+ false,
411
+ tarAvailability.error
412
+ );
413
+ void updateExtmgrStatus(ctx, pi);
414
+ return;
415
+ }
416
+
328
417
  // Download and extract
418
+ const tempDir = join(extensionDir, ".temp");
329
419
  const extractResult = await tryOperation(
330
420
  ctx,
331
421
  async () => {
332
- const tempDir = join(extensionDir, ".temp");
333
422
  await mkdir(tempDir, { recursive: true });
334
423
  const tarballPath = join(tempDir, `${packageName.replace(/[@/]/g, "-")}-${version}.tgz`);
335
424
 
336
425
  showProgress(ctx, "Downloading", `${packageName}@${version}`);
337
426
 
338
- const response = await fetch(tarballUrl);
427
+ const response = await fetchWithTimeout(tarballUrl, TIMEOUTS.packageInstall);
339
428
  if (!response.ok) {
340
429
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
341
430
  }
@@ -343,12 +432,13 @@ export async function installPackageLocally(
343
432
  const buffer = await response.arrayBuffer();
344
433
  await writeFile(tarballPath, new Uint8Array(buffer));
345
434
 
346
- return { tarballPath, tempDir };
435
+ return { tarballPath };
347
436
  },
348
437
  "Download failed"
349
438
  );
350
439
 
351
440
  if (!extractResult) {
441
+ await cleanupStandaloneTempArtifacts(tempDir);
352
442
  logPackageInstall(
353
443
  pi,
354
444
  `npm:${packageName}`,
@@ -361,7 +451,7 @@ export async function installPackageLocally(
361
451
  void updateExtmgrStatus(ctx, pi);
362
452
  return;
363
453
  }
364
- const { tarballPath, tempDir } = extractResult;
454
+ const { tarballPath } = extractResult;
365
455
 
366
456
  // Extract
367
457
  const extractDir = join(
@@ -390,17 +480,22 @@ export async function installPackageLocally(
390
480
  const hasEntrypoint = await hasStandaloneEntrypoint(extractDir);
391
481
  if (!hasEntrypoint) {
392
482
  throw new Error(
393
- `Package ${packageName} does not contain a runnable extension entrypoint (pi.extensions, index.ts, or index.js)`
483
+ `Package ${packageName} does not contain a runnable standalone extension entrypoint (manifest-declared entrypoint, index.ts, or index.js)`
394
484
  );
395
485
  }
396
486
 
487
+ const dependencyError = await getStandaloneDependencyError(extractDir);
488
+ if (dependencyError) {
489
+ throw new Error(dependencyError);
490
+ }
491
+
397
492
  return true;
398
493
  },
399
494
  "Extraction failed"
400
495
  );
401
496
 
402
497
  if (!extractSuccess) {
403
- await rm(extractDir, { recursive: true, force: true });
498
+ await cleanupStandaloneTempArtifacts(tempDir, extractDir);
404
499
  logPackageInstall(
405
500
  pi,
406
501
  `npm:${packageName}`,
@@ -429,7 +524,7 @@ export async function installPackageLocally(
429
524
  "Failed to copy extension"
430
525
  );
431
526
 
432
- await rm(extractDir, { recursive: true, force: true });
527
+ await cleanupStandaloneTempArtifacts(tempDir, extractDir);
433
528
 
434
529
  if (!destResult) {
435
530
  logPackageInstall(
@@ -448,7 +543,6 @@ export async function installPackageLocally(
448
543
  clearSearchCache();
449
544
  logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
450
545
  success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}`);
451
- clearUpdatesAvailable(pi, ctx);
452
546
 
453
547
  const reloaded = await confirmReload(ctx, "Extension installed.");
454
548
  if (!reloaded) {
@@ -10,8 +10,8 @@ import {
10
10
  isSourceInstalled,
11
11
  } from "./discovery.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
- import { formatInstalledPackageLabel, parseNpmSource } from "../utils/format.js";
14
- import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
13
+ import { formatInstalledPackageLabel } from "../utils/format.js";
14
+ import { normalizePackageIdentity } from "../utils/package-source.js";
15
15
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
16
16
  import { clearUpdatesAvailable } from "../utils/settings.js";
17
17
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -33,12 +33,19 @@ const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
33
33
  reloaded: false,
34
34
  };
35
35
 
36
+ const BULK_UPDATE_LABEL = "all packages";
37
+
36
38
  function packageMutationOutcome(
37
39
  overrides: Partial<PackageMutationOutcome>
38
40
  ): PackageMutationOutcome {
39
41
  return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides };
40
42
  }
41
43
 
44
+ function isUpToDateOutput(stdout: string): boolean {
45
+ const pinnedAsStatus = /^\s*pinned\b(?!\s+dependency\b)(?:\s*$|\s*[:(-])/im.test(stdout);
46
+ return /already\s+up\s+to\s+date/i.test(stdout) || pinnedAsStatus;
47
+ }
48
+
42
49
  async function updatePackageInternal(
43
50
  source: string,
44
51
  ctx: ExtensionCommandContext,
@@ -59,17 +66,19 @@ async function updatePackageInternal(
59
66
  return NO_PACKAGE_MUTATION_OUTCOME;
60
67
  }
61
68
 
69
+ const updateIdentity = normalizePackageIdentity(source);
62
70
  const stdout = res.stdout || "";
63
- if (stdout.includes("already up to date") || stdout.includes("pinned")) {
71
+ if (isUpToDateOutput(stdout)) {
64
72
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
65
73
  logPackageUpdate(pi, source, source, undefined, true);
74
+ clearUpdatesAvailable(pi, ctx, [updateIdentity]);
66
75
  void updateExtmgrStatus(ctx, pi);
67
76
  return NO_PACKAGE_MUTATION_OUTCOME;
68
77
  }
69
78
 
70
79
  logPackageUpdate(pi, source, source, undefined, true);
71
80
  success(ctx, `Updated ${source}`);
72
- clearUpdatesAvailable(pi, ctx);
81
+ clearUpdatesAvailable(pi, ctx, [updateIdentity]);
73
82
 
74
83
  const reloaded = await confirmReload(ctx, "Package updated.");
75
84
  if (!reloaded) {
@@ -87,18 +96,23 @@ async function updatePackagesInternal(
87
96
  const res = await pi.exec("pi", ["update"], { timeout: TIMEOUTS.packageUpdateAll, cwd: ctx.cwd });
88
97
 
89
98
  if (res.code !== 0) {
90
- notifyError(ctx, `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
99
+ const errorMsg = `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`;
100
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, false, errorMsg);
101
+ notifyError(ctx, errorMsg);
91
102
  void updateExtmgrStatus(ctx, pi);
92
103
  return NO_PACKAGE_MUTATION_OUTCOME;
93
104
  }
94
105
 
95
106
  const stdout = res.stdout || "";
96
- if (stdout.includes("already up to date") || stdout.trim() === "") {
107
+ if (isUpToDateOutput(stdout) || stdout.trim() === "") {
97
108
  notify(ctx, "All packages are already up to date.", "info");
109
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
110
+ clearUpdatesAvailable(pi, ctx);
98
111
  void updateExtmgrStatus(ctx, pi);
99
112
  return NO_PACKAGE_MUTATION_OUTCOME;
100
113
  }
101
114
 
115
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
102
116
  success(ctx, "Packages updated");
103
117
  clearUpdatesAvailable(pi, ctx);
104
118
 
@@ -139,23 +153,8 @@ export async function updatePackagesWithOutcome(
139
153
  return updatePackagesInternal(ctx, pi);
140
154
  }
141
155
 
142
- function packageIdentity(source: string, fallbackName?: string): string {
143
- const npm = parseNpmSource(source);
144
- if (npm?.name) {
145
- return `npm:${npm.name}`;
146
- }
147
-
148
- if (getPackageSourceKind(source) === "git") {
149
- const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
150
- const { repo } = splitGitRepoAndRef(gitSpec);
151
- return `git:${repo}`;
152
- }
153
-
154
- if (fallbackName) {
155
- return `name:${fallbackName}`;
156
- }
157
-
158
- return `src:${source}`;
156
+ function packageIdentity(source: string): string {
157
+ return normalizePackageIdentity(source);
159
158
  }
160
159
 
161
160
  async function getInstalledPackagesAllScopes(
@@ -238,12 +237,18 @@ function formatRemovalTargets(targets: RemovalTarget[]): string {
238
237
  return targets.map((t) => `${t.scope}: ${t.source}`).join("\n");
239
238
  }
240
239
 
240
+ interface RemovalExecutionResult {
241
+ target: RemovalTarget;
242
+ success: boolean;
243
+ error?: string;
244
+ }
245
+
241
246
  async function executeRemovalTargets(
242
247
  targets: RemovalTarget[],
243
248
  ctx: ExtensionCommandContext,
244
249
  pi: ExtensionAPI
245
- ): Promise<string[]> {
246
- const failures: string[] = [];
250
+ ): Promise<RemovalExecutionResult[]> {
251
+ const results: RemovalExecutionResult[] = [];
247
252
 
248
253
  for (const target of targets) {
249
254
  showProgress(ctx, "Removing", `${target.source} (${target.scope})`);
@@ -254,14 +259,15 @@ async function executeRemovalTargets(
254
259
  if (res.code !== 0) {
255
260
  const errorMsg = `Remove failed (${target.scope}): ${res.stderr || res.stdout || `exit ${res.code}`}`;
256
261
  logPackageRemove(pi, target.source, target.name, false, errorMsg);
257
- failures.push(errorMsg);
262
+ results.push({ target, success: false, error: errorMsg });
258
263
  continue;
259
264
  }
260
265
 
261
266
  logPackageRemove(pi, target.source, target.name, true);
267
+ results.push({ target, success: true });
262
268
  }
263
269
 
264
- return failures;
270
+ return results;
265
271
  }
266
272
 
267
273
  function notifyRemovalSummary(
@@ -295,9 +301,8 @@ async function removePackageInternal(
295
301
  pi: ExtensionAPI
296
302
  ): Promise<PackageMutationOutcome> {
297
303
  const installed = await getInstalledPackagesAllScopes(ctx, pi);
298
- const direct = installed.find((p) => p.source === source);
299
- const identity = packageIdentity(source, direct?.name);
300
- const matching = installed.filter((p) => packageIdentity(p.source, p.name) === identity);
304
+ const identity = packageIdentity(source);
305
+ const matching = installed.filter((p) => packageIdentity(p.source) === identity);
301
306
 
302
307
  const hasBothScopes =
303
308
  matching.some((pkg) => pkg.scope === "global") &&
@@ -326,25 +331,36 @@ async function removePackageInternal(
326
331
  return NO_PACKAGE_MUTATION_OUTCOME;
327
332
  }
328
333
 
329
- const failures = await executeRemovalTargets(targets, ctx, pi);
334
+ const results = await executeRemovalTargets(targets, ctx, pi);
330
335
  clearSearchCache();
331
336
 
337
+ const failures = results
338
+ .filter((result): result is RemovalExecutionResult & { success: false; error: string } =>
339
+ Boolean(!result.success && result.error)
340
+ )
341
+ .map((result) => result.error);
342
+ const successfulTargets = results
343
+ .filter((result) => result.success)
344
+ .map((result) => result.target);
345
+
332
346
  const remaining = (await getInstalledPackagesAllScopes(ctx, pi)).filter(
333
- (p) => packageIdentity(p.source, p.name) === identity
347
+ (p) => packageIdentity(p.source) === identity
334
348
  );
335
349
  notifyRemovalSummary(source, remaining, failures, ctx);
336
350
 
337
- if (failures.length === 0) {
338
- clearUpdatesAvailable(pi, ctx);
351
+ if (failures.length === 0 && remaining.length === 0) {
352
+ clearUpdatesAvailable(pi, ctx, [identity]);
339
353
  }
340
354
 
341
- // Wait for selected targets to disappear from their target scopes before reloading.
342
- if (failures.length === 0 && targets.length > 0) {
355
+ const successfulRemovalCount = successfulTargets.length;
356
+
357
+ // Wait for successfully removed targets to disappear from their target scopes before reloading.
358
+ if (successfulTargets.length > 0) {
343
359
  notify(ctx, "Waiting for removal to complete...", "info");
344
360
  const isRemoved = await waitForCondition(
345
361
  async () => {
346
362
  const installedChecks = await Promise.all(
347
- targets.map((target) =>
363
+ successfulTargets.map((target) =>
348
364
  isSourceInstalled(target.source, ctx, pi, {
349
365
  scope: target.scope,
350
366
  })
@@ -360,6 +376,11 @@ async function removePackageInternal(
360
376
  }
361
377
  }
362
378
 
379
+ if (successfulRemovalCount === 0) {
380
+ void updateExtmgrStatus(ctx, pi);
381
+ return NO_PACKAGE_MUTATION_OUTCOME;
382
+ }
383
+
363
384
  const reloaded = await confirmReload(ctx, "Removal complete.");
364
385
  if (!reloaded) {
365
386
  void updateExtmgrStatus(ctx, pi);