@indigoai-us/hq-cli 5.33.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.
@@ -34,12 +34,13 @@
34
34
  * from each pack's package.yaml; rationale lives in the layout-fix PR.)
35
35
  */
36
36
 
37
- !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]="08d95744-5e4f-5938-8a49-4f57a9bf042f")}catch(e){}}();
37
+ !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]="dafe45c8-d9bb-5082-b5b7-aaf1a2ee5c7e")}catch(e){}}();
38
38
  import * as fs from 'fs';
39
39
  import * as os from 'os';
40
40
  import * as path from 'path';
41
41
  import * as readline from 'readline';
42
42
  import * as yaml from 'js-yaml';
43
+ import { createHash, createPublicKey, verify as cryptoVerify, } from 'node:crypto';
43
44
  import { execFileSync, spawnSync } from 'child_process';
44
45
  import chalk from 'chalk';
45
46
  import semverSatisfies from 'semver/functions/satisfies.js';
@@ -47,7 +48,18 @@ import semverValid from 'semver/functions/valid.js';
47
48
  import semverValidRange from 'semver/ranges/valid.js';
48
49
  import semverGt from 'semver/functions/gt.js';
49
50
  import { findHqRoot } from '../utils/manifest.js';
51
+ import { safeExtractTarball } from './safe-extract.js';
52
+ import { vaultApiFetchPublic } from '../utils/vault-api.js';
53
+ /** Prefix that routes a source through the HQ marketplace transport (US-006). */
54
+ export const MARKETPLACE_PREFIX = 'marketplace:';
50
55
  export function classify(source) {
56
+ // US-006: marketplace:<slug>[@version] is recognized BEFORE the legacy
57
+ // registry fallback and ahead of every other transport. The slug is resolved
58
+ // against the public listings API, the presigned tarball is downloaded,
59
+ // verified (US-021), and safe-extracted (US-020) into the existing local
60
+ // install + symlink-wiring path.
61
+ if (source.startsWith(MARKETPLACE_PREFIX))
62
+ return 'marketplace';
51
63
  if (source.startsWith('@'))
52
64
  return 'npm';
53
65
  if (source.startsWith('http://') ||
@@ -124,7 +136,7 @@ export function sourceMatchesPackPattern(source) {
124
136
  return false;
125
137
  }
126
138
  }
127
- function fetchNpm(source, tmpDir) {
139
+ function fetchNpm(source, tmpDir, integrity) {
128
140
  // Use `npm pack` to grab the tarball without actually installing anything.
129
141
  // Capture output to get the produced filename. Arguments are passed as an
130
142
  // argv array — never interpolated into a shell string — so `source` cannot
@@ -136,10 +148,28 @@ function fetchNpm(source, tmpDir) {
136
148
  }
137
149
  const tarballPath = path.join(tmpDir, tarball);
138
150
  const extractDir = path.join(tmpDir, 'extracted');
139
- fs.mkdirSync(extractDir, { recursive: true });
140
- execFileSync('tar', ['-xzf', tarballPath, '-C', extractDir], {
141
- stdio: 'inherit',
142
- });
151
+ // SECURITY (US-021): verify integrity + authenticity over the EXACT
152
+ // downloaded bytes BEFORE we extract. A hash/signature mismatch throws
153
+ // `ArtifactVerificationError` here, so a tampered artifact is never unpacked
154
+ // or wired. This MUST precede `safeExtractTarball` (US-020). Only runs when
155
+ // the caller supplied a pinned hash (i.e. a marketplace listing did); the
156
+ // plain `npm pack` path with no listing metadata is unaffected.
157
+ if (integrity) {
158
+ const tarballBytes = fs.readFileSync(tarballPath);
159
+ verifyArtifact({
160
+ tarballBytes,
161
+ expectedHash: integrity.expectedHash,
162
+ signature: integrity.signature,
163
+ publicKey: integrity.publicKey,
164
+ requireSignature: integrity.requireSignature,
165
+ });
166
+ }
167
+ // SECURITY (US-020): the tarball is untrusted. Extract through the hardened
168
+ // path — zip-slip / link-escape containment + decompression-bomb caps +
169
+ // staged-then-atomic commit with full rollback — instead of a blind
170
+ // `tar -xzf`. safeExtractTarball requires the destination not to pre-exist,
171
+ // so we do NOT mkdir extractDir first; it is created atomically on success.
172
+ safeExtractTarball(tarballPath, extractDir);
143
173
  // npm tarballs unpack into ./package/
144
174
  const payloadDir = path.join(extractDir, 'package');
145
175
  if (!fs.existsSync(payloadDir)) {
@@ -159,6 +189,169 @@ function stripVersion(src) {
159
189
  return src.slice(0, at);
160
190
  return src;
161
191
  }
192
+ // ---------------------------------------------------------------------------
193
+ // Marketplace transport (US-006)
194
+ //
195
+ // `marketplace:<slug>[@version]` resolves a slug against the PUBLIC listings
196
+ // API (US-005, NONE-auth), downloads the presigned tarball, verifies it
197
+ // (US-021) BEFORE extraction, and feeds it into the existing safe-extract +
198
+ // validate + symlink-wire path. The legacy registry/npm/git/local transports
199
+ // are untouched.
200
+ // ---------------------------------------------------------------------------
201
+ /** Parsed `marketplace:<slug>[@version]` source. */
202
+ export function parseMarketplaceSource(source) {
203
+ if (!source.startsWith(MARKETPLACE_PREFIX)) {
204
+ throw new Error(`Not a marketplace source: "${source}"`);
205
+ }
206
+ const rest = source.slice(MARKETPLACE_PREFIX.length).trim();
207
+ if (!rest) {
208
+ throw new Error('marketplace: source requires a slug (marketplace:<slug>[@version]).');
209
+ }
210
+ // Split on the LAST '@' so a slug never legitimately contains one, but be
211
+ // defensive: only treat the suffix as a version if there's a non-empty slug.
212
+ const at = rest.lastIndexOf('@');
213
+ if (at > 0) {
214
+ return { slug: rest.slice(0, at), version: rest.slice(at + 1) || undefined };
215
+ }
216
+ return { slug: rest };
217
+ }
218
+ /** True when a download result signals an expired/forbidden presigned URL. */
219
+ function isExpired(r) {
220
+ return !(r instanceof Uint8Array) && r.expired === true;
221
+ }
222
+ /** Map a raw `GET /v1/listings/{id}` body into a MarketplaceListing. */
223
+ export function toMarketplaceListing(raw) {
224
+ // Unwrap the `{ listing: {...} }` envelope when present; fall back to the
225
+ // top level for the legacy flat shape (backwards-compat).
226
+ const detail = raw.listing && typeof raw.listing === 'object' ? raw.listing : raw;
227
+ // The detail uses `id`; the legacy flat shape may use `listingId`.
228
+ const listingId = detail.id ?? detail.listingId;
229
+ const downloadUrl = detail.downloadUrl ?? detail.url;
230
+ const contentHash = detail.contentHash ?? detail.sha256;
231
+ if (!listingId)
232
+ throw new Error('Listing detail missing an id.');
233
+ if (!downloadUrl) {
234
+ throw new Error(`Listing ${listingId} has no download URL — it may not be approved yet.`);
235
+ }
236
+ if (!contentHash) {
237
+ throw new Error(`Listing ${listingId} has no content hash — refusing to install an unverifiable artifact.`);
238
+ }
239
+ return {
240
+ listingId,
241
+ slug: detail.slug ?? '',
242
+ version: detail.version ?? detail.latestVersion,
243
+ downloadUrl,
244
+ contentHash,
245
+ contentHashAlg: detail.contentHashAlg,
246
+ signature: detail.signature,
247
+ signingKeyId: detail.signingKeyId,
248
+ publicKey: detail.publicKey,
249
+ };
250
+ }
251
+ /** Default production marketplace deps — public listings API + presigned S3. */
252
+ export function defaultMarketplaceDeps() {
253
+ const fetchDetail = async (listingId) => {
254
+ const res = await vaultApiFetchPublic({
255
+ path: `/v1/listings/${encodeURIComponent(listingId)}`,
256
+ });
257
+ if (!res.ok) {
258
+ throw new Error(`Failed to resolve listing ${listingId} (HTTP ${res.status}). The pack may be unavailable or unapproved.`);
259
+ }
260
+ const body = (await res.json().catch(() => ({})));
261
+ return toMarketplaceListing(body);
262
+ };
263
+ return {
264
+ resolveListing: async (slug, version) => {
265
+ // Search by slug (public). The API returns approved listings only.
266
+ const res = await vaultApiFetchPublic({
267
+ path: '/v1/listings',
268
+ query: version ? { slug, version } : { slug },
269
+ });
270
+ if (!res.ok) {
271
+ throw new Error(`Failed to search marketplace for "${slug}" (HTTP ${res.status}).`);
272
+ }
273
+ const body = (await res.json().catch(() => ({})));
274
+ const listings = body.listings ?? [];
275
+ const match = version
276
+ ? listings.find((l) => (l.version ?? l.latestVersion) === version)
277
+ : listings[0];
278
+ if (!match) {
279
+ throw new Error(`No approved marketplace listing found for "${slug}"${version ? `@${version}` : ''}.`);
280
+ }
281
+ const id = match.listingId ?? match.id;
282
+ if (!id)
283
+ throw new Error(`Listing for "${slug}" has no id.`);
284
+ // The summary may omit the presigned URL; always fetch detail to mint it.
285
+ return fetchDetail(id);
286
+ },
287
+ refreshListing: fetchDetail,
288
+ download: async (url) => {
289
+ const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
290
+ if (response.status === 403)
291
+ return { expired: true };
292
+ if (!response.ok) {
293
+ throw new Error(`Tarball download failed (HTTP ${response.status}).`);
294
+ }
295
+ return new Uint8Array(await response.arrayBuffer());
296
+ },
297
+ };
298
+ }
299
+ /**
300
+ * Fetch + verify + safe-extract a marketplace pack. Mirrors `fetchNpm`'s
301
+ * contract (returns a payloadDir the caller mvs into core/packages/), but
302
+ * resolves the tarball from the marketplace and ALWAYS verifies it against the
303
+ * listing's approved hash/signature BEFORE extraction (US-021 → US-020).
304
+ *
305
+ * @param requireSignature production posture once signing keys exist; during
306
+ * the key-deferral window callers pass false (hash still always checked).
307
+ */
308
+ export async function fetchMarketplace(source, tmpDir, deps, opts = {}) {
309
+ const { slug, version } = parseMarketplaceSource(source);
310
+ let listing = await deps.resolveListing(slug, version);
311
+ // Download with a single expired-URL retry: a presigned URL can expire
312
+ // between resolve and download, surfacing as a raw S3 403. We re-resolve the
313
+ // detail by id to mint a fresh URL and retry ONCE rather than leaking the 403.
314
+ let bytes = await deps.download(listing.downloadUrl);
315
+ if (isExpired(bytes)) {
316
+ listing = await deps.refreshListing(listing.listingId);
317
+ bytes = await deps.download(listing.downloadUrl);
318
+ if (isExpired(bytes)) {
319
+ throw new Error(`Marketplace download URL for "${slug}" expired and re-resolving did not help. ` +
320
+ `Try again in a moment.`);
321
+ }
322
+ }
323
+ const tarballBytes = bytes;
324
+ // SECURITY (US-021): verify integrity (+ authenticity) over the EXACT
325
+ // downloaded bytes BEFORE extraction. A hash/signature mismatch throws
326
+ // `ArtifactVerificationError` and nothing is unpacked or wired. The hash is
327
+ // ALWAYS verified when present; signature is enforced when requireSignature.
328
+ verifyArtifact({
329
+ tarballBytes,
330
+ expectedHash: listing.contentHash,
331
+ signature: listing.signature,
332
+ publicKey: listing.publicKey,
333
+ requireSignature: opts.requireSignature ?? false,
334
+ });
335
+ // Write to a tmp tarball and run it through the SAME hardened extractor
336
+ // (US-020) as every other tarball transport.
337
+ const tarballPath = path.join(tmpDir, 'marketplace.tar.gz');
338
+ fs.writeFileSync(tarballPath, tarballBytes);
339
+ const extractDir = path.join(tmpDir, 'extracted');
340
+ safeExtractTarball(tarballPath, extractDir);
341
+ // Marketplace tarballs are packed with `tar -C payloadDir .` (publish.ts), so
342
+ // the manifest + contributes dirs are at the archive root — no `package/`
343
+ // wrapper to descend into.
344
+ const payloadDir = extractDir;
345
+ if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
346
+ throw new Error(`Marketplace tarball for "${slug}" has no package.yaml at its root.`);
347
+ }
348
+ // Record the marketplace source for re-install + `hq packs update`. We stamp
349
+ // the resolved version so a later `update` can detect a newer listing.
350
+ const resolvedSource = listing.version
351
+ ? `${MARKETPLACE_PREFIX}${slug}@${listing.version}`
352
+ : `${MARKETPLACE_PREFIX}${slug}`;
353
+ return { payloadDir, resolvedSource };
354
+ }
162
355
  function fetchGit(source, tmpDir, followBranch) {
163
356
  const parsed = parseGitFragment(source);
164
357
  const url = expandGithubShorthand(parsed.url);
@@ -302,6 +495,20 @@ export function resolveLatest(source, installedVersion) {
302
495
  if (transport === 'local') {
303
496
  return { transport, updateAvailable: null, error: 'local source — re-run to re-sync' };
304
497
  }
498
+ if (transport === 'marketplace') {
499
+ // The marketplace probe needs an async network call (listings API), which
500
+ // this synchronous helper cannot make. Callers that want the marketplace
501
+ // update check use the async `resolveLatestMarketplace`; here we report the
502
+ // installed version but leave availability undeterminable (null) so the
503
+ // sync menubar `--check-updates` path is not regressed.
504
+ const { version } = parseMarketplaceSource(source);
505
+ return {
506
+ transport,
507
+ current: installedVersion ?? version,
508
+ updateAvailable: null,
509
+ error: 'marketplace source — use async update check',
510
+ };
511
+ }
305
512
  if (transport === 'npm') {
306
513
  const pkg = stripVersion(source);
307
514
  const current = installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
@@ -343,10 +550,145 @@ export function resolveLatest(source, installedVersion) {
343
550
  return { transport, current, updateAvailable: null, error: `git ls-remote failed: ${e.message}` };
344
551
  }
345
552
  }
553
+ /**
554
+ * Async marketplace update probe (US-006): resolve the slug's latest approved
555
+ * listing version and compare it to the installed version. Reuses the same
556
+ * `MarketplaceDeps.resolveListing` seam as the install path (resolving with NO
557
+ * version pin returns the latest listing). Never throws — network/parse
558
+ * failures return `{ updateAvailable: null, error }` so `hq packs update`
559
+ * stays resilient.
560
+ *
561
+ * @param source the stamped `marketplace:<slug>[@version]` source
562
+ * @param installedVersion the installed pack's manifest version (semver compare)
563
+ * @param deps injected for tests; defaults to the public API
564
+ */
565
+ export async function resolveLatestMarketplace(source, installedVersion, deps) {
566
+ let slug;
567
+ let pinned;
568
+ try {
569
+ ({ slug, version: pinned } = parseMarketplaceSource(source));
570
+ }
571
+ catch (e) {
572
+ return { transport: 'marketplace', updateAvailable: null, error: e.message };
573
+ }
574
+ const current = installedVersion ?? pinned;
575
+ try {
576
+ const d = deps ?? defaultMarketplaceDeps();
577
+ // No version pin → latest approved listing.
578
+ const listing = await d.resolveListing(slug);
579
+ const latest = listing.version;
580
+ let updateAvailable = null;
581
+ if (current && latest) {
582
+ // Prefer semver compare; fall back to inequality if either side isn't
583
+ // valid semver (so a non-semver listing version still flags a change).
584
+ updateAvailable =
585
+ semverValid(latest) && semverValid(current)
586
+ ? semverGt(latest, current)
587
+ : latest !== current;
588
+ }
589
+ return { transport: 'marketplace', current, latest, updateAvailable };
590
+ }
591
+ catch (e) {
592
+ return {
593
+ transport: 'marketplace',
594
+ current,
595
+ updateAvailable: null,
596
+ error: `marketplace check failed: ${e.message}`,
597
+ };
598
+ }
599
+ }
600
+ // ---------------------------------------------------------------------------
601
+ // Marketplace artifact verification (US-021, INSTALL side)
602
+ //
603
+ // Guarantees the bytes we install are EXACTLY the bytes a moderator approved:
604
+ // 1. recompute sha256 over the downloaded tarball bytes and compare it to the
605
+ // hash the listing pinned at publish (and approval bound to);
606
+ // 2. verify the platform Ed25519 signature over that hash against a
607
+ // distributable PUBLIC key.
608
+ //
609
+ // SHARED CONTRACT (must match hq-pro `src/listings/signing.ts`):
610
+ // • CANONICAL BYTES = the gzipped tarball object EXACTLY as uploaded to S3
611
+ // (i.e. exactly the bytes we downloaded — no transform).
612
+ // • contentHash = sha256(canonical bytes), lowercase hex.
613
+ // • signature = Ed25519 over the UTF-8 bytes of the lowercase-hex
614
+ // contentHash, base64-encoded.
615
+ //
616
+ // This runs BEFORE safe-extract (US-020): a mismatch must abort the install
617
+ // before any bytes are unpacked or wired. US-006 wires the marketplace
618
+ // transport that surfaces the listing's {hash, signature, publicKey}; this
619
+ // function is the reusable verifier it calls.
620
+ // ---------------------------------------------------------------------------
621
+ /** Thrown when a downloaded artifact fails hash or signature verification. */
622
+ export class ArtifactVerificationError extends Error {
623
+ constructor(message) {
624
+ super(message);
625
+ this.name = 'ArtifactVerificationError';
626
+ }
627
+ }
628
+ /** Hash algorithm for the content hash — pinned to sha256 (matches publish). */
629
+ export const ARTIFACT_HASH_ALG = 'sha256';
630
+ /** Lowercase-hex sha256 of the given bytes — the install-side content hash. */
631
+ export function computeArtifactHash(tarballBytes) {
632
+ return createHash(ARTIFACT_HASH_ALG).update(tarballBytes).digest('hex');
633
+ }
634
+ /**
635
+ * Verify a downloaded marketplace artifact's integrity (hash) and authenticity
636
+ * (signature). Throws `ArtifactVerificationError` on ANY mismatch — the caller
637
+ * MUST let it propagate so the install aborts before extraction.
638
+ *
639
+ * Constant-time-ish hash comparison: hashes are fixed-length lowercase hex, and
640
+ * the early-exit risk is negligible (the hash is public), but we still compare
641
+ * full strings rather than prefixes.
642
+ */
643
+ export function verifyArtifact(input) {
644
+ const { tarballBytes, expectedHash, signature, publicKey, requireSignature = false, } = input;
645
+ if (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {
646
+ throw new ArtifactVerificationError(`Refusing to install: listing did not provide a valid ${ARTIFACT_HASH_ALG} content hash.`);
647
+ }
648
+ // 1. Integrity — recompute the hash over the EXACT downloaded bytes.
649
+ const actualHash = computeArtifactHash(tarballBytes);
650
+ if (actualHash !== expectedHash) {
651
+ throw new ArtifactVerificationError(`Artifact hash mismatch — refusing to install. ` +
652
+ `expected ${ARTIFACT_HASH_ALG}=${expectedHash}, got ${actualHash}. ` +
653
+ `The downloaded bytes are NOT the bytes that were approved.`);
654
+ }
655
+ // 2. Authenticity — verify the Ed25519 signature over the hash.
656
+ if (!signature || !publicKey) {
657
+ if (requireSignature) {
658
+ throw new ArtifactVerificationError(signature
659
+ ? 'Artifact signature present but no public key available to verify it — refusing to install.'
660
+ : 'Artifact is unsigned and a signature is required — refusing to install.');
661
+ }
662
+ // Deferred-key window: hash verified, no signature to check. Caller opted to
663
+ // allow unsigned (requireSignature=false).
664
+ return;
665
+ }
666
+ let key;
667
+ try {
668
+ key = typeof publicKey === 'string' ? createPublicKey(publicKey) : publicKey;
669
+ }
670
+ catch (e) {
671
+ throw new ArtifactVerificationError(`Invalid artifact public key — refusing to install: ${e.message}`);
672
+ }
673
+ let ok = false;
674
+ try {
675
+ // Ed25519: digest algorithm is null (the scheme hashes internally). The
676
+ // signed payload is the UTF-8 bytes of the lowercase-hex hash — identical to
677
+ // what hq-pro signed at publish.
678
+ ok = cryptoVerify(null, Buffer.from(expectedHash, 'utf-8'), key, Buffer.from(signature, 'base64'));
679
+ }
680
+ catch (e) {
681
+ throw new ArtifactVerificationError(`Artifact signature verification errored — refusing to install: ${e.message}`);
682
+ }
683
+ if (!ok) {
684
+ throw new ArtifactVerificationError('Artifact signature is invalid — refusing to install. ' +
685
+ 'The artifact was not signed by the platform key (possible tampering).');
686
+ }
687
+ }
346
688
  // ---------------------------------------------------------------------------
347
689
  // Manifest validation (spec §Validation, 10 checks)
348
690
  // ---------------------------------------------------------------------------
349
- function validateManifest(payloadDir, hqVersion) {
691
+ export function validateManifest(payloadDir, hqVersion) {
350
692
  // 1. parse
351
693
  const manifestPath = path.join(payloadDir, 'package.yaml');
352
694
  if (!fs.existsSync(manifestPath)) {
@@ -414,6 +756,26 @@ function validateManifest(payloadDir, hqVersion) {
414
756
  }
415
757
  }
416
758
  }
759
+ // author + capabilities (US-001) — both OPTIONAL and backwards-compatible.
760
+ // Absent → fine (legacy packs). Present → must be well-shaped so a malformed
761
+ // attribution can't masquerade as a valid one.
762
+ if (m.author !== undefined) {
763
+ const a = m.author;
764
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
765
+ throw new Error('author must be a mapping with uid, handle, displayName');
766
+ }
767
+ for (const field of ['uid', 'handle', 'displayName']) {
768
+ if (typeof a[field] !== 'string' || a[field].trim() === '') {
769
+ throw new Error(`author.${field} must be a non-empty string`);
770
+ }
771
+ }
772
+ }
773
+ if (m.capabilities !== undefined) {
774
+ if (!Array.isArray(m.capabilities) ||
775
+ !m.capabilities.every((c) => typeof c === 'string')) {
776
+ throw new Error('capabilities must be a list of strings');
777
+ }
778
+ }
417
779
  return m;
418
780
  }
419
781
  function readHqVersion(hqRoot) {
@@ -507,13 +869,31 @@ export function installToPackages(payloadDir, pkg, hqRoot) {
507
869
  const packagesDir = path.join(hqRoot, 'core', 'packages');
508
870
  fs.mkdirSync(packagesDir, { recursive: true });
509
871
  const destDir = path.join(packagesDir, pkg.name);
510
- if (fs.existsSync(destDir)) {
511
- fs.rmSync(destDir, { recursive: true, force: true });
872
+ // SECURITY/ATOMICITY (US-020): wire atomically. rsync into a sibling staging
873
+ // dir on the SAME filesystem first; only swap it into the final destination
874
+ // once the copy fully succeeds. A failed/partial rsync therefore never
875
+ // leaves a half-wired pack dir in place (which scan-packages.sh would then
876
+ // wire into host paths). On any error the staging dir is rolled back.
877
+ const stagingDir = fs.mkdtempSync(path.join(packagesDir, `.${pkg.name}.staging-`));
878
+ try {
879
+ // rsync preserves modes/symlinks; argv form — no shell.
880
+ const srcSlashed = payloadDir.endsWith('/') ? payloadDir : `${payloadDir}/`;
881
+ const stagingSlashed = stagingDir.endsWith('/') ? stagingDir : `${stagingDir}/`;
882
+ execFileSync('rsync', ['-a', srcSlashed, stagingSlashed], { stdio: 'inherit' });
883
+ // Swap into place: remove any prior install, then atomic rename. The brief
884
+ // window between rm and rename is unavoidable with a same-name dir, but the
885
+ // staged copy guarantees the NEW contents are fully present before we touch
886
+ // the live location.
887
+ if (fs.existsSync(destDir)) {
888
+ fs.rmSync(destDir, { recursive: true, force: true });
889
+ }
890
+ fs.renameSync(stagingDir, destDir);
891
+ }
892
+ finally {
893
+ if (fs.existsSync(stagingDir)) {
894
+ fs.rmSync(stagingDir, { recursive: true, force: true });
895
+ }
512
896
  }
513
- // rsync preserves modes/symlinks; tar also works. argv form — no shell.
514
- const srcSlashed = payloadDir.endsWith('/') ? payloadDir : `${payloadDir}/`;
515
- const destSlashed = destDir.endsWith('/') ? destDir : `${destDir}/`;
516
- execFileSync('rsync', ['-a', srcSlashed, destSlashed], { stdio: 'inherit' });
517
897
  return destDir;
518
898
  }
519
899
  /**
@@ -615,7 +995,7 @@ export async function installPack(source, opts = {}) {
615
995
  let fetched;
616
996
  switch (transport) {
617
997
  case 'npm':
618
- fetched = fetchNpm(source, tmpDir);
998
+ fetched = fetchNpm(source, tmpDir, opts.integrity);
619
999
  break;
620
1000
  case 'git':
621
1001
  fetched = fetchGit(source, tmpDir, opts.followBranch ?? false);
@@ -623,6 +1003,9 @@ export async function installPack(source, opts = {}) {
623
1003
  case 'local':
624
1004
  fetched = fetchLocal(source, tmpDir);
625
1005
  break;
1006
+ case 'marketplace':
1007
+ fetched = await fetchMarketplace(source, tmpDir, opts.marketplaceDeps ?? defaultMarketplaceDeps(), { requireSignature: opts.requireSignature });
1008
+ break;
626
1009
  }
627
1010
  const pkg = validateManifest(fetched.payloadDir, hqVersion);
628
1011
  if (pkg.conditional) {
@@ -651,7 +1034,13 @@ export async function installPack(source, opts = {}) {
651
1034
  // setup.sh can dedup against `core/core.yaml:recommended_packages` on
652
1035
  // re-runs. Stamping the literal input (not the resolved SHA/version)
653
1036
  // matches the verbatim equality check in setup.sh.
654
- stampInstallSource(destDir, source);
1037
+ //
1038
+ // US-006 EXCEPTION: for the marketplace transport we stamp the RESOLVED
1039
+ // source (`marketplace:<slug>@<version>`) rather than the literal input.
1040
+ // The version is what lets `hq packs update` detect a newer listing — the
1041
+ // bare `marketplace:<slug>` input carries no version to compare against.
1042
+ const stampedSource = transport === 'marketplace' ? fetched.resolvedSource : source;
1043
+ stampInstallSource(destDir, stampedSource);
655
1044
  runScanPackages(hqRoot, { quiet: opts.quiet });
656
1045
  say(chalk.green(`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`));
657
1046
  say(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
@@ -662,4 +1051,4 @@ export async function installPack(source, opts = {}) {
662
1051
  }
663
1052
  }
664
1053
  //# sourceMappingURL=pack-install.js.map
665
- //# debugId=08d95744-5e4f-5938-8a49-4f57a9bf042f
1054
+ //# debugId=dafe45c8-d9bb-5082-b5b7-aaf1a2ee5c7e
@@ -18,7 +18,7 @@
18
18
  * Spec: knowledge/public/hq-core/package-yaml-spec.md.
19
19
  */
20
20
 
21
- !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]="cb73c4fb-255e-5df7-accd-9b7d12362a06")}catch(e){}}();
21
+ !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]="fa6cf3bf-90fa-5261-a15f-c8810e4b5615")}catch(e){}}();
22
22
  import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import * as readline from 'readline';
@@ -26,7 +26,7 @@ import { spawnSync } from 'child_process';
26
26
  import chalk from 'chalk';
27
27
  import semverSatisfies from 'semver/functions/satisfies.js';
28
28
  import { findHqRoot } from '../utils/manifest.js';
29
- import { classify, resolveLatest, runScanPackages, installPack, } from './pack-install.js';
29
+ import { classify, resolveLatest, resolveLatestMarketplace, runScanPackages, installPack, } from './pack-install.js';
30
30
  import { contributionLinks, linkStatus, listInstalledPacks, readPackManifest, unwirePack, readHqVersion, readRecommendedPackages, packagesDir, } from '../utils/pack-contributions.js';
31
31
  function resolveRoot(opts) {
32
32
  return opts.hqRoot ? path.resolve(opts.hqRoot) : findHqRoot();
@@ -195,7 +195,12 @@ async function runUpdate(name, opts) {
195
195
  results.push({ name: pname, transport: 'unknown', updateAvailable: null, applied: false, error: 'no stamped source -- cannot update' });
196
196
  continue;
197
197
  }
198
- const probe = resolveLatest(source, m.version);
198
+ // US-006: marketplace sources need the async listings probe (network)
199
+ // the sync `resolveLatest` cannot make that call and returns null. Every
200
+ // other transport keeps the existing synchronous probe unchanged.
201
+ const probe = safeClassify(source) === 'marketplace'
202
+ ? await resolveLatestMarketplace(source, m.version)
203
+ : resolveLatest(source, m.version);
199
204
  const base = {
200
205
  name: pname,
201
206
  transport: probe.transport,
@@ -403,4 +408,4 @@ export function registerPacksCommand(parent) {
403
408
  });
404
409
  }
405
410
  //# sourceMappingURL=packs.js.map
406
- //# debugId=cb73c4fb-255e-5df7-accd-9b7d12362a06
411
+ //# debugId=fa6cf3bf-90fa-5261-a15f-c8810e4b5615