pi-extmgr 0.1.28 → 0.2.0

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.
@@ -299,6 +299,41 @@ async function walkSessionFiles(dir: string): Promise<string[]> {
299
299
  return result;
300
300
  }
301
301
 
302
+ function pushGlobalHistoryEntry(
303
+ entries: GlobalHistoryEntry[],
304
+ nextEntry: GlobalHistoryEntry,
305
+ limit: number | undefined
306
+ ): void {
307
+ if (!limit || limit <= 0) {
308
+ entries.push(nextEntry);
309
+ return;
310
+ }
311
+
312
+ if (entries.length < limit) {
313
+ entries.push(nextEntry);
314
+ return;
315
+ }
316
+
317
+ let oldestIndex = 0;
318
+ let oldestEntry = entries[oldestIndex];
319
+ if (!oldestEntry) {
320
+ entries.push(nextEntry);
321
+ return;
322
+ }
323
+
324
+ for (let index = 1; index < entries.length; index++) {
325
+ const entry = entries[index];
326
+ if (entry && entry.change.timestamp < oldestEntry.change.timestamp) {
327
+ oldestIndex = index;
328
+ oldestEntry = entry;
329
+ }
330
+ }
331
+
332
+ if (oldestEntry.change.timestamp <= nextEntry.change.timestamp) {
333
+ entries[oldestIndex] = nextEntry;
334
+ }
335
+ }
336
+
302
337
  /**
303
338
  * Query change history across all persisted pi sessions.
304
339
  */
@@ -307,7 +342,8 @@ export async function queryGlobalHistory(
307
342
  sessionDir = DEFAULT_SESSION_DIR
308
343
  ): Promise<GlobalHistoryEntry[]> {
309
344
  const files = await walkSessionFiles(sessionDir);
310
- const all: GlobalHistoryEntry[] = [];
345
+ const matchedEntries: GlobalHistoryEntry[] = [];
346
+ const limit = filters.limit ?? 20;
311
347
 
312
348
  for (const file of files) {
313
349
  let text: string;
@@ -337,16 +373,16 @@ export async function queryGlobalHistory(
337
373
  }
338
374
 
339
375
  const change = asChangeEntry(parsed.data);
340
- if (change) {
341
- all.push({ change, sessionFile: file });
376
+ if (!change || !matchesHistoryFilters(change, filters)) {
377
+ continue;
342
378
  }
379
+
380
+ pushGlobalHistoryEntry(matchedEntries, { change, sessionFile: file }, limit);
343
381
  }
344
382
  }
345
383
 
346
- all.sort((a, b) => a.change.timestamp - b.change.timestamp);
347
-
348
- const filtered = all.filter((entry) => matchesHistoryFilters(entry.change, filters));
349
- return applyHistoryLimit(filtered, filters);
384
+ matchedEntries.sort((a, b) => a.change.timestamp - b.change.timestamp);
385
+ return matchedEntries;
350
386
  }
351
387
 
352
388
  /**
package/src/utils/mode.ts CHANGED
@@ -66,7 +66,7 @@ export async function runCustomUI<T>(
66
66
  }
67
67
 
68
68
  const suffix = fallbackMessage ? ` ${fallbackMessage}` : "";
69
- notify(ctx, `${featureName} requires the full interactive TUI.${suffix}`, "warning");
69
+ notify(ctx, `${featureName} is unavailable in the current UI mode.${suffix}`, "warning");
70
70
  return undefined;
71
71
  }
72
72
 
@@ -34,17 +34,3 @@ export function success(ctx: ExtensionCommandContext | ExtensionContext, message
34
34
  export function error(ctx: ExtensionCommandContext | ExtensionContext, message: string): void {
35
35
  notify(ctx, message, "error");
36
36
  }
37
-
38
- /**
39
- * Show warning message
40
- */
41
- export function warning(ctx: ExtensionCommandContext | ExtensionContext, message: string): void {
42
- notify(ctx, message, "warning");
43
- }
44
-
45
- /**
46
- * Show info message
47
- */
48
- export function info(ctx: ExtensionCommandContext | ExtensionContext, message: string): void {
49
- notify(ctx, message, "info");
50
- }
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join, resolve as resolvePath } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { parseNpmSource } from "./format.js";
8
+ import { normalizePathIdentity } from "./path-identity.js";
8
9
 
9
10
  export type PackageSourceKind = "npm" | "git" | "local" | "unknown";
10
11
 
@@ -52,11 +53,7 @@ export function getPackageSourceKind(source: string): PackageSourceKind {
52
53
  }
53
54
 
54
55
  export function normalizeLocalSourceIdentity(source: string): string {
55
- const normalized = source.replace(/\\/g, "/");
56
- const looksWindowsPath =
57
- /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || source.includes("\\");
58
-
59
- return looksWindowsPath ? normalized.toLowerCase() : normalized;
56
+ return normalizePathIdentity(source);
60
57
  }
61
58
 
62
59
  export function stripGitSourcePrefix(source: string): string {
@@ -0,0 +1,7 @@
1
+ export function normalizePathIdentity(value: string): string {
2
+ const normalized = value.replace(/\\/g, "/");
3
+ const looksWindowsPath =
4
+ /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\");
5
+
6
+ return looksWindowsPath ? normalized.toLowerCase() : normalized;
7
+ }
@@ -0,0 +1,100 @@
1
+ import { matchesGlob } from "node:path";
2
+
3
+ export function normalizeRelativePath(value: string): string {
4
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
5
+ }
6
+
7
+ export function hasGlobMagic(path: string): boolean {
8
+ return /[*?{}[\]]/.test(path);
9
+ }
10
+
11
+ export function isSafeRelativePath(path: string): boolean {
12
+ const normalizedPath = path.replace(/\\/g, "/");
13
+
14
+ return (
15
+ normalizedPath !== "" &&
16
+ normalizedPath !== ".." &&
17
+ !normalizedPath.startsWith("/") &&
18
+ !path.startsWith("\\") &&
19
+ !/^[A-Za-z]:/.test(normalizedPath) &&
20
+ !normalizedPath.startsWith("../") &&
21
+ !normalizedPath.includes("/../") &&
22
+ !normalizedPath.endsWith("/..")
23
+ );
24
+ }
25
+
26
+ export function safeMatchesGlob(targetPath: string, pattern: string): boolean {
27
+ try {
28
+ return matchesGlob(targetPath, pattern);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function matchesFilterPattern(targetPath: string, pattern: string): boolean {
35
+ const normalizedPattern = normalizeRelativePath(pattern.trim());
36
+ if (!normalizedPattern) return false;
37
+ if (targetPath === normalizedPattern) return true;
38
+
39
+ return safeMatchesGlob(targetPath, normalizedPattern);
40
+ }
41
+
42
+ export function selectDirectoryFiles(allFiles: readonly string[], directoryPath: string): string[] {
43
+ const prefix = `${directoryPath}/`;
44
+ return allFiles.filter((file) => file.startsWith(prefix));
45
+ }
46
+
47
+ export function applySelection(
48
+ selected: Set<string>,
49
+ files: Iterable<string>,
50
+ exclude: boolean
51
+ ): void {
52
+ for (const file of files) {
53
+ if (exclude) {
54
+ selected.delete(file);
55
+ } else {
56
+ selected.add(file);
57
+ }
58
+ }
59
+ }
60
+
61
+ export function resolveRelativePathSelection(
62
+ allFiles: readonly string[],
63
+ entries: readonly string[],
64
+ isExactPathSelectable: (path: string, allFiles: readonly string[]) => boolean
65
+ ): string[] {
66
+ const selected = new Set<string>();
67
+
68
+ for (const rawToken of entries) {
69
+ const token = rawToken.trim();
70
+ if (!token) continue;
71
+
72
+ const exclude = token.startsWith("!");
73
+ const normalizedToken = normalizeRelativePath(exclude ? token.slice(1) : token);
74
+ const pattern = normalizedToken.replace(/[\\/]+$/g, "");
75
+ if (!isSafeRelativePath(pattern)) {
76
+ continue;
77
+ }
78
+
79
+ if (hasGlobMagic(pattern)) {
80
+ applySelection(
81
+ selected,
82
+ allFiles.filter((file) => matchesFilterPattern(file, pattern)),
83
+ exclude
84
+ );
85
+ continue;
86
+ }
87
+
88
+ const directoryFiles = selectDirectoryFiles(allFiles, pattern);
89
+ if (directoryFiles.length > 0) {
90
+ applySelection(selected, directoryFiles, exclude);
91
+ continue;
92
+ }
93
+
94
+ if (isExactPathSelectable(pattern, allFiles)) {
95
+ applySelection(selected, [pattern], exclude);
96
+ }
97
+ }
98
+
99
+ return Array.from(selected).sort((a, b) => a.localeCompare(b));
100
+ }
@@ -11,6 +11,7 @@ import {
11
11
  type ExtensionCommandContext,
12
12
  type ExtensionContext,
13
13
  } from "@mariozechner/pi-coding-agent";
14
+ import { parseScheduleDuration } from "./duration.js";
14
15
  import { fileExists } from "./fs.js";
15
16
  import { normalizePackageIdentity } from "./package-source.js";
16
17
 
@@ -304,71 +305,11 @@ export function clearUpdatesAvailable(
304
305
  }
305
306
 
306
307
  /**
307
- * Parse duration string to milliseconds
308
- * Supports: 1h, 2h, 1d, 7d, 1m, 3m, etc.
309
- * Also supports: never, off, disable, daily, weekly
308
+ * Parse schedule duration strings for auto-update settings.
309
+ * Supports hours/days/weeks/months plus schedule aliases like `daily`, `weekly`, and `never`.
310
310
  */
311
311
  export function parseDuration(input: string): { ms: number; display: string } | undefined {
312
- const normalized = input.toLowerCase().trim();
313
-
314
- // Special cases for disabling
315
- if (normalized === "never" || normalized === "off" || normalized === "disable") {
316
- return { ms: 0, display: "off" };
317
- }
318
-
319
- // Named schedules
320
- if (normalized === "daily" || normalized === "day" || normalized === "1d") {
321
- return { ms: 24 * 60 * 60 * 1000, display: "daily" };
322
- }
323
- if (normalized === "weekly" || normalized === "week" || normalized === "1w") {
324
- return { ms: 7 * 24 * 60 * 60 * 1000, display: "weekly" };
325
- }
326
-
327
- // Parse duration patterns: 1h, 2h, 3d, 7d, 1m, etc.
328
- const durationMatch = normalized.match(
329
- /^(\d+)\s*(h|hr|hrs|hour|hours|d|day|days|w|wk|wks|week|weeks|m|mo|mos|month|months)$/
330
- );
331
- if (durationMatch) {
332
- const [, rawValue, rawUnit] = durationMatch;
333
- if (!rawValue || !rawUnit) {
334
- return undefined;
335
- }
336
-
337
- const value = Number.parseInt(rawValue, 10);
338
- const unit = rawUnit[0];
339
- if (!unit) {
340
- return undefined;
341
- }
342
-
343
- let ms: number;
344
- let display: string;
345
-
346
- switch (unit) {
347
- case "h":
348
- ms = value * 60 * 60 * 1000;
349
- display = value === 1 ? "1 hour" : `${value} hours`;
350
- break;
351
- case "d":
352
- ms = value * 24 * 60 * 60 * 1000;
353
- display = value === 1 ? "1 day" : `${value} days`;
354
- break;
355
- case "w":
356
- ms = value * 7 * 24 * 60 * 60 * 1000;
357
- display = value === 1 ? "1 week" : `${value} weeks`;
358
- break;
359
- case "m":
360
- // Approximate months as 30 days
361
- ms = value * 30 * 24 * 60 * 60 * 1000;
362
- display = value === 1 ? "1 month" : `${value} months`;
363
- break;
364
- default:
365
- return undefined;
366
- }
367
-
368
- return { ms, display };
369
- }
370
-
371
- return undefined;
312
+ return parseScheduleDuration(input);
372
313
  }
373
314
 
374
315
  /**
@@ -1,49 +0,0 @@
1
- /**
2
- * Retry utilities for async operations
3
- */
4
-
5
- export interface RetryOptions {
6
- maxAttempts?: number;
7
- delayMs?: number;
8
- backoff?: "fixed" | "linear" | "exponential";
9
- }
10
-
11
- export async function retryWithBackoff<T>(
12
- operation: () => Promise<T | undefined>,
13
- options: RetryOptions = {}
14
- ): Promise<T | undefined> {
15
- const { maxAttempts = 5, delayMs = 100, backoff = "exponential" } = options;
16
-
17
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18
- const result = await operation();
19
- if (result !== undefined) {
20
- return result;
21
- }
22
-
23
- if (attempt < maxAttempts) {
24
- const delay =
25
- backoff === "exponential"
26
- ? delayMs * 2 ** (attempt - 1)
27
- : backoff === "linear"
28
- ? delayMs * attempt
29
- : delayMs;
30
- await new Promise((resolve) => setTimeout(resolve, delay));
31
- }
32
- }
33
-
34
- return undefined;
35
- }
36
-
37
- /**
38
- * Wait for a condition to be true with timeout
39
- */
40
- export async function waitForCondition(
41
- condition: () => Promise<boolean> | boolean,
42
- options: RetryOptions = {}
43
- ): Promise<boolean> {
44
- const result = await retryWithBackoff(async () => {
45
- const value = await condition();
46
- return value ? true : undefined;
47
- }, options);
48
- return result === true;
49
- }