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.
@@ -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
+ });