pi-extmgr 0.1.23 → 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";
@@ -17,6 +17,7 @@ import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.
17
17
  import { tryOperation } from "../utils/mode.js";
18
18
  import { updateExtmgrStatus } from "../utils/status.js";
19
19
  import { execNpm } from "../utils/npm-exec.js";
20
+ import { normalizePackageIdentity } from "../utils/package-source.js";
20
21
  import { TIMEOUTS } from "../constants.js";
21
22
 
22
23
  export type InstallScope = "global" | "project";
@@ -72,20 +73,93 @@ function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo |
72
73
  return { owner, repo, branch, filePath };
73
74
  }
74
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
+
75
112
  async function hasStandaloneEntrypoint(packageRoot: string): Promise<boolean> {
76
- const declared = await resolveManifestExtensionEntrypoints(packageRoot);
77
- if (declared !== undefined) {
78
- for (const path of declared) {
79
- if (await fileExists(join(packageRoot, path))) {
80
- return true;
81
- }
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);
82
138
  }
83
- return false;
84
139
  }
85
140
 
86
- return (
87
- (await fileExists(join(packageRoot, "index.ts"))) ||
88
- (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
+ })
89
163
  );
90
164
  }
91
165
 
@@ -150,7 +224,7 @@ export async function installPackage(
150
224
  clearSearchCache();
151
225
  logPackageInstall(pi, normalized, normalized, undefined, scope, true);
152
226
  success(ctx, `Installed ${normalized} (${scope})`);
153
- clearUpdatesAvailable(pi, ctx);
227
+ clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized)]);
154
228
 
155
229
  // Wait for the extension to be discoverable before reloading.
156
230
  // This prevents a race condition where ctx.reload() runs before
@@ -208,7 +282,7 @@ export async function installFromUrl(
208
282
  await mkdir(extensionDir, { recursive: true });
209
283
  notify(ctx, `Downloading ${fileName}...`, "info");
210
284
 
211
- const response = await fetch(url);
285
+ const response = await fetchWithTimeout(url, TIMEOUTS.packageInstall);
212
286
  if (!response.ok) {
213
287
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
214
288
  }
@@ -231,7 +305,6 @@ export async function installFromUrl(
231
305
  const { fileName: name, destPath } = result;
232
306
  logPackageInstall(pi, url, name, undefined, scope, true);
233
307
  success(ctx, `Installed ${name} to:\n${destPath}`);
234
- clearUpdatesAvailable(pi, ctx);
235
308
 
236
309
  const reloaded = await confirmReload(ctx, "Extension installed.");
237
310
  if (!reloaded) {
@@ -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,12 +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 {
15
- getPackageSourceKind,
16
- normalizeLocalSourceIdentity,
17
- splitGitRepoAndRef,
18
- } from "../utils/package-source.js";
13
+ import { formatInstalledPackageLabel } from "../utils/format.js";
14
+ import { normalizePackageIdentity } from "../utils/package-source.js";
19
15
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
20
16
  import { clearUpdatesAvailable } from "../utils/settings.js";
21
17
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -70,18 +66,19 @@ async function updatePackageInternal(
70
66
  return NO_PACKAGE_MUTATION_OUTCOME;
71
67
  }
72
68
 
69
+ const updateIdentity = normalizePackageIdentity(source);
73
70
  const stdout = res.stdout || "";
74
71
  if (isUpToDateOutput(stdout)) {
75
72
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
76
73
  logPackageUpdate(pi, source, source, undefined, true);
77
- clearUpdatesAvailable(pi, ctx);
74
+ clearUpdatesAvailable(pi, ctx, [updateIdentity]);
78
75
  void updateExtmgrStatus(ctx, pi);
79
76
  return NO_PACKAGE_MUTATION_OUTCOME;
80
77
  }
81
78
 
82
79
  logPackageUpdate(pi, source, source, undefined, true);
83
80
  success(ctx, `Updated ${source}`);
84
- clearUpdatesAvailable(pi, ctx);
81
+ clearUpdatesAvailable(pi, ctx, [updateIdentity]);
85
82
 
86
83
  const reloaded = await confirmReload(ctx, "Package updated.");
87
84
  if (!reloaded) {
@@ -156,29 +153,8 @@ export async function updatePackagesWithOutcome(
156
153
  return updatePackagesInternal(ctx, pi);
157
154
  }
158
155
 
159
- function packageIdentity(source: string, fallbackName?: string): string {
160
- const npm = parseNpmSource(source);
161
- if (npm?.name) {
162
- return `npm:${npm.name}`;
163
- }
164
-
165
- const sourceKind = getPackageSourceKind(source);
166
-
167
- if (sourceKind === "git") {
168
- const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
169
- const { repo } = splitGitRepoAndRef(gitSpec);
170
- return `git:${repo}`;
171
- }
172
-
173
- if (sourceKind === "local") {
174
- return `src:${normalizeLocalSourceIdentity(source)}`;
175
- }
176
-
177
- if (fallbackName) {
178
- return `name:${fallbackName}`;
179
- }
180
-
181
- return `src:${source}`;
156
+ function packageIdentity(source: string): string {
157
+ return normalizePackageIdentity(source);
182
158
  }
183
159
 
184
160
  async function getInstalledPackagesAllScopes(
@@ -325,9 +301,8 @@ async function removePackageInternal(
325
301
  pi: ExtensionAPI
326
302
  ): Promise<PackageMutationOutcome> {
327
303
  const installed = await getInstalledPackagesAllScopes(ctx, pi);
328
- const direct = installed.find((p) => p.source === source);
329
- const identity = packageIdentity(source, direct?.name);
330
- 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);
331
306
 
332
307
  const hasBothScopes =
333
308
  matching.some((pkg) => pkg.scope === "global") &&
@@ -369,12 +344,12 @@ async function removePackageInternal(
369
344
  .map((result) => result.target);
370
345
 
371
346
  const remaining = (await getInstalledPackagesAllScopes(ctx, pi)).filter(
372
- (p) => packageIdentity(p.source, p.name) === identity
347
+ (p) => packageIdentity(p.source) === identity
373
348
  );
374
349
  notifyRemovalSummary(source, remaining, failures, ctx);
375
350
 
376
- if (failures.length === 0) {
377
- clearUpdatesAvailable(pi, ctx);
351
+ if (failures.length === 0 && remaining.length === 0) {
352
+ clearUpdatesAvailable(pi, ctx, [identity]);
378
353
  }
379
354
 
380
355
  const successfulRemovalCount = successfulTargets.length;
@@ -13,19 +13,20 @@ import {
13
13
  type SettingItem,
14
14
  } from "@mariozechner/pi-tui";
15
15
  import type { InstalledPackage, PackageExtensionEntry, State } from "../types/index.js";
16
- import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
16
+ import {
17
+ applyPackageExtensionStateChanges,
18
+ discoverPackageExtensions,
19
+ validatePackageExtensionSettings,
20
+ } from "../packages/extensions.js";
17
21
  import { notify } from "../utils/notify.js";
18
22
  import { logExtensionToggle } from "../utils/history.js";
23
+ import { requireCustomUI, runCustomUI } from "../utils/mode.js";
19
24
  import { getPackageSourceKind } from "../utils/package-source.js";
25
+ import { getSettingsListSelectedIndex } from "../utils/settings-list.js";
20
26
  import { fileExists } from "../utils/fs.js";
21
27
  import { UI } from "../constants.js";
22
28
  import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js";
23
29
 
24
- interface SelectableList {
25
- selectedIndex?: number;
26
- handleInput?(data: string): void;
27
- }
28
-
29
30
  export interface PackageConfigRow {
30
31
  id: string;
31
32
  extensionPath: string;
@@ -36,16 +37,6 @@ export interface PackageConfigRow {
36
37
 
37
38
  type ConfigurePanelAction = { type: "cancel" } | { type: "save" };
38
39
 
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
40
  export async function buildPackageConfigRows(
50
41
  entries: PackageExtensionEntry[]
51
42
  ): Promise<PackageConfigRow[]> {
@@ -130,99 +121,116 @@ async function showConfigurePanel(
130
121
  rows: PackageConfigRow[],
131
122
  staged: Map<string, State>,
132
123
  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) {
124
+ ): Promise<ConfigurePanelAction | undefined> {
125
+ return runCustomUI(ctx, "Package extension configuration", () =>
126
+ ctx.ui.custom<ConfigurePanelAction>((tui, theme, _keybindings, done) => {
127
+ const container = new Container();
128
+ const titleText = new Text("", 2, 0);
129
+ const subtitleText = new Text("", 2, 0);
130
+ const footerText = new Text("", 2, 0);
131
+
132
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
133
+ container.addChild(titleText);
134
+ container.addChild(subtitleText);
135
+ container.addChild(new Spacer(1));
136
+
137
+ const settingsItems = buildSettingItems(rows, staged, pkg, theme);
138
+ const rowById = new Map(rows.map((row) => [row.id, row]));
139
+ const syncThemedContent = (): void => {
140
+ titleText.setText(theme.fg("accent", theme.bold(`Configure extensions: ${pkg.name}`)));
141
+ subtitleText.setText(
142
+ theme.fg(
143
+ "muted",
144
+ `${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space/Enter toggle • S save • Esc cancel`
145
+ )
146
+ );
147
+ footerText.setText(theme.fg("dim", "↑↓ Navigate | Space/Enter Toggle | S Save | Esc Back"));
148
+
149
+ for (const settingsItem of settingsItems) {
150
+ const row = rowById.get(settingsItem.id);
151
+ if (!row) continue;
152
+ const currentState = staged.get(row.id) ?? row.originalState;
169
153
  settingsItem.label = formatConfigRowLabel(
170
154
  row,
171
- state,
155
+ currentState,
172
156
  pkg,
173
157
  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"
158
+ currentState !== row.originalState
217
159
  );
218
- return;
219
160
  }
161
+ };
162
+ syncThemedContent();
163
+
164
+ const settingsList = new SettingsList(
165
+ settingsItems,
166
+ Math.min(rows.length + 2, UI.maxListHeight),
167
+ getSettingsListTheme(),
168
+ (id: string, newValue: string) => {
169
+ const row = rowById.get(id);
170
+ if (!row || !row.available) return;
171
+
172
+ const state = newValue as State;
173
+ staged.set(id, state);
174
+
175
+ const settingsItem = settingsItems.find((item) => item.id === id);
176
+ if (settingsItem) {
177
+ settingsItem.label = formatConfigRowLabel(
178
+ row,
179
+ state,
180
+ pkg,
181
+ theme,
182
+ state !== row.originalState
183
+ );
184
+ }
185
+
186
+ tui.requestRender();
187
+ },
188
+ () => done({ type: "cancel" }),
189
+ { enableSearch: rows.length > UI.searchThreshold }
190
+ );
220
191
 
221
- settingsList.handleInput?.(data);
222
- tui.requestRender();
223
- },
224
- };
225
- });
192
+ container.addChild(settingsList);
193
+ container.addChild(new Spacer(1));
194
+ container.addChild(footerText);
195
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
196
+
197
+ return {
198
+ render(width: number) {
199
+ return container.render(width);
200
+ },
201
+ invalidate() {
202
+ container.invalidate();
203
+ syncThemedContent();
204
+ },
205
+ handleInput(data: string) {
206
+ if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") {
207
+ done({ type: "save" });
208
+ return;
209
+ }
210
+
211
+ const selectedIndex = getSettingsListSelectedIndex(settingsList) ?? 0;
212
+ const selectedId = settingsItems[selectedIndex]?.id ?? settingsItems[0]?.id;
213
+ const selectedRow = selectedId ? rowById.get(selectedId) : undefined;
214
+
215
+ if (
216
+ selectedRow &&
217
+ !selectedRow.available &&
218
+ (data === " " || data === "\r" || data === "\n")
219
+ ) {
220
+ notify(
221
+ ctx,
222
+ `${selectedRow.extensionPath} is missing on disk and cannot be toggled.`,
223
+ "warning"
224
+ );
225
+ return;
226
+ }
227
+
228
+ settingsList.handleInput?.(data);
229
+ tui.requestRender();
230
+ },
231
+ };
232
+ })
233
+ );
226
234
  }
227
235
 
228
236
  export async function applyPackageExtensionChanges(
@@ -232,40 +240,50 @@ export async function applyPackageExtensionChanges(
232
240
  cwd: string,
233
241
  pi: ExtensionAPI
234
242
  ): Promise<{ changed: number; errors: string[] }> {
235
- let changed = 0;
236
243
  const errors: string[] = [];
244
+ const changedRows = [...rows]
245
+ .sort((a, b) => a.extensionPath.localeCompare(b.extensionPath))
246
+ .flatMap((row) => {
247
+ const target = staged.get(row.id) ?? row.originalState;
248
+ if (target === row.originalState) {
249
+ return [];
250
+ }
237
251
 
238
- const sortedRows = [...rows].sort((a, b) => a.extensionPath.localeCompare(b.extensionPath));
252
+ if (!row.available) {
253
+ const error = `${row.extensionPath}: extension entrypoint is missing on disk`;
254
+ errors.push(error);
255
+ logExtensionToggle(pi, row.id, row.originalState, target, false, error);
256
+ return [];
257
+ }
239
258
 
240
- for (const row of sortedRows) {
241
- const target = staged.get(row.id) ?? row.originalState;
242
- if (target === row.originalState) continue;
259
+ return [{ row, target }];
260
+ });
243
261
 
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
- }
262
+ if (changedRows.length === 0) {
263
+ return { changed: 0, errors };
264
+ }
250
265
 
251
- const result = await setPackageExtensionState(
252
- pkg.source,
253
- row.extensionPath,
254
- pkg.scope,
255
- target,
256
- cwd
257
- );
266
+ const result = await applyPackageExtensionStateChanges(
267
+ pkg.source,
268
+ pkg.scope,
269
+ changedRows.map(({ row, target }) => ({ extensionPath: row.extensionPath, target })),
270
+ cwd
271
+ );
258
272
 
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}`);
273
+ if (!result.ok) {
274
+ for (const { row, target } of changedRows) {
275
+ const error = `${row.extensionPath}: ${result.error}`;
276
+ errors.push(error);
264
277
  logExtensionToggle(pi, row.id, row.originalState, target, false, result.error);
265
278
  }
279
+ return { changed: 0, errors };
280
+ }
281
+
282
+ for (const { row, target } of changedRows) {
283
+ logExtensionToggle(pi, row.id, row.originalState, target, true);
266
284
  }
267
285
 
268
- return { changed, errors };
286
+ return { changed: changedRows.length, errors };
269
287
  }
270
288
 
271
289
  async function promptRestartForPackageConfig(ctx: ExtensionCommandContext): Promise<boolean> {
@@ -302,6 +320,16 @@ export async function configurePackageExtensions(
302
320
  ctx: ExtensionCommandContext,
303
321
  pi: ExtensionAPI
304
322
  ): Promise<{ changed: number; reloaded: boolean }> {
323
+ if (!requireCustomUI(ctx, "Package extension configuration")) {
324
+ return { changed: 0, reloaded: false };
325
+ }
326
+
327
+ const validation = await validatePackageExtensionSettings(pkg.scope, ctx.cwd);
328
+ if (!validation.ok) {
329
+ notify(ctx, validation.error, "error");
330
+ return { changed: 0, reloaded: false };
331
+ }
332
+
305
333
  const discovered = await discoverPackageExtensions([pkg], ctx.cwd);
306
334
  const rows = await buildPackageConfigRows(discovered);
307
335
 
@@ -314,6 +342,9 @@ export async function configurePackageExtensions(
314
342
 
315
343
  while (true) {
316
344
  const action = await showConfigurePanel(pkg, rows, staged, ctx);
345
+ if (!action) {
346
+ return { changed: 0, reloaded: false };
347
+ }
317
348
 
318
349
  if (action.type === "cancel") {
319
350
  const pending = getPendingChangeCount(rows, staged);