@indigoai-us/hq-cli 5.59.0 → 5.61.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/dist/commands/meetings.js +127 -14
- package/dist/commands/pack-install.d.ts +7 -1
- package/dist/commands/pack-install.js +86 -15
- package/dist/commands/packs.d.ts +2 -1
- package/dist/commands/packs.js +13 -8
- package/dist/commands/secrets.d.ts +5 -0
- package/dist/commands/secrets.js +134 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +14 -240
- package/dist/main.d.ts +7 -0
- package/dist/main.js +247 -0
- package/dist/utils/sandbox-runner-client.d.ts +13 -0
- package/dist/utils/sandbox-runner-client.js +82 -5
- package/dist/utils/version-check.d.ts +6 -0
- package/dist/utils/version-check.js +78 -2
- package/package.json +1 -1
- package/src/commands/meetings.test.ts +116 -2
- package/src/commands/meetings.ts +206 -37
- package/src/commands/pack-install.ts +115 -18
- package/src/commands/pack-update-cache.test.ts +149 -0
- package/src/commands/packs.ts +28 -7
- package/src/commands/secrets.test.ts +407 -0
- package/src/commands/secrets.ts +203 -5
- package/src/index.test.ts +32 -0
- package/src/index.ts +11 -274
- package/src/main.ts +283 -0
- package/src/utils/sandbox-runner-client.test.ts +128 -0
- package/src/utils/sandbox-runner-client.ts +99 -3
- package/src/utils/version-check.test.ts +30 -0
- package/src/utils/version-check.ts +72 -0
package/src/commands/meetings.ts
CHANGED
|
@@ -5,35 +5,67 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
|
5
5
|
|
|
6
6
|
interface MeetingListItem {
|
|
7
7
|
meetingId: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
sourceShape?: "markdown" | "json";
|
|
9
|
+
title?: string | null;
|
|
10
|
+
startTime?: string;
|
|
11
|
+
endTime?: string;
|
|
12
|
+
duration?: number;
|
|
13
|
+
participantCount?: number;
|
|
14
|
+
status?: string;
|
|
15
|
+
hasTranscript?: boolean;
|
|
16
|
+
hasNotes?: boolean;
|
|
17
|
+
channel?: string;
|
|
18
|
+
ingested_at?: string;
|
|
19
|
+
hasSignals?: boolean;
|
|
20
|
+
companyId?: string;
|
|
21
|
+
attributed?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
interface MeetingDetail {
|
|
19
25
|
meetingId: string;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
sourceShape?: "markdown" | "json";
|
|
27
|
+
title?: string;
|
|
28
|
+
startTime?: string;
|
|
29
|
+
endTime?: string;
|
|
30
|
+
duration?: number;
|
|
31
|
+
participants?: Array<{ email: string; name: string | null; role: string }>;
|
|
32
|
+
calendarEventId?: string | null;
|
|
33
|
+
botProvider?: string;
|
|
34
|
+
sourceApp?: string;
|
|
35
|
+
companyId?: string;
|
|
36
|
+
status?: string;
|
|
37
|
+
recallBotId?: string | null;
|
|
38
|
+
isShared?: boolean;
|
|
39
|
+
createdAt?: string;
|
|
40
|
+
updatedAt?: string;
|
|
41
|
+
documentUrl?: string;
|
|
42
|
+
hasTranscript?: boolean;
|
|
43
|
+
hasNotes?: boolean;
|
|
44
|
+
source?: MarkdownMeetingSource;
|
|
45
|
+
signals?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MarkdownMeetingFrontmatter {
|
|
49
|
+
title?: string;
|
|
50
|
+
channel?: string;
|
|
51
|
+
origin?: string;
|
|
52
|
+
company_id?: string;
|
|
53
|
+
meeting_url?: string;
|
|
54
|
+
meeting_platform?: string;
|
|
55
|
+
calendar_event_id?: string | null;
|
|
56
|
+
scheduled_start_time?: string;
|
|
57
|
+
created_at?: string;
|
|
58
|
+
updated_at?: string;
|
|
59
|
+
ingested_at?: string;
|
|
60
|
+
recall_bot_id?: string | null;
|
|
61
|
+
bot_status?: string;
|
|
62
|
+
auto_scheduled?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface MarkdownMeetingSource {
|
|
66
|
+
path?: string;
|
|
67
|
+
presigned_url?: string;
|
|
68
|
+
frontmatter?: MarkdownMeetingFrontmatter;
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
interface TranscriptSegment {
|
|
@@ -78,6 +110,17 @@ function formatDuration(seconds: number): string {
|
|
|
78
110
|
return `${s}s`;
|
|
79
111
|
}
|
|
80
112
|
|
|
113
|
+
function safeFormatDuration(seconds: unknown): string {
|
|
114
|
+
return typeof seconds === "number" && Number.isFinite(seconds) ? formatDuration(seconds) : "-";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function safeFormatDate(iso: unknown, options?: Intl.DateTimeFormatOptions): string {
|
|
118
|
+
if (typeof iso !== "string" || iso.length === 0) return "-";
|
|
119
|
+
const date = new Date(iso);
|
|
120
|
+
if (Number.isNaN(date.getTime())) return "-";
|
|
121
|
+
return options ? date.toLocaleString("en-US", options) : date.toLocaleString();
|
|
122
|
+
}
|
|
123
|
+
|
|
81
124
|
function formatTimestamp(ts: number): string {
|
|
82
125
|
const m = Math.floor(ts / 60);
|
|
83
126
|
const s = Math.floor(ts % 60);
|
|
@@ -99,6 +142,45 @@ function statusBadge(status: string): string {
|
|
|
99
142
|
}
|
|
100
143
|
}
|
|
101
144
|
|
|
145
|
+
function isMarkdownShape(x: unknown): x is MeetingDetail & {
|
|
146
|
+
source: MarkdownMeetingSource;
|
|
147
|
+
} {
|
|
148
|
+
if (!x || typeof x !== "object") return false;
|
|
149
|
+
const candidate = x as {
|
|
150
|
+
sourceShape?: string;
|
|
151
|
+
source?: { frontmatter?: unknown };
|
|
152
|
+
};
|
|
153
|
+
return candidate.sourceShape === "markdown" || Boolean(candidate.source?.frontmatter);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hasSignals(signals: unknown): signals is Record<string, unknown> {
|
|
157
|
+
return Boolean(
|
|
158
|
+
signals &&
|
|
159
|
+
typeof signals === "object" &&
|
|
160
|
+
!Array.isArray(signals) &&
|
|
161
|
+
Object.keys(signals).length > 0,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderSignals(signals: Record<string, unknown>): void {
|
|
166
|
+
console.log(chalk.bold("Signals"));
|
|
167
|
+
for (const [key, value] of Object.entries(signals)) {
|
|
168
|
+
if (value === null || value === undefined) {
|
|
169
|
+
console.log(` ${key}: -`);
|
|
170
|
+
} else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
171
|
+
console.log(` ${key}: ${String(value)}`);
|
|
172
|
+
} else {
|
|
173
|
+
const rendered = JSON.stringify(value, null, 2)
|
|
174
|
+
.split("\n")
|
|
175
|
+
.map((line) => ` ${line}`)
|
|
176
|
+
.join("\n");
|
|
177
|
+
console.log(` ${key}:`);
|
|
178
|
+
console.log(rendered);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
console.log();
|
|
182
|
+
}
|
|
183
|
+
|
|
102
184
|
async function resolveShortId(
|
|
103
185
|
token: string,
|
|
104
186
|
prefix: string,
|
|
@@ -181,16 +263,19 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
|
|
|
181
263
|
const id = m.meetingId.slice(0, 8);
|
|
182
264
|
const fullTitle = displayTitle(m);
|
|
183
265
|
const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
|
|
184
|
-
const date =
|
|
266
|
+
const date = safeFormatDate(m.startTime, {
|
|
185
267
|
month: "short",
|
|
186
268
|
day: "numeric",
|
|
187
269
|
hour: "2-digit",
|
|
188
270
|
minute: "2-digit",
|
|
189
271
|
});
|
|
190
|
-
const dur =
|
|
272
|
+
const dur = safeFormatDuration(m.duration);
|
|
273
|
+
const status = m.status ? statusBadge(m.status) : chalk.dim("-");
|
|
274
|
+
const parts = typeof m.participantCount === "number" ? String(m.participantCount) : "-";
|
|
191
275
|
const flags = [
|
|
192
276
|
m.hasTranscript ? "T" : "",
|
|
193
277
|
m.hasNotes ? "N" : "",
|
|
278
|
+
m.hasSignals ? "S" : "",
|
|
194
279
|
].filter(Boolean).join("") || "-";
|
|
195
280
|
|
|
196
281
|
console.log(
|
|
@@ -199,9 +284,9 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
|
|
|
199
284
|
title.padEnd(TITLE_W),
|
|
200
285
|
chalk.dim(date.padEnd(DATE_W)),
|
|
201
286
|
dur.padEnd(DUR_W),
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
flags,
|
|
287
|
+
status.padEnd(STATUS_W + 10), // chalk adds escape chars
|
|
288
|
+
parts.padEnd(PARTS_W),
|
|
289
|
+
flags.padEnd(FLAGS_W),
|
|
205
290
|
].join(" "),
|
|
206
291
|
);
|
|
207
292
|
}
|
|
@@ -286,17 +371,39 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
286
371
|
return;
|
|
287
372
|
}
|
|
288
373
|
|
|
289
|
-
|
|
374
|
+
if (isMarkdownShape(detail)) {
|
|
375
|
+
const fm = detail.source.frontmatter ?? {};
|
|
376
|
+
console.log(chalk.bold(`\n${fm.title || "(untitled)"}\n`));
|
|
377
|
+
console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
|
|
378
|
+
console.log(` Status: ${fm.bot_status || "-"}`);
|
|
379
|
+
console.log(` Date: ${safeFormatDate(fm.scheduled_start_time || fm.created_at)}`);
|
|
380
|
+
console.log(` Platform: ${fm.meeting_platform || "-"}`);
|
|
381
|
+
console.log(` Origin: ${fm.origin || "-"}`);
|
|
382
|
+
console.log(` Company: ${fm.company_id || "-"}`);
|
|
383
|
+
if (fm.meeting_url) console.log(` Meeting URL: ${fm.meeting_url}`);
|
|
384
|
+
if (hasSignals(detail.signals)) {
|
|
385
|
+
console.log(` Signals: ${Object.keys(detail.signals).length}`);
|
|
386
|
+
}
|
|
387
|
+
console.log(
|
|
388
|
+
chalk.dim(
|
|
389
|
+
`\n This meeting is stored as a markdown document. Use \`hq meetings transcript ${detail.meetingId.slice(0, 8)}\` to view the full document.`,
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
console.log();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.log(chalk.bold(`\n${detail.title ?? "(untitled)"}\n`));
|
|
290
397
|
console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
|
|
291
|
-
console.log(` Status: ${statusBadge(detail.status)}`);
|
|
292
|
-
console.log(` Date: ${
|
|
293
|
-
console.log(` Duration: ${
|
|
294
|
-
console.log(` Source: ${detail.sourceApp} (${detail.botProvider})`);
|
|
398
|
+
console.log(` Status: ${detail.status ? statusBadge(detail.status) : chalk.dim("-")}`);
|
|
399
|
+
console.log(` Date: ${safeFormatDate(detail.startTime)}`);
|
|
400
|
+
console.log(` Duration: ${safeFormatDuration(detail.duration)}`);
|
|
401
|
+
console.log(` Source: ${detail.sourceApp ?? "-"} (${detail.botProvider ?? "-"})`);
|
|
295
402
|
console.log(` Shared: ${detail.isShared ? "yes" : "no"}`);
|
|
296
403
|
|
|
297
|
-
if (detail.participants
|
|
404
|
+
if ((detail.participants?.length ?? 0) > 0) {
|
|
298
405
|
console.log(chalk.bold("\n Participants:"));
|
|
299
|
-
for (const p of detail.participants) {
|
|
406
|
+
for (const p of detail.participants ?? []) {
|
|
300
407
|
const name = p.name ?? p.email;
|
|
301
408
|
const role = p.role === "organizer" ? chalk.yellow(" (organizer)") : "";
|
|
302
409
|
console.log(` - ${name}${role}`);
|
|
@@ -447,6 +554,32 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
447
554
|
if (!res.ok) await handleApiError(res);
|
|
448
555
|
|
|
449
556
|
const detail = (await res.json()) as MeetingDetail;
|
|
557
|
+
|
|
558
|
+
if (isMarkdownShape(detail)) {
|
|
559
|
+
const documentUrl = detail.source.presigned_url;
|
|
560
|
+
if (!documentUrl) {
|
|
561
|
+
console.error(chalk.red("No document available for this meeting."));
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const docRes = await fetch(documentUrl);
|
|
566
|
+
if (!docRes.ok) {
|
|
567
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const markdown = await docRes.text();
|
|
572
|
+
|
|
573
|
+
if (meetings.opts().json) {
|
|
574
|
+
console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
console.log(chalk.bold(`\nTranscript: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
|
|
579
|
+
console.log(markdown);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
450
583
|
if (!detail.documentUrl) {
|
|
451
584
|
console.error(chalk.red("No document URL available for this meeting."));
|
|
452
585
|
process.exit(1);
|
|
@@ -503,6 +636,42 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
503
636
|
if (!res.ok) await handleApiError(res);
|
|
504
637
|
|
|
505
638
|
const detail = (await res.json()) as MeetingDetail;
|
|
639
|
+
|
|
640
|
+
if (isMarkdownShape(detail)) {
|
|
641
|
+
if (hasSignals(detail.signals)) {
|
|
642
|
+
if (meetings.opts().json) {
|
|
643
|
+
console.log(JSON.stringify(detail.signals, null, 2));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
console.log(chalk.bold(`\nMeeting Notes: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
|
|
647
|
+
renderSignals(detail.signals);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const documentUrl = detail.source.presigned_url;
|
|
652
|
+
if (!documentUrl) {
|
|
653
|
+
console.log(chalk.yellow("No notes available for this meeting."));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const docRes = await fetch(documentUrl);
|
|
658
|
+
if (!docRes.ok) {
|
|
659
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const markdown = await docRes.text();
|
|
664
|
+
|
|
665
|
+
if (meetings.opts().json) {
|
|
666
|
+
console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
console.log(chalk.bold(`\nMeeting Notes: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
|
|
671
|
+
console.log(markdown);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
506
675
|
if (!detail.documentUrl) {
|
|
507
676
|
console.error(chalk.red("No document URL available for this meeting."));
|
|
508
677
|
process.exit(1);
|
|
@@ -68,6 +68,80 @@ import {
|
|
|
68
68
|
import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
69
69
|
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
70
70
|
|
|
71
|
+
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
72
|
+
const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
|
|
73
|
+
|
|
74
|
+
interface PackUpdateCacheEntry {
|
|
75
|
+
latest: string;
|
|
76
|
+
fetchedAt: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PackUpdateCacheFile {
|
|
80
|
+
entries: Record<string, PackUpdateCacheEntry>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ResolveLatestOptions {
|
|
84
|
+
forceRefresh?: boolean;
|
|
85
|
+
now?: number;
|
|
86
|
+
cacheTtlMs?: number;
|
|
87
|
+
fetchImpl?: typeof fetch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const gitLsRemoteMemo = new Map<string, string>();
|
|
91
|
+
|
|
92
|
+
function packUpdateCachePath(): string {
|
|
93
|
+
return path.join(os.homedir(), '.hq', 'pack-update-cache.json');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readPackUpdateCache(): PackUpdateCacheFile {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(fs.readFileSync(packUpdateCachePath(), 'utf-8')) as Partial<PackUpdateCacheFile>;
|
|
99
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object') {
|
|
100
|
+
return { entries: {} };
|
|
101
|
+
}
|
|
102
|
+
return { entries: parsed.entries as Record<string, PackUpdateCacheEntry> };
|
|
103
|
+
} catch {
|
|
104
|
+
return { entries: {} };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writePackUpdateCache(cache: PackUpdateCacheFile): void {
|
|
109
|
+
try {
|
|
110
|
+
const file = packUpdateCachePath();
|
|
111
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
112
|
+
fs.writeFileSync(file, JSON.stringify(cache));
|
|
113
|
+
} catch {
|
|
114
|
+
// best-effort; update checks must never fail because the cache is unwritable
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cachedLatest(cacheKey: string, opts: ResolveLatestOptions): string | undefined {
|
|
119
|
+
if (opts.forceRefresh) return undefined;
|
|
120
|
+
const entry = readPackUpdateCache().entries[cacheKey];
|
|
121
|
+
if (!entry || typeof entry.latest !== 'string' || typeof entry.fetchedAt !== 'number') return undefined;
|
|
122
|
+
const now = opts.now ?? Date.now();
|
|
123
|
+
const ttl = opts.cacheTtlMs ?? PACK_UPDATE_CACHE_TTL_MS;
|
|
124
|
+
return now - entry.fetchedAt <= ttl ? entry.latest : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function storeCachedLatest(cacheKey: string, latest: string, opts: ResolveLatestOptions): void {
|
|
128
|
+
const cache = readPackUpdateCache();
|
|
129
|
+
cache.entries[cacheKey] = { latest, fetchedAt: opts.now ?? Date.now() };
|
|
130
|
+
writePackUpdateCache(cache);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function latestWithDiskCache(
|
|
134
|
+
cacheKey: string,
|
|
135
|
+
opts: ResolveLatestOptions,
|
|
136
|
+
refresh: () => Promise<string | undefined> | string | undefined,
|
|
137
|
+
): Promise<string | undefined> {
|
|
138
|
+
const cached = cachedLatest(cacheKey, opts);
|
|
139
|
+
if (cached) return cached;
|
|
140
|
+
const latest = await refresh();
|
|
141
|
+
if (latest) storeCachedLatest(cacheKey, latest, opts);
|
|
142
|
+
return latest;
|
|
143
|
+
}
|
|
144
|
+
|
|
71
145
|
// ---------------------------------------------------------------------------
|
|
72
146
|
// Source classification
|
|
73
147
|
// ---------------------------------------------------------------------------
|
|
@@ -676,17 +750,37 @@ function rsyncDir(src: string, dest: string): void {
|
|
|
676
750
|
*/
|
|
677
751
|
function isNamedRef(url: string, ref: string): boolean {
|
|
678
752
|
try {
|
|
679
|
-
const out =
|
|
680
|
-
'git',
|
|
681
|
-
['ls-remote', '--heads', '--tags', url, ref],
|
|
682
|
-
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
683
|
-
);
|
|
753
|
+
const out = gitLsRemote(['--heads', '--tags', url, ref]);
|
|
684
754
|
return out.trim().length > 0;
|
|
685
755
|
} catch {
|
|
686
756
|
return false;
|
|
687
757
|
}
|
|
688
758
|
}
|
|
689
759
|
|
|
760
|
+
function gitLsRemote(args: string[]): string {
|
|
761
|
+
const key = args.join('\0');
|
|
762
|
+
const cached = gitLsRemoteMemo.get(key);
|
|
763
|
+
if (cached !== undefined) return cached;
|
|
764
|
+
const out = execFileSync(
|
|
765
|
+
'git',
|
|
766
|
+
['ls-remote', ...args],
|
|
767
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
768
|
+
);
|
|
769
|
+
gitLsRemoteMemo.set(key, out);
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function fetchLatestNpmVersion(pkg: string, opts: ResolveLatestOptions): Promise<string | undefined> {
|
|
774
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
775
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, {
|
|
776
|
+
headers: { Accept: 'application/json' },
|
|
777
|
+
signal: AbortSignal.timeout(PACK_UPDATE_FETCH_TIMEOUT_MS),
|
|
778
|
+
});
|
|
779
|
+
if (!res.ok) throw new Error(`registry returned ${res.status}`);
|
|
780
|
+
const body = (await res.json()) as { version?: unknown };
|
|
781
|
+
return typeof body.version === 'string' ? body.version : undefined;
|
|
782
|
+
}
|
|
783
|
+
|
|
690
784
|
// ---------------------------------------------------------------------------
|
|
691
785
|
// Update-availability probe (no install) — used by `hq packs update --check-only`
|
|
692
786
|
// ---------------------------------------------------------------------------
|
|
@@ -711,6 +805,10 @@ function gitRefFromSource(source: string): string | undefined {
|
|
|
711
805
|
return ref;
|
|
712
806
|
}
|
|
713
807
|
|
|
808
|
+
function isFullGitSha(ref: string): boolean {
|
|
809
|
+
return /^[0-9a-f]{40}$/i.test(ref);
|
|
810
|
+
}
|
|
811
|
+
|
|
714
812
|
/**
|
|
715
813
|
* Probe whether a newer version of an already-installed pack is available,
|
|
716
814
|
* WITHOUT fetching or installing. Reuses the same git/npm primitives as the
|
|
@@ -720,10 +818,11 @@ function gitRefFromSource(source: string): string | undefined {
|
|
|
720
818
|
* @param source the stamped `source:` from the installed package.yaml
|
|
721
819
|
* @param installedVersion the installed pack's manifest `version` (npm compare)
|
|
722
820
|
*/
|
|
723
|
-
export function resolveLatest(
|
|
821
|
+
export async function resolveLatest(
|
|
724
822
|
source: string,
|
|
725
823
|
installedVersion?: string,
|
|
726
|
-
|
|
824
|
+
opts: ResolveLatestOptions = {},
|
|
825
|
+
): Promise<LatestResult> {
|
|
727
826
|
let transport: Transport;
|
|
728
827
|
try {
|
|
729
828
|
transport = classify(source);
|
|
@@ -755,15 +854,14 @@ export function resolveLatest(
|
|
|
755
854
|
const current =
|
|
756
855
|
installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
|
|
757
856
|
try {
|
|
758
|
-
const latest =
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}).trim();
|
|
857
|
+
const latest = await latestWithDiskCache(`npm:${pkg}`, opts, () =>
|
|
858
|
+
fetchLatestNpmVersion(pkg, opts),
|
|
859
|
+
);
|
|
762
860
|
const updateAvailable =
|
|
763
861
|
current && latest ? semverGt(latest, current) : null;
|
|
764
862
|
return { transport, current, latest, updateAvailable };
|
|
765
863
|
} catch (e) {
|
|
766
|
-
return { transport, current, updateAvailable: null, error: `npm
|
|
864
|
+
return { transport, current, updateAvailable: null, error: `npm registry check failed: ${(e as Error).message}` };
|
|
767
865
|
}
|
|
768
866
|
}
|
|
769
867
|
|
|
@@ -778,13 +876,12 @@ export function resolveLatest(
|
|
|
778
876
|
const current = gitRefFromSource(source);
|
|
779
877
|
// If install followed a named ref (branch/tag), compare that ref's tip;
|
|
780
878
|
// otherwise (default SHA-pin) compare the default branch HEAD.
|
|
781
|
-
const refArg = current && isNamedRef(url, current) ? current : 'HEAD';
|
|
879
|
+
const refArg = current && !isFullGitSha(current) && isNamedRef(url, current) ? current : 'HEAD';
|
|
782
880
|
try {
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
})
|
|
787
|
-
const latest = out.split(/\s+/)[0] || undefined;
|
|
881
|
+
const latest = await latestWithDiskCache(`git:${url}#${refArg}`, opts, () => {
|
|
882
|
+
const out = gitLsRemote([url, refArg]).trim();
|
|
883
|
+
return out.split(/\s+/)[0] || undefined;
|
|
884
|
+
});
|
|
788
885
|
const updateAvailable =
|
|
789
886
|
current && latest ? !latest.startsWith(current) && !current.startsWith(latest) : null;
|
|
790
887
|
return { transport, current, latest, updateAvailable };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
vi.mock('child_process', () => ({
|
|
8
|
+
execFileSync: vi.fn(),
|
|
9
|
+
spawnSync: vi.fn(() => ({ status: 0 })),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../utils/vault-api.js', () => ({
|
|
13
|
+
vaultApiFetchPublic: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const tmpHome = path.join(os.tmpdir(), `hq-pack-update-cache-${process.pid}`);
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
20
|
+
fs.mkdirSync(tmpHome, { recursive: true });
|
|
21
|
+
vi.stubEnv('HOME', tmpHome);
|
|
22
|
+
vi.mocked(execFileSync).mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllEnvs();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function loadModule() {
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
return await import('./pack-install.js');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resolveLatest pack update cache', () => {
|
|
38
|
+
it('fetches npm registry metadata over HTTP once, then compares cached latest against the installed version at read time', async () => {
|
|
39
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
40
|
+
ok: true,
|
|
41
|
+
json: async () => ({ version: '2.0.0' }),
|
|
42
|
+
} as unknown as Response);
|
|
43
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
44
|
+
|
|
45
|
+
const { resolveLatest } = await loadModule();
|
|
46
|
+
|
|
47
|
+
const staleInstall = await resolveLatest('@scope/pack@1.0.0', '1.0.0', {
|
|
48
|
+
forceRefresh: true,
|
|
49
|
+
});
|
|
50
|
+
const currentInstall = await resolveLatest('@scope/pack@1.0.0', '2.0.0');
|
|
51
|
+
|
|
52
|
+
expect(staleInstall).toMatchObject({
|
|
53
|
+
transport: 'npm',
|
|
54
|
+
current: '1.0.0',
|
|
55
|
+
latest: '2.0.0',
|
|
56
|
+
updateAvailable: true,
|
|
57
|
+
});
|
|
58
|
+
expect(currentInstall).toMatchObject({
|
|
59
|
+
transport: 'npm',
|
|
60
|
+
current: '2.0.0',
|
|
61
|
+
latest: '2.0.0',
|
|
62
|
+
updateAvailable: false,
|
|
63
|
+
});
|
|
64
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(vi.mocked(execFileSync)).not.toHaveBeenCalledWith(
|
|
66
|
+
'npm',
|
|
67
|
+
['view', '@scope/pack', 'version'],
|
|
68
|
+
expect.anything(),
|
|
69
|
+
);
|
|
70
|
+
expect(fs.existsSync(path.join(tmpHome, '.hq', 'pack-update-cache.json'))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('bypasses a fresh npm cache entry when forceRefresh is set', async () => {
|
|
74
|
+
const fetchMock = vi
|
|
75
|
+
.fn()
|
|
76
|
+
.mockResolvedValueOnce({
|
|
77
|
+
ok: true,
|
|
78
|
+
json: async () => ({ version: '2.0.0' }),
|
|
79
|
+
} as unknown as Response)
|
|
80
|
+
.mockResolvedValueOnce({
|
|
81
|
+
ok: true,
|
|
82
|
+
json: async () => ({ version: '3.0.0' }),
|
|
83
|
+
} as unknown as Response);
|
|
84
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
85
|
+
|
|
86
|
+
const { resolveLatest } = await loadModule();
|
|
87
|
+
|
|
88
|
+
await resolveLatest('@scope/pack@1.0.0', '1.0.0', { forceRefresh: true });
|
|
89
|
+
const refreshed = await resolveLatest('@scope/pack@1.0.0', '1.0.0', {
|
|
90
|
+
forceRefresh: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(refreshed).toMatchObject({
|
|
94
|
+
latest: '3.0.0',
|
|
95
|
+
updateAvailable: true,
|
|
96
|
+
});
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('shares git ls-remote probes by URL and ref within one process', async () => {
|
|
101
|
+
vi.mocked(execFileSync).mockImplementation((_cmd, args) => {
|
|
102
|
+
const argv = args as string[];
|
|
103
|
+
if (argv[1] === '--heads') return '';
|
|
104
|
+
if (argv[0] === 'ls-remote') return 'abcdef1234567890\tHEAD\n';
|
|
105
|
+
throw new Error(`unexpected command: ${argv.join(' ')}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { resolveLatest } = await loadModule();
|
|
109
|
+
|
|
110
|
+
const first = await resolveLatest('https://example.test/repo.git#123456', '1.0.0');
|
|
111
|
+
const second = await resolveLatest('https://example.test/repo.git#123456', '1.0.0');
|
|
112
|
+
|
|
113
|
+
expect(first.latest).toBe('abcdef1234567890');
|
|
114
|
+
expect(second.latest).toBe('abcdef1234567890');
|
|
115
|
+
const lsRemoteCalls = vi
|
|
116
|
+
.mocked(execFileSync)
|
|
117
|
+
.mock.calls.filter(([cmd, args]) => cmd === 'git' && (args as string[])[0] === 'ls-remote');
|
|
118
|
+
expect(lsRemoteCalls).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses a fresh git disk cache entry without probing git for SHA-pinned sources', async () => {
|
|
122
|
+
const installedSha = '1111111111111111111111111111111111111111';
|
|
123
|
+
vi.mocked(execFileSync).mockImplementation((_cmd, args) => {
|
|
124
|
+
const argv = args as string[];
|
|
125
|
+
if (argv[0] === 'ls-remote') return 'abcdef1234567890\tHEAD\n';
|
|
126
|
+
throw new Error(`unexpected command: ${argv.join(' ')}`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const firstModule = await loadModule();
|
|
130
|
+
await firstModule.resolveLatest(`https://example.test/repo.git#${installedSha}`, '1.0.0', {
|
|
131
|
+
forceRefresh: true,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
vi.mocked(execFileSync).mockReset();
|
|
135
|
+
const secondModule = await loadModule();
|
|
136
|
+
const cached = await secondModule.resolveLatest(
|
|
137
|
+
`https://example.test/repo.git#${installedSha}`,
|
|
138
|
+
'1.0.0',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(cached).toMatchObject({
|
|
142
|
+
transport: 'git',
|
|
143
|
+
current: installedSha,
|
|
144
|
+
latest: 'abcdef1234567890',
|
|
145
|
+
updateAvailable: true,
|
|
146
|
+
});
|
|
147
|
+
expect(vi.mocked(execFileSync)).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|