@soleri/cli 9.4.0 → 9.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/hooks.js +126 -0
- package/dist/commands/hooks.js.map +1 -1
- package/dist/commands/install.js +5 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/hook-packs/converter/README.md +99 -0
- package/dist/hook-packs/converter/template.d.ts +36 -0
- package/dist/hook-packs/converter/template.js +127 -0
- package/dist/hook-packs/converter/template.js.map +1 -0
- package/dist/hook-packs/converter/template.test.ts +133 -0
- package/dist/hook-packs/converter/template.ts +163 -0
- package/dist/hook-packs/flock-guard/README.md +65 -0
- package/dist/hook-packs/flock-guard/manifest.json +36 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/dist/hook-packs/full/manifest.json +8 -1
- package/dist/hook-packs/graduation.d.ts +11 -0
- package/dist/hook-packs/graduation.js +48 -0
- package/dist/hook-packs/graduation.js.map +1 -0
- package/dist/hook-packs/graduation.ts +65 -0
- package/dist/hook-packs/installer.js +3 -1
- package/dist/hook-packs/installer.js.map +1 -1
- package/dist/hook-packs/installer.ts +3 -1
- package/dist/hook-packs/marketing-research/README.md +37 -0
- package/dist/hook-packs/marketing-research/manifest.json +24 -0
- package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/dist/hook-packs/registry.d.ts +1 -0
- package/dist/hook-packs/registry.js +14 -4
- package/dist/hook-packs/registry.js.map +1 -1
- package/dist/hook-packs/registry.ts +18 -4
- package/dist/hook-packs/safety/README.md +50 -0
- package/dist/hook-packs/safety/manifest.json +23 -0
- package/{src/hook-packs/yolo-safety → dist/hook-packs/safety}/scripts/anti-deletion.sh +7 -1
- package/dist/hook-packs/validator.d.ts +32 -0
- package/dist/hook-packs/validator.js +126 -0
- package/dist/hook-packs/validator.js.map +1 -0
- package/dist/hook-packs/validator.ts +158 -0
- package/dist/hook-packs/yolo-safety/manifest.json +3 -19
- package/package.json +1 -1
- package/src/__tests__/flock-guard.test.ts +225 -0
- package/src/__tests__/graduation.test.ts +199 -0
- package/src/__tests__/hook-packs.test.ts +44 -19
- package/src/__tests__/hooks-convert.test.ts +342 -0
- package/src/__tests__/validator.test.ts +265 -0
- package/src/commands/hooks.ts +172 -0
- package/src/commands/install.ts +6 -0
- package/src/hook-packs/converter/README.md +99 -0
- package/src/hook-packs/converter/template.test.ts +133 -0
- package/src/hook-packs/converter/template.ts +163 -0
- package/src/hook-packs/flock-guard/README.md +65 -0
- package/src/hook-packs/flock-guard/manifest.json +36 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/src/hook-packs/full/manifest.json +8 -1
- package/src/hook-packs/graduation.ts +65 -0
- package/src/hook-packs/installer.ts +3 -1
- package/src/hook-packs/marketing-research/README.md +37 -0
- package/src/hook-packs/marketing-research/manifest.json +24 -0
- package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/src/hook-packs/registry.ts +18 -4
- package/src/hook-packs/safety/README.md +50 -0
- package/src/hook-packs/safety/manifest.json +23 -0
- package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/src/hook-packs/validator.ts +158 -0
- package/src/hook-packs/yolo-safety/manifest.json +3 -19
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
const SCRIPTS_DIR = join(__dirname, '..', 'hook-packs', 'flock-guard', 'scripts');
|
|
9
|
+
const PRE_SCRIPT = join(SCRIPTS_DIR, 'flock-guard-pre.sh');
|
|
10
|
+
const POST_SCRIPT = join(SCRIPTS_DIR, 'flock-guard-post.sh');
|
|
11
|
+
|
|
12
|
+
// The scripts use `git rev-parse --show-toplevel` which resolves to the repo root.
|
|
13
|
+
// Compute the same hash the scripts will produce.
|
|
14
|
+
const PROJECT_ROOT = execSync('git rev-parse --show-toplevel', {
|
|
15
|
+
cwd: join(__dirname, '..'),
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
}).trim();
|
|
18
|
+
const PROJECT_HASH = execSync(`printf '%s' '${PROJECT_ROOT}' | shasum | cut -c1-8`, {
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
}).trim();
|
|
21
|
+
// Scripts use ${TMPDIR:-${TEMP:-/tmp}} — match that resolution for the test environment
|
|
22
|
+
const LOCK_DIR = `${process.env.TMPDIR || process.env.TEMP || tmpdir()}/soleri-guard-${PROJECT_HASH}.lock`;
|
|
23
|
+
|
|
24
|
+
function makePayload(command: string): string {
|
|
25
|
+
return JSON.stringify({ tool_name: 'Bash', tool_input: { command } });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runPre(
|
|
29
|
+
command: string,
|
|
30
|
+
env?: Record<string, string>,
|
|
31
|
+
): { stdout: string; exitCode: number } {
|
|
32
|
+
try {
|
|
33
|
+
const stdout = execSync(
|
|
34
|
+
`printf '%s' '${escapeShell(makePayload(command))}' | sh '${PRE_SCRIPT}'`,
|
|
35
|
+
{
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
stdio: 'pipe',
|
|
38
|
+
cwd: PROJECT_ROOT,
|
|
39
|
+
env: { ...process.env, ...env },
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
return { stdout, exitCode: 0 };
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runPost(
|
|
49
|
+
command: string,
|
|
50
|
+
env?: Record<string, string>,
|
|
51
|
+
): { stdout: string; exitCode: number } {
|
|
52
|
+
try {
|
|
53
|
+
const stdout = execSync(
|
|
54
|
+
`printf '%s' '${escapeShell(makePayload(command))}' | sh '${POST_SCRIPT}'`,
|
|
55
|
+
{
|
|
56
|
+
encoding: 'utf-8',
|
|
57
|
+
stdio: 'pipe',
|
|
58
|
+
cwd: PROJECT_ROOT,
|
|
59
|
+
env: { ...process.env, ...env },
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
return { stdout, exitCode: 0 };
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function escapeShell(s: string): string {
|
|
69
|
+
// Escape single quotes for use inside single-quoted shell string
|
|
70
|
+
return s.replace(/'/g, "'\\''");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cleanLock(): void {
|
|
74
|
+
if (existsSync(LOCK_DIR)) {
|
|
75
|
+
rmSync(LOCK_DIR, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('flock-guard hook pack', () => {
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
cleanLock();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 1. Pre: allows non-lockfile commands
|
|
85
|
+
it('pre: allows non-lockfile commands (exit 0, no output)', () => {
|
|
86
|
+
const { stdout, exitCode } = runPre('echo hello');
|
|
87
|
+
expect(exitCode).toBe(0);
|
|
88
|
+
expect(stdout.trim()).toBe('');
|
|
89
|
+
expect(existsSync(LOCK_DIR)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 2. Pre: acquires lock on npm install
|
|
93
|
+
it('pre: acquires lock on npm install', () => {
|
|
94
|
+
const sessionId = `test-acquire-${Date.now()}`;
|
|
95
|
+
const { exitCode } = runPre('npm install', { CLAUDE_SESSION_ID: sessionId });
|
|
96
|
+
expect(exitCode).toBe(0);
|
|
97
|
+
expect(existsSync(LOCK_DIR)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 3. Pre: lock dir contains valid JSON with agentId and timestamp
|
|
101
|
+
it('pre: lock dir contains valid JSON with agentId and timestamp', () => {
|
|
102
|
+
const sessionId = `test-json-${Date.now()}`;
|
|
103
|
+
runPre('npm install', { CLAUDE_SESSION_ID: sessionId });
|
|
104
|
+
|
|
105
|
+
const lockJson = JSON.parse(readFileSync(join(LOCK_DIR, 'lock.json'), 'utf-8'));
|
|
106
|
+
expect(lockJson).toHaveProperty('agentId', sessionId);
|
|
107
|
+
expect(lockJson).toHaveProperty('timestamp');
|
|
108
|
+
expect(typeof lockJson.timestamp).toBe('number');
|
|
109
|
+
expect(lockJson.timestamp).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 4. Post: releases lock after npm install
|
|
113
|
+
it('post: releases lock after npm install', () => {
|
|
114
|
+
const sessionId = `test-release-${Date.now()}`;
|
|
115
|
+
runPre('npm install', { CLAUDE_SESSION_ID: sessionId });
|
|
116
|
+
expect(existsSync(LOCK_DIR)).toBe(true);
|
|
117
|
+
|
|
118
|
+
const { exitCode } = runPost('npm install', { CLAUDE_SESSION_ID: sessionId });
|
|
119
|
+
expect(exitCode).toBe(0);
|
|
120
|
+
expect(existsSync(LOCK_DIR)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 5. Pre: blocks when lock held by another agent
|
|
124
|
+
it('pre: blocks when lock held by another agent', () => {
|
|
125
|
+
// Manually create lock with a different agentId
|
|
126
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
127
|
+
const now = Math.floor(Date.now() / 1000);
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(LOCK_DIR, 'lock.json'),
|
|
130
|
+
JSON.stringify({ agentId: 'other-agent-999', timestamp: now, command: 'npm install' }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const mySession = `test-blocked-${Date.now()}`;
|
|
134
|
+
const { stdout, exitCode } = runPre('npm install', { CLAUDE_SESSION_ID: mySession });
|
|
135
|
+
|
|
136
|
+
// Script exits 0 but outputs JSON with continue: false
|
|
137
|
+
expect(exitCode).toBe(0);
|
|
138
|
+
const output = JSON.parse(stdout.trim());
|
|
139
|
+
expect(output.continue).toBe(false);
|
|
140
|
+
expect(output.stopReason).toContain('BLOCKED');
|
|
141
|
+
expect(output.stopReason).toContain('other-agent-999');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 6. Pre: cleans stale lock (timestamp older than 30s)
|
|
145
|
+
it('pre: cleans stale lock and acquires', () => {
|
|
146
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
147
|
+
const staleTime = Math.floor(Date.now() / 1000) - 60; // 60s ago
|
|
148
|
+
writeFileSync(
|
|
149
|
+
join(LOCK_DIR, 'lock.json'),
|
|
150
|
+
JSON.stringify({ agentId: 'stale-agent', timestamp: staleTime, command: 'npm install' }),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const mySession = `test-stale-${Date.now()}`;
|
|
154
|
+
const { stdout, exitCode } = runPre('npm install', { CLAUDE_SESSION_ID: mySession });
|
|
155
|
+
expect(exitCode).toBe(0);
|
|
156
|
+
// Should not contain "continue: false" — lock was stale and cleaned
|
|
157
|
+
if (stdout.trim()) {
|
|
158
|
+
const output = JSON.parse(stdout.trim());
|
|
159
|
+
expect(output.continue).not.toBe(false);
|
|
160
|
+
}
|
|
161
|
+
// Lock should now be held by our session
|
|
162
|
+
expect(existsSync(LOCK_DIR)).toBe(true);
|
|
163
|
+
const lockJson = JSON.parse(readFileSync(join(LOCK_DIR, 'lock.json'), 'utf-8'));
|
|
164
|
+
expect(lockJson.agentId).toBe(mySession);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 7. Pre: allows same agent reentry
|
|
168
|
+
it('pre: allows same agent reentry', () => {
|
|
169
|
+
const sessionId = `test-reentry-${Date.now()}`;
|
|
170
|
+
const env = { CLAUDE_SESSION_ID: sessionId };
|
|
171
|
+
|
|
172
|
+
// First acquisition
|
|
173
|
+
const first = runPre('npm install', env);
|
|
174
|
+
expect(first.exitCode).toBe(0);
|
|
175
|
+
expect(existsSync(LOCK_DIR)).toBe(true);
|
|
176
|
+
|
|
177
|
+
// Second acquisition with same session — should succeed (reentry)
|
|
178
|
+
const second = runPre('npm install', env);
|
|
179
|
+
expect(second.exitCode).toBe(0);
|
|
180
|
+
// No "continue: false" in output
|
|
181
|
+
if (second.stdout.trim()) {
|
|
182
|
+
const output = JSON.parse(second.stdout.trim());
|
|
183
|
+
expect(output.continue).not.toBe(false);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 8. Post: only releases own lock (does not release lock held by other agent)
|
|
188
|
+
it('post: only releases own lock — does not remove lock held by another agent', () => {
|
|
189
|
+
// Create lock with a different agent
|
|
190
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
191
|
+
const now = Math.floor(Date.now() / 1000);
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(LOCK_DIR, 'lock.json'),
|
|
194
|
+
JSON.stringify({ agentId: 'other-agent-777', timestamp: now, command: 'npm install' }),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const mySession = `test-norelease-${Date.now()}`;
|
|
198
|
+
const { exitCode } = runPost('npm install', { CLAUDE_SESSION_ID: mySession });
|
|
199
|
+
expect(exitCode).toBe(0);
|
|
200
|
+
// Lock dir should still exist — we don't own it
|
|
201
|
+
expect(existsSync(LOCK_DIR)).toBe(true);
|
|
202
|
+
const lockJson = JSON.parse(readFileSync(join(LOCK_DIR, 'lock.json'), 'utf-8'));
|
|
203
|
+
expect(lockJson.agentId).toBe('other-agent-777');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// 9. Pre: detects other lockfile commands (yarn, pnpm, cargo, pip)
|
|
207
|
+
it('pre: detects yarn, pnpm install, cargo build, pip install', () => {
|
|
208
|
+
const commands = ['yarn', 'yarn install', 'pnpm install', 'cargo build', 'pip install'];
|
|
209
|
+
for (const cmd of commands) {
|
|
210
|
+
cleanLock();
|
|
211
|
+
const sessionId = `test-detect-${Date.now()}`;
|
|
212
|
+
const { exitCode } = runPre(cmd, { CLAUDE_SESSION_ID: sessionId });
|
|
213
|
+
expect(exitCode).toBe(0);
|
|
214
|
+
expect(existsSync(LOCK_DIR)).toBe(true);
|
|
215
|
+
cleanLock();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// 10. Post: ignores non-lockfile commands
|
|
220
|
+
it('post: ignores non-lockfile commands (no crash, no lock interaction)', () => {
|
|
221
|
+
const { exitCode, stdout } = runPost('echo hello');
|
|
222
|
+
expect(exitCode).toBe(0);
|
|
223
|
+
expect(stdout.trim()).toBe('');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
// Mock getPack before importing graduation
|
|
7
|
+
const mockGetPack = vi.fn();
|
|
8
|
+
vi.mock('../hook-packs/registry.js', () => ({
|
|
9
|
+
getPack: (...args: unknown[]) => mockGetPack(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { promotePack, demotePack } from '../hook-packs/graduation.js';
|
|
13
|
+
|
|
14
|
+
describe('graduation — promote/demote action levels', () => {
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
|
|
17
|
+
function createPackDir(actionLevel?: string): string {
|
|
18
|
+
const packDir = join(tempDir, 'test-pack');
|
|
19
|
+
mkdirSync(packDir, { recursive: true });
|
|
20
|
+
const manifest: Record<string, unknown> = {
|
|
21
|
+
name: 'test-pack',
|
|
22
|
+
description: 'A test hook pack',
|
|
23
|
+
hooks: ['PreToolUse'],
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
};
|
|
26
|
+
if (actionLevel !== undefined) {
|
|
27
|
+
manifest.actionLevel = actionLevel;
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
30
|
+
return packDir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readManifest(packDir: string): Record<string, unknown> {
|
|
34
|
+
return JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tempDir = join(tmpdir(), `graduation-test-${Date.now()}`);
|
|
39
|
+
mkdirSync(tempDir, { recursive: true });
|
|
40
|
+
mockGetPack.mockReset();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('promotePack', () => {
|
|
48
|
+
it('should promote remind → warn', () => {
|
|
49
|
+
const packDir = createPackDir('remind');
|
|
50
|
+
mockGetPack.mockReturnValue({
|
|
51
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
52
|
+
dir: packDir,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const result = promotePack('test-pack');
|
|
56
|
+
|
|
57
|
+
expect(result.previousLevel).toBe('remind');
|
|
58
|
+
expect(result.newLevel).toBe('warn');
|
|
59
|
+
expect(readManifest(packDir).actionLevel).toBe('warn');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should promote warn → block', () => {
|
|
63
|
+
const packDir = createPackDir('warn');
|
|
64
|
+
mockGetPack.mockReturnValue({
|
|
65
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
66
|
+
dir: packDir,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result = promotePack('test-pack');
|
|
70
|
+
|
|
71
|
+
expect(result.previousLevel).toBe('warn');
|
|
72
|
+
expect(result.newLevel).toBe('block');
|
|
73
|
+
expect(readManifest(packDir).actionLevel).toBe('block');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should throw at maximum level (block)', () => {
|
|
77
|
+
const packDir = createPackDir('block');
|
|
78
|
+
mockGetPack.mockReturnValue({
|
|
79
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
80
|
+
dir: packDir,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(() => promotePack('test-pack')).toThrow('already at maximum level: block');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should default to remind when actionLevel is missing', () => {
|
|
87
|
+
const packDir = createPackDir();
|
|
88
|
+
mockGetPack.mockReturnValue({
|
|
89
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
90
|
+
dir: packDir,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = promotePack('test-pack');
|
|
94
|
+
|
|
95
|
+
expect(result.previousLevel).toBe('remind');
|
|
96
|
+
expect(result.newLevel).toBe('warn');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should throw for unknown pack', () => {
|
|
100
|
+
mockGetPack.mockReturnValue(null);
|
|
101
|
+
|
|
102
|
+
expect(() => promotePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('demotePack', () => {
|
|
107
|
+
it('should demote block → warn', () => {
|
|
108
|
+
const packDir = createPackDir('block');
|
|
109
|
+
mockGetPack.mockReturnValue({
|
|
110
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
111
|
+
dir: packDir,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = demotePack('test-pack');
|
|
115
|
+
|
|
116
|
+
expect(result.previousLevel).toBe('block');
|
|
117
|
+
expect(result.newLevel).toBe('warn');
|
|
118
|
+
expect(readManifest(packDir).actionLevel).toBe('warn');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should demote warn → remind', () => {
|
|
122
|
+
const packDir = createPackDir('warn');
|
|
123
|
+
mockGetPack.mockReturnValue({
|
|
124
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
125
|
+
dir: packDir,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const result = demotePack('test-pack');
|
|
129
|
+
|
|
130
|
+
expect(result.previousLevel).toBe('warn');
|
|
131
|
+
expect(result.newLevel).toBe('remind');
|
|
132
|
+
expect(readManifest(packDir).actionLevel).toBe('remind');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should throw at minimum level (remind)', () => {
|
|
136
|
+
const packDir = createPackDir('remind');
|
|
137
|
+
mockGetPack.mockReturnValue({
|
|
138
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
139
|
+
dir: packDir,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(() => demotePack('test-pack')).toThrow('already at minimum level: remind');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should throw for unknown pack', () => {
|
|
146
|
+
mockGetPack.mockReturnValue(null);
|
|
147
|
+
|
|
148
|
+
expect(() => demotePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('manifest persistence', () => {
|
|
153
|
+
it('should write updated manifest to disk', () => {
|
|
154
|
+
const packDir = createPackDir('remind');
|
|
155
|
+
mockGetPack.mockReturnValue({
|
|
156
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
157
|
+
dir: packDir,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
promotePack('test-pack');
|
|
161
|
+
|
|
162
|
+
const manifest = readManifest(packDir);
|
|
163
|
+
expect(manifest.actionLevel).toBe('warn');
|
|
164
|
+
expect(manifest.name).toBe('test-pack');
|
|
165
|
+
expect(manifest.description).toBe('A test hook pack');
|
|
166
|
+
expect(manifest.version).toBe('1.0.0');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should preserve all manifest fields after promotion', () => {
|
|
170
|
+
const packDir = createPackDir('remind');
|
|
171
|
+
const originalManifest = readManifest(packDir);
|
|
172
|
+
mockGetPack.mockReturnValue({
|
|
173
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
174
|
+
dir: packDir,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
promotePack('test-pack');
|
|
178
|
+
|
|
179
|
+
const updatedManifest = readManifest(packDir);
|
|
180
|
+
expect(updatedManifest.name).toBe(originalManifest.name);
|
|
181
|
+
expect(updatedManifest.description).toBe(originalManifest.description);
|
|
182
|
+
expect(updatedManifest.hooks).toEqual(originalManifest.hooks);
|
|
183
|
+
expect(updatedManifest.version).toBe(originalManifest.version);
|
|
184
|
+
expect(updatedManifest.actionLevel).toBe('warn');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should return the manifest path in the result', () => {
|
|
188
|
+
const packDir = createPackDir('remind');
|
|
189
|
+
mockGetPack.mockReturnValue({
|
|
190
|
+
manifest: JSON.parse(readFileSync(join(packDir, 'manifest.json'), 'utf-8')),
|
|
191
|
+
dir: packDir,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = promotePack('test-pack');
|
|
195
|
+
|
|
196
|
+
expect(result.manifestPath).toBe(join(packDir, 'manifest.json'));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -23,15 +23,18 @@ describe('hook-packs', () => {
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
describe('registry', () => {
|
|
26
|
-
it('should list all
|
|
26
|
+
it('should list all 9 built-in packs', () => {
|
|
27
27
|
const packs = listPacks();
|
|
28
|
-
expect(packs.length).toBe(
|
|
28
|
+
expect(packs.length).toBe(9);
|
|
29
29
|
const names = packs.map((p) => p.name).sort();
|
|
30
30
|
expect(names).toEqual([
|
|
31
31
|
'a11y',
|
|
32
32
|
'clean-commits',
|
|
33
33
|
'css-discipline',
|
|
34
|
+
'flock-guard',
|
|
34
35
|
'full',
|
|
36
|
+
'marketing-research',
|
|
37
|
+
'safety',
|
|
35
38
|
'typescript-safety',
|
|
36
39
|
'yolo-safety',
|
|
37
40
|
]);
|
|
@@ -49,7 +52,7 @@ describe('hook-packs', () => {
|
|
|
49
52
|
expect(getPack('nonexistent')).toBeNull();
|
|
50
53
|
});
|
|
51
54
|
|
|
52
|
-
it('should return full pack with composedFrom including yolo-safety', () => {
|
|
55
|
+
it('should return full pack with composedFrom including safety and yolo-safety', () => {
|
|
53
56
|
const pack = getPack('full');
|
|
54
57
|
expect(pack).not.toBeNull();
|
|
55
58
|
expect(pack!.manifest.composedFrom).toEqual([
|
|
@@ -57,6 +60,7 @@ describe('hook-packs', () => {
|
|
|
57
60
|
'a11y',
|
|
58
61
|
'css-discipline',
|
|
59
62
|
'clean-commits',
|
|
63
|
+
'safety',
|
|
60
64
|
'yolo-safety',
|
|
61
65
|
]);
|
|
62
66
|
expect(pack!.manifest.hooks).toHaveLength(8);
|
|
@@ -64,19 +68,29 @@ describe('hook-packs', () => {
|
|
|
64
68
|
|
|
65
69
|
it('should return empty installed packs when none installed', () => {
|
|
66
70
|
const installed = getInstalledPacks();
|
|
67
|
-
expect(installed.filter((p) => p !== 'yolo-safety')).toEqual([]);
|
|
71
|
+
expect(installed.filter((p) => p !== 'yolo-safety' && p !== 'safety')).toEqual([]);
|
|
68
72
|
});
|
|
69
73
|
|
|
70
|
-
it('should get
|
|
71
|
-
const pack = getPack('
|
|
74
|
+
it('should get safety pack with scripts and lifecycleHooks', () => {
|
|
75
|
+
const pack = getPack('safety');
|
|
72
76
|
expect(pack).not.toBeNull();
|
|
73
|
-
expect(pack!.manifest.name).toBe('
|
|
77
|
+
expect(pack!.manifest.name).toBe('safety');
|
|
74
78
|
expect(pack!.manifest.hooks).toEqual([]);
|
|
75
79
|
expect(pack!.manifest.scripts).toHaveLength(1);
|
|
76
80
|
expect(pack!.manifest.scripts![0].name).toBe('anti-deletion');
|
|
77
81
|
expect(pack!.manifest.lifecycleHooks).toHaveLength(1);
|
|
78
82
|
expect(pack!.manifest.lifecycleHooks![0].event).toBe('PreToolUse');
|
|
79
83
|
});
|
|
84
|
+
|
|
85
|
+
it('should get yolo-safety pack as composed from safety', () => {
|
|
86
|
+
const pack = getPack('yolo-safety');
|
|
87
|
+
expect(pack).not.toBeNull();
|
|
88
|
+
expect(pack!.manifest.name).toBe('yolo-safety');
|
|
89
|
+
expect(pack!.manifest.hooks).toEqual([]);
|
|
90
|
+
expect(pack!.manifest.composedFrom).toEqual(['safety']);
|
|
91
|
+
expect(pack!.manifest.scripts).toBeUndefined();
|
|
92
|
+
expect(pack!.manifest.lifecycleHooks).toBeUndefined();
|
|
93
|
+
});
|
|
80
94
|
});
|
|
81
95
|
|
|
82
96
|
describe('installer', () => {
|
|
@@ -150,8 +164,8 @@ describe('hook-packs', () => {
|
|
|
150
164
|
expect(() => removePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
|
|
151
165
|
});
|
|
152
166
|
|
|
153
|
-
it('should install
|
|
154
|
-
const result = installPack('
|
|
167
|
+
it('should install safety pack with scripts and lifecycle hooks', () => {
|
|
168
|
+
const result = installPack('safety');
|
|
155
169
|
expect(result.installed).toEqual([]);
|
|
156
170
|
expect(result.scripts).toHaveLength(1);
|
|
157
171
|
expect(result.scripts[0]).toBe('hooks/anti-deletion.sh');
|
|
@@ -163,12 +177,12 @@ describe('hook-packs', () => {
|
|
|
163
177
|
expect(settings.hooks).toBeDefined();
|
|
164
178
|
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
|
165
179
|
expect(settings.hooks.PreToolUse[0].command).toBe('sh ~/.claude/hooks/anti-deletion.sh');
|
|
166
|
-
expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('
|
|
180
|
+
expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('safety');
|
|
167
181
|
});
|
|
168
182
|
|
|
169
|
-
it('should remove
|
|
170
|
-
installPack('
|
|
171
|
-
const result = removePack('
|
|
183
|
+
it('should remove safety pack including scripts and lifecycle hooks', () => {
|
|
184
|
+
installPack('safety');
|
|
185
|
+
const result = removePack('safety');
|
|
172
186
|
expect(result.scripts).toHaveLength(1);
|
|
173
187
|
expect(result.lifecycleHooks).toHaveLength(1);
|
|
174
188
|
const claudeDir = join(tempHome, '.claude');
|
|
@@ -177,14 +191,24 @@ describe('hook-packs', () => {
|
|
|
177
191
|
expect(settings.hooks.PreToolUse).toBeUndefined();
|
|
178
192
|
});
|
|
179
193
|
|
|
180
|
-
it('should be idempotent for
|
|
181
|
-
installPack('
|
|
182
|
-
const result2 = installPack('
|
|
194
|
+
it('should be idempotent for safety lifecycle hooks', () => {
|
|
195
|
+
installPack('safety');
|
|
196
|
+
const result2 = installPack('safety');
|
|
183
197
|
expect(result2.lifecycleHooks).toEqual([]);
|
|
184
198
|
const claudeDir = join(tempHome, '.claude');
|
|
185
199
|
const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
|
|
186
200
|
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
|
187
201
|
});
|
|
202
|
+
|
|
203
|
+
it('should install yolo-safety via composition from safety', () => {
|
|
204
|
+
const result = installPack('yolo-safety');
|
|
205
|
+
// yolo-safety composes from safety — script and lifecycle come from safety
|
|
206
|
+
expect(result.scripts).toHaveLength(1);
|
|
207
|
+
expect(result.scripts[0]).toBe('hooks/anti-deletion.sh');
|
|
208
|
+
expect(result.lifecycleHooks).toHaveLength(1);
|
|
209
|
+
const claudeDir = join(tempHome, '.claude');
|
|
210
|
+
expect(existsSync(join(claudeDir, 'hooks', 'anti-deletion.sh'))).toBe(true);
|
|
211
|
+
});
|
|
188
212
|
});
|
|
189
213
|
|
|
190
214
|
describe('isPackInstalled', () => {
|
|
@@ -207,9 +231,9 @@ describe('hook-packs', () => {
|
|
|
207
231
|
expect(isPackInstalled('nonexistent')).toBe(false);
|
|
208
232
|
});
|
|
209
233
|
|
|
210
|
-
it('should detect
|
|
211
|
-
installPack('
|
|
212
|
-
expect(isPackInstalled('
|
|
234
|
+
it('should detect safety as installed when script is present', () => {
|
|
235
|
+
installPack('safety');
|
|
236
|
+
expect(isPackInstalled('safety')).toBe(true);
|
|
213
237
|
});
|
|
214
238
|
});
|
|
215
239
|
|
|
@@ -231,6 +255,7 @@ describe('hook-packs', () => {
|
|
|
231
255
|
expect(installed).toContain('a11y');
|
|
232
256
|
expect(installed).toContain('css-discipline');
|
|
233
257
|
expect(installed).toContain('clean-commits');
|
|
258
|
+
expect(installed).toContain('safety');
|
|
234
259
|
expect(installed).toContain('yolo-safety');
|
|
235
260
|
});
|
|
236
261
|
});
|