deepflow 0.1.88 → 0.1.90
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/README.md +1 -7
- package/bin/install.js +49 -22
- package/bin/install.test.js +697 -0
- package/hooks/df-dashboard-push.js +170 -0
- package/hooks/df-execution-history.js +120 -0
- package/hooks/df-invariant-check.js +126 -0
- package/hooks/df-worktree-guard.js +101 -0
- package/package.json +1 -1
- package/src/commands/df/auto-cycle.md +1 -143
- package/src/commands/df/auto.md +1 -1
- package/src/commands/df/dashboard.md +35 -0
- package/src/commands/df/execute.md +167 -10
- package/src/commands/df/verify.md +38 -8
- package/src/skills/auto-cycle/SKILL.md +148 -0
- package/templates/config-template.yaml +12 -3
- package/hooks/df-consolidation-check.js +0 -67
- package/src/commands/df/consolidate.md +0 -42
- package/src/commands/df/note.md +0 -73
- package/src/commands/df/report.md +0 -73
- package/src/commands/df/resume.md +0 -47
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for bin/install.js installer/uninstaller logic.
|
|
3
|
+
*
|
|
4
|
+
* Tests are structured around the three focus areas from the command-cleanup spec:
|
|
5
|
+
* 1. Installer output lists the correct commands and skills
|
|
6
|
+
* 2. Hook configuration logic (consolidation-check hook setup/removal)
|
|
7
|
+
* 3. Uninstaller removes files and cleans settings correctly
|
|
8
|
+
*
|
|
9
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { test, describe, before, after, beforeEach, afterEach } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const { execFileSync } = require('node:child_process');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function makeTmpDir() {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-install-test-'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rmrf(dir) {
|
|
30
|
+
if (fs.existsSync(dir)) {
|
|
31
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run install.js in a subprocess with a fake HOME so it never touches the real
|
|
37
|
+
* ~/.claude directory. We override HOME and cwd so both GLOBAL_DIR and
|
|
38
|
+
* PROJECT_DIR resolve to deterministic temp paths.
|
|
39
|
+
*
|
|
40
|
+
* Returns { stdout, stderr, code } — always resolves (never throws).
|
|
41
|
+
*/
|
|
42
|
+
function runInstaller(args = [], { cwd, home, env = {} } = {}) {
|
|
43
|
+
const installScript = path.resolve(__dirname, 'install.js');
|
|
44
|
+
try {
|
|
45
|
+
const stdout = execFileSync(
|
|
46
|
+
process.execPath,
|
|
47
|
+
[installScript, ...args],
|
|
48
|
+
{
|
|
49
|
+
cwd: cwd || os.tmpdir(),
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
HOME: home || os.tmpdir(),
|
|
53
|
+
// Disable TTY so askInstallLevel defaults to global and no prompts appear
|
|
54
|
+
...env
|
|
55
|
+
},
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
// Allow the process to fail — we capture exit code via try/catch
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
return { stdout, stderr: '', code: 0 };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return {
|
|
63
|
+
stdout: err.stdout || '',
|
|
64
|
+
stderr: err.stderr || '',
|
|
65
|
+
code: err.status ?? 1
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Extract internal functions from install.js without running main().
|
|
72
|
+
// We do this by requiring after monkey-patching process.argv so the
|
|
73
|
+
// "deepflow auto" legacy path doesn't fire and main() is never called.
|
|
74
|
+
// Instead we read and eval the module pieces we need directly.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a minimal settings object that matches the structure
|
|
79
|
+
* configureHooks() expects, then call the relevant filtering logic inline.
|
|
80
|
+
* We test the logic by reproducing it from the source rather than importing,
|
|
81
|
+
* which avoids having to restructure the script.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// 1. Installer output: lists correct commands and skills
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('Installer output — commands and skills listing', () => {
|
|
89
|
+
let tmpHome;
|
|
90
|
+
let tmpProject;
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
tmpHome = makeTmpDir();
|
|
94
|
+
tmpProject = makeTmpDir();
|
|
95
|
+
|
|
96
|
+
// Pre-create the package source structure the installer expects
|
|
97
|
+
// The installer reads from __dirname/.. so we use the real package src.
|
|
98
|
+
// We only need to ensure a fresh fake HOME so it doesn't touch real dirs.
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
rmrf(tmpHome);
|
|
103
|
+
rmrf(tmpProject);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('output lists expected command names', () => {
|
|
107
|
+
const { stdout } = runInstaller([], { home: tmpHome, cwd: tmpProject });
|
|
108
|
+
// These commands should appear in installer output
|
|
109
|
+
const expectedCommands = [
|
|
110
|
+
'/df:discover',
|
|
111
|
+
'/df:debate',
|
|
112
|
+
'/df:spec',
|
|
113
|
+
'/df:plan',
|
|
114
|
+
'/df:execute',
|
|
115
|
+
'/df:verify',
|
|
116
|
+
'/df:auto',
|
|
117
|
+
'/df:update',
|
|
118
|
+
];
|
|
119
|
+
for (const cmd of expectedCommands) {
|
|
120
|
+
assert.ok(
|
|
121
|
+
stdout.includes(cmd),
|
|
122
|
+
`Expected installer output to include "${cmd}"\nActual output:\n${stdout}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('output does NOT list removed commands (report, note, resume, consolidate)', () => {
|
|
128
|
+
const { stdout } = runInstaller([], { home: tmpHome, cwd: tmpProject });
|
|
129
|
+
const removedCommands = [
|
|
130
|
+
'/df:report',
|
|
131
|
+
'/df:note',
|
|
132
|
+
'/df:resume',
|
|
133
|
+
'/df:consolidate',
|
|
134
|
+
];
|
|
135
|
+
for (const cmd of removedCommands) {
|
|
136
|
+
assert.ok(
|
|
137
|
+
!stdout.includes(cmd),
|
|
138
|
+
`Installer output should NOT include "${cmd}" — it was removed\nActual output:\n${stdout}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('output lists skills section', () => {
|
|
144
|
+
const { stdout } = runInstaller([], { home: tmpHome, cwd: tmpProject });
|
|
145
|
+
assert.ok(
|
|
146
|
+
stdout.includes('skills/'),
|
|
147
|
+
`Expected installer output to include "skills/"\nActual output:\n${stdout}`
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('output lists known skills', () => {
|
|
152
|
+
const { stdout } = runInstaller([], { home: tmpHome, cwd: tmpProject });
|
|
153
|
+
const expectedSkills = [
|
|
154
|
+
'gap-discovery',
|
|
155
|
+
'atomic-commits',
|
|
156
|
+
'code-completeness',
|
|
157
|
+
'browse-fetch',
|
|
158
|
+
'browse-verify',
|
|
159
|
+
];
|
|
160
|
+
for (const skill of expectedSkills) {
|
|
161
|
+
assert.ok(
|
|
162
|
+
stdout.includes(skill),
|
|
163
|
+
`Expected installer output to include skill "${skill}"\nActual output:\n${stdout}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('output lists hooks section for global install', () => {
|
|
169
|
+
const { stdout } = runInstaller([], { home: tmpHome, cwd: tmpProject });
|
|
170
|
+
// Non-interactive defaults to global — hooks section should be present
|
|
171
|
+
assert.ok(
|
|
172
|
+
stdout.includes('hooks/'),
|
|
173
|
+
`Expected installer output to include "hooks/" for global install\nActual output:\n${stdout}`
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// 2. Hook configuration logic — consolidation-check hook setup / removal
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe('Hook configuration — consolidation-check hook', () => {
|
|
183
|
+
/**
|
|
184
|
+
* We test the filtering logic that configureHooks() applies to SessionStart.
|
|
185
|
+
* The logic is: filter out hooks whose command includes 'df-consolidation-check'.
|
|
186
|
+
* We reproduce this inline since install.js does not export functions.
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
function filterSessionStart(hooks) {
|
|
190
|
+
return hooks.filter(hook => {
|
|
191
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
192
|
+
return !cmd.includes('df-check-update') &&
|
|
193
|
+
!cmd.includes('df-consolidation-check') &&
|
|
194
|
+
!cmd.includes('df-quota-logger');
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test('filterSessionStart removes consolidation-check hooks', () => {
|
|
199
|
+
const hooks = [
|
|
200
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-consolidation-check.js' }] },
|
|
201
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-check-update.js' }] },
|
|
202
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-other.js' }] },
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const filtered = filterSessionStart(hooks);
|
|
206
|
+
|
|
207
|
+
assert.equal(filtered.length, 1, 'Should keep only hooks not matching deepflow patterns');
|
|
208
|
+
assert.ok(
|
|
209
|
+
filtered[0].hooks[0].command.includes('df-other.js'),
|
|
210
|
+
'Should keep non-deepflow hooks'
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('filterSessionStart removes df-check-update hooks', () => {
|
|
215
|
+
const hooks = [
|
|
216
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-check-update.js' }] },
|
|
217
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/unrelated.js' }] },
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
const filtered = filterSessionStart(hooks);
|
|
221
|
+
assert.equal(filtered.length, 1);
|
|
222
|
+
assert.ok(filtered[0].hooks[0].command.includes('unrelated.js'));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('filterSessionStart removes df-quota-logger hooks', () => {
|
|
226
|
+
const hooks = [
|
|
227
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-quota-logger.js' }] },
|
|
228
|
+
];
|
|
229
|
+
const filtered = filterSessionStart(hooks);
|
|
230
|
+
assert.equal(filtered.length, 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('filterSessionStart keeps empty array as-is', () => {
|
|
234
|
+
const filtered = filterSessionStart([]);
|
|
235
|
+
assert.equal(filtered.length, 0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('filterSessionStart keeps non-deepflow hooks intact', () => {
|
|
239
|
+
const hooks = [
|
|
240
|
+
{ hooks: [{ type: 'command', command: 'node /home/custom-hook.js' }] },
|
|
241
|
+
{ hooks: [{ type: 'command', command: '/usr/local/bin/mytool' }] },
|
|
242
|
+
];
|
|
243
|
+
const filtered = filterSessionStart(hooks);
|
|
244
|
+
assert.equal(filtered.length, 2);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('filterSessionStart handles hook with missing command gracefully', () => {
|
|
248
|
+
const hooks = [
|
|
249
|
+
{ hooks: [{ type: 'command' }] }, // no command field
|
|
250
|
+
{ hooks: [] }, // empty hooks array
|
|
251
|
+
{}, // no hooks key
|
|
252
|
+
];
|
|
253
|
+
// Should not throw — all default to '' which doesn't match any pattern
|
|
254
|
+
const filtered = filterSessionStart(hooks);
|
|
255
|
+
assert.equal(filtered.length, 3, 'Malformed hooks should be kept (not errored)');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('configureHooks does NOT add consolidation-check to SessionStart', () => {
|
|
259
|
+
// Read install.js source and verify the string 'consolidation-check' does not
|
|
260
|
+
// appear as a command being PUSHED to SessionStart.
|
|
261
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
262
|
+
|
|
263
|
+
// Find all .push( calls that include consolidationCheckCmd
|
|
264
|
+
// The source should NOT push consolidationCheckCmd to SessionStart
|
|
265
|
+
// We check by looking at the section after "Remove any existing deepflow" and before
|
|
266
|
+
// "Add update check hook" — consolidation-check should not be in a push block
|
|
267
|
+
const pushConsolidationPattern = /settings\.hooks\.SessionStart\.push[\s\S]*?consolidation/;
|
|
268
|
+
assert.ok(
|
|
269
|
+
!pushConsolidationPattern.test(src),
|
|
270
|
+
'install.js should not push consolidation-check to SessionStart after cleanup'
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('source does not reference df-consolidation-check.js in hook setup variable', () => {
|
|
275
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
276
|
+
|
|
277
|
+
// consolidationCheckCmd should not be defined / used to register a hook
|
|
278
|
+
// (it may still appear in filter expressions for safe removal)
|
|
279
|
+
const consolidationCmdDef = /consolidationCheckCmd\s*=\s*`node/;
|
|
280
|
+
assert.ok(
|
|
281
|
+
!consolidationCmdDef.test(src),
|
|
282
|
+
'consolidationCheckCmd variable should not be defined in install.js after command-cleanup'
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('source does not add consolidation-check hook to SessionStart', () => {
|
|
287
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
288
|
+
|
|
289
|
+
// After cleanup, the installer must not push a consolidation-check command
|
|
290
|
+
// into any hooks array. The only valid reference to consolidation-check
|
|
291
|
+
// in the hooks section is inside .filter() calls (for safe removal).
|
|
292
|
+
const lines = src.split('\n');
|
|
293
|
+
let insidePush = false;
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < lines.length; i++) {
|
|
296
|
+
const line = lines[i];
|
|
297
|
+
|
|
298
|
+
if (line.includes('.push(')) insidePush = true;
|
|
299
|
+
if (insidePush && line.includes(');')) insidePush = false;
|
|
300
|
+
|
|
301
|
+
if (insidePush && line.includes('df-consolidation-check')) {
|
|
302
|
+
assert.fail(
|
|
303
|
+
`Line ${i + 1}: consolidation-check found inside a .push() call — it should not be registered as a hook`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// 3. Uninstaller: removes files and cleans settings
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
describe('Uninstaller — file removal and settings cleanup', () => {
|
|
315
|
+
let tmpHome;
|
|
316
|
+
let tmpProject;
|
|
317
|
+
|
|
318
|
+
function makeGlobalInstall(claudeDir) {
|
|
319
|
+
// Create the minimal structure that isInstalled() considers "installed"
|
|
320
|
+
fs.mkdirSync(path.join(claudeDir, 'commands', 'df'), { recursive: true });
|
|
321
|
+
fs.writeFileSync(path.join(claudeDir, 'commands', 'df', 'auto.md'), '# auto');
|
|
322
|
+
|
|
323
|
+
// Create hook files
|
|
324
|
+
const hookDir = path.join(claudeDir, 'hooks');
|
|
325
|
+
fs.mkdirSync(hookDir, { recursive: true });
|
|
326
|
+
for (const hook of [
|
|
327
|
+
'df-statusline.js',
|
|
328
|
+
'df-check-update.js',
|
|
329
|
+
'df-consolidation-check.js',
|
|
330
|
+
'df-invariant-check.js',
|
|
331
|
+
'df-quota-logger.js',
|
|
332
|
+
'df-tool-usage.js',
|
|
333
|
+
'df-dashboard-push.js',
|
|
334
|
+
'df-execution-history.js',
|
|
335
|
+
'df-worktree-guard.js',
|
|
336
|
+
]) {
|
|
337
|
+
fs.writeFileSync(path.join(hookDir, hook), '// hook');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create skills and agents
|
|
341
|
+
fs.mkdirSync(path.join(claudeDir, 'skills', 'atomic-commits'), { recursive: true });
|
|
342
|
+
fs.writeFileSync(path.join(claudeDir, 'skills', 'atomic-commits', 'SKILL.md'), '# skill');
|
|
343
|
+
fs.mkdirSync(path.join(claudeDir, 'agents'), { recursive: true });
|
|
344
|
+
fs.writeFileSync(path.join(claudeDir, 'agents', 'reasoner.md'), '# reasoner');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function makeGlobalSettings(claudeDir, extra = {}) {
|
|
348
|
+
const settings = {
|
|
349
|
+
env: { ENABLE_LSP_TOOL: '1', MY_CUSTOM_VAR: 'keep-me' },
|
|
350
|
+
hooks: {
|
|
351
|
+
SessionStart: [
|
|
352
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-check-update.js` }] },
|
|
353
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-consolidation-check.js` }] },
|
|
354
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-quota-logger.js` }] },
|
|
355
|
+
{ hooks: [{ type: 'command', command: 'node /usr/local/my-custom-hook.js' }] },
|
|
356
|
+
],
|
|
357
|
+
SessionEnd: [
|
|
358
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-quota-logger.js` }] },
|
|
359
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-dashboard-push.js` }] },
|
|
360
|
+
],
|
|
361
|
+
PostToolUse: [
|
|
362
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-tool-usage.js` }] },
|
|
363
|
+
{ hooks: [{ type: 'command', command: `node ${claudeDir}/hooks/df-worktree-guard.js` }] },
|
|
364
|
+
],
|
|
365
|
+
},
|
|
366
|
+
permissions: {
|
|
367
|
+
allow: ['Edit', 'Write', 'Read', 'Bash(git status:*)', 'MY_CUSTOM_PERM']
|
|
368
|
+
},
|
|
369
|
+
...extra
|
|
370
|
+
};
|
|
371
|
+
fs.writeFileSync(
|
|
372
|
+
path.join(claudeDir, 'settings.json'),
|
|
373
|
+
JSON.stringify(settings, null, 2)
|
|
374
|
+
);
|
|
375
|
+
return settings;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
beforeEach(() => {
|
|
379
|
+
tmpHome = makeTmpDir();
|
|
380
|
+
tmpProject = makeTmpDir();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
rmrf(tmpHome);
|
|
385
|
+
rmrf(tmpProject);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// -- Source-level checks (do not require filesystem install) --
|
|
389
|
+
|
|
390
|
+
test('uninstall source does not reference df-consolidation-check.js in toRemove array', () => {
|
|
391
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
392
|
+
|
|
393
|
+
// Find the toRemove array literal in the uninstall function
|
|
394
|
+
// It should not contain 'hooks/df-consolidation-check.js'
|
|
395
|
+
const toRemoveBlock = src.match(/const toRemove\s*=\s*\[([\s\S]*?)\];/);
|
|
396
|
+
assert.ok(toRemoveBlock, 'Could not find toRemove array in install.js');
|
|
397
|
+
|
|
398
|
+
assert.ok(
|
|
399
|
+
!toRemoveBlock[1].includes('df-consolidation-check.js'),
|
|
400
|
+
'toRemove array should not list df-consolidation-check.js — hook was removed from the project'
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('uninstall SessionStart filter does not include consolidation-check', () => {
|
|
405
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
406
|
+
|
|
407
|
+
// Find the SessionStart filter in uninstall function
|
|
408
|
+
// It should filter out df-check-update and df-quota-logger,
|
|
409
|
+
// but df-consolidation-check may or may not be in the filter.
|
|
410
|
+
// AC-10 says: uninstaller must not ERROR when hook is already absent.
|
|
411
|
+
// It's acceptable to leave the filter in for safe removal, but
|
|
412
|
+
// consolidation-check must NOT be in the toRemove list.
|
|
413
|
+
// This test validates the toRemove list (already done above),
|
|
414
|
+
// so here we just validate the filter handles missing hooks gracefully.
|
|
415
|
+
|
|
416
|
+
// The filter function should use optional chaining: hook.hooks?.[0]?.command
|
|
417
|
+
assert.ok(
|
|
418
|
+
src.includes('hook.hooks?.[0]?.command'),
|
|
419
|
+
'SessionStart filter should use optional chaining to avoid errors on missing hooks'
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('settings cleanup removes ENABLE_LSP_TOOL but keeps other env vars', () => {
|
|
424
|
+
// Reproduce the cleanup logic inline
|
|
425
|
+
const settings = {
|
|
426
|
+
env: { ENABLE_LSP_TOOL: '1', MY_CUSTOM: 'keep' },
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
if (settings.env?.ENABLE_LSP_TOOL) {
|
|
430
|
+
delete settings.env.ENABLE_LSP_TOOL;
|
|
431
|
+
if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
assert.ok(!settings.env?.ENABLE_LSP_TOOL, 'ENABLE_LSP_TOOL should be removed');
|
|
435
|
+
assert.ok(settings.env?.MY_CUSTOM === 'keep', 'Other env vars should be preserved');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('settings cleanup deletes env key when it becomes empty', () => {
|
|
439
|
+
const settings = {
|
|
440
|
+
env: { ENABLE_LSP_TOOL: '1' },
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
if (settings.env?.ENABLE_LSP_TOOL) {
|
|
444
|
+
delete settings.env.ENABLE_LSP_TOOL;
|
|
445
|
+
if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
assert.ok(!('env' in settings), 'env key should be deleted when empty');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('settings cleanup removes only deepflow permissions, keeps custom ones', () => {
|
|
452
|
+
// DEEPFLOW_PERMISSIONS includes 'Edit', 'Write', 'Read', 'Bash(git status:*)'
|
|
453
|
+
// We simulate the filter
|
|
454
|
+
const DEEPFLOW_PERMISSIONS = new Set([
|
|
455
|
+
'Edit', 'Write', 'Read', 'Glob', 'Grep',
|
|
456
|
+
'Bash(git status:*)', 'Bash(git diff:*)', 'Bash(git add:*)',
|
|
457
|
+
'Bash(node:*)', 'Bash(ls:*)', 'Bash(cat:*)',
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
const settings = {
|
|
461
|
+
permissions: {
|
|
462
|
+
allow: ['Edit', 'Write', 'Read', 'MY_CUSTOM_PERM', 'ANOTHER_PERM']
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
settings.permissions.allow = settings.permissions.allow.filter(p => !DEEPFLOW_PERMISSIONS.has(p));
|
|
467
|
+
if (settings.permissions.allow.length === 0) delete settings.permissions.allow;
|
|
468
|
+
|
|
469
|
+
assert.deepEqual(
|
|
470
|
+
settings.permissions.allow,
|
|
471
|
+
['MY_CUSTOM_PERM', 'ANOTHER_PERM'],
|
|
472
|
+
'Non-deepflow permissions should be preserved'
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('settings cleanup deletes permissions when allow list becomes empty', () => {
|
|
477
|
+
const DEEPFLOW_PERMISSIONS = new Set(['Edit', 'Write']);
|
|
478
|
+
const settings = {
|
|
479
|
+
permissions: { allow: ['Edit', 'Write'] }
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
settings.permissions.allow = settings.permissions.allow.filter(p => !DEEPFLOW_PERMISSIONS.has(p));
|
|
483
|
+
if (settings.permissions.allow.length === 0) delete settings.permissions.allow;
|
|
484
|
+
if (settings.permissions && Object.keys(settings.permissions).length === 0) delete settings.permissions;
|
|
485
|
+
|
|
486
|
+
assert.ok(!('permissions' in settings), 'permissions key should be deleted when empty');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('SessionStart cleanup removes df hooks and keeps custom hooks', () => {
|
|
490
|
+
const sessionStart = [
|
|
491
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-check-update.js' }] },
|
|
492
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-consolidation-check.js' }] },
|
|
493
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-quota-logger.js' }] },
|
|
494
|
+
{ hooks: [{ type: 'command', command: 'node /usr/local/my-hook.js' }] },
|
|
495
|
+
];
|
|
496
|
+
|
|
497
|
+
const filtered = sessionStart.filter(hook => {
|
|
498
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
499
|
+
return !cmd.includes('df-check-update') &&
|
|
500
|
+
!cmd.includes('df-consolidation-check') &&
|
|
501
|
+
!cmd.includes('df-quota-logger');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
assert.equal(filtered.length, 1, 'Should keep only non-deepflow hooks');
|
|
505
|
+
assert.ok(filtered[0].hooks[0].command.includes('my-hook.js'));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('SessionEnd cleanup removes quota-logger and dashboard-push', () => {
|
|
509
|
+
const sessionEnd = [
|
|
510
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-quota-logger.js' }] },
|
|
511
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-dashboard-push.js' }] },
|
|
512
|
+
{ hooks: [{ type: 'command', command: 'node /usr/local/keep.js' }] },
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const filtered = sessionEnd.filter(hook => {
|
|
516
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
517
|
+
return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
assert.equal(filtered.length, 1);
|
|
521
|
+
assert.ok(filtered[0].hooks[0].command.includes('keep.js'));
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('PostToolUse cleanup removes all deepflow hooks', () => {
|
|
525
|
+
const postToolUse = [
|
|
526
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-tool-usage.js' }] },
|
|
527
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-execution-history.js' }] },
|
|
528
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-worktree-guard.js' }] },
|
|
529
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-invariant-check.js' }] },
|
|
530
|
+
{ hooks: [{ type: 'command', command: 'node /usr/local/my-tool.js' }] },
|
|
531
|
+
];
|
|
532
|
+
|
|
533
|
+
const filtered = postToolUse.filter(hook => {
|
|
534
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
535
|
+
return !cmd.includes('df-tool-usage') &&
|
|
536
|
+
!cmd.includes('df-execution-history') &&
|
|
537
|
+
!cmd.includes('df-worktree-guard') &&
|
|
538
|
+
!cmd.includes('df-invariant-check');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
assert.equal(filtered.length, 1);
|
|
542
|
+
assert.ok(filtered[0].hooks[0].command.includes('my-tool.js'));
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('hooks object is deleted when all hook arrays are removed', () => {
|
|
546
|
+
const settings = {
|
|
547
|
+
hooks: {
|
|
548
|
+
SessionStart: [],
|
|
549
|
+
SessionEnd: [],
|
|
550
|
+
PostToolUse: [],
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// Simulate what uninstall does: delete empty arrays, then delete hooks if empty
|
|
555
|
+
for (const key of ['SessionStart', 'SessionEnd', 'PostToolUse']) {
|
|
556
|
+
if (settings.hooks[key] && settings.hooks[key].length === 0) {
|
|
557
|
+
delete settings.hooks[key];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
561
|
+
delete settings.hooks;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
assert.ok(!('hooks' in settings), 'hooks object should be deleted when all arrays are empty');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('hooks object is kept when non-deepflow hooks remain', () => {
|
|
568
|
+
const settings = {
|
|
569
|
+
hooks: {
|
|
570
|
+
SessionStart: [
|
|
571
|
+
{ hooks: [{ type: 'command', command: 'node /usr/local/keep.js' }] }
|
|
572
|
+
]
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const filtered = settings.hooks.SessionStart.filter(hook => {
|
|
577
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
578
|
+
return !cmd.includes('df-check-update') &&
|
|
579
|
+
!cmd.includes('df-consolidation-check') &&
|
|
580
|
+
!cmd.includes('df-quota-logger');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
settings.hooks.SessionStart = filtered;
|
|
584
|
+
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
|
585
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
586
|
+
|
|
587
|
+
assert.ok('hooks' in settings, 'hooks object should be preserved when non-deepflow hooks remain');
|
|
588
|
+
assert.equal(settings.hooks.SessionStart.length, 1);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// 4. isInstalled helper logic
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
describe('isInstalled logic', () => {
|
|
597
|
+
let tmpDir;
|
|
598
|
+
|
|
599
|
+
beforeEach(() => {
|
|
600
|
+
tmpDir = makeTmpDir();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
afterEach(() => {
|
|
604
|
+
rmrf(tmpDir);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('returns false when commands/df dir does not exist', () => {
|
|
608
|
+
// Reproduce isInstalled logic
|
|
609
|
+
function isInstalled(claudeDir) {
|
|
610
|
+
const commandsDir = path.join(claudeDir, 'commands', 'df');
|
|
611
|
+
return fs.existsSync(commandsDir) && fs.readdirSync(commandsDir).length > 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
assert.equal(isInstalled(tmpDir), false);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('returns false when commands/df dir is empty', () => {
|
|
618
|
+
function isInstalled(claudeDir) {
|
|
619
|
+
const commandsDir = path.join(claudeDir, 'commands', 'df');
|
|
620
|
+
return fs.existsSync(commandsDir) && fs.readdirSync(commandsDir).length > 0;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
fs.mkdirSync(path.join(tmpDir, 'commands', 'df'), { recursive: true });
|
|
624
|
+
assert.equal(isInstalled(tmpDir), false);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('returns true when commands/df has at least one file', () => {
|
|
628
|
+
function isInstalled(claudeDir) {
|
|
629
|
+
const commandsDir = path.join(claudeDir, 'commands', 'df');
|
|
630
|
+
return fs.existsSync(commandsDir) && fs.readdirSync(commandsDir).length > 0;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
fs.mkdirSync(path.join(tmpDir, 'commands', 'df'), { recursive: true });
|
|
634
|
+
fs.writeFileSync(path.join(tmpDir, 'commands', 'df', 'auto.md'), '# auto');
|
|
635
|
+
assert.equal(isInstalled(tmpDir), true);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// 5. copyDir helper logic
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
describe('copyDir logic', () => {
|
|
644
|
+
let tmpSrc;
|
|
645
|
+
let tmpDest;
|
|
646
|
+
|
|
647
|
+
beforeEach(() => {
|
|
648
|
+
tmpSrc = makeTmpDir();
|
|
649
|
+
tmpDest = makeTmpDir();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
afterEach(() => {
|
|
653
|
+
rmrf(tmpSrc);
|
|
654
|
+
rmrf(tmpDest);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
function copyDir(src, dest) {
|
|
658
|
+
if (!fs.existsSync(src)) return;
|
|
659
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
660
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
661
|
+
const srcPath = path.join(src, entry.name);
|
|
662
|
+
const destPath = path.join(dest, entry.name);
|
|
663
|
+
if (entry.isDirectory()) {
|
|
664
|
+
copyDir(srcPath, destPath);
|
|
665
|
+
} else {
|
|
666
|
+
fs.copyFileSync(srcPath, destPath);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
test('copies files from src to dest', () => {
|
|
672
|
+
fs.writeFileSync(path.join(tmpSrc, 'file.md'), '# content');
|
|
673
|
+
copyDir(tmpSrc, tmpDest);
|
|
674
|
+
assert.ok(fs.existsSync(path.join(tmpDest, 'file.md')));
|
|
675
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'file.md'), 'utf8'), '# content');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('recursively copies subdirectories', () => {
|
|
679
|
+
fs.mkdirSync(path.join(tmpSrc, 'sub'));
|
|
680
|
+
fs.writeFileSync(path.join(tmpSrc, 'sub', 'nested.md'), '# nested');
|
|
681
|
+
copyDir(tmpSrc, tmpDest);
|
|
682
|
+
assert.ok(fs.existsSync(path.join(tmpDest, 'sub', 'nested.md')));
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test('does nothing when src does not exist', () => {
|
|
686
|
+
const nonExistent = path.join(tmpSrc, 'does-not-exist');
|
|
687
|
+
// Should not throw
|
|
688
|
+
assert.doesNotThrow(() => copyDir(nonExistent, tmpDest));
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('creates dest directory if it does not exist', () => {
|
|
692
|
+
const newDest = path.join(tmpDest, 'new-dir');
|
|
693
|
+
fs.writeFileSync(path.join(tmpSrc, 'a.md'), '# a');
|
|
694
|
+
copyDir(tmpSrc, newDest);
|
|
695
|
+
assert.ok(fs.existsSync(path.join(newDest, 'a.md')));
|
|
696
|
+
});
|
|
697
|
+
});
|