mouaif 0.3.0
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 +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/files.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// File editor — read/write text files inside a project folder.
|
|
4
|
+
//
|
|
5
|
+
// Implements the editor popup described in docs/features/chat-ui.md
|
|
6
|
+
// (file editor button next to the message composer). The popup is a
|
|
7
|
+
// CodeMirror-based UI served by the mobile web client; this module
|
|
8
|
+
// is the matching server surface.
|
|
9
|
+
//
|
|
10
|
+
// Public surface (all paths must live under a project folder that
|
|
11
|
+
// itself lives under the user home unless MOUAIF_ALLOW_ANY_ROOT=1):
|
|
12
|
+
//
|
|
13
|
+
// listDir(projectDir, dir) -> { projectDir, dir, entries: [...] }
|
|
14
|
+
// readFile(projectDir, path) -> { projectDir, path, relPath, size, ext, content }
|
|
15
|
+
// writeFile(projectDir, path, content) -> { projectDir, path, relPath, size, bytesWritten }
|
|
16
|
+
//
|
|
17
|
+
// `projectDir` and `dir` may be the same: listing the project root
|
|
18
|
+
// is the default. `path` for read/write may be absolute or
|
|
19
|
+
// project-relative.
|
|
20
|
+
//
|
|
21
|
+
// Text-only: the editor never opens binary files. A list entry has a
|
|
22
|
+
// `binary: true` flag so the UI can grey those out; read/write reject
|
|
23
|
+
// non-text files with a typed EBINARY error so the caller can show
|
|
24
|
+
// "this file cannot be edited here" instead of a 500.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const fsp = require('fs').promises;
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const { err } = require('./util.js');
|
|
31
|
+
const projects = require('./projects.js');
|
|
32
|
+
|
|
33
|
+
// Default per-file size cap. The editor is for small fixes (config
|
|
34
|
+
// tweaks, prompts, READMEs), not 50 MB log dumps. Overridable per
|
|
35
|
+
// call when we need to (tests).
|
|
36
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
|
|
37
|
+
|
|
38
|
+
// Same allowlist as the file-tagging scan (docs/decisions.md §15).
|
|
39
|
+
// Anything outside is treated as binary for editor purposes unless
|
|
40
|
+
// the small-file text sniff in `isProbablyText` recognises it.
|
|
41
|
+
const TEXT_EXTS = new Set([
|
|
42
|
+
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json', '.md', '.mdx', '.txt',
|
|
43
|
+
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.h',
|
|
44
|
+
'.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.cs', '.m', '.mm',
|
|
45
|
+
'.css', '.scss', '.sass', '.less', '.html', '.htm',
|
|
46
|
+
'.yml', '.yaml', '.toml', '.sh', '.bash', '.zsh', '.fish',
|
|
47
|
+
'.xml', '.svg', '.ini', '.cfg', '.conf', '.env', '.gitignore',
|
|
48
|
+
'.editorconfig', '.dockerignore', '.ps1', '.psm1', '.psd1',
|
|
49
|
+
'.lua', '.pl', '.r', '.dart', '.php', '.phtml',
|
|
50
|
+
'.sql', '.csv', '.tsv', '.vue', '.svelte',
|
|
51
|
+
'.graphql', '.gql', '.proto', '.prisma', '.gradle', '.kts',
|
|
52
|
+
'.rst', '.tex', '.diff', '.patch', '.log',
|
|
53
|
+
'.lock', '.sum', '.mod', '.properties',
|
|
54
|
+
'.nim', '.zig', '.v', '.sv', '.svh',
|
|
55
|
+
'.ex', '.exs', '.erl', '.hrl', '.clj', '.cljs', '.cljc',
|
|
56
|
+
'.tf', '.hcl', '.nix', '.dhall'
|
|
57
|
+
]);
|
|
58
|
+
// Image extensions the file modal can preview inline. The same home
|
|
59
|
+
// + MOUAIF_ALLOW_ANY_ROOT guard protects reads, just like the text
|
|
60
|
+
// editor. SVG is included even though it's text — the editor still
|
|
61
|
+
// refuses to *edit* SVG, and treating it as a media file here means
|
|
62
|
+
// the user gets a visual preview in the modal.
|
|
63
|
+
const IMAGE_EXTS = new Set([
|
|
64
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'
|
|
65
|
+
]);
|
|
66
|
+
const EXT_TO_MIME = {
|
|
67
|
+
'.png': 'image/png',
|
|
68
|
+
'.jpg': 'image/jpeg',
|
|
69
|
+
'.jpeg': 'image/jpeg',
|
|
70
|
+
'.gif': 'image/gif',
|
|
71
|
+
'.webp': 'image/webp',
|
|
72
|
+
'.svg': 'image/svg+xml',
|
|
73
|
+
'.bmp': 'image/bmp',
|
|
74
|
+
'.ico': 'image/x-icon'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Names that the walker never descends into. Mirrors the file-tagging
|
|
78
|
+
// scan so the editor popup is consistent with the existing project
|
|
79
|
+
// views.
|
|
80
|
+
const SKIP_DIRS = new Set([
|
|
81
|
+
'node_modules', '.git', '.mouaif', 'dist', 'build', '.next',
|
|
82
|
+
'.cache', '.parcel-cache', '.turbo', '.vercel', '.nuxt',
|
|
83
|
+
'coverage', '.pytest_cache', '__pycache__', 'target'
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// ---- Errors ------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// err() is shared from src/util.js (files.js, tags.js and mcp.js used to
|
|
89
|
+
// each carry an identical private copy).
|
|
90
|
+
|
|
91
|
+
function isTextExt(ext) {
|
|
92
|
+
if (!ext) return false;
|
|
93
|
+
return TEXT_EXTS.has(ext.toLowerCase());
|
|
94
|
+
}
|
|
95
|
+
function isImageExt(ext) {
|
|
96
|
+
if (!ext) return false;
|
|
97
|
+
return IMAGE_EXTS.has(ext.toLowerCase());
|
|
98
|
+
}
|
|
99
|
+
function mimeForExt(ext) {
|
|
100
|
+
if (!ext) return 'application/octet-stream';
|
|
101
|
+
return EXT_TO_MIME[ext.toLowerCase()] || 'application/octet-stream';
|
|
102
|
+
}
|
|
103
|
+
// Sniff the first ~8 KiB of a small file and decide whether it looks
|
|
104
|
+
// like text. A NUL byte is the cheap, reliable tell: real text never
|
|
105
|
+
// has one in the first KB, but every binary format (PNG, ELF, ZIP,
|
|
106
|
+
// PDF, ...) starts with one. We only run this on files that fit under
|
|
107
|
+
// the editor size cap, so the read is bounded and the editor never
|
|
108
|
+
// tries to render a 50 MB file as text. The fallback exists so the
|
|
109
|
+
// user can open plain-text files with unfamiliar extensions (LICENSE
|
|
110
|
+
// without an extension, .gitattributes, dotfiles) instead of being
|
|
111
|
+
// forced to edit them in a different tool.
|
|
112
|
+
async function isProbablyText(abs) {
|
|
113
|
+
let fd;
|
|
114
|
+
try {
|
|
115
|
+
fd = await fsp.open(abs, 'r');
|
|
116
|
+
const buf = Buffer.alloc(8192);
|
|
117
|
+
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
|
|
118
|
+
if (bytesRead === 0) return true; // empty file is "text"
|
|
119
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
120
|
+
if (buf[i] === 0x00) return false;
|
|
121
|
+
}
|
|
122
|
+
// Also reject if the UTF-8 decode throws - a control-heavy
|
|
123
|
+
// binary will fail the decode even without an explicit NUL.
|
|
124
|
+
buf.toString('utf8', 0, bytesRead);
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
} finally {
|
|
129
|
+
if (fd) { try { await fd.close(); } catch { /* ignore */ } }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Sync version for use inside listDir, which is itself sync. Same
|
|
133
|
+
// 8 KiB NUL-byte heuristic. We do not call this on every entry; it
|
|
134
|
+
// is only used when the extension is missing or unknown and the file
|
|
135
|
+
// fits under the editor cap, so a one-off read here is fine.
|
|
136
|
+
function isProbablyTextSync(abs, maxBytes) {
|
|
137
|
+
let fd;
|
|
138
|
+
try {
|
|
139
|
+
fd = fs.openSync(abs, 'r');
|
|
140
|
+
const buf = Buffer.alloc(8192);
|
|
141
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
142
|
+
if (bytesRead === 0) return true; // empty file is "text"
|
|
143
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
144
|
+
if (buf[i] === 0x00) return false;
|
|
145
|
+
}
|
|
146
|
+
buf.toString('utf8', 0, bytesRead);
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
} finally {
|
|
151
|
+
if (fd != null) { try { fs.closeSync(fd); } catch { /* ignore */ } }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- Path safety -------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
// Resolve any incoming path to an absolute path. The projectDir itself
|
|
158
|
+
// is validated via projects.ensureSafeRoot (home / MOUAIF_ALLOW_ANY_ROOT),
|
|
159
|
+
// and — because the file editor can now browse above a project folder —
|
|
160
|
+
// the target path is validated with the same home guard rather than being
|
|
161
|
+
// confined to the project root. So the editor can list/read/write anywhere
|
|
162
|
+
// under the user home (or anywhere at all when MOUAIF_ALLOW_ANY_ROOT=1),
|
|
163
|
+
// mirroring the project-folder picker.
|
|
164
|
+
//
|
|
165
|
+
// `incoming` may be absolute or relative; relative is resolved against
|
|
166
|
+
// projectDir. Throws typed errors (EBADPATH, EOUTSIDE_HOME, ENOENT)
|
|
167
|
+
// instead of letting the underlying fs throw something the UI cannot
|
|
168
|
+
// render.
|
|
169
|
+
function resolveSafe(projectDir, incoming) {
|
|
170
|
+
if (typeof projectDir !== 'string' || !projectDir) {
|
|
171
|
+
throw err('EBADPATH', 'projectDir is required');
|
|
172
|
+
}
|
|
173
|
+
// Validate the project root. Throws EOUTSIDE_HOME etc.
|
|
174
|
+
const root = projects.ensureSafeRoot(projectDir);
|
|
175
|
+
if (!incoming || typeof incoming !== 'string') {
|
|
176
|
+
throw err('EBADPATH', 'path is required');
|
|
177
|
+
}
|
|
178
|
+
const abs = path.isAbsolute(incoming) ? path.resolve(incoming) : path.resolve(root, incoming);
|
|
179
|
+
// The browse/edit boundary is home / ALLOW_ANY_ROOT, not the project
|
|
180
|
+
// root. When ALLOW_ANY_ROOT is off this throws EOUTSIDE_HOME for any
|
|
181
|
+
// target outside the user home; when on, it allows the whole tree.
|
|
182
|
+
projects.ensureSafeRoot(abs);
|
|
183
|
+
// `rel` stays project-relative. For in-project paths it is a clean
|
|
184
|
+
// relative path; when browsing above the project root it becomes a
|
|
185
|
+
// `..`-prefixed string used to build relPath. Callers that need an
|
|
186
|
+
// absolute path use `abs`.
|
|
187
|
+
const rel = path.relative(root, abs);
|
|
188
|
+
return { root, abs, rel: rel || '.' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ensureDir(root, abs, kind) {
|
|
192
|
+
// kind: 'list' (must be a dir), 'parent' (must be a dir, used for writes)
|
|
193
|
+
let st;
|
|
194
|
+
try { st = fs.statSync(abs); }
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (e.code === 'ENOENT') {
|
|
197
|
+
throw err('ENOENT', kind === 'parent' ? 'Parent directory does not exist' : 'Directory does not exist', { path: abs });
|
|
198
|
+
}
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
if (!st.isDirectory()) {
|
|
202
|
+
throw err('ENOTDIR', 'Path is not a directory', { path: abs });
|
|
203
|
+
}
|
|
204
|
+
return st;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hasImmediateSubdirs(abs) {
|
|
208
|
+
let names;
|
|
209
|
+
try { names = fs.readdirSync(abs); }
|
|
210
|
+
catch { return false; }
|
|
211
|
+
for (const n of names) {
|
|
212
|
+
if (n.startsWith('.') || SKIP_DIRS.has(n)) continue;
|
|
213
|
+
try {
|
|
214
|
+
const st = fs.statSync(path.join(abs, n));
|
|
215
|
+
if (st.isDirectory()) return true;
|
|
216
|
+
} catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Public: list ------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function listDir(projectDir, dir) {
|
|
224
|
+
const target = (dir && typeof dir === 'string') ? dir : projectDir;
|
|
225
|
+
const { root, abs, rel } = resolveSafe(projectDir, target);
|
|
226
|
+
ensureDir(root, abs, 'list');
|
|
227
|
+
let names;
|
|
228
|
+
try { names = fs.readdirSync(abs); }
|
|
229
|
+
catch (e) {
|
|
230
|
+
throw err(e.code === 'EACCES' ? 'EACCES' : 'EREAD', e.message, { path: abs });
|
|
231
|
+
}
|
|
232
|
+
const entries = [];
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
// Hidden dotfiles (e.g. .gitignore, .env, .editorconfig) are real
|
|
235
|
+
// text files the editor is meant to open — see the feature doc. We
|
|
236
|
+
// still skip the known-junk dot-dirs (.git, .mouaif, .cache, …) via
|
|
237
|
+
// SKIP_DIRS below, but we no longer blanket-hide every `.` entry.
|
|
238
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
239
|
+
const full = path.join(abs, name);
|
|
240
|
+
let st;
|
|
241
|
+
try { st = fs.statSync(full); }
|
|
242
|
+
catch { continue; }
|
|
243
|
+
if (st.isDirectory()) {
|
|
244
|
+
entries.push({
|
|
245
|
+
name,
|
|
246
|
+
path: full,
|
|
247
|
+
relPath: rel === '.' ? name : (rel + '/' + name),
|
|
248
|
+
type: 'dir',
|
|
249
|
+
hasChildren: hasImmediateSubdirs(full)
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!st.isFile()) continue;
|
|
254
|
+
const ext = path.extname(name).toLowerCase();
|
|
255
|
+
let text = isTextExt(ext);
|
|
256
|
+
const image = isImageExt(ext);
|
|
257
|
+
// Sniff small files with an unknown extension (e.g. LICENSE,
|
|
258
|
+
// .gitattributes) so the modal offers to open them as text. A
|
|
259
|
+
// known image extension is never treated as text even if the
|
|
260
|
+
// first 8 KiB happen to contain no NUL bytes.
|
|
261
|
+
if (!text && !image && st.size <= DEFAULT_MAX_BYTES) {
|
|
262
|
+
text = isProbablyTextSync(full, DEFAULT_MAX_BYTES);
|
|
263
|
+
}
|
|
264
|
+
entries.push({
|
|
265
|
+
name,
|
|
266
|
+
path: full,
|
|
267
|
+
relPath: rel === '.' ? name : (rel + '/' + name),
|
|
268
|
+
type: 'file',
|
|
269
|
+
size: st.size,
|
|
270
|
+
ext: ext || '',
|
|
271
|
+
binary: !text,
|
|
272
|
+
text,
|
|
273
|
+
image
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// Dirs first, then files; both sorted case-insensitively.
|
|
277
|
+
entries.sort((a, b) => {
|
|
278
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
279
|
+
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
280
|
+
});
|
|
281
|
+
return { projectDir: root, dir: abs, relDir: rel, browseTop: projects.browseTop(abs), entries };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- Public: read ------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
async function readFile(projectDir, filePath, opts) {
|
|
287
|
+
const { root, abs, rel } = resolveSafe(projectDir, filePath);
|
|
288
|
+
const max = (opts && Number.isInteger(opts.maxBytes) && opts.maxBytes > 0) ? opts.maxBytes : DEFAULT_MAX_BYTES;
|
|
289
|
+
let st;
|
|
290
|
+
try { st = await fsp.stat(abs); }
|
|
291
|
+
catch (e) {
|
|
292
|
+
if (e.code === 'ENOENT') throw err('ENOENT', 'File does not exist', { path: abs });
|
|
293
|
+
throw e;
|
|
294
|
+
}
|
|
295
|
+
if (!st.isFile()) throw err('ENOTFILE', 'Path is not a file', { path: abs });
|
|
296
|
+
const ext = path.extname(abs).toLowerCase();
|
|
297
|
+
if (!isTextExt(ext) && (isImageExt(ext) || !(st.size <= max && await isProbablyText(abs)))) {
|
|
298
|
+
throw err('EBINARY', 'Binary files cannot be edited here', { path: abs, ext });
|
|
299
|
+
}
|
|
300
|
+
if (st.size > max) {
|
|
301
|
+
throw err('ETOOLARGE', 'File exceeds the editor size cap', { path: abs, size: st.size, maxBytes: max });
|
|
302
|
+
}
|
|
303
|
+
let content;
|
|
304
|
+
try { content = await fsp.readFile(abs, 'utf8'); }
|
|
305
|
+
catch (e) {
|
|
306
|
+
if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
return { projectDir: root, path: abs, relPath: rel, size: st.size, ext: ext || '', content };
|
|
310
|
+
}
|
|
311
|
+
// ---- Public: readMedia -------------------------------------------------
|
|
312
|
+
// Read an image (or any binary the modal can preview) as a
|
|
313
|
+
// base64-encoded data URL. The file editor is the only caller: it
|
|
314
|
+
// builds a <img src="data:..."> from the result so the preview does
|
|
315
|
+
// not need a separate auth-bearing URL. Files larger than the
|
|
316
|
+
// editor cap (1 MiB) are rejected with the same ETOOLARGE code the
|
|
317
|
+
// text read uses, so the UI shows a consistent error pill.
|
|
318
|
+
async function readMedia(projectDir, filePath, opts) {
|
|
319
|
+
const { root, abs, rel } = resolveSafe(projectDir, filePath);
|
|
320
|
+
const max = (opts && Number.isInteger(opts.maxBytes) && opts.maxBytes > 0) ? opts.maxBytes : DEFAULT_MAX_BYTES;
|
|
321
|
+
let st;
|
|
322
|
+
try { st = await fsp.stat(abs); }
|
|
323
|
+
catch (e) {
|
|
324
|
+
if (e.code === 'ENOENT') throw err('ENOENT', 'File does not exist', { path: abs });
|
|
325
|
+
throw e;
|
|
326
|
+
}
|
|
327
|
+
if (!st.isFile()) throw err('ENOTFILE', 'Path is not a file', { path: abs });
|
|
328
|
+
const ext = path.extname(abs).toLowerCase();
|
|
329
|
+
if (!isImageExt(ext)) {
|
|
330
|
+
throw err('ENOTIMAGE', 'File is not a previewable image', { path: abs, ext });
|
|
331
|
+
}
|
|
332
|
+
if (st.size > max) {
|
|
333
|
+
throw err('ETOOLARGE', 'Image exceeds the preview size cap', { path: abs, size: st.size, maxBytes: max });
|
|
334
|
+
}
|
|
335
|
+
let buf;
|
|
336
|
+
try { buf = await fsp.readFile(abs); }
|
|
337
|
+
catch (e) {
|
|
338
|
+
if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
const mime = mimeForExt(ext);
|
|
342
|
+
return {
|
|
343
|
+
projectDir: root,
|
|
344
|
+
path: abs,
|
|
345
|
+
relPath: rel,
|
|
346
|
+
size: st.size,
|
|
347
|
+
ext: ext || '',
|
|
348
|
+
mime,
|
|
349
|
+
dataUrl: 'data:' + mime + ';base64,' + buf.toString('base64')
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- Public: write -----------------------------------------------------
|
|
354
|
+
|
|
355
|
+
// Atomic write: stage to <file>.mouaif-tmp then rename. Same pattern
|
|
356
|
+
// used by src/tools/files.js so a crash mid-write cannot truncate the
|
|
357
|
+
// real file. Preserves the existing file mode when overwriting.
|
|
358
|
+
async function writeFile(projectDir, filePath, content) {
|
|
359
|
+
if (typeof content !== 'string') {
|
|
360
|
+
throw err('EBADINPUT', 'content must be a string');
|
|
361
|
+
}
|
|
362
|
+
const { root, abs, rel } = resolveSafe(projectDir, filePath);
|
|
363
|
+
// The parent must exist; we do not auto-create folders. The editor
|
|
364
|
+
// is for "edit existing files", not "create new project trees".
|
|
365
|
+
const parent = path.dirname(abs);
|
|
366
|
+
ensureDir(root, parent, 'parent');
|
|
367
|
+
|
|
368
|
+
// Refuse to clobber an existing directory at the target path BEFORE
|
|
369
|
+
// checking the extension allowlist: "this is a directory" is the
|
|
370
|
+
// real reason the write is invalid, not "the directory has no
|
|
371
|
+
// extension so it looks binary".
|
|
372
|
+
let existing;
|
|
373
|
+
try { existing = await fsp.stat(abs); }
|
|
374
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
375
|
+
if (existing && existing.isDirectory()) {
|
|
376
|
+
throw err('EISDIR', 'Target is a directory', { path: abs });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const ext = path.extname(abs).toLowerCase();
|
|
380
|
+
// Match readFile: known images are never text, everything else
|
|
381
|
+
// is allowed if it has a text extension or has no extension. A
|
|
382
|
+
// brand-new file (existing is undefined) cannot be sniffed, so we
|
|
383
|
+
// fall back to the extension allowlist only - this matches the
|
|
384
|
+
// original "must have a text extension" behavior for new files
|
|
385
|
+
// and means the user can save to .LICENSE, .gitattributes, etc.,
|
|
386
|
+
// by re-opening an existing one.
|
|
387
|
+
if (existing && !isTextExt(ext) && (isImageExt(ext) || !(await isProbablyText(abs)))) {
|
|
388
|
+
throw err('EBINARY', 'Binary files cannot be edited here', { path: abs, ext });
|
|
389
|
+
}
|
|
390
|
+
if (!existing && !isTextExt(ext)) {
|
|
391
|
+
throw err('EBINARY', 'Refusing to write to a non-text extension', { path: abs, ext });
|
|
392
|
+
}
|
|
393
|
+
const size = Buffer.byteLength(content, 'utf8');
|
|
394
|
+
if (size > DEFAULT_MAX_BYTES) {
|
|
395
|
+
throw err('ETOOLARGE', 'Refusing to write file larger than the editor size cap', { path: abs, size, maxBytes: DEFAULT_MAX_BYTES });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const tmp = abs + '.mouaif-tmp';
|
|
399
|
+
let st;
|
|
400
|
+
try { st = await fsp.stat(abs); }
|
|
401
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
402
|
+
try {
|
|
403
|
+
await fsp.writeFile(tmp, content, { encoding: 'utf8', mode: st ? st.mode : 0o644 });
|
|
404
|
+
await fsp.rename(tmp, abs);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
// Clean up the staging file on failure.
|
|
407
|
+
try { await fsp.unlink(tmp); } catch { /* ignore */ }
|
|
408
|
+
if (e.code === 'EACCES') throw err('EACCES', e.message, { path: abs });
|
|
409
|
+
throw e;
|
|
410
|
+
}
|
|
411
|
+
return { projectDir: root, path: abs, relPath: rel, size, bytesWritten: size };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
module.exports = {
|
|
415
|
+
// operations
|
|
416
|
+
listDir,
|
|
417
|
+
readFile,
|
|
418
|
+
readMedia,
|
|
419
|
+
writeFile,
|
|
420
|
+
// helpers (exported for tests)
|
|
421
|
+
resolveSafe,
|
|
422
|
+
isTextExt,
|
|
423
|
+
isImageExt,
|
|
424
|
+
mimeForExt,
|
|
425
|
+
isProbablyText,
|
|
426
|
+
isProbablyTextSync,
|
|
427
|
+
TEXT_EXTS,
|
|
428
|
+
IMAGE_EXTS,
|
|
429
|
+
SKIP_DIRS,
|
|
430
|
+
DEFAULT_MAX_BYTES
|
|
431
|
+
};
|