@indigoai-us/hq-cli 5.35.0 → 5.35.2

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.
@@ -379,6 +379,84 @@ describe("patchManifest", () => {
379
379
  bucket_name: "hq-vault-cmp-NEW",
380
380
  });
381
381
  });
382
+
383
+ it("returns true when it actually changes a value", () => {
384
+ expect(patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H")).toBe(
385
+ true,
386
+ );
387
+ });
388
+
389
+ // ── Conflict-loop regression ───────────────────────────────────────────────
390
+ // Repro for the recurring HQ Sync conflict loop on companies/manifest.yaml:
391
+ // a re-provision / menubar Connect on an already-provisioned company used to
392
+ // unconditionally reserialize the manifest via yaml.dump — stripping comments
393
+ // and reflowing — even though nothing changed. The initial-sync step then
394
+ // pushed that comment-stripped form, so it perpetually diverged from any
395
+ // peer/cloud copy still holding the commented form, re-firing a conflict on
396
+ // every sync. The fix: skip the write entirely when values already match.
397
+ describe("conflict-loop regression — no-op re-provision must not churn the file", () => {
398
+ // A manifest in the on-disk shape /newcompany writes: header comment, blank
399
+ // lines, per-entry comment — none of which yaml.dump round-trips.
400
+ const COMMENTED_MANIFEST = `# HQ companies manifest — source of truth for routing.
401
+ # Synced across machines via the personal vault; edit with care.
402
+
403
+ companies:
404
+ indigo:
405
+ name: Indigo
406
+ status: active
407
+ cloud_uid: cmp_01H
408
+ bucket_name: hq-vault-cmp-01H
409
+ acme:
410
+ name: Acme Corp
411
+ status: active
412
+ `;
413
+
414
+ beforeEach(() => {
415
+ const mPath = manifestPath(tmpRoot);
416
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
417
+ fs.writeFileSync(mPath, COMMENTED_MANIFEST);
418
+ });
419
+
420
+ it("returns false and leaves the file byte-for-byte intact (comments preserved) when values already match", () => {
421
+ const changed = patchManifest(
422
+ tmpRoot,
423
+ "indigo",
424
+ "cmp_01H",
425
+ "hq-vault-cmp-01H",
426
+ );
427
+ expect(changed).toBe(false);
428
+ // The whole point: no rewrite at all — comments + layout survive, so the
429
+ // synced bytes never diverge and no conflict is seeded.
430
+ expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(
431
+ COMMENTED_MANIFEST,
432
+ );
433
+ });
434
+
435
+ it("re-running many times never churns the on-disk bytes (loop cannot start)", () => {
436
+ for (let i = 0; i < 5; i++) {
437
+ patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
438
+ }
439
+ expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(
440
+ COMMENTED_MANIFEST,
441
+ );
442
+ });
443
+
444
+ it("a genuine change writes once, then converges — subsequent re-runs are no-ops", () => {
445
+ // First real change (acme had no cloud_uid) writes once and returns true.
446
+ expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
447
+ true,
448
+ );
449
+ const afterFirst = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
450
+ // Every subsequent identical provision is a no-op — stable bytes, false.
451
+ expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
452
+ false,
453
+ );
454
+ expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
455
+ false,
456
+ );
457
+ expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(afterFirst);
458
+ });
459
+ });
382
460
  });
383
461
 
384
462
  // ── writeCompanyConfig ───────────────────────────────────────────────────────
@@ -388,11 +388,20 @@ export interface ManifestCompanyEntry {
388
388
  * under the target slug. Read → mutate → temp-write → rename so concurrent
389
389
  * readers never see a partially-written file.
390
390
  *
391
- * Idempotent: if the values already match, this is a no-op (still rewrites
392
- * the file to canonical YAML, but the mutation is identical).
391
+ * Skip-if-unchanged: if the target slug already carries the exact
392
+ * `cloud_uid` + `bucket_name`, this is a true no-op — the file is left
393
+ * byte-for-byte intact (comments, ordering, and formatting preserved) and
394
+ * we return `false`. This matters because `yaml.dump` does NOT round-trip
395
+ * comments or the original layout: an unconditional rewrite re-canonicalises
396
+ * the manifest (stripping the `/newcompany` header comment, reflowing keys)
397
+ * on EVERY provision/Connect, even when nothing semantically changed. Each
398
+ * such rewrite is then pushed by the initial-sync step, so the
399
+ * comment-stripped local form perpetually diverges from any peer/cloud copy
400
+ * that still holds the commented form — manifesting as a recurring HQ Sync
401
+ * conflict loop on `companies/manifest.yaml` that re-fires every sync. Only
402
+ * writing when a value actually changes lets the two forms converge.
393
403
  *
394
- * Returns true if the file was written (always true in current impl
395
- * reserved for future "skip if unchanged" optimization).
404
+ * Returns true if the file was written, false if it was already current.
396
405
  */
397
406
  export function patchManifest(
398
407
  hqRoot: string,
@@ -408,6 +417,14 @@ export function patchManifest(
408
417
  // Preserve null / object / unknown — promote null → {} so we can write keys.
409
418
  const entry: ManifestCompanyEntry =
410
419
  existing && typeof existing === "object" ? { ...existing } : {};
420
+
421
+ // No-op guard: both fields already match → leave the on-disk file (and its
422
+ // comments) untouched so a re-provision can't churn the manifest and seed a
423
+ // sync conflict loop.
424
+ if (entry.cloud_uid === cloudUid && entry.bucket_name === bucketName) {
425
+ return false;
426
+ }
427
+
411
428
  entry.cloud_uid = cloudUid;
412
429
  entry.bucket_name = bucketName;
413
430
  parsed.companies[slug] = entry;
@@ -757,9 +774,20 @@ export async function provisionCompany(
757
774
  const bucketName = entity.bucketName;
758
775
  const kmsKeyId = entity.kmsKeyId ?? null;
759
776
 
760
- // Step 6: patch manifest atomically
761
- patchManifest(options.hqRoot, options.slug, cloudUid, bucketName);
762
- log(`patched companies/manifest.yaml`);
777
+ // Step 6: patch manifest atomically. Skip-if-unchanged returns false when
778
+ // the manifest already carries this slug's cloud_uid + bucket_name, so we
779
+ // report the honest outcome rather than always claiming a patch.
780
+ const manifestPatched = patchManifest(
781
+ options.hqRoot,
782
+ options.slug,
783
+ cloudUid,
784
+ bucketName,
785
+ );
786
+ log(
787
+ manifestPatched
788
+ ? `patched companies/manifest.yaml`
789
+ : `companies/manifest.yaml already current — left untouched`,
790
+ );
763
791
 
764
792
  // Step 7: write .hq/config.json atomically
765
793
  writeCompanyConfig(options.hqRoot, options.slug, {
@@ -821,7 +849,7 @@ export async function provisionCompany(
821
849
  vault_api_url: options.vaultApiUrl,
822
850
  kms_key_id: kmsKeyId,
823
851
  created_entity: createdEntity,
824
- manifest_patched: true,
852
+ manifest_patched: manifestPatched,
825
853
  config_written: true,
826
854
  initial_sync: initialSync,
827
855
  };
@@ -45,6 +45,34 @@ import {
45
45
  type BannerLevel,
46
46
  } from "../lib/narrow-hint-banner.js";
47
47
 
48
+ /**
49
+ * Resolve the `propagateDeletePolicy` for share() calls.
50
+ *
51
+ * Mirrors `@indigoai-us/hq-cloud`'s `bin/sync-runner.js` `resolveDeletePolicy`
52
+ * — the same function the AppBar HQ Sync menubar uses — so the CLI and the
53
+ * menubar agree on the policy by default. Inlined here (rather than
54
+ * deep-imported from `dist/bin/sync-runner.js`) to avoid coupling to a
55
+ * non-public subpath.
56
+ *
57
+ * Default is `"currency-gated"` (etag-verified safe delete). Override via
58
+ * the `HQ_SYNC_DELETE_POLICY` env var: `owned-only` (legacy direction-of-
59
+ * origin filter, multi-user safe but stranded down-stream litter on
60
+ * personal vaults until hq-cloud 6.0.1's prs_ override), `currency-gated`
61
+ * (the menubar default), or `all` (emergency-reconcile, no safety gates).
62
+ *
63
+ * Prior to this helper, the CLI passed nothing → share() fell through to
64
+ * its own `"owned-only"` default, leaving CLI users on a stricter (and
65
+ * sometimes wrong, on personal vault) policy than menubar users for
66
+ * months. See `workspace/reports/owned-only-delete-policy-purpose-debug.md`.
67
+ */
68
+ function resolveDeletePolicy(): "owned-only" | "currency-gated" | "all" {
69
+ const env = process.env.HQ_SYNC_DELETE_POLICY;
70
+ if (env === "owned-only" || env === "all" || env === "currency-gated") {
71
+ return env;
72
+ }
73
+ return "currency-gated";
74
+ }
75
+
48
76
  interface CommonSyncOptions {
49
77
  hqRoot: string;
50
78
  company?: string;
@@ -164,6 +192,7 @@ export interface ShareCallOptions {
164
192
  message?: string;
165
193
  skipUnchanged?: boolean;
166
194
  propagateDeletes?: boolean;
195
+ propagateDeletePolicy?: "owned-only" | "currency-gated" | "all";
167
196
  }
168
197
 
169
198
  export interface ShareCallResult {
@@ -397,6 +426,7 @@ export async function pushAll(
397
426
  paths: [path.join(options.hqRoot, "companies", slug)],
398
427
  skipUnchanged: true,
399
428
  propagateDeletes: true,
429
+ propagateDeletePolicy: resolveDeletePolicy(),
400
430
  ...(options.onConflict ? { onConflict: options.onConflict } : {}),
401
431
  ...(options.message ? { message: options.message } : {}),
402
432
  },
@@ -415,6 +445,7 @@ export async function pushAll(
415
445
  journalSlug: "personal",
416
446
  skipUnchanged: true,
417
447
  propagateDeletes: true,
448
+ propagateDeletePolicy: resolveDeletePolicy(),
418
449
  ...(options.onConflict ? { onConflict: options.onConflict } : {}),
419
450
  ...(options.message ? { message: options.message } : {}),
420
451
  },
@@ -1406,6 +1437,9 @@ async function runPushAll(
1406
1437
  ...(opts.propagateDeletes !== undefined
1407
1438
  ? { propagateDeletes: opts.propagateDeletes }
1408
1439
  : {}),
1440
+ ...(opts.propagateDeletePolicy !== undefined
1441
+ ? { propagateDeletePolicy: opts.propagateDeletePolicy }
1442
+ : {}),
1409
1443
  ...(author ? { author } : {}),
1410
1444
  }),
1411
1445
  },
@@ -1505,6 +1539,7 @@ async function runNowSingle(
1505
1539
  hqRoot,
1506
1540
  skipUnchanged: true,
1507
1541
  propagateDeletes: true,
1542
+ propagateDeletePolicy: resolveDeletePolicy(),
1508
1543
  ...(onConflict ? { onConflict } : {}),
1509
1544
  ...(message ? { message } : {}),
1510
1545
  ...(personalMode ? { personalMode: true } : {}),
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { runCreatorApply } from "./creators.js";
3
+
4
+ function jsonResponse(status: number, body: unknown): Response {
5
+ return {
6
+ status,
7
+ json: async () => body,
8
+ } as unknown as Response;
9
+ }
10
+
11
+ describe("runCreatorApply", () => {
12
+ it("submits the application and reports the application id", async () => {
13
+ const post = vi
14
+ .fn()
15
+ .mockResolvedValue(
16
+ jsonResponse(202, { status: "request_received", applicationId: "capp_1" }),
17
+ );
18
+ const res = await runCreatorApply(
19
+ { reason: "I build automation skills" },
20
+ { getAccessToken: async () => "tok", post },
21
+ );
22
+ expect(res.message).toMatch(/submitted \(capp_1\)/);
23
+ expect(post).toHaveBeenCalledWith("tok", { reason: "I build automation skills" });
24
+ });
25
+
26
+ it("passes an optional handle through", async () => {
27
+ const post = vi.fn().mockResolvedValue(jsonResponse(202, { applicationId: "capp_2" }));
28
+ await runCreatorApply(
29
+ { reason: "pitch", handle: " acme " },
30
+ { getAccessToken: async () => "tok", post },
31
+ );
32
+ expect(post).toHaveBeenCalledWith("tok", { reason: "pitch", handle: "acme" });
33
+ });
34
+
35
+ it("treats a 409 / APPLICATION_PENDING as an already-pending message", async () => {
36
+ const post = vi
37
+ .fn()
38
+ .mockResolvedValue(jsonResponse(409, { code: "APPLICATION_PENDING", applicationId: "capp_3" }));
39
+ const res = await runCreatorApply(
40
+ { reason: "pitch" },
41
+ { getAccessToken: async () => "tok", post },
42
+ );
43
+ expect(res.message).toMatch(/already have a pending/i);
44
+ });
45
+
46
+ it("requires a non-empty reason", async () => {
47
+ const post = vi.fn();
48
+ await expect(
49
+ runCreatorApply({ reason: " " }, { getAccessToken: async () => "tok", post }),
50
+ ).rejects.toThrow(/reason is required/i);
51
+ expect(post).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("throws on a hard server error", async () => {
55
+ const post = vi.fn().mockResolvedValue(jsonResponse(500, { error: "boom" }));
56
+ await expect(
57
+ runCreatorApply({ reason: "pitch" }, { getAccessToken: async () => "tok", post }),
58
+ ).rejects.toThrow(/boom/);
59
+ });
60
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `hq creators apply` — apply for verified-creator access.
3
+ *
4
+ * Publishing to the marketplace is gated on verified-creator status. An
5
+ * unverified caller submits an application (a short pitch + optional desired
6
+ * handle); it is persisted server-side and an Indigo admin reviews it. On
7
+ * approval the caller becomes a verified creator and can `hq publish`.
8
+ *
9
+ * Wire contract (POST /v1/creators/request-access, authed):
10
+ * body { reason: string, handle?: string }
11
+ * 202 { status, code, applicationId, requestAccessPath }
12
+ * 409 { code: "APPLICATION_PENDING", error, applicationId } (already pending)
13
+ */
14
+ import { Command } from "commander";
15
+ import chalk from "chalk";
16
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
17
+ import { vaultApiFetch } from "../utils/vault-api.js";
18
+
19
+ export interface CreatorApplyOptions {
20
+ reason: string;
21
+ handle?: string;
22
+ }
23
+
24
+ export interface CreatorApplyDeps {
25
+ /** Resolve a non-expired access token (prompts login when interactive). */
26
+ getAccessToken: () => Promise<string>;
27
+ /** POST the application body to the vault API. */
28
+ post: (token: string, body: Record<string, unknown>) => Promise<Response>;
29
+ }
30
+
31
+ export interface CreatorApplyResult {
32
+ message: string;
33
+ }
34
+
35
+ /**
36
+ * Testable core. Submits the application and maps the server response to a
37
+ * single human-readable message. Throws on hard failures.
38
+ */
39
+ export async function runCreatorApply(
40
+ opts: CreatorApplyOptions,
41
+ deps: CreatorApplyDeps,
42
+ ): Promise<CreatorApplyResult> {
43
+ const reason = opts.reason?.trim();
44
+ if (!reason) {
45
+ throw new Error(
46
+ 'A reason is required — pass --reason "why you want to publish to the marketplace".',
47
+ );
48
+ }
49
+
50
+ const token = await deps.getAccessToken();
51
+ const body: Record<string, unknown> = { reason };
52
+ const handle = opts.handle?.trim();
53
+ if (handle) body.handle = handle;
54
+
55
+ const res = await deps.post(token, body);
56
+ const parsed = (await res
57
+ .json()
58
+ .catch(() => ({}))) as Record<string, unknown>;
59
+
60
+ if (res.status === 409 || parsed.code === "APPLICATION_PENDING") {
61
+ return {
62
+ message:
63
+ "You already have a pending creator application — an Indigo admin will review it.",
64
+ };
65
+ }
66
+ if (res.status < 200 || res.status >= 300) {
67
+ const msg =
68
+ (parsed.error as string) ?? (parsed.message as string) ?? `HTTP ${res.status}`;
69
+ throw new Error(`Creator application failed: ${msg}`);
70
+ }
71
+
72
+ const id =
73
+ typeof parsed.applicationId === "string" ? ` (${parsed.applicationId})` : "";
74
+ return {
75
+ message:
76
+ `Creator application submitted${id} — an Indigo admin will review it. ` +
77
+ "You'll be able to `hq publish` once approved.",
78
+ };
79
+ }
80
+
81
+ export function registerCreatorsCommand(program: Command): void {
82
+ const creators = program
83
+ .command("creators")
84
+ .description("Creator marketplace account");
85
+
86
+ creators
87
+ .command("apply")
88
+ .description(
89
+ "Apply for verified-creator access (required to publish packs)",
90
+ )
91
+ .requiredOption(
92
+ "--reason <reason>",
93
+ "Why you want to publish to the marketplace",
94
+ )
95
+ .option("--handle <handle>", "Desired creator handle (optional)")
96
+ .action(async (options: CreatorApplyOptions) => {
97
+ try {
98
+ const result = await runCreatorApply(
99
+ { reason: options.reason, handle: options.handle },
100
+ {
101
+ getAccessToken: () => ensureCognitoToken({ interactive: true }),
102
+ post: (token, body) =>
103
+ vaultApiFetch({
104
+ token,
105
+ path: "/v1/creators/request-access",
106
+ method: "POST",
107
+ body,
108
+ }),
109
+ },
110
+ );
111
+ console.log(chalk.green(result.message));
112
+ } catch (err) {
113
+ console.error(chalk.red((err as Error).message));
114
+ process.exitCode = 1;
115
+ }
116
+ });
117
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * hq login — opens browser for Cognito auth via Google OAuth
2
+ * hq login — opens browser for Cognito auth.
3
3
  */
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
7
  import { browserLogin, loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
8
- import { DEFAULT_COGNITO } from '../utils/cognito-session.js';
8
+ import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
9
9
 
10
10
  function peekIdToken(idToken: string): { email?: string; sub?: string } {
11
11
  try {
@@ -23,8 +23,12 @@ function peekIdToken(idToken: string): { email?: string; sub?: string } {
23
23
  export function registerLoginCommand(program: Command): void {
24
24
  program
25
25
  .command('login')
26
- .description('Authenticate with HQ via Cognito (Google OAuth)')
27
- .action(async () => {
26
+ .description('Authenticate with HQ via Cognito')
27
+ .option(
28
+ '--provider <provider>',
29
+ 'OAuth provider to use: google, microsoft, or picker',
30
+ )
31
+ .action(async (options: { provider?: string }) => {
28
32
  try {
29
33
  const existing = loadCachedTokens();
30
34
  if (existing && !isExpiring(existing, 120)) {
@@ -34,7 +38,7 @@ export function registerLoginCommand(program: Command): void {
34
38
  }
35
39
 
36
40
  console.log('Opening browser for authentication...');
37
- const tokens = await browserLogin(DEFAULT_COGNITO);
41
+ const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
38
42
  const who = peekIdToken(tokens.idToken).email ?? 'HQ';
39
43
  console.log(chalk.green(`Logged in as ${who}`));
40
44
  } catch (error) {
@@ -38,6 +38,30 @@ vi.mock('node:readline', () => ({
38
38
  }),
39
39
  }));
40
40
 
41
+ // Mock the public listings API so `defaultMarketplaceDeps().resolveListing` is
42
+ // exercised against a controllable feed (BUG-1 exact-slug-match regression).
43
+ // `publicResponder` is read at call time so each test can shape the response —
44
+ // it receives the call options + a zero-based call index so a test can return a
45
+ // different body for the browse call vs. the follow-up detail fetch.
46
+ type PublicResponse = { ok: boolean; status: number; body: unknown };
47
+ let publicResponder: (
48
+ opts: { path: string; query?: Record<string, string> },
49
+ call: number,
50
+ ) => PublicResponse = () => ({ ok: true, status: 200, body: { listings: [] } });
51
+ const fetchPublicCalls: Array<{ path: string; query?: Record<string, string> }> = [];
52
+ vi.mock('../utils/vault-api.js', () => ({
53
+ vaultApiFetchPublic: async (opts: { path: string; query?: Record<string, string> }) => {
54
+ const call = fetchPublicCalls.length;
55
+ fetchPublicCalls.push(opts);
56
+ const r = publicResponder(opts, call);
57
+ return {
58
+ ok: r.ok,
59
+ status: r.status,
60
+ json: async () => r.body,
61
+ } as unknown as Response;
62
+ },
63
+ }));
64
+
41
65
  import {
42
66
  classify,
43
67
  parseMarketplaceSource,
@@ -45,6 +69,7 @@ import {
45
69
  fetchMarketplace,
46
70
  resolveLatestMarketplace,
47
71
  installPack,
72
+ defaultMarketplaceDeps,
48
73
  ArtifactVerificationError,
49
74
  type MarketplaceDeps,
50
75
  type MarketplaceListing,
@@ -412,3 +437,107 @@ describe('US-006 REGRESSION: legacy transports still dispatch + install unchange
412
437
  }
413
438
  });
414
439
  });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // BUG-1 — defaultMarketplaceDeps().resolveListing must EXACT-slug-match
443
+ //
444
+ // The browse handler historically ignored `?slug=` and only honored `?q=`
445
+ // (fuzzy), so `?slug=tdd` returned ALL approved listings newest-first. The old
446
+ // code took `listings[0]` → installed the NEWEST pack, not the requested slug
447
+ // (verified live: `marketplace:tdd` installed hq-pack-review). resolveListing
448
+ // must now exact-match `l.slug === slug` and throw when no exact match exists,
449
+ // and must query with BOTH `slug` + `q` for forward/back compatibility.
450
+ // ---------------------------------------------------------------------------
451
+
452
+ describe('BUG-1 resolveListing exact-slug match (defaultMarketplaceDeps)', () => {
453
+ beforeEach(() => {
454
+ fetchPublicCalls.length = 0;
455
+ publicResponder = () => ({ ok: true, status: 200, body: { listings: [] } });
456
+ });
457
+
458
+ /** A browse-summary entry (id present, no URL — detail fetch mints it). */
459
+ function summary(slug: string, version: string, extra?: Record<string, unknown>) {
460
+ return { listingId: `lst_${slug}`, slug, version, status: 'approved', ...extra };
461
+ }
462
+
463
+ /** The `GET /v1/listings/{id}` detail the follow-up fetch returns. */
464
+ function detailBody(slug: string, version: string) {
465
+ return {
466
+ listing: {
467
+ id: `lst_${slug}`,
468
+ slug,
469
+ version,
470
+ downloadUrl: 'https://s3.example/presigned?sig=abc',
471
+ contentHash: 'a'.repeat(64),
472
+ },
473
+ };
474
+ }
475
+
476
+ it('returns the EXACTLY-slugged listing — NOT listings[0] — from a multi-pack feed', async () => {
477
+ // Server returns a slug-ignoring fuzzy feed newest-first: review is [0], the
478
+ // requested tdd is later. Old code returned review; the fix returns tdd.
479
+ publicResponder = (_opts, call) =>
480
+ call === 0
481
+ ? {
482
+ ok: true,
483
+ status: 200,
484
+ body: { listings: [summary('review', '3.0.0'), summary('tdd', '1.2.0')] },
485
+ }
486
+ : { ok: true, status: 200, body: detailBody('tdd', '1.2.0') };
487
+
488
+ const listing = await defaultMarketplaceDeps().resolveListing('tdd');
489
+
490
+ expect(listing.slug).toBe('tdd');
491
+ expect(listing.listingId).toBe('lst_tdd');
492
+ // The browse query carried BOTH slug (exact) and q (fuzzy fallback).
493
+ expect(fetchPublicCalls[0].query).toMatchObject({ slug: 'tdd', q: 'tdd' });
494
+ });
495
+
496
+ it('throws a clear error when NO exact-slug match exists (never installs a near-miss)', async () => {
497
+ // Feed contains only OTHER slugs — a slug-ignoring/fuzzy server response.
498
+ publicResponder = () => ({
499
+ ok: true,
500
+ status: 200,
501
+ body: { listings: [summary('review', '3.0.0'), summary('tdd-helper', '1.0.0')] },
502
+ });
503
+
504
+ await expect(defaultMarketplaceDeps().resolveListing('tdd')).rejects.toThrow(
505
+ /No marketplace listing found for slug "tdd"/,
506
+ );
507
+ });
508
+
509
+ it('with a pinned version, requires BOTH exact slug AND exact version', async () => {
510
+ publicResponder = (_opts, call) =>
511
+ call === 0
512
+ ? {
513
+ ok: true,
514
+ status: 200,
515
+ body: {
516
+ listings: [
517
+ summary('tdd', '2.0.0'),
518
+ summary('tdd', '1.5.0'),
519
+ summary('review', '9.9.9'),
520
+ ],
521
+ },
522
+ }
523
+ : { ok: true, status: 200, body: detailBody('tdd', '1.5.0') };
524
+
525
+ const listing = await defaultMarketplaceDeps().resolveListing('tdd', '1.5.0');
526
+
527
+ expect(listing.slug).toBe('tdd');
528
+ expect(listing.version).toBe('1.5.0');
529
+ expect(fetchPublicCalls[0].query).toMatchObject({ slug: 'tdd', q: 'tdd', version: '1.5.0' });
530
+ });
531
+
532
+ it('throws when the pinned version is absent even if the slug matches', async () => {
533
+ publicResponder = () => ({
534
+ ok: true,
535
+ status: 200,
536
+ body: { listings: [summary('tdd', '2.0.0'), summary('tdd', '1.5.0')] },
537
+ });
538
+
539
+ await expect(defaultMarketplaceDeps().resolveListing('tdd', '9.9.9')).rejects.toThrow(
540
+ /No marketplace listing found for slug "tdd"@9\.9\.9/,
541
+ );
542
+ });
543
+ });
@@ -307,6 +307,55 @@ describe('pack-install: install path layout', () => {
307
307
  });
308
308
  });
309
309
 
310
+ // ---- 7. hqCore host-version check is prerelease-tolerant -------------------
311
+ // BUGFIX: a prerelease host version (e.g. `15.0.9-beta.1`) was rejected by the
312
+ // `requires.hqCore` check because node-semver excludes prereleases from range
313
+ // matching by default. The fix passes `{ includePrerelease: true }` so a beta/rc
314
+ // HQ build can still install packs — while a genuinely-too-old host still fails.
315
+ describe('requires.hqCore prerelease tolerance', () => {
316
+ function writePackRequiring(range: string): string {
317
+ const dir = mkFakePackPayload({ 'knowledge/demo/README.md': '# demo' });
318
+ fs.writeFileSync(
319
+ path.join(dir, 'package.yaml'),
320
+ [
321
+ 'name: hq-pack-test',
322
+ 'version: 1.0.0',
323
+ "publisher: '@indigoai-us'",
324
+ 'access: public',
325
+ 'requires:',
326
+ ` hqCore: '${range}'`,
327
+ 'contributes:',
328
+ ' knowledge:',
329
+ ' - demo',
330
+ '',
331
+ ].join('\n'),
332
+ );
333
+ return dir;
334
+ }
335
+
336
+ it('ACCEPTS a prerelease host version against a plain range (>=14.2.0)', () => {
337
+ const dir = writePackRequiring('>=14.2.0');
338
+ const m = validateManifest(dir, '15.0.9-beta.1');
339
+ expect(m.name).toBe('hq-pack-test');
340
+ fs.rmSync(dir, { recursive: true, force: true });
341
+ });
342
+
343
+ it('still REJECTS a genuinely-too-old host (13.0.0 against >=14.2.0)', () => {
344
+ const dir = writePackRequiring('>=14.2.0');
345
+ expect(() => validateManifest(dir, '13.0.0')).toThrow(
346
+ /does not satisfy pack requirement/,
347
+ );
348
+ fs.rmSync(dir, { recursive: true, force: true });
349
+ });
350
+
351
+ it('still ACCEPTS a normal (non-prerelease) host version', () => {
352
+ const dir = writePackRequiring('>=14.2.0');
353
+ const m = validateManifest(dir, '15.0.0');
354
+ expect(m.name).toBe('hq-pack-test');
355
+ fs.rmSync(dir, { recursive: true, force: true });
356
+ });
357
+ });
358
+
310
359
  it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
311
360
  // Regression: prepending `source:` before a `---` would split the file
312
361
  // into two YAML documents, and single-doc `yaml.load` callers downstream