@indigoai-us/hq-cli 5.60.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.
@@ -1,6 +1,13 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3cbec729-3b2b-5d53-9e90-5680dd725b7d")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e77a0f25-ac1a-574f-a1e4-985c6b3306d7")}catch(e){}}();
3
3
  const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
4
+ const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
5
+ const DEFAULT_RETRY_OPTIONS = {
6
+ maxAttempts: 8,
7
+ maxElapsedMs: 90_000,
8
+ baseDelayMs: 500,
9
+ maxDelayMs: 5_000,
10
+ };
4
11
  function normalizeBaseUrl(baseUrl) {
5
12
  return baseUrl.replace(/\/+$/, "");
6
13
  }
@@ -40,15 +47,85 @@ function delay(ms) {
40
47
  return Promise.resolve();
41
48
  return new Promise((resolve) => setTimeout(resolve, ms));
42
49
  }
50
+ function getErrorMessage(error) {
51
+ if (error instanceof Error) {
52
+ return error.message;
53
+ }
54
+ if (typeof error === "string") {
55
+ return error;
56
+ }
57
+ return String(error);
58
+ }
59
+ function parseRetryAfterMs(value) {
60
+ if (!value)
61
+ return undefined;
62
+ const seconds = Number(value);
63
+ if (Number.isFinite(seconds) && seconds >= 0) {
64
+ return seconds * 1000;
65
+ }
66
+ const dateMs = Date.parse(value);
67
+ if (!Number.isNaN(dateMs)) {
68
+ return Math.max(0, dateMs - Date.now());
69
+ }
70
+ return undefined;
71
+ }
43
72
  export class SandboxRunnerClient {
44
73
  baseUrl;
45
74
  fetchImpl;
75
+ retry;
76
+ sleep;
46
77
  constructor(options = {}) {
47
78
  this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
48
79
  this.fetchImpl = options.fetchImpl ?? fetch;
80
+ this.retry = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
81
+ this.sleep = options.sleep ?? delay;
82
+ }
83
+ async fetchWithRetry(url, init) {
84
+ const startedAt = Date.now();
85
+ let lastError = "unknown error";
86
+ let attempts = 0;
87
+ for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt += 1) {
88
+ attempts = attempt;
89
+ try {
90
+ const res = await this.fetchImpl(url, init);
91
+ if (!RETRYABLE_STATUS_CODES.has(res.status)) {
92
+ return res;
93
+ }
94
+ lastError = `HTTP ${res.status} ${res.statusText}`.trim();
95
+ if (!this.shouldRetry(attempt, startedAt)) {
96
+ break;
97
+ }
98
+ await this.sleep(this.nextDelayMs(attempt, res));
99
+ }
100
+ catch (error) {
101
+ lastError = getErrorMessage(error);
102
+ if (!this.shouldRetry(attempt, startedAt)) {
103
+ break;
104
+ }
105
+ await this.sleep(this.nextDelayMs(attempt));
106
+ }
107
+ }
108
+ throw new Error(`Sandbox Runner did not respond after ${attempts} attempts ` +
109
+ `(cold start or transient network); last error: ${lastError}`);
110
+ }
111
+ shouldRetry(attempt, startedAt) {
112
+ if (attempt >= this.retry.maxAttempts) {
113
+ return false;
114
+ }
115
+ return Date.now() - startedAt < this.retry.maxElapsedMs;
116
+ }
117
+ nextDelayMs(attempt, res) {
118
+ const retryAfterMs = parseRetryAfterMs(res?.headers.get("Retry-After") ?? null);
119
+ if (retryAfterMs !== undefined) {
120
+ return Math.min(retryAfterMs, this.retry.maxDelayMs);
121
+ }
122
+ const exponential = this.retry.baseDelayMs * 2 ** (attempt - 1);
123
+ const capped = Math.min(exponential, this.retry.maxDelayMs);
124
+ const jitter = Math.floor(Math.random() * Math.max(1, capped * 0.25));
125
+ return Math.min(capped + jitter, this.retry.maxDelayMs);
49
126
  }
50
127
  async startJob(token, request) {
51
- const res = await this.fetchImpl(`${this.baseUrl}/jobs`, {
128
+ const res = await this.fetchWithRetry(`${this.baseUrl}/jobs`, {
52
129
  method: "POST",
53
130
  headers: {
54
131
  Authorization: `Bearer ${token}`,
@@ -75,7 +152,7 @@ export class SandboxRunnerClient {
75
152
  };
76
153
  }
77
154
  async getJob(token, jobId) {
78
- const res = await this.fetchImpl(`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`, {
155
+ const res = await this.fetchWithRetry(`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`, {
79
156
  headers: { Authorization: `Bearer ${token}` },
80
157
  });
81
158
  const body = await parseJsonResponse(res);
@@ -97,10 +174,10 @@ export class SandboxRunnerClient {
97
174
  if (job.status === "succeeded" || job.status === "failed") {
98
175
  return job;
99
176
  }
100
- await delay(intervalMs);
177
+ await this.sleep(intervalMs);
101
178
  }
102
179
  throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
103
180
  }
104
181
  }
105
182
  //# sourceMappingURL=sandbox-runner-client.js.map
106
- //# debugId=3cbec729-3b2b-5d53-9e90-5680dd725b7d
183
+ //# debugId=e77a0f25-ac1a-574f-a1e4-985c6b3306d7
@@ -1,3 +1,9 @@
1
+ declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boolean;
1
2
  export declare function maybeWarnNewVersion(): void;
2
3
  export declare function refreshVersionCache(): Promise<void>;
4
+ export declare const __test__: {
5
+ CACHE_TTL_MS: number;
6
+ isKnownNoninteractiveStatusProbe: typeof isKnownNoninteractiveStatusProbe;
7
+ };
8
+ export {};
3
9
  //# sourceMappingURL=version-check.d.ts.map
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="99545a82-414c-5fed-a478-ea8e63009643")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f7058fc6-251e-5e11-af6c-de15f6e256f3")}catch(e){}}();
3
3
  import * as fs from "fs";
4
4
  import * as os from "os";
5
5
  import * as path from "path";
@@ -9,10 +9,15 @@ import { CLI_VERSION } from "../cli-version.js";
9
9
  const PACKAGE_NAME = "@indigoai-us/hq-cli";
10
10
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
11
11
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
12
+ const CACHE_TTL_JITTER_MS = 60 * 60 * 1000;
12
13
  const FETCH_TIMEOUT_MS = 3_000;
14
+ const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
13
15
  function cachePath() {
14
16
  return path.join(os.homedir(), ".hq", "version-check.json");
15
17
  }
18
+ function lockPath() {
19
+ return path.join(os.homedir(), ".hq", "version-check.lock");
20
+ }
16
21
  function isOptedOut() {
17
22
  return process.env.HQ_NO_UPDATE_CHECK === "1";
18
23
  }
@@ -40,6 +45,59 @@ function writeCache(entry) {
40
45
  // best-effort; never break the CLI on cache write failure
41
46
  }
42
47
  }
48
+ function freshEnough(entry, now = Date.now()) {
49
+ const jitter = Math.floor(Math.random() * CACHE_TTL_JITTER_MS);
50
+ return now - entry.fetchedAt <= CACHE_TTL_MS - jitter;
51
+ }
52
+ function isKnownNoninteractiveStatusProbe(argv = process.argv) {
53
+ const args = argv.slice(2);
54
+ const positional = args.filter((arg) => !arg.startsWith("-"));
55
+ const json = args.includes("--json") || !process.stdout.isTTY;
56
+ if (!json)
57
+ return false;
58
+ if (positional[0] === "mcp" && positional[1] === "status")
59
+ return true;
60
+ if (positional[0] === "packs" && (positional[1] === "list" || positional[1] === "ls"))
61
+ return true;
62
+ if (positional[0] === "packages" &&
63
+ positional[1] === "packs" &&
64
+ (positional[2] === "list" || positional[2] === "ls")) {
65
+ return true;
66
+ }
67
+ if ((positional[0] === "sources" || positional[0] === "signals") && positional[1] === "list") {
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ function acquireRefreshLock(now = Date.now()) {
73
+ const dir = lockPath();
74
+ try {
75
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
76
+ fs.mkdirSync(dir);
77
+ fs.writeFileSync(path.join(dir, "owner"), `${process.pid}\n${now}\n`);
78
+ return () => {
79
+ try {
80
+ fs.rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ catch {
83
+ // best-effort lock cleanup
84
+ }
85
+ };
86
+ }
87
+ catch {
88
+ try {
89
+ const stat = fs.statSync(dir);
90
+ if (now - stat.mtimeMs > REFRESH_LOCK_STALE_MS) {
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ return acquireRefreshLock(now);
93
+ }
94
+ }
95
+ catch {
96
+ // ignore lock inspection failures
97
+ }
98
+ return null;
99
+ }
100
+ }
43
101
  export function maybeWarnNewVersion() {
44
102
  if (isOptedOut())
45
103
  return;
@@ -60,7 +118,18 @@ export function maybeWarnNewVersion() {
60
118
  export async function refreshVersionCache() {
61
119
  if (isOptedOut())
62
120
  return;
121
+ if (isKnownNoninteractiveStatusProbe())
122
+ return;
123
+ const existing = readCache();
124
+ if (existing && freshEnough(existing))
125
+ return;
126
+ const releaseLock = acquireRefreshLock();
127
+ if (!releaseLock)
128
+ return;
63
129
  try {
130
+ const lockedExisting = readCache();
131
+ if (lockedExisting && freshEnough(lockedExisting))
132
+ return;
64
133
  const res = await fetch(REGISTRY_URL, {
65
134
  headers: { Accept: "application/json" },
66
135
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -75,6 +144,13 @@ export async function refreshVersionCache() {
75
144
  catch {
76
145
  // best-effort; offline / registry down / timeout — silent
77
146
  }
147
+ finally {
148
+ releaseLock();
149
+ }
78
150
  }
151
+ export const __test__ = {
152
+ CACHE_TTL_MS,
153
+ isKnownNoninteractiveStatusProbe,
154
+ };
79
155
  //# sourceMappingURL=version-check.js.map
80
- //# debugId=99545a82-414c-5fed-a478-ea8e63009643
156
+ //# debugId=f7058fc6-251e-5e11-af6c-de15f6e256f3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.60.0",
3
+ "version": "5.61.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
+ });
@@ -144,6 +144,7 @@ export function buildInstalledView(
144
144
  pack: InstalledPack,
145
145
  installedSources: Set<string>,
146
146
  checkUpdates: boolean,
147
+ latestProbe?: LatestResult,
147
148
  ): InstalledPackView {
148
149
  if (!pack.manifest) {
149
150
  return {
@@ -178,7 +179,7 @@ export function buildInstalledView(
178
179
 
179
180
  let updateAvailable: boolean | null = null;
180
181
  if (checkUpdates && m.source) {
181
- updateAvailable = resolveLatest(m.source, m.version).updateAvailable;
182
+ updateAvailable = latestProbe?.updateAvailable ?? null;
182
183
  }
183
184
 
184
185
  // US-005 — surface the pack's `initialization` block so the HQ Sync
@@ -214,15 +215,27 @@ export function buildInstalledView(
214
215
  };
215
216
  }
216
217
 
217
- function buildListView(hqRoot: string, checkUpdates: boolean, evalConditionals: boolean): PacksListView {
218
+ async function buildListView(
219
+ hqRoot: string,
220
+ checkUpdates: boolean,
221
+ evalConditionals: boolean,
222
+ refreshUpdates: boolean,
223
+ ): Promise<PacksListView> {
218
224
  const hqVersion = readHqVersion(hqRoot);
219
225
  const packs = listInstalledPacks(hqRoot);
220
226
  const catalog = readRecommendedPackages(hqRoot);
221
227
  const catalogSources = new Set(catalog.map((c) => c.source));
222
228
  const warnings: string[] = [];
223
229
 
224
- const installed = packs.map((p) =>
225
- buildInstalledView(hqRoot, hqVersion, p, catalogSources, checkUpdates),
230
+ const latestProbes = await Promise.all(
231
+ packs.map((p) =>
232
+ checkUpdates && p.manifest?.source
233
+ ? resolveLatest(p.manifest.source, p.manifest.version, { forceRefresh: refreshUpdates })
234
+ : Promise.resolve(undefined),
235
+ ),
236
+ );
237
+ const installed = packs.map((p, i) =>
238
+ buildInstalledView(hqRoot, hqVersion, p, catalogSources, checkUpdates, latestProbes[i]),
226
239
  );
227
240
  for (const p of installed) {
228
241
  if (p.error) warnings.push(`${p.name}: ${p.error}`);
@@ -303,6 +316,7 @@ interface UpdateCheck {
303
316
 
304
317
  interface UpdateOpts extends CommonOpts {
305
318
  checkOnly?: boolean;
319
+ refresh?: boolean;
306
320
  yes?: boolean;
307
321
  allowHooks?: boolean;
308
322
  allowMcp?: boolean;
@@ -334,7 +348,7 @@ async function runUpdate(name: string | undefined, opts: UpdateOpts): Promise<Up
334
348
  const probe: LatestResult =
335
349
  safeClassify(source) === 'marketplace'
336
350
  ? await resolveLatestMarketplace(source, m.version)
337
- : resolveLatest(source, m.version);
351
+ : await resolveLatest(source, m.version, { forceRefresh: true });
338
352
  const base: UpdateCheck = {
339
353
  name: pname,
340
354
  transport: probe.transport,
@@ -517,11 +531,17 @@ export function registerPacksCommand(parent: Command): void {
517
531
  .option('--json', 'Machine-readable JSON output')
518
532
  .option('--hq-root <path>', 'HQ root (default: auto-detect)')
519
533
  .option('--check-updates', 'Probe each pack for available updates (network I/O)')
534
+ .option('--refresh', 'Bypass cached update probes')
520
535
  .option('--eval-conditionals', 'Evaluate catalog conditional predicates (runs bash)')
521
536
  .action(
522
- async (opts: CommonOpts & { checkUpdates?: boolean; evalConditionals?: boolean }) => {
537
+ async (opts: CommonOpts & { checkUpdates?: boolean; evalConditionals?: boolean; refresh?: boolean }) => {
523
538
  try {
524
- const view = buildListView(resolveRoot(opts), !!opts.checkUpdates, !!opts.evalConditionals);
539
+ const view = await buildListView(
540
+ resolveRoot(opts),
541
+ !!opts.checkUpdates,
542
+ !!opts.evalConditionals,
543
+ !!opts.refresh,
544
+ );
525
545
  if (wantsJson(opts)) emitJson(view);
526
546
  else printListHuman(view);
527
547
  } catch (e) {
@@ -537,6 +557,7 @@ export function registerPacksCommand(parent: Command): void {
537
557
  .option('--json', 'Machine-readable JSON output')
538
558
  .option('--hq-root <path>', 'HQ root (default: auto-detect)')
539
559
  .option('--check-only', 'Report availability without installing')
560
+ .option('--refresh', 'Bypass cached update probes (update refreshes by default)')
540
561
  .option('-y, --yes', 'Non-interactive (implies --allow-hooks and --allow-mcp)')
541
562
  .option('--allow-hooks', 'Install pack hooks without prompting')
542
563
  .option('--allow-mcp', 'Register pack MCP servers without prompting')