@indigoai-us/hq-cli 5.32.0 → 5.33.1

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.
@@ -21,7 +21,17 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
21
21
  import * as fs from 'fs';
22
22
  import * as os from 'os';
23
23
  import * as path from 'path';
24
- import { installToPackages, runScanPackages, stampInstallSource } from './pack-install.js';
24
+ import {
25
+ installToPackages,
26
+ runScanPackages,
27
+ stampInstallSource,
28
+ validateManifest,
29
+ toMarketplaceListing,
30
+ fetchMarketplace,
31
+ computeArtifactHash,
32
+ ArtifactVerificationError,
33
+ } from './pack-install.js';
34
+ import type { MarketplaceDeps } from './pack-install.js';
25
35
  import type { PackManifest } from '../types.js';
26
36
 
27
37
  // ---------------------------------------------------------------------------
@@ -222,6 +232,81 @@ describe('pack-install: install path layout', () => {
222
232
  expect(() => stampInstallSource(dest, '@scope/x@1.0.0')).toThrow(/package\.yaml.*not found/);
223
233
  });
224
234
 
235
+ // ---- 6. author + capabilities attribution (US-001) ----------------------
236
+ // Records pack authorship so installs can be attributed. Both fields are
237
+ // OPTIONAL and backwards-compatible: legacy packs without an author still
238
+ // validate; a pack that declares an author surfaces uid/handle/displayName.
239
+ describe('author + capabilities (US-001)', () => {
240
+ // A minimal, otherwise-valid pack payload that validateManifest accepts.
241
+ // `contributes` declares one knowledge entry whose payload file exists, so
242
+ // the only variable across these tests is the author/capabilities block.
243
+ function writeValidPack(extraYaml: string): string {
244
+ const dir = mkFakePackPayload({
245
+ 'knowledge/demo/README.md': '# demo knowledge',
246
+ });
247
+ fs.writeFileSync(
248
+ path.join(dir, 'package.yaml'),
249
+ [
250
+ 'name: hq-pack-test',
251
+ 'version: 1.0.0',
252
+ "publisher: '@indigoai-us'",
253
+ 'access: public',
254
+ 'requires:',
255
+ " hqCore: '>=12.0.0'",
256
+ 'contributes:',
257
+ ' knowledge:',
258
+ ' - demo',
259
+ extraYaml,
260
+ '',
261
+ ].join('\n'),
262
+ );
263
+ return dir;
264
+ }
265
+
266
+ it('a manifest WITHOUT author validates (backwards-compatible)', () => {
267
+ const dir = writeValidPack('');
268
+ const m = validateManifest(dir, '12.0.0');
269
+ expect(m.name).toBe('hq-pack-test');
270
+ expect(m.author).toBeUndefined();
271
+ expect(m.capabilities).toBeUndefined();
272
+ fs.rmSync(dir, { recursive: true, force: true });
273
+ });
274
+
275
+ it('a manifest WITH an author block surfaces uid/handle/displayName', () => {
276
+ const dir = writeValidPack(
277
+ [
278
+ 'author:',
279
+ ' uid: prs_abc123',
280
+ ' handle: corey',
281
+ " displayName: 'Corey Epstein'",
282
+ 'capabilities:',
283
+ ' - hooks',
284
+ ' - network',
285
+ ].join('\n'),
286
+ );
287
+ const m = validateManifest(dir, '12.0.0');
288
+ expect(m.author).toEqual({
289
+ uid: 'prs_abc123',
290
+ handle: 'corey',
291
+ displayName: 'Corey Epstein',
292
+ });
293
+ expect(m.capabilities).toEqual(['hooks', 'network']);
294
+ fs.rmSync(dir, { recursive: true, force: true });
295
+ });
296
+
297
+ it('a malformed author (missing displayName) is rejected — not silently accepted', () => {
298
+ const dir = writeValidPack(['author:', ' uid: prs_abc123', ' handle: corey'].join('\n'));
299
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(/author\.displayName/);
300
+ fs.rmSync(dir, { recursive: true, force: true });
301
+ });
302
+
303
+ it('capabilities must be a list of strings', () => {
304
+ const dir = writeValidPack(['capabilities:', ' hooks: true'].join('\n'));
305
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(/capabilities must be a list/);
306
+ fs.rmSync(dir, { recursive: true, force: true });
307
+ });
308
+ });
309
+
225
310
  it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
226
311
  // Regression: prepending `source:` before a `---` would split the file
227
312
  // into two YAML documents, and single-doc `yaml.load` callers downstream
@@ -260,3 +345,126 @@ describe('pack-install: install path layout', () => {
260
345
  expect(rewritten.split('\n')[0]).toBe('---');
261
346
  });
262
347
  });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // GET /v1/listings/{id} detail-envelope mapping (Bug 1: contract/envelope)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe('pack-install: listing-detail envelope mapping (toMarketplaceListing)', () => {
354
+ it('maps from the `{ listing: {...} }` envelope (id + downloadUrl + contentHash)', () => {
355
+ // Prod shape: GET /v1/listings/{id} wraps the listing in an envelope and
356
+ // uses `id` (not `listingId`). Previously this threw "Listing detail
357
+ // missing an id." because the mapper read from the top level.
358
+ const listing = toMarketplaceListing({
359
+ listing: {
360
+ id: 'lst_abc123',
361
+ slug: 'hq-pack-test',
362
+ version: '1.2.3',
363
+ downloadUrl: 'https://s3.example/presigned',
364
+ contentHash: 'a'.repeat(64),
365
+ contentHashAlg: 'sha256',
366
+ signature: 'sig-base64',
367
+ signingKeyId: 'key-1',
368
+ publicKey: 'pub-pem',
369
+ },
370
+ });
371
+ expect(listing.listingId).toBe('lst_abc123');
372
+ expect(listing.downloadUrl).toBe('https://s3.example/presigned');
373
+ expect(listing.contentHash).toBe('a'.repeat(64));
374
+ expect(listing.contentHashAlg).toBe('sha256');
375
+ expect(listing.slug).toBe('hq-pack-test');
376
+ expect(listing.version).toBe('1.2.3');
377
+ expect(listing.signature).toBe('sig-base64');
378
+ expect(listing.signingKeyId).toBe('key-1');
379
+ expect(listing.publicKey).toBe('pub-pem');
380
+ });
381
+
382
+ it('still maps the legacy FLAT shape at the top level (backwards-compat)', () => {
383
+ const listing = toMarketplaceListing({
384
+ listingId: 'lst_legacy',
385
+ slug: 'hq-pack-old',
386
+ version: '0.9.0',
387
+ downloadUrl: 'https://s3.example/legacy',
388
+ contentHash: 'b'.repeat(64),
389
+ });
390
+ expect(listing.listingId).toBe('lst_legacy');
391
+ expect(listing.downloadUrl).toBe('https://s3.example/legacy');
392
+ expect(listing.contentHash).toBe('b'.repeat(64));
393
+ });
394
+
395
+ it('throws the missing-id error only when neither envelope nor flat carry an id', () => {
396
+ expect(() =>
397
+ toMarketplaceListing({ listing: { downloadUrl: 'x', contentHash: 'c'.repeat(64) } }),
398
+ ).toThrow(/missing an id/);
399
+ });
400
+
401
+ it('refuses (no content hash) when the unwrapped detail lacks contentHash', () => {
402
+ expect(() =>
403
+ toMarketplaceListing({
404
+ listing: { id: 'lst_nohash', downloadUrl: 'https://s3.example/x' },
405
+ }),
406
+ ).toThrow(/no content hash/);
407
+ });
408
+ });
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // fetchMarketplace integrity: envelope-mapped hash verifies / refuses
412
+ // ---------------------------------------------------------------------------
413
+
414
+ describe('pack-install: fetchMarketplace honors the envelope-mapped contentHash', () => {
415
+ function depsFor(
416
+ envelopeContentHash: string,
417
+ tarballBytes: Uint8Array,
418
+ ): MarketplaceDeps {
419
+ // resolveListing returns the envelope-mapped detail; download returns the
420
+ // bytes. fetchMarketplace verifies the bytes against the pinned hash.
421
+ const listing = toMarketplaceListing({
422
+ listing: {
423
+ id: 'lst_int',
424
+ slug: 'hq-pack-int',
425
+ version: '1.0.0',
426
+ downloadUrl: 'https://s3.example/int',
427
+ contentHash: envelopeContentHash,
428
+ contentHashAlg: 'sha256',
429
+ },
430
+ });
431
+ return {
432
+ resolveListing: async () => listing,
433
+ refreshListing: async () => listing,
434
+ download: async () => tarballBytes,
435
+ };
436
+ }
437
+
438
+ it('a tampered/mismatched hash still refuses (ArtifactVerificationError)', async () => {
439
+ const bytes = new Uint8Array([1, 2, 3, 4]);
440
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-int-'));
441
+ try {
442
+ await expect(
443
+ fetchMarketplace(
444
+ 'marketplace:hq-pack-int@1.0.0',
445
+ tmpDir,
446
+ depsFor('f'.repeat(64), bytes), // wrong hash
447
+ ),
448
+ ).rejects.toBeInstanceOf(ArtifactVerificationError);
449
+ } finally {
450
+ fs.rmSync(tmpDir, { recursive: true, force: true });
451
+ }
452
+ });
453
+
454
+ it('the correct envelope-mapped hash passes verification (reaches extraction)', async () => {
455
+ const bytes = new Uint8Array([1, 2, 3, 4]);
456
+ const realHash = computeArtifactHash(bytes);
457
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-int-'));
458
+ try {
459
+ // The 4 junk bytes are not a real gzip tarball, so extraction fails AFTER
460
+ // verification — proving the hash check passed (it did NOT throw the
461
+ // verification error first). A verification failure would reject with
462
+ // ArtifactVerificationError instead.
463
+ await expect(
464
+ fetchMarketplace('marketplace:hq-pack-int@1.0.0', tmpDir, depsFor(realHash, bytes)),
465
+ ).rejects.not.toBeInstanceOf(ArtifactVerificationError);
466
+ } finally {
467
+ fs.rmSync(tmpDir, { recursive: true, force: true });
468
+ }
469
+ });
470
+ });