@indigoai-us/hq-cli 5.60.0 → 5.62.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.
Files changed (98) hide show
  1. package/dist/commands/agents.d.ts +109 -0
  2. package/dist/commands/agents.js +385 -0
  3. package/dist/commands/db-migrate.d.ts +6 -0
  4. package/dist/commands/db-migrate.js +42 -0
  5. package/dist/commands/db-provision.d.ts +15 -0
  6. package/dist/commands/db-provision.js +78 -0
  7. package/dist/commands/db-sql.d.ts +9 -0
  8. package/dist/commands/db-sql.js +81 -0
  9. package/dist/commands/db-status.d.ts +7 -0
  10. package/dist/commands/db-status.js +70 -0
  11. package/dist/commands/db.d.ts +9 -0
  12. package/dist/commands/db.js +23 -0
  13. package/dist/commands/integrations.d.ts +78 -0
  14. package/dist/commands/integrations.js +309 -0
  15. package/dist/commands/members.js +4 -4
  16. package/dist/commands/outposts.d.ts +60 -0
  17. package/dist/commands/outposts.js +255 -0
  18. package/dist/commands/pack-install.d.ts +7 -1
  19. package/dist/commands/pack-install.js +86 -15
  20. package/dist/commands/packs.d.ts +2 -1
  21. package/dist/commands/packs.js +13 -8
  22. package/dist/commands/secrets.d.ts +13 -0
  23. package/dist/commands/secrets.js +149 -10
  24. package/dist/commands/skill.d.ts +153 -0
  25. package/dist/commands/skill.js +593 -0
  26. package/dist/commands/workers.d.ts +48 -0
  27. package/dist/commands/workers.js +229 -0
  28. package/dist/index.d.ts +5 -3
  29. package/dist/index.js +14 -240
  30. package/dist/lib/db/control-plane.d.ts +45 -0
  31. package/dist/lib/db/control-plane.js +81 -0
  32. package/dist/lib/db/local.d.ts +49 -0
  33. package/dist/lib/db/local.js +106 -0
  34. package/dist/lib/db/migrate.d.ts +41 -0
  35. package/dist/lib/db/migrate.js +104 -0
  36. package/dist/lib/db/paths.d.ts +56 -0
  37. package/dist/lib/db/paths.js +103 -0
  38. package/dist/lib/db/remote-engine.d.ts +58 -0
  39. package/dist/lib/db/remote-engine.js +90 -0
  40. package/dist/lib/db/remote-sql.d.ts +22 -0
  41. package/dist/lib/db/remote-sql.js +39 -0
  42. package/dist/lib/db/sql.d.ts +49 -0
  43. package/dist/lib/db/sql.js +132 -0
  44. package/dist/main.d.ts +7 -0
  45. package/dist/main.js +272 -0
  46. package/dist/utils/cognito-session.js +3 -3
  47. package/dist/utils/sandbox-runner-client.d.ts +13 -0
  48. package/dist/utils/sandbox-runner-client.js +83 -6
  49. package/dist/utils/version-check.d.ts +6 -0
  50. package/dist/utils/version-check.js +78 -2
  51. package/package.json +9 -1
  52. package/pnpm-workspace.yaml +2 -0
  53. package/src/commands/agents.test.ts +297 -0
  54. package/src/commands/agents.ts +561 -0
  55. package/src/commands/db-migrate.ts +55 -0
  56. package/src/commands/db-provision.ts +102 -0
  57. package/src/commands/db-sql.ts +124 -0
  58. package/src/commands/db-status.ts +100 -0
  59. package/src/commands/db.ts +26 -0
  60. package/src/commands/integrations.test.ts +284 -0
  61. package/src/commands/integrations.ts +438 -0
  62. package/src/commands/members.ts +2 -2
  63. package/src/commands/outposts.test.ts +177 -0
  64. package/src/commands/outposts.ts +338 -0
  65. package/src/commands/pack-install.ts +115 -18
  66. package/src/commands/pack-update-cache.test.ts +149 -0
  67. package/src/commands/packs.ts +28 -7
  68. package/src/commands/secrets.parse-destination.test.ts +38 -0
  69. package/src/commands/secrets.test.ts +342 -0
  70. package/src/commands/secrets.ts +227 -13
  71. package/src/commands/skill.test.ts +770 -0
  72. package/src/commands/skill.ts +796 -0
  73. package/src/commands/workers.test.ts +158 -0
  74. package/src/commands/workers.ts +298 -0
  75. package/src/index.test.ts +32 -0
  76. package/src/index.ts +11 -274
  77. package/src/lib/db/control-plane.test.ts +59 -0
  78. package/src/lib/db/control-plane.ts +113 -0
  79. package/src/lib/db/local.test.ts +81 -0
  80. package/src/lib/db/local.ts +148 -0
  81. package/src/lib/db/migrate.test.ts +133 -0
  82. package/src/lib/db/migrate.ts +137 -0
  83. package/src/lib/db/paths.test.ts +112 -0
  84. package/src/lib/db/paths.ts +128 -0
  85. package/src/lib/db/remote-engine.test.ts +44 -0
  86. package/src/lib/db/remote-engine.ts +148 -0
  87. package/src/lib/db/remote-sql.test.ts +32 -0
  88. package/src/lib/db/remote-sql.ts +62 -0
  89. package/src/lib/db/sql.test.ts +106 -0
  90. package/src/lib/db/sql.ts +192 -0
  91. package/src/main.ts +314 -0
  92. package/src/utils/cognito-session.ts +1 -1
  93. package/src/utils/sandbox-runner-client.test.ts +128 -0
  94. package/src/utils/sandbox-runner-client.ts +100 -4
  95. package/src/utils/version-check.test.ts +30 -0
  96. package/src/utils/version-check.ts +72 -0
  97. package/test/commands/db-tenant-isolation.test.ts +94 -0
  98. package/test/commands/db.test.ts +85 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * `hq outposts` — manage your personal HQ Outposts (EC2 boxes) from the
3
+ * terminal instead of the web console. Targets the hq-pro `/outpost/*`
4
+ * control plane on `DEFAULT_VAULT_API_URL` via the shared `vaultApiFetch`
5
+ * helper — the same routes the console's outpost panel calls.
6
+ *
7
+ * Outposts are PERSONAL / caller-scoped: hq-pro keys every `/outpost/*` route
8
+ * on the caller's Cognito sub, so there is no `--company`. `--id <outpostId>`
9
+ * selects a specific box (passed as the `outpostId` query param); when omitted
10
+ * hq-pro targets the caller's primary slot.
11
+ *
12
+ * Subcommands:
13
+ * hq outposts list — every Outpost you own (row summaries)
14
+ * hq outposts status [--id] — live detail for one box
15
+ * hq outposts codex-enable [--id] — enable / retry Codex on the box
16
+ * hq outposts login [--id] — request a fresh login URL
17
+ * hq outposts destroy [--id] --yes — tear the box down (destructive; flag-guarded)
18
+ *
19
+ * NOTE: hq-pro exposes no rename or settings-mutation route for Outposts (the
20
+ * web console can't rename them either), so this CLI wraps only the lifecycle
21
+ * and status routes that exist. Renaming an Outpost is not a backend capability.
22
+ */
23
+
24
+ import { Command } from "commander";
25
+ import chalk from "chalk";
26
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
27
+ import { vaultApiFetch } from "../utils/vault-api.js";
28
+
29
+ /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
30
+ export class OutpostHttpError extends Error {
31
+ status: number;
32
+ step?: string;
33
+ constructor(status: number, message: string, step?: string) {
34
+ super(message);
35
+ this.name = "OutpostHttpError";
36
+ this.status = status;
37
+ this.step = step;
38
+ }
39
+ }
40
+
41
+ /** Row summary from `GET /outpost/list`. */
42
+ export interface OutpostSummary {
43
+ outpostId: string;
44
+ state: string;
45
+ instanceName: string;
46
+ region: string;
47
+ agentRuntime: string;
48
+ platform: string;
49
+ createdAt: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /**
54
+ * Authenticated JSON round-trip against the outpost control plane. Throws
55
+ * `OutpostHttpError` on any non-2xx (never swallows — hq-never-swallow-errors),
56
+ * decoding hq-pro's `{ error | message, step }` envelope for the reason. The
57
+ * `step` is preserved so callers can recognise the `destroy` route's
58
+ * `teardown-incomplete` 409 (which means "retry", not "failed").
59
+ */
60
+ export async function outpostRequest<T>(opts: {
61
+ token: string;
62
+ path: string;
63
+ method?: string;
64
+ query?: Record<string, string>;
65
+ }): Promise<T> {
66
+ const res = await vaultApiFetch(opts);
67
+ if (!res.ok) {
68
+ const body = (await res.json().catch(() => ({}))) as {
69
+ error?: unknown;
70
+ message?: string;
71
+ step?: string;
72
+ };
73
+ const message =
74
+ typeof body.message === "string"
75
+ ? body.message
76
+ : typeof body.error === "string"
77
+ ? body.error
78
+ : res.statusText;
79
+ throw new OutpostHttpError(res.status, message, body.step);
80
+ }
81
+ return (await res.json()) as T;
82
+ }
83
+
84
+ export async function listOutposts(token: string): Promise<OutpostSummary[]> {
85
+ const data = await outpostRequest<{ outposts: OutpostSummary[] }>({
86
+ token,
87
+ path: "/outpost/list",
88
+ });
89
+ return data.outposts ?? [];
90
+ }
91
+
92
+ export async function getOutpostStatus(
93
+ token: string,
94
+ outpostId?: string,
95
+ ): Promise<Record<string, unknown>> {
96
+ return outpostRequest({
97
+ token,
98
+ path: "/outpost/status",
99
+ query: outpostId ? { outpostId } : undefined,
100
+ });
101
+ }
102
+
103
+ export async function enableCodex(
104
+ token: string,
105
+ outpostId?: string,
106
+ ): Promise<Record<string, unknown>> {
107
+ return outpostRequest({
108
+ token,
109
+ path: "/outpost/codex/enable",
110
+ method: "POST",
111
+ query: outpostId ? { outpostId } : undefined,
112
+ });
113
+ }
114
+
115
+ export async function regenerateLoginUrl(
116
+ token: string,
117
+ outpostId?: string,
118
+ ): Promise<Record<string, unknown>> {
119
+ return outpostRequest({
120
+ token,
121
+ path: "/outpost/regenerate-login-url",
122
+ method: "POST",
123
+ query: outpostId ? { outpostId } : undefined,
124
+ });
125
+ }
126
+
127
+ export async function destroyOutpost(
128
+ token: string,
129
+ outpostId?: string,
130
+ ): Promise<Record<string, unknown>> {
131
+ return outpostRequest({
132
+ token,
133
+ path: "/outpost/destroy",
134
+ method: "POST",
135
+ query: outpostId ? { outpostId } : undefined,
136
+ });
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Command registration
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function fail(err: unknown): never {
144
+ if (err instanceof OutpostHttpError) {
145
+ console.error(chalk.red(err.message));
146
+ } else {
147
+ console.error(
148
+ chalk.red("Error:"),
149
+ err instanceof Error ? err.message : String(err),
150
+ );
151
+ }
152
+ process.exit(1);
153
+ }
154
+
155
+ /** Print a top-level object as `key: value`, JSON-ifying nested values. */
156
+ function printKeyValues(obj: Record<string, unknown>): void {
157
+ for (const [k, v] of Object.entries(obj)) {
158
+ const rendered = v && typeof v === "object" ? JSON.stringify(v) : String(v);
159
+ console.log(`${chalk.bold(k)}: ${rendered}`);
160
+ }
161
+ }
162
+
163
+ export function registerOutpostsCommand(program: Command): void {
164
+ const outposts = program
165
+ .command("outposts")
166
+ .description("Manage your personal HQ Outposts (EC2 boxes)");
167
+
168
+ outposts
169
+ .command("list")
170
+ .description("List every Outpost you own")
171
+ .option("--json", "Emit raw JSON")
172
+ .action(async function (this: Command, opts: { json?: boolean }) {
173
+ try {
174
+ const token = await ensureCognitoToken();
175
+ const rows = await listOutposts(token);
176
+ if (opts.json) {
177
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
178
+ return;
179
+ }
180
+ if (rows.length === 0) {
181
+ console.log(chalk.gray("You don't own any Outposts yet."));
182
+ return;
183
+ }
184
+ const idW = Math.max(2, ...rows.map((r) => (r.outpostId ?? "").length));
185
+ const stateW = Math.max(5, ...rows.map((r) => (r.state ?? "").length));
186
+ const nameW = Math.max(
187
+ 4,
188
+ ...rows.map((r) => (r.instanceName ?? "").length),
189
+ );
190
+ const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
191
+ const rtW = Math.max(
192
+ 7,
193
+ ...rows.map((r) => (r.agentRuntime ?? "").length),
194
+ );
195
+ console.log(
196
+ chalk.bold(
197
+ [
198
+ "ID".padEnd(idW),
199
+ "STATE".padEnd(stateW),
200
+ "INSTANCE".padEnd(nameW),
201
+ "REGION".padEnd(regionW),
202
+ "RUNTIME".padEnd(rtW),
203
+ "PLATFORM",
204
+ ].join(" "),
205
+ ),
206
+ );
207
+ for (const r of rows) {
208
+ console.log(
209
+ [
210
+ (r.outpostId ?? "").padEnd(idW),
211
+ (r.state ?? "").padEnd(stateW),
212
+ (r.instanceName ?? "").padEnd(nameW),
213
+ (r.region ?? "").padEnd(regionW),
214
+ (r.agentRuntime ?? "").padEnd(rtW),
215
+ r.platform ?? "",
216
+ ].join(" "),
217
+ );
218
+ }
219
+ } catch (err) {
220
+ fail(err);
221
+ }
222
+ });
223
+
224
+ outposts
225
+ .command("status")
226
+ .description("Show live detail for one Outpost")
227
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
228
+ .option("--json", "Emit raw JSON")
229
+ .action(async function (
230
+ this: Command,
231
+ opts: { id?: string; json?: boolean },
232
+ ) {
233
+ try {
234
+ const token = await ensureCognitoToken();
235
+ const status = await getOutpostStatus(token, opts.id);
236
+ if (opts.json) {
237
+ process.stdout.write(JSON.stringify(status, null, 2) + "\n");
238
+ return;
239
+ }
240
+ printKeyValues(status);
241
+ } catch (err) {
242
+ fail(err);
243
+ }
244
+ });
245
+
246
+ outposts
247
+ .command("codex-enable")
248
+ .description("Enable (or retry) Codex on an Outpost")
249
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
250
+ .option("--json", "Emit raw JSON")
251
+ .action(async function (
252
+ this: Command,
253
+ opts: { id?: string; json?: boolean },
254
+ ) {
255
+ try {
256
+ const token = await ensureCognitoToken();
257
+ const result = await enableCodex(token, opts.id);
258
+ if (opts.json) {
259
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
260
+ return;
261
+ }
262
+ console.log(chalk.green("Codex enablement requested for the Outpost."));
263
+ } catch (err) {
264
+ fail(err);
265
+ }
266
+ });
267
+
268
+ outposts
269
+ .command("login")
270
+ .description("Request a fresh login URL for an Outpost")
271
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
272
+ .option("--json", "Emit raw JSON")
273
+ .action(async function (
274
+ this: Command,
275
+ opts: { id?: string; json?: boolean },
276
+ ) {
277
+ try {
278
+ const token = await ensureCognitoToken();
279
+ const result = await regenerateLoginUrl(token, opts.id);
280
+ if (opts.json) {
281
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
282
+ return;
283
+ }
284
+ console.log(
285
+ chalk.green(
286
+ "Login-URL regeneration requested. The box mints a fresh URL shortly — " +
287
+ "check `hq outposts status` to pick it up.",
288
+ ),
289
+ );
290
+ } catch (err) {
291
+ fail(err);
292
+ }
293
+ });
294
+
295
+ outposts
296
+ .command("destroy")
297
+ .description("Tear down (permanently destroy) an Outpost")
298
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
299
+ .option("--yes", "Confirm the irreversible teardown (required)")
300
+ .action(async function (
301
+ this: Command,
302
+ opts: { id?: string; yes?: boolean },
303
+ ) {
304
+ const target = opts.id ?? "your primary Outpost";
305
+ if (!opts.yes) {
306
+ console.error(
307
+ chalk.yellow(
308
+ `This will permanently destroy ${target} and delete its cloud resources. ` +
309
+ `This cannot be undone.\n` +
310
+ `Re-run with --yes to confirm: hq outposts destroy${opts.id ? ` --id ${opts.id}` : ""} --yes`,
311
+ ),
312
+ );
313
+ process.exit(1);
314
+ }
315
+ try {
316
+ const token = await ensureCognitoToken();
317
+ await destroyOutpost(token, opts.id);
318
+ console.log(chalk.green(`Destroyed ${target}.`));
319
+ } catch (err) {
320
+ // A 409 teardown-incomplete is not a failure — the gateway's 30s cap
321
+ // fired while the Lambda keeps working. The row is preserved and the
322
+ // operation is idempotent, so tell the caller to retry.
323
+ if (
324
+ err instanceof OutpostHttpError &&
325
+ (err.status === 409 || err.step === "teardown-incomplete")
326
+ ) {
327
+ console.log(
328
+ chalk.yellow(
329
+ `Teardown still in progress for ${target} — this is expected for large boxes. ` +
330
+ `Re-run the same destroy command to finish (it's idempotent).`,
331
+ ),
332
+ );
333
+ return;
334
+ }
335
+ fail(err);
336
+ }
337
+ });
338
+ }
@@ -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
+ });