@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
package/src/index.ts
CHANGED
|
@@ -44,7 +44,9 @@ import { registerSourcesCommand } from "./commands/sources.js";
|
|
|
44
44
|
import { registerSignalsCommand } from "./commands/signals.js";
|
|
45
45
|
import { registerReindexCommand } from "./commands/reindex.js";
|
|
46
46
|
import { registerRescueCommand } from "./commands/rescue.js";
|
|
47
|
+
import { registerMcpCommand } from "./commands/mcp-status.js";
|
|
47
48
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
49
|
+
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
48
50
|
import {
|
|
49
51
|
maybeWarnNewVersion,
|
|
50
52
|
refreshVersionCache,
|
|
@@ -198,6 +200,11 @@ registerReindexCommand(program);
|
|
|
198
200
|
// shipped from @indigoai-us/hq-cloud.
|
|
199
201
|
registerRescueCommand(program);
|
|
200
202
|
|
|
203
|
+
// MCP pack observability (subcommand group — `hq mcp status`). Read-only
|
|
204
|
+
// provenance-based status across BOTH Claude + Codex runtimes (reads `_hqPack`
|
|
205
|
+
// off the configs, NOT linkStatus), with secret-redacted output + `--json`.
|
|
206
|
+
registerMcpCommand(program);
|
|
207
|
+
|
|
201
208
|
(async () => {
|
|
202
209
|
try {
|
|
203
210
|
Sentry.addBreadcrumb({
|
|
@@ -215,7 +222,17 @@ registerRescueCommand(program);
|
|
|
215
222
|
}
|
|
216
223
|
await program.parseAsync();
|
|
217
224
|
} catch (err) {
|
|
218
|
-
|
|
225
|
+
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
226
|
+
// machine, not an HQ code defect. Surface a clear, actionable message and
|
|
227
|
+
// skip Sentry capture so one full disk doesn't flood the tracker with
|
|
228
|
+
// identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
|
|
229
|
+
// to Sentry and still exit 1.
|
|
230
|
+
const envMsg = environmentalFsErrorMessage(err);
|
|
231
|
+
if (envMsg) {
|
|
232
|
+
process.stderr.write(`hq: ${envMsg}\n`);
|
|
233
|
+
} else {
|
|
234
|
+
Sentry.captureException(err);
|
|
235
|
+
}
|
|
219
236
|
process.exitCode = 1;
|
|
220
237
|
} finally {
|
|
221
238
|
// Release health: finalize the per-run session before the flush.
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* knowledge/public/hq-core/package-yaml-spec.md)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { ContributionKey } from './utils/contribution-table.js';
|
|
12
|
+
|
|
11
13
|
export type LegacyStrategy = 'link' | 'merge' | 'copy';
|
|
12
14
|
export type SyncStrategy = LegacyStrategy | 'package';
|
|
13
15
|
export type AccessLevel = 'public' | 'team' | `role:${string}`;
|
|
@@ -73,14 +75,13 @@ export interface SyncResult {
|
|
|
73
75
|
// covers the entitlement-gated registry flow (`hq packages install <slug>`).
|
|
74
76
|
// ---------------------------------------------------------------------------
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
| 'scripts';
|
|
78
|
+
/**
|
|
79
|
+
* The `contributes.*` keys a pack may declare. DERIVED (US-003) from the single
|
|
80
|
+
* declarative contribution registry in `utils/contribution-table.ts` -- adding
|
|
81
|
+
* a contribution type is one row there, and this union updates automatically.
|
|
82
|
+
* Do NOT restate the keys here.
|
|
83
|
+
*/
|
|
84
|
+
export type PackContributeKey = ContributionKey;
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
87
|
* Pack authorship attribution (US-001). OPTIONAL and backwards-compatible —
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contribution registry -- the SINGLE declarative source of truth (US-003)
|
|
3
|
+
* for the `contributes.* -> host` mapping. Every other surface DERIVES from
|
|
4
|
+
* this table:
|
|
5
|
+
*
|
|
6
|
+
* - `PackContributeKey` (types.ts) = `keyof typeof CONTRIBUTION_TABLE`
|
|
7
|
+
* - `linkFor` / `contributionLinks` read `payload` + `host`
|
|
8
|
+
* - `validateManifest`'s payload check reads `payload`
|
|
9
|
+
* - `core/scripts/scan-packages.sh` reads a data block GENERATED
|
|
10
|
+
* from this table
|
|
11
|
+
* (scripts/generate-scan-packages-table.mjs)
|
|
12
|
+
*
|
|
13
|
+
* Adding a contribution type is ONE row here, not a five-site edit. The parity
|
|
14
|
+
* test (`pack-contributions.test.ts`) asserts every surface agrees on the full
|
|
15
|
+
* key-set, the payload suffix, and the wire mode.
|
|
16
|
+
*
|
|
17
|
+
* Row fields:
|
|
18
|
+
* - `payload`: path INSIDE the pack, with the literal token `{item}` for the
|
|
19
|
+
* declared name. The suffix after `{item}` (e.g. `.md`, `.json`, or none)
|
|
20
|
+
* IS the load-bearing per-key shape.
|
|
21
|
+
* - `host`: for `wire: 'symlink'`, the host DIRECTORY (relative to the HQ
|
|
22
|
+
* root) the symlink is created under -- the symlink dst is
|
|
23
|
+
* `<host>/<expanded-item-basename>`. For `wire: 'merge'`, a non-path
|
|
24
|
+
* SENTINEL describing the merge target (e.g. `merge:claude+codex`); it is
|
|
25
|
+
* NEVER used as a filesystem path.
|
|
26
|
+
* - `wire`: `symlink` (a single `ln -s` into `host`) or `merge` (merged into
|
|
27
|
+
* a shared host config; see US-004/US-005). Symlink-only readers MUST skip
|
|
28
|
+
* `merge` rows.
|
|
29
|
+
*
|
|
30
|
+
* NOTE: the `mcp` row is DECLARED here as data only. US-003 does NOT wire any
|
|
31
|
+
* MCP behavior -- the merge engine lands in US-004/US-005. Declaring it now
|
|
32
|
+
* makes the table the single source so adding the merge wiring is a code change
|
|
33
|
+
* against an already-present row, and the parity test guards it from day one.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type WireMode = 'symlink' | 'merge';
|
|
37
|
+
|
|
38
|
+
export interface ContributionRow {
|
|
39
|
+
/** Path inside the pack, with `{item}` substituted for the declared name. */
|
|
40
|
+
payload: string;
|
|
41
|
+
/**
|
|
42
|
+
* `wire: 'symlink'` -> host directory the symlink lives under (HQ-root
|
|
43
|
+
* relative). `wire: 'merge'` -> a non-path sentinel for the merge target.
|
|
44
|
+
*/
|
|
45
|
+
host: string;
|
|
46
|
+
wire: WireMode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The 8-key contribution registry. `as const` so `keyof typeof` yields the
|
|
51
|
+
* exact literal key union consumed by `PackContributeKey`.
|
|
52
|
+
*/
|
|
53
|
+
export const CONTRIBUTION_TABLE = {
|
|
54
|
+
workers: { payload: 'workers/{item}', host: 'core/workers/public', wire: 'symlink' },
|
|
55
|
+
knowledge: { payload: 'knowledge/{item}', host: 'core/knowledge/public', wire: 'symlink' },
|
|
56
|
+
skills: { payload: 'skills/{item}', host: '.claude/skills', wire: 'symlink' },
|
|
57
|
+
commands: { payload: 'commands/{item}.md', host: '.claude/commands', wire: 'symlink' },
|
|
58
|
+
hooks: { payload: 'hooks/{item}.sh', host: '.claude/hooks', wire: 'symlink' },
|
|
59
|
+
policies: { payload: 'policies/{item}.md', host: 'core/policies', wire: 'symlink' },
|
|
60
|
+
scripts: { payload: 'scripts/{item}', host: 'core/scripts', wire: 'symlink' },
|
|
61
|
+
// wire:merge -- DECLARED as data (US-003); the merge engine ships in
|
|
62
|
+
// US-004/US-005. Symlink readers skip this row.
|
|
63
|
+
mcp: { payload: 'mcp/{item}.json', host: 'merge:claude+codex', wire: 'merge' },
|
|
64
|
+
} as const satisfies Record<string, ContributionRow>;
|
|
65
|
+
|
|
66
|
+
/** The declared contribution keys, derived once from the table. */
|
|
67
|
+
export type ContributionKey = keyof typeof CONTRIBUTION_TABLE;
|
|
68
|
+
|
|
69
|
+
/** All contribution keys, in declaration order. */
|
|
70
|
+
export const CONTRIBUTION_KEYS = Object.keys(CONTRIBUTION_TABLE) as ContributionKey[];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Expand a row's `payload` template for a concrete item name. Returns the
|
|
74
|
+
* pack-relative path of the contribution's payload (e.g. `commands/foo.md`).
|
|
75
|
+
*/
|
|
76
|
+
export function payloadFor(key: ContributionKey, item: string): string {
|
|
77
|
+
return CONTRIBUTION_TABLE[key].payload.replace('{item}', item);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Keys whose contributions are wired by a host symlink (skip `merge` rows). */
|
|
81
|
+
export const SYMLINK_KEYS = CONTRIBUTION_KEYS.filter(
|
|
82
|
+
(k) => CONTRIBUTION_TABLE[k].wire === 'symlink',
|
|
83
|
+
);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { environmentalFsErrorMessage } from "./environmental-error.js";
|
|
3
|
+
|
|
4
|
+
/** Build a Node-style errno error with a `.code`, as fs.*Sync throws. */
|
|
5
|
+
function errnoError(code: string, message: string): NodeJS.ErrnoException {
|
|
6
|
+
const err = new Error(message) as NodeJS.ErrnoException;
|
|
7
|
+
err.code = code;
|
|
8
|
+
return err;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("environmentalFsErrorMessage", () => {
|
|
12
|
+
// HQ-CLI-2: the exact error that crashed `hq reindex` and flooded Sentry.
|
|
13
|
+
it("classifies ENOSPC (no space left on device) as environmental", () => {
|
|
14
|
+
const err = errnoError("ENOSPC", "ENOSPC: no space left on device, open '/x'");
|
|
15
|
+
const msg = environmentalFsErrorMessage(err);
|
|
16
|
+
expect(msg).not.toBeNull();
|
|
17
|
+
expect(msg).toMatch(/no space left on device/i);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("classifies EDQUOT (quota exceeded) as environmental", () => {
|
|
21
|
+
const msg = environmentalFsErrorMessage(errnoError("EDQUOT", "EDQUOT: disk quota exceeded"));
|
|
22
|
+
expect(msg).toMatch(/quota/i);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("classifies EROFS (read-only filesystem) as environmental", () => {
|
|
26
|
+
const msg = environmentalFsErrorMessage(errnoError("EROFS", "EROFS: read-only file system"));
|
|
27
|
+
expect(msg).toMatch(/read-only/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// A genuine code bug must still reach Sentry — only the disk-class codes are
|
|
31
|
+
// diverted, so we never silently swallow real defects.
|
|
32
|
+
it("returns null for a code-bug error (ENOENT) so it is still captured", () => {
|
|
33
|
+
expect(environmentalFsErrorMessage(errnoError("ENOENT", "ENOENT: not found"))).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null for a plain Error with no code", () => {
|
|
37
|
+
expect(environmentalFsErrorMessage(new Error("boom"))).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns null for non-error values (null/undefined/string)", () => {
|
|
41
|
+
expect(environmentalFsErrorMessage(null)).toBeNull();
|
|
42
|
+
expect(environmentalFsErrorMessage(undefined)).toBeNull();
|
|
43
|
+
expect(environmentalFsErrorMessage("ENOSPC")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/utils/environmental-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify errors that stem from the user's machine/filesystem state rather
|
|
4
|
+
// than an HQ code defect. These are unactionable from our side: a full disk, an
|
|
5
|
+
// exhausted quota, or a read-only filesystem. The CLI surfaces a clear,
|
|
6
|
+
// actionable message and does NOT report them to Sentry — otherwise a single
|
|
7
|
+
// full disk floods the issue tracker with identical, unfixable crash reports.
|
|
8
|
+
//
|
|
9
|
+
// HQ-CLI-2: `hq reindex` hit ENOSPC in the operation-lock temp-file write
|
|
10
|
+
// (`fs.openSync` → "ENOSPC: no space left on device, open") and the raw error
|
|
11
|
+
// propagated uncaught to the CLI's top-level handler, which captured it to
|
|
12
|
+
// Sentry and exited silently — 5 stack-trace crashes in 6 seconds, with no
|
|
13
|
+
// message telling the user their disk was full.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Node errno codes for "the filesystem cannot accept this write" — purely
|
|
17
|
+
* environmental, never a code bug. Mapped to the message shown to the user.
|
|
18
|
+
*/
|
|
19
|
+
const ENVIRONMENTAL_FS_CODES: Record<string, string> = {
|
|
20
|
+
ENOSPC: "No space left on device. Free up disk space and try again.",
|
|
21
|
+
EDQUOT:
|
|
22
|
+
"Disk quota exceeded. Free up space (or raise your quota) and try again.",
|
|
23
|
+
EROFS:
|
|
24
|
+
"The filesystem is read-only, so HQ can't write here. Check the mount/permissions and try again.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* If `err` is an environmental disk/quota/read-only filesystem error, return a
|
|
29
|
+
* short, user-facing message explaining it; otherwise return `null`.
|
|
30
|
+
*
|
|
31
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
32
|
+
* capture — the condition is the user's machine, not a bug HQ can fix. A null
|
|
33
|
+
* result means "this is a normal error; handle it as usual (capture to Sentry)".
|
|
34
|
+
*/
|
|
35
|
+
export function environmentalFsErrorMessage(err: unknown): string | null {
|
|
36
|
+
const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
|
|
37
|
+
if (typeof code !== "string") return null;
|
|
38
|
+
return ENVIRONMENTAL_FS_CODES[code] ?? null;
|
|
39
|
+
}
|
|
@@ -17,23 +17,39 @@ import {
|
|
|
17
17
|
linkStatus,
|
|
18
18
|
listInstalledPacks,
|
|
19
19
|
unwirePack,
|
|
20
|
+
unwirePackMcp,
|
|
20
21
|
packagesDir,
|
|
21
22
|
readHqVersion,
|
|
22
23
|
type WiredLink,
|
|
23
24
|
} from './pack-contributions.js';
|
|
25
|
+
import { parse as parseToml } from 'smol-toml';
|
|
26
|
+
import {
|
|
27
|
+
registerClaudeServer,
|
|
28
|
+
registerServer,
|
|
29
|
+
claudeConfigPath,
|
|
30
|
+
codexConfigPath,
|
|
31
|
+
codexHome,
|
|
32
|
+
} from '../commands/mcp-registration.js';
|
|
33
|
+
import {
|
|
34
|
+
CONTRIBUTION_TABLE,
|
|
35
|
+
CONTRIBUTION_KEYS,
|
|
36
|
+
SYMLINK_KEYS,
|
|
37
|
+
payloadFor,
|
|
38
|
+
type ContributionKey,
|
|
39
|
+
} from './contribution-table.js';
|
|
24
40
|
import type { PackContributeKey } from '../types.js';
|
|
25
41
|
|
|
26
|
-
// The canonical mapping
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
42
|
+
// The canonical mapping is DERIVED from the single-source CONTRIBUTION_TABLE
|
|
43
|
+
// (US-003) -- the oracle is no longer a hand-restated copy. For each SYMLINK
|
|
44
|
+
// key, src = the payload expanded for item "X"; dst = <host-dir>/<basename>.
|
|
45
|
+
const EXPECTED: Record<ContributionKey, { src: string; dst: string }> =
|
|
46
|
+
Object.fromEntries(
|
|
47
|
+
SYMLINK_KEYS.map((k) => {
|
|
48
|
+
const src = payloadFor(k, 'X');
|
|
49
|
+
const dst = `${CONTRIBUTION_TABLE[k].host}/${src.split('/').pop()}`;
|
|
50
|
+
return [k, { src, dst }];
|
|
51
|
+
}),
|
|
52
|
+
) as Record<ContributionKey, { src: string; dst: string }>;
|
|
37
53
|
|
|
38
54
|
function mkTmp(prefix: string): string {
|
|
39
55
|
// realpathSync so macOS /tmp -> /private/tmp doesn't break path equality.
|
|
@@ -41,10 +57,10 @@ function mkTmp(prefix: string): string {
|
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
describe('contributionLinks: mapping', () => {
|
|
44
|
-
it('maps every contributes key to the canonical src/dst', () => {
|
|
60
|
+
it('maps every SYMLINK contributes key to the canonical src/dst', () => {
|
|
45
61
|
const hqRoot = '/hq';
|
|
46
62
|
const packDir = '/hq/core/packages/hq-pack-x';
|
|
47
|
-
for (const key of
|
|
63
|
+
for (const key of SYMLINK_KEYS) {
|
|
48
64
|
const [link] = contributionLinks(hqRoot, packDir, { [key]: ['X'] });
|
|
49
65
|
expect(link.key).toBe(key);
|
|
50
66
|
expect(link.src).toBe(path.join(packDir, EXPECTED[key].src));
|
|
@@ -52,6 +68,19 @@ describe('contributionLinks: mapping', () => {
|
|
|
52
68
|
}
|
|
53
69
|
});
|
|
54
70
|
|
|
71
|
+
it('produces NO symlink for wire:merge keys (mcp)', () => {
|
|
72
|
+
// The mcp row is wire:merge — declared as data (US-003) but never wired as a
|
|
73
|
+
// symlink here; its host effect is a config merge (US-004/US-005).
|
|
74
|
+
const mergeKeys = CONTRIBUTION_KEYS.filter(
|
|
75
|
+
(k) => CONTRIBUTION_TABLE[k].wire === 'merge',
|
|
76
|
+
);
|
|
77
|
+
expect(mergeKeys).toContain('mcp'); // guards the row exists
|
|
78
|
+
for (const key of mergeKeys) {
|
|
79
|
+
const links = contributionLinks('/hq', '/p', { [key]: ['X'] });
|
|
80
|
+
expect(links).toEqual([]);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
55
84
|
it('ignores empty / non-array contributes subfields', () => {
|
|
56
85
|
const links = contributionLinks('/hq', '/p', {
|
|
57
86
|
skills: [],
|
|
@@ -63,21 +92,118 @@ describe('contributionLinks: mapping', () => {
|
|
|
63
92
|
});
|
|
64
93
|
});
|
|
65
94
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// US-003 single-source parity: FULL key-set + payload-suffix + wire-mode
|
|
97
|
+
// equivalence across every surface (table, derived union, validateManifest's
|
|
98
|
+
// payload reader, and the generated scan-packages.sh block). Replaces the old
|
|
99
|
+
// substring-only `toContain` check, which passed even if a key was MISSING, had
|
|
100
|
+
// the WRONG payload suffix, or the WRONG wire mode.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
describe('US-003 parity: contribution table is the single source', () => {
|
|
103
|
+
// Resolve the HQ root so we can read the GENERATED scan-packages.sh block.
|
|
104
|
+
const scanCandidates = [
|
|
70
105
|
path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh'),
|
|
71
106
|
path.resolve(process.cwd(), '../../../../core/scripts/scan-packages.sh'),
|
|
72
107
|
];
|
|
73
|
-
const scanPath =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
const scanPath = scanCandidates.find((p) => fs.existsSync(p));
|
|
109
|
+
|
|
110
|
+
// Parse the generated `contrib_row` case arms back into {key:{payload,host,wire}}.
|
|
111
|
+
function parseBashTable(bash: string): Record<
|
|
112
|
+
string,
|
|
113
|
+
{ payload: string; host: string; wire: string }
|
|
114
|
+
> {
|
|
115
|
+
const out: Record<string, { payload: string; host: string; wire: string }> = {};
|
|
116
|
+
const re =
|
|
117
|
+
/^\s*([a-z]+)\)\s*printf\s+'%s'\s+'([^|]+)\|([^|]+)\|([^']+)'\s*;;/gm;
|
|
118
|
+
let m;
|
|
119
|
+
while ((m = re.exec(bash)) !== null) {
|
|
120
|
+
out[m[1]] = { payload: m[2], host: m[3], wire: m[4] };
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it('the derived PackContributeKey union == the table keys (8 keys incl. mcp)', () => {
|
|
126
|
+
// CONTRIBUTION_KEYS IS PackContributeKey's source (types.ts derives the
|
|
127
|
+
// union from it). Assert the full expected set is present, no more, no less.
|
|
128
|
+
expect([...CONTRIBUTION_KEYS].sort()).toEqual(
|
|
129
|
+
[
|
|
130
|
+
'commands',
|
|
131
|
+
'hooks',
|
|
132
|
+
'knowledge',
|
|
133
|
+
'mcp',
|
|
134
|
+
'policies',
|
|
135
|
+
'scripts',
|
|
136
|
+
'skills',
|
|
137
|
+
'workers',
|
|
138
|
+
].sort(),
|
|
139
|
+
);
|
|
140
|
+
// Type-level: every key is assignable to PackContributeKey.
|
|
141
|
+
const asUnion: PackContributeKey[] = [...CONTRIBUTION_KEYS];
|
|
142
|
+
expect(asUnion.length).toBe(8);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('every key has a wire mode of exactly symlink|merge, mcp is the merge row', () => {
|
|
146
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
147
|
+
expect(['symlink', 'merge']).toContain(CONTRIBUTION_TABLE[k].wire);
|
|
148
|
+
}
|
|
149
|
+
expect(CONTRIBUTION_TABLE.mcp.wire).toBe('merge');
|
|
150
|
+
expect(CONTRIBUTION_TABLE.mcp.payload).toBe('mcp/{item}.json');
|
|
151
|
+
expect(CONTRIBUTION_TABLE.mcp.host).toBe('merge:claude+codex');
|
|
152
|
+
// All non-mcp rows are symlink in this milestone.
|
|
153
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
154
|
+
if (k !== 'mcp') expect(CONTRIBUTION_TABLE[k].wire).toBe('symlink');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('payload suffixes are pinned per key (suffix is load-bearing)', () => {
|
|
159
|
+
const suffix = (p: string) => {
|
|
160
|
+
const base = p.split('/').pop() as string;
|
|
161
|
+
const dot = base.indexOf('.', base.indexOf('}'));
|
|
162
|
+
return dot >= 0 ? base.slice(dot) : '';
|
|
163
|
+
};
|
|
164
|
+
expect(suffix(CONTRIBUTION_TABLE.commands.payload)).toBe('.md');
|
|
165
|
+
expect(suffix(CONTRIBUTION_TABLE.policies.payload)).toBe('.md');
|
|
166
|
+
expect(suffix(CONTRIBUTION_TABLE.hooks.payload)).toBe('.sh');
|
|
167
|
+
expect(suffix(CONTRIBUTION_TABLE.mcp.payload)).toBe('.json');
|
|
168
|
+
expect(suffix(CONTRIBUTION_TABLE.workers.payload)).toBe('');
|
|
169
|
+
expect(suffix(CONTRIBUTION_TABLE.knowledge.payload)).toBe('');
|
|
170
|
+
expect(suffix(CONTRIBUTION_TABLE.skills.payload)).toBe('');
|
|
171
|
+
expect(suffix(CONTRIBUTION_TABLE.scripts.payload)).toBe('');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
(scanPath ? it : it.skip)(
|
|
175
|
+
'scan-packages.sh generated block matches the table on key-set + payload + host + wire',
|
|
176
|
+
() => {
|
|
177
|
+
const bash = fs.readFileSync(scanPath as string, 'utf-8');
|
|
178
|
+
const bashTable = parseBashTable(bash);
|
|
179
|
+
|
|
180
|
+
// FULL key-set equivalence (fails if a surface is MISSING a key or has an
|
|
181
|
+
// EXTRA one — not just a substring presence check).
|
|
182
|
+
expect(Object.keys(bashTable).sort()).toEqual([...CONTRIBUTION_KEYS].sort());
|
|
183
|
+
|
|
184
|
+
// Per-key payload + host + wire-mode equivalence.
|
|
185
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
186
|
+
expect(bashTable[k], `scan-packages.sh missing row for "${k}"`).toBeDefined();
|
|
187
|
+
expect(bashTable[k].payload).toBe(CONTRIBUTION_TABLE[k].payload);
|
|
188
|
+
expect(bashTable[k].host).toBe(CONTRIBUTION_TABLE[k].host);
|
|
189
|
+
expect(bashTable[k].wire).toBe(CONTRIBUTION_TABLE[k].wire);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The CONTRIB_KEYS array in the script also lists every key.
|
|
193
|
+
const keysLine = bash.match(/CONTRIB_KEYS=\(([^)]*)\)/);
|
|
194
|
+
expect(keysLine).not.toBeNull();
|
|
195
|
+
const bashKeys = (keysLine as RegExpMatchArray)[1].trim().split(/\s+/).sort();
|
|
196
|
+
expect(bashKeys).toEqual([...CONTRIBUTION_KEYS].sort());
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
it('validateManifest payload reader (payloadFor) agrees with the table for every key', () => {
|
|
201
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
202
|
+
// payloadFor is exactly what validateManifest now uses for the
|
|
203
|
+
// payload-existence check, so this pins that surface to the table.
|
|
204
|
+
expect(payloadFor(k, 'demo')).toBe(
|
|
205
|
+
CONTRIBUTION_TABLE[k].payload.replace('{item}', 'demo'),
|
|
206
|
+
);
|
|
81
207
|
}
|
|
82
208
|
});
|
|
83
209
|
});
|
|
@@ -257,3 +383,109 @@ describe('readHqVersion: v15 layout awareness (feedback_57d7edcf, symptom b)', (
|
|
|
257
383
|
expect(readHqVersion(hqRoot)).toBeNull();
|
|
258
384
|
});
|
|
259
385
|
});
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// US-009: unwirePackMcp — the wire:merge (MCP) un-registration parallel to the
|
|
389
|
+
// symlink unwirePack. Runs against an ISOLATED tmpdir home (env.home) so the
|
|
390
|
+
// developer's real ~/.claude.json / ~/.codex are NEVER touched.
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
describe('US-009: unwirePackMcp (mcp un-registration parallel to symlink unwire)', () => {
|
|
394
|
+
let mcpHome: string;
|
|
395
|
+
|
|
396
|
+
beforeEach(() => {
|
|
397
|
+
mcpHome = mkTmp('hq-unwire-mcp-');
|
|
398
|
+
});
|
|
399
|
+
afterEach(() => {
|
|
400
|
+
fs.rmSync(mcpHome, { recursive: true, force: true });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('un-registers ONLY this pack\'s provenance-stamped mcp servers from ~/.claude.json', () => {
|
|
404
|
+
const env = { home: mcpHome };
|
|
405
|
+
// The user owns `figma`; our pack installs `vyg`.
|
|
406
|
+
fs.writeFileSync(
|
|
407
|
+
claudeConfigPath(env),
|
|
408
|
+
JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma' } } }, null, 2) + '\n',
|
|
409
|
+
);
|
|
410
|
+
registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
|
|
411
|
+
|
|
412
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
413
|
+
expect(result.servers).toHaveLength(1);
|
|
414
|
+
expect(result.servers[0]!.claude.outcome).toBe('removed');
|
|
415
|
+
|
|
416
|
+
const after = JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8'));
|
|
417
|
+
expect(after.mcpServers.vyg).toBeUndefined(); // ours removed
|
|
418
|
+
expect(after.mcpServers.figma.url).toBe('https://figma'); // user sibling preserved
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('no-ops cleanly when the pack declares no mcp (merge) keys', () => {
|
|
422
|
+
const result = unwirePackMcp('hq-pack-x', { commands: ['foo'], skills: ['bar'] }, { env: { home: mcpHome } });
|
|
423
|
+
expect(result.servers).toEqual([]);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('skip-and-warns (does not delete) a foreign/unstamped same-named entry', () => {
|
|
427
|
+
const env = { home: mcpHome };
|
|
428
|
+
// The user has their OWN `vyg` (no _hqPack stamp).
|
|
429
|
+
fs.writeFileSync(
|
|
430
|
+
claudeConfigPath(env),
|
|
431
|
+
JSON.stringify({ mcpServers: { vyg: { type: 'http', url: 'https://user-vyg' } } }, null, 2) + '\n',
|
|
432
|
+
);
|
|
433
|
+
const before = fs.readFileSync(claudeConfigPath(env), 'utf-8');
|
|
434
|
+
|
|
435
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
436
|
+
expect(result.servers[0]!.claude.outcome).toBe('skipped-foreign');
|
|
437
|
+
// The user's server is left byte-for-byte intact.
|
|
438
|
+
expect(fs.readFileSync(claudeConfigPath(env), 'utf-8')).toBe(before);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('is a clean no-op on a Codex-less host with no ~/.claude.json (never fabricates)', () => {
|
|
442
|
+
const env = { home: mcpHome };
|
|
443
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
444
|
+
expect(result.servers[0]!.claude.outcome).toBe('absent');
|
|
445
|
+
expect('skipped' in result.servers[0]!.codex && (result.servers[0]!.codex as any).skipped).toBe(true);
|
|
446
|
+
expect(fs.existsSync(path.join(mcpHome, '.claude.json'))).toBe(false);
|
|
447
|
+
expect(fs.existsSync(path.join(mcpHome, '.codex'))).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('delegates to unregisterMcpServers across BOTH surfaces when ~/.codex exists', () => {
|
|
451
|
+
// The unwire-hook must un-register from Claude AND Codex (not just Claude) — i.e.
|
|
452
|
+
// it really delegates to unregisterMcpServers, which fans out over both surfaces.
|
|
453
|
+
const env = { home: mcpHome };
|
|
454
|
+
fs.mkdirSync(codexHome(env), { recursive: true }); // make Codex a real runtime
|
|
455
|
+
// Install our vyg into BOTH surfaces, alongside a user-owned figma on Claude.
|
|
456
|
+
fs.writeFileSync(
|
|
457
|
+
claudeConfigPath(env),
|
|
458
|
+
JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma' } } }, null, 2) + '\n',
|
|
459
|
+
);
|
|
460
|
+
registerServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
|
|
461
|
+
// Both surfaces carry vyg before uninstall.
|
|
462
|
+
expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.vyg).toBeDefined();
|
|
463
|
+
expect((parseToml(fs.readFileSync(codexConfigPath(env), 'utf-8')) as any).mcp_servers.vyg).toBeDefined();
|
|
464
|
+
|
|
465
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
466
|
+
expect(result.servers).toHaveLength(1);
|
|
467
|
+
expect(result.servers[0]!.claude.outcome).toBe('removed');
|
|
468
|
+
expect('skipped' in result.servers[0]!.codex).toBe(false);
|
|
469
|
+
expect((result.servers[0]!.codex as any).outcome).toBe('removed');
|
|
470
|
+
|
|
471
|
+
// Both surfaces no longer carry vyg; the user's Claude figma sibling survives.
|
|
472
|
+
expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.vyg).toBeUndefined();
|
|
473
|
+
expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.figma).toBeDefined();
|
|
474
|
+
expect((parseToml(fs.readFileSync(codexConfigPath(env), 'utf-8')) as any).mcp_servers.vyg).toBeUndefined();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('skip-and-warns a foreign Codex table too (does not delete another pack\'s server)', () => {
|
|
478
|
+
// The Codex parallel of the foreign skip: a vyg table stamped by ANOTHER pack must
|
|
479
|
+
// be left in place when uninstalling OUR pack — provenance scope, not name match.
|
|
480
|
+
const env = { home: mcpHome };
|
|
481
|
+
fs.mkdirSync(codexHome(env), { recursive: true });
|
|
482
|
+
const original =
|
|
483
|
+
'# keep me\n[mcp_servers.vyg]\ntype = "http"\nurl = "https://vyg"\n_hqPack = "hq-pack-OTHER"\n';
|
|
484
|
+
fs.writeFileSync(codexConfigPath(env), original);
|
|
485
|
+
|
|
486
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
487
|
+
expect((result.servers[0]!.codex as any).outcome).toBe('skipped-foreign');
|
|
488
|
+
// Byte-for-byte preserved (comment intact — no write on a foreign skip).
|
|
489
|
+
expect(fs.readFileSync(codexConfigPath(env), 'utf-8')).toBe(original);
|
|
490
|
+
});
|
|
491
|
+
});
|