colonynote 0.0.0 → 1.0.0-beta.12
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/LICENSE +21 -0
- package/README.md +125 -0
- package/README.zh.md +125 -0
- package/bin/colonynote.js +148 -0
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/client/assets/_baseUniq-T-svP185.js +1 -0
- package/dist/client/assets/arc-DPGp6UO-.js +1 -0
- package/dist/client/assets/architectureDiagram-Q4EWVU46-BjON9QCG.js +36 -0
- package/dist/client/assets/blockDiagram-DXYQGD6D-BLoDfE0P.js +132 -0
- package/dist/client/assets/c4Diagram-AHTNJAMY-fQ1soS6G.js +10 -0
- package/dist/client/assets/channel-MqQDOUgw.js +1 -0
- package/dist/client/assets/chunk-4BX2VUAB-BQI2dXlO.js +1 -0
- package/dist/client/assets/chunk-4TB4RGXK-B0FuCAMO.js +206 -0
- package/dist/client/assets/chunk-55IACEB6-SbmoW08H.js +1 -0
- package/dist/client/assets/chunk-EDXVE4YY-BTkJGdcb.js +1 -0
- package/dist/client/assets/chunk-FMBD7UC4-2RG66CzU.js +15 -0
- package/dist/client/assets/chunk-OYMX7WX6-B7irjINX.js +231 -0
- package/dist/client/assets/chunk-QZHKN3VN-rsXCL0TK.js +1 -0
- package/dist/client/assets/chunk-YZCP3GAM-ArKVHPRF.js +1 -0
- package/dist/client/assets/classDiagram-6PBFFD2Q-CZdm6ATq.js +1 -0
- package/dist/client/assets/classDiagram-v2-HSJHXN6E-CZdm6ATq.js +1 -0
- package/dist/client/assets/clone-CxH2cmrL.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-DVBMSGze.js +1 -0
- package/dist/client/assets/cytoscape.esm-DxGcaOPV.js +331 -0
- package/dist/client/assets/dagre-KV5264BT-COm1avIi.js +4 -0
- package/dist/client/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/client/assets/diagram-5BDNPKRD-BHzIKztM.js +10 -0
- package/dist/client/assets/diagram-G4DWMVQ6-D1JhDGDX.js +24 -0
- package/dist/client/assets/diagram-MMDJMWI5-q28BYReU.js +43 -0
- package/dist/client/assets/diagram-TYMM5635-BoK9AqHX.js +24 -0
- package/dist/client/assets/erDiagram-SMLLAGMA-ogyu4HEv.js +85 -0
- package/dist/client/assets/flowDiagram-DWJPFMVM-DAdHTFWv.js +162 -0
- package/dist/client/assets/ganttDiagram-T4ZO3ILL-DrS8W1v-.js +292 -0
- package/dist/client/assets/gitGraphDiagram-UUTBAWPF-DtK9J5bd.js +106 -0
- package/dist/client/assets/graph-BO_7TUSR.js +1 -0
- package/dist/client/assets/index-BE_qBo7x.js +778 -0
- package/dist/client/assets/index-DpiDy0Bm.css +1 -0
- package/dist/client/assets/infoDiagram-42DDH7IO-C-F87kp2.js +2 -0
- package/dist/client/assets/init-Gi6I4Gst.js +1 -0
- package/dist/client/assets/ishikawaDiagram-UXIWVN3A-DpaZPVt0.js +70 -0
- package/dist/client/assets/journeyDiagram-VCZTEJTY-DLdy6qU_.js +139 -0
- package/dist/client/assets/kanban-definition-6JOO6SKY-Cw72R2rP.js +89 -0
- package/dist/client/assets/katex-DkKDou_j.js +257 -0
- package/dist/client/assets/layout-CVdqBmBb.js +1 -0
- package/dist/client/assets/linear-BZWegF8q.js +1 -0
- package/dist/client/assets/min-Bc431PrS.js +1 -0
- package/dist/client/assets/mindmap-definition-QFDTVHPH-DPQMmeL4.js +96 -0
- package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/client/assets/pieDiagram-DEJITSTG-QjO816qL.js +30 -0
- package/dist/client/assets/quadrantDiagram-34T5L4WZ-BDxYyK8x.js +7 -0
- package/dist/client/assets/requirementDiagram-MS252O5E-CTRbkAm3.js +84 -0
- package/dist/client/assets/sankeyDiagram-XADWPNL6-s1VkzWAg.js +10 -0
- package/dist/client/assets/sequenceDiagram-FGHM5R23-UyatbRZw.js +157 -0
- package/dist/client/assets/stateDiagram-FHFEXIEX-ClKORA1p.js +1 -0
- package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-Cf33alPP.js +1 -0
- package/dist/client/assets/timeline-definition-GMOUNBTQ-BaJ6GkFx.js +120 -0
- package/dist/client/assets/vennDiagram-DHZGUBPP-C2I2hokK.js +34 -0
- package/dist/client/assets/wardley-RL74JXVD-BaYnW9zG.js +162 -0
- package/dist/client/assets/wardleyDiagram-NUSXRM2D-lI3b3rv0.js +20 -0
- package/dist/client/assets/xychartDiagram-5P7HB3ND-gAPYB-k3.js +7 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.html +48 -0
- package/dist/client/logo.png +0 -0
- package/dist/config.js +117 -0
- package/dist/server/api.js +593 -0
- package/dist/server/app.js +21 -0
- package/dist/server/ignore.js +210 -0
- package/dist/server/index.js +116 -0
- package/dist/server/watcher.js +62 -0
- package/package.json +105 -10
- package/index.js +0 -1
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { saveConfig, DEFAULT_SENSITIVE_PATHS } from '../config.js';
|
|
7
|
+
import { minimatch } from 'minimatch';
|
|
8
|
+
function isAllowed(pathStr, config) {
|
|
9
|
+
const resolved = path.resolve(pathStr);
|
|
10
|
+
return config.roots.some(root => resolved.startsWith(path.resolve(root.path)));
|
|
11
|
+
}
|
|
12
|
+
function validateRoot(rootPath, config) {
|
|
13
|
+
const resolved = path.resolve(rootPath);
|
|
14
|
+
const root = config.roots.find(r => path.resolve(r.path) === resolved);
|
|
15
|
+
return root ? path.resolve(root.path) : null;
|
|
16
|
+
}
|
|
17
|
+
function checkSensitivePath(inputPath) {
|
|
18
|
+
const basename = path.basename(inputPath);
|
|
19
|
+
for (const pattern of DEFAULT_SENSITIVE_PATHS) {
|
|
20
|
+
if (minimatch(basename, pattern, { nocase: true, dot: true }))
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
function checkNestedPath(newPath, existingRoots) {
|
|
26
|
+
const resolved = path.resolve(newPath);
|
|
27
|
+
for (const root of existingRoots) {
|
|
28
|
+
const existing = path.resolve(root.path);
|
|
29
|
+
if (resolved === existing)
|
|
30
|
+
return { isNested: true, conflictWith: root.path, reason: 'duplicate' };
|
|
31
|
+
if (resolved.startsWith(existing + path.sep))
|
|
32
|
+
return { isNested: true, conflictWith: root.path, reason: 'child' };
|
|
33
|
+
if (existing.startsWith(resolved + path.sep))
|
|
34
|
+
return { isNested: true, conflictWith: root.path, reason: 'parent' };
|
|
35
|
+
}
|
|
36
|
+
return { isNested: false };
|
|
37
|
+
}
|
|
38
|
+
function findRootForPath(filePath, config) {
|
|
39
|
+
for (const root of config.roots) {
|
|
40
|
+
const rootPath = path.resolve(root.path);
|
|
41
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
42
|
+
const fullPath = path.join(rootPath, relativePath);
|
|
43
|
+
if ((fullPath.startsWith(rootPath + path.sep) || fullPath === rootPath) && existsSync(fullPath)) {
|
|
44
|
+
return rootPath;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function hasAllowedExtension(filename, extensions) {
|
|
50
|
+
const ext = path.extname(filename).toLowerCase();
|
|
51
|
+
return extensions.includes(ext);
|
|
52
|
+
}
|
|
53
|
+
async function walkDirectory(dir, rootPath, config, matcher) {
|
|
54
|
+
const nodes = [];
|
|
55
|
+
try {
|
|
56
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const fullPath = path.join(dir, entry.name);
|
|
59
|
+
const isDir = entry.isDirectory();
|
|
60
|
+
if (!config.showHiddenFiles && entry.name.startsWith('.'))
|
|
61
|
+
continue;
|
|
62
|
+
if (matcher.isIgnored(fullPath, isDir))
|
|
63
|
+
continue;
|
|
64
|
+
if (isDir) {
|
|
65
|
+
const children = await walkDirectory(fullPath, rootPath, config, matcher);
|
|
66
|
+
nodes.push({
|
|
67
|
+
name: entry.name,
|
|
68
|
+
path: fullPath.replace(rootPath, '').replace(/\\/g, '/') || '/',
|
|
69
|
+
type: 'directory',
|
|
70
|
+
rootPath,
|
|
71
|
+
children,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else if (entry.isFile()) {
|
|
75
|
+
if (hasAllowedExtension(entry.name, config.allowedExtensions)) {
|
|
76
|
+
nodes.push({
|
|
77
|
+
name: entry.name,
|
|
78
|
+
path: fullPath.replace(rootPath, '').replace(/\\/g, '/') || '/',
|
|
79
|
+
type: 'file',
|
|
80
|
+
rootPath,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
// Re-throw error if this is the root directory, otherwise ignore
|
|
88
|
+
if (dir === rootPath) {
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
nodes.sort((a, b) => {
|
|
93
|
+
if (a.type === 'directory' && b.type === 'file')
|
|
94
|
+
return -1;
|
|
95
|
+
if (a.type === 'file' && b.type === 'directory')
|
|
96
|
+
return 1;
|
|
97
|
+
return a.name.localeCompare(b.name);
|
|
98
|
+
});
|
|
99
|
+
return nodes;
|
|
100
|
+
}
|
|
101
|
+
export function createFileRouter(config, matcher) {
|
|
102
|
+
const router = new Hono();
|
|
103
|
+
router.get('/config', async (c) => {
|
|
104
|
+
return c.json({
|
|
105
|
+
showHiddenFiles: config.showHiddenFiles,
|
|
106
|
+
allowedExtensions: config.allowedExtensions,
|
|
107
|
+
ignore: config.ignore,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
router.patch('/config', async (c) => {
|
|
111
|
+
try {
|
|
112
|
+
const body = await c.req.json();
|
|
113
|
+
const allowedFields = ['showHiddenFiles', 'allowedExtensions', 'ignore'];
|
|
114
|
+
const updates = {};
|
|
115
|
+
for (const key of allowedFields) {
|
|
116
|
+
if (key in body) {
|
|
117
|
+
updates[key] = body[key];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
saveConfig(config);
|
|
121
|
+
if (typeof updates.showHiddenFiles === 'boolean') {
|
|
122
|
+
config.showHiddenFiles = updates.showHiddenFiles;
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(updates.allowedExtensions)) {
|
|
125
|
+
config.allowedExtensions = updates.allowedExtensions;
|
|
126
|
+
}
|
|
127
|
+
if (updates.ignore) {
|
|
128
|
+
if (typeof updates.ignore.enableIgnoreFiles === 'boolean') {
|
|
129
|
+
config.ignore.enableIgnoreFiles = updates.ignore.enableIgnoreFiles;
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(updates.ignore.ignoreFileNames)) {
|
|
132
|
+
config.ignore.ignoreFileNames = updates.ignore.ignoreFileNames;
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(updates.ignore.patterns)) {
|
|
135
|
+
config.ignore.patterns = updates.ignore.patterns;
|
|
136
|
+
matcher.updateGlobalPatterns(updates.ignore.patterns);
|
|
137
|
+
}
|
|
138
|
+
matcher.clearCache();
|
|
139
|
+
}
|
|
140
|
+
return c.json({ success: true, config: { showHiddenFiles: config.showHiddenFiles, allowedExtensions: config.allowedExtensions, ignore: config.ignore } });
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.error('Failed to update config:', e);
|
|
144
|
+
return c.json({ error: 'Failed to update config' }, 500);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
// Root management routes
|
|
148
|
+
router.get('/roots', async (c) => {
|
|
149
|
+
return c.json({ roots: config.roots });
|
|
150
|
+
});
|
|
151
|
+
router.post('/roots', async (c) => {
|
|
152
|
+
try {
|
|
153
|
+
const body = await c.req.json();
|
|
154
|
+
const newPath = body.path;
|
|
155
|
+
if (!newPath)
|
|
156
|
+
return c.json({ error: 'Path is required' }, 400);
|
|
157
|
+
try {
|
|
158
|
+
const stat = await fs.stat(newPath);
|
|
159
|
+
if (!stat.isDirectory())
|
|
160
|
+
return c.json({ error: 'Path must be a directory' }, 400);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return c.json({ error: 'Path does not exist' }, 400);
|
|
164
|
+
}
|
|
165
|
+
if (checkSensitivePath(newPath))
|
|
166
|
+
return c.json({ error: 'Sensitive path not allowed' }, 400);
|
|
167
|
+
const nested = checkNestedPath(newPath, config.roots);
|
|
168
|
+
if (nested.isNested)
|
|
169
|
+
return c.json({ error: 'Nested path not allowed', conflictWith: nested.conflictWith, reason: nested.reason }, 400);
|
|
170
|
+
const newRoot = { path: path.resolve(newPath), exclude: body.exclude };
|
|
171
|
+
config.roots.push(newRoot);
|
|
172
|
+
saveConfig(config);
|
|
173
|
+
return c.json({ success: true, root: newRoot });
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
return c.json({ error: 'Failed to add root' }, 500);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
router.delete('/roots', async (c) => {
|
|
180
|
+
const pathParam = c.req.query('path');
|
|
181
|
+
if (!pathParam)
|
|
182
|
+
return c.json({ error: 'path parameter required' }, 400);
|
|
183
|
+
const idx = config.roots.findIndex(r => path.resolve(r.path) === path.resolve(pathParam));
|
|
184
|
+
if (idx === -1)
|
|
185
|
+
return c.json({ error: 'Root not found' }, 404);
|
|
186
|
+
config.roots.splice(idx, 1);
|
|
187
|
+
saveConfig(config);
|
|
188
|
+
return c.json({ success: true });
|
|
189
|
+
});
|
|
190
|
+
router.patch('/roots', async (c) => {
|
|
191
|
+
try {
|
|
192
|
+
const body = await c.req.json();
|
|
193
|
+
const { path: rootPath, exclude } = body;
|
|
194
|
+
if (!rootPath)
|
|
195
|
+
return c.json({ error: 'Path is required' }, 400);
|
|
196
|
+
const root = config.roots.find(r => path.resolve(r.path) === path.resolve(rootPath));
|
|
197
|
+
if (!root)
|
|
198
|
+
return c.json({ error: 'Root not found' }, 404);
|
|
199
|
+
if (exclude !== undefined)
|
|
200
|
+
root.exclude = exclude;
|
|
201
|
+
saveConfig(config);
|
|
202
|
+
return c.json({ success: true, root });
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
return c.json({ error: 'Failed to update root' }, 500);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
router.get('/roots/search', async (c) => {
|
|
209
|
+
const query = c.req.query('q') || '';
|
|
210
|
+
if (!query.trim())
|
|
211
|
+
return c.json({ matches: [] });
|
|
212
|
+
const matches = [];
|
|
213
|
+
let searchRoot;
|
|
214
|
+
let searchTerm;
|
|
215
|
+
if (query.startsWith('/')) {
|
|
216
|
+
const normalizedQuery = path.normalize(query);
|
|
217
|
+
const lastSlashIndex = normalizedQuery.lastIndexOf('/');
|
|
218
|
+
if (lastSlashIndex <= 0) {
|
|
219
|
+
searchRoot = '/';
|
|
220
|
+
searchTerm = normalizedQuery.slice(1);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const dirPart = normalizedQuery.slice(0, lastSlashIndex) || '/';
|
|
224
|
+
const basePart = normalizedQuery.slice(lastSlashIndex + 1);
|
|
225
|
+
if (existsSync(dirPart) && (await fs.stat(dirPart)).isDirectory()) {
|
|
226
|
+
searchRoot = dirPart;
|
|
227
|
+
searchTerm = basePart;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
searchRoot = path.dirname(dirPart);
|
|
231
|
+
searchTerm = basePart;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
searchRoot = os.homedir();
|
|
237
|
+
searchTerm = query;
|
|
238
|
+
}
|
|
239
|
+
const pattern = `*${searchTerm}*`;
|
|
240
|
+
async function traverse(dir, depth) {
|
|
241
|
+
if (depth > 3 || matches.length >= 20)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (matches.length >= 20)
|
|
247
|
+
break;
|
|
248
|
+
if (entry.name.startsWith('.'))
|
|
249
|
+
continue;
|
|
250
|
+
const fullPath = path.join(dir, entry.name);
|
|
251
|
+
if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
|
|
252
|
+
if (minimatch(entry.name, pattern, { nocase: true }))
|
|
253
|
+
matches.push(fullPath);
|
|
254
|
+
await traverse(fullPath, depth + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch { }
|
|
259
|
+
}
|
|
260
|
+
await traverse(searchRoot, 0);
|
|
261
|
+
return c.json({ matches });
|
|
262
|
+
});
|
|
263
|
+
router.get('/', async (c) => {
|
|
264
|
+
try {
|
|
265
|
+
const groups = await Promise.all(config.roots.map(async (root) => {
|
|
266
|
+
try {
|
|
267
|
+
return {
|
|
268
|
+
root,
|
|
269
|
+
files: await walkDirectory(root.path, root.path, config, matcher)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
return {
|
|
274
|
+
root,
|
|
275
|
+
files: [],
|
|
276
|
+
error: e instanceof Error ? e.message : 'Failed to read directory'
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}));
|
|
280
|
+
return c.json({ groups });
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
return c.json({ error: 'Failed to read directory' }, 500);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
router.get('/content', async (c) => {
|
|
287
|
+
const pathsParam = c.req.query('paths');
|
|
288
|
+
if (!pathsParam) {
|
|
289
|
+
return c.json({ error: 'paths parameter is required' }, 400);
|
|
290
|
+
}
|
|
291
|
+
const paths = pathsParam.split(',').filter(Boolean);
|
|
292
|
+
const results = [];
|
|
293
|
+
for (const filePath of paths) {
|
|
294
|
+
const rootParam = c.req.query('root');
|
|
295
|
+
let rootPath;
|
|
296
|
+
if (rootParam) {
|
|
297
|
+
rootPath = path.resolve(rootParam);
|
|
298
|
+
if (!config.roots.some(r => path.resolve(r.path) === rootPath)) {
|
|
299
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
rootPath = findRootForPath(filePath, config);
|
|
304
|
+
}
|
|
305
|
+
if (!rootPath)
|
|
306
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
307
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
308
|
+
const fullPath = path.join(rootPath, relativePath);
|
|
309
|
+
if (!isAllowed(fullPath, config)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const stat = await fs.stat(fullPath);
|
|
314
|
+
if (stat.isFile()) {
|
|
315
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
316
|
+
results.push({
|
|
317
|
+
path: filePath,
|
|
318
|
+
name: path.basename(filePath),
|
|
319
|
+
content,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
// skip files that can't be read
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return c.json({ files: results });
|
|
328
|
+
});
|
|
329
|
+
router.get('/*', async (c) => {
|
|
330
|
+
const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
|
|
331
|
+
// Handle root path - return grouped file tree
|
|
332
|
+
if (filePath === '/' || filePath === '') {
|
|
333
|
+
try {
|
|
334
|
+
const groups = await Promise.all(config.roots.map(async (root) => {
|
|
335
|
+
try {
|
|
336
|
+
return {
|
|
337
|
+
root,
|
|
338
|
+
files: await walkDirectory(root.path, root.path, config, matcher)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
return {
|
|
343
|
+
root,
|
|
344
|
+
files: [],
|
|
345
|
+
error: e instanceof Error ? e.message : 'Failed to read directory'
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}));
|
|
349
|
+
return c.json({ groups });
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
return c.json({ error: 'Failed to read directory' }, 500);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Handle root parameter from query string
|
|
356
|
+
const rootParam = c.req.query('root');
|
|
357
|
+
let rootPath;
|
|
358
|
+
if (rootParam) {
|
|
359
|
+
rootPath = path.resolve(rootParam);
|
|
360
|
+
if (!config.roots.some(r => path.resolve(r.path) === rootPath)) {
|
|
361
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
rootPath = findRootForPath(filePath, config);
|
|
366
|
+
}
|
|
367
|
+
if (!rootPath)
|
|
368
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
369
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
370
|
+
const fullPath = path.join(rootPath, relativePath);
|
|
371
|
+
if (!isAllowed(fullPath, config)) {
|
|
372
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const stat = await fs.stat(fullPath);
|
|
376
|
+
if (stat.isDirectory()) {
|
|
377
|
+
const files = await walkDirectory(fullPath, rootPath, config, matcher);
|
|
378
|
+
return c.json({ files });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
// not a directory
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
386
|
+
return c.text(content);
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
return c.json({ error: 'File not found' }, 404);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
router.post('/copy', async (c) => {
|
|
393
|
+
try {
|
|
394
|
+
const body = await c.req.json();
|
|
395
|
+
let { sourcePath, targetPath, sourceRoot, targetRoot } = body;
|
|
396
|
+
if (!sourcePath || !targetPath) {
|
|
397
|
+
return c.json({ error: 'sourcePath and targetPath required' }, 400);
|
|
398
|
+
}
|
|
399
|
+
if (!sourceRoot || !targetRoot) {
|
|
400
|
+
return c.json({ error: 'sourceRoot and targetRoot required' }, 400);
|
|
401
|
+
}
|
|
402
|
+
const validatedSourceRoot = validateRoot(sourceRoot, config);
|
|
403
|
+
const validatedTargetRoot = validateRoot(targetRoot, config);
|
|
404
|
+
if (!validatedSourceRoot)
|
|
405
|
+
return c.json({ error: 'Invalid source root' }, 400);
|
|
406
|
+
if (!validatedTargetRoot)
|
|
407
|
+
return c.json({ error: 'Invalid target root' }, 400);
|
|
408
|
+
const srcFullPath = path.join(validatedSourceRoot, sourcePath);
|
|
409
|
+
let tgtFullPath = path.join(validatedTargetRoot, targetPath);
|
|
410
|
+
const stat = await fs.stat(srcFullPath);
|
|
411
|
+
const targetExists = await fs.access(tgtFullPath).then(() => true).catch(() => false);
|
|
412
|
+
if (targetExists) {
|
|
413
|
+
const ext = path.extname(targetPath);
|
|
414
|
+
const base = path.basename(targetPath, ext);
|
|
415
|
+
const dir = path.dirname(targetPath);
|
|
416
|
+
targetPath = `${dir}/${base} (copy)${ext}`;
|
|
417
|
+
tgtFullPath = path.join(validatedTargetRoot, targetPath);
|
|
418
|
+
}
|
|
419
|
+
if (stat.isDirectory()) {
|
|
420
|
+
await fs.cp(srcFullPath, tgtFullPath, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
await fs.copyFile(srcFullPath, tgtFullPath);
|
|
424
|
+
}
|
|
425
|
+
return c.json({ success: true, newPath: targetPath });
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
console.error('Copy error:', e);
|
|
429
|
+
return c.json({ error: 'Failed to copy' }, 500);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
router.post('/*', async (c) => {
|
|
433
|
+
const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
|
|
434
|
+
const rootParam = c.req.query('root');
|
|
435
|
+
let rootPath;
|
|
436
|
+
if (rootParam) {
|
|
437
|
+
rootPath = validateRoot(rootParam, config);
|
|
438
|
+
if (!rootPath)
|
|
439
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
rootPath = findRootForPath(filePath, config);
|
|
443
|
+
if (!rootPath)
|
|
444
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
445
|
+
}
|
|
446
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
447
|
+
const fullPath = path.join(rootPath, relativePath);
|
|
448
|
+
if (!isAllowed(fullPath, config)) {
|
|
449
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
450
|
+
}
|
|
451
|
+
const contentType = c.req.header('Content-Type') || '';
|
|
452
|
+
if (contentType.includes('application/json')) {
|
|
453
|
+
const body = await c.req.json();
|
|
454
|
+
if (body.type === 'create') {
|
|
455
|
+
const parentPath = body.parentPath || '';
|
|
456
|
+
const name = body.name;
|
|
457
|
+
let parentRootPath;
|
|
458
|
+
if (body.root) {
|
|
459
|
+
parentRootPath = validateRoot(body.root, config);
|
|
460
|
+
if (!parentRootPath) {
|
|
461
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
parentRootPath = findRootForPath(parentPath, config);
|
|
466
|
+
if (!parentRootPath)
|
|
467
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
468
|
+
}
|
|
469
|
+
const targetPath = path.join(parentRootPath, parentPath, name);
|
|
470
|
+
if (!isAllowed(targetPath, config)) {
|
|
471
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
if (body.isDirectory) {
|
|
475
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
if (!hasAllowedExtension(name, config.allowedExtensions)) {
|
|
479
|
+
return c.json({ error: 'File type not allowed' }, 400);
|
|
480
|
+
}
|
|
481
|
+
await fs.writeFile(targetPath, '', 'utf-8');
|
|
482
|
+
}
|
|
483
|
+
return c.json({ success: true, path: targetPath.replace(parentRootPath, '') });
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
return c.json({ error: 'Failed to create' }, 500);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
492
|
+
const content = await c.req.text();
|
|
493
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
494
|
+
return c.json({ success: true });
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
return c.json({ error: 'Failed to save file' }, 500);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
router.put('/*', async (c) => {
|
|
501
|
+
try {
|
|
502
|
+
const body = await c.req.json();
|
|
503
|
+
const { oldPath, newPath, isDirectory, sourceRoot, targetRoot } = body;
|
|
504
|
+
if (!oldPath || !newPath) {
|
|
505
|
+
return c.json({ error: 'oldPath and newPath are required' }, 400);
|
|
506
|
+
}
|
|
507
|
+
if (!sourceRoot || !targetRoot) {
|
|
508
|
+
return c.json({ error: 'sourceRoot and targetRoot are required' }, 400);
|
|
509
|
+
}
|
|
510
|
+
const validatedSourceRoot = validateRoot(sourceRoot, config);
|
|
511
|
+
const validatedTargetRoot = validateRoot(targetRoot, config);
|
|
512
|
+
if (!validatedSourceRoot)
|
|
513
|
+
return c.json({ error: 'Invalid source root' }, 400);
|
|
514
|
+
if (!validatedTargetRoot)
|
|
515
|
+
return c.json({ error: 'Invalid target root' }, 400);
|
|
516
|
+
const oldFullPath = path.join(validatedSourceRoot, oldPath);
|
|
517
|
+
const newFullPath = path.join(validatedTargetRoot, newPath);
|
|
518
|
+
if (!isAllowed(oldFullPath, config) || !isAllowed(newFullPath, config)) {
|
|
519
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
520
|
+
}
|
|
521
|
+
if (isDirectory) {
|
|
522
|
+
const stat = await fs.stat(oldFullPath);
|
|
523
|
+
if (!stat.isDirectory()) {
|
|
524
|
+
return c.json({ error: 'Source is not a directory' }, 400);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const ext = path.extname(newPath).toLowerCase();
|
|
529
|
+
if (!hasAllowedExtension(newPath, config.allowedExtensions)) {
|
|
530
|
+
return c.json({ error: 'File type not allowed' }, 400);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await fs.rename(oldFullPath, newFullPath);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
if (err instanceof Error && 'code' in err && err.code === 'EXDEV') {
|
|
538
|
+
// Cross-filesystem move - copy then delete
|
|
539
|
+
if (isDirectory) {
|
|
540
|
+
await fs.cp(oldFullPath, newFullPath, { recursive: true });
|
|
541
|
+
await fs.rm(oldFullPath, { recursive: true });
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
await fs.copyFile(oldFullPath, newFullPath);
|
|
545
|
+
await fs.unlink(oldFullPath);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return c.json({ success: true, newPath });
|
|
553
|
+
}
|
|
554
|
+
catch (e) {
|
|
555
|
+
console.error('Rename/Move error:', e);
|
|
556
|
+
return c.json({ error: 'Failed to rename or move' }, 500);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
router.delete('/*', async (c) => {
|
|
560
|
+
const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
|
|
561
|
+
const rootParam = c.req.query('root');
|
|
562
|
+
let rootPath;
|
|
563
|
+
if (rootParam) {
|
|
564
|
+
rootPath = validateRoot(rootParam, config);
|
|
565
|
+
if (!rootPath)
|
|
566
|
+
return c.json({ error: 'Invalid root' }, 400);
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
rootPath = findRootForPath(filePath, config);
|
|
570
|
+
if (!rootPath)
|
|
571
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
572
|
+
}
|
|
573
|
+
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
574
|
+
const fullPath = path.join(rootPath, relativePath);
|
|
575
|
+
if (!isAllowed(fullPath, config)) {
|
|
576
|
+
return c.json({ error: 'Access denied' }, 403);
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const stat = await fs.stat(fullPath);
|
|
580
|
+
if (stat.isDirectory()) {
|
|
581
|
+
await fs.rm(fullPath, { recursive: true });
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
await fs.unlink(fullPath);
|
|
585
|
+
}
|
|
586
|
+
return c.json({ success: true });
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
return c.json({ error: 'Failed to delete' }, 500);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
return router;
|
|
593
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { cors } from 'hono/cors';
|
|
3
|
+
import { createFileRouter } from './api.js';
|
|
4
|
+
import { IgnoreMatcher } from './ignore.js';
|
|
5
|
+
export function createApp(config) {
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
const ignoreConfig = {
|
|
8
|
+
enableIgnoreFiles: config.ignore.enableIgnoreFiles,
|
|
9
|
+
ignoreFileNames: config.ignore.ignoreFileNames,
|
|
10
|
+
globalPatterns: config.ignore.patterns,
|
|
11
|
+
};
|
|
12
|
+
const matcher = new IgnoreMatcher(config.roots[0]?.path || process.cwd(), ignoreConfig);
|
|
13
|
+
app.use('*', cors());
|
|
14
|
+
app.use('*', async (c, next) => {
|
|
15
|
+
c.set('config', config);
|
|
16
|
+
await next();
|
|
17
|
+
});
|
|
18
|
+
const fileRouter = createFileRouter(config, matcher);
|
|
19
|
+
app.route('/api/files', fileRouter);
|
|
20
|
+
return { app, matcher };
|
|
21
|
+
}
|