@indigoai-us/hq-cli 5.77.14 → 5.78.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.
- package/CHANGELOG.md +23 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +42 -2
- package/dist/commands/pkg-install.js +39 -2
- package/dist/commands/run.js +6 -2
- package/dist/commands/secrets.js +120 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2256
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs-update-api-key.test.ts +0 -105
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -103
- package/src/commands/pkg-install.ts +0 -221
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -190
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2050
- package/src/commands/secrets.ts +0 -2249
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- package/vitest.e2e.config.ts +0 -7
|
@@ -1,2256 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hq install <source> — content-pack installer (US-003, hq-core v12.0.0+)
|
|
3
|
-
*
|
|
4
|
-
* Source patterns and transports:
|
|
5
|
-
* @scope/name[@version] → npm (default pin = latest, frozen on install)
|
|
6
|
-
* https://... | git@... | *.git → git (SHA-pinned by default; --branch to follow)
|
|
7
|
-
* github:owner/repo[#...] → git (sugar — expands to https://github.com/owner/repo.git)
|
|
8
|
-
* ./path | ../path | /path | file:... → local (path recorded as-is)
|
|
9
|
-
*
|
|
10
|
-
* Git fragment grammar (after '#'):
|
|
11
|
-
* '#<ref>' → whole repo at ref (tag / branch / SHA)
|
|
12
|
-
* '#<subpath>' → subdirectory of repo at default branch
|
|
13
|
-
* '#<subpath>@<ref>' → subdirectory at ref
|
|
14
|
-
* Fragment disambiguation: if the token after '#' contains '/' it's a subpath;
|
|
15
|
-
* otherwise it's a ref. Subpath enables monorepo-hosted packs (e.g.
|
|
16
|
-
* 'github:indigoai-us/hq#packages/hq-pack-gstack@abc1234') without a separate
|
|
17
|
-
* registry or release tarball.
|
|
18
|
-
*
|
|
19
|
-
* Distinct from pkg-install.ts (entitlement-gated registry flow against the
|
|
20
|
-
* proprietary HQ registry — that still runs for plain slugs). Spec:
|
|
21
|
-
* knowledge/public/hq-core/package-yaml-spec.md.
|
|
22
|
-
*
|
|
23
|
-
* Flow:
|
|
24
|
-
* 1. Classify source → transport
|
|
25
|
-
* 2. Fetch payload to tmpdir (npm pack | git clone | rsync)
|
|
26
|
-
* 3. Parse + validate package.yaml (10 checks from spec)
|
|
27
|
-
* 4. Evaluate `conditional` predicate — skip if exits non-zero
|
|
28
|
-
* 5. Confirm hooks if `contributes.hooks` non-empty (unless --allow-hooks)
|
|
29
|
-
* 6. Move into core/packages/{name}/
|
|
30
|
-
* 7. Run core/scripts/scan-packages.sh to wire contributions into host paths
|
|
31
|
-
*
|
|
32
|
-
* Installed packs are tracked by filesystem presence — there's no separate
|
|
33
|
-
* registry file under the v12 layout. (`hq update <pack>` re-resolves source
|
|
34
|
-
* from each pack's package.yaml; rationale lives in the layout-fix PR.)
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import * as fs from 'fs';
|
|
38
|
-
import * as os from 'os';
|
|
39
|
-
import * as path from 'path';
|
|
40
|
-
import * as readline from 'readline';
|
|
41
|
-
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
42
|
-
import {
|
|
43
|
-
createHash,
|
|
44
|
-
createPublicKey,
|
|
45
|
-
verify as cryptoVerify,
|
|
46
|
-
type KeyObject,
|
|
47
|
-
} from 'node:crypto';
|
|
48
|
-
import { execFileSync, spawnSync } from 'child_process';
|
|
49
|
-
import { Command } from 'commander';
|
|
50
|
-
import chalk from 'chalk';
|
|
51
|
-
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
52
|
-
import semverValid from 'semver/functions/valid.js';
|
|
53
|
-
import semverValidRange from 'semver/ranges/valid.js';
|
|
54
|
-
import semverGt from 'semver/functions/gt.js';
|
|
55
|
-
import { findHqRoot } from '../utils/manifest.js';
|
|
56
|
-
import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
|
|
57
|
-
import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
|
|
58
|
-
import { safeExtractTarball } from './safe-extract.js';
|
|
59
|
-
import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
60
|
-
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
61
|
-
import {
|
|
62
|
-
redactSecrets,
|
|
63
|
-
SECRET_REDACTION,
|
|
64
|
-
registerMcpServers,
|
|
65
|
-
McpManifestError,
|
|
66
|
-
type McpManifest,
|
|
67
|
-
type SecretResolver,
|
|
68
|
-
} from './mcp-registration.js';
|
|
69
|
-
import { listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
70
|
-
import { loadRevealedSecrets } from './secrets.js';
|
|
71
|
-
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
72
|
-
|
|
73
|
-
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
74
|
-
const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
|
|
75
|
-
|
|
76
|
-
interface PackUpdateCacheEntry {
|
|
77
|
-
latest: string;
|
|
78
|
-
fetchedAt: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface PackUpdateCacheFile {
|
|
82
|
-
entries: Record<string, PackUpdateCacheEntry>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface ResolveLatestOptions {
|
|
86
|
-
forceRefresh?: boolean;
|
|
87
|
-
now?: number;
|
|
88
|
-
cacheTtlMs?: number;
|
|
89
|
-
fetchImpl?: typeof fetch;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const gitLsRemoteMemo = new Map<string, string>();
|
|
93
|
-
|
|
94
|
-
function packUpdateCachePath(): string {
|
|
95
|
-
return path.join(os.homedir(), '.hq', 'pack-update-cache.json');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function readPackUpdateCache(): PackUpdateCacheFile {
|
|
99
|
-
try {
|
|
100
|
-
const parsed = JSON.parse(fs.readFileSync(packUpdateCachePath(), 'utf-8')) as Partial<PackUpdateCacheFile>;
|
|
101
|
-
if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object') {
|
|
102
|
-
return { entries: {} };
|
|
103
|
-
}
|
|
104
|
-
return { entries: parsed.entries as Record<string, PackUpdateCacheEntry> };
|
|
105
|
-
} catch {
|
|
106
|
-
return { entries: {} };
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function writePackUpdateCache(cache: PackUpdateCacheFile): void {
|
|
111
|
-
try {
|
|
112
|
-
const file = packUpdateCachePath();
|
|
113
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
114
|
-
fs.writeFileSync(file, JSON.stringify(cache));
|
|
115
|
-
} catch {
|
|
116
|
-
// best-effort; update checks must never fail because the cache is unwritable
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function cachedLatest(cacheKey: string, opts: ResolveLatestOptions): string | undefined {
|
|
121
|
-
if (opts.forceRefresh) return undefined;
|
|
122
|
-
const entry = readPackUpdateCache().entries[cacheKey];
|
|
123
|
-
if (!entry || typeof entry.latest !== 'string' || typeof entry.fetchedAt !== 'number') return undefined;
|
|
124
|
-
const now = opts.now ?? Date.now();
|
|
125
|
-
const ttl = opts.cacheTtlMs ?? PACK_UPDATE_CACHE_TTL_MS;
|
|
126
|
-
return now - entry.fetchedAt <= ttl ? entry.latest : undefined;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function storeCachedLatest(cacheKey: string, latest: string, opts: ResolveLatestOptions): void {
|
|
130
|
-
const cache = readPackUpdateCache();
|
|
131
|
-
cache.entries[cacheKey] = { latest, fetchedAt: opts.now ?? Date.now() };
|
|
132
|
-
writePackUpdateCache(cache);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function latestWithDiskCache(
|
|
136
|
-
cacheKey: string,
|
|
137
|
-
opts: ResolveLatestOptions,
|
|
138
|
-
refresh: () => Promise<string | undefined> | string | undefined,
|
|
139
|
-
): Promise<string | undefined> {
|
|
140
|
-
const cached = cachedLatest(cacheKey, opts);
|
|
141
|
-
if (cached) return cached;
|
|
142
|
-
const latest = await refresh();
|
|
143
|
-
if (latest) storeCachedLatest(cacheKey, latest, opts);
|
|
144
|
-
return latest;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
// Source classification
|
|
149
|
-
// ---------------------------------------------------------------------------
|
|
150
|
-
|
|
151
|
-
export type Transport = 'npm' | 'git' | 'local' | 'marketplace';
|
|
152
|
-
|
|
153
|
-
/** Prefix that routes a source through the HQ marketplace transport (US-006). */
|
|
154
|
-
export const MARKETPLACE_PREFIX = 'marketplace:';
|
|
155
|
-
|
|
156
|
-
export function classify(source: string): Transport {
|
|
157
|
-
// US-006: marketplace:<slug>[@version] is recognized BEFORE the legacy
|
|
158
|
-
// registry fallback and ahead of every other transport. The slug is resolved
|
|
159
|
-
// against the public listings API, the presigned tarball is downloaded,
|
|
160
|
-
// verified (US-021), and safe-extracted (US-020) into the existing local
|
|
161
|
-
// install + symlink-wiring path.
|
|
162
|
-
if (source.startsWith(MARKETPLACE_PREFIX)) return 'marketplace';
|
|
163
|
-
if (source.startsWith('@')) return 'npm';
|
|
164
|
-
if (
|
|
165
|
-
source.startsWith('http://') ||
|
|
166
|
-
source.startsWith('https://') ||
|
|
167
|
-
source.startsWith('git@') ||
|
|
168
|
-
source.startsWith('git://') ||
|
|
169
|
-
source.startsWith('github:') ||
|
|
170
|
-
source.endsWith('.git') ||
|
|
171
|
-
/\.git#/.test(source)
|
|
172
|
-
) {
|
|
173
|
-
return 'git';
|
|
174
|
-
}
|
|
175
|
-
// A '#' fragment is only meaningful to git (ref / subpath). If we see one
|
|
176
|
-
// on a file: or path source, the caller means "clone this local git repo"
|
|
177
|
-
// — route to git. Local transport does not consume fragments.
|
|
178
|
-
if (source.includes('#')) {
|
|
179
|
-
return 'git';
|
|
180
|
-
}
|
|
181
|
-
if (
|
|
182
|
-
source.startsWith('./') ||
|
|
183
|
-
source.startsWith('../') ||
|
|
184
|
-
source.startsWith('/') ||
|
|
185
|
-
source.startsWith('file:')
|
|
186
|
-
) {
|
|
187
|
-
return 'local';
|
|
188
|
-
}
|
|
189
|
-
// Fallback: bare slug — caller (dispatcher) routes those to legacy flow.
|
|
190
|
-
throw new Error(
|
|
191
|
-
`Cannot classify source "${source}" as pack (npm @scope/name, git URL, or path). ` +
|
|
192
|
-
`Bare slugs go through the registry (Cognito) flow.`
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Expand 'github:owner/repo' shorthand to a cloneable URL. Passes anything
|
|
198
|
-
* else through unchanged. Separate from classify() so the transport-dispatch
|
|
199
|
-
* stays readable.
|
|
200
|
-
*/
|
|
201
|
-
function expandGithubShorthand(url: string): string {
|
|
202
|
-
if (!url.startsWith('github:')) return url;
|
|
203
|
-
const slug = url.slice('github:'.length);
|
|
204
|
-
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug)) {
|
|
205
|
-
throw new Error(
|
|
206
|
-
`github: shorthand must be 'github:owner/repo' (got 'github:${slug}')`
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
return `https://github.com/${slug}.git`;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Parse a git source's '#<...>' fragment into { url, subpath, ref }.
|
|
214
|
-
* Disambiguation: fragment containing '/' is a subpath (optionally with
|
|
215
|
-
* '@<ref>' suffix); fragment without '/' is a ref.
|
|
216
|
-
*/
|
|
217
|
-
export function parseGitFragment(source: string): {
|
|
218
|
-
url: string;
|
|
219
|
-
subpath?: string;
|
|
220
|
-
ref?: string;
|
|
221
|
-
} {
|
|
222
|
-
const hashAt = source.indexOf('#');
|
|
223
|
-
if (hashAt < 0) return { url: source };
|
|
224
|
-
const url = source.slice(0, hashAt);
|
|
225
|
-
const frag = source.slice(hashAt + 1);
|
|
226
|
-
if (frag.includes('/')) {
|
|
227
|
-
// subpath[@ref]
|
|
228
|
-
const atAt = frag.lastIndexOf('@');
|
|
229
|
-
if (atAt > 0) {
|
|
230
|
-
return { url, subpath: frag.slice(0, atAt), ref: frag.slice(atAt + 1) };
|
|
231
|
-
}
|
|
232
|
-
return { url, subpath: frag };
|
|
233
|
-
}
|
|
234
|
-
return { url, ref: frag || undefined };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* sourceMatchesPackPattern — exported for the dispatcher in pkg-install.ts
|
|
239
|
-
* so it can decide whether to route to the new content-pack handler or fall
|
|
240
|
-
* back to the legacy registry flow.
|
|
241
|
-
*/
|
|
242
|
-
export function sourceMatchesPackPattern(source: string): boolean {
|
|
243
|
-
try {
|
|
244
|
-
classify(source);
|
|
245
|
-
return true;
|
|
246
|
-
} catch {
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ---------------------------------------------------------------------------
|
|
252
|
-
// Transports
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
interface FetchResult {
|
|
256
|
-
payloadDir: string; // where the extracted pack lives (caller mvs it)
|
|
257
|
-
resolvedSource: string; // e.g. '@scope/name@1.0.0' (version frozen) or URL#sha
|
|
258
|
-
resolvedSha?: string; // git transport only
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Optional marketplace integrity metadata for a tarball-based install (US-021).
|
|
263
|
-
* When present, `verifyArtifact` runs over the downloaded tarball bytes BEFORE
|
|
264
|
-
* `safeExtractTarball` — a hash/signature mismatch aborts the install before
|
|
265
|
-
* any byte is unpacked. US-006 populates this from the listing detail; the npm
|
|
266
|
-
* fetch path also honors it so a registry-fetched tarball can be verified.
|
|
267
|
-
*/
|
|
268
|
-
export interface ArtifactIntegrity {
|
|
269
|
-
expectedHash: string;
|
|
270
|
-
signature?: string;
|
|
271
|
-
publicKey?: string;
|
|
272
|
-
requireSignature?: boolean;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function fetchNpm(
|
|
276
|
-
source: string,
|
|
277
|
-
tmpDir: string,
|
|
278
|
-
integrity?: ArtifactIntegrity,
|
|
279
|
-
): FetchResult {
|
|
280
|
-
// Use `npm pack` to grab the tarball without actually installing anything.
|
|
281
|
-
// Capture output to get the produced filename. Arguments are passed as an
|
|
282
|
-
// argv array — never interpolated into a shell string — so `source` cannot
|
|
283
|
-
// break out even if it contains metacharacters.
|
|
284
|
-
const out = execFileSync(
|
|
285
|
-
'npm',
|
|
286
|
-
['pack', '--silent', '--pack-destination', tmpDir, source],
|
|
287
|
-
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'inherit'] },
|
|
288
|
-
);
|
|
289
|
-
const tarball = out.trim().split('\n').filter(Boolean).pop();
|
|
290
|
-
if (!tarball) {
|
|
291
|
-
throw new Error(`npm pack produced no tarball for "${source}"`);
|
|
292
|
-
}
|
|
293
|
-
const tarballPath = path.join(tmpDir, tarball);
|
|
294
|
-
const extractDir = path.join(tmpDir, 'extracted');
|
|
295
|
-
// SECURITY (US-021): verify integrity + authenticity over the EXACT
|
|
296
|
-
// downloaded bytes BEFORE we extract. A hash/signature mismatch throws
|
|
297
|
-
// `ArtifactVerificationError` here, so a tampered artifact is never unpacked
|
|
298
|
-
// or wired. This MUST precede `safeExtractTarball` (US-020). Only runs when
|
|
299
|
-
// the caller supplied a pinned hash (i.e. a marketplace listing did); the
|
|
300
|
-
// plain `npm pack` path with no listing metadata is unaffected.
|
|
301
|
-
if (integrity) {
|
|
302
|
-
const tarballBytes = fs.readFileSync(tarballPath);
|
|
303
|
-
verifyArtifact({
|
|
304
|
-
tarballBytes,
|
|
305
|
-
expectedHash: integrity.expectedHash,
|
|
306
|
-
signature: integrity.signature,
|
|
307
|
-
publicKey: integrity.publicKey,
|
|
308
|
-
requireSignature: integrity.requireSignature,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
// SECURITY (US-020): the tarball is untrusted. Extract through the hardened
|
|
312
|
-
// path — zip-slip / link-escape containment + decompression-bomb caps +
|
|
313
|
-
// staged-then-atomic commit with full rollback — instead of a blind
|
|
314
|
-
// `tar -xzf`. safeExtractTarball requires the destination not to pre-exist,
|
|
315
|
-
// so we do NOT mkdir extractDir first; it is created atomically on success.
|
|
316
|
-
safeExtractTarball(tarballPath, extractDir);
|
|
317
|
-
// npm tarballs unpack into ./package/
|
|
318
|
-
const payloadDir = path.join(extractDir, 'package');
|
|
319
|
-
if (!fs.existsSync(payloadDir)) {
|
|
320
|
-
throw new Error(`npm tarball layout unexpected — no "package/" in ${tarballPath}`);
|
|
321
|
-
}
|
|
322
|
-
// Freeze version from the resolved tarball filename when caller passed e.g.
|
|
323
|
-
// '@scope/name' (no @version) — fall back to the source string.
|
|
324
|
-
// npm pack writes the filename as scope-name-version.tgz (no leading @).
|
|
325
|
-
const m = /([0-9]+\.[0-9]+\.[0-9]+[^.]*)\.tgz$/.exec(tarball);
|
|
326
|
-
const resolvedSource = m ? `${stripVersion(source)}@${m[1]}` : source;
|
|
327
|
-
return { payloadDir, resolvedSource };
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function stripVersion(src: string): string {
|
|
331
|
-
// '@scope/name@1.0.0' → '@scope/name'
|
|
332
|
-
const at = src.lastIndexOf('@');
|
|
333
|
-
if (at > 0) return src.slice(0, at);
|
|
334
|
-
return src;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ---------------------------------------------------------------------------
|
|
338
|
-
// Marketplace transport (US-006)
|
|
339
|
-
//
|
|
340
|
-
// `marketplace:<slug>[@version]` resolves a slug against the PUBLIC listings
|
|
341
|
-
// API (US-005, NONE-auth), downloads the presigned tarball, verifies it
|
|
342
|
-
// (US-021) BEFORE extraction, and feeds it into the existing safe-extract +
|
|
343
|
-
// validate + symlink-wire path. The legacy registry/npm/git/local transports
|
|
344
|
-
// are untouched.
|
|
345
|
-
// ---------------------------------------------------------------------------
|
|
346
|
-
|
|
347
|
-
/** Parsed `marketplace:<slug>[@version]` source. */
|
|
348
|
-
export function parseMarketplaceSource(source: string): {
|
|
349
|
-
slug: string;
|
|
350
|
-
version?: string;
|
|
351
|
-
} {
|
|
352
|
-
if (!source.startsWith(MARKETPLACE_PREFIX)) {
|
|
353
|
-
throw new Error(`Not a marketplace source: "${source}"`);
|
|
354
|
-
}
|
|
355
|
-
const rest = source.slice(MARKETPLACE_PREFIX.length).trim();
|
|
356
|
-
if (!rest) {
|
|
357
|
-
throw new Error('marketplace: source requires a slug (marketplace:<slug>[@version]).');
|
|
358
|
-
}
|
|
359
|
-
// Split on the LAST '@' so a slug never legitimately contains one, but be
|
|
360
|
-
// defensive: only treat the suffix as a version if there's a non-empty slug.
|
|
361
|
-
const at = rest.lastIndexOf('@');
|
|
362
|
-
if (at > 0) {
|
|
363
|
-
return { slug: rest.slice(0, at), version: rest.slice(at + 1) || undefined };
|
|
364
|
-
}
|
|
365
|
-
return { slug: rest };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* A resolved marketplace listing detail — the public `GET /v1/listings/{id}`
|
|
370
|
-
* shape (US-005) reduced to the fields the installer needs. The presigned
|
|
371
|
-
* `downloadUrl` is short-lived; `listingId` lets us re-resolve a fresh one if
|
|
372
|
-
* it expires mid-download.
|
|
373
|
-
*/
|
|
374
|
-
export interface MarketplaceListing {
|
|
375
|
-
listingId: string;
|
|
376
|
-
slug: string;
|
|
377
|
-
version?: string;
|
|
378
|
-
/** Short-lived presigned S3 URL for the tarball. */
|
|
379
|
-
downloadUrl: string;
|
|
380
|
-
/** Lowercase-hex sha256 the listing pinned + approval is bound to (US-021). */
|
|
381
|
-
contentHash: string;
|
|
382
|
-
/** Hash algorithm the server reported (e.g. `sha256`), when present. */
|
|
383
|
-
contentHashAlg?: string;
|
|
384
|
-
/** Base64 Ed25519 signature over the contentHash (optional during key-defer). */
|
|
385
|
-
signature?: string;
|
|
386
|
-
/** Identifier of the signing key the server used, when present. */
|
|
387
|
-
signingKeyId?: string;
|
|
388
|
-
/** Platform public key (PEM/SPKI) to verify the signature against. */
|
|
389
|
-
publicKey?: string;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Network seams for the marketplace transport, dependency-injected so unit
|
|
394
|
-
* tests can mock resolve + download without real HTTP/S3. Production wiring
|
|
395
|
-
* lives in `defaultMarketplaceDeps`.
|
|
396
|
-
*/
|
|
397
|
-
export interface MarketplaceDeps {
|
|
398
|
-
/**
|
|
399
|
-
* Resolve `slug[@version]` to a listing detail (id + presigned URL + the
|
|
400
|
-
* approved hash/signature/publicKey). Throws if no approved listing matches.
|
|
401
|
-
*/
|
|
402
|
-
resolveListing: (slug: string, version?: string) => Promise<MarketplaceListing>;
|
|
403
|
-
/** Re-fetch the listing detail by id to mint a FRESH presigned URL. */
|
|
404
|
-
refreshListing: (listingId: string) => Promise<MarketplaceListing>;
|
|
405
|
-
/** Download the tarball bytes from a presigned URL. Returns null on 403/expired. */
|
|
406
|
-
download: (url: string) => Promise<Uint8Array | { expired: true }>;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** True when a download result signals an expired/forbidden presigned URL. */
|
|
410
|
-
function isExpired(r: Uint8Array | { expired: true }): r is { expired: true } {
|
|
411
|
-
return !(r instanceof Uint8Array) && (r as { expired?: true }).expired === true;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
interface RawListingSummary {
|
|
415
|
-
listingId?: string;
|
|
416
|
-
id?: string;
|
|
417
|
-
slug?: string;
|
|
418
|
-
version?: string;
|
|
419
|
-
latestVersion?: string;
|
|
420
|
-
status?: string;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
interface RawListingDetailFields extends RawListingSummary {
|
|
424
|
-
downloadUrl?: string;
|
|
425
|
-
url?: string;
|
|
426
|
-
contentHash?: string;
|
|
427
|
-
contentHashAlg?: string;
|
|
428
|
-
sha256?: string;
|
|
429
|
-
signature?: string;
|
|
430
|
-
signingKeyId?: string;
|
|
431
|
-
publicKey?: string;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* `GET /v1/listings/{id}` returns the listing WRAPPED in an envelope
|
|
436
|
-
* (`{ listing: { id, downloadUrl, contentHash, ... } }`). Older/back-compat
|
|
437
|
-
* responses returned the listing fields at the top level, so we accept both.
|
|
438
|
-
*/
|
|
439
|
-
interface RawListingDetail extends RawListingDetailFields {
|
|
440
|
-
listing?: RawListingDetailFields;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** Map a raw `GET /v1/listings/{id}` body into a MarketplaceListing. */
|
|
444
|
-
export function toMarketplaceListing(raw: RawListingDetail): MarketplaceListing {
|
|
445
|
-
// Unwrap the `{ listing: {...} }` envelope when present; fall back to the
|
|
446
|
-
// top level for the legacy flat shape (backwards-compat).
|
|
447
|
-
const detail: RawListingDetailFields =
|
|
448
|
-
raw.listing && typeof raw.listing === 'object' ? raw.listing : raw;
|
|
449
|
-
// The detail uses `id`; the legacy flat shape may use `listingId`.
|
|
450
|
-
const listingId = detail.id ?? detail.listingId;
|
|
451
|
-
const downloadUrl = detail.downloadUrl ?? detail.url;
|
|
452
|
-
const contentHash = detail.contentHash ?? detail.sha256;
|
|
453
|
-
if (!listingId) throw new Error('Listing detail missing an id.');
|
|
454
|
-
if (!downloadUrl) {
|
|
455
|
-
throw new Error(`Listing ${listingId} has no download URL — it may not be approved yet.`);
|
|
456
|
-
}
|
|
457
|
-
if (!contentHash) {
|
|
458
|
-
throw new Error(
|
|
459
|
-
`Listing ${listingId} has no content hash — refusing to install an unverifiable artifact.`,
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
return {
|
|
463
|
-
listingId,
|
|
464
|
-
slug: detail.slug ?? '',
|
|
465
|
-
version: detail.version ?? detail.latestVersion,
|
|
466
|
-
downloadUrl,
|
|
467
|
-
contentHash,
|
|
468
|
-
contentHashAlg: detail.contentHashAlg,
|
|
469
|
-
signature: detail.signature,
|
|
470
|
-
signingKeyId: detail.signingKeyId,
|
|
471
|
-
publicKey: detail.publicKey,
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** Default production marketplace deps — public listings API + presigned S3. */
|
|
476
|
-
export function defaultMarketplaceDeps(): MarketplaceDeps {
|
|
477
|
-
const fetchDetail = async (listingId: string): Promise<MarketplaceListing> => {
|
|
478
|
-
const res = await vaultApiFetchPublic({
|
|
479
|
-
path: `/v1/listings/${encodeURIComponent(listingId)}`,
|
|
480
|
-
});
|
|
481
|
-
if (!res.ok) {
|
|
482
|
-
throw new Error(
|
|
483
|
-
`Failed to resolve listing ${listingId} (HTTP ${res.status}). The pack may be unavailable or unapproved.`,
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
const body = (await res.json().catch(() => ({}))) as RawListingDetail;
|
|
487
|
-
return toMarketplaceListing(body);
|
|
488
|
-
};
|
|
489
|
-
return {
|
|
490
|
-
resolveListing: async (slug, version) => {
|
|
491
|
-
// Search by slug (public). The API returns approved listings only.
|
|
492
|
-
//
|
|
493
|
-
// BUGFIX: the browse handler historically IGNORED `?slug=` and only
|
|
494
|
-
// honored `?q=` (fuzzy), so `?slug=tdd` returned ALL approved listings
|
|
495
|
-
// newest-first — and blindly taking `listings[0]` then installed the
|
|
496
|
-
// newest pack, not the requested slug (e.g. `marketplace:tdd` installed
|
|
497
|
-
// hq-pack-review). We now (a) query with BOTH `slug` (exact, server-side
|
|
498
|
-
// filter being added in parallel) AND `q` (fuzzy fallback) so the right
|
|
499
|
-
// results come back whichever param the server honors, and (b) NEVER take
|
|
500
|
-
// `listings[0]` — we exact-match `l.slug === slug` among the results and
|
|
501
|
-
// throw a clear error if none matches, rather than installing the wrong
|
|
502
|
-
// pack.
|
|
503
|
-
const query: Record<string, string> = { slug, q: slug };
|
|
504
|
-
if (version) query.version = version;
|
|
505
|
-
const res = await vaultApiFetchPublic({
|
|
506
|
-
path: '/v1/listings',
|
|
507
|
-
query,
|
|
508
|
-
});
|
|
509
|
-
if (!res.ok) {
|
|
510
|
-
throw new Error(
|
|
511
|
-
`Failed to search marketplace for "${slug}" (HTTP ${res.status}).`,
|
|
512
|
-
);
|
|
513
|
-
}
|
|
514
|
-
const body = (await res.json().catch(() => ({}))) as {
|
|
515
|
-
listings?: RawListingSummary[];
|
|
516
|
-
};
|
|
517
|
-
const listings = body.listings ?? [];
|
|
518
|
-
// Only consider listings whose slug EXACTLY matches the request — the
|
|
519
|
-
// fuzzy `q` search (and a slug-ignoring browse handler) can return
|
|
520
|
-
// unrelated packs, so an exact-slug filter is the safety floor.
|
|
521
|
-
const exact = listings.filter((l) => l.slug === slug);
|
|
522
|
-
let match: RawListingSummary | undefined;
|
|
523
|
-
if (version) {
|
|
524
|
-
// Pinned `marketplace:<slug>@<version>` → require an exact version too.
|
|
525
|
-
match = exact.find((l) => (l.version ?? l.latestVersion) === version);
|
|
526
|
-
} else {
|
|
527
|
-
// No pin → newest exact-slug match. The feed is newest-first, so the
|
|
528
|
-
// first exact-slug entry is the newest version of that slug.
|
|
529
|
-
match = exact[0];
|
|
530
|
-
}
|
|
531
|
-
if (!match) {
|
|
532
|
-
throw new Error(
|
|
533
|
-
`No marketplace listing found for slug "${slug}"${version ? `@${version}` : ''}.`,
|
|
534
|
-
);
|
|
535
|
-
}
|
|
536
|
-
const id = match.listingId ?? match.id;
|
|
537
|
-
if (!id) throw new Error(`Listing for "${slug}" has no id.`);
|
|
538
|
-
// The summary may omit the presigned URL; always fetch detail to mint it.
|
|
539
|
-
return fetchDetail(id);
|
|
540
|
-
},
|
|
541
|
-
refreshListing: fetchDetail,
|
|
542
|
-
download: async (url) => {
|
|
543
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
544
|
-
if (response.status === 403) return { expired: true };
|
|
545
|
-
if (!response.ok) {
|
|
546
|
-
throw new Error(`Tarball download failed (HTTP ${response.status}).`);
|
|
547
|
-
}
|
|
548
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
549
|
-
},
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Fetch + verify + safe-extract a marketplace pack. Mirrors `fetchNpm`'s
|
|
555
|
-
* contract (returns a payloadDir the caller mvs into core/packages/), but
|
|
556
|
-
* resolves the tarball from the marketplace and ALWAYS verifies it against the
|
|
557
|
-
* listing's approved hash/signature BEFORE extraction (US-021 → US-020).
|
|
558
|
-
*
|
|
559
|
-
* @param requireSignature production posture once signing keys exist; during
|
|
560
|
-
* the key-deferral window callers pass false (hash still always checked).
|
|
561
|
-
*/
|
|
562
|
-
export async function fetchMarketplace(
|
|
563
|
-
source: string,
|
|
564
|
-
tmpDir: string,
|
|
565
|
-
deps: MarketplaceDeps,
|
|
566
|
-
opts: { requireSignature?: boolean } = {},
|
|
567
|
-
): Promise<FetchResult> {
|
|
568
|
-
const { slug, version } = parseMarketplaceSource(source);
|
|
569
|
-
let listing = await deps.resolveListing(slug, version);
|
|
570
|
-
|
|
571
|
-
// Download with a single expired-URL retry: a presigned URL can expire
|
|
572
|
-
// between resolve and download, surfacing as a raw S3 403. We re-resolve the
|
|
573
|
-
// detail by id to mint a fresh URL and retry ONCE rather than leaking the 403.
|
|
574
|
-
let bytes = await deps.download(listing.downloadUrl);
|
|
575
|
-
if (isExpired(bytes)) {
|
|
576
|
-
listing = await deps.refreshListing(listing.listingId);
|
|
577
|
-
bytes = await deps.download(listing.downloadUrl);
|
|
578
|
-
if (isExpired(bytes)) {
|
|
579
|
-
throw new Error(
|
|
580
|
-
`Marketplace download URL for "${slug}" expired and re-resolving did not help. ` +
|
|
581
|
-
`Try again in a moment.`,
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
const tarballBytes = bytes as Uint8Array;
|
|
586
|
-
|
|
587
|
-
// SECURITY (US-021): verify integrity (+ authenticity) over the EXACT
|
|
588
|
-
// downloaded bytes BEFORE extraction. A hash/signature mismatch throws
|
|
589
|
-
// `ArtifactVerificationError` and nothing is unpacked or wired. The hash is
|
|
590
|
-
// ALWAYS verified when present; signature is enforced when requireSignature.
|
|
591
|
-
verifyArtifact({
|
|
592
|
-
tarballBytes,
|
|
593
|
-
expectedHash: listing.contentHash,
|
|
594
|
-
signature: listing.signature,
|
|
595
|
-
publicKey: listing.publicKey,
|
|
596
|
-
requireSignature: opts.requireSignature ?? false,
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
// Write to a tmp tarball and run it through the SAME hardened extractor
|
|
600
|
-
// (US-020) as every other tarball transport.
|
|
601
|
-
const tarballPath = path.join(tmpDir, 'marketplace.tar.gz');
|
|
602
|
-
fs.writeFileSync(tarballPath, tarballBytes);
|
|
603
|
-
const extractDir = path.join(tmpDir, 'extracted');
|
|
604
|
-
safeExtractTarball(tarballPath, extractDir);
|
|
605
|
-
|
|
606
|
-
// Marketplace tarballs are packed with `tar -C payloadDir .` (publish.ts), so
|
|
607
|
-
// the manifest + contributes dirs are at the archive root — no `package/`
|
|
608
|
-
// wrapper to descend into.
|
|
609
|
-
const payloadDir = extractDir;
|
|
610
|
-
if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
|
|
611
|
-
throw new Error(
|
|
612
|
-
`Marketplace tarball for "${slug}" has no package.yaml at its root.`,
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Record the marketplace source for re-install + `hq packs update`. We stamp
|
|
617
|
-
// the resolved version so a later `update` can detect a newer listing.
|
|
618
|
-
const resolvedSource = listing.version
|
|
619
|
-
? `${MARKETPLACE_PREFIX}${slug}@${listing.version}`
|
|
620
|
-
: `${MARKETPLACE_PREFIX}${slug}`;
|
|
621
|
-
return { payloadDir, resolvedSource };
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function fetchGit(
|
|
625
|
-
source: string,
|
|
626
|
-
tmpDir: string,
|
|
627
|
-
followBranch: boolean
|
|
628
|
-
): FetchResult {
|
|
629
|
-
const parsed = parseGitFragment(source);
|
|
630
|
-
const url = expandGithubShorthand(parsed.url);
|
|
631
|
-
const { subpath, ref } = parsed;
|
|
632
|
-
|
|
633
|
-
// Defensive: subpath must not escape the clone root.
|
|
634
|
-
if (subpath) {
|
|
635
|
-
if (
|
|
636
|
-
subpath.startsWith('/') ||
|
|
637
|
-
subpath.split('/').some((seg) => seg === '..' || seg === '')
|
|
638
|
-
) {
|
|
639
|
-
throw new Error(
|
|
640
|
-
`Invalid subpath "${subpath}" — must be a relative path within the repo.`
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Defensive: reject refs that would be ambiguous or shell-dangerous.
|
|
646
|
-
// `git` itself would tolerate most of these, but refusing them early avoids
|
|
647
|
-
// having to reason about quoting later.
|
|
648
|
-
if (ref && /[\s;&|`$<>(){}\\]/.test(ref)) {
|
|
649
|
-
throw new Error(`Invalid ref "${ref}" — contains disallowed characters.`);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const cloneDir = path.join(tmpDir, 'clone');
|
|
653
|
-
// Disambiguate ref=branch-or-tag vs. ref=SHA authoritatively via `git
|
|
654
|
-
// ls-remote`. Hex-shaped branch names (e.g. `deadbeef`) previously got
|
|
655
|
-
// silently downgraded to the "no --branch" path by a heuristic regex;
|
|
656
|
-
// that is now fixed. Arguments are passed as argv; no shell.
|
|
657
|
-
const refIsNamedRef = !!ref && isNamedRef(url, ref);
|
|
658
|
-
const cloneArgs = ['clone', '--depth', '1'];
|
|
659
|
-
if (refIsNamedRef) cloneArgs.push('--branch', ref);
|
|
660
|
-
cloneArgs.push(url, cloneDir);
|
|
661
|
-
execFileSync('git', cloneArgs, { stdio: 'inherit' });
|
|
662
|
-
if (ref && !refIsNamedRef) {
|
|
663
|
-
// SHA (or something ls-remote didn't recognize as a ref). Try a direct
|
|
664
|
-
// checkout first — works if the SHA happens to be HEAD of the default
|
|
665
|
-
// branch — otherwise unshallow and retry.
|
|
666
|
-
try {
|
|
667
|
-
execFileSync('git', ['-C', cloneDir, 'checkout', ref], {
|
|
668
|
-
stdio: 'inherit',
|
|
669
|
-
});
|
|
670
|
-
} catch {
|
|
671
|
-
execFileSync('git', ['-C', cloneDir, 'fetch', '--unshallow', 'origin'], {
|
|
672
|
-
stdio: 'inherit',
|
|
673
|
-
});
|
|
674
|
-
execFileSync('git', ['-C', cloneDir, 'checkout', ref], {
|
|
675
|
-
stdio: 'inherit',
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
const resolvedSha = execFileSync(
|
|
680
|
-
'git',
|
|
681
|
-
['-C', cloneDir, 'rev-parse', 'HEAD'],
|
|
682
|
-
{ encoding: 'utf-8' },
|
|
683
|
-
).trim();
|
|
684
|
-
|
|
685
|
-
// If a subpath is specified, the payload is only that subdirectory.
|
|
686
|
-
let payloadDir = cloneDir;
|
|
687
|
-
if (subpath) {
|
|
688
|
-
const subAbs = path.join(cloneDir, subpath);
|
|
689
|
-
if (!fs.existsSync(subAbs) || !fs.statSync(subAbs).isDirectory()) {
|
|
690
|
-
throw new Error(
|
|
691
|
-
`subpath "${subpath}" not found in ${url}@${resolvedSha.slice(0, 7)}`
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
payloadDir = path.join(tmpDir, 'payload');
|
|
695
|
-
fs.mkdirSync(payloadDir, { recursive: true });
|
|
696
|
-
rsyncDir(subAbs, payloadDir);
|
|
697
|
-
} else {
|
|
698
|
-
// Drop .git — the pack should be file content, not a nested repo
|
|
699
|
-
fs.rmSync(path.join(cloneDir, '.git'), { recursive: true, force: true });
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Recorded source shape:
|
|
703
|
-
// with subpath: url#subpath@<sha|ref>
|
|
704
|
-
// without subpath: url#<sha|ref>
|
|
705
|
-
// --branch opt-in follows the ref by name; default SHA-pins.
|
|
706
|
-
const recordedRef = followBranch && ref ? ref : resolvedSha;
|
|
707
|
-
const resolvedSource = subpath
|
|
708
|
-
? `${parsed.url}#${subpath}@${recordedRef}`
|
|
709
|
-
: `${parsed.url}#${recordedRef}`;
|
|
710
|
-
return { payloadDir, resolvedSource, resolvedSha };
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function fetchLocal(source: string, tmpDir: string): FetchResult {
|
|
714
|
-
const clean = source.startsWith('file:') ? source.slice('file:'.length) : source;
|
|
715
|
-
const abs = path.resolve(process.cwd(), clean);
|
|
716
|
-
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
717
|
-
throw new Error(`Local path not a directory: ${abs}`);
|
|
718
|
-
}
|
|
719
|
-
const payloadDir = path.join(tmpDir, 'local');
|
|
720
|
-
fs.mkdirSync(payloadDir, { recursive: true });
|
|
721
|
-
rsyncDir(abs, payloadDir);
|
|
722
|
-
return { payloadDir, resolvedSource: clean };
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Invoke rsync with an argv array (no shell). Trailing slashes on the source
|
|
727
|
-
* path are preserved by passing the exact strings through execFileSync.
|
|
728
|
-
*/
|
|
729
|
-
function rsyncDir(src: string, dest: string): void {
|
|
730
|
-
const srcSlashed = src.endsWith('/') ? src : `${src}/`;
|
|
731
|
-
const destSlashed = dest.endsWith('/') ? dest : `${dest}/`;
|
|
732
|
-
execFileSync(
|
|
733
|
-
'rsync',
|
|
734
|
-
[
|
|
735
|
-
'-a',
|
|
736
|
-
'--exclude=.git',
|
|
737
|
-
'--exclude=node_modules',
|
|
738
|
-
'--exclude=.DS_Store',
|
|
739
|
-
srcSlashed,
|
|
740
|
-
destSlashed,
|
|
741
|
-
],
|
|
742
|
-
{ stdio: 'inherit' },
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Ask git whether <ref> resolves as a named branch or tag on <url>. Returns
|
|
748
|
-
* true only when ls-remote prints a matching refs/heads/<ref> or
|
|
749
|
-
* refs/tags/<ref>. Anything else (including hex-shaped branch names that
|
|
750
|
-
* happen to be missing, network failures, or explicit SHAs) falls through to
|
|
751
|
-
* the SHA-checkout path. Uses argv — no shell.
|
|
752
|
-
*/
|
|
753
|
-
function isNamedRef(url: string, ref: string): boolean {
|
|
754
|
-
try {
|
|
755
|
-
const out = gitLsRemote(['--heads', '--tags', url, ref]);
|
|
756
|
-
return out.trim().length > 0;
|
|
757
|
-
} catch {
|
|
758
|
-
return false;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function gitLsRemote(args: string[]): string {
|
|
763
|
-
const key = args.join('\0');
|
|
764
|
-
const cached = gitLsRemoteMemo.get(key);
|
|
765
|
-
if (cached !== undefined) return cached;
|
|
766
|
-
const out = execFileSync(
|
|
767
|
-
'git',
|
|
768
|
-
['ls-remote', ...args],
|
|
769
|
-
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
770
|
-
);
|
|
771
|
-
gitLsRemoteMemo.set(key, out);
|
|
772
|
-
return out;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
async function fetchLatestNpmVersion(pkg: string, opts: ResolveLatestOptions): Promise<string | undefined> {
|
|
776
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
777
|
-
const res = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, {
|
|
778
|
-
headers: { Accept: 'application/json' },
|
|
779
|
-
signal: AbortSignal.timeout(PACK_UPDATE_FETCH_TIMEOUT_MS),
|
|
780
|
-
});
|
|
781
|
-
if (!res.ok) throw new Error(`registry returned ${res.status}`);
|
|
782
|
-
const body = (await res.json()) as { version?: unknown };
|
|
783
|
-
return typeof body.version === 'string' ? body.version : undefined;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// ---------------------------------------------------------------------------
|
|
787
|
-
// Update-availability probe (no install) — used by `hq packs update --check-only`
|
|
788
|
-
// ---------------------------------------------------------------------------
|
|
789
|
-
|
|
790
|
-
export interface LatestResult {
|
|
791
|
-
transport: Transport;
|
|
792
|
-
/** Identifier of the currently-installed pack (sha for git, version for npm). */
|
|
793
|
-
current?: string;
|
|
794
|
-
/** Latest available identifier from the remote. */
|
|
795
|
-
latest?: string;
|
|
796
|
-
/** true/false when a comparison was possible; null when undeterminable (local). */
|
|
797
|
-
updateAvailable: boolean | null;
|
|
798
|
-
error?: string;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/** Extract the ref (sha or named ref) recorded in a stamped git source. */
|
|
802
|
-
function gitRefFromSource(source: string): string | undefined {
|
|
803
|
-
const { subpath, ref } = parseGitFragment(source);
|
|
804
|
-
// For 'url#subpath@ref' parseGitFragment returns ref; for 'url#ref' likewise.
|
|
805
|
-
// A bare 'url#subpath' (no @ref) has no ref.
|
|
806
|
-
void subpath;
|
|
807
|
-
return ref;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function isFullGitSha(ref: string): boolean {
|
|
811
|
-
return /^[0-9a-f]{40}$/i.test(ref);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Probe whether a newer version of an already-installed pack is available,
|
|
816
|
-
* WITHOUT fetching or installing. Reuses the same git/npm primitives as the
|
|
817
|
-
* install path. Never throws — network/parse failures return
|
|
818
|
-
* `{ updateAvailable: null, error }` so callers (the menubar) stay resilient.
|
|
819
|
-
*
|
|
820
|
-
* @param source the stamped `source:` from the installed package.yaml
|
|
821
|
-
* @param installedVersion the installed pack's manifest `version` (npm compare)
|
|
822
|
-
*/
|
|
823
|
-
export async function resolveLatest(
|
|
824
|
-
source: string,
|
|
825
|
-
installedVersion?: string,
|
|
826
|
-
opts: ResolveLatestOptions = {},
|
|
827
|
-
): Promise<LatestResult> {
|
|
828
|
-
let transport: Transport;
|
|
829
|
-
try {
|
|
830
|
-
transport = classify(source);
|
|
831
|
-
} catch (e) {
|
|
832
|
-
return { transport: 'local', updateAvailable: null, error: (e as Error).message };
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
if (transport === 'local') {
|
|
836
|
-
return { transport, updateAvailable: null, error: 'local source — re-run to re-sync' };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
if (transport === 'marketplace') {
|
|
840
|
-
// The marketplace probe needs an async network call (listings API), which
|
|
841
|
-
// this synchronous helper cannot make. Callers that want the marketplace
|
|
842
|
-
// update check use the async `resolveLatestMarketplace`; here we report the
|
|
843
|
-
// installed version but leave availability undeterminable (null) so the
|
|
844
|
-
// sync menubar `--check-updates` path is not regressed.
|
|
845
|
-
const { version } = parseMarketplaceSource(source);
|
|
846
|
-
return {
|
|
847
|
-
transport,
|
|
848
|
-
current: installedVersion ?? version,
|
|
849
|
-
updateAvailable: null,
|
|
850
|
-
error: 'marketplace source — use async update check',
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
if (transport === 'npm') {
|
|
855
|
-
const pkg = stripVersion(source);
|
|
856
|
-
const current =
|
|
857
|
-
installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
|
|
858
|
-
try {
|
|
859
|
-
const latest = await latestWithDiskCache(`npm:${pkg}`, opts, () =>
|
|
860
|
-
fetchLatestNpmVersion(pkg, opts),
|
|
861
|
-
);
|
|
862
|
-
const updateAvailable =
|
|
863
|
-
current && latest ? semverGt(latest, current) : null;
|
|
864
|
-
return { transport, current, latest, updateAvailable };
|
|
865
|
-
} catch (e) {
|
|
866
|
-
return { transport, current, updateAvailable: null, error: `npm registry check failed: ${(e as Error).message}` };
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// git
|
|
871
|
-
const parsed = parseGitFragment(source);
|
|
872
|
-
let url: string;
|
|
873
|
-
try {
|
|
874
|
-
url = expandGithubShorthand(parsed.url);
|
|
875
|
-
} catch (e) {
|
|
876
|
-
return { transport, updateAvailable: null, error: (e as Error).message };
|
|
877
|
-
}
|
|
878
|
-
const current = gitRefFromSource(source);
|
|
879
|
-
// If install followed a named ref (branch/tag), compare that ref's tip;
|
|
880
|
-
// otherwise (default SHA-pin) compare the default branch HEAD.
|
|
881
|
-
const refArg = current && !isFullGitSha(current) && isNamedRef(url, current) ? current : 'HEAD';
|
|
882
|
-
try {
|
|
883
|
-
const latest = await latestWithDiskCache(`git:${url}#${refArg}`, opts, () => {
|
|
884
|
-
const out = gitLsRemote([url, refArg]).trim();
|
|
885
|
-
return out.split(/\s+/)[0] || undefined;
|
|
886
|
-
});
|
|
887
|
-
const updateAvailable =
|
|
888
|
-
current && latest ? !latest.startsWith(current) && !current.startsWith(latest) : null;
|
|
889
|
-
return { transport, current, latest, updateAvailable };
|
|
890
|
-
} catch (e) {
|
|
891
|
-
return { transport, current, updateAvailable: null, error: `git ls-remote failed: ${(e as Error).message}` };
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Async marketplace update probe (US-006): resolve the slug's latest approved
|
|
897
|
-
* listing version and compare it to the installed version. Reuses the same
|
|
898
|
-
* `MarketplaceDeps.resolveListing` seam as the install path (resolving with NO
|
|
899
|
-
* version pin returns the latest listing). Never throws — network/parse
|
|
900
|
-
* failures return `{ updateAvailable: null, error }` so `hq packs update`
|
|
901
|
-
* stays resilient.
|
|
902
|
-
*
|
|
903
|
-
* @param source the stamped `marketplace:<slug>[@version]` source
|
|
904
|
-
* @param installedVersion the installed pack's manifest version (semver compare)
|
|
905
|
-
* @param deps injected for tests; defaults to the public API
|
|
906
|
-
*/
|
|
907
|
-
export async function resolveLatestMarketplace(
|
|
908
|
-
source: string,
|
|
909
|
-
installedVersion?: string,
|
|
910
|
-
deps?: MarketplaceDeps,
|
|
911
|
-
): Promise<LatestResult> {
|
|
912
|
-
let slug: string;
|
|
913
|
-
let pinned: string | undefined;
|
|
914
|
-
try {
|
|
915
|
-
({ slug, version: pinned } = parseMarketplaceSource(source));
|
|
916
|
-
} catch (e) {
|
|
917
|
-
return { transport: 'marketplace', updateAvailable: null, error: (e as Error).message };
|
|
918
|
-
}
|
|
919
|
-
const current = installedVersion ?? pinned;
|
|
920
|
-
try {
|
|
921
|
-
const d = deps ?? defaultMarketplaceDeps();
|
|
922
|
-
// No version pin → latest approved listing.
|
|
923
|
-
const listing = await d.resolveListing(slug);
|
|
924
|
-
const latest = listing.version;
|
|
925
|
-
let updateAvailable: boolean | null = null;
|
|
926
|
-
if (current && latest) {
|
|
927
|
-
// Prefer semver compare; fall back to inequality if either side isn't
|
|
928
|
-
// valid semver (so a non-semver listing version still flags a change).
|
|
929
|
-
updateAvailable =
|
|
930
|
-
semverValid(latest) && semverValid(current)
|
|
931
|
-
? semverGt(latest, current)
|
|
932
|
-
: latest !== current;
|
|
933
|
-
}
|
|
934
|
-
return { transport: 'marketplace', current, latest, updateAvailable };
|
|
935
|
-
} catch (e) {
|
|
936
|
-
return {
|
|
937
|
-
transport: 'marketplace',
|
|
938
|
-
current,
|
|
939
|
-
updateAvailable: null,
|
|
940
|
-
error: `marketplace check failed: ${(e as Error).message}`,
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
// ---------------------------------------------------------------------------
|
|
946
|
-
// Marketplace artifact verification (US-021, INSTALL side)
|
|
947
|
-
//
|
|
948
|
-
// Guarantees the bytes we install are EXACTLY the bytes a moderator approved:
|
|
949
|
-
// 1. recompute sha256 over the downloaded tarball bytes and compare it to the
|
|
950
|
-
// hash the listing pinned at publish (and approval bound to);
|
|
951
|
-
// 2. verify the platform Ed25519 signature over that hash against a
|
|
952
|
-
// distributable PUBLIC key.
|
|
953
|
-
//
|
|
954
|
-
// SHARED CONTRACT (must match hq-pro `src/listings/signing.ts`):
|
|
955
|
-
// • CANONICAL BYTES = the gzipped tarball object EXACTLY as uploaded to S3
|
|
956
|
-
// (i.e. exactly the bytes we downloaded — no transform).
|
|
957
|
-
// • contentHash = sha256(canonical bytes), lowercase hex.
|
|
958
|
-
// • signature = Ed25519 over the UTF-8 bytes of the lowercase-hex
|
|
959
|
-
// contentHash, base64-encoded.
|
|
960
|
-
//
|
|
961
|
-
// This runs BEFORE safe-extract (US-020): a mismatch must abort the install
|
|
962
|
-
// before any bytes are unpacked or wired. US-006 wires the marketplace
|
|
963
|
-
// transport that surfaces the listing's {hash, signature, publicKey}; this
|
|
964
|
-
// function is the reusable verifier it calls.
|
|
965
|
-
// ---------------------------------------------------------------------------
|
|
966
|
-
|
|
967
|
-
/** Thrown when a downloaded artifact fails hash or signature verification. */
|
|
968
|
-
export class ArtifactVerificationError extends Error {
|
|
969
|
-
constructor(message: string) {
|
|
970
|
-
super(message);
|
|
971
|
-
this.name = 'ArtifactVerificationError';
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/** Hash algorithm for the content hash — pinned to sha256 (matches publish). */
|
|
976
|
-
export const ARTIFACT_HASH_ALG = 'sha256' as const;
|
|
977
|
-
|
|
978
|
-
export interface VerifyArtifactInput {
|
|
979
|
-
/** The downloaded gzipped tarball bytes (canonical bytes). */
|
|
980
|
-
tarballBytes: Uint8Array;
|
|
981
|
-
/** Lowercase-hex sha256 the listing pinned at publish (approval bound to it). */
|
|
982
|
-
expectedHash: string;
|
|
983
|
-
/**
|
|
984
|
-
* Base64 Ed25519 signature over the lowercase-hex `expectedHash`. Optional:
|
|
985
|
-
* when the listing carries no signature (e.g. published before signing-key
|
|
986
|
-
* provisioning), hash verification still runs but signature verification is
|
|
987
|
-
* skipped UNLESS `requireSignature` is set.
|
|
988
|
-
*/
|
|
989
|
-
signature?: string;
|
|
990
|
-
/**
|
|
991
|
-
* The platform PUBLIC key (Ed25519) as a PEM/SPKI string or a pre-parsed
|
|
992
|
-
* KeyObject. Required to verify a signature. The public key is distributable
|
|
993
|
-
* (embedded in / fetched by the installer).
|
|
994
|
-
*/
|
|
995
|
-
publicKey?: string | KeyObject;
|
|
996
|
-
/**
|
|
997
|
-
* When true, a missing signature OR missing public key is a hard failure
|
|
998
|
-
* (production posture once signing keys are provisioned). Default false so an
|
|
999
|
-
* unsigned listing still hash-verifies during the deferred-key window.
|
|
1000
|
-
*/
|
|
1001
|
-
requireSignature?: boolean;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
/** Lowercase-hex sha256 of the given bytes — the install-side content hash. */
|
|
1005
|
-
export function computeArtifactHash(tarballBytes: Uint8Array): string {
|
|
1006
|
-
return createHash(ARTIFACT_HASH_ALG).update(tarballBytes).digest('hex');
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
/**
|
|
1010
|
-
* Verify a downloaded marketplace artifact's integrity (hash) and authenticity
|
|
1011
|
-
* (signature). Throws `ArtifactVerificationError` on ANY mismatch — the caller
|
|
1012
|
-
* MUST let it propagate so the install aborts before extraction.
|
|
1013
|
-
*
|
|
1014
|
-
* Constant-time-ish hash comparison: hashes are fixed-length lowercase hex, and
|
|
1015
|
-
* the early-exit risk is negligible (the hash is public), but we still compare
|
|
1016
|
-
* full strings rather than prefixes.
|
|
1017
|
-
*/
|
|
1018
|
-
export function verifyArtifact(input: VerifyArtifactInput): void {
|
|
1019
|
-
const {
|
|
1020
|
-
tarballBytes,
|
|
1021
|
-
expectedHash,
|
|
1022
|
-
signature,
|
|
1023
|
-
publicKey,
|
|
1024
|
-
requireSignature = false,
|
|
1025
|
-
} = input;
|
|
1026
|
-
|
|
1027
|
-
if (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {
|
|
1028
|
-
throw new ArtifactVerificationError(
|
|
1029
|
-
`Refusing to install: listing did not provide a valid ${ARTIFACT_HASH_ALG} content hash.`,
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// 1. Integrity — recompute the hash over the EXACT downloaded bytes.
|
|
1034
|
-
const actualHash = computeArtifactHash(tarballBytes);
|
|
1035
|
-
if (actualHash !== expectedHash) {
|
|
1036
|
-
throw new ArtifactVerificationError(
|
|
1037
|
-
`Artifact hash mismatch — refusing to install. ` +
|
|
1038
|
-
`expected ${ARTIFACT_HASH_ALG}=${expectedHash}, got ${actualHash}. ` +
|
|
1039
|
-
`The downloaded bytes are NOT the bytes that were approved.`,
|
|
1040
|
-
);
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// 2. Authenticity — verify the Ed25519 signature over the hash.
|
|
1044
|
-
if (!signature || !publicKey) {
|
|
1045
|
-
if (requireSignature) {
|
|
1046
|
-
throw new ArtifactVerificationError(
|
|
1047
|
-
signature
|
|
1048
|
-
? 'Artifact signature present but no public key available to verify it — refusing to install.'
|
|
1049
|
-
: 'Artifact is unsigned and a signature is required — refusing to install.',
|
|
1050
|
-
);
|
|
1051
|
-
}
|
|
1052
|
-
// Deferred-key window: hash verified, no signature to check. Caller opted to
|
|
1053
|
-
// allow unsigned (requireSignature=false).
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
let key: KeyObject;
|
|
1058
|
-
try {
|
|
1059
|
-
key = typeof publicKey === 'string' ? createPublicKey(publicKey) : publicKey;
|
|
1060
|
-
} catch (e) {
|
|
1061
|
-
throw new ArtifactVerificationError(
|
|
1062
|
-
`Invalid artifact public key — refusing to install: ${(e as Error).message}`,
|
|
1063
|
-
);
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
let ok = false;
|
|
1067
|
-
try {
|
|
1068
|
-
// Ed25519: digest algorithm is null (the scheme hashes internally). The
|
|
1069
|
-
// signed payload is the UTF-8 bytes of the lowercase-hex hash — identical to
|
|
1070
|
-
// what hq-pro signed at publish.
|
|
1071
|
-
ok = cryptoVerify(
|
|
1072
|
-
null,
|
|
1073
|
-
Buffer.from(expectedHash, 'utf-8'),
|
|
1074
|
-
key,
|
|
1075
|
-
Buffer.from(signature, 'base64'),
|
|
1076
|
-
);
|
|
1077
|
-
} catch (e) {
|
|
1078
|
-
throw new ArtifactVerificationError(
|
|
1079
|
-
`Artifact signature verification errored — refusing to install: ${(e as Error).message}`,
|
|
1080
|
-
);
|
|
1081
|
-
}
|
|
1082
|
-
if (!ok) {
|
|
1083
|
-
throw new ArtifactVerificationError(
|
|
1084
|
-
'Artifact signature is invalid — refusing to install. ' +
|
|
1085
|
-
'The artifact was not signed by the platform key (possible tampering).',
|
|
1086
|
-
);
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
// ---------------------------------------------------------------------------
|
|
1091
|
-
// MCP per-server manifest validation (US-005, the TS counterpart of the bash
|
|
1092
|
-
// `validate_mcp_manifest` arm in core/scripts/scan-packages.sh).
|
|
1093
|
-
//
|
|
1094
|
-
// The `mcp` contributes key is `wire: 'merge'` — its per-server manifests are
|
|
1095
|
-
// NEVER symlinked; they are validated here and (US-006) merged into the agent
|
|
1096
|
-
// configs via registerMcpServers. This function MIRRORS the bash transport
|
|
1097
|
-
// rules hand-coded against the draft-07 schema
|
|
1098
|
-
// (repos/public/knowledge-hq-core/mcp-manifest.schema.json). There is no `ajv`
|
|
1099
|
-
// dependency in hq-cli; the schema file is single-source documentation, so the
|
|
1100
|
-
// rules are replicated here (dependency-free, robust if the schema is absent).
|
|
1101
|
-
// ---------------------------------------------------------------------------
|
|
1102
|
-
|
|
1103
|
-
/** Allowed top-level keys (schema `additionalProperties: false`). */
|
|
1104
|
-
const MCP_ALLOWED_KEYS = ['type', 'url', 'headers', 'command', 'args', 'env', 'tools'];
|
|
1105
|
-
|
|
1106
|
-
/** Schema `$id` for an at-a-glance error reference (kept inline; no file read). */
|
|
1107
|
-
const MCP_SCHEMA_ID = 'mcp-manifest.schema.json';
|
|
1108
|
-
|
|
1109
|
-
/** True iff a header/env value carries a `${secret:NAME}` reference. */
|
|
1110
|
-
function hasSecretRef(value: string): boolean {
|
|
1111
|
-
return /\$\{secret:/.test(value);
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
/**
|
|
1115
|
-
* Validate that a record (`headers` or `env`) is an object of STRING values,
|
|
1116
|
-
* pushing one error per offending entry into `errs`. Mirrors the schema's
|
|
1117
|
-
* `additionalProperties: { type: 'string' }`.
|
|
1118
|
-
*/
|
|
1119
|
-
function checkStringMap(
|
|
1120
|
-
errs: string[],
|
|
1121
|
-
label: 'headers' | 'env',
|
|
1122
|
-
value: unknown,
|
|
1123
|
-
): void {
|
|
1124
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1125
|
-
errs.push(`${label} must be an object`);
|
|
1126
|
-
return;
|
|
1127
|
-
}
|
|
1128
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
1129
|
-
if (typeof v !== 'string') {
|
|
1130
|
-
errs.push(`${label} "${k}" value must be a string`);
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
/**
|
|
1136
|
-
* Parse + shape-validate one pack's per-server MCP manifest (`mcp/{item}.json`),
|
|
1137
|
-
* mirroring the bash `validate_mcp_manifest` arm. EXPORTED so the acceptance
|
|
1138
|
-
* test (and US-006's registration engine) can call it directly.
|
|
1139
|
-
*
|
|
1140
|
-
* Throws an `Error` (whose message names the pack-relative payload path, e.g.
|
|
1141
|
-
* `mcp/foo.json`) when the file does not parse as JSON or violates a transport
|
|
1142
|
-
* rule:
|
|
1143
|
-
* - `type` required, one of `http|stdio|sse`;
|
|
1144
|
-
* - no unknown top-level keys (allowed: {@link MCP_ALLOWED_KEYS});
|
|
1145
|
-
* - http/sse: `url` required + `^https?://`; `command`/`args`/`env` forbidden;
|
|
1146
|
-
* - stdio: non-empty `command` required; `url`/`headers` forbidden;
|
|
1147
|
-
* - `headers`/`env` (if present) are objects of string values;
|
|
1148
|
-
* - NO inline literal `Bearer ` secret — such a value MUST carry a
|
|
1149
|
-
* `${secret:NAME}` reference, never a literal token.
|
|
1150
|
-
*
|
|
1151
|
-
* @param payloadDir the pack payload root (holds `mcp/{item}.json`)
|
|
1152
|
-
* @param item the bare server name declared under `contributes.mcp`
|
|
1153
|
-
*/
|
|
1154
|
-
export function validateMcpManifest(payloadDir: string, item: string): void {
|
|
1155
|
-
const rel = payloadFor('mcp', item); // mcp/{item}.json
|
|
1156
|
-
const abs = path.join(payloadDir, rel);
|
|
1157
|
-
|
|
1158
|
-
// EXISTS — the existsSync gate in validateManifest already covers this, but
|
|
1159
|
-
// re-check so the helper is safe to call standalone (tests, US-006).
|
|
1160
|
-
if (!fs.existsSync(abs)) {
|
|
1161
|
-
throw new Error(`MCP manifest missing: ${rel} (declared under contributes.mcp but no such file)`);
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// PARSE — a non-JSON file is a hard error that names the file.
|
|
1165
|
-
let doc: unknown;
|
|
1166
|
-
try {
|
|
1167
|
-
doc = JSON.parse(fs.readFileSync(abs, 'utf-8'));
|
|
1168
|
-
} catch (e) {
|
|
1169
|
-
throw new Error(`MCP manifest malformed: ${rel} is not valid JSON (failed to parse: ${(e as Error).message})`);
|
|
1170
|
-
}
|
|
1171
|
-
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
1172
|
-
throw new Error(`MCP manifest malformed: ${rel} must be a JSON object`);
|
|
1173
|
-
}
|
|
1174
|
-
const m = doc as Record<string, unknown>;
|
|
1175
|
-
const errs: string[] = [];
|
|
1176
|
-
|
|
1177
|
-
// type: required + enum.
|
|
1178
|
-
const type = m.type;
|
|
1179
|
-
if (!('type' in m)) {
|
|
1180
|
-
errs.push('missing required field: type');
|
|
1181
|
-
} else if (type !== 'http' && type !== 'stdio' && type !== 'sse') {
|
|
1182
|
-
errs.push(`type must be one of http|stdio|sse (got: ${JSON.stringify(type)})`);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// No unknown top-level keys (schema additionalProperties: false).
|
|
1186
|
-
for (const k of Object.keys(m)) {
|
|
1187
|
-
if (!MCP_ALLOWED_KEYS.includes(k)) {
|
|
1188
|
-
errs.push(`unknown top-level key: ${k}`);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// http / sse transport: url required (^https?://); command/args/env forbidden.
|
|
1193
|
-
if (type === 'http' || type === 'sse') {
|
|
1194
|
-
if (!('url' in m)) {
|
|
1195
|
-
errs.push(`${type} transport requires a url`);
|
|
1196
|
-
} else if (typeof m.url !== 'string' || !/^https?:\/\//.test(m.url)) {
|
|
1197
|
-
errs.push('url must be an absolute http(s) URL');
|
|
1198
|
-
}
|
|
1199
|
-
for (const forbidden of ['command', 'args', 'env'] as const) {
|
|
1200
|
-
if (forbidden in m) errs.push(`${type} transport forbids: ${forbidden}`);
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// stdio transport: non-empty command required; url/headers forbidden.
|
|
1205
|
-
if (type === 'stdio') {
|
|
1206
|
-
if (typeof m.command !== 'string' || m.command.length === 0) {
|
|
1207
|
-
errs.push('stdio transport requires a non-empty command');
|
|
1208
|
-
}
|
|
1209
|
-
for (const forbidden of ['url', 'headers'] as const) {
|
|
1210
|
-
if (forbidden in m) errs.push(`stdio transport forbids: ${forbidden}`);
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// headers / env values must be strings.
|
|
1215
|
-
if ('headers' in m) checkStringMap(errs, 'headers', m.headers);
|
|
1216
|
-
if ('env' in m) checkStringMap(errs, 'env', m.env);
|
|
1217
|
-
|
|
1218
|
-
if (errs.length > 0) {
|
|
1219
|
-
throw new Error(
|
|
1220
|
-
`MCP manifest fails shape validation: ${rel} (schema: ${MCP_SCHEMA_ID})\n` +
|
|
1221
|
-
errs.map((e) => ` - ${e}`).join('\n'),
|
|
1222
|
-
);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
// Reject INLINE LITERAL Bearer secrets. A header/env value containing
|
|
1226
|
-
// 'Bearer ' MUST use a ${secret:NAME} reference, never a literal token baked
|
|
1227
|
-
// into a synced/committed artifact.
|
|
1228
|
-
const bad: string[] = [];
|
|
1229
|
-
for (const [label, mapVal] of [
|
|
1230
|
-
['header', m.headers],
|
|
1231
|
-
['env', m.env],
|
|
1232
|
-
] as const) {
|
|
1233
|
-
if (mapVal === null || typeof mapVal !== 'object' || Array.isArray(mapVal)) continue;
|
|
1234
|
-
for (const [k, v] of Object.entries(mapVal as Record<string, unknown>)) {
|
|
1235
|
-
if (typeof v === 'string' && v.includes('Bearer ') && !hasSecretRef(v)) {
|
|
1236
|
-
bad.push(`${label} "${k}"`);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
if (bad.length > 0) {
|
|
1241
|
-
throw new Error(
|
|
1242
|
-
`MCP manifest contains an inline literal Bearer secret: ${rel} (${bad.join(', ')}) — ` +
|
|
1243
|
-
'a \'Bearer \' header/env value MUST use a ${secret:NAME} reference, never a literal token ' +
|
|
1244
|
-
'(a resolved secret is never written to a synced/committed artifact)',
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
// ---------------------------------------------------------------------------
|
|
1250
|
-
// Manifest validation (spec §Validation, 10 checks)
|
|
1251
|
-
// ---------------------------------------------------------------------------
|
|
1252
|
-
|
|
1253
|
-
export function validateManifest(
|
|
1254
|
-
payloadDir: string,
|
|
1255
|
-
hqVersion: string | null
|
|
1256
|
-
): PackManifest {
|
|
1257
|
-
// 1. parse
|
|
1258
|
-
const manifestPath = path.join(payloadDir, 'package.yaml');
|
|
1259
|
-
if (!fs.existsSync(manifestPath)) {
|
|
1260
|
-
throw new Error('package.yaml missing from pack payload');
|
|
1261
|
-
}
|
|
1262
|
-
let parsed: unknown;
|
|
1263
|
-
try {
|
|
1264
|
-
parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
|
|
1265
|
-
} catch (e) {
|
|
1266
|
-
if (e instanceof Error && (e as { expected?: unknown }).expected === true) {
|
|
1267
|
-
throw e;
|
|
1268
|
-
}
|
|
1269
|
-
throw new Error(`package.yaml invalid YAML: ${(e as Error).message}`);
|
|
1270
|
-
}
|
|
1271
|
-
const m = parsed as Partial<PackManifest> & Record<string, unknown>;
|
|
1272
|
-
if (!m || typeof m !== 'object') {
|
|
1273
|
-
throw new Error('package.yaml must be a YAML mapping');
|
|
1274
|
-
}
|
|
1275
|
-
// 2. name
|
|
1276
|
-
if (!m.name || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(m.name)) {
|
|
1277
|
-
throw new Error(`Invalid pack name "${m.name}" — must match ^hq-pack-[a-z0-9][a-z0-9-]*$`);
|
|
1278
|
-
}
|
|
1279
|
-
// 3. version
|
|
1280
|
-
if (!m.version || !semverValid(m.version)) {
|
|
1281
|
-
throw new Error(`Invalid version "${m.version}" — must be valid semver`);
|
|
1282
|
-
}
|
|
1283
|
-
// 4. publisher
|
|
1284
|
-
if (!m.publisher || !/^@[a-z0-9][a-z0-9-]*$/i.test(m.publisher)) {
|
|
1285
|
-
throw new Error(`Invalid publisher "${m.publisher}" — must be a valid npm scope starting with @`);
|
|
1286
|
-
}
|
|
1287
|
-
// 5. access
|
|
1288
|
-
if (m.access !== 'public' && m.access !== 'private') {
|
|
1289
|
-
throw new Error(`access must be "public" or "private" (got "${m.access}")`);
|
|
1290
|
-
}
|
|
1291
|
-
// 6. requires.hqCore
|
|
1292
|
-
const range = m.requires?.hqCore;
|
|
1293
|
-
if (!range || !semverValidRange(range)) {
|
|
1294
|
-
throw new Error(`requires.hqCore must be a valid semver range (got "${range}")`);
|
|
1295
|
-
}
|
|
1296
|
-
// `includePrerelease` so a prerelease host version (e.g. `15.0.9-beta.1`)
|
|
1297
|
-
// satisfies a plain range like `>=14.2.0`. By default node-semver excludes
|
|
1298
|
-
// prereleases from range matching, which would wrongly reject every dogfooding
|
|
1299
|
-
// beta/rc HQ build from installing packs. A genuinely-too-old host (e.g.
|
|
1300
|
-
// `13.0.0`) still fails the range.
|
|
1301
|
-
if (hqVersion && !semverSatisfies(hqVersion, range, { includePrerelease: true })) {
|
|
1302
|
-
throw new Error(
|
|
1303
|
-
`Host hqCore ${hqVersion} does not satisfy pack requirement ${range}`
|
|
1304
|
-
);
|
|
1305
|
-
}
|
|
1306
|
-
// 6b. requires.packs (M0) — OPTIONAL pack-to-pack dependencies. Absent → legacy
|
|
1307
|
-
// behavior (hqCore is the only prerequisite). Present → a list of
|
|
1308
|
-
// { name, version? } where `name` is a valid hq-pack name and `version` (if
|
|
1309
|
-
// given) is a valid semver RANGE. SHAPE validation only (no filesystem) so a
|
|
1310
|
-
// malformed dependency can't masquerade as valid; whether the named packs are
|
|
1311
|
-
// actually INSTALLED is enforced at install time by assertPackDependencies
|
|
1312
|
-
// (which needs hqRoot — a pure manifest validator doesn't have it).
|
|
1313
|
-
const reqPacks = m.requires?.packs;
|
|
1314
|
-
if (reqPacks !== undefined) {
|
|
1315
|
-
if (!Array.isArray(reqPacks)) {
|
|
1316
|
-
throw new Error('requires.packs must be a list of { name, version? } entries');
|
|
1317
|
-
}
|
|
1318
|
-
for (const dep of reqPacks) {
|
|
1319
|
-
if (!dep || typeof dep !== 'object' || Array.isArray(dep)) {
|
|
1320
|
-
throw new Error(
|
|
1321
|
-
'requires.packs entries must be mappings with a name (and optional version)'
|
|
1322
|
-
);
|
|
1323
|
-
}
|
|
1324
|
-
const d = dep as unknown as Record<string, unknown>;
|
|
1325
|
-
if (typeof d.name !== 'string' || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(d.name)) {
|
|
1326
|
-
throw new Error(
|
|
1327
|
-
`requires.packs[].name "${d.name}" must match ^hq-pack-[a-z0-9][a-z0-9-]*$`
|
|
1328
|
-
);
|
|
1329
|
-
}
|
|
1330
|
-
if (
|
|
1331
|
-
d.version !== undefined &&
|
|
1332
|
-
(typeof d.version !== 'string' || !semverValidRange(d.version))
|
|
1333
|
-
) {
|
|
1334
|
-
throw new Error(
|
|
1335
|
-
`requires.packs entry for "${d.name}" has an invalid version range "${d.version}"`
|
|
1336
|
-
);
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
// 7. contributes has at least one non-empty subfield
|
|
1341
|
-
const contributes = (m.contributes ?? {}) as PackManifest['contributes'];
|
|
1342
|
-
const nonEmpty = Object.values(contributes).some(
|
|
1343
|
-
(v) => Array.isArray(v) && v.length > 0
|
|
1344
|
-
);
|
|
1345
|
-
if (!nonEmpty) {
|
|
1346
|
-
throw new Error('contributes must have at least one non-empty subfield');
|
|
1347
|
-
}
|
|
1348
|
-
// 10. payload files exist (hooks check happens separately in step 8).
|
|
1349
|
-
// Payload paths are READ from the single-source CONTRIBUTION_TABLE (US-003)
|
|
1350
|
-
// via payloadFor -- no restated `subpaths` record. An unknown key (not in the
|
|
1351
|
-
// table) is rejected here so a typo can't silently install unwired.
|
|
1352
|
-
for (const [key, items] of Object.entries(contributes) as [string, unknown][]) {
|
|
1353
|
-
if (!(key in CONTRIBUTION_TABLE)) {
|
|
1354
|
-
throw new Error(`contributes.${key} is not a known contribution type`);
|
|
1355
|
-
}
|
|
1356
|
-
if (!Array.isArray(items)) continue;
|
|
1357
|
-
for (const item of items) {
|
|
1358
|
-
const rel = payloadFor(key as PackContributeKey, item as string);
|
|
1359
|
-
const abs = path.join(payloadDir, rel);
|
|
1360
|
-
if (!fs.existsSync(abs)) {
|
|
1361
|
-
throw new Error(
|
|
1362
|
-
`contributes.${key} declares "${item}" but payload file missing: ${rel}`
|
|
1363
|
-
);
|
|
1364
|
-
}
|
|
1365
|
-
// The `mcp` key (wire: 'merge') is NOT existsSync-only: after the file is
|
|
1366
|
-
// confirmed present it must PARSE + SHAPE-validate against the per-server
|
|
1367
|
-
// MCP schema (US-005), mirroring the bash validate_mcp_manifest arm. Other
|
|
1368
|
-
// (symlink) keys keep their existsSync-only behavior. We dispatch on the
|
|
1369
|
-
// ROUTE from the single-source table (not a hardcoded key) so the merge
|
|
1370
|
-
// path is table-driven; the merge itself (registering into the agent
|
|
1371
|
-
// configs via registerMcpServers, US-006) is NEVER a symlink.
|
|
1372
|
-
if (routeContribution(key as PackContributeKey) === 'merge') {
|
|
1373
|
-
validateMcpManifest(payloadDir, item as string);
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
// author + capabilities (US-001) — both OPTIONAL and backwards-compatible.
|
|
1378
|
-
// Absent → fine (legacy packs). Present → must be well-shaped so a malformed
|
|
1379
|
-
// attribution can't masquerade as a valid one.
|
|
1380
|
-
if (m.author !== undefined) {
|
|
1381
|
-
const a = m.author as unknown as Record<string, unknown>;
|
|
1382
|
-
if (!a || typeof a !== 'object' || Array.isArray(a)) {
|
|
1383
|
-
throw new Error('author must be a mapping with uid, handle, displayName');
|
|
1384
|
-
}
|
|
1385
|
-
for (const field of ['uid', 'handle', 'displayName'] as const) {
|
|
1386
|
-
if (typeof a[field] !== 'string' || (a[field] as string).trim() === '') {
|
|
1387
|
-
throw new Error(`author.${field} must be a non-empty string`);
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
if (m.capabilities !== undefined) {
|
|
1392
|
-
if (
|
|
1393
|
-
!Array.isArray(m.capabilities) ||
|
|
1394
|
-
!m.capabilities.every((c) => typeof c === 'string')
|
|
1395
|
-
) {
|
|
1396
|
-
throw new Error('capabilities must be a list of strings');
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
// initialization (US-004) — OPTIONAL and backwards-compatible. Absent → fine
|
|
1400
|
-
// (legacy packs). Present → the `entrypoint` is REQUIRED and MUST resolve to a
|
|
1401
|
-
// declared `contributes.skills` or `contributes.commands` entry (this is the
|
|
1402
|
-
// content-pack system, so entries are named under `contributes.*` — NOT the
|
|
1403
|
-
// registry `exposes.*` system). The post-install initialization prompt is
|
|
1404
|
-
// rendered/moderated in a later story; here we only validate shape so a
|
|
1405
|
-
// malformed block can't slip through to install.
|
|
1406
|
-
if (m.initialization !== undefined) {
|
|
1407
|
-
const init = m.initialization as unknown;
|
|
1408
|
-
if (!init || typeof init !== 'object' || Array.isArray(init)) {
|
|
1409
|
-
throw new Error('initialization must be a mapping with an entrypoint');
|
|
1410
|
-
}
|
|
1411
|
-
const initObj = init as Record<string, unknown>;
|
|
1412
|
-
const entrypoint = initObj.entrypoint;
|
|
1413
|
-
if (typeof entrypoint !== 'string' || entrypoint.trim() === '') {
|
|
1414
|
-
throw new Error(
|
|
1415
|
-
'initialization.entrypoint is required and must be a non-empty string',
|
|
1416
|
-
);
|
|
1417
|
-
}
|
|
1418
|
-
// Resolve the entrypoint against declared skills/commands. Normalize a
|
|
1419
|
-
// leading slash on BOTH sides so `/email-assistant` matches a contributes
|
|
1420
|
-
// entry named `email-assistant` and vice-versa.
|
|
1421
|
-
const stripSlash = (s: string): string => (s.startsWith('/') ? s.slice(1) : s);
|
|
1422
|
-
const target = stripSlash(entrypoint.trim());
|
|
1423
|
-
const declared = [
|
|
1424
|
-
...(contributes.skills ?? []),
|
|
1425
|
-
...(contributes.commands ?? []),
|
|
1426
|
-
];
|
|
1427
|
-
const resolves = declared.some((d) => stripSlash(d) === target);
|
|
1428
|
-
if (!resolves) {
|
|
1429
|
-
throw new Error(
|
|
1430
|
-
`initialization.entrypoint "${entrypoint}" does not resolve to a declared ` +
|
|
1431
|
-
`contributes.skills or contributes.commands entry. ` +
|
|
1432
|
-
`Valid entries: ${declared.length ? declared.join(', ') : '(none declared)'}`,
|
|
1433
|
-
);
|
|
1434
|
-
}
|
|
1435
|
-
// initialization.prompt — OPTIONAL. When present it must be a string ≤ 2000
|
|
1436
|
-
// chars. (Rendering/moderation is a later story; we only validate type/length.)
|
|
1437
|
-
if (initObj.prompt !== undefined) {
|
|
1438
|
-
if (typeof initObj.prompt !== 'string') {
|
|
1439
|
-
throw new Error('initialization.prompt must be a string');
|
|
1440
|
-
}
|
|
1441
|
-
if (initObj.prompt.length > 2000) {
|
|
1442
|
-
throw new Error(
|
|
1443
|
-
`initialization.prompt must be ≤ 2000 characters (got ${initObj.prompt.length})`,
|
|
1444
|
-
);
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
return m as PackManifest;
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
// ---------------------------------------------------------------------------
|
|
1452
|
-
// Pack-to-pack dependency pre-flight (M0)
|
|
1453
|
-
// ---------------------------------------------------------------------------
|
|
1454
|
-
|
|
1455
|
-
/**
|
|
1456
|
-
* Enforce a pack's `requires.packs`: every named dependency MUST already be
|
|
1457
|
-
* installed (and satisfy its optional semver RANGE) before we write anything.
|
|
1458
|
-
*
|
|
1459
|
-
* Installed packs are discovered by FILESYSTEM PRESENCE via `listInstalledPacks`
|
|
1460
|
-
* — deliberately NOT `modules.yaml`, which `installPack` no longer writes under
|
|
1461
|
-
* the v12+ layout (a modules.yaml-based check would silently ignore every modern
|
|
1462
|
-
* pack). Throws on the first unmet dependency so the install aborts with NO
|
|
1463
|
-
* partial state (it is called before installToPackages). No-op when
|
|
1464
|
-
* `requires.packs` is absent/empty, keeping legacy packs unaffected.
|
|
1465
|
-
*/
|
|
1466
|
-
export function assertPackDependencies(hqRoot: string, pkg: PackManifest): void {
|
|
1467
|
-
const deps = pkg.requires?.packs ?? [];
|
|
1468
|
-
if (deps.length === 0) return;
|
|
1469
|
-
const installed = new Map<string, string | undefined>();
|
|
1470
|
-
for (const p of listInstalledPacks(hqRoot)) {
|
|
1471
|
-
// Key on the manifest name when readable, else the directory name.
|
|
1472
|
-
installed.set(p.manifest?.name ?? p.name, p.manifest?.version);
|
|
1473
|
-
}
|
|
1474
|
-
for (const dep of deps) {
|
|
1475
|
-
if (dep.name === pkg.name) {
|
|
1476
|
-
throw new Error(`Pack ${pkg.name} cannot list itself in requires.packs.`);
|
|
1477
|
-
}
|
|
1478
|
-
if (!installed.has(dep.name)) {
|
|
1479
|
-
throw new Error(
|
|
1480
|
-
`Pack ${pkg.name} requires ${dep.name}, which is not installed. ` +
|
|
1481
|
-
`Install it first, e.g.: hq install marketplace:${dep.name}`
|
|
1482
|
-
);
|
|
1483
|
-
}
|
|
1484
|
-
if (dep.version) {
|
|
1485
|
-
const have = installed.get(dep.name);
|
|
1486
|
-
if (!have || !semverSatisfies(have, dep.version, { includePrerelease: true })) {
|
|
1487
|
-
throw new Error(
|
|
1488
|
-
`Pack ${pkg.name} requires ${dep.name} ${dep.version}, but ` +
|
|
1489
|
-
`${dep.name}${have ? ` ${have}` : ' (version unknown)'} is installed.`
|
|
1490
|
-
);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// ---------------------------------------------------------------------------
|
|
1497
|
-
// Post-install get-started line (US-005)
|
|
1498
|
-
// ---------------------------------------------------------------------------
|
|
1499
|
-
|
|
1500
|
-
/**
|
|
1501
|
-
* Derive the safe, auto-generated "get started" line for a freshly installed
|
|
1502
|
-
* pack from its `initialization.entrypoint` ONLY. PHASE 1 deliberately ignores
|
|
1503
|
-
* the free-text `initialization.prompt` prose (rendering/moderation is a later
|
|
1504
|
-
* story) so untrusted prose can't reach the operator's terminal.
|
|
1505
|
-
*
|
|
1506
|
-
* The command is slash-normalized to exactly one leading slash regardless of
|
|
1507
|
-
* whether `entrypoint` was stored with or without one, matching the HQ Sync
|
|
1508
|
-
* desktop render: ``Run `/email-assistant` to get started``.
|
|
1509
|
-
*
|
|
1510
|
-
* Returns `null` when there is no initialization block (backwards-compatible —
|
|
1511
|
-
* the caller prints nothing extra).
|
|
1512
|
-
*/
|
|
1513
|
-
export function getStartedLine(
|
|
1514
|
-
initialization?: PackManifest['initialization'],
|
|
1515
|
-
): string | null {
|
|
1516
|
-
const entrypoint = initialization?.entrypoint;
|
|
1517
|
-
if (typeof entrypoint !== 'string' || entrypoint.trim() === '') return null;
|
|
1518
|
-
const command = '/' + entrypoint.trim().replace(/^\/+/, '');
|
|
1519
|
-
return `Run \`${command}\` to get started`;
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
// ---------------------------------------------------------------------------
|
|
1523
|
-
// Hooks confirmation
|
|
1524
|
-
// ---------------------------------------------------------------------------
|
|
1525
|
-
|
|
1526
|
-
async function confirmHooks(
|
|
1527
|
-
pkg: PackManifest,
|
|
1528
|
-
allowHooks: boolean
|
|
1529
|
-
): Promise<boolean> {
|
|
1530
|
-
const hooks = pkg.contributes.hooks ?? [];
|
|
1531
|
-
if (hooks.length === 0) return true;
|
|
1532
|
-
if (allowHooks) {
|
|
1533
|
-
console.log(
|
|
1534
|
-
chalk.yellow(
|
|
1535
|
-
`--allow-hooks set; installing ${hooks.length} hook(s) without prompting.`
|
|
1536
|
-
)
|
|
1537
|
-
);
|
|
1538
|
-
return true;
|
|
1539
|
-
}
|
|
1540
|
-
console.log('');
|
|
1541
|
-
console.log(
|
|
1542
|
-
chalk.yellow(`Pack ${pkg.publisher}/${pkg.name} declares ${hooks.length} hook(s):`)
|
|
1543
|
-
);
|
|
1544
|
-
for (const h of hooks) console.log(chalk.yellow(` - ${h}.sh`));
|
|
1545
|
-
console.log(
|
|
1546
|
-
chalk.yellow(
|
|
1547
|
-
'These run automatically on tool events with your shell permissions.'
|
|
1548
|
-
)
|
|
1549
|
-
);
|
|
1550
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1551
|
-
const answer: string = await new Promise((resolve) => {
|
|
1552
|
-
rl.question('Install anyway? [y/N] ', (a) => {
|
|
1553
|
-
rl.close();
|
|
1554
|
-
resolve(a);
|
|
1555
|
-
});
|
|
1556
|
-
});
|
|
1557
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
// ---------------------------------------------------------------------------
|
|
1561
|
-
// MCP confirmation (US-010)
|
|
1562
|
-
//
|
|
1563
|
-
// The MCP equivalent of confirmHooks. MCP servers are a high-trust surface: an
|
|
1564
|
-
// http/sse server points your agent at a REMOTE endpoint (typosquat / phishing
|
|
1565
|
-
// risk — the FULL url is disclosed verbatim so the operator can eyeball it), and
|
|
1566
|
-
// a stdio server runs a LOCAL binary with your shell permissions. We GATE on a
|
|
1567
|
-
// non-empty `contributes.mcp` (NEVER the advisory `capabilities` field, which is
|
|
1568
|
-
// reserved/unenforced — gating on it would let a pack omit a capability to dodge
|
|
1569
|
-
// the prompt) and render per-server transport + url-or-command + a count of ALL
|
|
1570
|
-
// servers being added.
|
|
1571
|
-
//
|
|
1572
|
-
// REDACTION: header/env VALUES are never printed. The manifests use
|
|
1573
|
-
// `${secret:NAME}` references (never literal Bearers), but we STILL run the
|
|
1574
|
-
// rendered prompt through `redactSecrets` against the raw header/env values so
|
|
1575
|
-
// that even a LITERAL secret could not leak. We show header KEY names with a
|
|
1576
|
-
// redacted value marker.
|
|
1577
|
-
//
|
|
1578
|
-
// FAIL-SAFE: a manifest that fails to load/parse here is NOT a bypass — we
|
|
1579
|
-
// surface the server name with a `[manifest unreadable]` note and STILL require
|
|
1580
|
-
// confirmation (a malformed manifest should make the operator MORE cautious).
|
|
1581
|
-
// ---------------------------------------------------------------------------
|
|
1582
|
-
|
|
1583
|
-
/**
|
|
1584
|
-
* Render one declared MCP server's prompt line, redacting every header/env value.
|
|
1585
|
-
* EXPORTED so the acceptance/redaction self-test can assert no secret/Bearer
|
|
1586
|
-
* substring ever appears in the rendered output.
|
|
1587
|
-
*/
|
|
1588
|
-
export function renderMcpServerLine(payloadDir: string, item: string): string {
|
|
1589
|
-
let manifest: McpManifest;
|
|
1590
|
-
try {
|
|
1591
|
-
const abs = path.join(payloadDir, payloadFor('mcp', item));
|
|
1592
|
-
manifest = JSON.parse(fs.readFileSync(abs, 'utf-8')) as McpManifest;
|
|
1593
|
-
if (manifest === null || typeof manifest !== 'object') throw new Error('not an object');
|
|
1594
|
-
} catch {
|
|
1595
|
-
// Fail-safe: do not crash the prompt; flag the server and keep requiring
|
|
1596
|
-
// confirmation.
|
|
1597
|
-
return ` - ${item} [manifest unreadable] — could not load mcp/${item}.json; treat with caution`;
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
const transport = manifest.type;
|
|
1601
|
-
// Accumulate every raw header/env value so a final redactSecrets pass scrubs
|
|
1602
|
-
// even a literal token that slipped past `${secret:}` (defense in depth — we
|
|
1603
|
-
// already replace the values with the redaction marker below).
|
|
1604
|
-
const rawValues = new Set<string>();
|
|
1605
|
-
for (const v of Object.values(manifest.headers ?? {})) rawValues.add(v);
|
|
1606
|
-
for (const v of Object.values(manifest.env ?? {})) rawValues.add(v);
|
|
1607
|
-
|
|
1608
|
-
// Header KEYS shown, VALUES always redacted (never the raw value).
|
|
1609
|
-
const headerKeys = Object.keys(manifest.headers ?? {});
|
|
1610
|
-
const headerStr =
|
|
1611
|
-
headerKeys.length > 0
|
|
1612
|
-
? ` headers: { ${headerKeys.map((k) => `${k}: ${SECRET_REDACTION}`).join(', ')} }`
|
|
1613
|
-
: '';
|
|
1614
|
-
|
|
1615
|
-
let line: string;
|
|
1616
|
-
if (transport === 'http' || transport === 'sse') {
|
|
1617
|
-
// FULL url verbatim — typosquat disclosure is the whole point.
|
|
1618
|
-
line =
|
|
1619
|
-
` - ${item} [${transport}] url: ${manifest.url ?? '(missing)'}` +
|
|
1620
|
-
headerStr +
|
|
1621
|
-
` (contacts a remote endpoint)`;
|
|
1622
|
-
} else {
|
|
1623
|
-
// stdio — local binary with shell permissions.
|
|
1624
|
-
const args = manifest.args ?? [];
|
|
1625
|
-
const argsStr = args.length > 0 ? ` args: [${args.join(', ')}]` : '';
|
|
1626
|
-
line =
|
|
1627
|
-
` - ${item} [stdio] command: ${manifest.command ?? '(missing)'}` +
|
|
1628
|
-
argsStr +
|
|
1629
|
-
headerStr +
|
|
1630
|
-
` (runs a local binary with your shell permissions)`;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
// Final defense-in-depth scrub: even though values are already replaced with
|
|
1634
|
-
// the marker above, run the whole line through redactSecrets so a stray
|
|
1635
|
-
// literal token (e.g. inside command/args) can never reach the terminal.
|
|
1636
|
-
return redactSecrets(line, rawValues);
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
/**
|
|
1640
|
-
* Install-time MCP trust prompt. EXPORTED so the acceptance test can drive the
|
|
1641
|
-
* gate / bypass / non-TTY branches directly (capture stdout, assert no secret
|
|
1642
|
-
* substring leaks, assert deny returns false). See {@link confirmHooks} for the
|
|
1643
|
-
* voice/shape this mirrors.
|
|
1644
|
-
*/
|
|
1645
|
-
export async function confirmMcp(
|
|
1646
|
-
pkg: PackManifest,
|
|
1647
|
-
payloadDir: string,
|
|
1648
|
-
allowMcp: boolean,
|
|
1649
|
-
): Promise<boolean> {
|
|
1650
|
-
// GATE on non-empty `contributes.mcp` — mirrors confirmHooks. Never on the
|
|
1651
|
-
// advisory `capabilities` field.
|
|
1652
|
-
const servers = pkg.contributes.mcp ?? [];
|
|
1653
|
-
if (servers.length === 0) return true;
|
|
1654
|
-
|
|
1655
|
-
if (allowMcp) {
|
|
1656
|
-
console.log(
|
|
1657
|
-
chalk.yellow(
|
|
1658
|
-
`--allow-mcp set; registering ${servers.length} MCP server(s) without prompting.`,
|
|
1659
|
-
),
|
|
1660
|
-
);
|
|
1661
|
-
return true;
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
console.log('');
|
|
1665
|
-
console.log(
|
|
1666
|
-
chalk.yellow(
|
|
1667
|
-
`Pack ${pkg.publisher}/${pkg.name} declares ${servers.length} MCP server(s):`,
|
|
1668
|
-
),
|
|
1669
|
-
);
|
|
1670
|
-
for (const item of servers) {
|
|
1671
|
-
console.log(chalk.yellow(renderMcpServerLine(payloadDir, item)));
|
|
1672
|
-
}
|
|
1673
|
-
console.log(
|
|
1674
|
-
chalk.yellow(
|
|
1675
|
-
'These servers will be registered into your Claude and Codex agent configs.',
|
|
1676
|
-
),
|
|
1677
|
-
);
|
|
1678
|
-
|
|
1679
|
-
// Non-TTY guard (mirrors the secrets.ts pattern): do NOT call rl.question in a
|
|
1680
|
-
// non-interactive shell — it would hang. Instruct the operator to re-run with
|
|
1681
|
-
// --allow-mcp and DENY (return false -> abort, no partial state).
|
|
1682
|
-
if (!process.stdin.isTTY) {
|
|
1683
|
-
console.log(
|
|
1684
|
-
chalk.red(
|
|
1685
|
-
`Refusing to register ${servers.length} MCP server(s) without confirmation ` +
|
|
1686
|
-
'in a non-interactive shell. Re-run with --allow-mcp to approve.',
|
|
1687
|
-
),
|
|
1688
|
-
);
|
|
1689
|
-
return false;
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1693
|
-
const answer: string = await new Promise((resolve) => {
|
|
1694
|
-
rl.question('Install anyway? [y/N] ', (a) => {
|
|
1695
|
-
rl.close();
|
|
1696
|
-
resolve(a);
|
|
1697
|
-
});
|
|
1698
|
-
});
|
|
1699
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// ---------------------------------------------------------------------------
|
|
1703
|
-
// Conditional predicate
|
|
1704
|
-
//
|
|
1705
|
-
// `package.yaml:conditional` is arbitrary bash sourced from a remote pack.
|
|
1706
|
-
// Evaluating it is effectively code execution — same trust surface as the
|
|
1707
|
-
// post-install hooks. We therefore gate it behind the same confirmation flow:
|
|
1708
|
-
// a pack with a `conditional` must be approved (interactively) or run under
|
|
1709
|
-
// `--allow-hooks` (ambient trust, e.g. `create-hq --full`).
|
|
1710
|
-
// ---------------------------------------------------------------------------
|
|
1711
|
-
|
|
1712
|
-
async function confirmConditional(
|
|
1713
|
-
pkg: PackManifest,
|
|
1714
|
-
allowHooks: boolean,
|
|
1715
|
-
): Promise<boolean> {
|
|
1716
|
-
if (!pkg.conditional) return true;
|
|
1717
|
-
if (allowHooks) return true;
|
|
1718
|
-
console.log('');
|
|
1719
|
-
console.log(
|
|
1720
|
-
chalk.yellow(
|
|
1721
|
-
`Pack ${pkg.publisher}/${pkg.name} declares a conditional predicate:`,
|
|
1722
|
-
),
|
|
1723
|
-
);
|
|
1724
|
-
console.log(chalk.yellow(` $ ${pkg.conditional}`));
|
|
1725
|
-
console.log(
|
|
1726
|
-
chalk.yellow(
|
|
1727
|
-
'This runs as bash with your shell permissions before install proceeds.',
|
|
1728
|
-
),
|
|
1729
|
-
);
|
|
1730
|
-
const rl = readline.createInterface({
|
|
1731
|
-
input: process.stdin,
|
|
1732
|
-
output: process.stdout,
|
|
1733
|
-
});
|
|
1734
|
-
const answer: string = await new Promise((resolve) => {
|
|
1735
|
-
rl.question('Evaluate predicate? [y/N] ', (a) => {
|
|
1736
|
-
rl.close();
|
|
1737
|
-
resolve(a);
|
|
1738
|
-
});
|
|
1739
|
-
});
|
|
1740
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
function evalConditional(expr: string): boolean {
|
|
1744
|
-
const r = spawnSync('bash', ['-c', expr], { stdio: 'ignore' });
|
|
1745
|
-
return r.status === 0;
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
// ---------------------------------------------------------------------------
|
|
1749
|
-
// MCP registration wiring (US-007) — call registerMcpServers from installPack.
|
|
1750
|
-
//
|
|
1751
|
-
// The registration CORE + Claude/Codex emitters live in mcp-registration.ts and
|
|
1752
|
-
// are fully unit-tested; this is the install-time SEAM that feeds them a real
|
|
1753
|
-
// manifest loader and a production, vault-backed secret resolver, with per-server
|
|
1754
|
-
// secret-deferral so a fresh install (before `/connect-shopify` has minted the
|
|
1755
|
-
// key) still SUCCEEDS, skipping only the server whose secret is not yet present.
|
|
1756
|
-
// ---------------------------------------------------------------------------
|
|
1757
|
-
|
|
1758
|
-
/**
|
|
1759
|
-
* Build an install-time {@link SecretResolver} from a fresh server authorization
|
|
1760
|
-
* decision. The encrypted cache may identify one unambiguous scope, but its
|
|
1761
|
-
* plaintext is never trusted or read to register an MCP server.
|
|
1762
|
-
*
|
|
1763
|
-
* With `--company`, the caller-selected company is resolved normally. Without
|
|
1764
|
-
* it, exactly one cache scope may identify the intended company/person context;
|
|
1765
|
-
* multiple scopes are ambiguous and fail closed. The requested names are then
|
|
1766
|
-
* loaded through the same server-authorized endpoint as exec/env/run. Offline,
|
|
1767
|
-
* expired-session, forbidden, not-found, script-lock, and malformed-response
|
|
1768
|
-
* outcomes all produce an empty resolver, so MCP registration is deferred.
|
|
1769
|
-
*
|
|
1770
|
-
* The returned synchronous resolver contains only values from that fresh
|
|
1771
|
-
* authorization response. No cached value is a fallback.
|
|
1772
|
-
*/
|
|
1773
|
-
export async function makeInstallSecretResolver(
|
|
1774
|
-
secretNames: string[],
|
|
1775
|
-
company?: string,
|
|
1776
|
-
): Promise<SecretResolver> {
|
|
1777
|
-
const names = [...new Set(secretNames)];
|
|
1778
|
-
if (names.length === 0) return () => null;
|
|
1779
|
-
|
|
1780
|
-
try {
|
|
1781
|
-
const token = await ensureCognitoToken({ interactive: false });
|
|
1782
|
-
let scopeUid: string;
|
|
1783
|
-
if (company) {
|
|
1784
|
-
scopeUid = await getCompanyUid(token, company);
|
|
1785
|
-
} else {
|
|
1786
|
-
const scopes = listSecretCacheScopes();
|
|
1787
|
-
if (scopes.length !== 1) return () => null;
|
|
1788
|
-
[scopeUid] = scopes;
|
|
1789
|
-
}
|
|
1790
|
-
const authorized = await loadRevealedSecrets(token, scopeUid, names);
|
|
1791
|
-
return (name: string): string | null => authorized.get(name) ?? null;
|
|
1792
|
-
} catch {
|
|
1793
|
-
return () => null;
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
const INSTALL_SECRET_REF_RE =
|
|
1798
|
-
/\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
|
|
1799
|
-
|
|
1800
|
-
function collectManifestSecretNames(manifest: McpManifest): string[] {
|
|
1801
|
-
const names = new Set<string>();
|
|
1802
|
-
for (const value of [
|
|
1803
|
-
...Object.values(manifest.headers ?? {}),
|
|
1804
|
-
...Object.values(manifest.env ?? {}),
|
|
1805
|
-
]) {
|
|
1806
|
-
INSTALL_SECRET_REF_RE.lastIndex = 0;
|
|
1807
|
-
for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
|
|
1808
|
-
names.add(match[1]);
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
return [...names];
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
/**
|
|
1815
|
-
* Load + shape-validate + parse a pack's per-server MCP manifest from disk.
|
|
1816
|
-
* `validateMcpManifest(destDir, name)` is the SAME shape gate `validateManifest`
|
|
1817
|
-
* runs at install; it returns void and THROWS on bad shape, so we call it first
|
|
1818
|
-
* (re-using one source of truth), then parse `mcp/<name>.json` and return it
|
|
1819
|
-
* typed as {@link McpManifest}. `destDir` is the installed pack root
|
|
1820
|
-
* (`core/packages/<pkg>/`), the realpath `registerMcpServers` emits from.
|
|
1821
|
-
*/
|
|
1822
|
-
function loadMcpManifestFrom(destDir: string, name: string): McpManifest {
|
|
1823
|
-
validateMcpManifest(destDir, name); // throws on bad shape (void on success)
|
|
1824
|
-
const abs = path.join(destDir, 'mcp', `${name}.json`);
|
|
1825
|
-
return JSON.parse(fs.readFileSync(abs, 'utf-8')) as McpManifest;
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
/**
|
|
1829
|
-
* Derive the per-server "needs secret" remedy command from the pack's
|
|
1830
|
-
* `initialization.entrypoint` (e.g. the vyg pack's `connect-shopify` →
|
|
1831
|
-
* `/connect-shopify`). Falls back to a generic "provision the secret then re-run
|
|
1832
|
-
* `hq install`" when the pack declares no entrypoint.
|
|
1833
|
-
*/
|
|
1834
|
-
function mcpSecretRemedy(initialization: PackManifest['initialization']): string {
|
|
1835
|
-
const entrypoint = initialization?.entrypoint;
|
|
1836
|
-
if (typeof entrypoint === 'string' && entrypoint.trim() !== '') {
|
|
1837
|
-
const command = '/' + entrypoint.trim().replace(/^\/+/, '');
|
|
1838
|
-
return `run \`${command}\` to provision it, then re-run \`hq install\``;
|
|
1839
|
-
}
|
|
1840
|
-
return 'provision the secret then re-run `hq install`';
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
/** Pull the `${secret:NAME}` token out of a `cannot resolve ${secret:NAME}` error message. */
|
|
1844
|
-
function extractDeferredSecretName(message: string): string {
|
|
1845
|
-
const m = /\$\{secret:([^}]+)\}/.exec(message);
|
|
1846
|
-
return m ? m[1] : 'a required secret';
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
/**
|
|
1850
|
-
* Register one pack's `contributes.mcp` servers into the shared Claude/Codex
|
|
1851
|
-
* agent configs, PER SERVER, so a single unresolvable `${secret:NAME}` defers
|
|
1852
|
-
* ONLY that server instead of aborting the whole install. Returns the registered
|
|
1853
|
-
* and skipped (deferred) server-name lists for the one-line summary.
|
|
1854
|
-
*
|
|
1855
|
-
* Per-server policy:
|
|
1856
|
-
* - SUCCESS → push to `registered`.
|
|
1857
|
-
* - McpManifestError matching
|
|
1858
|
-
* `/cannot resolve \$\{secret:/` → SKIP (push to `skipped`), warn on stderr,
|
|
1859
|
-
* install still succeeds (the key gets minted later, then a re-install wires it).
|
|
1860
|
-
* - ANY OTHER error → RE-THROW (abort install). Only the
|
|
1861
|
-
* unresolvable-secret case is swallowed.
|
|
1862
|
-
*/
|
|
1863
|
-
async function wireMcpServers(
|
|
1864
|
-
pkg: PackManifest,
|
|
1865
|
-
destDir: string,
|
|
1866
|
-
company?: string,
|
|
1867
|
-
): Promise<{ registered: string[]; skipped: string[] }> {
|
|
1868
|
-
const loadManifest = (name: string): McpManifest => loadMcpManifestFrom(destDir, name);
|
|
1869
|
-
const registered: string[] = [];
|
|
1870
|
-
const skipped: string[] = [];
|
|
1871
|
-
|
|
1872
|
-
for (const name of pkg.contributes.mcp ?? []) {
|
|
1873
|
-
try {
|
|
1874
|
-
const resolveSecret = await makeInstallSecretResolver(
|
|
1875
|
-
collectManifestSecretNames(loadManifest(name)),
|
|
1876
|
-
company,
|
|
1877
|
-
);
|
|
1878
|
-
// Per-server call: registerMcpServers throws on the FIRST unresolvable
|
|
1879
|
-
// secret, so calling it one name at a time lets us catch + continue.
|
|
1880
|
-
registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
|
|
1881
|
-
registered.push(name);
|
|
1882
|
-
} catch (e) {
|
|
1883
|
-
const isUnresolvableSecret =
|
|
1884
|
-
e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
|
|
1885
|
-
if (!isUnresolvableSecret) {
|
|
1886
|
-
// ConfigParseError / ConfigPermissionError / McpNameCollisionError / a
|
|
1887
|
-
// malformed-manifest McpManifestError / etc. — propagate, abort install.
|
|
1888
|
-
throw e;
|
|
1889
|
-
}
|
|
1890
|
-
skipped.push(name);
|
|
1891
|
-
const secret = extractDeferredSecretName(e.message);
|
|
1892
|
-
// Always stderr (never suppressed by --quiet): the user must see WHY a
|
|
1893
|
-
// server was deferred and exactly how to finish wiring it.
|
|
1894
|
-
process.stderr.write(
|
|
1895
|
-
`MCP server '${name}' needs secret ${secret} — ${mcpSecretRemedy(pkg.initialization)}.\n`,
|
|
1896
|
-
);
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
return { registered, skipped };
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// ---------------------------------------------------------------------------
|
|
1903
|
-
// Move into core/packages/ + run core/scripts/scan-packages.sh
|
|
1904
|
-
// ---------------------------------------------------------------------------
|
|
1905
|
-
|
|
1906
|
-
/**
|
|
1907
|
-
* Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
|
|
1908
|
-
* v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
|
|
1909
|
-
* `core/packages/` as the canonical pack root; writing to top-level
|
|
1910
|
-
* `packages/` would leave an orphan tree alongside the real one.
|
|
1911
|
-
*
|
|
1912
|
-
* Re-installs replace the existing destination so stale contributions don't
|
|
1913
|
-
* linger (the post-install `scan-packages.sh` would otherwise wire them
|
|
1914
|
-
* back into host paths).
|
|
1915
|
-
*
|
|
1916
|
-
* Exported for tests in pack-install.test.ts — see that file for the
|
|
1917
|
-
* contract this function pins.
|
|
1918
|
-
*/
|
|
1919
|
-
export function installToPackages(
|
|
1920
|
-
payloadDir: string,
|
|
1921
|
-
pkg: PackManifest,
|
|
1922
|
-
hqRoot: string
|
|
1923
|
-
): string {
|
|
1924
|
-
const packagesDir = path.join(hqRoot, 'core', 'packages');
|
|
1925
|
-
fs.mkdirSync(packagesDir, { recursive: true });
|
|
1926
|
-
const destDir = path.join(packagesDir, pkg.name);
|
|
1927
|
-
|
|
1928
|
-
// SECURITY/ATOMICITY (US-020): wire atomically. rsync into a sibling staging
|
|
1929
|
-
// dir on the SAME filesystem first; only swap it into the final destination
|
|
1930
|
-
// once the copy fully succeeds. A failed/partial rsync therefore never
|
|
1931
|
-
// leaves a half-wired pack dir in place (which scan-packages.sh would then
|
|
1932
|
-
// wire into host paths). On any error the staging dir is rolled back.
|
|
1933
|
-
const stagingDir = fs.mkdtempSync(path.join(packagesDir, `.${pkg.name}.staging-`));
|
|
1934
|
-
try {
|
|
1935
|
-
// rsync preserves modes/symlinks; argv form — no shell.
|
|
1936
|
-
const srcSlashed = payloadDir.endsWith('/') ? payloadDir : `${payloadDir}/`;
|
|
1937
|
-
const stagingSlashed = stagingDir.endsWith('/') ? stagingDir : `${stagingDir}/`;
|
|
1938
|
-
execFileSync('rsync', ['-a', srcSlashed, stagingSlashed], { stdio: 'inherit' });
|
|
1939
|
-
|
|
1940
|
-
// Swap into place: remove any prior install, then atomic rename. The brief
|
|
1941
|
-
// window between rm and rename is unavoidable with a same-name dir, but the
|
|
1942
|
-
// staged copy guarantees the NEW contents are fully present before we touch
|
|
1943
|
-
// the live location.
|
|
1944
|
-
if (fs.existsSync(destDir)) {
|
|
1945
|
-
fs.rmSync(destDir, { recursive: true, force: true });
|
|
1946
|
-
}
|
|
1947
|
-
fs.renameSync(stagingDir, destDir);
|
|
1948
|
-
} finally {
|
|
1949
|
-
if (fs.existsSync(stagingDir)) {
|
|
1950
|
-
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
return destDir;
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
/**
|
|
1957
|
-
* Stamp the install-time `source:` into the destination package.yaml. This is
|
|
1958
|
-
* the only on-disk record of which `recommended_packages[].source` produced
|
|
1959
|
-
* each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
|
|
1960
|
-
* `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
|
|
1961
|
-
* against `core/core.yaml:recommended_packages` — without this stamp, the
|
|
1962
|
-
* dedup set is empty and every recommended pack is re-prompted on every
|
|
1963
|
-
* setup.sh run.
|
|
1964
|
-
*
|
|
1965
|
-
* Idempotent: re-installs (and `hq update`) replace any pre-existing
|
|
1966
|
-
* top-level `source:` line rather than duplicating it. Nested `source:`
|
|
1967
|
-
* fields under `contributes:` (or any other block) are left untouched.
|
|
1968
|
-
*
|
|
1969
|
-
* The value is YAML double-quoted so `github:` sources containing `#`
|
|
1970
|
-
* survive round-trip (an unquoted `#` would be parsed as a comment).
|
|
1971
|
-
*
|
|
1972
|
-
* If the manifest opens with a `---` YAML document marker, the source line is
|
|
1973
|
-
* inserted *after* the marker — otherwise the prepend would create a
|
|
1974
|
-
* two-document stream that single-doc `yaml.load` callers in this codebase
|
|
1975
|
-
* cannot read.
|
|
1976
|
-
*
|
|
1977
|
-
* Exported for tests.
|
|
1978
|
-
*/
|
|
1979
|
-
export function stampInstallSource(destDir: string, source: string): void {
|
|
1980
|
-
const manifestPath = path.join(destDir, 'package.yaml');
|
|
1981
|
-
if (!fs.existsSync(manifestPath)) {
|
|
1982
|
-
// installToPackages already validated the payload contained package.yaml.
|
|
1983
|
-
// If it's gone by the time we stamp, something deleted it between calls —
|
|
1984
|
-
// fail loud so the regression surfaces in tests.
|
|
1985
|
-
throw new Error(`stampInstallSource: ${manifestPath} not found`);
|
|
1986
|
-
}
|
|
1987
|
-
const original = fs.readFileSync(manifestPath, 'utf-8');
|
|
1988
|
-
// Strip any existing top-level `source:` line. Match only lines starting at
|
|
1989
|
-
// column 0 so nested `source:` fields (none in spec today, but defensive)
|
|
1990
|
-
// are not touched.
|
|
1991
|
-
const lines = original.split('\n').filter((line) => !/^source\s*:/.test(line));
|
|
1992
|
-
const quoted = `"${source.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
1993
|
-
const sourceLine = `source: ${quoted}`;
|
|
1994
|
-
|
|
1995
|
-
// Detect a leading YAML document marker (`---`, optionally followed by
|
|
1996
|
-
// trailing whitespace or a comment). If present, the source line goes
|
|
1997
|
-
// *after* it so the manifest stays a single document.
|
|
1998
|
-
let insertAt = 0;
|
|
1999
|
-
while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
|
|
2000
|
-
const opensWithDocMarker =
|
|
2001
|
-
insertAt < lines.length && /^---(\s|#|$)/.test(lines[insertAt]);
|
|
2002
|
-
if (opensWithDocMarker) {
|
|
2003
|
-
lines.splice(insertAt + 1, 0, sourceLine);
|
|
2004
|
-
} else {
|
|
2005
|
-
// Drop any leading blank lines we walked past so the file doesn't grow a
|
|
2006
|
-
// blank-line gutter on every re-stamp.
|
|
2007
|
-
lines.splice(0, insertAt, sourceLine);
|
|
2008
|
-
}
|
|
2009
|
-
fs.writeFileSync(manifestPath, lines.join('\n'));
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
/**
|
|
2013
|
-
* Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
|
|
2014
|
-
* pack's contributions into the host paths (skills, hooks, policies, etc.).
|
|
2015
|
-
* Skipping with a dim warning if the script is missing keeps fresh HQs (or
|
|
2016
|
-
* older templates) usable — the next session start picks them up via its
|
|
2017
|
-
* own scan.
|
|
2018
|
-
*
|
|
2019
|
-
* Exported for tests.
|
|
2020
|
-
*
|
|
2021
|
-
* `quiet` keeps the script's stdout off our stdout (it routes only stderr
|
|
2022
|
-
* through, and sets HQ_SCAN_QUIET=1) so callers emitting machine-readable
|
|
2023
|
-
* JSON — e.g. `hq packs uninstall --json` — produce clean output.
|
|
2024
|
-
*/
|
|
2025
|
-
export function runScanPackages(hqRoot: string, opts: { quiet?: boolean } = {}): void {
|
|
2026
|
-
const script = path.join(hqRoot, 'core', 'scripts', 'scan-packages.sh');
|
|
2027
|
-
if (!fs.existsSync(script)) {
|
|
2028
|
-
if (!opts.quiet) {
|
|
2029
|
-
console.log(
|
|
2030
|
-
chalk.dim(
|
|
2031
|
-
` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
|
|
2032
|
-
`will run on next session start)`
|
|
2033
|
-
)
|
|
2034
|
-
);
|
|
2035
|
-
}
|
|
2036
|
-
return;
|
|
2037
|
-
}
|
|
2038
|
-
const r = spawnSync('bash', [script], {
|
|
2039
|
-
cwd: hqRoot,
|
|
2040
|
-
env: { ...process.env, HQ_ROOT: hqRoot, ...(opts.quiet ? { HQ_SCAN_QUIET: '1' } : {}) },
|
|
2041
|
-
stdio: opts.quiet ? ['ignore', 'ignore', 'inherit'] : 'inherit',
|
|
2042
|
-
});
|
|
2043
|
-
if (r.status !== 0 && !opts.quiet) {
|
|
2044
|
-
console.log(chalk.yellow(' scan-packages.sh exited non-zero; see output above.'));
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
// ---------------------------------------------------------------------------
|
|
2049
|
-
// Public entry
|
|
2050
|
-
// ---------------------------------------------------------------------------
|
|
2051
|
-
|
|
2052
|
-
export interface InstallPackOptions {
|
|
2053
|
-
allowHooks?: boolean;
|
|
2054
|
-
/** Company context for online authorization of install-time MCP secret refs. */
|
|
2055
|
-
company?: string;
|
|
2056
|
-
/**
|
|
2057
|
-
* US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
|
|
2058
|
-
* `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
|
|
2059
|
-
* notice. Absent/false → the operator is prompted (or, in a non-TTY shell,
|
|
2060
|
-
* the install is refused with a "re-run with --allow-mcp" hint).
|
|
2061
|
-
*/
|
|
2062
|
-
allowMcp?: boolean;
|
|
2063
|
-
followBranch?: boolean;
|
|
2064
|
-
/**
|
|
2065
|
-
* Route this function's human output to stderr (and silence scan-packages
|
|
2066
|
-
* stdout) so a caller emitting machine-readable JSON keeps stdout clean.
|
|
2067
|
-
* The fetch tools are already quiet/stderr-only (`npm pack --silent`,
|
|
2068
|
-
* `rsync -a`, `git clone` progress -> stderr), so this is sufficient.
|
|
2069
|
-
*/
|
|
2070
|
-
quiet?: boolean;
|
|
2071
|
-
/**
|
|
2072
|
-
* US-021 marketplace artifact integrity. When provided (a marketplace listing
|
|
2073
|
-
* carries a pinned content hash + signature + public key), the downloaded
|
|
2074
|
-
* tarball is verified BEFORE extraction; a mismatch aborts the install. US-006
|
|
2075
|
-
* populates this from the listing detail response.
|
|
2076
|
-
*/
|
|
2077
|
-
integrity?: ArtifactIntegrity;
|
|
2078
|
-
/**
|
|
2079
|
-
* US-006 marketplace transport seams (resolve/refresh/download), injected for
|
|
2080
|
-
* tests. When omitted, `defaultMarketplaceDeps()` (public listings API +
|
|
2081
|
-
* presigned S3) is used. Only consulted when `source` is `marketplace:...`.
|
|
2082
|
-
*/
|
|
2083
|
-
marketplaceDeps?: MarketplaceDeps;
|
|
2084
|
-
/**
|
|
2085
|
-
* US-006: require a valid signature for marketplace artifacts. Default false
|
|
2086
|
-
* during the signing-key-deferral window (the content hash is still ALWAYS
|
|
2087
|
-
* verified); set true once platform signing keys are provisioned.
|
|
2088
|
-
*/
|
|
2089
|
-
requireSignature?: boolean;
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
export async function installPack(
|
|
2093
|
-
source: string,
|
|
2094
|
-
opts: InstallPackOptions = {}
|
|
2095
|
-
): Promise<void> {
|
|
2096
|
-
// NOTE: the HQ_API_KEY fail-closed gate is NOT enforced here. `installPack`
|
|
2097
|
-
// is a shared primitive called both by the top-level `hq install` CLI route
|
|
2098
|
-
// (where Cognito-only is the right gate — checked there instead) AND by
|
|
2099
|
-
// `hq packs update` (packs.ts:runUpdate), which un-wires an existing pack's
|
|
2100
|
-
// contributions BEFORE re-installing. Throwing from inside `installPack`
|
|
2101
|
-
// would leave an update mid-flight (unwired, not reinstalled) whenever
|
|
2102
|
-
// HQ_API_KEY happened to be set. Restricting the assertion to the install
|
|
2103
|
-
// entrypoint keeps `hq packs update` fail-closed at its own call site
|
|
2104
|
-
// instead (checked before it un-wires anything).
|
|
2105
|
-
const transport = classify(source);
|
|
2106
|
-
const hqRoot = findHqRoot();
|
|
2107
|
-
// `readHqVersion` (shared with `hq packs`) reads the CANONICAL
|
|
2108
|
-
// `core/core.yaml` (hq-core ≥v14), falling back to the legacy
|
|
2109
|
-
// `<root>/core.yaml` only when the nested file is absent. The previous local
|
|
2110
|
-
// reader looked at `<root>/core.yaml` ONLY, so on a post-v12→v15 tree it
|
|
2111
|
-
// picked up a STALE leftover root copy (e.g. hqVersion 12.1.0) and wrongly
|
|
2112
|
-
// rejected packs requiring a newer hqCore (feedback_57d7edcf, symptom b).
|
|
2113
|
-
const hqVersion = readHqVersion(hqRoot);
|
|
2114
|
-
const say: (...a: unknown[]) => void = opts.quiet
|
|
2115
|
-
? (...a) => console.error(...a)
|
|
2116
|
-
: (...a) => console.log(...a);
|
|
2117
|
-
|
|
2118
|
-
say(chalk.dim(`-> transport: ${transport}; source: ${source}`));
|
|
2119
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-pack-'));
|
|
2120
|
-
try {
|
|
2121
|
-
let fetched: FetchResult;
|
|
2122
|
-
switch (transport) {
|
|
2123
|
-
case 'npm':
|
|
2124
|
-
fetched = fetchNpm(source, tmpDir, opts.integrity);
|
|
2125
|
-
break;
|
|
2126
|
-
case 'git':
|
|
2127
|
-
fetched = fetchGit(source, tmpDir, opts.followBranch ?? false);
|
|
2128
|
-
break;
|
|
2129
|
-
case 'local':
|
|
2130
|
-
fetched = fetchLocal(source, tmpDir);
|
|
2131
|
-
break;
|
|
2132
|
-
case 'marketplace':
|
|
2133
|
-
fetched = await fetchMarketplace(
|
|
2134
|
-
source,
|
|
2135
|
-
tmpDir,
|
|
2136
|
-
opts.marketplaceDeps ?? defaultMarketplaceDeps(),
|
|
2137
|
-
{ requireSignature: opts.requireSignature },
|
|
2138
|
-
);
|
|
2139
|
-
break;
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
const pkg = validateManifest(fetched.payloadDir, hqVersion);
|
|
2143
|
-
|
|
2144
|
-
// M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
|
|
2145
|
-
// so a missing/unsatisfied `requires.packs` aborts with no partial state, and
|
|
2146
|
-
// before we bother the operator with hook/MCP trust prompts for a pack that
|
|
2147
|
-
// can't install anyway.
|
|
2148
|
-
assertPackDependencies(hqRoot, pkg);
|
|
2149
|
-
|
|
2150
|
-
if (pkg.conditional) {
|
|
2151
|
-
const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
|
|
2152
|
-
if (!allowed) {
|
|
2153
|
-
say(
|
|
2154
|
-
chalk.red(
|
|
2155
|
-
'Install aborted (conditional predicate not approved).',
|
|
2156
|
-
),
|
|
2157
|
-
);
|
|
2158
|
-
return;
|
|
2159
|
-
}
|
|
2160
|
-
const ok = evalConditional(pkg.conditional);
|
|
2161
|
-
if (!ok) {
|
|
2162
|
-
say(
|
|
2163
|
-
chalk.yellow(
|
|
2164
|
-
`Skipping ${pkg.name}: conditional "${pkg.conditional}" returned non-zero.`
|
|
2165
|
-
)
|
|
2166
|
-
);
|
|
2167
|
-
return;
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
const confirmed = await confirmHooks(pkg, opts.allowHooks ?? false);
|
|
2172
|
-
if (!confirmed) {
|
|
2173
|
-
say(chalk.red('Install aborted (hooks denied).'));
|
|
2174
|
-
return;
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
// US-010 — install-time MCP trust prompt. Positioned AFTER confirmHooks and
|
|
2178
|
-
// BEFORE installToPackages so a deny aborts with NO partial state (no config
|
|
2179
|
-
// written, no pack moved into core/packages, no registerMcpServers call). The
|
|
2180
|
-
// actual registration (US-007) now fires AFTER installToPackages +
|
|
2181
|
-
// runScanPackages below (pack on disk + symlinks wired), once this gate has
|
|
2182
|
-
// confirmed the servers — so a deny here means registration never runs.
|
|
2183
|
-
const mcpConfirmed = await confirmMcp(
|
|
2184
|
-
pkg,
|
|
2185
|
-
fetched.payloadDir,
|
|
2186
|
-
opts.allowMcp ?? false,
|
|
2187
|
-
);
|
|
2188
|
-
if (!mcpConfirmed) {
|
|
2189
|
-
say(chalk.red('Install aborted (MCP servers denied).'));
|
|
2190
|
-
return;
|
|
2191
|
-
}
|
|
2192
|
-
|
|
2193
|
-
const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
|
|
2194
|
-
// Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
|
|
2195
|
-
// are tracked by filesystem presence — no `modules.yaml` write (that
|
|
2196
|
-
// removed the orphan top-level `modules/` tree alongside the canonical
|
|
2197
|
-
// `core/`). The one piece of install-time provenance we DO record is the
|
|
2198
|
-
// input `source` string, stamped into the destination `package.yaml` so
|
|
2199
|
-
// setup.sh can dedup against `core/core.yaml:recommended_packages` on
|
|
2200
|
-
// re-runs. Stamping the literal input (not the resolved SHA/version)
|
|
2201
|
-
// matches the verbatim equality check in setup.sh.
|
|
2202
|
-
//
|
|
2203
|
-
// US-006 EXCEPTION: for the marketplace transport we stamp the RESOLVED
|
|
2204
|
-
// source (`marketplace:<slug>@<version>`) rather than the literal input.
|
|
2205
|
-
// The version is what lets `hq packs update` detect a newer listing — the
|
|
2206
|
-
// bare `marketplace:<slug>` input carries no version to compare against.
|
|
2207
|
-
const stampedSource =
|
|
2208
|
-
transport === 'marketplace' ? fetched.resolvedSource : source;
|
|
2209
|
-
stampInstallSource(destDir, stampedSource);
|
|
2210
|
-
runScanPackages(hqRoot, { quiet: opts.quiet });
|
|
2211
|
-
|
|
2212
|
-
// US-007 — register `contributes.mcp` servers into the shared Claude/Codex
|
|
2213
|
-
// agent configs. Fires HERE (after installToPackages + runScanPackages, i.e.
|
|
2214
|
-
// pack on disk + symlinks wired) and only once the confirmMcp gate above
|
|
2215
|
-
// approved them. The `mcp` key is wire:'merge' — NEVER symlinked; this merge
|
|
2216
|
-
// is its only wiring path. registerMcpServers honors the
|
|
2217
|
-
// HQ_DISABLE_MCP_REGISTRATION kill-switch internally, so we never re-check it.
|
|
2218
|
-
// Per-server secret-deferral keeps a fresh install (key not yet minted) at
|
|
2219
|
-
// exit 0, deferring only the unresolvable server (see wireMcpServers).
|
|
2220
|
-
if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
|
|
2221
|
-
const { registered, skipped } = await wireMcpServers(
|
|
2222
|
-
pkg,
|
|
2223
|
-
destDir,
|
|
2224
|
-
opts.company,
|
|
2225
|
-
);
|
|
2226
|
-
// One-line summary (server NAMES only — never resolved secret VALUES).
|
|
2227
|
-
say(
|
|
2228
|
-
chalk.dim(
|
|
2229
|
-
` MCP servers: registered [${registered.join(', ')}]; ` +
|
|
2230
|
-
`skipped [${skipped.join(', ')}].`
|
|
2231
|
-
)
|
|
2232
|
-
);
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
say(
|
|
2236
|
-
chalk.green(
|
|
2237
|
-
`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`
|
|
2238
|
-
)
|
|
2239
|
-
);
|
|
2240
|
-
say(
|
|
2241
|
-
chalk.dim(
|
|
2242
|
-
` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
|
|
2243
|
-
`contribution(s) into host-side paths.`
|
|
2244
|
-
)
|
|
2245
|
-
);
|
|
2246
|
-
// US-005 — when the pack declares an `initialization` block, print a safe,
|
|
2247
|
-
// auto-generated "get started" line right after the success output. PHASE 1
|
|
2248
|
-
// derives the line from `initialization.entrypoint` ONLY (never the
|
|
2249
|
-
// free-text `initialization.prompt` prose), and matches the HQ Sync desktop
|
|
2250
|
-
// wording for consistency. Absent block → nothing extra (backwards-compat).
|
|
2251
|
-
const getStarted = getStartedLine(pkg.initialization);
|
|
2252
|
-
if (getStarted) say(chalk.cyan(getStarted));
|
|
2253
|
-
} finally {
|
|
2254
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2255
|
-
}
|
|
2256
|
-
}
|