@mmnto/cli 2.1.0 → 2.2.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/doctor-parity-fetch.d.ts +93 -2
- package/dist/commands/doctor-parity-fetch.d.ts.map +1 -1
- package/dist/commands/doctor-parity-fetch.js +332 -11
- package/dist/commands/doctor-parity-fetch.js.map +1 -1
- package/dist/commands/doctor-parity-fetch.test.d.ts +6 -0
- package/dist/commands/doctor-parity-fetch.test.d.ts.map +1 -1
- package/dist/commands/doctor-parity-fetch.test.js +405 -1
- package/dist/commands/doctor-parity-fetch.test.js.map +1 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +27 -1
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +67 -0
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/eject-gate-needle.test.d.ts +2 -0
- package/dist/commands/eject-gate-needle.test.d.ts.map +1 -0
- package/dist/commands/eject-gate-needle.test.js +48 -0
- package/dist/commands/eject-gate-needle.test.js.map +1 -0
- package/dist/commands/eject.d.ts +10 -0
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +67 -12
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +113 -0
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gate-install.d.ts +55 -5
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +47 -34
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +514 -56
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/gate.d.ts +24 -9
- package/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +50 -18
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/gate.test.js +74 -3
- package/dist/commands/gate.test.js.map +1 -1
- package/dist/commands/host-hooks.d.ts +8 -2
- package/dist/commands/host-hooks.d.ts.map +1 -1
- package/dist/commands/host-hooks.js.map +1 -1
- package/dist/commands/init-templates.d.ts +4 -4
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +243 -63
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +35 -17
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +10 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/session-context-journal-select.test.d.ts +2 -0
- package/dist/commands/session-context-journal-select.test.d.ts.map +1 -0
- package/dist/commands/session-context-journal-select.test.js +95 -0
- package/dist/commands/session-context-journal-select.test.js.map +1 -0
- package/dist/commands/sync-labels-forms.test.d.ts +33 -0
- package/dist/commands/sync-labels-forms.test.d.ts.map +1 -0
- package/dist/commands/sync-labels-forms.test.js +325 -0
- package/dist/commands/sync-labels-forms.test.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -3,10 +3,10 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import {
|
|
6
|
+
import { knownGates, TotemError } from '@mmnto/totem';
|
|
7
7
|
import { ejectCommand } from './eject.js';
|
|
8
|
-
import { gateInstallCommand
|
|
9
|
-
import { commandInstallsGate, GATE_WRAPPER_REL, installGates } from './gate-install.js';
|
|
8
|
+
import { gateInstallCommand } from './gate.js';
|
|
9
|
+
import { commandInstallsGate, GATE_WRAPPER_REL, installGates, } from './gate-install.js';
|
|
10
10
|
import { initCommand } from './init.js';
|
|
11
11
|
import { CLAUDE_GATE_WRAPPER, TOTEM_FILE_END, TOTEM_FILE_MARKER } from './init-templates.js';
|
|
12
12
|
import { resolveGitRootForHookPath, resolveHooksDir } from './install-hooks.js';
|
|
@@ -51,6 +51,26 @@ function makeTmpDir() {
|
|
|
51
51
|
// `.native` expands Windows 8.3 short names so process.cwd()/realpath agree.
|
|
52
52
|
return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-gate-install-')));
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The parent env with EVERY case-variant of PATH dropped and exactly one
|
|
56
|
+
* `PATH` set (win32 stores it as `Path`; a plain spread would leave both keys
|
|
57
|
+
* in the child's block). Everything else is inherited so node still boots under
|
|
58
|
+
* an empty PATH.
|
|
59
|
+
*
|
|
60
|
+
* Load-bearing since the wrapper grew a PATH fallback arm (mmnto-ai/totem#2822):
|
|
61
|
+
* a wrapper test that means "no CLI is resolvable" must say so explicitly, or it
|
|
62
|
+
* silently reads the developer's global `@mmnto/cli` and passes/fails by machine.
|
|
63
|
+
*/
|
|
64
|
+
function envWithPath(value) {
|
|
65
|
+
const env = {};
|
|
66
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
67
|
+
if (key.toUpperCase() === 'PATH')
|
|
68
|
+
continue;
|
|
69
|
+
env[key] = val;
|
|
70
|
+
}
|
|
71
|
+
env.PATH = value;
|
|
72
|
+
return env;
|
|
73
|
+
}
|
|
54
74
|
function readSettings(cwd) {
|
|
55
75
|
const raw = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf-8');
|
|
56
76
|
return JSON.parse(raw);
|
|
@@ -60,13 +80,41 @@ function preToolUseEntries(cwd) {
|
|
|
60
80
|
const hooks = (parsed.hooks ?? {});
|
|
61
81
|
return (hooks.PreToolUse ?? []);
|
|
62
82
|
}
|
|
63
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* The `{event, matcher}` pairs the caller resolves from the core registry and
|
|
85
|
+
* hands `installGates` (mmnto-ai/totem#2799). Spelled literally here rather than
|
|
86
|
+
* read from `knownGates()` so a registry change that silently moved a gate to a
|
|
87
|
+
* different matcher would FAIL these tests instead of following them.
|
|
88
|
+
*/
|
|
89
|
+
const FREEZE_CHECK = { event: 'freeze-check', matcher: 'Write|Edit' };
|
|
90
|
+
const TRANSPORT_SHIELD = {
|
|
91
|
+
event: 'transport-shield',
|
|
92
|
+
matcher: 'Bash|PowerShell',
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Every PreToolUse matcher an entry installing `event` currently appears under.
|
|
96
|
+
* Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
|
|
97
|
+
* shows up here as two members, which is the invariant the #2799 tests assert.
|
|
98
|
+
*/
|
|
99
|
+
function matchersFor(cwd, event) {
|
|
100
|
+
const found = [];
|
|
101
|
+
for (const e of preToolUseEntries(cwd)) {
|
|
102
|
+
if (!Array.isArray(e.hooks))
|
|
103
|
+
continue;
|
|
104
|
+
for (const h of e.hooks) {
|
|
105
|
+
const cmd = typeof h === 'string' ? h : (h?.command ?? '');
|
|
106
|
+
// Collision-safe exact --event match (tier-independent).
|
|
107
|
+
if (commandInstallsGate(cmd, event))
|
|
108
|
+
found.push(e.matcher ?? '');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
/** Count PreToolUse entries (under ANY matcher) whose command references a given gate. */
|
|
64
114
|
function gateEntryCount(cwd, event) {
|
|
65
|
-
return preToolUseEntries(cwd).filter((e) => e.
|
|
66
|
-
Array.isArray(e.hooks) &&
|
|
115
|
+
return preToolUseEntries(cwd).filter((e) => Array.isArray(e.hooks) &&
|
|
67
116
|
e.hooks.some((h) => {
|
|
68
117
|
const cmd = typeof h === 'string' ? h : (h?.command ?? '');
|
|
69
|
-
// Collision-safe exact --event match (tier-independent).
|
|
70
118
|
return commandInstallsGate(cmd, event);
|
|
71
119
|
})).length;
|
|
72
120
|
}
|
|
@@ -74,7 +122,7 @@ function gateEntryCount(cwd, event) {
|
|
|
74
122
|
function gateCommandFor(cwd, event) {
|
|
75
123
|
const cmds = [];
|
|
76
124
|
for (const e of preToolUseEntries(cwd)) {
|
|
77
|
-
if (
|
|
125
|
+
if (!Array.isArray(e.hooks))
|
|
78
126
|
continue;
|
|
79
127
|
for (const h of e.hooks) {
|
|
80
128
|
const cmd = typeof h === 'string' ? h : (h?.command ?? '');
|
|
@@ -97,7 +145,7 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
97
145
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
98
146
|
});
|
|
99
147
|
it('scaffolds the wrapper and merges one PreToolUse entry per gate', () => {
|
|
100
|
-
const results = installGates(cwd, [
|
|
148
|
+
const results = installGates(cwd, [FREEZE_CHECK]);
|
|
101
149
|
// wrapper scaffold + one entry merge
|
|
102
150
|
expect(results.some((r) => r.file === GATE_WRAPPER_REL && r.action === 'created')).toBe(true);
|
|
103
151
|
expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
|
|
@@ -108,7 +156,7 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
108
156
|
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
109
157
|
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true });
|
|
110
158
|
fs.writeFileSync(wrapperPath, `${TOTEM_FILE_MARKER}\nstale\n${TOTEM_FILE_END}\n`, 'utf-8');
|
|
111
|
-
const results = installGates(cwd, [
|
|
159
|
+
const results = installGates(cwd, [FREEZE_CHECK]);
|
|
112
160
|
// gate install now threads the end marker, so scaffoldFile drift-repairs the bounded
|
|
113
161
|
// wrapper (`refreshed`) and gate-install maps that to `merged` (a write happened).
|
|
114
162
|
expect(results.find((r) => r.file === GATE_WRAPPER_REL).action).toBe('merged');
|
|
@@ -116,9 +164,9 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
116
164
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
117
165
|
});
|
|
118
166
|
it('is idempotent: a second install of the same gate is a no-op', () => {
|
|
119
|
-
installGates(cwd, [
|
|
167
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
120
168
|
const before = JSON.stringify(readSettings(cwd));
|
|
121
|
-
const second = installGates(cwd, [
|
|
169
|
+
const second = installGates(cwd, [FREEZE_CHECK]);
|
|
122
170
|
const entryResult = second.find((r) => r.event === 'freeze-check');
|
|
123
171
|
expect(entryResult?.action).toBe('skipped');
|
|
124
172
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
@@ -127,8 +175,8 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
127
175
|
it('keys idempotency on the per-gate command substring (distinct gates → distinct entries)', () => {
|
|
128
176
|
// freeze-check installed; a hypothetical future gate keyed on a different
|
|
129
177
|
// --event substring produces a SECOND entry rather than colliding.
|
|
130
|
-
installGates(cwd, [
|
|
131
|
-
installGates(cwd, ['some-future-gate']);
|
|
178
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
179
|
+
installGates(cwd, [{ event: 'some-future-gate', matcher: 'Write|Edit' }]);
|
|
132
180
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
133
181
|
expect(gateEntryCount(cwd, 'some-future-gate')).toBe(1);
|
|
134
182
|
// Two distinct Write|Edit gate entries under one matcher.
|
|
@@ -147,16 +195,110 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
147
195
|
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-hook' }] }],
|
|
148
196
|
},
|
|
149
197
|
}));
|
|
150
|
-
installGates(cwd, [
|
|
198
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
151
199
|
const entries = preToolUseEntries(cwd);
|
|
152
200
|
expect(entries.some((e) => e.matcher === 'Bash')).toBe(true);
|
|
153
201
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
154
202
|
});
|
|
155
|
-
it('gateInstallCommand --all installs every known gate', async () => {
|
|
203
|
+
it('gateInstallCommand --all installs every known gate under ITS registry matcher', async () => {
|
|
156
204
|
await gateInstallCommand({ all: true });
|
|
157
|
-
for (const event of
|
|
205
|
+
for (const { event, matcher } of knownGates()) {
|
|
206
|
+
expect(gateEntryCount(cwd, event)).toBe(1);
|
|
207
|
+
// The installed matcher is the registry's, not a shared default.
|
|
208
|
+
expect(matchersFor(cwd, event)).toEqual([matcher]);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
// ─── Per-gate matchers (mmnto-ai/totem#2799) ───────────────────────────
|
|
213
|
+
//
|
|
214
|
+
// Before #2799 every gate entry was written under one hardcoded `Write|Edit`
|
|
215
|
+
// matcher. The registry now carries each gate's own matcher, the caller
|
|
216
|
+
// resolves it (`knownGates()`), and `installGates` writes THAT — so
|
|
217
|
+
// `transport-shield` lands on `Bash|PowerShell` while `freeze-check` keeps the
|
|
218
|
+
// byte-identical `Write|Edit` entry it has always written.
|
|
219
|
+
describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
|
|
220
|
+
let cwd;
|
|
221
|
+
let originalCwd;
|
|
222
|
+
const STRICT_FREEZE_ENTRY = {
|
|
223
|
+
matcher: 'Write|Edit',
|
|
224
|
+
hooks: [
|
|
225
|
+
{
|
|
226
|
+
type: 'command',
|
|
227
|
+
command: 'node .claude/hooks/gate-wrapper.cjs --event freeze-check --strict',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
const STRICT_TRANSPORT_ENTRY = {
|
|
232
|
+
matcher: 'Bash|PowerShell',
|
|
233
|
+
hooks: [
|
|
234
|
+
{
|
|
235
|
+
type: 'command',
|
|
236
|
+
command: 'node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict',
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
cwd = makeTmpDir();
|
|
242
|
+
originalCwd = process.cwd();
|
|
243
|
+
process.chdir(cwd);
|
|
244
|
+
});
|
|
245
|
+
afterEach(() => {
|
|
246
|
+
process.chdir(originalCwd);
|
|
247
|
+
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
248
|
+
});
|
|
249
|
+
it('transport-shield writes exactly ONE entry under Bash|PowerShell with the baked command', () => {
|
|
250
|
+
installGates(cwd, [TRANSPORT_SHIELD]);
|
|
251
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
252
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
253
|
+
expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
|
|
254
|
+
// The whole emitted entry, byte for byte.
|
|
255
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_TRANSPORT_ENTRY]);
|
|
256
|
+
});
|
|
257
|
+
it('freeze-check writes the SAME Write|Edit entry it wrote before #2799, byte for byte', () => {
|
|
258
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
259
|
+
// The full emitted JSON entry — the regression guard on the gate whose
|
|
260
|
+
// matcher did NOT change.
|
|
261
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY]);
|
|
262
|
+
});
|
|
263
|
+
it('both gates together write both entries, each under its own matcher', () => {
|
|
264
|
+
installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
|
|
265
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
|
|
266
|
+
expect(matchersFor(cwd, 'freeze-check')).toEqual(['Write|Edit']);
|
|
267
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
268
|
+
});
|
|
269
|
+
it('re-running both gates at the same tier is a byte-level no-op', () => {
|
|
270
|
+
installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
|
|
271
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.json');
|
|
272
|
+
const before = fs.readFileSync(settingsPath, 'utf-8');
|
|
273
|
+
const second = installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
|
|
274
|
+
expect(second.find((r) => r.event === 'freeze-check')?.action).toBe('skipped');
|
|
275
|
+
expect(second.find((r) => r.event === 'transport-shield')?.action).toBe('skipped');
|
|
276
|
+
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
|
|
277
|
+
});
|
|
278
|
+
it('a tier switch updates the transport-shield entry IN PLACE under its own matcher', () => {
|
|
279
|
+
installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
|
|
280
|
+
expect(gateCommandFor(cwd, 'transport-shield')).toContain('--pilot');
|
|
281
|
+
const switched = installGates(cwd, [TRANSPORT_SHIELD], 'strict');
|
|
282
|
+
expect(switched.find((r) => r.event === 'transport-shield')?.action).toBe('updated');
|
|
283
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
284
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
285
|
+
const cmd = gateCommandFor(cwd, 'transport-shield');
|
|
286
|
+
expect(cmd).toContain('--strict');
|
|
287
|
+
expect(cmd).not.toContain('--pilot');
|
|
288
|
+
});
|
|
289
|
+
it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
|
|
290
|
+
// Interleave tiers and selections — the upsert must converge on exactly one
|
|
291
|
+
// entry per gate, always under that gate's registry matcher.
|
|
292
|
+
installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
293
|
+
installGates(cwd, [TRANSPORT_SHIELD]);
|
|
294
|
+
installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
|
|
295
|
+
installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD], 'strict');
|
|
296
|
+
installGates(cwd, [TRANSPORT_SHIELD], 'strict');
|
|
297
|
+
for (const { event, matcher } of [FREEZE_CHECK, TRANSPORT_SHIELD]) {
|
|
298
|
+
expect(matchersFor(cwd, event)).toEqual([matcher]);
|
|
158
299
|
expect(gateEntryCount(cwd, event)).toBe(1);
|
|
159
300
|
}
|
|
301
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
|
|
160
302
|
});
|
|
161
303
|
});
|
|
162
304
|
// ─── FIX A: tier-AWARE upsert (update in place, never silent no-op/dup) ──
|
|
@@ -179,17 +321,17 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
179
321
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
180
322
|
});
|
|
181
323
|
it('default(strict) then re-install --strict → skipped, one entry, command keeps --strict', () => {
|
|
182
|
-
installGates(cwd, [
|
|
183
|
-
const second = installGates(cwd, [
|
|
324
|
+
installGates(cwd, [FREEZE_CHECK]); // default tier === strict
|
|
325
|
+
const second = installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
184
326
|
const entryResult = second.find((r) => r.event === 'freeze-check');
|
|
185
327
|
expect(entryResult?.action).toBe('skipped');
|
|
186
328
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
187
329
|
expect(gateCommandFor(cwd, 'freeze-check')).toContain('--strict');
|
|
188
330
|
});
|
|
189
331
|
it('--pilot then --strict → updated, ONE entry, command flips to --strict (not --pilot)', () => {
|
|
190
|
-
installGates(cwd, [
|
|
332
|
+
installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
191
333
|
expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
|
|
192
|
-
const switched = installGates(cwd, [
|
|
334
|
+
const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
193
335
|
const entryResult = switched.find((r) => r.event === 'freeze-check');
|
|
194
336
|
expect(entryResult?.action).toBe('updated');
|
|
195
337
|
// Exactly one entry — no duplicate created by the tier switch.
|
|
@@ -199,8 +341,8 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
199
341
|
expect(cmd).not.toContain('--pilot');
|
|
200
342
|
});
|
|
201
343
|
it('--strict then --pilot → updated back to --pilot, one entry', () => {
|
|
202
|
-
installGates(cwd, [
|
|
203
|
-
const switched = installGates(cwd, [
|
|
344
|
+
installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
345
|
+
const switched = installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
204
346
|
const entryResult = switched.find((r) => r.event === 'freeze-check');
|
|
205
347
|
expect(entryResult?.action).toBe('updated');
|
|
206
348
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
@@ -242,13 +384,13 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
242
384
|
return c === shieldCommand;
|
|
243
385
|
}));
|
|
244
386
|
// Install the gate at pilot → a distinct second Write|Edit entry.
|
|
245
|
-
installGates(cwd, [
|
|
387
|
+
installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
246
388
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
247
389
|
expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
|
|
248
390
|
expect(hasShield()).toBe(true);
|
|
249
391
|
expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
|
|
250
392
|
// Tier switch → updates ONLY the gate entry in place; PreWriteShield stays.
|
|
251
|
-
const switched = installGates(cwd, [
|
|
393
|
+
const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
252
394
|
expect(switched.find((r) => r.event === 'freeze-check')?.action).toBe('updated');
|
|
253
395
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
254
396
|
const cmd2 = gateCommandFor(cwd, 'freeze-check');
|
|
@@ -258,21 +400,9 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
258
400
|
expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
|
|
259
401
|
});
|
|
260
402
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
});
|
|
265
|
-
it('a known --<name> resolves to itself', async () => {
|
|
266
|
-
expect(await resolveGateEvents({ name: 'freeze-check' })).toEqual(['freeze-check']);
|
|
267
|
-
});
|
|
268
|
-
it('an unknown --<name> fails loud (never default-install)', async () => {
|
|
269
|
-
await expect(resolveGateEvents({ name: 'made-up-gate' })).rejects.toBeInstanceOf(TotemError);
|
|
270
|
-
await expect(resolveGateEvents({ name: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
|
|
271
|
-
});
|
|
272
|
-
it('no --all and no --<name> fails loud (no default-install)', async () => {
|
|
273
|
-
await expect(resolveGateEvents({})).rejects.toThrow(/no gate selected/i);
|
|
274
|
-
});
|
|
275
|
-
});
|
|
403
|
+
// `resolveGates` (the registry-driven `{event, matcher}` resolver that replaced
|
|
404
|
+
// `resolveGateEvents` in mmnto-ai/totem#2799) is covered in gate.test.ts, beside
|
|
405
|
+
// the other `./gate.js` command-seam tests.
|
|
276
406
|
// ─── The parameterized wrapper's disposition → exit-code map ───────────
|
|
277
407
|
//
|
|
278
408
|
// We render the wrapper template to a temp dir and drive it via stdin with
|
|
@@ -291,36 +421,76 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
291
421
|
afterEach(() => {
|
|
292
422
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
293
423
|
});
|
|
294
|
-
/**
|
|
424
|
+
/** Where the stub CLI records the argv and stdin it was spawned with (absent ⇒ never spawned). */
|
|
425
|
+
const stubRecordPath = () => path.join(cwd, 'stub-record.json');
|
|
426
|
+
/** Install a stub local CLI that records its argv + stdin and emits a controlled verdict / exit code. */
|
|
295
427
|
function writeStubCli(opts) {
|
|
296
428
|
const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
|
|
297
429
|
fs.mkdirSync(distDir, { recursive: true });
|
|
298
430
|
const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
|
|
299
431
|
const exitCode = opts.exit ?? 0;
|
|
300
432
|
// CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
|
|
301
|
-
// because there is no package.json type:module in the temp dir.
|
|
433
|
+
// because there is no package.json type:module in the temp dir. The record
|
|
434
|
+
// is what lets a test assert BOTH that a spawn happened and exactly which
|
|
435
|
+
// `gate check --event … --payload -` the wrapper projected, argv AND the
|
|
436
|
+
// stdin the payload rides on (mmnto-ai/totem#2799); `JSON.stringify` on
|
|
437
|
+
// the path handles Windows separators without a hand-written escape.
|
|
302
438
|
const stub = [
|
|
303
439
|
'"use strict";',
|
|
440
|
+
'const fs = require("fs");',
|
|
304
441
|
`const out = ${JSON.stringify(verdictJson)};`,
|
|
442
|
+
'let stdin = "";',
|
|
443
|
+
'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
|
|
444
|
+
`fs.writeFileSync(${JSON.stringify(stubRecordPath())}, JSON.stringify({ argv: process.argv.slice(2), stdin }));`,
|
|
305
445
|
'if (out) process.stdout.write(out + "\\n");',
|
|
306
446
|
`process.exit(${exitCode});`,
|
|
307
447
|
'',
|
|
308
448
|
].join('\n');
|
|
309
449
|
fs.writeFileSync(path.join(distDir, 'index.js'), stub);
|
|
310
450
|
}
|
|
311
|
-
/**
|
|
312
|
-
function
|
|
451
|
+
/** What the stub CLI was spawned with, or null when the wrapper never spawned it. */
|
|
452
|
+
function stubRecord() {
|
|
453
|
+
if (!fs.existsSync(stubRecordPath()))
|
|
454
|
+
return null;
|
|
455
|
+
return JSON.parse(fs.readFileSync(stubRecordPath(), 'utf-8'));
|
|
456
|
+
}
|
|
457
|
+
/** The argv the stub CLI was spawned with, or null when the wrapper never spawned it. */
|
|
458
|
+
function stubArgv() {
|
|
459
|
+
return stubRecord()?.argv ?? null;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The payload JSON the wrapper projected, parsed (throws if it never
|
|
463
|
+
* spawned). The payload rides on the child's stdin under `--payload -` —
|
|
464
|
+
* never argv, whose win32 limit a long Bash command would exceed.
|
|
465
|
+
*/
|
|
466
|
+
function spawnedPayload() {
|
|
467
|
+
const record = stubRecord();
|
|
468
|
+
expect(record, 'the wrapper never spawned the CLI').not.toBeNull();
|
|
469
|
+
const at = record.argv.indexOf('--payload');
|
|
470
|
+
expect(at).toBeGreaterThan(-1);
|
|
471
|
+
expect(record.argv[at + 1]).toBe('-');
|
|
472
|
+
return JSON.parse(record.stdin);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Run the rendered wrapper with the given envelope + baked extra args.
|
|
476
|
+
* `env` defaults to the parent's (every test here writes a repo-local stub,
|
|
477
|
+
* which the wrapper resolves FIRST, so the ambient PATH is inert for them);
|
|
478
|
+
* the no-CLI test passes an empty PATH explicitly.
|
|
479
|
+
*/
|
|
480
|
+
function runWrapper(envelope, extraArgs = [], event = 'freeze-check', env = undefined) {
|
|
313
481
|
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
314
|
-
const res = spawnSync(process.execPath, [wrapperPath, '--event',
|
|
482
|
+
const res = spawnSync(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
|
|
315
483
|
cwd,
|
|
316
484
|
input: JSON.stringify(envelope),
|
|
317
485
|
encoding: 'utf-8',
|
|
318
486
|
timeout: 30000,
|
|
487
|
+
...(env ? { env } : {}),
|
|
319
488
|
});
|
|
320
489
|
return { status: res.status, stderr: res.stderr ?? '' };
|
|
321
490
|
}
|
|
322
491
|
const EDIT_NO_SUBSYSTEM = { tool_name: 'Edit', tool_input: { file_path: 'src/foo.ts' } };
|
|
323
492
|
const DECLARED = { tool_name: 'Edit', tool_input: { subsystem: 'rule-compilation' } };
|
|
493
|
+
const ALLOW_VERDICT = { disposition: 'allow', reason: 'ok', provenance: {} };
|
|
324
494
|
it('allow → exit 0 (silent)', () => {
|
|
325
495
|
writeStubCli({ verdict: { disposition: 'allow', reason: 'ok', provenance: {} }, exit: 0 });
|
|
326
496
|
const { status } = runWrapper(DECLARED);
|
|
@@ -382,14 +552,17 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
382
552
|
expect(res.status).toBe(0);
|
|
383
553
|
});
|
|
384
554
|
// ─── FIX 2: applicable gate but no local CLI dist → fail-closed ────────
|
|
385
|
-
it('applicable gate but
|
|
386
|
-
// Deliberately do NOT write the stub CLI
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
555
|
+
it('applicable gate but NO CLI is resolvable (local absent, PATH empty) → exit 2 fail-closed (FIX 2)', () => {
|
|
556
|
+
// Deliberately do NOT write the stub CLI, and hand the wrapper an EMPTY
|
|
557
|
+
// PATH so neither resolution arm can answer (mmnto-ai/totem#2822 added the
|
|
558
|
+
// PATH fallback; without this the test would silently read a developer's
|
|
559
|
+
// global @mmnto/cli and stop testing fail-closed). A declared subsystem
|
|
560
|
+
// makes the gate APPLY; freeze-check has no commit-time hard floor, so an
|
|
561
|
+
// applicable-but-unevaluable gate must fail closed (exit 2), never silently
|
|
562
|
+
// allow (exit 0).
|
|
563
|
+
const { status, stderr } = runWrapper(DECLARED, [], 'freeze-check', envWithPath(''));
|
|
391
564
|
expect(status).toBe(2);
|
|
392
|
-
expect(stderr).toMatch(/
|
|
565
|
+
expect(stderr).toMatch(/failing closed/i);
|
|
393
566
|
});
|
|
394
567
|
it('gate check exits 0 with unparseable stdout → exit 2 fail-closed', () => {
|
|
395
568
|
// verdict undefined → stub emits nothing; the wrapper cannot parse a
|
|
@@ -417,6 +590,258 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
417
590
|
});
|
|
418
591
|
expect(res.status).toBe(0);
|
|
419
592
|
});
|
|
593
|
+
// ─── Per-event payload projection (mmnto-ai/totem#2799) ────────────────
|
|
594
|
+
//
|
|
595
|
+
// The wrapper is --event-parameterized but used to build ONE freeze-check-
|
|
596
|
+
// shaped payload for every event. It now branches on the baked --event AFTER
|
|
597
|
+
// the envelope parse and the non-object guard: each gate owns its own
|
|
598
|
+
// NOT-APPLICABLE test and its own payload, and an event it cannot project
|
|
599
|
+
// fails CLOSED. The stub CLI records its argv, so "did not spawn" is an
|
|
600
|
+
// assertion about the filesystem, not an inference from the exit code.
|
|
601
|
+
const BASH_CMD = { tool_name: 'Bash', tool_input: { command: 'git status' } };
|
|
602
|
+
const PWSH_CMD = { tool_name: 'PowerShell', tool_input: { command: 'Get-ChildItem' } };
|
|
603
|
+
it('transport-shield on a Write envelope → exit 0, never spawns', () => {
|
|
604
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
605
|
+
const { status } = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'src/foo.ts' } }, [], 'transport-shield');
|
|
606
|
+
expect(status).toBe(0);
|
|
607
|
+
expect(stubArgv()).toBeNull();
|
|
608
|
+
});
|
|
609
|
+
it('transport-shield on a Write envelope that DECLARES a subsystem still never spawns', () => {
|
|
610
|
+
// The falsifier for the projection swap: pre-#2799 the wrapper keyed
|
|
611
|
+
// applicability on `tool_input.subsystem` for EVERY event, so this envelope
|
|
612
|
+
// shelled out with a freeze-check-shaped `{subsystem}` payload under
|
|
613
|
+
// `--event transport-shield`. Applicability is now the gate's own.
|
|
614
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
615
|
+
const { status } = runWrapper({ tool_name: 'Write', tool_input: { subsystem: 'rule-compilation' } }, [], 'transport-shield');
|
|
616
|
+
expect(status).toBe(0);
|
|
617
|
+
expect(stubArgv()).toBeNull();
|
|
618
|
+
});
|
|
619
|
+
it('transport-shield on a Bash envelope with no string command → exit 0, never spawns', () => {
|
|
620
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
621
|
+
const { status } = runWrapper({ tool_name: 'Bash', tool_input: { description: 'no command field' } }, [], 'transport-shield');
|
|
622
|
+
expect(status).toBe(0);
|
|
623
|
+
expect(stubArgv()).toBeNull();
|
|
624
|
+
});
|
|
625
|
+
it('transport-shield on a Bash envelope with a command spawns {tool, command, platform}', () => {
|
|
626
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
627
|
+
const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
|
|
628
|
+
expect(status).toBe(0);
|
|
629
|
+
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
|
|
630
|
+
expect(spawnedPayload()).toEqual({
|
|
631
|
+
tool: 'Bash',
|
|
632
|
+
command: 'git status',
|
|
633
|
+
platform: process.platform,
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
it('a Bash command past the win32 command-line limit still reaches the gate on stdin (P3-F6)', () => {
|
|
637
|
+
// 40,000 characters: past win32's 32,767-character argv cap, where an argv
|
|
638
|
+
// payload failed the spawn with ENAMETOOLONG and fell into the fail-closed
|
|
639
|
+
// arm with nothing broken. On stdin the verdict comes back and maps to exit 0.
|
|
640
|
+
// Discriminates the fold on the windows-latest CI leg only: linux (128 KB per
|
|
641
|
+
// argument) and darwin admit a 40 KB argv, so there the pre-fold wrapper also
|
|
642
|
+
// exits 0; the stdin record assertion below is what holds on every platform.
|
|
643
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
644
|
+
/** One command-line past win32's 32,767-character cap (CreateProcess), where argv transport fails. */
|
|
645
|
+
const PAST_WIN32_ARGV_CAP = 40_000;
|
|
646
|
+
const long = 'x'.repeat(PAST_WIN32_ARGV_CAP);
|
|
647
|
+
const { status, stderr } = runWrapper({ tool_name: 'Bash', tool_input: { command: long } }, [], 'transport-shield');
|
|
648
|
+
expect(status).toBe(0);
|
|
649
|
+
expect(stderr).not.toContain('fail-closed');
|
|
650
|
+
expect(spawnedPayload()).toEqual({ tool: 'Bash', command: long, platform: process.platform });
|
|
651
|
+
});
|
|
652
|
+
it('transport-shield on a PowerShell envelope spawns the same shape with tool PowerShell', () => {
|
|
653
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
654
|
+
const { status } = runWrapper(PWSH_CMD, [], 'transport-shield');
|
|
655
|
+
expect(status).toBe(0);
|
|
656
|
+
expect(spawnedPayload()).toEqual({
|
|
657
|
+
tool: 'PowerShell',
|
|
658
|
+
command: 'Get-ChildItem',
|
|
659
|
+
platform: process.platform,
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
it('freeze-check is UNCHANGED: no subsystem → exit 0 without spawning', () => {
|
|
663
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
664
|
+
const { status } = runWrapper(EDIT_NO_SUBSYSTEM);
|
|
665
|
+
expect(status).toBe(0);
|
|
666
|
+
expect(stubArgv()).toBeNull();
|
|
667
|
+
});
|
|
668
|
+
it('freeze-check is UNCHANGED: a declared subsystem spawns the {subsystem} payload', () => {
|
|
669
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
670
|
+
const { status } = runWrapper(DECLARED);
|
|
671
|
+
expect(status).toBe(0);
|
|
672
|
+
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
|
|
673
|
+
expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
|
|
674
|
+
});
|
|
675
|
+
it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
|
|
676
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
677
|
+
const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
|
|
678
|
+
expect(status).toBe(2);
|
|
679
|
+
expect(stderr).toContain('no payload projection for event "nope"; failing closed.');
|
|
680
|
+
expect(stubArgv()).toBeNull();
|
|
681
|
+
});
|
|
682
|
+
it('an unprojectable --event fails closed even under --pilot (tier maps dispositions only)', () => {
|
|
683
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
684
|
+
const { status } = runWrapper(BASH_CMD, ['--pilot'], 'nope');
|
|
685
|
+
expect(status).toBe(2);
|
|
686
|
+
expect(stubArgv()).toBeNull();
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
// ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
|
|
690
|
+
//
|
|
691
|
+
// A `Bash|PowerShell`-matched gate applies to `pnpm install` / `pnpm build` —
|
|
692
|
+
// the very commands that CREATE the repo-local CLI — so a repo-local-ONLY
|
|
693
|
+
// resolution made the gate block its own bootstrap on a fresh clone, and block
|
|
694
|
+
// the cure its message named (`totem eject`, a Bash command). The wrapper now
|
|
695
|
+
// falls back to a `totem` on PATH (repo-local still FIRST) and fails closed
|
|
696
|
+
// only when NEITHER arm resolves — the #2799 ruling is kept, the self-block is
|
|
697
|
+
// not. Each test below drives the rendered wrapper with a CONTROLLED PATH and
|
|
698
|
+
// discriminates the fold: pre-fix, the first two exited through the no-CLI arm
|
|
699
|
+
// (there was no PATH probe at all) and the third's message named `totem eject`.
|
|
700
|
+
describe('gate-wrapper.cjs PATH fallback (mmnto-ai/totem#2822)', () => {
|
|
701
|
+
let cwd;
|
|
702
|
+
let pathDir;
|
|
703
|
+
/** The self-block case: a bootstrap command under the shell-matched gate. */
|
|
704
|
+
const BOOTSTRAP = { tool_name: 'Bash', tool_input: { command: 'pnpm install' } };
|
|
705
|
+
const ALLOW = { disposition: 'allow', reason: 'ok', provenance: {} };
|
|
706
|
+
beforeEach(() => {
|
|
707
|
+
cwd = makeTmpDir();
|
|
708
|
+
fs.mkdirSync(path.join(cwd, '.claude', 'hooks'), { recursive: true });
|
|
709
|
+
fs.writeFileSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'), CLAUDE_GATE_WRAPPER);
|
|
710
|
+
// A PATH entry shaped like an npm global prefix on win32: the `totem.cmd`
|
|
711
|
+
// shim's sibling `node_modules/@mmnto/cli/dist/index.js`. Deliberately NOT
|
|
712
|
+
// under `cwd/node_modules`, so the repo-local arm cannot resolve it.
|
|
713
|
+
pathDir = path.join(cwd, 'global-bin');
|
|
714
|
+
fs.mkdirSync(pathDir, { recursive: true });
|
|
715
|
+
});
|
|
716
|
+
afterEach(() => {
|
|
717
|
+
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
718
|
+
});
|
|
719
|
+
/**
|
|
720
|
+
* A CLI stub at `<dir>/node_modules/@mmnto/cli/dist/index.js` emitting the
|
|
721
|
+
* given verdict and exit code; returns the entry path the wrapper should
|
|
722
|
+
* resolve. It drains stdin (the `--payload -` channel) before exiting so the
|
|
723
|
+
* parent's `input` write never races an EPIPE into `result.error`.
|
|
724
|
+
*/
|
|
725
|
+
function writeCliUnder(dir, opts) {
|
|
726
|
+
const distDir = path.join(dir, 'node_modules', '@mmnto', 'cli', 'dist');
|
|
727
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
728
|
+
const out = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
|
|
729
|
+
const stub = [
|
|
730
|
+
'"use strict";',
|
|
731
|
+
'const fs = require("fs");',
|
|
732
|
+
`const out = ${JSON.stringify(out)};`,
|
|
733
|
+
'try { fs.readFileSync(0, "utf-8"); } catch { /* no stdin */ }',
|
|
734
|
+
'if (out) process.stdout.write(out + "\\n");',
|
|
735
|
+
`process.exit(${opts.exit ?? 0});`,
|
|
736
|
+
'',
|
|
737
|
+
].join('\n');
|
|
738
|
+
const entry = path.join(distDir, 'index.js');
|
|
739
|
+
fs.writeFileSync(entry, stub);
|
|
740
|
+
return entry;
|
|
741
|
+
}
|
|
742
|
+
/** Run the rendered wrapper in `cwd` with an exact PATH value. */
|
|
743
|
+
function runWithPath(envelope, pathValue, event = 'transport-shield') {
|
|
744
|
+
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
745
|
+
const res = spawnSync(process.execPath, [wrapperPath, '--event', event, '--strict'], {
|
|
746
|
+
cwd,
|
|
747
|
+
input: JSON.stringify(envelope),
|
|
748
|
+
encoding: 'utf-8',
|
|
749
|
+
timeout: 30000,
|
|
750
|
+
env: envWithPath(pathValue),
|
|
751
|
+
});
|
|
752
|
+
return { status: res.status, stderr: res.stderr ?? '' };
|
|
753
|
+
}
|
|
754
|
+
it('no repo-local CLI but a totem on PATH → the PATH arm evaluates (allow → exit 0)', () => {
|
|
755
|
+
writeCliUnder(pathDir, { verdict: ALLOW, exit: 0 });
|
|
756
|
+
const { status } = runWithPath(BOOTSTRAP, pathDir);
|
|
757
|
+
// Pre-fix this was exit 2: the wrapper never probed PATH, so a fresh clone's
|
|
758
|
+
// `pnpm install` was blocked by the gate it was about to install the CLI for.
|
|
759
|
+
expect(status).toBe(0);
|
|
760
|
+
});
|
|
761
|
+
it('the PATH CLI failing → exit 2 fail-closed, disclosing which CLI evaluated (basenames only)', () => {
|
|
762
|
+
// A CLI older than 2.2.0 has no `gate check --payload -` and lands exactly
|
|
763
|
+
// here (unknown option → non-zero exit). Fail-closed is UNCHANGED; the
|
|
764
|
+
// added line names the arm so the operator knows what to update.
|
|
765
|
+
const entry = writeCliUnder(pathDir, { exit: 1 });
|
|
766
|
+
const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
|
|
767
|
+
expect(status).toBe(2);
|
|
768
|
+
expect(stderr).toMatch(/fail-closed/i);
|
|
769
|
+
expect(stderr).toContain('evaluated by the PATH CLI at');
|
|
770
|
+
// The rendering is NON-resolvable: the PATH entry's basename plus the fixed
|
|
771
|
+
// package suffix. Hook stderr lands in transcripts that get pasted into
|
|
772
|
+
// issues, so a user-profile path must never ride along.
|
|
773
|
+
expect(stderr).toContain('evaluated by the PATH CLI at global-bin/node_modules/@mmnto/cli/dist/index.js');
|
|
774
|
+
expect(stderr).not.toContain(entry);
|
|
775
|
+
expect(stderr).not.toContain(cwd);
|
|
776
|
+
});
|
|
777
|
+
it('no CLI anywhere → exit 2 naming exits this gate does NOT block', () => {
|
|
778
|
+
const { status, stderr } = runWithPath(BOOTSTRAP, '');
|
|
779
|
+
expect(status).toBe(2);
|
|
780
|
+
expect(stderr).toMatch(/failing closed/i);
|
|
781
|
+
expect(stderr).toContain('terminal OUTSIDE the harness');
|
|
782
|
+
expect(stderr).toContain('.claude/settings.json');
|
|
783
|
+
// The cure must not be a command this very gate blocks (the pre-fix message
|
|
784
|
+
// said "Reinstall totem or run `totem eject`" — both Bash).
|
|
785
|
+
expect(stderr).not.toContain('totem eject');
|
|
786
|
+
});
|
|
787
|
+
it('the repo-local CLI still wins when both resolve (pinned beats ambient)', () => {
|
|
788
|
+
// ADR-072 §2 / Tenet 14: the PATH arm is a FALLBACK, never a preference.
|
|
789
|
+
// The PATH stub would fail closed if the cascade ever inverted.
|
|
790
|
+
writeCliUnder(cwd, { verdict: ALLOW, exit: 0 });
|
|
791
|
+
writeCliUnder(pathDir, { exit: 1 });
|
|
792
|
+
const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
|
|
793
|
+
expect(status).toBe(0);
|
|
794
|
+
expect(stderr).not.toContain('PATH CLI');
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
// ─── Install-time disclosure of the shell-matcher property (#2822) ─────
|
|
798
|
+
describe('gate install discloses a Bash-matched gate applies to bootstrap', () => {
|
|
799
|
+
let cwd;
|
|
800
|
+
let originalCwd;
|
|
801
|
+
let lines;
|
|
802
|
+
let errorSpy;
|
|
803
|
+
beforeEach(() => {
|
|
804
|
+
cwd = makeTmpDir();
|
|
805
|
+
originalCwd = process.cwd();
|
|
806
|
+
process.chdir(cwd);
|
|
807
|
+
lines = [];
|
|
808
|
+
// `log.*` writes through console.error (ui.ts); capture the rendered lines.
|
|
809
|
+
// Restored per-test rather than via restoreAllMocks, which would also clear
|
|
810
|
+
// the module-level install-hooks seam this file mocks.
|
|
811
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
812
|
+
lines.push(args.map((a) => String(a)).join(' '));
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
afterEach(() => {
|
|
816
|
+
errorSpy.mockRestore();
|
|
817
|
+
process.chdir(originalCwd);
|
|
818
|
+
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
819
|
+
});
|
|
820
|
+
it('prints the line for a Bash|PowerShell gate and NOT for a Write|Edit gate', async () => {
|
|
821
|
+
await gateInstallCommand({ name: 'transport-shield' });
|
|
822
|
+
const shield = lines.join('\n');
|
|
823
|
+
expect(shield).toContain('transport-shield matches Bash|PowerShell:');
|
|
824
|
+
expect(shield).toContain("fresh clone's bootstrap commands (your package manager's install and build; here pnpm install and pnpm build)");
|
|
825
|
+
expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
826
|
+
lines = [];
|
|
827
|
+
await gateInstallCommand({ name: 'freeze-check' });
|
|
828
|
+
const freeze = lines.join('\n');
|
|
829
|
+
// It DID install (the control), and said nothing about bootstrap: the
|
|
830
|
+
// property is the shell matcher's, not every gate's.
|
|
831
|
+
expect(freeze).toContain('freeze-check');
|
|
832
|
+
expect(freeze).not.toContain('bootstrap');
|
|
833
|
+
expect(freeze).not.toContain('matches Bash|PowerShell');
|
|
834
|
+
// A third install in the SAME cwd: transport-shield is already present, so
|
|
835
|
+
// the merge is a no-op — and the disclosure still prints beside it. The
|
|
836
|
+
// property belongs to the installed gate, not to the write that installed
|
|
837
|
+
// it, and a re-install is where an operator most often reads the output.
|
|
838
|
+
lines = [];
|
|
839
|
+
await gateInstallCommand({ name: 'transport-shield' });
|
|
840
|
+
const again = lines.join('\n');
|
|
841
|
+
expect(again).toContain('already present — no change');
|
|
842
|
+
expect(again).toContain('transport-shield matches Bash|PowerShell:');
|
|
843
|
+
expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
844
|
+
});
|
|
420
845
|
});
|
|
421
846
|
// ─── eject parity ──────────────────────────────────────────────────────
|
|
422
847
|
describe('eject removes the gate entry (parity with install)', () => {
|
|
@@ -433,7 +858,7 @@ describe('eject removes the gate entry (parity with install)', () => {
|
|
|
433
858
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
434
859
|
});
|
|
435
860
|
it('scrubs the gate PreToolUse entry and the wrapper script on eject', async () => {
|
|
436
|
-
installGates(cwd, [
|
|
861
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
437
862
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
438
863
|
expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
|
|
439
864
|
await ejectCommand({ force: true });
|
|
@@ -449,7 +874,7 @@ describe('eject removes the gate entry (parity with install)', () => {
|
|
|
449
874
|
fs.writeFileSync(path.join(cwd, '.claude', 'settings.json'), JSON.stringify({
|
|
450
875
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'mine' }] }] },
|
|
451
876
|
}));
|
|
452
|
-
installGates(cwd, [
|
|
877
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
453
878
|
await ejectCommand({ force: true });
|
|
454
879
|
expect(fs.existsSync(path.join(cwd, '.claude', 'settings.json'))).toBe(true);
|
|
455
880
|
const entries = preToolUseEntries(cwd);
|
|
@@ -475,12 +900,45 @@ describe('init --gates= routes through the shared installer', () => {
|
|
|
475
900
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
476
901
|
expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
|
|
477
902
|
});
|
|
478
|
-
it('init --gates=all installs every known gate', async () => {
|
|
903
|
+
it('init --gates=all installs every known gate under ITS registry matcher', async () => {
|
|
479
904
|
await initCommand({ bare: true, gates: 'all' });
|
|
480
|
-
for (const event of
|
|
905
|
+
for (const { event, matcher } of knownGates()) {
|
|
481
906
|
expect(gateEntryCount(cwd, event)).toBe(1);
|
|
907
|
+
expect(matchersFor(cwd, event)).toEqual([matcher]);
|
|
482
908
|
}
|
|
483
909
|
});
|
|
910
|
+
it('init --gates=transport-shield installs it under Bash|PowerShell (the pair, not a default)', async () => {
|
|
911
|
+
await initCommand({ bare: true, gates: 'transport-shield' });
|
|
912
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
913
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
914
|
+
expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
|
|
915
|
+
});
|
|
916
|
+
it('init --gates= discloses the bootstrap property for a Bash-matched gate, not for Write|Edit', async () => {
|
|
917
|
+
// The verb and init share `installGates` but NOT their output: init prints
|
|
918
|
+
// its own summary rows, so before mmnto-ai/totem#2822's shared helper the
|
|
919
|
+
// disclosure existed only on the verb and `--gates=` dropped it silently.
|
|
920
|
+
const shieldLines = [];
|
|
921
|
+
const shieldSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
922
|
+
shieldLines.push(args.map((a) => String(a)).join(' '));
|
|
923
|
+
});
|
|
924
|
+
await initCommand({ bare: true, gates: 'transport-shield' });
|
|
925
|
+
shieldSpy.mockRestore();
|
|
926
|
+
const shield = shieldLines.join('\n');
|
|
927
|
+
expect(shield).toContain('transport-shield matches Bash|PowerShell:');
|
|
928
|
+
expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
929
|
+
const freezeLines = [];
|
|
930
|
+
const freezeSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
931
|
+
freezeLines.push(args.map((a) => String(a)).join(' '));
|
|
932
|
+
});
|
|
933
|
+
await initCommand({ bare: true, gates: 'freeze-check' });
|
|
934
|
+
freezeSpy.mockRestore();
|
|
935
|
+
const freeze = freezeLines.join('\n');
|
|
936
|
+
// The control: freeze-check installs (a row names it) and says nothing
|
|
937
|
+
// about bootstrap — the property is the shell matcher's, not every gate's.
|
|
938
|
+
expect(freeze).toContain('freeze-check');
|
|
939
|
+
expect(freeze).not.toContain('bootstrap');
|
|
940
|
+
expect(freeze).not.toContain('matches Bash|PowerShell');
|
|
941
|
+
});
|
|
484
942
|
it('init --gates= with an unknown member fails loud', async () => {
|
|
485
943
|
await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
|
|
486
944
|
});
|