cc-viewer 1.6.274 → 1.6.276

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.
Files changed (106) hide show
  1. package/README.md +11 -0
  2. package/cli.js +29 -0
  3. package/dist/assets/App-VfOsEOzR.css +1 -0
  4. package/dist/assets/App-g0FMGNbK.js +1 -0
  5. package/dist/assets/{MdxEditorPanel-DvqnmX-m.css → MdxEditorPanel-1vSx6ymU.css} +1 -1
  6. package/dist/assets/{MdxEditorPanel-BAnfnKiF.js → MdxEditorPanel-lkBOg8Jh.js} +1 -1
  7. package/dist/assets/Mobile-CfP2RFHS.js +1 -0
  8. package/dist/assets/{_baseUniq-CPJrFyUF.js → _baseUniq-IhLiJsz9.js} +1 -1
  9. package/dist/assets/{arc-BLBrFElt.js → arc-CI5WB-FW.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-CbnBsMiQ.js → architectureDiagram-Q4EWVU46-YOnqcO8r.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-0mYr6-Fl.js → blockDiagram-DXYQGD6D-B6oK5mZJ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-CS7vcr0z.js → c4Diagram-AHTNJAMY-JsynU_tx.js} +1 -1
  13. package/dist/assets/{channel-CF3zZzSR.js → channel-DLsvHA9V.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-1FZYtnJ7.js → chunk-4BX2VUAB-DGhBdIp1.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-COs1qui5.js → chunk-4TB4RGXK-crHwtw-n.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-p77Qw3wN.js → chunk-55IACEB6-CHTfiCuV.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-5qaIrQKg.js → chunk-EDXVE4YY-buLXKlmZ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-DmCR8mDZ.js → chunk-FMBD7UC4-DSfXNwCE.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-D6xDfgW3.js → chunk-OYMX7WX6-16K1lWZl.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-B6cmUU0N.js → chunk-QZHKN3VN-DgDuGTt2.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-l-OyOqnn.js → chunk-YZCP3GAM-DHqswL_6.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-BCPnChlV.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-BCPnChlV.js +1 -0
  24. package/dist/assets/clone-CJyRHxRw.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-CEzaS8XS.js → cose-bilkent-S5V4N54A-D1j0CqN7.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-B3U2njWW.js → dagre-KV5264BT-XKbpcECY.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-BRSDbyBr.js → diagram-5BDNPKRD-CcbZUyrk.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-BZfOi9B7.js → diagram-G4DWMVQ6-UxGWOz8H.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-CWpb3Cg0.js → diagram-MMDJMWI5-COG8DLtM.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-CTJyBSVj.js → diagram-TYMM5635-_dGuaThe.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-CHtTRd5S.js → erDiagram-SMLLAGMA-CNu-hiRP.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-Bda8X-WJ.js → flowDiagram-DWJPFMVM-DQHinAg1.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CPfnGu5V.js → ganttDiagram-T4ZO3ILL-BAySDtwF.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-B5QxesQg.js → gitGraphDiagram-UUTBAWPF-BBO_XdYa.js} +1 -1
  35. package/dist/assets/{graph-ChltdhTU.js → graph-D9285_n5.js} +1 -1
  36. package/dist/assets/index-0hGZ2hk-.js +2 -0
  37. package/dist/assets/{index-BK4sui_O.js → index-6IDcGlHG.js} +1 -1
  38. package/dist/assets/{index-C2fhupP6.js → index-CXhN926Q.js} +1 -1
  39. package/dist/assets/{index-CfEkC3bc.js → index-CaKHIO3v.js} +1 -1
  40. package/dist/assets/{index-yClPXlMf.js → index-D18bphjK.js} +1 -1
  41. package/dist/assets/{index-B7lK5fJz.js → index-DTi4w1dY.js} +1 -1
  42. package/dist/assets/{index-C5PA4OJg.js → index-gSNq4ykV.js} +1 -1
  43. package/dist/assets/{index-DyGa-jNv.js → index-pEgUz6fU.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-CD4TS4O2.js → infoDiagram-42DDH7IO-DODFGD-6.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-jhiYabQ7.js → ishikawaDiagram-UXIWVN3A-kn6kHfYH.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-BDzIXxxt.js → journeyDiagram-VCZTEJTY-BRaTTmzg.js} +1 -1
  47. package/dist/assets/{jszip.min-CuGGBMI4.js → jszip.min-CnLSX52a.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-D34MYATQ.js → kanban-definition-6JOO6SKY-CzLQb2u1.js} +1 -1
  49. package/dist/assets/{layout-D6sLAapX.js → layout-B9TjxURJ.js} +1 -1
  50. package/dist/assets/{linear-CFV4P-wn.js → linear-Dpck4eFk.js} +1 -1
  51. package/dist/assets/{mermaid.core-YmJi7T-s.js → mermaid.core-Dd_GyvDR.js} +2 -2
  52. package/dist/assets/{min-akJQqRMn.js → min-CUkc58GS.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-w5zaJyrN.js → mindmap-definition-QFDTVHPH-BteQHO2X.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-B6EM4Ow6.js → pieDiagram-DEJITSTG-CaOYrVcc.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-2TmBxFy-.js → quadrantDiagram-34T5L4WZ-DTcmYcmQ.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-EOjvbxUy.js → requirementDiagram-MS252O5E-CY8WoU0d.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-BmTQD5eT.js → sankeyDiagram-XADWPNL6-9NPSQPLx.js} +1 -1
  58. package/dist/assets/seqResourceLoaders-BgEHTs14.js +2 -0
  59. package/dist/assets/seqResourceLoaders-DPmXT2Hh.css +41 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-CLjtqA1D.js → sequenceDiagram-FGHM5R23-e7V5HhHU.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-Cbq90iZ8.js → stateDiagram-FHFEXIEX-B6R9nxzj.js} +1 -1
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-sUx4g5Jk.js +1 -0
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-f3kEDay0.js → timeline-definition-GMOUNBTQ-CqpCMRBB.js} +1 -1
  64. package/dist/assets/{vendor-antd-5xE7sz6B.js → vendor-antd-BqzW9CXo.js} +2 -2
  65. package/dist/assets/{vendor-codemirror-ib-jPbXC.js → vendor-codemirror-B5WQ33Y0.js} +1 -1
  66. package/dist/assets/vendor-markdown-DOJHsAxX.js +3 -0
  67. package/dist/assets/{vendor-mdxeditor-BdKMdw6O.js → vendor-mdxeditor-DwzTmNBd.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-vKlE-WYu.js → vendor-qrcode-C9ZMcovz.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-DOIfjLfU.js → vendor-virtuoso-CQj5-1oy.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-0GDSFVnH.js → vennDiagram-DHZGUBPP-CxzTP1Mu.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-B2rj5j7G.js → wardley-RL74JXVD-_Dcq-0iY.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-COVTciJP.js → wardleyDiagram-NUSXRM2D-CltoIcVL.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-DXoLnGxX.js → xychartDiagram-5P7HB3ND-DXKa-Cxe.js} +1 -1
  74. package/dist/index.html +4 -4
  75. package/package.json +1 -1
  76. package/server/i18n.js +186 -2
  77. package/server/lib/auth.js +348 -0
  78. package/server/lib/voice-pack-events.js +2 -2
  79. package/server/routes/_dispatch.js +43 -0
  80. package/server/routes/ask-perm.js +452 -0
  81. package/server/routes/auth.js +166 -0
  82. package/server/routes/events.js +335 -0
  83. package/server/routes/files-content.js +421 -0
  84. package/server/routes/files-fs.js +955 -0
  85. package/server/routes/git.js +228 -0
  86. package/server/routes/logs.js +201 -0
  87. package/server/routes/misc.js +22 -0
  88. package/server/routes/plugins.js +96 -0
  89. package/server/routes/preferences.js +216 -0
  90. package/server/routes/project-meta.js +118 -0
  91. package/server/routes/skills.js +261 -0
  92. package/server/routes/team.js +169 -0
  93. package/server/routes/voice-pack.js +235 -0
  94. package/server/routes/workspaces.js +171 -0
  95. package/server/server.js +340 -3953
  96. package/dist/assets/App-BediOgt6.js +0 -1
  97. package/dist/assets/App-TGGslOeT.css +0 -1
  98. package/dist/assets/Mobile-C_nqfEXb.js +0 -1
  99. package/dist/assets/classDiagram-6PBFFD2Q-BiCYgTHO.js +0 -1
  100. package/dist/assets/classDiagram-v2-HSJHXN6E-BiCYgTHO.js +0 -1
  101. package/dist/assets/clone-ChTCnPsO.js +0 -1
  102. package/dist/assets/index-CuE3VwXB.js +0 -2
  103. package/dist/assets/seqResourceLoaders-CgNKpehN.js +0 -2
  104. package/dist/assets/seqResourceLoaders-Dd_x1M9-.css +0 -41
  105. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BKDg-3t_.js +0 -1
  106. package/dist/assets/vendor-markdown-BFrYfpb0.js +0 -1
@@ -0,0 +1,955 @@
1
+ // Filesystem mutation + browse + editor + OS-open routes (moved verbatim from server.js handleRequest).
2
+ import {
3
+ existsSync, statSync, lstatSync, readdirSync, mkdirSync, writeFileSync,
4
+ realpathSync, renameSync, rmSync, unlinkSync, cpSync, copyFileSync,
5
+ } from 'node:fs';
6
+ import { join, dirname, basename, resolve, sep } from 'node:path';
7
+ import { homedir, tmpdir } from 'node:os';
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
10
+ import { validateImportDir } from '../lib/file-api.js';
11
+ import { LOG_FILE, PROFILE_PATH, _projectName } from '../interceptor.js';
12
+ import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
13
+
14
+ function upload(req, res, parsedUrl, isLocal, deps) {
15
+ const contentType = req.headers['content-type'] || '';
16
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
17
+ if (!boundaryMatch) {
18
+ res.writeHead(400, { 'Content-Type': 'application/json' });
19
+ res.end(JSON.stringify({ error: 'Missing boundary' }));
20
+ return;
21
+ }
22
+ const MAX_UPLOAD = 100 * 1024 * 1024; // 100MB
23
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
24
+ if (contentLength > MAX_UPLOAD) {
25
+ res.writeHead(413, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
27
+ return;
28
+ }
29
+ const boundary = boundaryMatch[1];
30
+ const chunks = [];
31
+ let totalSize = 0;
32
+ let aborted = false;
33
+ req.on('data', chunk => {
34
+ totalSize += chunk.length;
35
+ if (totalSize > MAX_UPLOAD) {
36
+ aborted = true;
37
+ res.writeHead(413, { 'Content-Type': 'application/json' });
38
+ res.end(JSON.stringify({ error: 'File too large (max 50MB)' }));
39
+ req.destroy();
40
+ return;
41
+ }
42
+ chunks.push(chunk);
43
+ });
44
+ req.on('end', () => {
45
+ if (aborted) return;
46
+ try {
47
+ const buf = Buffer.concat(chunks);
48
+ // Find the first part's headers and body
49
+ const headerEnd = buf.indexOf('\r\n\r\n');
50
+ if (headerEnd === -1) throw new Error('Malformed multipart');
51
+ const headerStr = buf.slice(0, headerEnd).toString();
52
+ const nameMatch = headerStr.match(/filename="([^"]+)"/);
53
+ if (!nameMatch) throw new Error('No filename');
54
+ // sanitize: 只过 null byte + 控制字符 + 路径分隔符(真正会破坏 fs 调用的字符);
55
+ // Windows 非法字符 <>:"|?* 在 Unix 合法(ISO 时间戳 10:30:45.log、name:v1.txt 等常见),
56
+ // 不做跨平台代理过滤,让 writeFileSync 在 Windows 上自行抛错即可。
57
+ const originalName = nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_');
58
+ // Windows 保留设备名守卫(/api/upload-image)——见 WINDOWS_RESERVED_NAMES 注释。
59
+ {
60
+ const base = originalName.split('.')[0].trim().toLowerCase();
61
+ if (deps.WINDOWS_RESERVED_NAMES.test(base)) {
62
+ throw new Error('Reserved filename not allowed');
63
+ }
64
+ }
65
+ const bodyStart = headerEnd + 4;
66
+ // Find the closing boundary
67
+ const closingBoundary = Buffer.from('\r\n--' + boundary);
68
+ const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
69
+ const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
70
+ // Windows 没有 /tmp,走 os.tmpdir() (%TEMP%);POSIX 保留 /tmp/cc-viewer-uploads/
71
+ // 以兼容 1.6.245 PR #81 的 macOS allowlist 修复(/private/tmp 双 realpath)。
72
+ const uploadDir = process.platform === 'win32' ? join(tmpdir(), 'cc-viewer-uploads') : '/tmp/cc-viewer-uploads';
73
+ mkdirSync(uploadDir, { recursive: true });
74
+ bumpWorkspacesVersion();
75
+ // Unique filename: prepend timestamp to avoid silent overwrite
76
+ const ts = Date.now();
77
+ const dotIdx = originalName.lastIndexOf('.');
78
+ const uniqueName = dotIdx > 0
79
+ ? `${originalName.slice(0, dotIdx)}-${ts}${originalName.slice(dotIdx)}`
80
+ : `${originalName}-${ts}`;
81
+ const savePath = join(uploadDir, uniqueName);
82
+ writeFileSync(savePath, fileData);
83
+ // 持久化副本到 ~/.claude/cc-viewer/${project}/images/,避免 /tmp 清理后丢失
84
+ let persistPath = null;
85
+ try {
86
+ const pName = _projectName || 'default';
87
+ const persistDir = join(getClaudeConfigDir(), 'cc-viewer', pName, 'images');
88
+ mkdirSync(persistDir, { recursive: true });
89
+ persistPath = join(persistDir, uniqueName);
90
+ writeFileSync(persistPath, fileData);
91
+ } catch { }
92
+ res.writeHead(200, { 'Content-Type': 'application/json' });
93
+ res.end(JSON.stringify({ ok: true, path: savePath, persistPath }));
94
+ } catch (err) {
95
+ console.error('upload error:', err);
96
+ res.writeHead(500, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ error: 'Upload failed' }));
98
+ }
99
+ });
100
+ }
101
+
102
+ function importFile(req, res, parsedUrl, isLocal, deps) {
103
+ const contentType = req.headers['content-type'] || '';
104
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
105
+ if (!boundaryMatch) {
106
+ res.writeHead(400, { 'Content-Type': 'application/json' });
107
+ res.end(JSON.stringify({ error: 'Missing boundary' }));
108
+ return;
109
+ }
110
+ const importUrl = new URL(req.url, `${deps.protocol}://${req.headers.host}`);
111
+ const dir = importUrl.searchParams.get('dir') || '';
112
+ // 在 mkdirSync 之前纯字符串校验,防 symlink 副作用目录被创建在项目外
113
+ const dirCheck = validateImportDir(dir);
114
+ if (!dirCheck.ok) {
115
+ res.writeHead(400, { 'Content-Type': 'application/json' });
116
+ res.end(JSON.stringify({ error: dirCheck.error }));
117
+ return;
118
+ }
119
+ const MAX_UPLOAD = 100 * 1024 * 1024; // 100MB
120
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
121
+ if (contentLength > MAX_UPLOAD) {
122
+ res.writeHead(413, { 'Content-Type': 'application/json' });
123
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
124
+ return;
125
+ }
126
+ const boundary = boundaryMatch[1];
127
+ const chunks = [];
128
+ let totalSize = 0;
129
+ let aborted = false;
130
+ req.on('data', chunk => {
131
+ totalSize += chunk.length;
132
+ if (totalSize > MAX_UPLOAD) {
133
+ aborted = true;
134
+ res.writeHead(413, { 'Content-Type': 'application/json' });
135
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
136
+ req.destroy();
137
+ return;
138
+ }
139
+ chunks.push(chunk);
140
+ });
141
+ req.on('end', () => {
142
+ if (aborted) return;
143
+ try {
144
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
145
+ const targetDir = join(cwd, dir);
146
+ mkdirSync(targetDir, { recursive: true });
147
+ const realDir = realpathSync(targetDir);
148
+ const realCwd = realpathSync(cwd);
149
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
150
+ res.writeHead(400, { 'Content-Type': 'application/json' });
151
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
152
+ return;
153
+ }
154
+ const buf = Buffer.concat(chunks);
155
+ const headerEnd = buf.indexOf('\r\n\r\n');
156
+ if (headerEnd === -1) throw new Error('Malformed multipart');
157
+ const headerStr = buf.slice(0, headerEnd).toString();
158
+ const nameMatch = headerStr.match(/filename="([^"]+)"/);
159
+ if (!nameMatch) throw new Error('No filename');
160
+ // sanitize 与 /api/upload 一致:只过真正有害的字符,保留 Unix 合法 : " < > | ? * 等
161
+ const originalName = nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_');
162
+ // Windows 保留设备名守卫(见 WINDOWS_RESERVED_NAMES 注释)。
163
+ {
164
+ const base = originalName.split('.')[0].trim().toLowerCase();
165
+ if (deps.WINDOWS_RESERVED_NAMES.test(base)) {
166
+ throw new Error('Reserved filename not allowed');
167
+ }
168
+ }
169
+ const bodyStart = headerEnd + 4;
170
+ const closingBoundary = Buffer.from('\r\n--' + boundary);
171
+ const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
172
+ const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
173
+ // Resolve unique filename via exclusive write (wx);避免并发 TOCTOU 覆盖
174
+ const dotIdx = originalName.lastIndexOf('.');
175
+ const stem = dotIdx > 0 ? originalName.slice(0, dotIdx) : originalName;
176
+ const ext = dotIdx > 0 ? originalName.slice(dotIdx) : '';
177
+ let finalName = originalName;
178
+ let savePath = join(realDir, finalName);
179
+ let counter = 1;
180
+ let written = false;
181
+ // 最多重试 10000 次(极端场景保底);耗尽后必须显式抛错,防止返回虚假成功
182
+ while (counter < 10001) {
183
+ try {
184
+ writeFileSync(savePath, fileData, { flag: 'wx' });
185
+ written = true;
186
+ break;
187
+ } catch (e) {
188
+ if (e && e.code === 'EEXIST') {
189
+ finalName = `${stem}-${counter}${ext}`;
190
+ savePath = join(realDir, finalName);
191
+ counter++;
192
+ continue;
193
+ }
194
+ throw e;
195
+ }
196
+ }
197
+ if (!written) throw new Error('Too many filename conflicts');
198
+ const relPath = dir ? `${dir}/${finalName}` : finalName;
199
+ res.writeHead(200, { 'Content-Type': 'application/json' });
200
+ res.end(JSON.stringify({ ok: true, name: finalName, relPath }));
201
+ } catch (err) {
202
+ console.error('import-file error:', err);
203
+ res.writeHead(500, { 'Content-Type': 'application/json' });
204
+ res.end(JSON.stringify({ error: 'Import failed' }));
205
+ }
206
+ });
207
+ }
208
+
209
+ function browseDir(req, res, parsedUrl) {
210
+ try {
211
+ const dirPath = parsedUrl.searchParams.get('path') || homedir();
212
+ if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
213
+ res.writeHead(400, { 'Content-Type': 'application/json' });
214
+ res.end(JSON.stringify({ error: 'Invalid directory' }));
215
+ return;
216
+ }
217
+ const entries = readdirSync(dirPath, { withFileTypes: true });
218
+ const dirs = [];
219
+ for (const entry of entries) {
220
+ if (!entry.isDirectory()) continue;
221
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
222
+ const fullPath = join(dirPath, entry.name);
223
+ let hasGit = false;
224
+ try { hasGit = existsSync(join(fullPath, '.git')); } catch {}
225
+ dirs.push({ name: entry.name, path: fullPath, hasGit });
226
+ }
227
+ dirs.sort((a, b) => {
228
+ if (a.hasGit !== b.hasGit) return a.hasGit ? -1 : 1;
229
+ return a.name.localeCompare(b.name);
230
+ });
231
+ const parent = join(dirPath, '..');
232
+ res.writeHead(200, { 'Content-Type': 'application/json' });
233
+ res.end(JSON.stringify({ current: dirPath, parent: parent !== dirPath ? parent : null, dirs }));
234
+ } catch (err) {
235
+ res.writeHead(500, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ error: err.message }));
237
+ }
238
+ }
239
+
240
+ async function files(req, res, parsedUrl, isLocal, deps) {
241
+ const reqPath = parsedUrl.searchParams.get('path') || '.';
242
+ // 安全校验:拒绝绝对路径和 .. 路径穿越
243
+ if (reqPath.startsWith('/') || reqPath.includes('..')) {
244
+ res.writeHead(400, { 'Content-Type': 'application/json' });
245
+ res.end(JSON.stringify({ error: 'Invalid path' }));
246
+ return;
247
+ }
248
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
249
+ const targetDir = join(cwd, reqPath);
250
+ try {
251
+ const entries = readdirSync(targetDir, { withFileTypes: true });
252
+ const items = entries
253
+ .filter(e => !deps.IGNORED_PATTERNS.has(e.name))
254
+ .map(e => {
255
+ // Dirent.isDirectory() 不解引用 symlink —— 对指向目录的 symlink 也返回 false。
256
+ // 需要对 symlink 单独 statSync(follow link)才能拿到真实类型,否则前端会把
257
+ // symlink-to-dir 当成文件渲染,不可展开。断链时 fallback 到 file,避免单个
258
+ // 坏链接让整个目录返回 404。
259
+ let type = e.isDirectory() ? 'directory' : 'file';
260
+ if (e.isSymbolicLink()) {
261
+ try { type = statSync(join(targetDir, e.name)).isDirectory() ? 'directory' : 'file'; }
262
+ catch { type = 'file'; }
263
+ }
264
+ return { name: e.name, type };
265
+ })
266
+ .sort((a, b) => {
267
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
268
+ return a.name.localeCompare(b.name);
269
+ });
270
+ // 使用 git check-ignore 批量检测被 .gitignore 忽略的文件
271
+ let gitIgnoredSet = new Set();
272
+ try {
273
+ const names = items.map(i => {
274
+ const rel = reqPath === '.' ? i.name : `${reqPath}/${i.name}`;
275
+ return i.type === 'directory' ? `${rel}/` : rel;
276
+ });
277
+ if (names.length > 0) {
278
+ const result = await deps.execWithStdin('git', ['check-ignore', '--stdin'], names.join('\n'), {
279
+ cwd,
280
+ timeout: 3000,
281
+ });
282
+ result.split('\n').filter(Boolean).forEach(line => {
283
+ const name = line.endsWith('/') ? line.slice(0, -1) : line;
284
+ const baseName = name.includes('/') ? name.split('/').pop() : name;
285
+ gitIgnoredSet.add(baseName);
286
+ });
287
+ }
288
+ } catch { /* git 未安装或非 git 仓库,忽略 */ }
289
+ const result = items.map(i => gitIgnoredSet.has(i.name) ? { ...i, gitIgnored: true } : i);
290
+ res.writeHead(200, { 'Content-Type': 'application/json' });
291
+ res.end(JSON.stringify(result));
292
+ } catch (err) {
293
+ res.writeHead(404, { 'Content-Type': 'application/json' });
294
+ res.end(JSON.stringify({ error: 'Directory not found' }));
295
+ }
296
+ }
297
+
298
+ function renameFile(req, res, parsedUrl, isLocal, deps) {
299
+ let body = '';
300
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
301
+ req.on('end', () => {
302
+ let parsed;
303
+ try { parsed = JSON.parse(body); } catch {
304
+ res.writeHead(400, { 'Content-Type': 'application/json' });
305
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
306
+ return;
307
+ }
308
+ try {
309
+ const { oldPath, newName } = parsed;
310
+ if (!oldPath || !newName) {
311
+ res.writeHead(400, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: 'Missing oldPath or newName' }));
313
+ return;
314
+ }
315
+ // 安全校验
316
+ if (oldPath.startsWith('/') || oldPath.includes('..') || newName.includes('/') || newName.includes('\\') || newName.includes('..')) {
317
+ res.writeHead(400, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ error: 'Invalid path' }));
319
+ return;
320
+ }
321
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
322
+ const oldFullPath = join(cwd, oldPath);
323
+ const parentDir = dirname(oldFullPath);
324
+ const newFullPath = join(parentDir, newName);
325
+ // 检查源文件存在
326
+ if (!existsSync(oldFullPath)) {
327
+ res.writeHead(404, { 'Content-Type': 'application/json' });
328
+ res.end(JSON.stringify({ error: 'File not found' }));
329
+ return;
330
+ }
331
+ // 检查目标是否已存在
332
+ if (existsSync(newFullPath)) {
333
+ res.writeHead(409, { 'Content-Type': 'application/json' });
334
+ res.end(JSON.stringify({ error: 'Target already exists' }));
335
+ return;
336
+ }
337
+ renameSync(oldFullPath, newFullPath);
338
+ const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
339
+ res.writeHead(200, { 'Content-Type': 'application/json' });
340
+ res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
341
+ } catch (err) {
342
+ res.writeHead(500, { 'Content-Type': 'application/json' });
343
+ res.end(JSON.stringify({ error: err.message }));
344
+ }
345
+ });
346
+ }
347
+
348
+ function moveFile(req, res, parsedUrl, isLocal, deps) {
349
+ let body = '';
350
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
351
+ req.on('end', () => {
352
+ let parsed;
353
+ try { parsed = JSON.parse(body); } catch {
354
+ res.writeHead(400, { 'Content-Type': 'application/json' });
355
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
356
+ return;
357
+ }
358
+ try {
359
+ const { fromPath, toDir } = parsed;
360
+ if (!fromPath || !toDir) {
361
+ res.writeHead(400, { 'Content-Type': 'application/json' });
362
+ res.end(JSON.stringify({ error: 'Missing fromPath or toDir' }));
363
+ return;
364
+ }
365
+ // 安全校验
366
+ if (fromPath.startsWith('/') || fromPath.includes('..') || toDir.startsWith('/') || toDir.includes('..')) {
367
+ res.writeHead(400, { 'Content-Type': 'application/json' });
368
+ res.end(JSON.stringify({ error: 'Invalid path' }));
369
+ return;
370
+ }
371
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
372
+ const oldFullPath = join(cwd, fromPath);
373
+ const toDirFull = join(cwd, toDir);
374
+ // 检查源文件/目录存在
375
+ if (!existsSync(oldFullPath)) {
376
+ res.writeHead(404, { 'Content-Type': 'application/json' });
377
+ res.end(JSON.stringify({ error: 'Source not found' }));
378
+ return;
379
+ }
380
+ // 检查目标目录存在且是目录
381
+ if (!existsSync(toDirFull) || !statSync(toDirFull).isDirectory()) {
382
+ res.writeHead(400, { 'Content-Type': 'application/json' });
383
+ res.end(JSON.stringify({ error: 'Target directory not found' }));
384
+ return;
385
+ }
386
+ // 不能把目录移到自身或其子目录下
387
+ if (statSync(oldFullPath).isDirectory()) {
388
+ const srcResolved = resolve(oldFullPath);
389
+ const destResolved = resolve(toDirFull);
390
+ if (destResolved === srcResolved || destResolved.startsWith(srcResolved + sep)) {
391
+ res.writeHead(400, { 'Content-Type': 'application/json' });
392
+ res.end(JSON.stringify({ error: 'Cannot move directory into itself' }));
393
+ return;
394
+ }
395
+ }
396
+ const name = basename(fromPath);
397
+ const newFullPath = join(toDirFull, name);
398
+ // 检查目标位置不存在同名文件
399
+ if (existsSync(newFullPath)) {
400
+ res.writeHead(409, { 'Content-Type': 'application/json' });
401
+ res.end(JSON.stringify({ error: 'Target already exists' }));
402
+ return;
403
+ }
404
+ try {
405
+ renameSync(oldFullPath, newFullPath);
406
+ } catch (mvErr) {
407
+ if (mvErr.code === 'EXDEV') {
408
+ // 跨文件系统:fallback to copy + delete。先 lstat 拒 symlink ——避免攻击者 swap 让
409
+ // cpSync 跟随复制 + rmSync 跟随删除(同 /api/delete-file 的 TOCTOU)。
410
+ const oldStat = lstatSync(oldFullPath);
411
+ if (oldStat.isSymbolicLink()) {
412
+ res.writeHead(400, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ error: 'Cannot move symbolic links via this endpoint' }));
414
+ return;
415
+ }
416
+ if (oldStat.isDirectory()) {
417
+ // dereference: false 是 Node 默认,但显式写明意图——避免未来默认变更让递归 copy
418
+ // 跟随内嵌 symlink 复制到 newFullPath 形成 cwd 内"指向 cwd 外"的活链。
419
+ cpSync(oldFullPath, newFullPath, { recursive: true, dereference: false });
420
+ rmSync(oldFullPath, { recursive: true, force: true });
421
+ } else {
422
+ copyFileSync(oldFullPath, newFullPath);
423
+ unlinkSync(oldFullPath);
424
+ }
425
+ } else if (mvErr.code === 'EEXIST') {
426
+ res.writeHead(409, { 'Content-Type': 'application/json' });
427
+ res.end(JSON.stringify({ error: 'Target already exists' }));
428
+ return;
429
+ } else {
430
+ throw mvErr;
431
+ }
432
+ }
433
+ // 返回前端 JSON 统一 POSIX 风格,path.join 在 Win 上会产 backslash,需 normalize 回 '/'。
434
+ const newRelPath = join(toDir, name).replace(/\\/g, '/');
435
+ res.writeHead(200, { 'Content-Type': 'application/json' });
436
+ res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
437
+ } catch (err) {
438
+ console.error('move-file error:', err);
439
+ res.writeHead(500, { 'Content-Type': 'application/json' });
440
+ res.end(JSON.stringify({ error: 'Internal server error' }));
441
+ }
442
+ });
443
+ }
444
+
445
+ function deleteFile(req, res, parsedUrl, isLocal, deps) {
446
+ let body = '';
447
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
448
+ req.on('end', () => {
449
+ let parsed;
450
+ try { parsed = JSON.parse(body); } catch {
451
+ res.writeHead(400, { 'Content-Type': 'application/json' });
452
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
453
+ return;
454
+ }
455
+ try {
456
+ const { path: filePath } = parsed;
457
+ if (!filePath) {
458
+ res.writeHead(400, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify({ error: 'Missing path' }));
460
+ return;
461
+ }
462
+ if (filePath.startsWith('/') || filePath.includes('..')) {
463
+ res.writeHead(400, { 'Content-Type': 'application/json' });
464
+ res.end(JSON.stringify({ error: 'Invalid path' }));
465
+ return;
466
+ }
467
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
468
+ const fullPath = join(cwd, filePath);
469
+ if (!existsSync(fullPath)) {
470
+ res.writeHead(404, { 'Content-Type': 'application/json' });
471
+ res.end(JSON.stringify({ error: 'File not found' }));
472
+ return;
473
+ }
474
+ const realFull = realpathSync(fullPath);
475
+ const realCwd = realpathSync(cwd);
476
+ if (!realFull.startsWith(realCwd + sep)) {
477
+ res.writeHead(400, { 'Content-Type': 'application/json' });
478
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
479
+ return;
480
+ }
481
+ // 用 lstatSync 不跟随 symlink ——避免 TOCTOU 攻击者把目标换成软链让 rmSync(recursive)
482
+ // 在 POSIX 上跟着删 cwd 外的目录。一切 symlink 目标都拒绝。
483
+ const stat = lstatSync(fullPath);
484
+ if (stat.isSymbolicLink()) {
485
+ res.writeHead(400, { 'Content-Type': 'application/json' });
486
+ res.end(JSON.stringify({ error: 'Cannot delete symbolic links via this endpoint' }));
487
+ return;
488
+ }
489
+ if (stat.isDirectory()) {
490
+ // protectedDirs 守卫得对 Win backslash 路径 & NTFS case-insensitive 同时设防 ——
491
+ // 否则 `path: "node_modules\\foo"` 或 `".GIT"` 都能绕过 split('/') 直接删整目录。
492
+ const protectedDirs = new Set(['node_modules', '.git', '.svn', '.hg']);
493
+ const normalizedSegs = filePath.split(/[\\/]/).map(s => s.toLowerCase());
494
+ if (normalizedSegs.some(part => protectedDirs.has(part))) {
495
+ res.writeHead(400, { 'Content-Type': 'application/json' });
496
+ res.end(JSON.stringify({ error: 'Cannot delete protected directory' }));
497
+ return;
498
+ }
499
+ // Defense-in-depth:lstat 跟 rmSync 间窗内攻击者再次 swap → 再 realpath 确认。
500
+ const realFull2 = realpathSync(fullPath);
501
+ if (!realFull2.startsWith(realCwd + sep)) {
502
+ res.writeHead(400, { 'Content-Type': 'application/json' });
503
+ res.end(JSON.stringify({ error: 'Path escaped cwd after validation' }));
504
+ return;
505
+ }
506
+ rmSync(fullPath, { recursive: true, force: true });
507
+ } else if (stat.isFile()) {
508
+ unlinkSync(fullPath);
509
+ } else {
510
+ res.writeHead(400, { 'Content-Type': 'application/json' });
511
+ res.end(JSON.stringify({ error: 'Unsupported path type' }));
512
+ return;
513
+ }
514
+ res.writeHead(200, { 'Content-Type': 'application/json' });
515
+ res.end(JSON.stringify({ ok: true }));
516
+ } catch (err) {
517
+ res.writeHead(500, { 'Content-Type': 'application/json' });
518
+ res.end(JSON.stringify({ error: err.message }));
519
+ }
520
+ });
521
+ }
522
+
523
+ function revealFile(req, res, parsedUrl, isLocal, deps) {
524
+ let body = '';
525
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
526
+ req.on('end', () => {
527
+ let parsed;
528
+ try { parsed = JSON.parse(body); } catch {
529
+ res.writeHead(400, { 'Content-Type': 'application/json' });
530
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
531
+ return;
532
+ }
533
+ try {
534
+ const { path: filePath } = parsed;
535
+ if (!filePath) {
536
+ res.writeHead(400, { 'Content-Type': 'application/json' });
537
+ res.end(JSON.stringify({ error: 'Missing path' }));
538
+ return;
539
+ }
540
+ if (filePath.startsWith('/') || filePath.includes('..')) {
541
+ res.writeHead(400, { 'Content-Type': 'application/json' });
542
+ res.end(JSON.stringify({ error: 'Invalid path' }));
543
+ return;
544
+ }
545
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
546
+ const fullPath = join(cwd, filePath);
547
+ if (!existsSync(fullPath)) {
548
+ res.writeHead(404, { 'Content-Type': 'application/json' });
549
+ res.end(JSON.stringify({ error: 'File not found' }));
550
+ return;
551
+ }
552
+ const realFull = realpathSync(fullPath);
553
+ const realCwd = realpathSync(cwd);
554
+ if (!realFull.startsWith(realCwd + sep)) {
555
+ res.writeHead(400, { 'Content-Type': 'application/json' });
556
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
557
+ return;
558
+ }
559
+ const plat = process.platform;
560
+ if (plat === 'darwin') {
561
+ execFile('open', ['-R', fullPath], () => {});
562
+ } else if (plat === 'win32') {
563
+ // explorer /select,<path> 必须合到一个 arg;分两个会让含空格/中文路径 escape 失败。
564
+ spawn('explorer.exe', [`/select,${fullPath}`], { shell: false, windowsHide: true });
565
+ } else {
566
+ execFile('xdg-open', [dirname(fullPath)], () => {});
567
+ }
568
+ res.writeHead(200, { 'Content-Type': 'application/json' });
569
+ res.end(JSON.stringify({ ok: true, fullPath }));
570
+ } catch (err) {
571
+ res.writeHead(500, { 'Content-Type': 'application/json' });
572
+ res.end(JSON.stringify({ error: err.message }));
573
+ }
574
+ });
575
+ }
576
+
577
+ function openFile(req, res, parsedUrl, isLocal, deps) {
578
+ let body = '';
579
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
580
+ req.on('end', () => {
581
+ let parsed;
582
+ try { parsed = JSON.parse(body); } catch {
583
+ res.writeHead(400, { 'Content-Type': 'application/json' });
584
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
585
+ return;
586
+ }
587
+ try {
588
+ const { path: filePath } = parsed;
589
+ if (!filePath) {
590
+ res.writeHead(400, { 'Content-Type': 'application/json' });
591
+ res.end(JSON.stringify({ error: 'Missing path' }));
592
+ return;
593
+ }
594
+ if (filePath.startsWith('/') || filePath.includes('..')) {
595
+ res.writeHead(400, { 'Content-Type': 'application/json' });
596
+ res.end(JSON.stringify({ error: 'Invalid path' }));
597
+ return;
598
+ }
599
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
600
+ const fullPath = join(cwd, filePath);
601
+ if (!existsSync(fullPath)) {
602
+ res.writeHead(404, { 'Content-Type': 'application/json' });
603
+ res.end(JSON.stringify({ error: 'File not found' }));
604
+ return;
605
+ }
606
+ const realFull = realpathSync(fullPath);
607
+ const realCwd = realpathSync(cwd);
608
+ if (!realFull.startsWith(realCwd + sep)) {
609
+ res.writeHead(400, { 'Content-Type': 'application/json' });
610
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
611
+ return;
612
+ }
613
+ const plat = process.platform;
614
+ if (plat === 'darwin') {
615
+ execFile('open', [fullPath], () => {});
616
+ } else if (plat === 'win32') {
617
+ execFile('cmd.exe', ['/c', 'start', '', fullPath], () => {});
618
+ } else {
619
+ execFile('xdg-open', [fullPath], () => {});
620
+ }
621
+ res.writeHead(200, { 'Content-Type': 'application/json' });
622
+ res.end(JSON.stringify({ ok: true }));
623
+ } catch (err) {
624
+ res.writeHead(500, { 'Content-Type': 'application/json' });
625
+ res.end(JSON.stringify({ error: err.message }));
626
+ }
627
+ });
628
+ }
629
+
630
+ function resolvePath(req, res, parsedUrl, isLocal, deps) {
631
+ let body = '';
632
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
633
+ req.on('end', () => {
634
+ let parsed;
635
+ try { parsed = JSON.parse(body); } catch {
636
+ res.writeHead(400, { 'Content-Type': 'application/json' });
637
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
638
+ return;
639
+ }
640
+ try {
641
+ const relPath = parsed.path || '';
642
+ if (relPath.startsWith('/') || relPath.includes('..')) {
643
+ res.writeHead(400, { 'Content-Type': 'application/json' });
644
+ res.end(JSON.stringify({ error: 'Invalid path' }));
645
+ return;
646
+ }
647
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
648
+ const fullPath = relPath ? join(cwd, relPath) : cwd;
649
+ res.writeHead(200, { 'Content-Type': 'application/json' });
650
+ res.end(JSON.stringify({ ok: true, fullPath }));
651
+ } catch (err) {
652
+ res.writeHead(500, { 'Content-Type': 'application/json' });
653
+ res.end(JSON.stringify({ error: err.message }));
654
+ }
655
+ });
656
+ }
657
+
658
+ function createFile(req, res, parsedUrl, isLocal, deps) {
659
+ let body = '';
660
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
661
+ req.on('end', () => {
662
+ let parsed;
663
+ try { parsed = JSON.parse(body); } catch {
664
+ res.writeHead(400, { 'Content-Type': 'application/json' });
665
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
666
+ return;
667
+ }
668
+ try {
669
+ const { dirPath, name } = parsed;
670
+ if (!name) {
671
+ res.writeHead(400, { 'Content-Type': 'application/json' });
672
+ res.end(JSON.stringify({ error: 'Missing name' }));
673
+ return;
674
+ }
675
+ if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
676
+ res.writeHead(400, { 'Content-Type': 'application/json' });
677
+ res.end(JSON.stringify({ error: 'Invalid file name' }));
678
+ return;
679
+ }
680
+ const relDir = dirPath || '';
681
+ if (relDir.startsWith('/') || relDir.includes('..')) {
682
+ res.writeHead(400, { 'Content-Type': 'application/json' });
683
+ res.end(JSON.stringify({ error: 'Invalid path' }));
684
+ return;
685
+ }
686
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
687
+ const fullDirPath = relDir ? join(cwd, relDir) : cwd;
688
+ if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
689
+ res.writeHead(400, { 'Content-Type': 'application/json' });
690
+ res.end(JSON.stringify({ error: 'Directory not found' }));
691
+ return;
692
+ }
693
+ const realDir = realpathSync(fullDirPath);
694
+ const realCwd = realpathSync(cwd);
695
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
696
+ res.writeHead(400, { 'Content-Type': 'application/json' });
697
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
698
+ return;
699
+ }
700
+ const fullPath = join(fullDirPath, name);
701
+ if (existsSync(fullPath)) {
702
+ res.writeHead(409, { 'Content-Type': 'application/json' });
703
+ res.end(JSON.stringify({ error: 'File already exists' }));
704
+ return;
705
+ }
706
+ writeFileSync(fullPath, '');
707
+ const relPath = relDir ? `${relDir}/${name}` : name;
708
+ res.writeHead(200, { 'Content-Type': 'application/json' });
709
+ res.end(JSON.stringify({ ok: true, path: relPath }));
710
+ } catch (err) {
711
+ res.writeHead(500, { 'Content-Type': 'application/json' });
712
+ res.end(JSON.stringify({ error: err.message }));
713
+ }
714
+ });
715
+ }
716
+
717
+ function openTerminal(req, res, parsedUrl, isLocal, deps) {
718
+ let body = '';
719
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
720
+ req.on('end', () => {
721
+ let parsed;
722
+ try { parsed = JSON.parse(body); } catch {
723
+ res.writeHead(400, { 'Content-Type': 'application/json' });
724
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
725
+ return;
726
+ }
727
+ try {
728
+ const relDir = (parsed.path || '');
729
+ if (relDir.startsWith('/') || relDir.includes('..')) {
730
+ res.writeHead(400, { 'Content-Type': 'application/json' });
731
+ res.end(JSON.stringify({ error: 'Invalid path' }));
732
+ return;
733
+ }
734
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
735
+ const fullDir = relDir ? join(cwd, relDir) : cwd;
736
+ if (!existsSync(fullDir) || !statSync(fullDir).isDirectory()) {
737
+ res.writeHead(400, { 'Content-Type': 'application/json' });
738
+ res.end(JSON.stringify({ error: 'Directory not found' }));
739
+ return;
740
+ }
741
+ const realDir = realpathSync(fullDir);
742
+ const realCwd = realpathSync(cwd);
743
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
744
+ res.writeHead(400, { 'Content-Type': 'application/json' });
745
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
746
+ return;
747
+ }
748
+ const plat = process.platform;
749
+ if (plat === 'darwin') {
750
+ spawn('open', ['-a', 'Terminal', fullDir], { stdio: 'ignore', detached: true }).unref();
751
+ } else if (plat === 'win32') {
752
+ spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true, windowsHide: true }).unref();
753
+ } else {
754
+ // Linux: try common terminal emulators
755
+ const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
756
+ let launched = false;
757
+ for (const term of terminals) {
758
+ try {
759
+ if (term === 'gnome-terminal') {
760
+ spawn(term, ['--working-directory=' + fullDir], { stdio: 'ignore', detached: true }).unref();
761
+ } else if (term === 'konsole') {
762
+ spawn(term, ['--workdir', fullDir], { stdio: 'ignore', detached: true }).unref();
763
+ } else {
764
+ spawn(term, [], { cwd: fullDir, stdio: 'ignore', detached: true }).unref();
765
+ }
766
+ launched = true;
767
+ break;
768
+ } catch { continue; }
769
+ }
770
+ if (!launched) {
771
+ spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true }).unref();
772
+ }
773
+ }
774
+ res.writeHead(200, { 'Content-Type': 'application/json' });
775
+ res.end(JSON.stringify({ ok: true }));
776
+ } catch (err) {
777
+ res.writeHead(500, { 'Content-Type': 'application/json' });
778
+ res.end(JSON.stringify({ error: err.message }));
779
+ }
780
+ });
781
+ }
782
+
783
+ function createDir(req, res, parsedUrl, isLocal, deps) {
784
+ let body = '';
785
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
786
+ req.on('end', () => {
787
+ let parsed;
788
+ try { parsed = JSON.parse(body); } catch {
789
+ res.writeHead(400, { 'Content-Type': 'application/json' });
790
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
791
+ return;
792
+ }
793
+ try {
794
+ const { dirPath, name } = parsed;
795
+ if (!name) {
796
+ res.writeHead(400, { 'Content-Type': 'application/json' });
797
+ res.end(JSON.stringify({ error: 'Missing name' }));
798
+ return;
799
+ }
800
+ if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
801
+ res.writeHead(400, { 'Content-Type': 'application/json' });
802
+ res.end(JSON.stringify({ error: 'Invalid folder name' }));
803
+ return;
804
+ }
805
+ const relDir = dirPath || '';
806
+ if (relDir.startsWith('/') || relDir.includes('..')) {
807
+ res.writeHead(400, { 'Content-Type': 'application/json' });
808
+ res.end(JSON.stringify({ error: 'Invalid path' }));
809
+ return;
810
+ }
811
+ const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
812
+ const fullDirPath = relDir ? join(cwd, relDir) : cwd;
813
+ if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
814
+ res.writeHead(400, { 'Content-Type': 'application/json' });
815
+ res.end(JSON.stringify({ error: 'Directory not found' }));
816
+ return;
817
+ }
818
+ const realDir = realpathSync(fullDirPath);
819
+ const realCwd = realpathSync(cwd);
820
+ if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
821
+ res.writeHead(400, { 'Content-Type': 'application/json' });
822
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
823
+ return;
824
+ }
825
+ const fullPath = join(fullDirPath, name);
826
+ if (existsSync(fullPath)) {
827
+ res.writeHead(409, { 'Content-Type': 'application/json' });
828
+ res.end(JSON.stringify({ error: 'Already exists' }));
829
+ return;
830
+ }
831
+ mkdirSync(fullPath);
832
+ const relPath = relDir ? `${relDir}/${name}` : name;
833
+ res.writeHead(200, { 'Content-Type': 'application/json' });
834
+ res.end(JSON.stringify({ ok: true, path: relPath }));
835
+ } catch (err) {
836
+ res.writeHead(500, { 'Content-Type': 'application/json' });
837
+ res.end(JSON.stringify({ error: err.message }));
838
+ }
839
+ });
840
+ }
841
+
842
+ function openLogDir(req, res) {
843
+ const dir = LOG_FILE ? dirname(LOG_FILE) : LOG_DIR;
844
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
845
+ execFile(cmd, [dir], () => {});
846
+ res.writeHead(200, { 'Content-Type': 'application/json' });
847
+ res.end(JSON.stringify({ ok: true, dir }));
848
+ }
849
+
850
+ function openProfileDir(req, res) {
851
+ const dir = dirname(PROFILE_PATH);
852
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
853
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
854
+ execFile(cmd, [dir], () => {});
855
+ res.writeHead(200, { 'Content-Type': 'application/json' });
856
+ res.end(JSON.stringify({ ok: true, dir }));
857
+ }
858
+
859
+ function openProjectDir(req, res) {
860
+ const dir = process.env.CCV_PROJECT_DIR || process.cwd();
861
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
862
+ execFile(cmd, [dir], () => {});
863
+ res.writeHead(200, { 'Content-Type': 'application/json' });
864
+ res.end(JSON.stringify({ ok: true, dir }));
865
+ }
866
+
867
+ function editorOpen(req, res, parsedUrl, isLocal, deps) {
868
+ let body = '';
869
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
870
+ req.on('end', () => {
871
+ try {
872
+ const { sessionId, filePath } = JSON.parse(body);
873
+ if (!sessionId || !filePath) {
874
+ res.writeHead(400, { 'Content-Type': 'application/json' });
875
+ res.end(JSON.stringify({ error: 'Missing sessionId or filePath' }));
876
+ return;
877
+ }
878
+ deps.editorSessions.set(sessionId, { filePath, done: false, createdAt: Date.now() });
879
+ // Broadcast to all terminal WebSocket clients
880
+ if (deps.terminalWss) {
881
+ const msg = JSON.stringify({ type: 'editor-open', sessionId, filePath });
882
+ deps.terminalWss.clients.forEach(client => {
883
+ if (client.readyState === 1) {
884
+ try { client.send(msg); } catch {}
885
+ }
886
+ });
887
+ }
888
+ res.writeHead(200, { 'Content-Type': 'application/json' });
889
+ res.end(JSON.stringify({ ok: true }));
890
+ } catch {
891
+ res.writeHead(400, { 'Content-Type': 'application/json' });
892
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
893
+ }
894
+ });
895
+ }
896
+
897
+ function editorStatus(req, res, parsedUrl, isLocal, deps) {
898
+ const id = parsedUrl.searchParams.get('id');
899
+ if (!id) {
900
+ res.writeHead(400, { 'Content-Type': 'application/json' });
901
+ res.end(JSON.stringify({ error: 'Missing id' }));
902
+ return;
903
+ }
904
+ const session = deps.editorSessions.get(id);
905
+ res.writeHead(200, { 'Content-Type': 'application/json' });
906
+ res.end(JSON.stringify({ done: session ? session.done : true }));
907
+ }
908
+
909
+ function editorDone(req, res, parsedUrl, isLocal, deps) {
910
+ let body = '';
911
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
912
+ req.on('end', () => {
913
+ try {
914
+ const { sessionId } = JSON.parse(body);
915
+ if (!sessionId) {
916
+ res.writeHead(400, { 'Content-Type': 'application/json' });
917
+ res.end(JSON.stringify({ error: 'Missing sessionId' }));
918
+ return;
919
+ }
920
+ const session = deps.editorSessions.get(sessionId);
921
+ if (session) {
922
+ session.done = true;
923
+ }
924
+ // Clean up after a short delay to allow the polling to pick it up
925
+ setTimeout(() => deps.editorSessions.delete(sessionId), 5000);
926
+ res.writeHead(200, { 'Content-Type': 'application/json' });
927
+ res.end(JSON.stringify({ ok: true }));
928
+ } catch {
929
+ res.writeHead(400, { 'Content-Type': 'application/json' });
930
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
931
+ }
932
+ });
933
+ }
934
+
935
+ export const filesFsRoutes = [
936
+ { method: 'POST', match: 'exact', path: '/api/upload', handler: upload },
937
+ { method: 'POST', match: 'prefix', path: '/api/import-file', handler: importFile },
938
+ { method: 'GET', match: 'prefix', path: '/api/browse-dir', handler: browseDir },
939
+ { method: 'GET', match: 'exact', path: '/api/files', handler: files },
940
+ { method: 'POST', match: 'exact', path: '/api/rename-file', handler: renameFile },
941
+ { method: 'POST', match: 'exact', path: '/api/move-file', handler: moveFile },
942
+ { method: 'POST', match: 'exact', path: '/api/delete-file', handler: deleteFile },
943
+ { method: 'POST', match: 'exact', path: '/api/reveal-file', handler: revealFile },
944
+ { method: 'POST', match: 'exact', path: '/api/open-file', handler: openFile },
945
+ { method: 'POST', match: 'exact', path: '/api/resolve-path', handler: resolvePath },
946
+ { method: 'POST', match: 'exact', path: '/api/create-file', handler: createFile },
947
+ { method: 'POST', match: 'exact', path: '/api/open-terminal', handler: openTerminal },
948
+ { method: 'POST', match: 'exact', path: '/api/create-dir', handler: createDir },
949
+ { method: 'POST', match: 'exact', path: '/api/open-log-dir', handler: openLogDir },
950
+ { method: 'POST', match: 'exact', path: '/api/open-profile-dir', handler: openProfileDir },
951
+ { method: 'POST', match: 'exact', path: '/api/open-project-dir', handler: openProjectDir },
952
+ { method: 'POST', match: 'exact', path: '/api/editor-open', handler: editorOpen },
953
+ { method: 'GET', match: 'prefix', path: '/api/editor-status', handler: editorStatus },
954
+ { method: 'POST', match: 'exact', path: '/api/editor-done', handler: editorDone },
955
+ ];