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.
- package/README.md +18 -8
- package/package.json +10 -2
- package/src/commands/auto-update.ts +1 -1
- package/src/commands/history.ts +2 -31
- package/src/constants.ts +0 -8
- package/src/extensions/discovery.ts +121 -39
- package/src/packages/discovery.ts +34 -0
- package/src/packages/extensions.ts +55 -98
- package/src/packages/install.ts +85 -56
- package/src/packages/management.ts +25 -38
- package/src/types/index.ts +4 -2
- package/src/ui/footer.ts +49 -29
- package/src/ui/help.ts +15 -11
- package/src/ui/remote.ts +704 -112
- package/src/ui/unified.ts +922 -311
- package/src/utils/auto-update.ts +34 -29
- package/src/utils/cache.ts +205 -34
- package/src/utils/duration.ts +132 -0
- package/src/utils/format.ts +0 -30
- package/src/utils/fs.ts +8 -4
- package/src/utils/history.ts +43 -7
- package/src/utils/mode.ts +1 -1
- package/src/utils/notify.ts +0 -14
- package/src/utils/package-source.ts +2 -5
- package/src/utils/path-identity.ts +7 -0
- package/src/utils/relative-path-selection.ts +100 -0
- package/src/utils/settings.ts +4 -63
- package/src/utils/retry.ts +0 -49
package/src/utils/auto-update.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type ExtensionContext,
|
|
8
8
|
} from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import { getPackageCatalog } from "../packages/catalog.js";
|
|
10
|
+
import { parseChoiceByLabel } from "./command.js";
|
|
10
11
|
import { logAutoUpdateConfig } from "./history.js";
|
|
11
12
|
import { notify } from "./notify.js";
|
|
12
13
|
import { normalizePackageIdentity } from "./package-source.js";
|
|
@@ -21,6 +22,15 @@ import {
|
|
|
21
22
|
|
|
22
23
|
import { isTimerRunning, startTimer, stopTimer } from "./timer.js";
|
|
23
24
|
|
|
25
|
+
const AUTO_UPDATE_WIZARD_CHOICES = {
|
|
26
|
+
off: "Off",
|
|
27
|
+
hour: "Every hour",
|
|
28
|
+
daily: "Daily",
|
|
29
|
+
weekly: "Weekly",
|
|
30
|
+
custom: "Custom...",
|
|
31
|
+
cancel: "Cancel",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
24
34
|
// Context provider for safe session handling
|
|
25
35
|
export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined;
|
|
26
36
|
|
|
@@ -148,35 +158,30 @@ export async function promptAutoUpdateWizard(
|
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
const current = getAutoUpdateConfig(ctx);
|
|
151
|
-
const choice =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
]);
|
|
159
|
-
|
|
160
|
-
if (!choice || choice === "Cancel") return;
|
|
161
|
-
|
|
162
|
-
if (choice === "Off") {
|
|
163
|
-
disableAutoUpdate(pi, ctx);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (choice === "Every hour") {
|
|
168
|
-
enableAutoUpdate(pi, ctx, 60 * 60 * 1000, "1 hour", onUpdateAvailable);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (choice === "Daily") {
|
|
173
|
-
enableAutoUpdate(pi, ctx, 24 * 60 * 60 * 1000, "daily", onUpdateAvailable);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
161
|
+
const choice = parseChoiceByLabel(
|
|
162
|
+
AUTO_UPDATE_WIZARD_CHOICES,
|
|
163
|
+
await ctx.ui.select(
|
|
164
|
+
`Auto-update (${current.displayText})`,
|
|
165
|
+
Object.values(AUTO_UPDATE_WIZARD_CHOICES)
|
|
166
|
+
)
|
|
167
|
+
);
|
|
176
168
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
169
|
+
switch (choice) {
|
|
170
|
+
case "off":
|
|
171
|
+
disableAutoUpdate(pi, ctx);
|
|
172
|
+
return;
|
|
173
|
+
case "hour":
|
|
174
|
+
enableAutoUpdate(pi, ctx, 60 * 60 * 1000, "1 hour", onUpdateAvailable);
|
|
175
|
+
return;
|
|
176
|
+
case "daily":
|
|
177
|
+
enableAutoUpdate(pi, ctx, 24 * 60 * 60 * 1000, "daily", onUpdateAvailable);
|
|
178
|
+
return;
|
|
179
|
+
case "weekly":
|
|
180
|
+
enableAutoUpdate(pi, ctx, 7 * 24 * 60 * 60 * 1000, "weekly", onUpdateAvailable);
|
|
181
|
+
return;
|
|
182
|
+
case "cancel":
|
|
183
|
+
case undefined:
|
|
184
|
+
return;
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
const input = await ctx.ui.input("Auto-update interval", current.displayText || "1d");
|
|
@@ -184,7 +189,7 @@ export async function promptAutoUpdateWizard(
|
|
|
184
189
|
|
|
185
190
|
const parsed = parseDuration(input.trim());
|
|
186
191
|
if (!parsed) {
|
|
187
|
-
notify(ctx, "Invalid duration. Examples: 1h, 1d, 1w,
|
|
192
|
+
notify(ctx, "Invalid duration. Examples: 1h, 1d, 1w, 1mo, never", "warning");
|
|
188
193
|
return;
|
|
189
194
|
}
|
|
190
195
|
|
package/src/utils/cache.ts
CHANGED
|
@@ -13,13 +13,27 @@ const CACHE_DIR = process.env.PI_EXTMGR_CACHE_DIR
|
|
|
13
13
|
: join(homedir(), ".pi", "agent", ".extmgr-cache");
|
|
14
14
|
const CACHE_FILE = join(CACHE_DIR, "metadata.json");
|
|
15
15
|
const CURRENT_SEARCH_CACHE_STRATEGY = "npm-registry-v1-paginated";
|
|
16
|
+
const CACHED_PACKAGE_FIELDS = [
|
|
17
|
+
"description",
|
|
18
|
+
"version",
|
|
19
|
+
"author",
|
|
20
|
+
"keywords",
|
|
21
|
+
"date",
|
|
22
|
+
"size",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
type CachedPackageField = (typeof CACHED_PACKAGE_FIELDS)[number];
|
|
16
26
|
|
|
17
27
|
interface CachedPackageData {
|
|
18
28
|
name: string;
|
|
19
29
|
description?: string | undefined;
|
|
20
30
|
version?: string | undefined;
|
|
31
|
+
author?: string | undefined;
|
|
32
|
+
keywords?: string[] | undefined;
|
|
33
|
+
date?: string | undefined;
|
|
21
34
|
size?: number | undefined;
|
|
22
35
|
timestamp: number;
|
|
36
|
+
fieldTimestamps?: Partial<Record<CachedPackageField, number>> | undefined;
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
interface CacheData {
|
|
@@ -55,17 +69,66 @@ function normalizeCachedPackageEntry(key: string, value: unknown): CachedPackage
|
|
|
55
69
|
name,
|
|
56
70
|
timestamp,
|
|
57
71
|
};
|
|
72
|
+
const rawFieldTimestamps = isRecord(value.fieldTimestamps) ? value.fieldTimestamps : undefined;
|
|
73
|
+
|
|
74
|
+
const getFieldTimestamp = (field: CachedPackageField): number => {
|
|
75
|
+
const fieldTimestamp = rawFieldTimestamps?.[field];
|
|
76
|
+
return typeof fieldTimestamp === "number" &&
|
|
77
|
+
Number.isFinite(fieldTimestamp) &&
|
|
78
|
+
fieldTimestamp > 0
|
|
79
|
+
? fieldTimestamp
|
|
80
|
+
: timestamp;
|
|
81
|
+
};
|
|
58
82
|
|
|
59
83
|
if (typeof value.description === "string") {
|
|
60
84
|
entry.description = value.description;
|
|
85
|
+
entry.fieldTimestamps = {
|
|
86
|
+
...entry.fieldTimestamps,
|
|
87
|
+
description: getFieldTimestamp("description"),
|
|
88
|
+
};
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
if (typeof value.version === "string") {
|
|
64
92
|
entry.version = value.version;
|
|
93
|
+
entry.fieldTimestamps = {
|
|
94
|
+
...entry.fieldTimestamps,
|
|
95
|
+
version: getFieldTimestamp("version"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value.author === "string") {
|
|
100
|
+
entry.author = value.author;
|
|
101
|
+
entry.fieldTimestamps = {
|
|
102
|
+
...entry.fieldTimestamps,
|
|
103
|
+
author: getFieldTimestamp("author"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (Array.isArray(value.keywords)) {
|
|
108
|
+
const keywords = value.keywords.filter((item): item is string => typeof item === "string");
|
|
109
|
+
if (keywords.length > 0) {
|
|
110
|
+
entry.keywords = keywords;
|
|
111
|
+
entry.fieldTimestamps = {
|
|
112
|
+
...entry.fieldTimestamps,
|
|
113
|
+
keywords: getFieldTimestamp("keywords"),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof value.date === "string") {
|
|
119
|
+
entry.date = value.date;
|
|
120
|
+
entry.fieldTimestamps = {
|
|
121
|
+
...entry.fieldTimestamps,
|
|
122
|
+
date: getFieldTimestamp("date"),
|
|
123
|
+
};
|
|
65
124
|
}
|
|
66
125
|
|
|
67
126
|
if (typeof value.size === "number" && Number.isFinite(value.size) && value.size >= 0) {
|
|
68
127
|
entry.size = value.size;
|
|
128
|
+
entry.fieldTimestamps = {
|
|
129
|
+
...entry.fieldTimestamps,
|
|
130
|
+
size: getFieldTimestamp("size"),
|
|
131
|
+
};
|
|
69
132
|
}
|
|
70
133
|
|
|
71
134
|
return entry;
|
|
@@ -234,11 +297,117 @@ async function enqueueCacheSave(): Promise<void> {
|
|
|
234
297
|
return cacheWriteQueue;
|
|
235
298
|
}
|
|
236
299
|
|
|
300
|
+
function setCachedPackageField(
|
|
301
|
+
data: CachedPackageData,
|
|
302
|
+
field: CachedPackageField,
|
|
303
|
+
value: CachedPackageData[CachedPackageField],
|
|
304
|
+
timestamp: number
|
|
305
|
+
): void {
|
|
306
|
+
switch (field) {
|
|
307
|
+
case "description":
|
|
308
|
+
data.description = value as string | undefined;
|
|
309
|
+
break;
|
|
310
|
+
case "version":
|
|
311
|
+
data.version = value as string | undefined;
|
|
312
|
+
break;
|
|
313
|
+
case "author":
|
|
314
|
+
data.author = value as string | undefined;
|
|
315
|
+
break;
|
|
316
|
+
case "keywords":
|
|
317
|
+
data.keywords = value as string[] | undefined;
|
|
318
|
+
break;
|
|
319
|
+
case "date":
|
|
320
|
+
data.date = value as string | undefined;
|
|
321
|
+
break;
|
|
322
|
+
case "size":
|
|
323
|
+
data.size = value as number | undefined;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
data.fieldTimestamps = {
|
|
328
|
+
...data.fieldTimestamps,
|
|
329
|
+
[field]: timestamp,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function getCachedFieldTimestamp(data: CachedPackageData, field: CachedPackageField): number {
|
|
334
|
+
return data.fieldTimestamps?.[field] ?? data.timestamp;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function mergeCachedPackageData(
|
|
338
|
+
existing: CachedPackageData | undefined,
|
|
339
|
+
next: Omit<CachedPackageData, "timestamp" | "fieldTimestamps">
|
|
340
|
+
): CachedPackageData {
|
|
341
|
+
const timestamp = Date.now();
|
|
342
|
+
const merged: CachedPackageData = {
|
|
343
|
+
name: next.name || existing?.name || "",
|
|
344
|
+
timestamp,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
for (const field of CACHED_PACKAGE_FIELDS) {
|
|
348
|
+
const nextValue = next[field];
|
|
349
|
+
if (nextValue !== undefined) {
|
|
350
|
+
setCachedPackageField(merged, field, nextValue, timestamp);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const existingValue = existing?.[field];
|
|
355
|
+
if (existingValue !== undefined && existing) {
|
|
356
|
+
setCachedPackageField(merged, field, existingValue, getCachedFieldTimestamp(existing, field));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return merged;
|
|
361
|
+
}
|
|
362
|
+
|
|
237
363
|
/**
|
|
238
364
|
* Check if cached data is still valid (within TTL)
|
|
239
365
|
*/
|
|
240
|
-
function isCacheValid(timestamp: number): boolean {
|
|
241
|
-
return Date.now() - timestamp < CACHE_LIMITS.metadataTTL;
|
|
366
|
+
function isCacheValid(timestamp: number | undefined): boolean {
|
|
367
|
+
return typeof timestamp === "number" && Date.now() - timestamp < CACHE_LIMITS.metadataTTL;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getFreshCachedField(
|
|
371
|
+
data: CachedPackageData,
|
|
372
|
+
field: CachedPackageField
|
|
373
|
+
): CachedPackageData[CachedPackageField] | undefined {
|
|
374
|
+
const value = data[field];
|
|
375
|
+
if (value === undefined) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return isCacheValid(getCachedFieldTimestamp(data, field)) ? value : undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function hasFreshCachedField(data: CachedPackageData): boolean {
|
|
383
|
+
return CACHED_PACKAGE_FIELDS.some((field) => {
|
|
384
|
+
const value = data[field];
|
|
385
|
+
return value !== undefined && isCacheValid(getCachedFieldTimestamp(data, field));
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function toFreshCachedPackage(data: CachedPackageData | undefined): CachedPackageData | null {
|
|
390
|
+
if (!data) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const fresh: CachedPackageData = {
|
|
395
|
+
name: data.name,
|
|
396
|
+
timestamp: data.timestamp,
|
|
397
|
+
};
|
|
398
|
+
let hasFreshField = false;
|
|
399
|
+
|
|
400
|
+
for (const field of CACHED_PACKAGE_FIELDS) {
|
|
401
|
+
const value = getFreshCachedField(data, field);
|
|
402
|
+
if (value === undefined) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
hasFreshField = true;
|
|
407
|
+
setCachedPackageField(fresh, field, value, getCachedFieldTimestamp(data, field));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return hasFreshField ? fresh : null;
|
|
242
411
|
}
|
|
243
412
|
|
|
244
413
|
/**
|
|
@@ -246,13 +415,7 @@ function isCacheValid(timestamp: number): boolean {
|
|
|
246
415
|
*/
|
|
247
416
|
export async function getCachedPackage(name: string): Promise<CachedPackageData | null> {
|
|
248
417
|
const cache = await loadCache();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!data || !isCacheValid(data.timestamp)) {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return data;
|
|
418
|
+
return toFreshCachedPackage(cache.packages.get(name));
|
|
256
419
|
}
|
|
257
420
|
|
|
258
421
|
/**
|
|
@@ -260,13 +423,10 @@ export async function getCachedPackage(name: string): Promise<CachedPackageData
|
|
|
260
423
|
*/
|
|
261
424
|
export async function setCachedPackage(
|
|
262
425
|
name: string,
|
|
263
|
-
data: Omit<CachedPackageData, "timestamp">
|
|
426
|
+
data: Omit<CachedPackageData, "timestamp" | "fieldTimestamps">
|
|
264
427
|
): Promise<void> {
|
|
265
428
|
const cache = await loadCache();
|
|
266
|
-
cache.packages.set(name,
|
|
267
|
-
...data,
|
|
268
|
-
timestamp: Date.now(),
|
|
269
|
-
});
|
|
429
|
+
cache.packages.set(name, mergeCachedPackageData(cache.packages.get(name), data));
|
|
270
430
|
await enqueueCacheSave();
|
|
271
431
|
}
|
|
272
432
|
|
|
@@ -295,8 +455,12 @@ export async function getCachedSearch(query: string): Promise<NpmPackage[] | nul
|
|
|
295
455
|
if (pkg) {
|
|
296
456
|
packages.push({
|
|
297
457
|
name: pkg.name,
|
|
298
|
-
description: pkg
|
|
299
|
-
version: pkg
|
|
458
|
+
description: getFreshCachedField(pkg, "description") as string | undefined,
|
|
459
|
+
version: getFreshCachedField(pkg, "version") as string | undefined,
|
|
460
|
+
author: getFreshCachedField(pkg, "author") as string | undefined,
|
|
461
|
+
keywords: getFreshCachedField(pkg, "keywords") as string[] | undefined,
|
|
462
|
+
date: getFreshCachedField(pkg, "date") as string | undefined,
|
|
463
|
+
size: getFreshCachedField(pkg, "size") as number | undefined,
|
|
300
464
|
});
|
|
301
465
|
}
|
|
302
466
|
}
|
|
@@ -312,12 +476,18 @@ export async function setCachedSearch(query: string, packages: NpmPackage[]): Pr
|
|
|
312
476
|
|
|
313
477
|
// Update cache with new packages
|
|
314
478
|
for (const pkg of packages) {
|
|
315
|
-
cache.packages.set(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
479
|
+
cache.packages.set(
|
|
480
|
+
pkg.name,
|
|
481
|
+
mergeCachedPackageData(cache.packages.get(pkg.name), {
|
|
482
|
+
name: pkg.name,
|
|
483
|
+
description: pkg.description ?? undefined,
|
|
484
|
+
version: pkg.version ?? undefined,
|
|
485
|
+
author: pkg.author ?? undefined,
|
|
486
|
+
keywords: pkg.keywords ?? undefined,
|
|
487
|
+
date: pkg.date ?? undefined,
|
|
488
|
+
size: pkg.size ?? undefined,
|
|
489
|
+
})
|
|
490
|
+
);
|
|
321
491
|
}
|
|
322
492
|
|
|
323
493
|
// Store search results
|
|
@@ -355,7 +525,7 @@ export async function getCacheStats(): Promise<{
|
|
|
355
525
|
let expired = 0;
|
|
356
526
|
|
|
357
527
|
for (const [, data] of cache.packages) {
|
|
358
|
-
if (
|
|
528
|
+
if (hasFreshCachedField(data)) {
|
|
359
529
|
valid++;
|
|
360
530
|
} else {
|
|
361
531
|
expired++;
|
|
@@ -383,8 +553,9 @@ export async function getPackageDescriptions(
|
|
|
383
553
|
if (!npmSource?.name) continue;
|
|
384
554
|
|
|
385
555
|
const cached = cache.packages.get(npmSource.name);
|
|
386
|
-
|
|
387
|
-
|
|
556
|
+
const description = cached ? getFreshCachedField(cached, "description") : undefined;
|
|
557
|
+
if (typeof description === "string") {
|
|
558
|
+
descriptions.set(pkg.source, description);
|
|
388
559
|
}
|
|
389
560
|
}
|
|
390
561
|
|
|
@@ -397,12 +568,7 @@ export async function getPackageDescriptions(
|
|
|
397
568
|
export async function getCachedPackageSize(name: string): Promise<number | undefined> {
|
|
398
569
|
const cache = await loadCache();
|
|
399
570
|
const data = cache.packages.get(name);
|
|
400
|
-
|
|
401
|
-
if (data && isCacheValid(data.timestamp)) {
|
|
402
|
-
return data.size;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return undefined;
|
|
571
|
+
return data ? (getFreshCachedField(data, "size") as number | undefined) : undefined;
|
|
406
572
|
}
|
|
407
573
|
|
|
408
574
|
/**
|
|
@@ -412,14 +578,19 @@ export async function setCachedPackageSize(name: string, size: number): Promise<
|
|
|
412
578
|
const cache = await loadCache();
|
|
413
579
|
const existing = cache.packages.get(name);
|
|
414
580
|
|
|
581
|
+
const timestamp = Date.now();
|
|
582
|
+
|
|
415
583
|
if (existing) {
|
|
416
|
-
existing.
|
|
417
|
-
existing
|
|
584
|
+
existing.timestamp = timestamp;
|
|
585
|
+
setCachedPackageField(existing, "size", size, timestamp);
|
|
418
586
|
} else {
|
|
419
587
|
cache.packages.set(name, {
|
|
420
588
|
name,
|
|
421
589
|
size,
|
|
422
|
-
timestamp
|
|
590
|
+
timestamp,
|
|
591
|
+
fieldTimestamps: {
|
|
592
|
+
size: timestamp,
|
|
593
|
+
},
|
|
423
594
|
});
|
|
424
595
|
}
|
|
425
596
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export type DurationUnit = "minute" | "hour" | "day" | "week" | "month";
|
|
2
|
+
|
|
3
|
+
interface ParsedDuration {
|
|
4
|
+
ms: number;
|
|
5
|
+
display: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface DurationAlias {
|
|
9
|
+
ms: number;
|
|
10
|
+
display: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DurationUnitDefinition {
|
|
14
|
+
aliases: readonly string[];
|
|
15
|
+
ms: number;
|
|
16
|
+
singular: string;
|
|
17
|
+
plural: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ParseDurationOptions {
|
|
21
|
+
allowedUnits: readonly DurationUnit[];
|
|
22
|
+
aliases?: Readonly<Record<string, DurationAlias>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
26
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
27
|
+
const WEEK_MS = 7 * DAY_MS;
|
|
28
|
+
const MONTH_MS = 30 * DAY_MS;
|
|
29
|
+
|
|
30
|
+
const DURATION_UNITS: Record<DurationUnit, DurationUnitDefinition> = {
|
|
31
|
+
minute: {
|
|
32
|
+
aliases: ["m", "min", "mins", "minute", "minutes"],
|
|
33
|
+
ms: 60 * 1000,
|
|
34
|
+
singular: "minute",
|
|
35
|
+
plural: "minutes",
|
|
36
|
+
},
|
|
37
|
+
hour: {
|
|
38
|
+
aliases: ["h", "hr", "hrs", "hour", "hours"],
|
|
39
|
+
ms: HOUR_MS,
|
|
40
|
+
singular: "hour",
|
|
41
|
+
plural: "hours",
|
|
42
|
+
},
|
|
43
|
+
day: {
|
|
44
|
+
aliases: ["d", "day", "days"],
|
|
45
|
+
ms: DAY_MS,
|
|
46
|
+
singular: "day",
|
|
47
|
+
plural: "days",
|
|
48
|
+
},
|
|
49
|
+
week: {
|
|
50
|
+
aliases: ["w", "wk", "wks", "week", "weeks"],
|
|
51
|
+
ms: WEEK_MS,
|
|
52
|
+
singular: "week",
|
|
53
|
+
plural: "weeks",
|
|
54
|
+
},
|
|
55
|
+
month: {
|
|
56
|
+
aliases: ["mo", "mos", "month", "months"],
|
|
57
|
+
ms: MONTH_MS,
|
|
58
|
+
singular: "month",
|
|
59
|
+
plural: "months",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function formatDisplay(value: number, unit: DurationUnit): string {
|
|
64
|
+
const definition = DURATION_UNITS[unit];
|
|
65
|
+
return `${value} ${value === 1 ? definition.singular : definition.plural}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findDurationUnit(
|
|
69
|
+
rawUnit: string,
|
|
70
|
+
allowedUnits: readonly DurationUnit[]
|
|
71
|
+
): DurationUnit | undefined {
|
|
72
|
+
return allowedUnits.find((unit) => DURATION_UNITS[unit].aliases.includes(rawUnit));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseDurationValue(
|
|
76
|
+
input: string,
|
|
77
|
+
options: ParseDurationOptions
|
|
78
|
+
): ParsedDuration | undefined {
|
|
79
|
+
const normalized = input.toLowerCase().trim();
|
|
80
|
+
if (!normalized) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const alias = options.aliases?.[normalized];
|
|
85
|
+
if (alias) {
|
|
86
|
+
return { ...alias };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const match = normalized.match(/^(\d+)\s*([a-z]+)$/i);
|
|
90
|
+
if (!match) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const value = Number.parseInt(match[1] ?? "", 10);
|
|
95
|
+
const rawUnit = match[2] ?? "";
|
|
96
|
+
if (!Number.isFinite(value) || value <= 0 || !rawUnit) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const unit = findDurationUnit(rawUnit, options.allowedUnits);
|
|
101
|
+
if (!unit) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ms: value * DURATION_UNITS[unit].ms,
|
|
107
|
+
display: formatDisplay(value, unit),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SCHEDULE_DURATION_ALIASES = {
|
|
112
|
+
never: { ms: 0, display: "off" },
|
|
113
|
+
off: { ms: 0, display: "off" },
|
|
114
|
+
disable: { ms: 0, display: "off" },
|
|
115
|
+
daily: { ms: DAY_MS, display: "daily" },
|
|
116
|
+
day: { ms: DAY_MS, display: "daily" },
|
|
117
|
+
weekly: { ms: WEEK_MS, display: "weekly" },
|
|
118
|
+
week: { ms: WEEK_MS, display: "weekly" },
|
|
119
|
+
} satisfies Record<string, DurationAlias>;
|
|
120
|
+
|
|
121
|
+
export function parseScheduleDuration(input: string): ParsedDuration | undefined {
|
|
122
|
+
return parseDurationValue(input, {
|
|
123
|
+
allowedUnits: ["hour", "day", "week", "month"],
|
|
124
|
+
aliases: SCHEDULE_DURATION_ALIASES,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseLookbackDuration(input: string): number | undefined {
|
|
129
|
+
return parseDurationValue(input, {
|
|
130
|
+
allowedUnits: ["minute", "hour", "day", "week", "month"],
|
|
131
|
+
})?.ms;
|
|
132
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -9,36 +9,6 @@ export function truncate(text: string, maxLength: number): string {
|
|
|
9
9
|
return `${text.slice(0, maxLength - 3)}...`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Get the terminal width, with a minimum fallback
|
|
14
|
-
*/
|
|
15
|
-
export function getTerminalWidth(minWidth = 80): number {
|
|
16
|
-
return Math.max(minWidth, process.stdout.columns || minWidth);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Calculate available space for description based on fixed-width elements
|
|
21
|
-
*/
|
|
22
|
-
export function getDescriptionWidth(
|
|
23
|
-
totalWidth: number,
|
|
24
|
-
reservedSpace: number,
|
|
25
|
-
minDescWidth = 20
|
|
26
|
-
): number {
|
|
27
|
-
return Math.max(minDescWidth, totalWidth - reservedSpace);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Dynamic truncate that adapts to available terminal width
|
|
32
|
-
* @param text - Text to truncate
|
|
33
|
-
* @param reservedSpace - Space taken by fixed elements (icons, name, version, etc.)
|
|
34
|
-
* @param minWidth - Minimum terminal width to consider
|
|
35
|
-
*/
|
|
36
|
-
export function dynamicTruncate(text: string, reservedSpace: number, minWidth = 80): string {
|
|
37
|
-
const termWidth = getTerminalWidth(minWidth);
|
|
38
|
-
const maxDescWidth = getDescriptionWidth(termWidth, reservedSpace);
|
|
39
|
-
return truncate(text, maxDescWidth);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
12
|
export function formatEntry(entry: ExtensionEntry): string {
|
|
43
13
|
const state = entry.state === "enabled" ? "on " : "off";
|
|
44
14
|
const scope = entry.scope === "global" ? "G" : "P";
|
package/src/utils/fs.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
import { access, readFile } from "node:fs/promises";
|
|
5
5
|
import { truncate } from "./format.js";
|
|
6
6
|
|
|
7
|
+
function formatSummary(text: string): string {
|
|
8
|
+
return truncate(text.replace(/\s+/g, " ").trim(), 80);
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export async function fileExists(filePath: string): Promise<boolean> {
|
|
8
12
|
try {
|
|
9
13
|
await access(filePath);
|
|
@@ -28,7 +32,7 @@ export async function readSummary(filePath: string): Promise<string> {
|
|
|
28
32
|
for (const pattern of descriptionPatterns) {
|
|
29
33
|
const match = text.match(pattern);
|
|
30
34
|
const value = match?.[1]?.trim();
|
|
31
|
-
if (value) return
|
|
35
|
+
if (value) return formatSummary(value);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// Look for block comments
|
|
@@ -45,7 +49,7 @@ export async function readSummary(filePath: string): Promise<string> {
|
|
|
45
49
|
)
|
|
46
50
|
.filter((s): s is string => Boolean(s));
|
|
47
51
|
const firstLine = lines[0];
|
|
48
|
-
if (firstLine) return
|
|
52
|
+
if (firstLine) return formatSummary(firstLine);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
// Look for line comments
|
|
@@ -55,13 +59,13 @@ export async function readSummary(filePath: string): Promise<string> {
|
|
|
55
59
|
.split("\n")
|
|
56
60
|
.map((line) => line.replace(/^\s*\/\/\s?/, "").trim())
|
|
57
61
|
.filter(Boolean)[0];
|
|
58
|
-
if (first) return
|
|
62
|
+
if (first) return formatSummary(first);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// First non-empty line
|
|
62
66
|
for (const line of text.split("\n")) {
|
|
63
67
|
const clean = line.trim();
|
|
64
|
-
if (clean.length > 0) return
|
|
68
|
+
if (clean.length > 0) return formatSummary(clean);
|
|
65
69
|
}
|
|
66
70
|
} catch {
|
|
67
71
|
// ignore
|