deepflow 0.1.104 → 0.1.106
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/bin/install.js +21 -0
- package/bin/install.test.js +205 -0
- package/hooks/df-command-usage.js +17 -26
- package/hooks/df-dashboard-push.js +4 -4
- package/hooks/df-dashboard-push.test.js +256 -0
- package/hooks/df-execution-history.js +43 -53
- package/hooks/df-explore-protocol.js +30 -39
- package/hooks/df-invariant-check.js +88 -61
- package/hooks/df-invariant-check.test.js +315 -0
- package/hooks/df-quota-logger.js +11 -23
- package/hooks/df-quota-logger.test.js +324 -0
- package/hooks/df-snapshot-guard.js +32 -40
- package/hooks/df-statusline.js +3 -12
- package/hooks/df-stdin-migration.test.js +106 -0
- package/hooks/df-subagent-registry.js +42 -48
- package/hooks/df-tool-usage-spike.js +15 -26
- package/hooks/df-tool-usage.js +37 -47
- package/hooks/df-worktree-guard.js +28 -36
- package/hooks/lib/hook-stdin.js +47 -0
- package/hooks/lib/hook-stdin.test.js +200 -0
- package/hooks/lib/lint-no-bare-stdin.js +68 -0
- package/hooks/lib/lint-no-bare-stdin.test.js +82 -0
- package/package.json +1 -1
- package/src/commands/df/execute.md +25 -174
- package/src/eval/git-memory.js +8 -8
- package/src/eval/git-memory.test.js +128 -1
- package/src/eval/loop.js +3 -3
- package/src/eval/loop.test.js +158 -0
- package/templates/config-template.yaml +0 -6
package/bin/install.js
CHANGED
|
@@ -238,12 +238,33 @@ function isInstalled(claudeDir) {
|
|
|
238
238
|
function copyDir(src, dest) {
|
|
239
239
|
if (!fs.existsSync(src)) return;
|
|
240
240
|
|
|
241
|
+
const resolvedSrcRoot = path.resolve(src);
|
|
242
|
+
const resolvedDestRoot = path.resolve(dest);
|
|
243
|
+
|
|
241
244
|
fs.mkdirSync(dest, { recursive: true });
|
|
242
245
|
|
|
243
246
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
244
247
|
const srcPath = path.join(src, entry.name);
|
|
245
248
|
const destPath = path.join(dest, entry.name);
|
|
246
249
|
|
|
250
|
+
// Reject symlinks to prevent symlink attacks
|
|
251
|
+
if (entry.isSymbolicLink()) {
|
|
252
|
+
process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Guard against path traversal — resolved paths must stay under their roots
|
|
257
|
+
const resolvedSrc = path.resolve(srcPath);
|
|
258
|
+
const resolvedDest = path.resolve(destPath);
|
|
259
|
+
if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
|
|
260
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
|
|
264
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
247
268
|
if (entry.isDirectory()) {
|
|
248
269
|
copyDir(srcPath, destPath);
|
|
249
270
|
} else {
|
package/bin/install.test.js
CHANGED
|
@@ -909,3 +909,208 @@ describe('copyDir logic', () => {
|
|
|
909
909
|
assert.ok(fs.existsSync(path.join(newDest, 'a.md')));
|
|
910
910
|
});
|
|
911
911
|
});
|
|
912
|
+
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
// 6. copyDir security hardening — symlink rejection & path traversal guard
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
describe('copyDir security hardening (symlink & path traversal)', () => {
|
|
918
|
+
let tmpSrc;
|
|
919
|
+
let tmpDest;
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Reproduces the hardened copyDir from install.js (commit f3b7f47).
|
|
923
|
+
* Includes isSymbolicLink() check and path traversal guard.
|
|
924
|
+
*/
|
|
925
|
+
function copyDir(src, dest) {
|
|
926
|
+
if (!fs.existsSync(src)) return;
|
|
927
|
+
|
|
928
|
+
const resolvedSrcRoot = path.resolve(src);
|
|
929
|
+
const resolvedDestRoot = path.resolve(dest);
|
|
930
|
+
|
|
931
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
932
|
+
|
|
933
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
934
|
+
const srcPath = path.join(src, entry.name);
|
|
935
|
+
const destPath = path.join(dest, entry.name);
|
|
936
|
+
|
|
937
|
+
// Reject symlinks to prevent symlink attacks
|
|
938
|
+
if (entry.isSymbolicLink()) {
|
|
939
|
+
process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Guard against path traversal — resolved paths must stay under their roots
|
|
944
|
+
const resolvedSrc = path.resolve(srcPath);
|
|
945
|
+
const resolvedDest = path.resolve(destPath);
|
|
946
|
+
if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
|
|
947
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
|
|
951
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (entry.isDirectory()) {
|
|
956
|
+
copyDir(srcPath, destPath);
|
|
957
|
+
} else {
|
|
958
|
+
fs.copyFileSync(srcPath, destPath);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
beforeEach(() => {
|
|
964
|
+
tmpSrc = makeTmpDir();
|
|
965
|
+
tmpDest = makeTmpDir();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
afterEach(() => {
|
|
969
|
+
rmrf(tmpSrc);
|
|
970
|
+
rmrf(tmpDest);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
// -- Symlink rejection --
|
|
974
|
+
|
|
975
|
+
test('skips symlinked files and does not copy them to dest', () => {
|
|
976
|
+
// Create a real file outside the src tree
|
|
977
|
+
const outsideFile = path.join(os.tmpdir(), `df-symlink-target-${Date.now()}.txt`);
|
|
978
|
+
fs.writeFileSync(outsideFile, 'secret content');
|
|
979
|
+
|
|
980
|
+
// Create a symlink inside the src dir pointing to the outside file
|
|
981
|
+
fs.symlinkSync(outsideFile, path.join(tmpSrc, 'link-to-secret.txt'));
|
|
982
|
+
|
|
983
|
+
// Also create a normal file to ensure it IS copied
|
|
984
|
+
fs.writeFileSync(path.join(tmpSrc, 'normal.md'), '# normal');
|
|
985
|
+
|
|
986
|
+
copyDir(tmpSrc, tmpDest);
|
|
987
|
+
|
|
988
|
+
// The symlink should NOT have been copied
|
|
989
|
+
assert.ok(
|
|
990
|
+
!fs.existsSync(path.join(tmpDest, 'link-to-secret.txt')),
|
|
991
|
+
'Symlinked file should be skipped and not appear in dest'
|
|
992
|
+
);
|
|
993
|
+
// The normal file should still be copied
|
|
994
|
+
assert.ok(
|
|
995
|
+
fs.existsSync(path.join(tmpDest, 'normal.md')),
|
|
996
|
+
'Normal files should still be copied alongside skipped symlinks'
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
// Cleanup
|
|
1000
|
+
fs.unlinkSync(outsideFile);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
test('skips symlinked directories and does not copy them to dest', () => {
|
|
1004
|
+
// Create a real directory outside src
|
|
1005
|
+
const outsideDir = makeTmpDir();
|
|
1006
|
+
fs.writeFileSync(path.join(outsideDir, 'inside.txt'), 'hidden');
|
|
1007
|
+
|
|
1008
|
+
// Create a directory symlink inside src
|
|
1009
|
+
fs.symlinkSync(outsideDir, path.join(tmpSrc, 'link-to-dir'));
|
|
1010
|
+
|
|
1011
|
+
// Normal subdir to verify it copies
|
|
1012
|
+
fs.mkdirSync(path.join(tmpSrc, 'real-sub'));
|
|
1013
|
+
fs.writeFileSync(path.join(tmpSrc, 'real-sub', 'file.md'), '# real');
|
|
1014
|
+
|
|
1015
|
+
copyDir(tmpSrc, tmpDest);
|
|
1016
|
+
|
|
1017
|
+
assert.ok(
|
|
1018
|
+
!fs.existsSync(path.join(tmpDest, 'link-to-dir')),
|
|
1019
|
+
'Symlinked directory should be skipped'
|
|
1020
|
+
);
|
|
1021
|
+
assert.ok(
|
|
1022
|
+
fs.existsSync(path.join(tmpDest, 'real-sub', 'file.md')),
|
|
1023
|
+
'Real subdirectories should still be copied'
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
rmrf(outsideDir);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test('skips relative symlinks within the source tree', () => {
|
|
1030
|
+
// Create a real file and a relative symlink to it
|
|
1031
|
+
fs.writeFileSync(path.join(tmpSrc, 'real.md'), '# real');
|
|
1032
|
+
fs.symlinkSync('real.md', path.join(tmpSrc, 'relative-link.md'));
|
|
1033
|
+
|
|
1034
|
+
copyDir(tmpSrc, tmpDest);
|
|
1035
|
+
|
|
1036
|
+
assert.ok(
|
|
1037
|
+
fs.existsSync(path.join(tmpDest, 'real.md')),
|
|
1038
|
+
'Real file should be copied'
|
|
1039
|
+
);
|
|
1040
|
+
assert.ok(
|
|
1041
|
+
!fs.existsSync(path.join(tmpDest, 'relative-link.md')),
|
|
1042
|
+
'Even relative symlinks should be skipped'
|
|
1043
|
+
);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// -- Normal files continue to copy correctly --
|
|
1047
|
+
|
|
1048
|
+
test('copies normal files correctly when no symlinks or traversal present', () => {
|
|
1049
|
+
fs.writeFileSync(path.join(tmpSrc, 'a.md'), '# a');
|
|
1050
|
+
fs.writeFileSync(path.join(tmpSrc, 'b.txt'), 'content b');
|
|
1051
|
+
fs.mkdirSync(path.join(tmpSrc, 'sub'));
|
|
1052
|
+
fs.writeFileSync(path.join(tmpSrc, 'sub', 'c.md'), '# c');
|
|
1053
|
+
|
|
1054
|
+
copyDir(tmpSrc, tmpDest);
|
|
1055
|
+
|
|
1056
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'a.md'), 'utf8'), '# a');
|
|
1057
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'b.txt'), 'utf8'), 'content b');
|
|
1058
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'sub', 'c.md'), 'utf8'), '# c');
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test('copies deeply nested directories correctly', () => {
|
|
1062
|
+
fs.mkdirSync(path.join(tmpSrc, 'l1', 'l2', 'l3'), { recursive: true });
|
|
1063
|
+
fs.writeFileSync(path.join(tmpSrc, 'l1', 'l2', 'l3', 'deep.md'), '# deep');
|
|
1064
|
+
|
|
1065
|
+
copyDir(tmpSrc, tmpDest);
|
|
1066
|
+
|
|
1067
|
+
assert.ok(fs.existsSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md')));
|
|
1068
|
+
assert.equal(
|
|
1069
|
+
fs.readFileSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md'), 'utf8'),
|
|
1070
|
+
'# deep'
|
|
1071
|
+
);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// -- Source-level verification that the guards exist in install.js --
|
|
1075
|
+
|
|
1076
|
+
test('install.js copyDir checks isSymbolicLink()', () => {
|
|
1077
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1078
|
+
assert.ok(
|
|
1079
|
+
src.includes('entry.isSymbolicLink()'),
|
|
1080
|
+
'copyDir should check entry.isSymbolicLink() to reject symlinks'
|
|
1081
|
+
);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('install.js copyDir has path traversal guard using startsWith', () => {
|
|
1085
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1086
|
+
// The guard resolves src/dest and checks they stay under their root
|
|
1087
|
+
assert.ok(
|
|
1088
|
+
src.includes('resolvedSrc.startsWith(resolvedSrcRoot + path.sep)'),
|
|
1089
|
+
'copyDir should guard source paths against traversal using startsWith'
|
|
1090
|
+
);
|
|
1091
|
+
assert.ok(
|
|
1092
|
+
src.includes('resolvedDest.startsWith(resolvedDestRoot + path.sep)'),
|
|
1093
|
+
'copyDir should guard dest paths against traversal using startsWith'
|
|
1094
|
+
);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test('install.js copyDir logs stderr warning for skipped symlinks', () => {
|
|
1098
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1099
|
+
assert.ok(
|
|
1100
|
+
src.includes('[deepflow] skipping symlink:'),
|
|
1101
|
+
'copyDir should log a warning to stderr when skipping a symlink'
|
|
1102
|
+
);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test('install.js copyDir logs stderr warning for path traversal attempts', () => {
|
|
1106
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1107
|
+
assert.ok(
|
|
1108
|
+
src.includes('[deepflow] skipping path traversal attempt (src):'),
|
|
1109
|
+
'copyDir should log a warning for source path traversal'
|
|
1110
|
+
);
|
|
1111
|
+
assert.ok(
|
|
1112
|
+
src.includes('[deepflow] skipping path traversal attempt (dest):'),
|
|
1113
|
+
'copyDir should log a warning for dest path traversal'
|
|
1114
|
+
);
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
@@ -18,24 +18,20 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
21
22
|
|
|
22
23
|
const event = process.env.CLAUDE_HOOK_EVENT || '';
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
let raw = '';
|
|
26
|
-
process.stdin.setEncoding('utf8');
|
|
27
|
-
process.stdin.on('data', d => raw += d);
|
|
28
|
-
process.stdin.on('end', () => {
|
|
25
|
+
readStdinIfMain(module, (data) => {
|
|
29
26
|
try {
|
|
30
|
-
main();
|
|
27
|
+
main(data);
|
|
31
28
|
} catch (_e) {
|
|
32
29
|
// REQ-8: never break Claude Code
|
|
33
30
|
}
|
|
34
|
-
process.exit(0);
|
|
35
31
|
});
|
|
36
32
|
|
|
37
|
-
function main() {
|
|
38
|
-
const baseDir = findProjectDir();
|
|
33
|
+
function main(data) {
|
|
34
|
+
const baseDir = findProjectDir(data);
|
|
39
35
|
if (!baseDir) return;
|
|
40
36
|
|
|
41
37
|
const deepflowDir = path.join(baseDir, '.deepflow');
|
|
@@ -44,20 +40,18 @@ function main() {
|
|
|
44
40
|
const tokenHistoryPath = path.join(deepflowDir, 'token-history.jsonl');
|
|
45
41
|
|
|
46
42
|
if (event === 'PreToolUse') {
|
|
47
|
-
handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath);
|
|
43
|
+
handlePreToolUse(data, deepflowDir, markerPath, usagePath, tokenHistoryPath);
|
|
48
44
|
} else if (event === 'PostToolUse') {
|
|
49
|
-
handlePostToolUse(markerPath);
|
|
45
|
+
handlePostToolUse(data, markerPath);
|
|
50
46
|
} else if (event === 'SessionStart') {
|
|
51
|
-
handleSessionStart(markerPath, usagePath, tokenHistoryPath);
|
|
47
|
+
handleSessionStart(data, markerPath, usagePath, tokenHistoryPath);
|
|
52
48
|
} else if (event === 'SessionEnd') {
|
|
53
49
|
handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath);
|
|
54
50
|
}
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
function handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath) {
|
|
58
|
-
|
|
59
|
-
try { payload = JSON.parse(raw); } catch { return; }
|
|
60
|
-
|
|
53
|
+
function handlePreToolUse(data, deepflowDir, markerPath, usagePath, tokenHistoryPath) {
|
|
54
|
+
const payload = data;
|
|
61
55
|
const toolName = payload.tool_name || '';
|
|
62
56
|
const toolInput = payload.tool_input || {};
|
|
63
57
|
|
|
@@ -96,12 +90,11 @@ function handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath)
|
|
|
96
90
|
safeWriteFile(markerPath, JSON.stringify(marker, null, 2));
|
|
97
91
|
}
|
|
98
92
|
|
|
99
|
-
function handlePostToolUse(markerPath) {
|
|
93
|
+
function handlePostToolUse(data, markerPath) {
|
|
100
94
|
if (!safeExists(markerPath)) return;
|
|
101
95
|
|
|
102
96
|
// Don't count the Skill call itself (the one that opened the marker)
|
|
103
|
-
|
|
104
|
-
try { payload = JSON.parse(raw); } catch { return; }
|
|
97
|
+
const payload = data;
|
|
105
98
|
const toolName = payload.tool_name || '';
|
|
106
99
|
const toolInput = payload.tool_input || {};
|
|
107
100
|
if (toolName === 'Skill' && (toolInput.skill || '').startsWith('df:')) return;
|
|
@@ -119,10 +112,9 @@ function handlePostToolUse(markerPath) {
|
|
|
119
112
|
* On /clear or /compact, context resets — close any orphaned marker.
|
|
120
113
|
* Only fires for source=clear|compact (not startup/resume).
|
|
121
114
|
*/
|
|
122
|
-
function handleSessionStart(markerPath, usagePath, tokenHistoryPath) {
|
|
115
|
+
function handleSessionStart(data, markerPath, usagePath, tokenHistoryPath) {
|
|
123
116
|
if (!safeExists(markerPath)) return;
|
|
124
|
-
|
|
125
|
-
try { payload = JSON.parse(raw); } catch { return; }
|
|
117
|
+
const payload = data;
|
|
126
118
|
const source = payload.source || '';
|
|
127
119
|
if (source === 'clear' || source === 'compact') {
|
|
128
120
|
closeCommand(markerPath, usagePath, tokenHistoryPath);
|
|
@@ -250,11 +242,10 @@ function parseTranscriptOutputTokens(transcriptPath, offset) {
|
|
|
250
242
|
/**
|
|
251
243
|
* Find the project directory from hook payload or environment.
|
|
252
244
|
*/
|
|
253
|
-
function findProjectDir() {
|
|
245
|
+
function findProjectDir(data) {
|
|
254
246
|
try {
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
if (payload.workspace && payload.workspace.current_dir) return payload.workspace.current_dir;
|
|
247
|
+
if (data && data.cwd) return data.cwd;
|
|
248
|
+
if (data && data.workspace && data.workspace.current_dir) return data.workspace.current_dir;
|
|
258
249
|
} catch (_e) {
|
|
259
250
|
// fall through
|
|
260
251
|
}
|
|
@@ -41,9 +41,9 @@ function getStdinSync() {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Read
|
|
45
|
-
function getDashboardUrl(
|
|
46
|
-
const configPath = path.join(
|
|
44
|
+
/** Read ~/.deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
|
|
45
|
+
function getDashboardUrl() {
|
|
46
|
+
const configPath = path.join(os.homedir(), '.deepflow', 'config.yaml');
|
|
47
47
|
if (!fs.existsSync(configPath)) return null;
|
|
48
48
|
try {
|
|
49
49
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
@@ -112,7 +112,7 @@ function postJson(url, payload) {
|
|
|
112
112
|
async function main() {
|
|
113
113
|
try {
|
|
114
114
|
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
115
|
-
const dashboardUrl = getDashboardUrl(
|
|
115
|
+
const dashboardUrl = getDashboardUrl();
|
|
116
116
|
|
|
117
117
|
// Silently skip if not configured
|
|
118
118
|
if (!dashboardUrl) process.exit(0);
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks/df-dashboard-push.js (security-hardening, T2)
|
|
3
|
+
*
|
|
4
|
+
* Validates that the dashboard push hook reads dashboard_url from
|
|
5
|
+
* ~/.deepflow/config.yaml (home directory) rather than the project
|
|
6
|
+
* directory, and silently skips when not configured.
|
|
7
|
+
*
|
|
8
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const HOOK_PATH = path.resolve(__dirname, 'df-dashboard-push.js');
|
|
25
|
+
|
|
26
|
+
function makeTmpDir() {
|
|
27
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-dashboard-push-test-'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rmrf(dir) {
|
|
31
|
+
if (fs.existsSync(dir)) {
|
|
32
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run the dashboard push hook in --background mode as a child process.
|
|
38
|
+
* Overrides HOME so getDashboardUrl reads from our fake home dir.
|
|
39
|
+
* Returns { stdout, stderr, code }.
|
|
40
|
+
*/
|
|
41
|
+
function runHook({ home, cwd, hookInput } = {}) {
|
|
42
|
+
const env = { ...process.env };
|
|
43
|
+
if (home) env.HOME = home;
|
|
44
|
+
if (hookInput !== undefined) env._DF_HOOK_INPUT = hookInput;
|
|
45
|
+
// Ensure CLAUDE_PROJECT_DIR points to cwd so main() uses it
|
|
46
|
+
if (cwd) env.CLAUDE_PROJECT_DIR = cwd;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const stdout = execFileSync(
|
|
50
|
+
process.execPath,
|
|
51
|
+
[HOOK_PATH, '--background'],
|
|
52
|
+
{
|
|
53
|
+
cwd: cwd || os.tmpdir(),
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: 10000,
|
|
56
|
+
env,
|
|
57
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
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
|
+
* Write a config.yaml into {dir}/.deepflow/config.yaml with given content.
|
|
72
|
+
*/
|
|
73
|
+
function writeConfig(dir, content) {
|
|
74
|
+
const configDir = path.join(dir, '.deepflow');
|
|
75
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Tests
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe('df-dashboard-push hook', () => {
|
|
84
|
+
let fakeHome;
|
|
85
|
+
let fakeCwd;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
fakeHome = makeTmpDir();
|
|
89
|
+
fakeCwd = makeTmpDir();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
rmrf(fakeHome);
|
|
94
|
+
rmrf(fakeCwd);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
// Config resolution: reads from HOME, not CWD
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('config resolution', () => {
|
|
102
|
+
test('exits 0 when no config file exists at HOME', () => {
|
|
103
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
104
|
+
assert.equal(result.code, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('exits 0 when config exists at HOME but has no dashboard_url key', () => {
|
|
108
|
+
writeConfig(fakeHome, 'build_command: "npm run build"\ntest_command: "npm test"\n');
|
|
109
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
110
|
+
assert.equal(result.code, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('exits 0 when dashboard_url is empty string', () => {
|
|
114
|
+
writeConfig(fakeHome, 'dashboard_url: ""\n');
|
|
115
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
116
|
+
assert.equal(result.code, 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('exits 0 when dashboard_url is bare empty (no quotes)', () => {
|
|
120
|
+
writeConfig(fakeHome, 'dashboard_url: \n');
|
|
121
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
122
|
+
assert.equal(result.code, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('ignores dashboard_url in project cwd config (reads from HOME only)', () => {
|
|
126
|
+
// Put a valid URL in the project config, but NOT in home config
|
|
127
|
+
writeConfig(fakeCwd, 'dashboard_url: "http://localhost:9999"\n');
|
|
128
|
+
// Home has no config at all
|
|
129
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
130
|
+
// Should exit 0 without attempting POST because HOME has no config
|
|
131
|
+
assert.equal(result.code, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('reads dashboard_url from HOME config even when project has none', () => {
|
|
135
|
+
// Home has a dashboard_url pointing to an unreachable port
|
|
136
|
+
writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
|
|
137
|
+
// Project has no config
|
|
138
|
+
// Hook should attempt POST, fail silently, exit 0
|
|
139
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
140
|
+
assert.equal(result.code, 0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// YAML parsing edge cases
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe('dashboard_url parsing', () => {
|
|
149
|
+
test('extracts unquoted URL', () => {
|
|
150
|
+
writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
|
|
151
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
152
|
+
// Attempts POST, fails silently on unreachable port, exits 0
|
|
153
|
+
assert.equal(result.code, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('extracts single-quoted URL', () => {
|
|
157
|
+
writeConfig(fakeHome, "dashboard_url: 'http://127.0.0.1:19999'\n");
|
|
158
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
159
|
+
assert.equal(result.code, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('extracts double-quoted URL', () => {
|
|
163
|
+
writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
|
|
164
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
165
|
+
assert.equal(result.code, 0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('handles dashboard_url with leading spaces (indented)', () => {
|
|
169
|
+
writeConfig(fakeHome, ' dashboard_url: http://127.0.0.1:19999\n');
|
|
170
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
171
|
+
assert.equal(result.code, 0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('ignores commented-out dashboard_url', () => {
|
|
175
|
+
writeConfig(fakeHome, '# dashboard_url: http://127.0.0.1:19999\n');
|
|
176
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
177
|
+
// The regex uses ^ so a comment line should not match
|
|
178
|
+
assert.equal(result.code, 0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
// Error handling / resilience
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
describe('error handling', () => {
|
|
187
|
+
test('exits 0 when config file is unreadable (permissions)', () => {
|
|
188
|
+
writeConfig(fakeHome, 'dashboard_url: http://localhost:9999\n');
|
|
189
|
+
const configPath = path.join(fakeHome, '.deepflow', 'config.yaml');
|
|
190
|
+
fs.chmodSync(configPath, 0o000);
|
|
191
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
192
|
+
assert.equal(result.code, 0);
|
|
193
|
+
// Restore permissions for cleanup
|
|
194
|
+
fs.chmodSync(configPath, 0o644);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('exits 0 when hook input is invalid JSON', () => {
|
|
198
|
+
writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
|
|
199
|
+
const result = runHook({
|
|
200
|
+
home: fakeHome,
|
|
201
|
+
cwd: fakeCwd,
|
|
202
|
+
hookInput: '{not valid json',
|
|
203
|
+
});
|
|
204
|
+
assert.equal(result.code, 0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('exits 0 when hook input is empty', () => {
|
|
208
|
+
writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
|
|
209
|
+
const result = runHook({
|
|
210
|
+
home: fakeHome,
|
|
211
|
+
cwd: fakeCwd,
|
|
212
|
+
hookInput: '',
|
|
213
|
+
});
|
|
214
|
+
assert.equal(result.code, 0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('exits 0 when dashboard_url has invalid URL format', () => {
|
|
218
|
+
writeConfig(fakeHome, 'dashboard_url: not-a-url\n');
|
|
219
|
+
const result = runHook({
|
|
220
|
+
home: fakeHome,
|
|
221
|
+
cwd: fakeCwd,
|
|
222
|
+
hookInput: '{}',
|
|
223
|
+
});
|
|
224
|
+
// postJson should handle invalid URL gracefully
|
|
225
|
+
assert.equal(result.code, 0);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
// Foreground mode (spawns background and exits immediately)
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('foreground mode', () => {
|
|
234
|
+
test('exits 0 immediately without --background flag', () => {
|
|
235
|
+
const env = { ...process.env, HOME: fakeHome };
|
|
236
|
+
try {
|
|
237
|
+
const stdout = execFileSync(
|
|
238
|
+
process.execPath,
|
|
239
|
+
[HOOK_PATH],
|
|
240
|
+
{
|
|
241
|
+
cwd: fakeCwd,
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
timeout: 5000,
|
|
244
|
+
env,
|
|
245
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
246
|
+
input: '{}',
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
assert.equal(typeof stdout, 'string');
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Even if it errors, check code is 0 (fire-and-forget exit)
|
|
252
|
+
assert.equal(err.status, 0, 'foreground mode should exit 0');
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|