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.
@@ -19,6 +19,8 @@ import {
19
19
  } from "./settings.js";
20
20
  import { parseNpmSource } from "./format.js";
21
21
  import { execNpm } from "./npm-exec.js";
22
+ import { normalizePackageIdentity } from "./package-source.js";
23
+ import { logAutoUpdateConfig } from "./history.js";
22
24
  import { TIMEOUTS } from "../constants.js";
23
25
 
24
26
  import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
@@ -26,6 +28,10 @@ import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
26
28
  // Context provider for safe session handling
27
29
  export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined;
28
30
 
31
+ function getUpdateIdentity(pkg: InstalledPackage): string {
32
+ return normalizePackageIdentity(pkg.source);
33
+ }
34
+
29
35
  /**
30
36
  * Start auto-update background checker
31
37
  * Uses a context provider to avoid stale context issues when sessions switch
@@ -48,27 +54,23 @@ export function startAutoUpdateTimer(
48
54
  const interval = getScheduleInterval(config);
49
55
  if (!interval) return;
50
56
 
51
- // Run an initial check immediately.
52
- const initialCtx = getCtx();
53
- if (initialCtx) {
54
- void checkForUpdates(pi, initialCtx, onUpdateAvailable);
55
- }
56
-
57
- // Set up recurring checks
58
- startTimer(interval, () => {
59
- const checkCtx = getCtx();
60
- if (!checkCtx) {
61
- stopAutoUpdateTimer();
62
- return;
63
- }
64
- void checkForUpdates(pi, checkCtx, onUpdateAvailable);
65
- });
66
-
67
- // Persist that timer is running
68
- saveAutoUpdateConfig(pi, {
69
- ...config,
70
- nextCheck: calculateNextCheck(config.intervalMs),
71
- });
57
+ const now = Date.now();
58
+ const nextCheck = config.nextCheck;
59
+ const initialDelayMs =
60
+ typeof nextCheck === "number" && nextCheck > now ? Math.max(0, nextCheck - now) : 0;
61
+
62
+ startTimer(
63
+ interval,
64
+ () => {
65
+ const checkCtx = getCtx();
66
+ if (!checkCtx) {
67
+ stopAutoUpdateTimer();
68
+ return;
69
+ }
70
+ void checkForUpdates(pi, checkCtx, onUpdateAvailable);
71
+ },
72
+ { initialDelayMs }
73
+ );
72
74
  }
73
75
 
74
76
  /**
@@ -98,28 +100,30 @@ export async function checkForUpdates(
98
100
  const npmPackages = packages.filter((p) => p.source.startsWith("npm:"));
99
101
 
100
102
  const updatesAvailable: string[] = [];
103
+ const updatedPackageNames: string[] = [];
101
104
 
102
105
  for (const pkg of npmPackages) {
103
106
  const hasUpdate = await checkPackageUpdate(pkg, ctx, pi);
104
107
  if (hasUpdate) {
105
- updatesAvailable.push(pkg.name);
108
+ updatesAvailable.push(getUpdateIdentity(pkg));
109
+ updatedPackageNames.push(pkg.name);
106
110
  }
107
111
  }
108
112
 
109
- // Update last check time
113
+ const checkedAt = Date.now();
110
114
  const config = getAutoUpdateConfig(ctx);
111
115
  saveAutoUpdateConfig(pi, {
112
116
  ...config,
113
- lastCheck: Date.now(),
117
+ lastCheck: checkedAt,
114
118
  nextCheck: calculateNextCheck(config.intervalMs),
115
119
  updatesAvailable,
116
120
  });
117
121
 
118
- if (updatesAvailable.length > 0 && onUpdateAvailable) {
119
- onUpdateAvailable(updatesAvailable);
122
+ if (updatedPackageNames.length > 0 && onUpdateAvailable) {
123
+ onUpdateAvailable(updatedPackageNames);
120
124
  }
121
125
 
122
- return updatesAvailable;
126
+ return updatedPackageNames;
123
127
  }
124
128
 
125
129
  /**
@@ -168,7 +172,7 @@ export function getAutoUpdateStatus(ctx: ExtensionCommandContext | ExtensionCont
168
172
  }
169
173
 
170
174
  /**
171
- * Return package names currently known to have updates available
175
+ * Return normalized package identities currently known to have updates available
172
176
  * (from the latest background check).
173
177
  */
174
178
  export function getKnownUpdates(ctx: ExtensionCommandContext | ExtensionContext): Set<string> {
@@ -251,12 +255,12 @@ export function enableAutoUpdate(
251
255
  intervalMs,
252
256
  enabled: true,
253
257
  displayText,
254
- lastCheck: Date.now(),
255
258
  nextCheck: calculateNextCheck(intervalMs),
256
259
  updatesAvailable: [],
257
260
  };
258
261
 
259
262
  saveAutoUpdateConfig(pi, config);
263
+ logAutoUpdateConfig(pi, `set to ${displayText}`, true);
260
264
 
261
265
  const getCtx: ContextProvider = () => ctx;
262
266
 
@@ -280,6 +284,7 @@ export function disableAutoUpdate(
280
284
  displayText: "off",
281
285
  updatesAvailable: [],
282
286
  });
287
+ logAutoUpdateConfig(pi, "disabled", true);
283
288
 
284
289
  notify(ctx, "Auto-update disabled", "info");
285
290
  }
@@ -9,10 +9,12 @@ import { join } from "node:path";
9
9
 
10
10
  export type ChangeAction =
11
11
  | "extension_toggle"
12
+ | "extension_delete"
12
13
  | "package_install"
13
14
  | "package_update"
14
15
  | "package_remove"
15
- | "cache_clear";
16
+ | "cache_clear"
17
+ | "auto_update_config";
16
18
 
17
19
  export interface ExtensionChangeEntry {
18
20
  action: ChangeAction;
@@ -26,6 +28,7 @@ export interface ExtensionChangeEntry {
26
28
  packageName?: string | undefined;
27
29
  version?: string | undefined;
28
30
  scope?: "global" | "project" | undefined;
31
+ detail?: string | undefined;
29
32
  // Result
30
33
  success: boolean;
31
34
  error?: string | undefined;
@@ -80,6 +83,34 @@ export function logExtensionToggle(
80
83
  });
81
84
  }
82
85
 
86
+ export function logExtensionDelete(
87
+ pi: ExtensionAPI,
88
+ extensionId: string,
89
+ success: boolean,
90
+ error?: string
91
+ ): void {
92
+ logChange(pi, {
93
+ action: "extension_delete",
94
+ extensionId,
95
+ success,
96
+ error,
97
+ });
98
+ }
99
+
100
+ export function logAutoUpdateConfig(
101
+ pi: ExtensionAPI,
102
+ detail: string,
103
+ success: boolean,
104
+ error?: string
105
+ ): void {
106
+ logChange(pi, {
107
+ action: "auto_update_config",
108
+ detail,
109
+ success,
110
+ error,
111
+ });
112
+ }
113
+
83
114
  /**
84
115
  * Log package installation
85
116
  */
@@ -180,10 +211,12 @@ function matchesHistoryFilters(change: ExtensionChangeEntry, filters: HistoryFil
180
211
  const packageName = change.packageName?.toLowerCase() ?? "";
181
212
  const packageSource = change.packageSource?.toLowerCase() ?? "";
182
213
  const extensionId = change.extensionId?.toLowerCase() ?? "";
214
+ const detail = change.detail?.toLowerCase() ?? "";
183
215
  if (
184
216
  !packageName.includes(packageQuery) &&
185
217
  !packageSource.includes(packageQuery) &&
186
- !extensionId.includes(packageQuery)
218
+ !extensionId.includes(packageQuery) &&
219
+ !detail.includes(packageQuery)
187
220
  ) {
188
221
  return false;
189
222
  }
@@ -330,6 +363,9 @@ export function formatChangeEntry(entry: ExtensionChangeEntry): string {
330
363
  case "extension_toggle":
331
364
  return `[${time}] ${icon} ${entry.extensionId}: ${entry.fromState} → ${entry.toState}`;
332
365
 
366
+ case "extension_delete":
367
+ return `[${time}] ${icon} Deleted ${entry.extensionId ?? "extension"}`;
368
+
333
369
  case "package_install":
334
370
  return `[${time}] ${icon} Installed ${packageLabel}${entry.version ? `@${entry.version}` : ""}${sourceSuffix}`;
335
371
 
@@ -342,6 +378,9 @@ export function formatChangeEntry(entry: ExtensionChangeEntry): string {
342
378
  case "cache_clear":
343
379
  return `[${time}] ${icon} Cache cleared`;
344
380
 
381
+ case "auto_update_config":
382
+ return `[${time}] ${icon} Auto-update ${entry.detail ?? "configuration changed"}`;
383
+
345
384
  default:
346
385
  return `[${time}] ${icon} Unknown action`;
347
386
  }
package/src/utils/mode.ts CHANGED
@@ -1,12 +1,25 @@
1
1
  /**
2
- * UI vs non-UI mode abstractions
2
+ * UI capability helpers
3
3
  */
4
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
4
+ import type { ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import { notify } from "./notify.js";
6
6
 
7
- /**
8
- * Check if operation can proceed in current mode
9
- */
7
+ type AnyContext = ExtensionCommandContext | ExtensionContext;
8
+
9
+ export type UICapability = "none" | "dialog" | "custom";
10
+
11
+ export function getUICapability(ctx: AnyContext): UICapability {
12
+ if (!ctx.hasUI) {
13
+ return "none";
14
+ }
15
+
16
+ return typeof ctx.ui?.custom === "function" ? "custom" : "dialog";
17
+ }
18
+
19
+ export function hasCustomUI(ctx: AnyContext): boolean {
20
+ return getUICapability(ctx) === "custom";
21
+ }
22
+
10
23
  export function requireUI(ctx: ExtensionCommandContext, featureName: string): boolean {
11
24
  if (!ctx.hasUI) {
12
25
  notify(
@@ -19,6 +32,44 @@ export function requireUI(ctx: ExtensionCommandContext, featureName: string): bo
19
32
  return true;
20
33
  }
21
34
 
35
+ export function requireCustomUI(
36
+ ctx: AnyContext,
37
+ featureName: string,
38
+ fallbackMessage?: string
39
+ ): boolean {
40
+ if (hasCustomUI(ctx)) {
41
+ return true;
42
+ }
43
+
44
+ const suffix = fallbackMessage ? ` ${fallbackMessage}` : "";
45
+ if (ctx.hasUI) {
46
+ notify(ctx, `${featureName} requires the full interactive TUI.${suffix}`, "warning");
47
+ } else {
48
+ notify(ctx, `${featureName} requires interactive mode.${suffix}`, "warning");
49
+ }
50
+ return false;
51
+ }
52
+
53
+ export async function runCustomUI<T>(
54
+ ctx: AnyContext,
55
+ featureName: string,
56
+ open: () => Promise<T | undefined>,
57
+ fallbackMessage?: string
58
+ ): Promise<T | undefined> {
59
+ if (!requireCustomUI(ctx, featureName, fallbackMessage)) {
60
+ return undefined;
61
+ }
62
+
63
+ const result = await open();
64
+ if (result !== undefined) {
65
+ return result;
66
+ }
67
+
68
+ const suffix = fallbackMessage ? ` ${fallbackMessage}` : "";
69
+ notify(ctx, `${featureName} requires the full interactive TUI.${suffix}`, "warning");
70
+ return undefined;
71
+ }
72
+
22
73
  /**
23
74
  * Execute operation with automatic error handling
24
75
  */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Package source parsing helpers shared across discovery/management flows.
3
3
  */
4
+ import { parseNpmSource } from "./format.js";
4
5
 
5
6
  export type PackageSourceKind = "npm" | "git" | "local" | "unknown";
6
7
 
@@ -55,6 +56,36 @@ export function normalizeLocalSourceIdentity(source: string): string {
55
56
  return looksWindowsPath ? normalized.toLowerCase() : normalized;
56
57
  }
57
58
 
59
+ export function stripGitSourcePrefix(source: string): string {
60
+ const withoutGitPlus = source.startsWith("git+") ? source.slice(4) : source;
61
+ return withoutGitPlus.startsWith("git:") ? withoutGitPlus.slice(4) : withoutGitPlus;
62
+ }
63
+
64
+ export function normalizePackageIdentity(
65
+ source: string,
66
+ options?: { resolvedPath?: string }
67
+ ): string {
68
+ const normalized = sanitizeSource(source);
69
+ const kind = getPackageSourceKind(normalized);
70
+
71
+ if (kind === "npm") {
72
+ const npm = parseNpmSource(normalized);
73
+ return `npm:${(npm?.name ?? normalized).toLowerCase()}`;
74
+ }
75
+
76
+ if (kind === "git") {
77
+ const gitSpec = stripGitSourcePrefix(normalized);
78
+ const { repo } = splitGitRepoAndRef(gitSpec);
79
+ return `git:${repo.replace(/\\/g, "/").toLowerCase()}`;
80
+ }
81
+
82
+ if (kind === "local") {
83
+ return `local:${normalizeLocalSourceIdentity(options?.resolvedPath ?? normalized)}`;
84
+ }
85
+
86
+ return `raw:${normalized.replace(/\\/g, "/").toLowerCase()}`;
87
+ }
88
+
58
89
  export function splitGitRepoAndRef(gitSpec: string): { repo: string; ref?: string | undefined } {
59
90
  const lastAt = gitSpec.lastIndexOf("@");
60
91
  if (lastAt <= 0) {
@@ -0,0 +1,12 @@
1
+ interface SelectableListLike {
2
+ selectedIndex?: number;
3
+ }
4
+
5
+ export function getSettingsListSelectedIndex(settingsList: unknown): number | undefined {
6
+ if (!settingsList || typeof settingsList !== "object") {
7
+ return undefined;
8
+ }
9
+
10
+ const selectable = settingsList as SelectableListLike;
11
+ return Number.isInteger(selectable.selectedIndex) ? selectable.selectedIndex : undefined;
12
+ }
@@ -11,6 +11,7 @@ import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { fileExists } from "./fs.js";
14
+ import { normalizePackageIdentity } from "./package-source.js";
14
15
 
15
16
  export interface AutoUpdateConfig {
16
17
  intervalMs: number;
@@ -49,6 +50,20 @@ function sanitizeStringArray(value: unknown): string[] | undefined {
49
50
  return sanitized.length > 0 ? sanitized : undefined;
50
51
  }
51
52
 
53
+ function isUpdateIdentity(value: string): boolean {
54
+ return /^(npm|git|local|raw):/i.test(value);
55
+ }
56
+
57
+ function sanitizeUpdateIdentities(value: unknown): string[] | undefined {
58
+ const updates = sanitizeStringArray(value);
59
+ if (!updates) return undefined;
60
+
61
+ const sanitized = updates
62
+ .filter(isUpdateIdentity)
63
+ .map((entry) => normalizePackageIdentity(entry));
64
+ return sanitized.length > 0 ? sanitized : undefined;
65
+ }
66
+
52
67
  function sanitizeAutoUpdateConfig(input: unknown): AutoUpdateConfig {
53
68
  if (!isRecord(input)) {
54
69
  return { ...DEFAULT_CONFIG };
@@ -85,7 +100,7 @@ function sanitizeAutoUpdateConfig(input: unknown): AutoUpdateConfig {
85
100
  config.nextCheck = input.nextCheck;
86
101
  }
87
102
 
88
- const updates = sanitizeStringArray(input.updatesAvailable);
103
+ const updates = sanitizeUpdateIdentities(input.updatesAvailable);
89
104
  if (updates) {
90
105
  config.updatesAvailable = updates;
91
106
  }
@@ -263,15 +278,28 @@ export function saveAutoUpdateConfig(pi: ExtensionAPI, config: Partial<AutoUpdat
263
278
  */
264
279
  export function clearUpdatesAvailable(
265
280
  pi: ExtensionAPI,
266
- ctx: ExtensionCommandContext | ExtensionContext
281
+ ctx: ExtensionCommandContext | ExtensionContext,
282
+ identities?: Iterable<string>
267
283
  ): void {
268
284
  const config = getAutoUpdateConfig(ctx);
269
- if (config.updatesAvailable && config.updatesAvailable.length > 0) {
270
- saveAutoUpdateConfig(pi, {
271
- ...config,
272
- updatesAvailable: [],
273
- });
285
+ const currentUpdates = config.updatesAvailable ?? [];
286
+ if (currentUpdates.length === 0) {
287
+ return;
288
+ }
289
+
290
+ const clearedIdentities = identities ? new Set(identities) : undefined;
291
+ const updatesAvailable = clearedIdentities
292
+ ? currentUpdates.filter((identity) => !clearedIdentities.has(identity))
293
+ : [];
294
+
295
+ if (updatesAvailable.length === currentUpdates.length) {
296
+ return;
274
297
  }
298
+
299
+ saveAutoUpdateConfig(pi, {
300
+ ...config,
301
+ updatesAvailable,
302
+ });
275
303
  }
276
304
 
277
305
  /**
@@ -8,14 +8,22 @@ import type {
8
8
  } from "@mariozechner/pi-coding-agent";
9
9
  import { getInstalledPackages } from "../packages/discovery.js";
10
10
  import { getAutoUpdateStatus } from "./auto-update.js";
11
+ import { normalizePackageIdentity } from "./package-source.js";
11
12
  import { getAutoUpdateConfigAsync, saveAutoUpdateConfig } from "./settings.js";
12
13
 
13
14
  function filterStaleUpdates(
14
15
  knownUpdates: string[],
15
16
  installedPackages: Awaited<ReturnType<typeof getInstalledPackages>>
16
17
  ): string[] {
17
- const installedNames = new Set(installedPackages.map((p) => p.name));
18
- return knownUpdates.filter((name) => installedNames.has(name));
18
+ const installedIdentities = new Set(
19
+ installedPackages.map((pkg) =>
20
+ normalizePackageIdentity(
21
+ pkg.source,
22
+ pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
23
+ )
24
+ )
25
+ );
26
+ return knownUpdates.filter((identity) => installedIdentities.has(identity));
19
27
  }
20
28
 
21
29
  export async function updateExtmgrStatus(
@@ -4,33 +4,57 @@
4
4
 
5
5
  export type TimerCallback = () => void;
6
6
 
7
- let timerId: ReturnType<typeof setInterval> | null = null;
7
+ let intervalId: ReturnType<typeof setInterval> | null = null;
8
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
8
9
 
9
10
  /**
10
11
  * Start a recurring timer with the given interval and callback.
11
12
  * Clears any existing timer first.
12
13
  */
13
- export function startTimer(intervalMs: number, callback: TimerCallback): void {
14
+ export function startTimer(
15
+ intervalMs: number,
16
+ callback: TimerCallback,
17
+ options?: { initialDelayMs?: number }
18
+ ): void {
14
19
  stopTimer();
15
20
 
16
21
  if (intervalMs <= 0) return;
17
22
 
18
- timerId = setInterval(callback, intervalMs);
23
+ const runAndReschedule = (): void => {
24
+ intervalId = setInterval(callback, intervalMs);
25
+ callback();
26
+ };
27
+
28
+ const initialDelayMs = options?.initialDelayMs ?? 0;
29
+ if (initialDelayMs <= 0) {
30
+ runAndReschedule();
31
+ return;
32
+ }
33
+
34
+ timeoutId = setTimeout(() => {
35
+ timeoutId = null;
36
+ runAndReschedule();
37
+ }, initialDelayMs);
19
38
  }
20
39
 
21
40
  /**
22
41
  * Stop the current timer if running.
23
42
  */
24
43
  export function stopTimer(): void {
25
- if (!timerId) return;
26
-
27
- clearInterval(timerId);
28
- timerId = null;
44
+ if (timeoutId) {
45
+ clearTimeout(timeoutId);
46
+ timeoutId = null;
47
+ }
48
+
49
+ if (intervalId) {
50
+ clearInterval(intervalId);
51
+ intervalId = null;
52
+ }
29
53
  }
30
54
 
31
55
  /**
32
56
  * Check if a timer is currently running.
33
57
  */
34
58
  export function isTimerRunning(): boolean {
35
- return timerId !== null;
59
+ return timeoutId !== null || intervalId !== null;
36
60
  }
@@ -67,9 +67,10 @@ export function formatListOutput(
67
67
  }
68
68
 
69
69
  const output = items.join("\n");
70
+ const titledOutput = `${title}:\n${output}`;
70
71
 
71
72
  if (ctx.hasUI) {
72
- ctx.ui.notify(output, "info");
73
+ ctx.ui.notify(titledOutput, "info");
73
74
  } else {
74
75
  console.log(`${title}:`);
75
76
  console.log(output);