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.
package/README.md CHANGED
@@ -26,6 +26,7 @@ Requires Node.js `>=22.5.0`.
26
26
  - Scope indicators (global/project), status indicators, update badges
27
27
  - **Package extension configuration panel**
28
28
  - Configure individual extension entrypoints inside an installed package (`c` on package row)
29
+ - Works with manifest-declared entrypoints and conventional `extensions/` package layouts
29
30
  - Persists to package filters in `settings.json` (no manual JSON editing)
30
31
  - **Safe staged local extension toggles**
31
32
  - Toggle with `Space/Enter`, apply with `S`
@@ -36,15 +37,16 @@ Requires Node.js `>=22.5.0`.
36
37
  - **Remote discovery and install**
37
38
  - npm search/browse with pagination
38
39
  - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
39
- - Supports direct GitHub `.ts` installs and local standalone install mode
40
+ - Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
40
41
  - **Auto-update**
41
42
  - Interactive wizard (`t` in manager, or `/extensions auto-update`)
42
43
  - Persistent schedule restored on startup and session switch
43
- - Background checks + status bar updates
44
+ - Background checks + status bar updates for installed npm packages
44
45
  - **Operational visibility**
45
46
  - Session history (`/extensions history`)
46
- - Cache controls (`/extensions clear-cache`)
47
+ - Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches)
47
48
  - Status line summary (`pkg count • auto-update • known updates`)
49
+ - History now records local extension deletions and auto-update configuration changes
48
50
  - **Interactive + non-interactive support**
49
51
  - Works in TUI and non-UI modes
50
52
  - Non-interactive commands for list/install/remove/update/auto-update
@@ -91,9 +93,9 @@ Open the manager:
91
93
  /extensions remove [source] # Remove package
92
94
  /extensions uninstall [source] # Alias: remove
93
95
  /extensions update [source] # Update one package (or all when omitted)
94
- /extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, never, etc.
96
+ /extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, 1mo, never, etc.
95
97
  /extensions history [options] # View change history (supports filters)
96
- /extensions clear-cache # Clear metadata cache
98
+ /extensions clear-cache # Clear persistent + runtime extmgr caches
97
99
  ```
98
100
 
99
101
  ### Non-interactive mode
@@ -107,23 +109,33 @@ When Pi is running without UI, extmgr still supports command-driven workflows:
107
109
  - `/extensions update [source]`
108
110
  - `/extensions history [options]`
109
111
  - `/extensions auto-update <duration>`
112
+ - Use `1mo` for monthly schedules (`/extensions history --since <duration>` also accepts `1mo`; `30m`/`24h` are just lookback examples)
110
113
 
111
- Remote browsing/search menus require interactive mode.
114
+ Remote browsing/search menus require the full interactive TUI.
115
+
116
+ ### RPC / limited-UI mode
117
+
118
+ In RPC mode, dialog-based commands still work, but the custom TUI panels do not:
119
+
120
+ - `/extensions` falls back to read-only local/package lists
121
+ - `/extensions installed` lists packages directly
122
+ - remote browsing/search panels require the full interactive TUI
123
+ - package extension configuration requires the full interactive TUI
112
124
 
113
125
  History options (works in non-interactive mode too):
114
126
 
115
127
  - `--limit <n>`
116
- - `--action <extension_toggle|package_install|package_update|package_remove|cache_clear>`
128
+ - `--action <extension_toggle|extension_delete|package_install|package_update|package_remove|cache_clear|auto_update_config>`
117
129
  - `--success` / `--failed`
118
130
  - `--package <query>`
119
- - `--since <duration>` (e.g. `30m`, `24h`, `7d`, `1mo`)
120
- - `--global` (non-interactive mode only; reads all persisted sessions)
131
+ - `--since <duration>` (e.g. `30m`, `24h`, `7d`, `1mo`; `1mo` is supported for monthly lookbacks)
132
+ - `--global` (non-interactive mode only; reads all persisted sessions under `~/.pi/agent/sessions`)
121
133
 
122
134
  Examples:
123
135
 
124
136
  - `/extensions history --failed --limit 50`
125
137
  - `/extensions history --action package_update --since 7d`
126
- - `/extensions history --global --package extmgr --since 24h`
138
+ - `/extensions history --global --package extmgr --since 1mo`
127
139
 
128
140
  ### Install sources
129
141
 
@@ -144,9 +156,10 @@ Examples:
144
156
  - **Package extension config**: Select a package and press `c` (or Enter/A → Configure) to enable/disable individual package entrypoints.
145
157
  - After saving package extension config, restart pi to fully apply changes.
146
158
  - **Two install modes**:
147
- - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache
148
- - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, supports multi-file extensions
159
+ - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading
160
+ - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, so it only accepts runnable standalone layouts (manifest-declared/root entrypoints), requires `tar` on `PATH`, and rejects packages whose runtime `dependencies` are not already bundled with the package contents
149
161
  - **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
162
+ - **Auto-update coverage is npm-only today**: extmgr checks update availability for managed npm packages; git/local installs are not included in the background update badge yet.
150
163
  - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
151
164
  - **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
152
165
  - **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -54,7 +54,7 @@ export async function handleAutoUpdateSubcommand(
54
54
  " 3d - Check every 3 days",
55
55
  " 1w - Check weekly",
56
56
  " 2w - Check every 2 weeks",
57
- " 1m - Check monthly",
57
+ " 1mo - Check monthly (1m also works)",
58
58
  " daily - Check daily (alias)",
59
59
  " weekly - Check weekly (alias)",
60
60
  ];
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { clearSearchCache } from "../packages/discovery.js";
3
+ import { clearRemotePackageInfoCache } from "../ui/remote.js";
2
4
  import { clearCache } from "../utils/cache.js";
3
5
  import { logCacheClear } from "../utils/history.js";
4
6
  import { notify } from "../utils/notify.js";
@@ -10,8 +12,10 @@ export async function clearMetadataCacheCommand(
10
12
  ): Promise<void> {
11
13
  try {
12
14
  await clearCache();
15
+ clearSearchCache();
16
+ clearRemotePackageInfoCache();
13
17
  logCacheClear(pi, true);
14
- notify(ctx, "Metadata cache cleared.", "info");
18
+ notify(ctx, "Metadata and in-memory extmgr caches cleared.", "info");
15
19
  } catch (error) {
16
20
  const message = error instanceof Error ? error.message : String(error);
17
21
  logCacheClear(pi, false, message);
@@ -11,10 +11,12 @@ import { formatListOutput } from "../utils/ui-helpers.js";
11
11
 
12
12
  const HISTORY_ACTIONS: ChangeAction[] = [
13
13
  "extension_toggle",
14
+ "extension_delete",
14
15
  "package_install",
15
16
  "package_update",
16
17
  "package_remove",
17
18
  "cache_clear",
19
+ "auto_update_config",
18
20
  ];
19
21
 
20
22
  interface ParsedHistoryArgs {
@@ -187,12 +189,12 @@ function showHistoryHelp(ctx: ExtensionCommandContext): void {
187
189
  "Options:",
188
190
  " --limit <n> Maximum entries to show (default: 20)",
189
191
  " --action <type> Filter by action",
190
- " extension_toggle | package_install | package_update | package_remove | cache_clear",
192
+ ` ${HISTORY_ACTIONS.join(" | ")}`,
191
193
  " --success Show only successful entries",
192
194
  " --failed Show only failed entries",
193
195
  " --package <q> Filter by package/source/extension id",
194
196
  " --since <d> Show only entries newer than duration (e.g. 30m, 24h, 7d, 1mo)",
195
- " --global Read all persisted sessions (non-interactive mode only)",
197
+ " --global Read all persisted sessions from ~/.pi/agent/sessions (non-interactive mode only)",
196
198
  "",
197
199
  "Examples:",
198
200
  " /extensions history --failed --limit 50",
@@ -34,7 +34,7 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void {
34
34
  " /extensions remove <source> - Remove a package",
35
35
  " /extensions update [source] - Update one package or all packages",
36
36
  " /extensions history [opts] - Show history (supports filters)",
37
- " /extensions auto-update <d> - Configure auto-update (e.g. 1d, 1w, never)",
37
+ " /extensions auto-update <d> - Configure auto-update (e.g. 1d, 1w, 1mo, never)",
38
38
  "",
39
39
  "History examples:",
40
40
  " /extensions history --failed --limit 50",
@@ -14,8 +14,9 @@ import { readSummary } from "../utils/fs.js";
14
14
  import { parseNpmSource } from "../utils/format.js";
15
15
  import {
16
16
  getPackageSourceKind,
17
- normalizeLocalSourceIdentity,
17
+ normalizePackageIdentity,
18
18
  splitGitRepoAndRef,
19
+ stripGitSourcePrefix,
19
20
  } from "../utils/package-source.js";
20
21
  import { execNpm } from "../utils/npm-exec.js";
21
22
 
@@ -121,15 +122,11 @@ function sanitizeListSourceSuffix(source: string): string {
121
122
  .trim();
122
123
  }
123
124
 
124
- function normalizeSourceIdentity(source: string): string {
125
- const sanitized = sanitizeListSourceSuffix(source);
126
- const kind = getPackageSourceKind(sanitized);
127
-
128
- if (kind === "local") {
129
- return normalizeLocalSourceIdentity(sanitized);
130
- }
131
-
132
- return sanitized.replace(/\\/g, "/").toLowerCase();
125
+ function getInstalledPackageIdentity(pkg: InstalledPackage): string {
126
+ return normalizePackageIdentity(
127
+ pkg.source,
128
+ pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
129
+ );
133
130
  }
134
131
 
135
132
  function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
@@ -181,12 +178,8 @@ function parseResolvedPathLine(line: string): string | undefined {
181
178
  return undefined;
182
179
  }
183
180
 
184
- function parseInstalledPackagesOutputInternal(
185
- text: string,
186
- options?: { dedupeBySource?: boolean }
187
- ): InstalledPackage[] {
181
+ function parseInstalledPackagesOutputInternal(text: string): InstalledPackage[] {
188
182
  const packages: InstalledPackage[] = [];
189
- const seenSources = new Set<string>();
190
183
 
191
184
  const lines = text.split("\n");
192
185
  let currentScope: "global" | "project" = "global";
@@ -222,15 +215,6 @@ function parseInstalledPackagesOutputInternal(
222
215
  if (!looksLikePackageSource(candidate)) continue;
223
216
 
224
217
  const source = sanitizeListSourceSuffix(candidate);
225
- if (options?.dedupeBySource !== false) {
226
- const sourceIdentity = normalizeSourceIdentity(source);
227
- if (seenSources.has(sourceIdentity)) {
228
- currentPackage = undefined;
229
- continue;
230
- }
231
- seenSources.add(sourceIdentity);
232
- }
233
-
234
218
  const { name, version } = parsePackageNameAndVersion(source);
235
219
 
236
220
  const pkg: InstalledPackage = { source, name, scope: currentScope };
@@ -244,8 +228,34 @@ function parseInstalledPackagesOutputInternal(
244
228
  return packages;
245
229
  }
246
230
 
231
+ function shouldReplaceInstalledPackage(
232
+ current: InstalledPackage | undefined,
233
+ candidate: InstalledPackage
234
+ ): boolean {
235
+ if (!current) {
236
+ return true;
237
+ }
238
+
239
+ if (current.scope !== candidate.scope) {
240
+ return candidate.scope === "project";
241
+ }
242
+
243
+ return false;
244
+ }
245
+
247
246
  export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
248
- return parseInstalledPackagesOutputInternal(text, { dedupeBySource: true });
247
+ const parsed = parseInstalledPackagesOutputInternal(text);
248
+ const deduped = new Map<string, InstalledPackage>();
249
+
250
+ for (const pkg of parsed) {
251
+ const identity = getInstalledPackageIdentity(pkg);
252
+ const current = deduped.get(identity);
253
+ if (shouldReplaceInstalledPackage(current, pkg)) {
254
+ deduped.set(identity, pkg);
255
+ }
256
+ }
257
+
258
+ return Array.from(deduped.values());
249
259
  }
250
260
 
251
261
  /**
@@ -263,10 +273,10 @@ export async function isSourceInstalled(
263
273
  if (res.code !== 0) return false;
264
274
 
265
275
  const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
266
- const expected = normalizeSourceIdentity(source);
276
+ const expected = normalizePackageIdentity(source);
267
277
 
268
278
  return installed.some((pkg) => {
269
- if (normalizeSourceIdentity(pkg.source) !== expected) {
279
+ if (getInstalledPackageIdentity(pkg) !== expected) {
270
280
  return false;
271
281
  }
272
282
  return options?.scope ? pkg.scope === options.scope : true;
@@ -276,8 +286,14 @@ export async function isSourceInstalled(
276
286
  }
277
287
  }
278
288
 
289
+ /**
290
+ * parseInstalledPackagesOutputAllScopes returns the raw parsed entries from
291
+ * parseInstalledPackagesOutputInternal without deduplication or scope merging.
292
+ * Prefer parseInstalledPackagesOutput for user-facing lists, since it applies
293
+ * deduplication and normalized scope selection.
294
+ */
279
295
  export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
280
- return parseInstalledPackagesOutputInternal(text, { dedupeBySource: false });
296
+ return parseInstalledPackagesOutputInternal(text);
281
297
  }
282
298
 
283
299
  function extractGitPackageName(repoSpec: string): string {
@@ -316,7 +332,7 @@ function parsePackageNameAndVersion(fullSource: string): {
316
332
 
317
333
  const sourceKind = getPackageSourceKind(fullSource);
318
334
  if (sourceKind === "git") {
319
- const gitSpec = fullSource.startsWith("git:") ? fullSource.slice(4) : fullSource;
335
+ const gitSpec = stripGitSourcePrefix(fullSource);
320
336
  const { repo } = splitGitRepoAndRef(gitSpec);
321
337
  return { name: extractGitPackageName(repo) };
322
338
  }
@@ -9,6 +9,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
9
9
  import type { InstalledPackage, PackageExtensionEntry, Scope, State } from "../types/index.js";
10
10
  import { parseNpmSource } from "../utils/format.js";
11
11
  import { fileExists, readSummary } from "../utils/fs.js";
12
+ import { resolveNpmCommand } from "../utils/npm-exec.js";
12
13
 
13
14
  interface PackageSettingsObject {
14
15
  source: string;
@@ -19,6 +20,14 @@ interface SettingsFile {
19
20
  packages?: (string | PackageSettingsObject)[];
20
21
  }
21
22
 
23
+ export interface PackageManifest {
24
+ name?: string;
25
+ dependencies?: Record<string, string>;
26
+ pi?: {
27
+ extensions?: unknown;
28
+ };
29
+ }
30
+
22
31
  const execFileAsync = promisify(execFile);
23
32
  let globalNpmRootCache: string | null | undefined;
24
33
 
@@ -50,7 +59,8 @@ async function getGlobalNpmRoot(): Promise<string | undefined> {
50
59
  }
51
60
 
52
61
  try {
53
- const { stdout } = await execFileAsync("npm", ["root", "-g"], {
62
+ const npmCommand = resolveNpmCommand(["root", "-g"]);
63
+ const { stdout } = await execFileAsync(npmCommand.command, npmCommand.args, {
54
64
  timeout: 2_000,
55
65
  windowsHide: true,
56
66
  });
@@ -206,6 +216,125 @@ async function writeSettingsFile(path: string, settings: SettingsFile): Promise<
206
216
  }
207
217
  }
208
218
 
219
+ function findPackageSettingsIndex(
220
+ packages: SettingsFile["packages"] extends infer T ? NonNullable<T> : never,
221
+ normalizedSource: string
222
+ ): number {
223
+ return packages.findIndex((pkg) => {
224
+ if (typeof pkg === "string") {
225
+ return normalizeSource(pkg) === normalizedSource;
226
+ }
227
+ return normalizeSource(pkg.source) === normalizedSource;
228
+ });
229
+ }
230
+
231
+ function toPackageSettingsObject(
232
+ existing: string | PackageSettingsObject | undefined,
233
+ packageSource: string
234
+ ): PackageSettingsObject {
235
+ if (typeof existing === "string") {
236
+ return { source: existing, extensions: [] };
237
+ }
238
+
239
+ if (existing && typeof existing.source === "string") {
240
+ return {
241
+ source: existing.source,
242
+ extensions: Array.isArray(existing.extensions) ? [...existing.extensions] : [],
243
+ };
244
+ }
245
+
246
+ return { source: packageSource, extensions: [] };
247
+ }
248
+
249
+ function updateExtensionMarkers(
250
+ existingTokens: string[] | undefined,
251
+ changes: ReadonlyMap<string, State>
252
+ ): string[] {
253
+ const nextTokens: string[] = [];
254
+
255
+ for (const token of existingTokens ?? []) {
256
+ if (typeof token !== "string") {
257
+ continue;
258
+ }
259
+
260
+ if (token[0] !== "+" && token[0] !== "-") {
261
+ nextTokens.push(token);
262
+ continue;
263
+ }
264
+
265
+ const tokenPath = normalizeRelativePath(token.slice(1));
266
+ if (!changes.has(tokenPath)) {
267
+ nextTokens.push(token);
268
+ }
269
+ }
270
+
271
+ for (const [extensionPath, target] of Array.from(changes.entries()).sort((a, b) =>
272
+ a[0].localeCompare(b[0])
273
+ )) {
274
+ nextTokens.push(`${target === "enabled" ? "+" : "-"}${extensionPath}`);
275
+ }
276
+
277
+ return nextTokens;
278
+ }
279
+
280
+ export async function validatePackageExtensionSettings(
281
+ scope: Scope,
282
+ cwd: string
283
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
284
+ try {
285
+ await readSettingsFile(getSettingsPath(scope, cwd), { strict: true });
286
+ return { ok: true };
287
+ } catch (error) {
288
+ return {
289
+ ok: false,
290
+ error: error instanceof Error ? error.message : String(error),
291
+ };
292
+ }
293
+ }
294
+
295
+ export async function applyPackageExtensionStateChanges(
296
+ packageSource: string,
297
+ scope: Scope,
298
+ changes: readonly { extensionPath: string; target: State }[],
299
+ cwd: string
300
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
301
+ try {
302
+ if (changes.length === 0) {
303
+ return { ok: true };
304
+ }
305
+
306
+ const settingsPath = getSettingsPath(scope, cwd);
307
+ const settings = await readSettingsFile(settingsPath, { strict: true });
308
+ const normalizedSource = normalizeSource(packageSource);
309
+ const packages = [...(settings.packages ?? [])];
310
+ const index = findPackageSettingsIndex(packages, normalizedSource);
311
+ const packageEntry = toPackageSettingsObject(packages[index], packageSource);
312
+
313
+ const normalizedChanges = new Map<string, State>();
314
+ for (const change of changes) {
315
+ normalizedChanges.set(normalizeRelativePath(change.extensionPath), change.target);
316
+ }
317
+
318
+ packageEntry.extensions = updateExtensionMarkers(packageEntry.extensions, normalizedChanges);
319
+
320
+ if (index === -1) {
321
+ packages.push(packageEntry);
322
+ } else {
323
+ packages[index] = packageEntry;
324
+ }
325
+
326
+ settings.packages = packages;
327
+ await writeSettingsFile(settingsPath, settings);
328
+
329
+ return { ok: true };
330
+ } catch (error) {
331
+ return {
332
+ ok: false,
333
+ error: error instanceof Error ? error.message : String(error),
334
+ };
335
+ }
336
+ }
337
+
209
338
  function safeMatchesGlob(targetPath: string, pattern: string): boolean {
210
339
  try {
211
340
  return matchesGlob(targetPath, pattern);
@@ -405,20 +534,29 @@ async function resolveManifestExtensionEntries(
405
534
  return Array.from(selected).sort((a, b) => a.localeCompare(b));
406
535
  }
407
536
 
408
- export async function resolveManifestExtensionEntrypoints(
537
+ export async function readPackageManifest(
409
538
  packageRoot: string
410
- ): Promise<string[] | undefined> {
539
+ ): Promise<PackageManifest | undefined> {
411
540
  const packageJsonPath = join(packageRoot, "package.json");
412
541
 
413
- let parsed: { pi?: { extensions?: unknown } };
414
542
  try {
415
543
  const raw = await readFile(packageJsonPath, "utf8");
416
- parsed = JSON.parse(raw) as { pi?: { extensions?: unknown } };
544
+ const parsed = JSON.parse(raw) as unknown;
545
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
546
+ return undefined;
547
+ }
548
+ return parsed as PackageManifest;
417
549
  } catch {
418
550
  return undefined;
419
551
  }
552
+ }
420
553
 
421
- const extensions = parsed.pi?.extensions;
554
+ export async function resolveManifestExtensionEntrypoints(
555
+ packageRoot: string,
556
+ manifest?: PackageManifest
557
+ ): Promise<string[] | undefined> {
558
+ const parsed = manifest ?? (await readPackageManifest(packageRoot));
559
+ const extensions = parsed?.pi?.extensions;
422
560
  if (!Array.isArray(extensions)) {
423
561
  return undefined;
424
562
  }
@@ -427,12 +565,35 @@ export async function resolveManifestExtensionEntrypoints(
427
565
  return resolveManifestExtensionEntries(packageRoot, entries);
428
566
  }
429
567
 
430
- async function discoverEntrypoints(packageRoot: string): Promise<string[]> {
431
- const manifestEntrypoints = await resolveManifestExtensionEntrypoints(packageRoot);
568
+ async function resolveConventionExtensionEntrypoints(packageRoot: string): Promise<string[]> {
569
+ const extensionsDir = join(packageRoot, "extensions");
570
+ return collectExtensionFilesFromDir(packageRoot, extensionsDir);
571
+ }
572
+
573
+ export async function discoverPackageExtensionEntrypoints(
574
+ packageRoot: string,
575
+ options?: {
576
+ allowConventionDirectory?: boolean;
577
+ allowRootIndexFallback?: boolean;
578
+ }
579
+ ): Promise<string[]> {
580
+ const manifest = await readPackageManifest(packageRoot);
581
+ const manifestEntrypoints = await resolveManifestExtensionEntrypoints(packageRoot, manifest);
432
582
  if (manifestEntrypoints !== undefined) {
433
583
  return manifestEntrypoints;
434
584
  }
435
585
 
586
+ if (options?.allowConventionDirectory !== false) {
587
+ const conventionEntrypoints = await resolveConventionExtensionEntrypoints(packageRoot);
588
+ if (conventionEntrypoints.length > 0) {
589
+ return conventionEntrypoints.sort((a, b) => a.localeCompare(b));
590
+ }
591
+ }
592
+
593
+ if (options?.allowRootIndexFallback === false) {
594
+ return [];
595
+ }
596
+
436
597
  const indexTs = join(packageRoot, "index.ts");
437
598
  if (await fileExists(indexTs)) {
438
599
  return ["index.ts"];
@@ -456,7 +617,7 @@ export async function discoverPackageExtensions(
456
617
  const packageRoot = await toPackageRoot(pkg, cwd);
457
618
  if (!packageRoot) continue;
458
619
 
459
- const extensionPaths = await discoverEntrypoints(packageRoot);
620
+ const extensionPaths = await discoverPackageExtensionEntrypoints(packageRoot);
460
621
  for (const extensionPath of extensionPaths) {
461
622
  const normalizedPath = normalizeRelativePath(extensionPath);
462
623
  const absolutePath = resolve(packageRoot, extensionPath);
@@ -490,58 +651,5 @@ export async function setPackageExtensionState(
490
651
  target: State,
491
652
  cwd: string
492
653
  ): Promise<{ ok: true } | { ok: false; error: string }> {
493
- try {
494
- const settingsPath = getSettingsPath(scope, cwd);
495
- const settings = await readSettingsFile(settingsPath, { strict: true });
496
-
497
- const normalizedSource = normalizeSource(packageSource);
498
- const normalizedPath = normalizeRelativePath(extensionPath);
499
- const marker = `${target === "enabled" ? "+" : "-"}${normalizedPath}`;
500
-
501
- const packages = [...(settings.packages ?? [])];
502
- let index = packages.findIndex((pkg) => {
503
- if (typeof pkg === "string") {
504
- return normalizeSource(pkg) === normalizedSource;
505
- }
506
- return normalizeSource(pkg.source) === normalizedSource;
507
- });
508
-
509
- let packageEntry: PackageSettingsObject;
510
- if (index === -1) {
511
- packageEntry = { source: packageSource, extensions: [marker] };
512
- packages.push(packageEntry);
513
- index = packages.length - 1;
514
- } else {
515
- const existing = packages[index];
516
- if (typeof existing === "string") {
517
- packageEntry = { source: existing, extensions: [] };
518
- } else if (existing && typeof existing.source === "string") {
519
- packageEntry = {
520
- source: existing.source,
521
- extensions: Array.isArray(existing.extensions) ? [...existing.extensions] : [],
522
- };
523
- } else {
524
- packageEntry = { source: packageSource, extensions: [] };
525
- }
526
-
527
- packageEntry.extensions = (packageEntry.extensions ?? []).filter((token) => {
528
- if (typeof token !== "string") return false;
529
- if (token[0] !== "+" && token[0] !== "-") return true;
530
- return normalizeRelativePath(token.slice(1)) !== normalizedPath;
531
- });
532
- packageEntry.extensions.push(marker);
533
- packages[index] = packageEntry;
534
- }
535
-
536
- settings.packages = packages;
537
-
538
- await writeSettingsFile(settingsPath, settings);
539
-
540
- return { ok: true };
541
- } catch (error) {
542
- return {
543
- ok: false,
544
- error: error instanceof Error ? error.message : String(error),
545
- };
546
- }
654
+ return applyPackageExtensionStateChanges(packageSource, scope, [{ extensionPath, target }], cwd);
547
655
  }