pi-extmgr 0.1.20 → 0.1.22

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/src/ui/unified.ts CHANGED
@@ -14,22 +14,14 @@ import {
14
14
  matchesKey,
15
15
  Key,
16
16
  } from "@mariozechner/pi-tui";
17
- import type {
18
- UnifiedItem,
19
- State,
20
- UnifiedAction,
21
- InstalledPackage,
22
- PackageExtensionEntry,
23
- } from "../types/index.js";
17
+ import type { UnifiedItem, State, UnifiedAction, InstalledPackage } from "../types/index.js";
24
18
  import {
25
19
  discoverExtensions,
26
20
  removeLocalExtension,
27
21
  setExtensionState,
28
22
  } from "../extensions/discovery.js";
29
23
  import { getInstalledPackages } from "../packages/discovery.js";
30
- import { discoverPackageExtensions, setPackageExtensionState } from "../packages/extensions.js";
31
24
  import {
32
- showPackageActions,
33
25
  updatePackageWithOutcome,
34
26
  removePackageWithOutcome,
35
27
  updatePackagesWithOutcome,
@@ -44,12 +36,14 @@ import {
44
36
  getChangeMarker,
45
37
  formatSize,
46
38
  } from "./theme.js";
39
+ import { buildFooterState, buildFooterShortcuts, getPendingToggleChangeCount } from "./footer.js";
47
40
  import { logExtensionToggle } from "../utils/history.js";
48
41
  import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js";
49
42
  import { updateExtmgrStatus } from "../utils/status.js";
50
43
  import { parseChoiceByLabel } from "../utils/command.js";
51
44
  import { getPackageSourceKind } from "../utils/package-source.js";
52
45
  import { UI } from "../constants.js";
46
+ import { configurePackageExtensions } from "./package-config.js";
53
47
 
54
48
  // Type guard for SettingsList with selectedIndex
55
49
  interface SelectableList {
@@ -86,16 +80,15 @@ async function showInteractiveOnce(
86
80
  ctx: ExtensionCommandContext,
87
81
  pi: ExtensionAPI
88
82
  ): Promise<boolean> {
89
- // Load local extensions and installed packages, then discover package entrypoints.
83
+ // Load local extensions and installed packages.
90
84
  const [localEntries, installedPackages] = await Promise.all([
91
85
  discoverExtensions(ctx.cwd),
92
86
  getInstalledPackages(ctx, pi),
93
87
  ]);
94
- const packageExtensions = await discoverPackageExtensions(installedPackages, ctx.cwd);
95
88
 
96
- // Build unified items list
89
+ // Build unified items list.
97
90
  const knownUpdates = getKnownUpdates(ctx);
98
- const items = buildUnifiedItems(localEntries, installedPackages, packageExtensions, knownUpdates);
91
+ const items = buildUnifiedItems(localEntries, installedPackages, knownUpdates);
99
92
 
100
93
  // If nothing found, show quick actions
101
94
  if (items.length === 0) {
@@ -111,16 +104,12 @@ async function showInteractiveOnce(
111
104
  return true;
112
105
  }
113
106
 
114
- // Staged changes tracking for toggleable rows (local + package extensions)
107
+ // Staged changes tracking for local extensions.
115
108
  const staged = new Map<string, State>();
116
109
  const byId = new Map(items.map((item) => [item.id, item]));
117
110
 
118
111
  const result = await ctx.ui.custom<UnifiedAction>((tui, theme, _keybindings, done) => {
119
112
  const container = new Container();
120
- const hasLocals = items.some((i) => i.type === "local");
121
- const hasPackageExtensions = items.some((i) => i.type === "package-extension");
122
- const hasToggleRows = hasLocals || hasPackageExtensions;
123
- const hasPackages = items.some((i) => i.type === "package");
124
113
 
125
114
  // Header
126
115
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
@@ -129,7 +118,7 @@ async function showInteractiveOnce(
129
118
  new Text(
130
119
  theme.fg(
131
120
  "muted",
132
- `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle extensions • Enter/A actions • u update pkg • x remove selected`
121
+ `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected`
133
122
  ),
134
123
  2,
135
124
  0
@@ -153,7 +142,7 @@ async function showInteractiveOnce(
153
142
  getSettingsListTheme(),
154
143
  (id: string, newValue: string) => {
155
144
  const item = byId.get(id);
156
- if (!item || (item.type !== "local" && item.type !== "package-extension")) return;
145
+ if (!item || item.type !== "local") return;
157
146
 
158
147
  const state = newValue as State;
159
148
  staged.set(id, state);
@@ -173,8 +162,8 @@ async function showInteractiveOnce(
173
162
  container.addChild(new Spacer(1));
174
163
 
175
164
  // Footer with keyboard shortcuts
176
- const footerParts = buildFooter(hasToggleRows, hasLocals, hasPackages, staged, byId);
177
- container.addChild(new Text(theme.fg("dim", footerParts.join(" | ")), 2, 0));
165
+ const footerState = buildFooterState(items);
166
+ container.addChild(new Text(theme.fg("dim", buildFooterShortcuts(footerState)), 2, 0));
178
167
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
179
168
 
180
169
  return {
@@ -239,6 +228,10 @@ async function showInteractiveOnce(
239
228
  done({ type: "action", itemId: selectedId, action: "details" });
240
229
  return;
241
230
  }
231
+ if (data === "c" || data === "C") {
232
+ done({ type: "action", itemId: selectedId, action: "configure" });
233
+ return;
234
+ }
242
235
  }
243
236
 
244
237
  if (selectedId && selectedItem?.type === "local") {
@@ -269,35 +262,21 @@ async function showInteractiveOnce(
269
262
  return await handleUnifiedAction(result, items, staged, byId, ctx, pi);
270
263
  }
271
264
 
265
+ function normalizePathForDuplicateCheck(value: string): string {
266
+ return value.replace(/\\/g, "/").toLowerCase();
267
+ }
268
+
272
269
  export function buildUnifiedItems(
273
270
  localEntries: Awaited<ReturnType<typeof discoverExtensions>>,
274
271
  installedPackages: InstalledPackage[],
275
- packageExtensions: PackageExtensionEntry[],
276
272
  knownUpdates: Set<string>
277
273
  ): UnifiedItem[] {
278
274
  const items: UnifiedItem[] = [];
279
275
  const localPaths = new Set<string>();
280
276
 
281
- const packageExtensionGroups = new Map<string, PackageExtensionEntry[]>();
282
- for (const entry of packageExtensions) {
283
- const key = `${entry.packageScope}:${entry.packageSource.toLowerCase()}`;
284
- const group = packageExtensionGroups.get(key) ?? [];
285
- group.push(entry);
286
- packageExtensionGroups.set(key, group);
287
- }
288
-
289
- const visiblePackageExtensions = packageExtensions.filter((entry) => {
290
- const key = `${entry.packageScope}:${entry.packageSource.toLowerCase()}`;
291
- const group = packageExtensionGroups.get(key) ?? [];
292
-
293
- // Avoid duplicate-looking rows for packages that expose a single enabled extension entrypoint.
294
- // Keep extension rows when there are multiple entrypoints, or when an entry is disabled so it can be re-enabled.
295
- return group.length > 1 || entry.state === "disabled";
296
- });
297
-
298
277
  // Add local extensions
299
278
  for (const entry of localEntries) {
300
- localPaths.add(entry.activePath?.toLowerCase() ?? "");
279
+ localPaths.add(normalizePathForDuplicateCheck(entry.activePath));
301
280
  items.push({
302
281
  type: "local",
303
282
  id: entry.id,
@@ -311,39 +290,28 @@ export function buildUnifiedItems(
311
290
  });
312
291
  }
313
292
 
314
- for (const entry of visiblePackageExtensions) {
315
- items.push({
316
- type: "package-extension",
317
- id: entry.id,
318
- displayName: entry.displayName,
319
- summary: entry.summary,
320
- scope: entry.packageScope,
321
- state: entry.state,
322
- originalState: entry.state,
323
- packageSource: entry.packageSource,
324
- extensionPath: entry.extensionPath,
325
- });
326
- }
327
-
328
293
  for (const pkg of installedPackages) {
329
- const pkgSourceLower = pkg.source.toLowerCase();
330
- const pkgResolvedLower = pkg.resolvedPath?.toLowerCase() ?? "";
294
+ const pkgSourceNormalized = normalizePathForDuplicateCheck(pkg.source);
295
+ const pkgResolvedNormalized = pkg.resolvedPath
296
+ ? normalizePathForDuplicateCheck(pkg.resolvedPath)
297
+ : "";
331
298
 
332
299
  let isDuplicate = false;
333
300
  for (const localPath of localPaths) {
334
- if (pkgSourceLower === localPath || pkgResolvedLower === localPath) {
301
+ if (pkgSourceNormalized === localPath || pkgResolvedNormalized === localPath) {
335
302
  isDuplicate = true;
336
303
  break;
337
304
  }
338
305
  if (
339
- pkgResolvedLower &&
340
- (localPath.startsWith(pkgResolvedLower + "/") || pkgResolvedLower.startsWith(localPath))
306
+ pkgResolvedNormalized &&
307
+ (localPath.startsWith(`${pkgResolvedNormalized}/`) ||
308
+ pkgResolvedNormalized.startsWith(localPath))
341
309
  ) {
342
310
  isDuplicate = true;
343
311
  break;
344
312
  }
345
- const localDir = localPath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
346
- if (pkgResolvedLower && pkgResolvedLower.replace(/\\/g, "/") === localDir) {
313
+ const localDir = localPath.split("/").slice(0, -1).join("/");
314
+ if (pkgResolvedNormalized && pkgResolvedNormalized === localDir) {
347
315
  isDuplicate = true;
348
316
  break;
349
317
  }
@@ -368,8 +336,7 @@ export function buildUnifiedItems(
368
336
  items.sort((a, b) => {
369
337
  const rank = (type: UnifiedItem["type"]): number => {
370
338
  if (type === "local") return 0;
371
- if (type === "package") return 1;
372
- return 2;
339
+ return 1;
373
340
  };
374
341
 
375
342
  const diff = rank(a.type) - rank(b.type);
@@ -386,7 +353,7 @@ function buildSettingsItems(
386
353
  theme: Theme
387
354
  ): SettingItem[] {
388
355
  return items.map((item) => {
389
- if (item.type === "local" || item.type === "package-extension") {
356
+ if (item.type === "local") {
390
357
  const currentState = staged.get(item.id) ?? item.state!;
391
358
  const changed = staged.has(item.id) && staged.get(item.id) !== item.originalState;
392
359
  return {
@@ -406,34 +373,6 @@ function buildSettingsItems(
406
373
  });
407
374
  }
408
375
 
409
- function buildFooter(
410
- hasToggleRows: boolean,
411
- hasLocals: boolean,
412
- hasPackages: boolean,
413
- staged: Map<string, State>,
414
- byId: Map<string, UnifiedItem>
415
- ): string[] {
416
- const hasChanges = getPendingToggleChangeCount(staged, byId) > 0;
417
-
418
- const footerParts: string[] = [];
419
- footerParts.push("↑↓ Navigate");
420
- if (hasToggleRows) footerParts.push("Space/Enter Toggle");
421
- if (hasToggleRows) footerParts.push(hasChanges ? "S Save*" : "S Save");
422
- if (hasPackages) footerParts.push("Enter/A Actions");
423
- if (hasPackages) footerParts.push("u Update");
424
- if (hasPackages || hasLocals) footerParts.push("X Remove");
425
- footerParts.push("i Install");
426
- footerParts.push("f Search");
427
- footerParts.push("U Update all");
428
- footerParts.push("t Auto-update");
429
- footerParts.push("P Palette");
430
- footerParts.push("R Browse");
431
- footerParts.push("? Help");
432
- footerParts.push("Esc Cancel");
433
-
434
- return footerParts;
435
- }
436
-
437
376
  function formatUnifiedItemLabel(
438
377
  item: UnifiedItem,
439
378
  state: State,
@@ -442,34 +381,19 @@ function formatUnifiedItemLabel(
442
381
  ): string {
443
382
  if (item.type === "local") {
444
383
  const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
445
- const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
384
+ const scopeIcon = getScopeIcon(theme, item.scope);
446
385
  const changeMarker = getChangeMarker(theme, changed);
447
386
  const name = theme.bold(item.displayName);
448
387
  const summary = theme.fg("dim", item.summary);
449
388
  return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
450
389
  }
451
390
 
452
- if (item.type === "package-extension") {
453
- const statusIcon = getStatusIcon(theme, state === "enabled" ? "enabled" : "disabled");
454
- const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
455
- const sourceKind = getPackageSourceKind(item.packageSource ?? "");
456
- const pkgIcon = getPackageIcon(
457
- theme,
458
- sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
459
- );
460
- const sourceLabel = sourceKind === "unknown" ? "package" : `${sourceKind} package`;
461
- const changeMarker = getChangeMarker(theme, changed);
462
- const name = theme.bold(item.displayName);
463
- const summary = theme.fg("dim", `${item.summary} • ${sourceLabel}`);
464
- return `${statusIcon} ${pkgIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`;
465
- }
466
-
467
391
  const sourceKind = getPackageSourceKind(item.source ?? "");
468
392
  const pkgIcon = getPackageIcon(
469
393
  theme,
470
394
  sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local"
471
395
  );
472
- const scopeIcon = getScopeIcon(theme, item.scope as "global" | "project");
396
+ const scopeIcon = getScopeIcon(theme, item.scope);
473
397
  const name = theme.bold(item.displayName);
474
398
  const version = item.version ? theme.fg("dim", `@${item.version}`) : "";
475
399
  const updateBadge = item.updateAvailable ? ` ${theme.fg("warning", "[update]")}` : "";
@@ -498,26 +422,8 @@ function formatUnifiedItemLabel(
498
422
  return `${pkgIcon} [${scopeIcon}] ${name}${version}${updateBadge} - ${summary}`;
499
423
  }
500
424
 
501
- function getPendingToggleChangeCount(
502
- staged: Map<string, State>,
503
- byId: Map<string, UnifiedItem>
504
- ): number {
505
- let count = 0;
506
- for (const [id, state] of staged.entries()) {
507
- const item = byId.get(id);
508
- if (!item) continue;
509
- if (
510
- (item.type === "local" || item.type === "package-extension") &&
511
- item.originalState !== state
512
- ) {
513
- count += 1;
514
- }
515
- }
516
- return count;
517
- }
518
-
519
425
  function getToggleItemsForApply(items: UnifiedItem[]): UnifiedItem[] {
520
- return items.filter((item) => item.type === "local" || item.type === "package-extension");
426
+ return items.filter((item) => item.type === "local");
521
427
  }
522
428
 
523
429
  async function applyToggleChangesFromManager(
@@ -528,7 +434,7 @@ async function applyToggleChangesFromManager(
528
434
  options?: { promptReload?: boolean }
529
435
  ): Promise<{ changed: number; reloaded: boolean }> {
530
436
  const toggleItems = getToggleItemsForApply(items);
531
- const apply = await applyStagedChanges(toggleItems, staged, pi, ctx.cwd);
437
+ const apply = await applyStagedChanges(toggleItems, staged, pi);
532
438
 
533
439
  if (apply.errors.length > 0) {
534
440
  ctx.ui.notify(
@@ -538,7 +444,7 @@ async function applyToggleChangesFromManager(
538
444
  } else if (apply.changed === 0) {
539
445
  ctx.ui.notify("No changes to apply.", "info");
540
446
  } else {
541
- ctx.ui.notify(`Applied ${apply.changed} extension change(s).`, "info");
447
+ ctx.ui.notify(`Applied ${apply.changed} local extension change(s).`, "info");
542
448
  }
543
449
 
544
450
  if (apply.changed > 0) {
@@ -547,7 +453,7 @@ async function applyToggleChangesFromManager(
547
453
  if (shouldPromptReload) {
548
454
  const shouldReload = await ctx.ui.confirm(
549
455
  "Reload Required",
550
- "Extensions changed. Reload pi now?"
456
+ "Local extensions changed. Reload pi now?"
551
457
  );
552
458
 
553
459
  if (shouldReload) {
@@ -619,6 +525,34 @@ const QUICK_DESTINATION_LABELS: Record<QuickDestination, string> = {
619
525
  help: "Help",
620
526
  };
621
527
 
528
+ const PACKAGE_ACTION_OPTIONS = {
529
+ configure: "Configure extensions",
530
+ update: "Update package",
531
+ remove: "Remove package",
532
+ details: "View details",
533
+ back: "Back to manager",
534
+ } as const;
535
+
536
+ type PackageActionKey = keyof typeof PACKAGE_ACTION_OPTIONS;
537
+
538
+ type PackageActionSelection = Exclude<PackageActionKey, "back"> | "cancel";
539
+
540
+ async function promptPackageActionSelection(
541
+ pkg: InstalledPackage,
542
+ ctx: ExtensionCommandContext
543
+ ): Promise<PackageActionSelection> {
544
+ const selection = parseChoiceByLabel(
545
+ PACKAGE_ACTION_OPTIONS,
546
+ await ctx.ui.select(pkg.name, Object.values(PACKAGE_ACTION_OPTIONS))
547
+ );
548
+
549
+ if (!selection || selection === "back") {
550
+ return "cancel";
551
+ }
552
+
553
+ return selection;
554
+ }
555
+
622
556
  async function navigateWithPendingGuard(
623
557
  destination: QuickDestination,
624
558
  items: UnifiedItem[],
@@ -803,20 +737,29 @@ async function handleUnifiedAction(
803
737
  return false;
804
738
  }
805
739
 
806
- if (item.type === "package-extension") {
807
- return false;
808
- }
809
-
810
740
  const pkg: InstalledPackage = {
811
741
  source: item.source!,
812
742
  name: item.displayName,
813
743
  ...(item.version ? { version: item.version } : {}),
814
- scope: item.scope as "global" | "project",
744
+ scope: item.scope,
815
745
  ...(item.description ? { description: item.description } : {}),
816
746
  ...(item.size !== undefined ? { size: item.size } : {}),
817
747
  };
818
748
 
819
- switch (result.action) {
749
+ const selection =
750
+ !result.action || result.action === "menu"
751
+ ? await promptPackageActionSelection(pkg, ctx)
752
+ : result.action;
753
+
754
+ if (selection === "cancel") {
755
+ return false;
756
+ }
757
+
758
+ switch (selection) {
759
+ case "configure": {
760
+ const outcome = await configurePackageExtensions(pkg, ctx, pi);
761
+ return outcome.reloaded;
762
+ }
820
763
  case "update": {
821
764
  const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi);
822
765
  return outcome.reloaded;
@@ -833,11 +776,6 @@ async function handleUnifiedAction(
833
776
  );
834
777
  return false;
835
778
  }
836
- case "menu":
837
- default: {
838
- const exitManager = await showPackageActions(pkg, ctx, pi);
839
- return exitManager;
840
- }
841
779
  }
842
780
  }
843
781
 
@@ -848,37 +786,23 @@ async function handleUnifiedAction(
848
786
  async function applyStagedChanges(
849
787
  items: UnifiedItem[],
850
788
  staged: Map<string, State>,
851
- pi: ExtensionAPI,
852
- cwd: string
789
+ pi: ExtensionAPI
853
790
  ) {
854
791
  let changed = 0;
855
792
  const errors: string[] = [];
856
793
 
857
794
  for (const item of items) {
858
- if ((item.type !== "local" && item.type !== "package-extension") || !item.originalState) {
795
+ if (item.type !== "local" || !item.originalState || !item.activePath || !item.disabledPath) {
859
796
  continue;
860
797
  }
861
798
 
862
799
  const target = staged.get(item.id) ?? item.originalState;
863
800
  if (target === item.originalState) continue;
864
801
 
865
- let result: { ok: true } | { ok: false; error: string };
866
- if (item.type === "local") {
867
- if (!item.activePath || !item.disabledPath) continue;
868
- result = await setExtensionState(
869
- { activePath: item.activePath, disabledPath: item.disabledPath },
870
- target
871
- );
872
- } else {
873
- if (!item.packageSource || !item.extensionPath) continue;
874
- result = await setPackageExtensionState(
875
- item.packageSource,
876
- item.extensionPath,
877
- item.scope as "global" | "project",
878
- target,
879
- cwd
880
- );
881
- }
802
+ const result = await setExtensionState(
803
+ { activePath: item.activePath, disabledPath: item.disabledPath },
804
+ target
805
+ );
882
806
 
883
807
  if (result.ok) {
884
808
  changed++;
@@ -20,8 +20,7 @@ import {
20
20
  import { parseNpmSource } from "./format.js";
21
21
  import { TIMEOUTS } from "../constants.js";
22
22
 
23
- // Global timer reference (module-level singleton)
24
- let autoUpdateTimer: ReturnType<typeof setInterval> | null = null;
23
+ import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
25
24
 
26
25
  // Context provider for safe session handling
27
26
  export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined;
@@ -35,10 +34,8 @@ export function startAutoUpdateTimer(
35
34
  getCtx: ContextProvider,
36
35
  onUpdateAvailable?: (packages: string[]) => void
37
36
  ): void {
38
- // Clear existing timer
39
37
  stopAutoUpdateTimer();
40
38
 
41
- // Get fresh config from current context
42
39
  const ctx = getCtx();
43
40
  if (!ctx) return;
44
41
 
@@ -51,22 +48,20 @@ export function startAutoUpdateTimer(
51
48
  if (!interval) return;
52
49
 
53
50
  // Run an initial check immediately.
54
- void (async () => {
55
- const checkCtx = getCtx();
56
- if (!checkCtx) return;
57
- await checkForUpdates(pi, checkCtx, onUpdateAvailable);
58
- })();
51
+ const initialCtx = getCtx();
52
+ if (initialCtx) {
53
+ void checkForUpdates(pi, initialCtx, onUpdateAvailable);
54
+ }
59
55
 
60
- // Set up interval with context provider
61
- autoUpdateTimer = setInterval(() => {
56
+ // Set up recurring checks
57
+ startTimer(interval, () => {
62
58
  const checkCtx = getCtx();
63
59
  if (!checkCtx) {
64
- // Session ended, stop timer
65
60
  stopAutoUpdateTimer();
66
61
  return;
67
62
  }
68
63
  void checkForUpdates(pi, checkCtx, onUpdateAvailable);
69
- }, interval);
64
+ });
70
65
 
71
66
  // Persist that timer is running
72
67
  saveAutoUpdateConfig(pi, {
@@ -79,17 +74,14 @@ export function startAutoUpdateTimer(
79
74
  * Stop auto-update background checker
80
75
  */
81
76
  export function stopAutoUpdateTimer(): void {
82
- if (autoUpdateTimer) {
83
- clearInterval(autoUpdateTimer);
84
- autoUpdateTimer = null;
85
- }
77
+ stopTimer();
86
78
  }
87
79
 
88
80
  /**
89
81
  * Check if auto-update timer is running
90
82
  */
91
83
  export function isAutoUpdateRunning(): boolean {
92
- return autoUpdateTimer !== null;
84
+ return isTimerRunning();
93
85
  }
94
86
 
95
87
  /**
@@ -57,28 +57,43 @@ export function formatBytes(bytes: number): string {
57
57
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
58
58
  }
59
59
 
60
+ const GIT_PATTERNS = {
61
+ gitPrefix: /^git:/,
62
+ httpPrefix: /^https?:\/\//,
63
+ sshPrefix: /^ssh:\/\//,
64
+ gitProtoPrefix: /^git:\/\//,
65
+ gitSsh: /^git@[^\s:]+:.+/,
66
+ } as const;
67
+
68
+ const LOCAL_PATH_PATTERNS = {
69
+ unixAbsolute: /^\//,
70
+ unixRelative: /^\.\.?\//,
71
+ windowsRelative: /^\.\.?\\/,
72
+ homeRelative: /^~\//,
73
+ fileProto: /^file:\/\//,
74
+ windowsDrive: /^[a-zA-Z]:[\\/]/,
75
+ uncPath: /^\\\\/,
76
+ } as const;
77
+
60
78
  function isGitLikeSource(source: string): boolean {
61
79
  return (
62
- source.startsWith("git:") ||
63
- source.startsWith("http://") ||
64
- source.startsWith("https://") ||
65
- source.startsWith("ssh://") ||
66
- source.startsWith("git://") ||
67
- /^git@[^\s:]+:.+/.test(source)
80
+ GIT_PATTERNS.gitPrefix.test(source) ||
81
+ GIT_PATTERNS.httpPrefix.test(source) ||
82
+ GIT_PATTERNS.sshPrefix.test(source) ||
83
+ GIT_PATTERNS.gitProtoPrefix.test(source) ||
84
+ GIT_PATTERNS.gitSsh.test(source)
68
85
  );
69
86
  }
70
87
 
71
88
  function isLocalPathSource(source: string): boolean {
72
89
  return (
73
- source.startsWith("/") ||
74
- source.startsWith("./") ||
75
- source.startsWith("../") ||
76
- source.startsWith(".\\") ||
77
- source.startsWith("..\\") ||
78
- source.startsWith("~/") ||
79
- source.startsWith("file://") ||
80
- /^[a-zA-Z]:[\\/]/.test(source) ||
81
- source.startsWith("\\\\")
90
+ LOCAL_PATH_PATTERNS.unixAbsolute.test(source) ||
91
+ LOCAL_PATH_PATTERNS.unixRelative.test(source) ||
92
+ LOCAL_PATH_PATTERNS.windowsRelative.test(source) ||
93
+ LOCAL_PATH_PATTERNS.homeRelative.test(source) ||
94
+ LOCAL_PATH_PATTERNS.fileProto.test(source) ||
95
+ LOCAL_PATH_PATTERNS.windowsDrive.test(source) ||
96
+ LOCAL_PATH_PATTERNS.uncPath.test(source)
82
97
  );
83
98
  }
84
99
 
@@ -93,7 +108,7 @@ export function normalizePackageSource(source: string): string {
93
108
  const trimmed = source.trim();
94
109
  if (!trimmed) return trimmed;
95
110
 
96
- if (/^git@[^\s:]+:.+/.test(trimmed)) {
111
+ if (GIT_PATTERNS.gitSsh.test(trimmed)) {
97
112
  return `git:${trimmed}`;
98
113
  }
99
114
 
@@ -119,7 +119,6 @@ export function logPackageUpdate(
119
119
  packageSource: source,
120
120
  packageName: name,
121
121
  version: toVersion,
122
- scope: source.includes("node_modules") ? "global" : "project",
123
122
  success,
124
123
  error,
125
124
  });
@@ -155,15 +154,19 @@ export function logCacheClear(pi: ExtensionAPI, success: boolean, error?: string
155
154
  });
156
155
  }
157
156
 
158
- function asChangeEntry(data: unknown): ExtensionChangeEntry | undefined {
159
- if (!data || typeof data !== "object") return undefined;
157
+ function isExtensionChangeEntry(value: unknown): value is ExtensionChangeEntry {
158
+ if (!value || typeof value !== "object") return false;
160
159
 
161
- const maybe = data as Partial<ExtensionChangeEntry>;
162
- if (typeof maybe.action !== "string") return undefined;
163
- if (typeof maybe.timestamp !== "number") return undefined;
164
- if (typeof maybe.success !== "boolean") return undefined;
160
+ const maybe = value as Partial<ExtensionChangeEntry>;
161
+ if (typeof maybe.action !== "string") return false;
162
+ if (typeof maybe.timestamp !== "number") return false;
163
+ if (typeof maybe.success !== "boolean") return false;
165
164
 
166
- return maybe as ExtensionChangeEntry;
165
+ return true;
166
+ }
167
+
168
+ function asChangeEntry(data: unknown): ExtensionChangeEntry | undefined {
169
+ return isExtensionChangeEntry(data) ? data : undefined;
167
170
  }
168
171
 
169
172
  function matchesHistoryFilters(change: ExtensionChangeEntry, filters: HistoryFilters): boolean {
@@ -235,6 +238,10 @@ export function querySessionChanges(
235
238
  return applyHistoryFilters(getAllSessionChanges(ctx), filters);
236
239
  }
237
240
 
241
+ function isRecord(value: unknown): value is Record<string, unknown> {
242
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
243
+ }
244
+
238
245
  async function walkSessionFiles(dir: string): Promise<string[]> {
239
246
  const result: string[] = [];
240
247
 
@@ -284,18 +291,17 @@ export async function queryGlobalHistory(
284
291
  continue;
285
292
  }
286
293
 
287
- if (!parsed || typeof parsed !== "object") continue;
288
- const entry = parsed as {
289
- type?: string;
290
- customType?: string;
291
- data?: unknown;
292
- };
294
+ if (!isRecord(parsed)) continue;
293
295
 
294
- if (entry.type !== "custom" || entry.customType !== EXT_CHANGE_CUSTOM_TYPE || !entry.data) {
296
+ if (
297
+ parsed.type !== "custom" ||
298
+ parsed.customType !== EXT_CHANGE_CUSTOM_TYPE ||
299
+ !parsed.data
300
+ ) {
295
301
  continue;
296
302
  }
297
303
 
298
- const change = asChangeEntry(entry.data);
304
+ const change = asChangeEntry(parsed.data);
299
305
  if (change) {
300
306
  all.push({ change, sessionFile: file });
301
307
  }