@indigoai-us/hq-cli 5.33.0 → 5.34.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 (39) hide show
  1. package/dist/commands/__fixtures__/make-tar.d.ts +46 -0
  2. package/dist/commands/__fixtures__/make-tar.js +105 -0
  3. package/dist/commands/master-sync.d.ts +11 -0
  4. package/dist/commands/master-sync.js +15 -0
  5. package/dist/commands/pack-install.d.ts +187 -1
  6. package/dist/commands/pack-install.js +405 -16
  7. package/dist/commands/packs.js +9 -4
  8. package/dist/commands/publish.d.ts +186 -0
  9. package/dist/commands/publish.js +375 -0
  10. package/dist/commands/rescue.d.ts +33 -0
  11. package/dist/commands/rescue.js +161 -0
  12. package/dist/commands/safe-extract.d.ts +154 -0
  13. package/dist/commands/safe-extract.js +347 -0
  14. package/dist/index.js +17 -2
  15. package/dist/lib/local-tree-diff.d.ts +21 -0
  16. package/dist/lib/local-tree-diff.js +18 -3
  17. package/dist/types.d.ts +22 -0
  18. package/dist/utils/vault-api.d.ts +11 -0
  19. package/dist/utils/vault-api.js +39 -2
  20. package/package.json +2 -2
  21. package/src/commands/__fixtures__/make-tar.ts +126 -0
  22. package/src/commands/artifact-verify.test.ts +177 -0
  23. package/src/commands/marketplace-install.test.ts +414 -0
  24. package/src/commands/marketplace-security.test.ts +646 -0
  25. package/src/commands/master-sync.ts +23 -0
  26. package/src/commands/pack-install.test.ts +209 -1
  27. package/src/commands/pack-install.ts +617 -15
  28. package/src/commands/packs.ts +8 -1
  29. package/src/commands/publish.test.ts +538 -0
  30. package/src/commands/publish.ts +517 -0
  31. package/src/commands/rescue.test.ts +39 -0
  32. package/src/commands/rescue.ts +210 -0
  33. package/src/commands/safe-extract.test.ts +459 -0
  34. package/src/commands/safe-extract.ts +444 -0
  35. package/src/index.ts +18 -0
  36. package/src/lib/local-tree-diff.test.ts +19 -0
  37. package/src/lib/local-tree-diff.ts +17 -1
  38. package/src/types.ts +23 -0
  39. package/src/utils/vault-api.ts +41 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * US-006 — `hq install marketplace:<slug>` transport.
3
+ *
4
+ * All network is mocked via injected `MarketplaceDeps` (resolveListing /
5
+ * refreshListing / download); the only real I/O is the local filesystem
6
+ * (tarball creation, safe-extract, install + symlink wiring) under per-test
7
+ * tmp dirs. These tests never hit the real listings API or S3.
8
+ *
9
+ * E2E behaviors covered:
10
+ * - Approved listing → `hq install marketplace:<slug>` installs; contributes
11
+ * (skills/workers/knowledge) appear under core/packages/<name>/.
12
+ * - Pack declaring hooks → the hook-consent gate fires before wiring.
13
+ * - Installed marketplace pack → `hq packs update` (resolveLatestMarketplace)
14
+ * checks for a newer listing version.
15
+ * - Tarball whose bytes don't match the approved hash → refuses, wires
16
+ * nothing (ArtifactVerificationError, no core/packages/<name>/).
17
+ * - Expired presigned URL → re-resolve once and retry; raw S3 403 never leaks.
18
+ * - REGRESSION: legacy/local transport still classifies + installs unchanged
19
+ * after adding the marketplace transport.
20
+ */
21
+
22
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23
+ import * as fs from 'node:fs';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { execFileSync } from 'node:child_process';
27
+ import { createHash } from 'node:crypto';
28
+
29
+ // Mock readline so the hook-consent prompt is non-interactive. `consentAnswer`
30
+ // is read at prompt time, so each test can set it before installing. ESM
31
+ // disallows spying on the live `createInterface` export, so we replace the
32
+ // module instead.
33
+ let consentAnswer = 'n';
34
+ vi.mock('node:readline', () => ({
35
+ createInterface: () => ({
36
+ question: (_q: string, cb: (a: string) => void) => cb(consentAnswer),
37
+ close: () => undefined,
38
+ }),
39
+ }));
40
+
41
+ import {
42
+ classify,
43
+ parseMarketplaceSource,
44
+ toMarketplaceListing,
45
+ fetchMarketplace,
46
+ resolveLatestMarketplace,
47
+ installPack,
48
+ ArtifactVerificationError,
49
+ type MarketplaceDeps,
50
+ type MarketplaceListing,
51
+ } from './pack-install.js';
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Fixtures
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** A minimal, valid pack payload that validateManifest accepts. */
58
+ function writePackPayload(
59
+ root: string,
60
+ opts: { name?: string; version?: string; withHooks?: boolean } = {},
61
+ ): string {
62
+ const name = opts.name ?? 'hq-pack-demo';
63
+ const version = opts.version ?? '1.0.0';
64
+ const dir = path.join(root, 'payload');
65
+ fs.mkdirSync(path.join(dir, 'skills', 'demo'), { recursive: true });
66
+ fs.writeFileSync(path.join(dir, 'skills', 'demo', 'SKILL.md'), '# demo skill\n');
67
+ const contributes: string[] = ['contributes:', ' skills:', ' - demo'];
68
+ if (opts.withHooks) {
69
+ fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true });
70
+ fs.writeFileSync(path.join(dir, 'hooks', 'pre.sh'), '#!/bin/sh\necho hi\n');
71
+ contributes.push(' hooks:', ' - pre');
72
+ }
73
+ fs.writeFileSync(
74
+ path.join(dir, 'package.yaml'),
75
+ [
76
+ `name: ${name}`,
77
+ `version: ${version}`,
78
+ "publisher: '@indigoai-us'",
79
+ 'access: public',
80
+ 'requires:',
81
+ " hqCore: '>=1.0.0'",
82
+ ...contributes,
83
+ '',
84
+ ].join('\n'),
85
+ );
86
+ return dir;
87
+ }
88
+
89
+ /** tar -czf payloadDir contents at archive root (matches publish.ts packing). */
90
+ function tarballOf(payloadDir: string): Uint8Array {
91
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-tar-'));
92
+ const tarPath = path.join(tmp, 'pack.tar.gz');
93
+ try {
94
+ execFileSync(
95
+ 'tar',
96
+ ['-czf', tarPath, '-C', payloadDir, '--exclude=.DS_Store', '.'],
97
+ { stdio: ['ignore', 'ignore', 'inherit'] },
98
+ );
99
+ return fs.readFileSync(tarPath);
100
+ } finally {
101
+ fs.rmSync(tmp, { recursive: true, force: true });
102
+ }
103
+ }
104
+
105
+ function sha256Hex(bytes: Uint8Array): string {
106
+ return createHash('sha256').update(bytes).digest('hex');
107
+ }
108
+
109
+ /** Build injected deps that serve `bytes` for `slug` with the matching hash. */
110
+ function makeDeps(
111
+ slug: string,
112
+ bytes: Uint8Array,
113
+ opts: {
114
+ version?: string;
115
+ contentHash?: string; // override to simulate tamper
116
+ latestVersion?: string; // for update probe
117
+ expireFirstDownload?: boolean;
118
+ } = {},
119
+ ): { deps: MarketplaceDeps; calls: { resolve: number; refresh: number; download: number } } {
120
+ const calls = { resolve: 0, refresh: 0, download: 0 };
121
+ const detail: MarketplaceListing = {
122
+ listingId: `lst_${slug}`,
123
+ slug,
124
+ version: opts.version ?? '1.0.0',
125
+ downloadUrl: 'https://s3.example/presigned?sig=abc',
126
+ contentHash: opts.contentHash ?? sha256Hex(bytes),
127
+ };
128
+ let firstDownload = true;
129
+ return {
130
+ calls,
131
+ deps: {
132
+ resolveListing: async (s, v) => {
133
+ calls.resolve++;
134
+ expect(s).toBe(slug);
135
+ // For the update probe we resolve with NO version → report latest.
136
+ if (v === undefined && opts.latestVersion) {
137
+ return { ...detail, version: opts.latestVersion };
138
+ }
139
+ return { ...detail };
140
+ },
141
+ refreshListing: async (id) => {
142
+ calls.refresh++;
143
+ expect(id).toBe(detail.listingId);
144
+ return { ...detail, downloadUrl: 'https://s3.example/presigned?sig=fresh' };
145
+ },
146
+ download: async (_url) => {
147
+ calls.download++;
148
+ if (opts.expireFirstDownload && firstDownload) {
149
+ firstDownload = false;
150
+ return { expired: true };
151
+ }
152
+ return bytes;
153
+ },
154
+ },
155
+ };
156
+ }
157
+
158
+ /** A fake HQ root with `.claude/` so findHqRoot() resolves to it. */
159
+ function mkFakeHq(): string {
160
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-hq-'));
161
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
162
+ fs.writeFileSync(path.join(root, 'core.yaml'), 'hqVersion: 99.0.0\n');
163
+ return root;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // parse + classify
168
+ // ---------------------------------------------------------------------------
169
+
170
+ describe('US-006 marketplace: source parsing + classification', () => {
171
+ it('classify recognizes marketplace: BEFORE every other transport', () => {
172
+ expect(classify('marketplace:my-pack')).toBe('marketplace');
173
+ expect(classify('marketplace:my-pack@1.2.3')).toBe('marketplace');
174
+ // A marketplace source must never be misread as npm/git/local.
175
+ expect(classify('@scope/name')).toBe('npm');
176
+ expect(classify('./local')).toBe('local');
177
+ });
178
+
179
+ it('parseMarketplaceSource splits slug and optional version', () => {
180
+ expect(parseMarketplaceSource('marketplace:foo')).toEqual({ slug: 'foo' });
181
+ expect(parseMarketplaceSource('marketplace:foo@2.0.0')).toEqual({
182
+ slug: 'foo',
183
+ version: '2.0.0',
184
+ });
185
+ });
186
+
187
+ it('parseMarketplaceSource rejects an empty slug', () => {
188
+ expect(() => parseMarketplaceSource('marketplace:')).toThrow(/requires a slug/);
189
+ });
190
+
191
+ it('toMarketplaceListing refuses a listing with no content hash', () => {
192
+ expect(() =>
193
+ toMarketplaceListing({ id: 'lst_x', downloadUrl: 'https://s3/x' }),
194
+ ).toThrow(/no content hash/);
195
+ });
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // fetchMarketplace — verify-before-extract
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe('US-006 marketplace: fetchMarketplace', () => {
203
+ let hq: string;
204
+ let tmpDir: string;
205
+
206
+ beforeEach(() => {
207
+ hq = mkFakeHq();
208
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-fetch-'));
209
+ });
210
+ afterEach(() => {
211
+ fs.rmSync(hq, { recursive: true, force: true });
212
+ fs.rmSync(tmpDir, { recursive: true, force: true });
213
+ });
214
+
215
+ it('downloads, verifies hash, and safe-extracts to a payloadDir with package.yaml', async () => {
216
+ const payload = writePackPayload(hq);
217
+ const bytes = tarballOf(payload);
218
+ const { deps } = makeDeps('demo', bytes, { version: '1.0.0' });
219
+
220
+ const result = await fetchMarketplace('marketplace:demo', tmpDir, deps);
221
+
222
+ expect(fs.existsSync(path.join(result.payloadDir, 'package.yaml'))).toBe(true);
223
+ expect(fs.existsSync(path.join(result.payloadDir, 'skills', 'demo', 'SKILL.md'))).toBe(
224
+ true,
225
+ );
226
+ // Resolved source records the marketplace version for re-install/update.
227
+ expect(result.resolvedSource).toBe('marketplace:demo@1.0.0');
228
+ });
229
+
230
+ it('REFUSES + extracts nothing when the bytes do not match the approved hash', async () => {
231
+ const payload = writePackPayload(hq);
232
+ const bytes = tarballOf(payload);
233
+ // Listing pins a DIFFERENT hash than the bytes we serve (tamper).
234
+ const wrongHash = 'a'.repeat(64);
235
+ const { deps } = makeDeps('demo', bytes, { contentHash: wrongHash });
236
+
237
+ await expect(fetchMarketplace('marketplace:demo', tmpDir, deps)).rejects.toThrow(
238
+ ArtifactVerificationError,
239
+ );
240
+ // No extract dir was produced — nothing unpacked.
241
+ expect(fs.existsSync(path.join(tmpDir, 'extracted'))).toBe(false);
242
+ });
243
+
244
+ it('re-resolves a FRESH presigned URL on an expired (403) download, then succeeds', async () => {
245
+ const payload = writePackPayload(hq);
246
+ const bytes = tarballOf(payload);
247
+ const { deps, calls } = makeDeps('demo', bytes, { expireFirstDownload: true });
248
+
249
+ const result = await fetchMarketplace('marketplace:demo', tmpDir, deps);
250
+
251
+ expect(fs.existsSync(path.join(result.payloadDir, 'package.yaml'))).toBe(true);
252
+ expect(calls.refresh).toBe(1); // re-resolved once
253
+ expect(calls.download).toBe(2); // first 403, second OK
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // installPack — full E2E through the existing local-install path
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe('US-006 marketplace: installPack E2E', () => {
262
+ let hq: string;
263
+ let prevCwd: string;
264
+
265
+ beforeEach(() => {
266
+ hq = mkFakeHq();
267
+ prevCwd = process.cwd();
268
+ process.chdir(hq); // findHqRoot() resolves to hq via .claude/
269
+ });
270
+ afterEach(() => {
271
+ process.chdir(prevCwd);
272
+ fs.rmSync(hq, { recursive: true, force: true });
273
+ vi.restoreAllMocks();
274
+ });
275
+
276
+ it('approved listing → installs; contributes land under core/packages/<name>/ and source is stamped', async () => {
277
+ const payload = writePackPayload(hq, { name: 'hq-pack-demo', version: '1.0.0' });
278
+ const bytes = tarballOf(payload);
279
+ const { deps } = makeDeps('demo', bytes, { version: '1.0.0' });
280
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
281
+
282
+ await installPack('marketplace:demo', { marketplaceDeps: deps });
283
+
284
+ const dest = path.join(hq, 'core', 'packages', 'hq-pack-demo');
285
+ expect(fs.existsSync(path.join(dest, 'skills', 'demo', 'SKILL.md'))).toBe(true);
286
+ // modules.yaml provenance equivalent: the resolved marketplace source is
287
+ // stamped into the installed package.yaml so re-install + packs update work.
288
+ const stamped = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
289
+ expect(stamped.split('\n')[0]).toBe('source: "marketplace:demo@1.0.0"');
290
+ });
291
+
292
+ it('pack declaring hooks → hook-consent gate fires BEFORE wiring (declines → nothing installed)', async () => {
293
+ const payload = writePackPayload(hq, { name: 'hq-pack-hooked', withHooks: true });
294
+ const bytes = tarballOf(payload);
295
+ const { deps } = makeDeps('hooked', bytes);
296
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
297
+
298
+ // Consent prompt DECLINES (simulates the gate firing).
299
+ consentAnswer = 'n';
300
+ await installPack('marketplace:hooked', { marketplaceDeps: deps });
301
+
302
+ // Declined at the consent gate → pack must NOT be wired.
303
+ expect(fs.existsSync(path.join(hq, 'core', 'packages', 'hq-pack-hooked'))).toBe(false);
304
+ });
305
+
306
+ it('pack declaring hooks → consent accepted installs the pack (gate not bypassed)', async () => {
307
+ const payload = writePackPayload(hq, { name: 'hq-pack-hooked', withHooks: true });
308
+ const bytes = tarballOf(payload);
309
+ const { deps } = makeDeps('hooked', bytes);
310
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
311
+
312
+ // Consent prompt ACCEPTS — gate fired and was approved (not bypassed).
313
+ consentAnswer = 'y';
314
+ await installPack('marketplace:hooked', { marketplaceDeps: deps });
315
+
316
+ expect(fs.existsSync(path.join(hq, 'core', 'packages', 'hq-pack-hooked'))).toBe(true);
317
+ });
318
+
319
+ it('tampered bytes (hash mismatch) → installPack refuses; nothing wired', async () => {
320
+ const payload = writePackPayload(hq, { name: 'hq-pack-demo' });
321
+ const bytes = tarballOf(payload);
322
+ const { deps } = makeDeps('demo', bytes, { contentHash: 'b'.repeat(64) });
323
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
324
+
325
+ await expect(
326
+ installPack('marketplace:demo', { marketplaceDeps: deps }),
327
+ ).rejects.toThrow(ArtifactVerificationError);
328
+
329
+ expect(fs.existsSync(path.join(hq, 'core', 'packages', 'hq-pack-demo'))).toBe(false);
330
+ });
331
+ });
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // hq packs update — async marketplace probe
335
+ // ---------------------------------------------------------------------------
336
+
337
+ describe('US-006 marketplace: update probe', () => {
338
+ it('reports a newer listing version as updateAvailable', async () => {
339
+ const dummy = new Uint8Array([1, 2, 3]);
340
+ const { deps } = makeDeps('demo', dummy, { latestVersion: '2.0.0' });
341
+
342
+ const r = await resolveLatestMarketplace('marketplace:demo@1.0.0', '1.0.0', deps);
343
+
344
+ expect(r.transport).toBe('marketplace');
345
+ expect(r.current).toBe('1.0.0');
346
+ expect(r.latest).toBe('2.0.0');
347
+ expect(r.updateAvailable).toBe(true);
348
+ });
349
+
350
+ it('reports no update when the listing version matches', async () => {
351
+ const dummy = new Uint8Array([1, 2, 3]);
352
+ const { deps } = makeDeps('demo', dummy, { latestVersion: '1.0.0' });
353
+
354
+ const r = await resolveLatestMarketplace('marketplace:demo@1.0.0', '1.0.0', deps);
355
+
356
+ expect(r.updateAvailable).toBe(false);
357
+ });
358
+
359
+ it('never throws on a network failure — returns updateAvailable:null + error', async () => {
360
+ const deps: MarketplaceDeps = {
361
+ resolveListing: async () => {
362
+ throw new Error('network down');
363
+ },
364
+ refreshListing: async () => {
365
+ throw new Error('network down');
366
+ },
367
+ download: async () => new Uint8Array(),
368
+ };
369
+
370
+ const r = await resolveLatestMarketplace('marketplace:demo@1.0.0', '1.0.0', deps);
371
+
372
+ expect(r.updateAvailable).toBeNull();
373
+ expect(r.error).toMatch(/network down/);
374
+ });
375
+ });
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // REGRESSION — legacy transports unchanged by adding marketplace
379
+ // ---------------------------------------------------------------------------
380
+
381
+ describe('US-006 REGRESSION: legacy transports still dispatch + install unchanged', () => {
382
+ it('classify still routes npm / git / local exactly as before', () => {
383
+ expect(classify('@scope/name@1.0.0')).toBe('npm');
384
+ expect(classify('https://github.com/o/r.git')).toBe('git');
385
+ expect(classify('github:owner/repo')).toBe('git');
386
+ expect(classify('git@github.com:o/r.git')).toBe('git');
387
+ expect(classify('./path')).toBe('local');
388
+ expect(classify('/abs/path')).toBe('local');
389
+ expect(classify('file:./x')).toBe('local');
390
+ // Bare slug still rejected by classify → routed to the legacy registry flow.
391
+ expect(() => classify('bare-slug')).toThrow(/Bare slugs go through the registry/);
392
+ });
393
+
394
+ it('local transport still installs end-to-end (no marketplace deps involved)', async () => {
395
+ const hq = mkFakeHq();
396
+ const payload = writePackPayload(hq, { name: 'hq-pack-local' });
397
+ const prevCwd = process.cwd();
398
+ process.chdir(hq);
399
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
400
+ try {
401
+ // Point installPack at the local payload dir — the legacy local path.
402
+ await installPack(payload);
403
+ const dest = path.join(hq, 'core', 'packages', 'hq-pack-local');
404
+ expect(fs.existsSync(path.join(dest, 'skills', 'demo', 'SKILL.md'))).toBe(true);
405
+ // Local source stamped verbatim (NOT rewritten as a marketplace source).
406
+ const stamped = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
407
+ expect(stamped.split('\n')[0]).toBe(`source: "${payload}"`);
408
+ } finally {
409
+ process.chdir(prevCwd);
410
+ fs.rmSync(hq, { recursive: true, force: true });
411
+ vi.restoreAllMocks();
412
+ }
413
+ });
414
+ });