@indigoai-us/hq-cli 5.49.0 → 5.50.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/mcp-registration.d.ts +905 -0
- package/dist/commands/mcp-registration.js +2001 -0
- package/dist/commands/mcp-status.d.ts +130 -0
- package/dist/commands/mcp-status.js +406 -0
- package/dist/commands/pack-install.d.ts +62 -0
- package/dist/commands/pack-install.js +422 -14
- package/dist/commands/packs.js +28 -4
- package/dist/commands/pkg-install.js +5 -2
- package/dist/index.js +20 -3
- package/dist/types.d.ts +8 -1
- package/dist/utils/contribution-table.d.ts +103 -0
- package/dist/utils/contribution-table.js +65 -0
- package/dist/utils/environmental-error.d.ts +10 -0
- package/dist/utils/environmental-error.js +40 -0
- package/dist/utils/pack-contributions.d.ts +86 -10
- package/dist/utils/pack-contributions.js +130 -48
- package/dist/utils/secrets-cache.d.ts +9 -0
- package/dist/utils/secrets-cache.js +24 -2
- package/package.json +3 -2
- package/scripts/generate-scan-packages-table.mjs +113 -0
- package/src/commands/mcp-registration.test.ts +2787 -0
- package/src/commands/mcp-registration.ts +2612 -0
- package/src/commands/mcp-status.test.ts +483 -0
- package/src/commands/mcp-status.ts +575 -0
- package/src/commands/mcp-status.us011.test.ts +243 -0
- package/src/commands/pack-install.test.ts +589 -0
- package/src/commands/pack-install.ts +497 -13
- package/src/commands/packs.ts +26 -1
- package/src/commands/pkg-install.ts +4 -1
- package/src/index.ts +18 -1
- package/src/types.ts +9 -8
- package/src/utils/contribution-table.ts +83 -0
- package/src/utils/environmental-error.test.ts +45 -0
- package/src/utils/environmental-error.ts +39 -0
- package/src/utils/pack-contributions.test.ts +257 -25
- package/src/utils/pack-contributions.ts +177 -47
- package/src/utils/secrets-cache.ts +22 -0
- package/test/e2e/smoke-install-mcp.sh +113 -0
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
|
@@ -1,26 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Content-pack contribution helpers -- the
|
|
3
|
-
* the `contributes.* -> host
|
|
2
|
+
* Content-pack contribution helpers -- the SINGLE SOURCE OF TRUTH (US-003) for
|
|
3
|
+
* the `contributes.* -> host` mapping that `hq install` wires via
|
|
4
4
|
* `core/scripts/scan-packages.sh`.
|
|
5
5
|
*
|
|
6
6
|
* `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
|
|
7
7
|
* tracked by filesystem presence -- there is no registry file). The list /
|
|
8
8
|
* update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
|
|
9
9
|
* SAME mapping so it can report link health and cleanly un-wire a pack without
|
|
10
|
-
* leaving dangling symlinks.
|
|
10
|
+
* leaving dangling symlinks.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Historically that mapping was RESTATED in five places (the scan-packages.sh
|
|
13
|
+
* bash `case`, pack-install.ts:validateManifest's `subpaths` record,
|
|
14
|
+
* `linkFor`'s switch, `contributionLinks`, and the `PackContributeKey` union),
|
|
15
|
+
* drift-guarded only by a substring check. US-003 collapses it to ONE
|
|
16
|
+
* declarative table -- `CONTRIBUTION_TABLE` below -- with a row per key
|
|
17
|
+
* `{ payload, host, wire }`:
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* - `PackContributeKey` is DERIVED from the table keys (see types.ts).
|
|
20
|
+
* - `linkFor` / `contributionLinks` / the `subpaths` validator READ the table.
|
|
21
|
+
* - `core/scripts/scan-packages.sh` reads a generated data block emitted from
|
|
22
|
+
* this same table (regenerate via `scripts/generate-scan-packages-table.mjs`).
|
|
23
|
+
*
|
|
24
|
+
* A parity test (`pack-contributions.test.ts`) asserts FULL key-set +
|
|
25
|
+
* payload-suffix + wire-mode equivalence across every surface, so the copies
|
|
26
|
+
* cannot drift.
|
|
27
|
+
*
|
|
28
|
+
* `wire` discriminates HOW a contribution reaches the host:
|
|
29
|
+
* - `symlink`: a single `ln -s` into a well-known host directory (every
|
|
30
|
+
* contribution type that shipped before MCP).
|
|
31
|
+
* - `merge`: the contribution is MERGED into a shared host config rather than
|
|
32
|
+
* symlinked (the `mcp` row -- registered into the Claude + Codex agent
|
|
33
|
+
* configs). The merge WIRING is delivered in US-004/US-005; US-003 only
|
|
34
|
+
* DECLARES the row as data so the table is the single source and the parity
|
|
35
|
+
* test guards the real invariant. `symlink`-only readers (linkFor,
|
|
36
|
+
* contributionLinks, the symlink validator, scan-packages) must SKIP
|
|
37
|
+
* `merge` rows -- they never produce a symlink.
|
|
18
38
|
*/
|
|
19
39
|
|
|
20
40
|
import * as fs from 'fs';
|
|
21
41
|
import * as path from 'path';
|
|
22
42
|
import * as yaml from 'js-yaml';
|
|
23
43
|
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
44
|
+
import { CONTRIBUTION_TABLE, payloadFor, type WireMode } from './contribution-table.js';
|
|
45
|
+
import {
|
|
46
|
+
unregisterMcpServers,
|
|
47
|
+
type UnregisterServerResult,
|
|
48
|
+
type SafeWriteEnv,
|
|
49
|
+
type AcquireLockOptions,
|
|
50
|
+
} from '../commands/mcp-registration.js';
|
|
51
|
+
|
|
52
|
+
// NOTE on dependency direction: this module imports from ../commands/mcp-registration.js
|
|
53
|
+
// to un-register `wire:merge` (mcp) servers on uninstall. mcp-registration.ts imports
|
|
54
|
+
// ONLY fs/os/path/smol-toml (it does NOT import from pack-contributions.ts), so there
|
|
55
|
+
// is NO import cycle. If that ever changes, invert via a callback injected by packs.ts.
|
|
56
|
+
|
|
57
|
+
export { CONTRIBUTION_TABLE };
|
|
58
|
+
export type { WireMode };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where a contributes key is ROUTED (US-005). Reads the single-source table's
|
|
62
|
+
* `wire` field — `'symlink'` keys go through `linkFor`/`contributionLinks` (a
|
|
63
|
+
* host `ln -s`); `'merge'` keys (e.g. `mcp`) go through the `registerMcpServers`
|
|
64
|
+
* seam in `commands/mcp-registration.ts` and are NEVER symlinked.
|
|
65
|
+
*
|
|
66
|
+
* This is the explicit, code-enforced discriminator behind the invariant that
|
|
67
|
+
* merge keys never reach the symlink path: callers fan out on the return value
|
|
68
|
+
* rather than relying on `linkFor` happening to return `null`.
|
|
69
|
+
*/
|
|
70
|
+
export function routeContribution(key: PackContributeKey): WireMode {
|
|
71
|
+
return CONTRIBUTION_TABLE[key].wire;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
|
|
76
|
+
* (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
|
|
77
|
+
* and MUST NOT be symlinked. Unknown keys and non-array/empty values are
|
|
78
|
+
* ignored, mirroring `contributionLinks`. Returned in `contributes` iteration
|
|
79
|
+
* order, de-duplicated.
|
|
80
|
+
*/
|
|
81
|
+
export function mergeKeys(
|
|
82
|
+
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
83
|
+
): PackContributeKey[] {
|
|
84
|
+
const out: PackContributeKey[] = [];
|
|
85
|
+
for (const [key, items] of Object.entries(contributes) as [
|
|
86
|
+
PackContributeKey,
|
|
87
|
+
unknown,
|
|
88
|
+
][]) {
|
|
89
|
+
if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
|
|
90
|
+
if (!Array.isArray(items) || items.length === 0) continue;
|
|
91
|
+
if (routeContribution(key) === 'merge' && !out.includes(key)) out.push(key);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
24
95
|
|
|
25
96
|
/** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
|
|
26
97
|
export interface WiredLink {
|
|
@@ -37,58 +108,40 @@ export type LinkStatus =
|
|
|
37
108
|
| 'foreign'; // dst exists but points elsewhere or is a real file (collision)
|
|
38
109
|
|
|
39
110
|
/**
|
|
40
|
-
* Map one contributes entry to its source/host paths
|
|
41
|
-
*
|
|
111
|
+
* Map one SYMLINK contributes entry to its source/host paths, READING the
|
|
112
|
+
* single-source `CONTRIBUTION_TABLE` (US-003). The src is the expanded pack
|
|
113
|
+
* payload; the dst is `<host-dir>/<payload-basename>` so per-key suffixes
|
|
114
|
+
* (`.md`, `.sh`, none) carry through.
|
|
115
|
+
*
|
|
116
|
+
* Returns `null` for a `wire: 'merge'` key (e.g. `mcp`): a merge contribution
|
|
117
|
+
* is not a symlink and has no `WiredLink`. Callers that only handle symlinks
|
|
118
|
+
* (contributionLinks/unwirePack) skip those keys.
|
|
42
119
|
*/
|
|
43
120
|
function linkFor(
|
|
44
121
|
hqRoot: string,
|
|
45
122
|
packDir: string,
|
|
46
123
|
key: PackContributeKey,
|
|
47
124
|
item: string,
|
|
48
|
-
): WiredLink {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
break;
|
|
56
|
-
case 'knowledge':
|
|
57
|
-
srcRel = path.join('knowledge', item);
|
|
58
|
-
dstRel = path.join('core', 'knowledge', 'public', item);
|
|
59
|
-
break;
|
|
60
|
-
case 'skills':
|
|
61
|
-
srcRel = path.join('skills', item);
|
|
62
|
-
dstRel = path.join('.claude', 'skills', item);
|
|
63
|
-
break;
|
|
64
|
-
case 'commands':
|
|
65
|
-
srcRel = path.join('commands', `${item}.md`);
|
|
66
|
-
dstRel = path.join('.claude', 'commands', `${item}.md`);
|
|
67
|
-
break;
|
|
68
|
-
case 'hooks':
|
|
69
|
-
srcRel = path.join('hooks', `${item}.sh`);
|
|
70
|
-
dstRel = path.join('.claude', 'hooks', `${item}.sh`);
|
|
71
|
-
break;
|
|
72
|
-
case 'policies':
|
|
73
|
-
srcRel = path.join('policies', `${item}.md`);
|
|
74
|
-
dstRel = path.join('core', 'policies', `${item}.md`);
|
|
75
|
-
break;
|
|
76
|
-
case 'scripts':
|
|
77
|
-
srcRel = path.join('scripts', item);
|
|
78
|
-
dstRel = path.join('core', 'scripts', item);
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
125
|
+
): WiredLink | null {
|
|
126
|
+
const row = CONTRIBUTION_TABLE[key];
|
|
127
|
+
if (row.wire !== 'symlink') return null;
|
|
128
|
+
const payloadRel = payloadFor(key, item); // e.g. commands/foo.md
|
|
129
|
+
// dst keeps the payload's basename (so the suffix carries through) under the
|
|
130
|
+
// host directory declared in the table.
|
|
131
|
+
const dstRel = path.join(row.host, path.basename(payloadRel));
|
|
81
132
|
return {
|
|
82
133
|
key,
|
|
83
134
|
item,
|
|
84
|
-
src: path.join(packDir,
|
|
135
|
+
src: path.join(packDir, payloadRel),
|
|
85
136
|
dst: path.join(hqRoot, dstRel),
|
|
86
137
|
};
|
|
87
138
|
}
|
|
88
139
|
|
|
89
140
|
/**
|
|
90
|
-
* Every
|
|
91
|
-
* non-array values are ignored, mirroring scan-packages.sh.
|
|
141
|
+
* Every SYMLINK a pack's `contributes` block declares. Empty subfields and
|
|
142
|
+
* non-array values are ignored, mirroring scan-packages.sh. `wire: 'merge'`
|
|
143
|
+
* keys (e.g. `mcp`) produce no symlink and are skipped here -- their host
|
|
144
|
+
* effect (a config merge) is handled separately (US-004/US-005).
|
|
92
145
|
*/
|
|
93
146
|
export function contributionLinks(
|
|
94
147
|
hqRoot: string,
|
|
@@ -100,10 +153,17 @@ export function contributionLinks(
|
|
|
100
153
|
PackContributeKey,
|
|
101
154
|
unknown,
|
|
102
155
|
][]) {
|
|
156
|
+
if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
|
|
103
157
|
if (!Array.isArray(items)) continue;
|
|
158
|
+
// INVARIANT (US-005): merge keys are NEVER symlinked. They route to
|
|
159
|
+
// registerMcpServers (commands/mcp-registration.ts) instead. Skip them
|
|
160
|
+
// explicitly here -- not just via linkFor returning null -- so the symlink
|
|
161
|
+
// path provably excludes them.
|
|
162
|
+
if (routeContribution(key) === 'merge') continue;
|
|
104
163
|
for (const item of items) {
|
|
105
164
|
if (typeof item !== 'string' || item.length === 0) continue;
|
|
106
|
-
|
|
165
|
+
const link = linkFor(hqRoot, packDir, key, item);
|
|
166
|
+
if (link) links.push(link); // null for merge keys -- skip
|
|
107
167
|
}
|
|
108
168
|
}
|
|
109
169
|
return links;
|
|
@@ -256,6 +316,76 @@ export function unwirePack(
|
|
|
256
316
|
return result;
|
|
257
317
|
}
|
|
258
318
|
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// MCP un-wiring (US-009) — the `wire: 'merge'` parallel to symlink un-wiring.
|
|
321
|
+
//
|
|
322
|
+
// `unwirePack` above only removes SYMLINKS: it iterates `contributionLinks`, which
|
|
323
|
+
// SKIPS every `wire: 'merge'` key (e.g. `mcp`). So a pack's MCP servers — registered
|
|
324
|
+
// into ~/.claude.json + ~/.codex/config.toml, NOT symlinked — are INVISIBLE to the
|
|
325
|
+
// symlink unwire and would leak after uninstall. `unwirePackMcp` is the parallel
|
|
326
|
+
// path: for each name in `contributes.mcp` it calls `unregisterMcpServers` (US-009),
|
|
327
|
+
// which removes ONLY entries whose `_hqPack` provenance matches THIS pack and
|
|
328
|
+
// skip-and-warns on any foreign/unstamped same-named entry.
|
|
329
|
+
//
|
|
330
|
+
// Kept as a SEPARATE, composable call from `unwirePack` (not folded into it) so the
|
|
331
|
+
// existing `unwirePack` signature + tests stay intact and each path is testable in
|
|
332
|
+
// isolation. The uninstall command composes both (symlink unwire + MCP unwire).
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/** The result of {@link unwirePackMcp}: the per-server un-registration outcomes (empty when no `mcp` keys). */
|
|
336
|
+
export interface UnwireMcpResult {
|
|
337
|
+
/** One {@link UnregisterServerResult} per declared `contributes.mcp` server, in declaration order. */
|
|
338
|
+
servers: UnregisterServerResult[];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Tuning passthrough for {@link unwirePackMcp} (tests inject a tmpdir `env.home` + tiny lock timeouts). */
|
|
342
|
+
export interface UnwirePackMcpOptions {
|
|
343
|
+
/** Injectable env — tests MUST pass a tmpdir `home` so the real ~/.claude.json / ~/.codex are untouched. */
|
|
344
|
+
env?: Partial<SafeWriteEnv>;
|
|
345
|
+
/** Lock tuning (tests use tiny timeouts). */
|
|
346
|
+
lock?: AcquireLockOptions;
|
|
347
|
+
/** Backup timestamp override (deterministic tests). */
|
|
348
|
+
stamp?: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
|
|
353
|
+
* {@link unwirePack}'s symlink removal. For each name in `mergeKeys(contributes)` →
|
|
354
|
+
* `contributes.<mergeKey>` (today only `mcp`), this delegates to
|
|
355
|
+
* {@link unregisterMcpServers}, which removes ONLY entries stamped with THIS pack's
|
|
356
|
+
* `_hqPack` provenance and SKIPS-AND-WARNS on a foreign/unstamped same-named entry.
|
|
357
|
+
*
|
|
358
|
+
* No-ops cleanly when the pack declares no merge keys (returns `{ servers: [] }`),
|
|
359
|
+
* when the runtime config is absent (Codex-less host → first-class skip; missing
|
|
360
|
+
* ~/.claude.json → `absent`), and on a re-run (already-removed → `absent`). It does
|
|
361
|
+
* NOT throw on a foreign entry — uninstall of one pack must not abort on a server
|
|
362
|
+
* another pack (or the user) owns.
|
|
363
|
+
*
|
|
364
|
+
* @param packName the pack being uninstalled — the provenance to match
|
|
365
|
+
* @param contributes the pack's `contributes` block (its `mcp` list drives removal)
|
|
366
|
+
*/
|
|
367
|
+
export function unwirePackMcp(
|
|
368
|
+
packName: string,
|
|
369
|
+
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
370
|
+
options?: UnwirePackMcpOptions,
|
|
371
|
+
): UnwireMcpResult {
|
|
372
|
+
const servers: UnregisterServerResult[] = [];
|
|
373
|
+
for (const key of mergeKeys(contributes)) {
|
|
374
|
+
const names = (contributes[key] ?? []).filter(
|
|
375
|
+
(n): n is string => typeof n === 'string' && n.length > 0,
|
|
376
|
+
);
|
|
377
|
+
if (names.length === 0) continue;
|
|
378
|
+
servers.push(
|
|
379
|
+
...unregisterMcpServers(packName, names, {
|
|
380
|
+
env: options?.env,
|
|
381
|
+
lock: options?.lock,
|
|
382
|
+
stamp: options?.stamp,
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return { servers };
|
|
387
|
+
}
|
|
388
|
+
|
|
259
389
|
// ---------------------------------------------------------------------------
|
|
260
390
|
// Host introspection: hqVersion + recommended_packages catalog
|
|
261
391
|
// ---------------------------------------------------------------------------
|
|
@@ -142,6 +142,28 @@ export function writeCache(
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
|
|
147
|
+
* secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
|
|
148
|
+
* registration) that have no `--company` flag and no network token and so cannot
|
|
149
|
+
* resolve a single active company UID up front: they instead probe every cached
|
|
150
|
+
* scope for a given secret name. Returns `[]` when the cache root is absent or
|
|
151
|
+
* unreadable (the desired graceful-deferral behavior — no scopes, no hits).
|
|
152
|
+
*/
|
|
153
|
+
export function listSecretCacheScopes(): string[] {
|
|
154
|
+
try {
|
|
155
|
+
return fs
|
|
156
|
+
.readdirSync(CACHE_DIR, { withFileTypes: true })
|
|
157
|
+
.filter((e) => e.isDirectory())
|
|
158
|
+
.map((e) => e.name)
|
|
159
|
+
// Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
|
|
160
|
+
// rejects anything with `/` or `..`, so this is belt-and-suspenders.
|
|
161
|
+
.filter((name) => !name.startsWith("."));
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
145
167
|
export function removeCacheEntry(companyUid: string, name: string): void {
|
|
146
168
|
if (!validateInputs(companyUid, name)) return;
|
|
147
169
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# smoke-install-mcp.sh — TRUE end-to-end smoke test of `hq install` for
|
|
4
|
+
# MCP-bearing packs, in a fully isolated sandbox.
|
|
5
|
+
#
|
|
6
|
+
# Unlike the vitest suite (which unit-tests mcp-registration.ts against an
|
|
7
|
+
# injected $HOME), this drives the REAL built `hq` CLI through a REAL
|
|
8
|
+
# `hq install` against REAL packs, so it catches WIRING gaps unit tests miss —
|
|
9
|
+
# e.g. "registerMcpServers is implemented + tested but installPack never calls
|
|
10
|
+
# it" (the exact bug this harness first caught).
|
|
11
|
+
#
|
|
12
|
+
# It NEVER touches the developer's real ~/.claude.json, ~/.mcp.json, or
|
|
13
|
+
# ~/.codex/config.toml — everything lives under a mktemp sandbox HOME, and the
|
|
14
|
+
# install targets a sandbox HQ root.
|
|
15
|
+
#
|
|
16
|
+
# Two packs, two paths:
|
|
17
|
+
# A) hq-pack-smoke-mcp (no-secret fixture) → MUST register into both configs
|
|
18
|
+
# B) vyg-shopify-hq-pack (needs ${secret:VYG_API_KEY}) → MUST defer gracefully
|
|
19
|
+
# (install succeeds, server NOT registered, "connect first" warning shown)
|
|
20
|
+
#
|
|
21
|
+
# Usage: bash test/e2e/smoke-install-mcp.sh [--keep]
|
|
22
|
+
# Exit 0 = all checks pass. Exit 1 = at least one failed.
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
CLI_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
27
|
+
HQ_ROOT_REAL="$(cd "$CLI_REPO/../../.." && pwd)" # …/HQ
|
|
28
|
+
CLI="$CLI_REPO/dist/index.js"
|
|
29
|
+
FIXTURE="$CLI_REPO/test/fixtures/hq-pack-smoke-mcp" # no-secret pack
|
|
30
|
+
VYG="$HQ_ROOT_REAL/repos/private/vyg-shopify-hq-pack" # secret-bearing pack
|
|
31
|
+
KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
|
|
32
|
+
|
|
33
|
+
pass=0; fail=0
|
|
34
|
+
ok() { echo " ✓ $1"; pass=$((pass+1)); }
|
|
35
|
+
bad() { echo " ✗ $1"; fail=$((fail+1)); }
|
|
36
|
+
|
|
37
|
+
[ -f "$CLI" ] || { echo "FATAL: build first (npm run build) — missing $CLI"; exit 2; }
|
|
38
|
+
[ -d "$FIXTURE" ] || { echo "FATAL: fixture pack missing at $FIXTURE"; exit 2; }
|
|
39
|
+
command -v node >/dev/null || { echo "FATAL: node not on PATH"; exit 2; }
|
|
40
|
+
|
|
41
|
+
SBX="$(mktemp -d -t hq-smoke-mcp.XXXXXX)"
|
|
42
|
+
SBX_HOME="$SBX/home"; SBX_HQ="$SBX/hq"
|
|
43
|
+
cleanup() { [ "$KEEP" = 1 ] && { echo "sandbox kept: $SBX"; return; }; rm -rf "$SBX"; }
|
|
44
|
+
trap cleanup EXIT
|
|
45
|
+
|
|
46
|
+
mkdir -p "$SBX_HOME/.codex" "$SBX_HQ/core/packages" "$SBX_HQ/core/workers/public" \
|
|
47
|
+
"$SBX_HQ/core/knowledge/public/hq-core" "$SBX_HQ/core/policies" \
|
|
48
|
+
"$SBX_HQ/core/hooks" "$SBX_HQ/.claude/skills" "$SBX_HQ/.claude/commands" \
|
|
49
|
+
"$SBX_HQ/companies"
|
|
50
|
+
cp -R "$HQ_ROOT_REAL/core/scripts" "$SBX_HQ/core/scripts"
|
|
51
|
+
# Pin the sandbox host version at the M1 floor (>=15.1.0). Copying the live
|
|
52
|
+
# core.yaml is unreliable — hq-sync can reset it below the floor (e.g. 15.0.24),
|
|
53
|
+
# which makes the floor check CORRECTLY refuse the test packs (they require
|
|
54
|
+
# >=15.1.0). This harness exercises the install path; floor-refusal has its own
|
|
55
|
+
# unit test. Pin a satisfying version so the install path is what's under test.
|
|
56
|
+
printf 'hqVersion: "15.1.0"\n' > "$SBX_HQ/core/core.yaml"
|
|
57
|
+
cp "$HQ_ROOT_REAL/core/knowledge/public/hq-core/mcp-manifest.schema.json" \
|
|
58
|
+
"$SBX_HQ/core/knowledge/public/hq-core/mcp-manifest.schema.json"
|
|
59
|
+
printf 'companies: {}\n' > "$SBX_HQ/companies/manifest.yaml"
|
|
60
|
+
|
|
61
|
+
# pre-existing non-HQ servers that MUST survive every install/uninstall
|
|
62
|
+
echo '{ "mcpServers": { "figma": { "type": "http", "url": "https://figma.example.test/mcp" } } }' > "$SBX_HOME/.claude.json"
|
|
63
|
+
printf '# user existing codex config\n[mcp_servers.node_repl]\ncommand = "node"\nargs = ["--repl"]\n' > "$SBX_HOME/.codex/config.toml"
|
|
64
|
+
|
|
65
|
+
# Always run from inside the sandbox HQ root. The CLI resolves the HQ root by
|
|
66
|
+
# walking up from cwd (utils/manifest.js findHqRoot), NOT from $HQ_ROOT — so the
|
|
67
|
+
# cwd is what actually scopes install/uninstall. (We still export HQ_ROOT for the
|
|
68
|
+
# code paths that DO honor it, e.g. scan-packages.) Without the cd, `packs
|
|
69
|
+
# uninstall` resolves the developer's real HQ root and silently no-ops.
|
|
70
|
+
run_hq() { ( cd "$SBX_HQ" && HOME="$SBX_HOME" HQ_ROOT="$SBX_HQ" node "$CLI" "$@" ); }
|
|
71
|
+
claude_has() { node -e "try{const j=require('$SBX_HOME/.claude.json');process.stdout.write(j.mcpServers&&j.mcpServers['$1']?'1':'0')}catch(e){process.stdout.write('0')}"; }
|
|
72
|
+
codex_has() { c=$(grep -c "\[mcp_servers.$1\]" "$SBX_HOME/.codex/config.toml" 2>/dev/null); echo "${c:-0}"; }
|
|
73
|
+
|
|
74
|
+
echo "=== HQ MCP install smoke test ==="
|
|
75
|
+
echo "sandbox: $SBX"; echo
|
|
76
|
+
|
|
77
|
+
# ---- A) NO-SECRET FIXTURE → must register ---------------------------------
|
|
78
|
+
echo "[A] install no-secret fixture (hq-pack-smoke-mcp) — must REGISTER"
|
|
79
|
+
OUT="$(cd "$SBX_HQ" && run_hq install "$FIXTURE" --allow-mcp --allow-hooks 2>&1)"; rc=$?
|
|
80
|
+
echo "$OUT" | sed 's/^/ /'
|
|
81
|
+
[ $rc -eq 0 ] && ok "install exited 0" || bad "install exited $rc"
|
|
82
|
+
[ "$(claude_has smoke-http)" = "1" ] && ok "smoke-http registered in ~/.claude.json" || bad "smoke-http MISSING from ~/.claude.json"
|
|
83
|
+
[ "$(codex_has smoke-http)" -ge 1 ] && ok "smoke-http registered in ~/.codex/config.toml" || bad "smoke-http MISSING from ~/.codex/config.toml"
|
|
84
|
+
[ "$(claude_has figma)" = "1" ] && ok "figma preserved" || bad "figma LOST"
|
|
85
|
+
[ "$(codex_has node_repl)" -ge 1 ] && ok "node_repl table preserved" || bad "node_repl LOST"
|
|
86
|
+
if grep -rqiE 'X-Smoke.*static-not-a-secret' "$SBX_HOME/.claude.json"; then ok "static header written (http client usable)"; else bad "static header missing from registered server"; fi
|
|
87
|
+
|
|
88
|
+
echo "[A.2] hq mcp status --json reports it"
|
|
89
|
+
ST="$(run_hq mcp status --json 2>&1)"
|
|
90
|
+
echo "$ST" | grep -q 'smoke-http' && ok "mcp status reports smoke-http" || bad "mcp status missing smoke-http"
|
|
91
|
+
|
|
92
|
+
echo "[A.3] uninstall round-trip"
|
|
93
|
+
run_hq packs uninstall hq-pack-smoke-mcp --yes >/dev/null 2>&1 || run_hq packs uninstall hq-pack-smoke-mcp >/dev/null 2>&1
|
|
94
|
+
[ "$(claude_has smoke-http)" = "0" ] && ok "smoke-http removed on uninstall" || bad "smoke-http still present after uninstall"
|
|
95
|
+
[ "$(claude_has figma)" = "1" ] && ok "figma still preserved after uninstall" || bad "figma lost during uninstall"
|
|
96
|
+
|
|
97
|
+
# ---- B) VYG PACK (needs a vault secret) → must DEFER gracefully -----------
|
|
98
|
+
echo
|
|
99
|
+
echo "[B] install vyg pack (needs \${secret:VYG_API_KEY}, not in vault) — must DEFER gracefully"
|
|
100
|
+
if [ -d "$VYG" ]; then
|
|
101
|
+
OUT="$(cd "$SBX_HQ" && run_hq install "$VYG" --allow-mcp --allow-hooks 2>&1)"; rc=$?
|
|
102
|
+
echo "$OUT" | sed 's/^/ /'
|
|
103
|
+
[ $rc -eq 0 ] && ok "install exited 0 (did not abort on the missing secret)" || bad "install aborted (rc=$rc) on missing secret"
|
|
104
|
+
[ "$(claude_has vyg-shopify)" = "0" ] && ok "vyg-shopify correctly DEFERRED (not registered without its key)" || bad "vyg-shopify registered despite missing secret"
|
|
105
|
+
echo "$OUT" | grep -qiE "VYG_API_KEY|connect-shopify|needs secret" && ok "clear 'provision the key' guidance shown" || bad "no deferral guidance shown to the user"
|
|
106
|
+
[ "$(claude_has figma)" = "1" ] && ok "figma still preserved through the deferred install" || bad "figma LOST during deferred install"
|
|
107
|
+
else
|
|
108
|
+
echo " · vyg pack not present — skipping deferral checks"
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
echo
|
|
112
|
+
echo "=== RESULT: $pass passed · $fail failed ==="
|
|
113
|
+
[ $fail -eq 0 ] && exit 0 || exit 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "http", "url": "https://smoke.example.test/mcp", "headers": { "X-Smoke": "static-not-a-secret" } }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
name: hq-pack-smoke-mcp
|
|
2
|
+
version: 0.0.1
|
|
3
|
+
publisher: '@indigoai-us'
|
|
4
|
+
access: public
|
|
5
|
+
description: 'No-secret MCP pack — e2e smoke fixture for install registration.'
|
|
6
|
+
requires:
|
|
7
|
+
hqCore: '>=15.1.0'
|
|
8
|
+
capabilities: [network]
|
|
9
|
+
contributes:
|
|
10
|
+
mcp:
|
|
11
|
+
- smoke-http
|