@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.
@@ -5,35 +5,67 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
5
 
6
6
  interface MeetingListItem {
7
7
  meetingId: string;
8
- title: string;
9
- startTime: string;
10
- endTime: string;
11
- duration: number;
12
- participantCount: number;
13
- status: string;
14
- hasTranscript: boolean;
15
- hasNotes: boolean;
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
- title: string;
21
- startTime: string;
22
- endTime: string;
23
- duration: number;
24
- participants: Array<{ email: string; name: string | null; role: string }>;
25
- calendarEventId: string | null;
26
- botProvider: string;
27
- sourceApp: string;
28
- companyId: string;
29
- status: string;
30
- recallBotId: string | null;
31
- isShared: boolean;
32
- createdAt: string;
33
- updatedAt: string;
34
- documentUrl: string;
35
- hasTranscript: boolean;
36
- hasNotes: boolean;
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 = new Date(m.startTime).toLocaleDateString("en-US", {
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 = formatDuration(m.duration);
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
- statusBadge(m.status).padEnd(STATUS_W + 10), // chalk adds escape chars
203
- String(m.participantCount).padEnd(PARTS_W),
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
- console.log(chalk.bold(`\n${detail.title}\n`));
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: ${new Date(detail.startTime).toLocaleString()}`);
293
- console.log(` Duration: ${formatDuration(detail.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.length > 0) {
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 = execFileSync(
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
- ): LatestResult {
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 = execFileSync('npm', ['view', pkg, 'version'], {
759
- encoding: 'utf-8',
760
- stdio: ['ignore', 'pipe', 'ignore'],
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 view failed: ${(e as Error).message}` };
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 out = execFileSync('git', ['ls-remote', url, refArg], {
784
- encoding: 'utf-8',
785
- stdio: ['ignore', 'pipe', 'ignore'],
786
- }).trim();
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
+ });