pi-extmgr 0.1.21 → 0.1.23

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.
@@ -10,8 +10,12 @@ import {
10
10
  isSourceInstalled,
11
11
  } from "./discovery.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
- import { formatInstalledPackageLabel, formatBytes, parseNpmSource } from "../utils/format.js";
14
- import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
13
+ import { formatInstalledPackageLabel, parseNpmSource } from "../utils/format.js";
14
+ import {
15
+ getPackageSourceKind,
16
+ normalizeLocalSourceIdentity,
17
+ splitGitRepoAndRef,
18
+ } from "../utils/package-source.js";
15
19
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
16
20
  import { clearUpdatesAvailable } from "../utils/settings.js";
17
21
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -33,12 +37,19 @@ const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
33
37
  reloaded: false,
34
38
  };
35
39
 
40
+ const BULK_UPDATE_LABEL = "all packages";
41
+
36
42
  function packageMutationOutcome(
37
43
  overrides: Partial<PackageMutationOutcome>
38
44
  ): PackageMutationOutcome {
39
45
  return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides };
40
46
  }
41
47
 
48
+ function isUpToDateOutput(stdout: string): boolean {
49
+ const pinnedAsStatus = /^\s*pinned\b(?!\s+dependency\b)(?:\s*$|\s*[:(-])/im.test(stdout);
50
+ return /already\s+up\s+to\s+date/i.test(stdout) || pinnedAsStatus;
51
+ }
52
+
42
53
  async function updatePackageInternal(
43
54
  source: string,
44
55
  ctx: ExtensionCommandContext,
@@ -60,9 +71,10 @@ async function updatePackageInternal(
60
71
  }
61
72
 
62
73
  const stdout = res.stdout || "";
63
- if (stdout.includes("already up to date") || stdout.includes("pinned")) {
74
+ if (isUpToDateOutput(stdout)) {
64
75
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
65
76
  logPackageUpdate(pi, source, source, undefined, true);
77
+ clearUpdatesAvailable(pi, ctx);
66
78
  void updateExtmgrStatus(ctx, pi);
67
79
  return NO_PACKAGE_MUTATION_OUTCOME;
68
80
  }
@@ -87,18 +99,23 @@ async function updatePackagesInternal(
87
99
  const res = await pi.exec("pi", ["update"], { timeout: TIMEOUTS.packageUpdateAll, cwd: ctx.cwd });
88
100
 
89
101
  if (res.code !== 0) {
90
- notifyError(ctx, `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
102
+ const errorMsg = `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`;
103
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, false, errorMsg);
104
+ notifyError(ctx, errorMsg);
91
105
  void updateExtmgrStatus(ctx, pi);
92
106
  return NO_PACKAGE_MUTATION_OUTCOME;
93
107
  }
94
108
 
95
109
  const stdout = res.stdout || "";
96
- if (stdout.includes("already up to date") || stdout.trim() === "") {
110
+ if (isUpToDateOutput(stdout) || stdout.trim() === "") {
97
111
  notify(ctx, "All packages are already up to date.", "info");
112
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
113
+ clearUpdatesAvailable(pi, ctx);
98
114
  void updateExtmgrStatus(ctx, pi);
99
115
  return NO_PACKAGE_MUTATION_OUTCOME;
100
116
  }
101
117
 
118
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
102
119
  success(ctx, "Packages updated");
103
120
  clearUpdatesAvailable(pi, ctx);
104
121
 
@@ -145,12 +162,18 @@ function packageIdentity(source: string, fallbackName?: string): string {
145
162
  return `npm:${npm.name}`;
146
163
  }
147
164
 
148
- if (getPackageSourceKind(source) === "git") {
165
+ const sourceKind = getPackageSourceKind(source);
166
+
167
+ if (sourceKind === "git") {
149
168
  const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
150
169
  const { repo } = splitGitRepoAndRef(gitSpec);
151
170
  return `git:${repo}`;
152
171
  }
153
172
 
173
+ if (sourceKind === "local") {
174
+ return `src:${normalizeLocalSourceIdentity(source)}`;
175
+ }
176
+
154
177
  if (fallbackName) {
155
178
  return `name:${fallbackName}`;
156
179
  }
@@ -238,12 +261,18 @@ function formatRemovalTargets(targets: RemovalTarget[]): string {
238
261
  return targets.map((t) => `${t.scope}: ${t.source}`).join("\n");
239
262
  }
240
263
 
264
+ interface RemovalExecutionResult {
265
+ target: RemovalTarget;
266
+ success: boolean;
267
+ error?: string;
268
+ }
269
+
241
270
  async function executeRemovalTargets(
242
271
  targets: RemovalTarget[],
243
272
  ctx: ExtensionCommandContext,
244
273
  pi: ExtensionAPI
245
- ): Promise<string[]> {
246
- const failures: string[] = [];
274
+ ): Promise<RemovalExecutionResult[]> {
275
+ const results: RemovalExecutionResult[] = [];
247
276
 
248
277
  for (const target of targets) {
249
278
  showProgress(ctx, "Removing", `${target.source} (${target.scope})`);
@@ -254,14 +283,15 @@ async function executeRemovalTargets(
254
283
  if (res.code !== 0) {
255
284
  const errorMsg = `Remove failed (${target.scope}): ${res.stderr || res.stdout || `exit ${res.code}`}`;
256
285
  logPackageRemove(pi, target.source, target.name, false, errorMsg);
257
- failures.push(errorMsg);
286
+ results.push({ target, success: false, error: errorMsg });
258
287
  continue;
259
288
  }
260
289
 
261
290
  logPackageRemove(pi, target.source, target.name, true);
291
+ results.push({ target, success: true });
262
292
  }
263
293
 
264
- return failures;
294
+ return results;
265
295
  }
266
296
 
267
297
  function notifyRemovalSummary(
@@ -326,9 +356,18 @@ async function removePackageInternal(
326
356
  return NO_PACKAGE_MUTATION_OUTCOME;
327
357
  }
328
358
 
329
- const failures = await executeRemovalTargets(targets, ctx, pi);
359
+ const results = await executeRemovalTargets(targets, ctx, pi);
330
360
  clearSearchCache();
331
361
 
362
+ const failures = results
363
+ .filter((result): result is RemovalExecutionResult & { success: false; error: string } =>
364
+ Boolean(!result.success && result.error)
365
+ )
366
+ .map((result) => result.error);
367
+ const successfulTargets = results
368
+ .filter((result) => result.success)
369
+ .map((result) => result.target);
370
+
332
371
  const remaining = (await getInstalledPackagesAllScopes(ctx, pi)).filter(
333
372
  (p) => packageIdentity(p.source, p.name) === identity
334
373
  );
@@ -338,13 +377,15 @@ async function removePackageInternal(
338
377
  clearUpdatesAvailable(pi, ctx);
339
378
  }
340
379
 
341
- // Wait for selected targets to disappear from their target scopes before reloading.
342
- if (failures.length === 0 && targets.length > 0) {
380
+ const successfulRemovalCount = successfulTargets.length;
381
+
382
+ // Wait for successfully removed targets to disappear from their target scopes before reloading.
383
+ if (successfulTargets.length > 0) {
343
384
  notify(ctx, "Waiting for removal to complete...", "info");
344
385
  const isRemoved = await waitForCondition(
345
386
  async () => {
346
387
  const installedChecks = await Promise.all(
347
- targets.map((target) =>
388
+ successfulTargets.map((target) =>
348
389
  isSourceInstalled(target.source, ctx, pi, {
349
390
  scope: target.scope,
350
391
  })
@@ -360,6 +401,11 @@ async function removePackageInternal(
360
401
  }
361
402
  }
362
403
 
404
+ if (successfulRemovalCount === 0) {
405
+ void updateExtmgrStatus(ctx, pi);
406
+ return NO_PACKAGE_MUTATION_OUTCOME;
407
+ }
408
+
363
409
  const reloaded = await confirmReload(ctx, "Removal complete.");
364
410
  if (!reloaded) {
365
411
  void updateExtmgrStatus(ctx, pi);
@@ -408,62 +454,6 @@ export async function promptRemove(ctx: ExtensionCommandContext, pi: ExtensionAP
408
454
  }
409
455
  }
410
456
 
411
- export async function showPackageActions(
412
- pkg: InstalledPackage,
413
- ctx: ExtensionCommandContext,
414
- pi: ExtensionAPI
415
- ): Promise<boolean> {
416
- if (!requireUI(ctx, "Package actions")) {
417
- console.log(`Package: ${pkg.name}`);
418
- console.log(`Version: ${pkg.version || "unknown"}`);
419
- console.log(`Source: ${pkg.source}`);
420
- console.log(`Scope: ${pkg.scope}`);
421
- return true;
422
- }
423
-
424
- const choice = await ctx.ui.select(pkg.name, [
425
- `Remove ${pkg.name}`,
426
- `Update ${pkg.name}`,
427
- "View details",
428
- "Back to manager",
429
- ]);
430
-
431
- if (!choice || choice.includes("Back")) {
432
- return false;
433
- }
434
-
435
- const action = choice.startsWith("Remove")
436
- ? "remove"
437
- : choice.startsWith("Update")
438
- ? "update"
439
- : choice.includes("details")
440
- ? "details"
441
- : "back";
442
-
443
- switch (action) {
444
- case "remove": {
445
- const outcome = await removePackageWithOutcome(pkg.source, ctx, pi);
446
- return outcome.reloaded;
447
- }
448
- case "update": {
449
- const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
450
- return outcome.reloaded;
451
- }
452
- case "details": {
453
- const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : "";
454
- notify(
455
- ctx,
456
- `Name: ${pkg.name}\nVersion: ${pkg.version || "unknown"}\nSource: ${pkg.source}\nScope: ${pkg.scope}${sizeStr}`,
457
- "info"
458
- );
459
- return showPackageActions(pkg, ctx, pi);
460
- }
461
- case "back":
462
- default:
463
- return false;
464
- }
465
- }
466
-
467
457
  export async function showInstalledPackagesList(
468
458
  ctx: ExtensionCommandContext,
469
459
  pi: ExtensionAPI
@@ -47,11 +47,11 @@ export interface PackageExtensionEntry {
47
47
  }
48
48
 
49
49
  export interface UnifiedItem {
50
- type: "local" | "package" | "package-extension";
50
+ type: "local" | "package";
51
51
  id: string;
52
52
  displayName: string;
53
53
  summary: string;
54
- scope: Scope | "global" | "project";
54
+ scope: Scope;
55
55
  // Local extension fields
56
56
  state?: State | undefined;
57
57
  activePath?: string | undefined;
@@ -63,9 +63,6 @@ export interface UnifiedItem {
63
63
  description?: string | undefined;
64
64
  size?: number | undefined; // Package size in bytes
65
65
  updateAvailable?: boolean | undefined;
66
- // Package extension fields
67
- packageSource?: string | undefined;
68
- extensionPath?: string | undefined;
69
66
  }
70
67
 
71
68
  export interface SearchCache {
@@ -82,7 +79,11 @@ export type UnifiedAction =
82
79
  | { type: "help" }
83
80
  | { type: "menu" }
84
81
  | { type: "quick"; action: "install" | "search" | "update-all" | "auto-update" }
85
- | { type: "action"; itemId: string; action?: "menu" | "update" | "remove" | "details" };
82
+ | {
83
+ type: "action";
84
+ itemId: string;
85
+ action?: "menu" | "update" | "remove" | "details" | "configure";
86
+ };
86
87
 
87
88
  export type BrowseAction =
88
89
  | { type: "package"; name: string }
package/src/ui/footer.ts CHANGED
@@ -14,10 +14,9 @@ export interface FooterState {
14
14
  */
15
15
  export function buildFooterState(items: UnifiedItem[]): FooterState {
16
16
  const hasLocals = items.some((i) => i.type === "local");
17
- const hasPackageExtensions = items.some((i) => i.type === "package-extension");
18
17
 
19
18
  return {
20
- hasToggleRows: hasLocals || hasPackageExtensions,
19
+ hasToggleRows: hasLocals,
21
20
  hasLocals,
22
21
  hasPackages: items.some((i) => i.type === "package"),
23
22
  };
@@ -33,10 +32,7 @@ export function getPendingToggleChangeCount(
33
32
  const item = byId.get(id);
34
33
  if (!item) continue;
35
34
 
36
- if (
37
- (item.type === "local" || item.type === "package-extension") &&
38
- item.originalState !== state
39
- ) {
35
+ if (item.type === "local" && item.originalState !== state) {
40
36
  count += 1;
41
37
  }
42
38
  }
@@ -54,6 +50,7 @@ export function buildFooterShortcuts(state: FooterState): string {
54
50
  if (state.hasToggleRows) parts.push("Space/Enter Toggle");
55
51
  if (state.hasToggleRows) parts.push("S Save");
56
52
  if (state.hasPackages) parts.push("Enter/A Actions");
53
+ if (state.hasPackages) parts.push("c Configure");
57
54
  if (state.hasPackages) parts.push("u Update");
58
55
  if (state.hasPackages || state.hasLocals) parts.push("X Remove");
59
56
 
package/src/ui/help.ts CHANGED
@@ -17,6 +17,7 @@ export function showHelp(ctx: ExtensionCommandContext): void {
17
17
  " Space/Enter Toggle local extension enabled/disabled",
18
18
  " S Save changes to local extensions",
19
19
  " Enter/A Open actions for selected package",
20
+ " c Configure selected package extensions (restart required after save)",
20
21
  " u Update selected package",
21
22
  " X Remove selected item (package or local extension)",
22
23
  " i Quick install by source",
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Package extension configuration panel.
3
+ */
4
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
5
+ import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
6
+ import {
7
+ Container,
8
+ Key,
9
+ matchesKey,
10
+ SettingsList,
11
+ Spacer,
12
+ Text,
13
+ type SettingItem,
14
+ } from "@mariozechner/pi-tui";
15
+ import type { InstalledPackage, PackageExtensionEntry, State } from "../types/index.js";
16
+ import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
17
+ import { notify } from "../utils/notify.js";
18
+ import { logExtensionToggle } from "../utils/history.js";
19
+ import { getPackageSourceKind } from "../utils/package-source.js";
20
+ import { fileExists } from "../utils/fs.js";
21
+ import { UI } from "../constants.js";
22
+ import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
23
+
24
+ interface SelectableList {
25
+ selectedIndex?: number;
26
+ handleInput?(data: string): void;
27
+ }
28
+
29
+ export interface PackageConfigRow {
30
+ id: string;
31
+ extensionPath: string;
32
+ summary: string;
33
+ originalState: State;
34
+ available: boolean;
35
+ }
36
+
37
+ type ConfigurePanelAction = { type: "cancel" } | { type: "save" };
38
+
39
+ function getSelectedIndex(settingsList: unknown): number | undefined {
40
+ if (settingsList && typeof settingsList === "object") {
41
+ const selectable = settingsList as SelectableList;
42
+ if (typeof selectable.selectedIndex === "number") {
43
+ return selectable.selectedIndex;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export async function buildPackageConfigRows(
50
+ entries: PackageExtensionEntry[]
51
+ ): Promise<PackageConfigRow[]> {
52
+ const dedupedEntries = new Map<string, PackageExtensionEntry>();
53
+ for (const entry of entries) {
54
+ if (!dedupedEntries.has(entry.extensionPath)) {
55
+ dedupedEntries.set(entry.extensionPath, entry);
56
+ }
57
+ }
58
+
59
+ const rows = await Promise.all(
60
+ Array.from(dedupedEntries.values()).map(async (entry) => ({
61
+ id: entry.id,
62
+ extensionPath: entry.extensionPath,
63
+ summary: entry.summary,
64
+ originalState: entry.state,
65
+ available: await fileExists(entry.absolutePath),
66
+ }))
67
+ );
68
+
69
+ rows.sort((a, b) => a.extensionPath.localeCompare(b.extensionPath));
70
+ return rows;
71
+ }
72
+
73
+ function formatConfigRowLabel(
74
+ row: PackageConfigRow,
75
+ state: State,
76
+ pkg: InstalledPackage,
77
+ theme: Theme,
78
+ changed: boolean
79
+ ): string {
80
+ const statusIcon = getStatusIcon(theme, state);
81
+ const scopeIcon = getScopeIcon(theme, pkg.scope);
82
+ const sourceKind = getPackageSourceKind(pkg.source);
83
+ const pkgIcon = getPackageIcon(
84
+ theme,
85
+ sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
86
+ );
87
+ const changeMarker = getChangeMarker(theme, changed);
88
+ const name = theme.bold(row.extensionPath);
89
+ const availability = row.available
90
+ ? ""
91
+ : ` ${theme.fg("warning", "[missing]")}${theme.fg("dim", " (cannot toggle)")}`;
92
+ const summary = theme.fg("dim", row.summary);
93
+
94
+ return `${statusIcon} ${pkgIcon} [${scopeIcon}] ${name}${availability} - ${summary}${changeMarker}`;
95
+ }
96
+
97
+ function buildSettingItems(
98
+ rows: PackageConfigRow[],
99
+ staged: Map<string, State>,
100
+ pkg: InstalledPackage,
101
+ theme: Theme
102
+ ): SettingItem[] {
103
+ return rows.map((row) => {
104
+ const current = staged.get(row.id) ?? row.originalState;
105
+ const changed = current !== row.originalState;
106
+
107
+ return {
108
+ id: row.id,
109
+ label: formatConfigRowLabel(row, current, pkg, theme, changed),
110
+ currentValue: current,
111
+ values: row.available ? ["enabled", "disabled"] : [current],
112
+ };
113
+ });
114
+ }
115
+
116
+ function getPendingChangeCount(rows: PackageConfigRow[], staged: Map<string, State>): number {
117
+ let count = 0;
118
+
119
+ for (const row of rows) {
120
+ const target = staged.get(row.id);
121
+ if (!target) continue;
122
+ if (target !== row.originalState) count += 1;
123
+ }
124
+
125
+ return count;
126
+ }
127
+
128
+ async function showConfigurePanel(
129
+ pkg: InstalledPackage,
130
+ rows: PackageConfigRow[],
131
+ staged: Map<string, State>,
132
+ ctx: ExtensionCommandContext
133
+ ): Promise<ConfigurePanelAction> {
134
+ return ctx.ui.custom<ConfigurePanelAction>((tui, theme, _keybindings, done) => {
135
+ const container = new Container();
136
+
137
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
138
+ container.addChild(
139
+ new Text(theme.fg("accent", theme.bold(`Configure extensions: ${pkg.name}`)), 2, 0)
140
+ );
141
+ container.addChild(
142
+ new Text(
143
+ theme.fg(
144
+ "muted",
145
+ `${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space/Enter toggle • S save • Esc cancel`
146
+ ),
147
+ 2,
148
+ 0
149
+ )
150
+ );
151
+ container.addChild(new Spacer(1));
152
+
153
+ const settingsItems = buildSettingItems(rows, staged, pkg, theme);
154
+ const rowById = new Map(rows.map((row) => [row.id, row]));
155
+
156
+ const settingsList = new SettingsList(
157
+ settingsItems,
158
+ Math.min(rows.length + 2, UI.maxListHeight),
159
+ getSettingsListTheme(),
160
+ (id: string, newValue: string) => {
161
+ const row = rowById.get(id);
162
+ if (!row || !row.available) return;
163
+
164
+ const state = newValue as State;
165
+ staged.set(id, state);
166
+
167
+ const settingsItem = settingsItems.find((item) => item.id === id);
168
+ if (settingsItem) {
169
+ settingsItem.label = formatConfigRowLabel(
170
+ row,
171
+ state,
172
+ pkg,
173
+ theme,
174
+ state !== row.originalState
175
+ );
176
+ }
177
+
178
+ tui.requestRender();
179
+ },
180
+ () => done({ type: "cancel" }),
181
+ { enableSearch: rows.length > UI.searchThreshold }
182
+ );
183
+
184
+ container.addChild(settingsList);
185
+ container.addChild(new Spacer(1));
186
+ container.addChild(
187
+ new Text(theme.fg("dim", "↑↓ Navigate | Space/Enter Toggle | S Save | Esc Back"), 2, 0)
188
+ );
189
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
190
+
191
+ return {
192
+ render(width: number) {
193
+ return container.render(width);
194
+ },
195
+ invalidate() {
196
+ container.invalidate();
197
+ },
198
+ handleInput(data: string) {
199
+ if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
200
+ done({ type: "save" });
201
+ return;
202
+ }
203
+
204
+ const selectedIndex = getSelectedIndex(settingsList) ?? 0;
205
+ const selectedId = settingsItems[selectedIndex]?.id ?? settingsItems[0]?.id;
206
+ const selectedRow = selectedId ? rowById.get(selectedId) : undefined;
207
+
208
+ if (
209
+ selectedRow &&
210
+ !selectedRow.available &&
211
+ (data === " " || data === "\r" || data === "\n")
212
+ ) {
213
+ notify(
214
+ ctx,
215
+ `${selectedRow.extensionPath} is missing on disk and cannot be toggled.`,
216
+ "warning"
217
+ );
218
+ return;
219
+ }
220
+
221
+ settingsList.handleInput?.(data);
222
+ tui.requestRender();
223
+ },
224
+ };
225
+ });
226
+ }
227
+
228
+ export async function applyPackageExtensionChanges(
229
+ rows: PackageConfigRow[],
230
+ staged: Map<string, State>,
231
+ pkg: InstalledPackage,
232
+ cwd: string,
233
+ pi: ExtensionAPI
234
+ ): Promise<{ changed: number; errors: string[] }> {
235
+ let changed = 0;
236
+ const errors: string[] = [];
237
+
238
+ const sortedRows = [...rows].sort((a, b) => a.extensionPath.localeCompare(b.extensionPath));
239
+
240
+ for (const row of sortedRows) {
241
+ const target = staged.get(row.id) ?? row.originalState;
242
+ if (target === row.originalState) continue;
243
+
244
+ if (!row.available) {
245
+ const error = `${row.extensionPath}: extension entrypoint is missing on disk`;
246
+ errors.push(error);
247
+ logExtensionToggle(pi, row.id, row.originalState, target, false, error);
248
+ continue;
249
+ }
250
+
251
+ const result = await setPackageExtensionState(
252
+ pkg.source,
253
+ row.extensionPath,
254
+ pkg.scope,
255
+ target,
256
+ cwd
257
+ );
258
+
259
+ if (result.ok) {
260
+ changed += 1;
261
+ logExtensionToggle(pi, row.id, row.originalState, target, true);
262
+ } else {
263
+ errors.push(`${row.extensionPath}: ${result.error}`);
264
+ logExtensionToggle(pi, row.id, row.originalState, target, false, result.error);
265
+ }
266
+ }
267
+
268
+ return { changed, errors };
269
+ }
270
+
271
+ async function promptRestartForPackageConfig(ctx: ExtensionCommandContext): Promise<boolean> {
272
+ if (!ctx.hasUI) {
273
+ notify(
274
+ ctx,
275
+ "Restart pi to apply package extension configuration changes. /reload may not be enough.",
276
+ "warning"
277
+ );
278
+ return false;
279
+ }
280
+
281
+ const restartNow = await ctx.ui.confirm(
282
+ "Restart Required",
283
+ "Package extension configuration changed.\nA full pi restart is required to apply it.\nExit pi now?"
284
+ );
285
+
286
+ if (!restartNow) {
287
+ notify(
288
+ ctx,
289
+ "Restart pi manually to apply package extension configuration changes. /reload may not be enough.",
290
+ "warning"
291
+ );
292
+ return false;
293
+ }
294
+
295
+ notify(ctx, "Shutting down pi. Start it again to apply changes.", "info");
296
+ ctx.shutdown();
297
+ return true;
298
+ }
299
+
300
+ export async function configurePackageExtensions(
301
+ pkg: InstalledPackage,
302
+ ctx: ExtensionCommandContext,
303
+ pi: ExtensionAPI
304
+ ): Promise<{ changed: number; reloaded: boolean }> {
305
+ const discovered = await discoverPackageExtensions([pkg], ctx.cwd);
306
+ const rows = await buildPackageConfigRows(discovered);
307
+
308
+ if (rows.length === 0) {
309
+ notify(ctx, "No configurable extensions discovered for this package.", "info");
310
+ return { changed: 0, reloaded: false };
311
+ }
312
+
313
+ const staged = new Map<string, State>();
314
+
315
+ while (true) {
316
+ const action = await showConfigurePanel(pkg, rows, staged, ctx);
317
+
318
+ if (action.type === "cancel") {
319
+ const pending = getPendingChangeCount(rows, staged);
320
+ if (pending === 0) {
321
+ return { changed: 0, reloaded: false };
322
+ }
323
+
324
+ const choice = await ctx.ui.select(`Unsaved changes (${pending})`, [
325
+ "Save and back",
326
+ "Discard changes",
327
+ "Stay in configure",
328
+ ]);
329
+
330
+ if (!choice || choice === "Stay in configure") {
331
+ continue;
332
+ }
333
+
334
+ if (choice === "Discard changes") {
335
+ return { changed: 0, reloaded: false };
336
+ }
337
+ }
338
+
339
+ const apply = await applyPackageExtensionChanges(rows, staged, pkg, ctx.cwd, pi);
340
+
341
+ if (apply.errors.length > 0) {
342
+ notify(
343
+ ctx,
344
+ `Applied ${apply.changed} change(s), ${apply.errors.length} failed.\n${apply.errors.join("\n")}`,
345
+ "warning"
346
+ );
347
+ } else if (apply.changed === 0) {
348
+ notify(ctx, "No changes to apply.", "info");
349
+ return { changed: 0, reloaded: false };
350
+ } else {
351
+ notify(ctx, `Applied ${apply.changed} package extension change(s).`, "info");
352
+ }
353
+
354
+ if (apply.changed === 0) {
355
+ return { changed: 0, reloaded: false };
356
+ }
357
+
358
+ const restarted = await promptRestartForPackageConfig(ctx);
359
+ return { changed: apply.changed, reloaded: restarted };
360
+ }
361
+ }
package/src/ui/remote.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  isCacheValid,
16
16
  } from "../packages/discovery.js";
17
17
  import { installPackage, installPackageLocally } from "../packages/install.js";
18
+ import { execNpm } from "../utils/npm-exec.js";
18
19
  import { notify } from "../utils/notify.js";
19
20
 
20
21
  interface PackageInfoCacheEntry {
@@ -143,9 +144,8 @@ async function buildPackageInfoText(
143
144
  }
144
145
 
145
146
  const [infoRes, weeklyDownloads] = await Promise.all([
146
- pi.exec("npm", ["view", packageName, "--json"], {
147
+ execNpm(pi, ["view", packageName, "--json"], ctx, {
147
148
  timeout: TIMEOUTS.npmView,
148
- cwd: ctx.cwd,
149
149
  }),
150
150
  fetchWeeklyDownloads(packageName),
151
151
  ]);