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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,467 @@
1
+ 'use strict';
2
+
3
+ // Git REST handlers. Extracted from the original single-file
4
+ // http-server.js. Shared helpers live in src/server-shared.js.
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const { sendJSON, readJsonOr400, xyToText } = require('./server-shared.js');
8
+
9
+ // ---- Git API ----------------------------------------------------------------
10
+ //
11
+ // Lightweight git operations run directly against the project directory.
12
+ // Routes:
13
+ // POST /api/git body: { projectDir, action, args? }
14
+ // -> { ok, stdout, stderr, exitCode }
15
+ //
16
+ // Supported actions: status, diff, log, add, unstage, commit, branch, checkout,
17
+ // stash, stash-apply, stash-pop, stash-drop, push, pull, cherry-pick, revert
18
+ // These are read-safe or explicit-save commands. `commit` and `add` require
19
+ // an extra `message` field. `push` and `pull` talk to the configured remote.
20
+ // `cherry-pick` and `revert` operate on a commit hash in `args` and create new
21
+ // commits (the frontend confirms before calling them).
22
+ async function handleGit(req, res, parsed) {
23
+ const body = await readJsonOr400(req, res);
24
+ if (!body) return;
25
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
26
+ const action = body && typeof body.action === 'string' ? body.action : '';
27
+ const args = body && typeof body.args === 'string' ? body.args : '';
28
+ const message = body && typeof body.message === 'string' ? body.message : '';
29
+ // `files` is an array of paths to operate on (used by stage/unstage). It is
30
+ // an alternative to the string `args` form: when present, each path is passed
31
+ // to git as a single argv element, so file names containing spaces survive
32
+ // intact. No shell is involved either way — paths never go through a string
33
+ // split, so spaces, quotes, and leading dashes are safe.
34
+ const files = Array.isArray(body && body.files) ? body.files.filter((p) => typeof p === 'string' && p.length > 0) : [];
35
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
36
+ if (!action) return sendJSON(res, 400, { error: 'action is required' });
37
+ // These are read-safe or explicit-save commands. `cherry-pick` and `revert`
38
+ // are explicit-save operations that create new commits (and can conflict);
39
+ // the frontend confirms before it calls them. `revert` passes `--no-edit` so
40
+ // it never blocks on the commit-message editor in the non-TTY child.
41
+ const SAFE_ACTIONS = new Set(['status', 'diff', 'log', 'add', 'unstage', 'commit', 'branch', 'checkout', 'stash', 'stash-apply', 'stash-pop', 'stash-drop', 'push', 'pull', 'cherry-pick', 'revert']);
42
+ if (!SAFE_ACTIONS.has(action)) {
43
+ return sendJSON(res, 400, { error: 'unsupported action: ' + action, supported: [...SAFE_ACTIONS] });
44
+ }
45
+
46
+ // Build an argv array (never a string + split) so values containing
47
+ // spaces — commit messages in particular — reach git as single
48
+ // arguments instead of being shattered into separate tokens.
49
+ const splitArgs = (s) => (s ? s.trim().split(/\s+/).filter(Boolean) : []);
50
+ let argv;
51
+ switch (action) {
52
+ case 'status':
53
+ argv = ['status', '--short', '--branch'];
54
+ break;
55
+ case 'diff':
56
+ argv = ['diff'].concat(args ? splitArgs(args) : ['--stat']);
57
+ break;
58
+ case 'log':
59
+ argv = ['log', '--oneline', '-20'].concat(splitArgs(args));
60
+ break;
61
+ case 'add':
62
+ if (files.length === 0) {
63
+ if (!args) return sendJSON(res, 400, { error: 'files (array of paths) required for add' });
64
+ argv = ['add'].concat(splitArgs(args));
65
+ } else {
66
+ argv = ['add', '--'].concat(files);
67
+ }
68
+ break;
69
+ case 'unstage':
70
+ if (files.length === 0) {
71
+ if (!args) return sendJSON(res, 400, { error: 'files (array of paths) required for unstage' });
72
+ argv = ['reset', '-q', 'HEAD', '--'].concat(splitArgs(args));
73
+ } else {
74
+ argv = ['reset', '-q', 'HEAD', '--'].concat(files);
75
+ }
76
+ break;
77
+ case 'commit':
78
+ if (!message) return sendJSON(res, 400, { error: 'message required for commit' });
79
+ argv = ['commit', '-m', message];
80
+ break;
81
+ case 'branch':
82
+ argv = ['branch'].concat(splitArgs(args));
83
+ break;
84
+ case 'checkout':
85
+ if (!args) return sendJSON(res, 400, { error: 'args (branch name) required for checkout' });
86
+ argv = ['checkout', args];
87
+ break;
88
+ case 'stash':
89
+ argv = ['stash'].concat(splitArgs(args));
90
+ break;
91
+ case 'stash-apply':
92
+ argv = ['stash', 'apply'].concat(splitArgs(args));
93
+ break;
94
+ case 'stash-pop':
95
+ argv = ['stash', 'pop'].concat(splitArgs(args));
96
+ break;
97
+ case 'stash-drop':
98
+ argv = ['stash', 'drop'].concat(splitArgs(args));
99
+ break;
100
+ case 'push':
101
+ argv = ['push'].concat(splitArgs(args));
102
+ break;
103
+ case 'pull':
104
+ argv = ['pull'].concat(splitArgs(args));
105
+ break;
106
+ case 'cherry-pick':
107
+ if (!args) return sendJSON(res, 400, { error: 'args (commit hash) required for cherry-pick' });
108
+ argv = ['cherry-pick'].concat(splitArgs(args));
109
+ break;
110
+ case 'revert':
111
+ if (!args) return sendJSON(res, 400, { error: 'args (commit hash) required for revert' });
112
+ argv = ['revert', '--no-edit'].concat(splitArgs(args));
113
+ break;
114
+ default:
115
+ return sendJSON(res, 400, { error: 'unsupported action' });
116
+ }
117
+
118
+ const child = spawn('git', ['-C', projectDir].concat(argv), {
119
+ cwd: projectDir,
120
+ timeout: 15000,
121
+ env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' })
122
+ });
123
+ let stdout = '', stderr = '';
124
+ child.stdout.on('data', d => { stdout += d; });
125
+ child.stderr.on('data', d => { stderr += d; });
126
+ try {
127
+ const exitCode = await new Promise((resolve, reject) => {
128
+ child.on('close', resolve);
129
+ child.on('error', reject);
130
+ });
131
+ return sendJSON(res, 200, { ok: exitCode === 0, stdout: stdout.trim(), stderr: stderr.trim(), exitCode });
132
+ } catch (err) {
133
+ return sendJSON(res, 500, { ok: false, error: err.message, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: -1 });
134
+ }
135
+ }
136
+
137
+ // Parse `git status --porcelain=v1 -z` into the two working-tree sections.
138
+ // Untracked entries use `??`; they belong in the unstaged section even though
139
+ // their worktree status character is `?` rather than a tracked-file status.
140
+ function parsePorcelainStatus(stdout) {
141
+ const records = String(stdout || '').split('\0').filter((s) => s.length > 0);
142
+ const staged = [];
143
+ const unstaged = [];
144
+ for (let i = 0; i < records.length; i++) {
145
+ const rec = records[i];
146
+ const xy = rec.slice(0, 2);
147
+ let path = rec.slice(3);
148
+ if (xy[0] === 'R' || xy[0] === 'C') {
149
+ const target = records[i + 1];
150
+ if (target !== undefined) {
151
+ path = path + ' -> ' + target;
152
+ i++;
153
+ }
154
+ }
155
+ const statusText = xyToText(xy);
156
+ if (xy[0] !== ' ' && xy[0] !== '?') {
157
+ staged.push({ path, status: xy[0], statusText, diff: '' });
158
+ }
159
+ if (xy[1] !== ' ') {
160
+ unstaged.push({ path, status: xy[1], statusText, diff: '' });
161
+ }
162
+ }
163
+ return { staged, unstaged };
164
+ }
165
+ // ---- Git info API ---------------------------------------------------------
166
+ //
167
+ // GET /api/git/info?projectDir=<abs>
168
+ // -> {
169
+ // ok: true,
170
+ // branch: 'master',
171
+ // branches: [ 'master', 'dev', ... ], // local + remote branches
172
+ // stashes: [ { index, subject, date } ],
173
+ // staged: [ { path, status, statusText, diff } ], // staged changes
174
+ // unstaged: [ { path, status, statusText, diff } ], // unstaged changes
175
+ // commits: [ { hash, short, subject, author, date } ] // metadata only
176
+ // }
177
+ //
178
+ // One `git status` + one `git log -20` + per-file diffs (only for the
179
+ // staged/unstaged sections). Everything is parsed from machine-readable
180
+ // output (porcelain v1 with -z record separators, %x00 log formats,
181
+ // unified diff hunks) so the frontend never touches raw shell text.
182
+ // Per-commit file lists are NOT fetched here — the Git modal calls
183
+ // GET /api/git/commit-files the first time a commit is expanded.
184
+ async function handleGitInfo(req, res, parsed) {
185
+ const projectDir = typeof parsed.query.projectDir === 'string' ? parsed.query.projectDir : '';
186
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
187
+
188
+ const run = (args) => new Promise((resolve) => {
189
+ const child = spawn('git', ['-C', projectDir].concat(args), {
190
+ cwd: projectDir,
191
+ timeout: 15000,
192
+ env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }),
193
+ windowsHide: true
194
+ });
195
+ let stdout = '';
196
+ child.stdout.on('data', (d) => { stdout += d; });
197
+ child.on('error', () => resolve({ ok: false, stdout: '', exitCode: -1 }));
198
+ child.on('close', (code) => resolve({ ok: code === 0, stdout, exitCode: code }));
199
+ });
200
+
201
+ const statusRes = await run(['status', '--porcelain=v1', '-z']);
202
+ if (!statusRes.ok) {
203
+ return sendJSON(res, 200, { ok: false, error: 'not a git repository', code: 'ENOGIT' });
204
+ }
205
+
206
+ const branchRes = await run(['symbolic-ref', '--short', '-q', 'HEAD']);
207
+ const branch = (branchRes.ok ? branchRes.stdout : '').trim();
208
+
209
+ // ---- Ahead / behind counts against upstream ---------------------------
210
+ let ahead = 0, behind = 0;
211
+ if (branch) {
212
+ // `git rev-list --count --left-right HEAD...@{upstream}` gives
213
+ // "<ahead>\t<behind>" on stdout (or "0\t0" and exits non-zero when
214
+ // no upstream is configured).
215
+ const abRes = await run(['rev-list', '--count', '--left-right', 'HEAD...@{upstream}']);
216
+ if (abRes.ok) {
217
+ const parts = abRes.stdout.trim().split('\t');
218
+ ahead = parseInt(parts[0], 10) || 0;
219
+ behind = parseInt(parts[1], 10) || 0;
220
+ }
221
+ }
222
+
223
+ // ---- Branches (local + remote) --------------------------------------
224
+ const branchesRes = await run(['branch', '-a', '--format=%(refname:short)']);
225
+ const branches = branchesRes.ok
226
+ ? branchesRes.stdout.split('\n').map((b) => b.trim()).filter(Boolean)
227
+ : [];
228
+
229
+ // ---- Stashes ---------------------------------------------------------
230
+ // `git stash list --format=...` gives one entry per stash:
231
+ // stash@{0} :: subject :: author-relative-date
232
+ const stashRes = await run([
233
+ 'stash', 'list', '--format=%S%x00%s%x00%ad'
234
+ ]);
235
+ const stashes = [];
236
+ if (stashRes.ok) {
237
+ const fields = stashRes.stdout.split('\0');
238
+ for (let i = 0; i + 2 < fields.length; i += 3) {
239
+ stashes.push({
240
+ index: fields[i].trim(), // "stash@{0}"
241
+ subject: fields[i + 1].trim(),
242
+ date: fields[i + 2].trim()
243
+ });
244
+ }
245
+ }
246
+
247
+ // ---- Parse porcelain v1 -z status -------------------------------------
248
+ // One record per change. A renamed file is `XY old\0new\0` (the path
249
+ // is followed by the rename target inside the same record).
250
+ const { staged, unstaged } = parsePorcelainStatus(statusRes.stdout);
251
+
252
+ // ---- Recent commits ----------------------------------------------------
253
+ const logRes = await run([
254
+ 'log', '-20', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00', '--'
255
+ ]);
256
+ const commits = [];
257
+ if (logRes.ok) {
258
+ const logFields = logRes.stdout.split('\0');
259
+ for (let i = 0; i + 4 < logFields.length; i += 5) {
260
+ commits.push({
261
+ hash: logFields[i].trim(),
262
+ short: logFields[i + 1].trim(),
263
+ subject: logFields[i + 2].trim(),
264
+ author: logFields[i + 3].trim(),
265
+ date: logFields[i + 4].trim()
266
+ });
267
+ }
268
+ }
269
+
270
+ // ---- Diffs -----------------------------------------------------------
271
+ // Untracked files have no committed baseline, so no diff exists.
272
+ const capDiff = (text) => {
273
+ const lines = text.split('\n');
274
+ if (lines.length > 120) {
275
+ return lines.slice(0, 120).join('\n') + '\n… diff truncated';
276
+ }
277
+ return text;
278
+ };
279
+
280
+ const MAX_FILE_DIFFS = 12;
281
+ await Promise.all(staged.slice(0, MAX_FILE_DIFFS).map(async (f) => {
282
+ const r = await run(['diff', '--cached', '--', f.path.split(' -> ')[0]]);
283
+ if (r.ok) f.diff = capDiff(r.stdout);
284
+ }));
285
+ await Promise.all(unstaged.slice(0, MAX_FILE_DIFFS).map(async (f) => {
286
+ const r = await run(['diff', '--', f.path.split(' -> ')[0]]);
287
+ if (r.ok) f.diff = capDiff(r.stdout);
288
+ }));
289
+
290
+ return sendJSON(res, 200, {
291
+ ok: true,
292
+ branch,
293
+ ahead,
294
+ behind,
295
+ branches,
296
+ stashes,
297
+ staged,
298
+ unstaged,
299
+ commits,
300
+ truncated: {
301
+ stagedFiles: staged.length > MAX_FILE_DIFFS,
302
+ unstagedFiles: unstaged.length > MAX_FILE_DIFFS
303
+ }
304
+ });
305
+ }
306
+
307
+ // ---- Paginated commits ----------------------------------------------------
308
+ //
309
+ // GET /api/git/commits?projectDir=<abs>&offset=0&count=20
310
+ // -> { ok: true, commits: [ ... ], total: <count of all commits> }
311
+ // Each commit: { hash, short, subject, author, date } — metadata only.
312
+ // File lists are fetched on demand via GET /api/git/commit-files.
313
+ async function handleGitLog(req, res, parsed) {
314
+ const projectDir = typeof parsed.query.projectDir === 'string' ? parsed.query.projectDir : '';
315
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
316
+
317
+ const queryOffset = parseInt(parsed.query.offset, 10);
318
+ const offset = isFinite(queryOffset) && queryOffset >= 0 ? queryOffset : 0;
319
+ const queryCount = parseInt(parsed.query.count, 10);
320
+ const count = isFinite(queryCount) && queryCount >= 1 && queryCount <= 100 ? queryCount : 20;
321
+
322
+ const run = (args) => new Promise((resolve) => {
323
+ const child = spawn('git', ['-C', projectDir].concat(args), {
324
+ cwd: projectDir,
325
+ timeout: 15000,
326
+ env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }),
327
+ windowsHide: true
328
+ });
329
+ let stdout = '';
330
+ child.stdout.on('data', (d) => { stdout += d; });
331
+ child.on('error', () => resolve({ ok: false, stdout: '', exitCode: -1 }));
332
+ child.on('close', (code) => resolve({ ok: code === 0, stdout, exitCode: code }));
333
+ });
334
+
335
+ // Get total commit count
336
+ const totalRes = await run(['rev-list', '--count', 'HEAD']);
337
+ const total = totalRes.ok ? parseInt(totalRes.stdout.trim(), 10) || 0 : 0;
338
+
339
+ // Fetch the page of commits
340
+ const logRes = await run([
341
+ 'log', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00',
342
+ '--skip=' + offset,
343
+ '-' + count,
344
+ '--'
345
+ ]);
346
+ const commits = [];
347
+ if (logRes.ok) {
348
+ const logFields = logRes.stdout.split('\0');
349
+ for (let i = 0; i + 4 < logFields.length; i += 5) {
350
+ commits.push({
351
+ hash: logFields[i].trim(),
352
+ short: logFields[i + 1].trim(),
353
+ subject: logFields[i + 2].trim(),
354
+ author: logFields[i + 3].trim(),
355
+ date: logFields[i + 4].trim()
356
+ });
357
+ }
358
+ }
359
+
360
+ return sendJSON(res, 200, { ok: true, commits, total, offset, count });
361
+ }
362
+
363
+ // ---- Per-commit file list ------------------------------------------------
364
+ //
365
+ // GET /api/git/commit-files?projectDir=<abs>&hash=<full-or-short>&nameStatus=1
366
+ // -> { ok: true, files: [ { path, status, statusText, diff } ] }
367
+ //
368
+ // Full file list for one commit, fetched lazily when the user expands a
369
+ // commit in the Git modal. Uses `git show --name-status` so every changed
370
+ // file is listed (no diff-line truncation); `git show --format=` adds the
371
+ // actual diff hunks. No caps: the whole list is returned for the commit.
372
+ async function handleGitCommitFiles(req, res, parsed) {
373
+ const projectDir = typeof parsed.query.projectDir === 'string' ? parsed.query.projectDir : '';
374
+ const hash = typeof parsed.query.hash === 'string' ? parsed.query.hash.trim() : '';
375
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
376
+ if (!hash) return sendJSON(res, 400, { error: 'hash is required' });
377
+
378
+ const run = (args) => new Promise((resolve) => {
379
+ const child = spawn('git', ['-C', projectDir].concat(args), {
380
+ cwd: projectDir,
381
+ timeout: 15000,
382
+ env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }),
383
+ windowsHide: true
384
+ });
385
+ let stdout = '';
386
+ child.stdout.on('data', (d) => { stdout += d; });
387
+ child.on('error', () => resolve({ ok: false, stdout: '', exitCode: -1 }));
388
+ child.on('close', (code) => resolve({ ok: code === 0, stdout, exitCode: code }));
389
+ });
390
+
391
+ // Sanity-check the hash before passing it to git: allow only hex (at
392
+ // least 4 chars) or a ref-like token such as a commit-ish. Everything
393
+ // else (spaces, shell metacharacters, slashes) is rejected so the arg
394
+ // never escapes into an unexpected git invocation.
395
+ if (!/^[0-9a-fA-F]{4,40}$/.test(hash)) {
396
+ return sendJSON(res, 400, { error: 'invalid hash' });
397
+ }
398
+
399
+ // `--name-status` prints one `<XY>\t<path>` line per changed file (plus
400
+ // `R100\t<old>\t<new>` for renames). Default output is non-verbose, so
401
+ // each file appears exactly once with no copy/rename duplicates.
402
+ const nameRes = await run(['show', '--name-status', '--format=', hash]);
403
+ if (!nameRes.ok) {
404
+ return sendJSON(res, 200, { ok: false, error: 'unknown commit', code: 'ENOCOMMIT' });
405
+ }
406
+
407
+ const files = [];
408
+ const capDiff = (text) => {
409
+ const lines = text.split('\n');
410
+ if (lines.length > 120) {
411
+ return lines.slice(0, 120).join('\n') + '\n… diff truncated';
412
+ }
413
+ return text;
414
+ };
415
+
416
+ // Diff hunks for each changed file, parsed into the same shape the
417
+ // Git modal already renders (statusText derived from the porcelain
418
+ // letter, diff capped at 120 lines per file).
419
+ const diffRes = await run(['show', '--format=', hash]);
420
+ let diffByPath = new Map();
421
+ if (diffRes.ok) {
422
+ let cur = null;
423
+ const lines = diffRes.stdout.split('\n');
424
+ for (const line of lines) {
425
+ const m = line.match(/^diff --git a\/(.*) b\/(.*)$/);
426
+ if (m) {
427
+ if (cur) diffByPath.set(cur.path, cur.diff);
428
+ cur = { path: m[2], diff: line + '\n' };
429
+ continue;
430
+ }
431
+ if (!cur) continue;
432
+ if (line.startsWith('new file mode ')) cur.status = 'A';
433
+ else if (line.startsWith('deleted file mode ')) cur.status = 'D';
434
+ else if (line.startsWith('rename from ')) cur.status = 'R';
435
+ cur.diff += line + '\n';
436
+ }
437
+ if (cur) diffByPath.set(cur.path, cur.diff);
438
+ }
439
+
440
+ const STATUS_TEXT = {
441
+ A: 'Added', M: 'Modified', D: 'Deleted',
442
+ R: 'Renamed', C: 'Copied', T: 'Type changed',
443
+ U: 'Unmerged', X: 'Unknown', B: 'Broken'
444
+ };
445
+
446
+ const nameLines = nameRes.stdout.split('\n').filter((l) => l.length > 0);
447
+ for (const line of nameLines) {
448
+ const fields = line.split('\t');
449
+ const status = (fields[0] || 'M').replace(/[0-9]/g, '').slice(0, 1);
450
+ let path = fields[1] || '';
451
+ if (status === 'R' || status === 'C') {
452
+ path = (fields[1] || '') + ' -> ' + (fields[2] || '');
453
+ }
454
+ if (!path) continue;
455
+ const full = diffByPath.get(path.split(' -> ')[0]) || diffByPath.get(path);
456
+ files.push({
457
+ path,
458
+ status,
459
+ statusText: STATUS_TEXT[status] || status,
460
+ diff: full ? capDiff(full) : ''
461
+ });
462
+ }
463
+
464
+ return sendJSON(res, 200, { ok: true, files });
465
+ }
466
+
467
+ module.exports = { handleGit, handleGitInfo, handleGitLog, handleGitCommitFiles, parsePorcelainStatus };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const oauth = require('./oauth-mcp.js');
4
+ const { sendJSON, readJsonOr400, expectedOrigin, mcp, safeDecode } = require('./server-shared.js');
5
+
6
+ async function handleMcpOAuth(req, res, parsed, serverConfig) {
7
+ const match = parsed.pathname.match(/^\/api\/mcp\/servers\/([^/]+)\/oauth(?:\/(start))?$/);
8
+ if (!match) return false;
9
+ res.setHeader('Cache-Control', 'no-store');
10
+ const isStart = !!match[2];
11
+ if ((isStart && req.method !== 'POST') || (!isStart && !['GET', 'DELETE'].includes(req.method))) {
12
+ sendJSON(res, 405, { error: 'Method not allowed' });
13
+ return true;
14
+ }
15
+ const body = isStart ? await readJsonOr400(req, res) : {};
16
+ if (!body) return true;
17
+ const dir = typeof parsed.query?.projectDir === 'string' ? parsed.query.projectDir : (typeof body.projectDir === 'string' ? body.projectDir : '');
18
+ try {
19
+ const context = mcp.getOAuthContext(dir, safeDecode(match[1]));
20
+ if (!context) { sendJSON(res, 404, { error: 'Server not found' }); return true; }
21
+ if (isStart) {
22
+ await mcp.stopOAuthSessions(context);
23
+ sendJSON(res, 200, await oauth.begin(context, expectedOrigin(req, serverConfig.publicOrigin)));
24
+ } else if (req.method === 'DELETE') {
25
+ oauth.clear(context);
26
+ await mcp.stopOAuthSessions(context);
27
+ sendJSON(res, 200, { ok: true });
28
+ } else {
29
+ sendJSON(res, 200, oauth.status(context));
30
+ }
31
+ } catch (e) {
32
+ const code = ['EBADINPUT', 'EKEYRING', 'EMCP_AUTH'].includes(e.code) ? e.code : 'EMCP_AUTH';
33
+ sendJSON(res, code === 'EBADINPUT' ? 400 : code === 'EKEYRING' ? 503 : 409, {
34
+ code, error: code === e.code ? e.message : 'MCP OAuth failed. Check configuration and try again.'
35
+ });
36
+ }
37
+ return true;
38
+ }
39
+
40
+ async function handleMcpOAuthCallback(req, res, parsed) {
41
+ res.setHeader('Cache-Control', 'no-store');
42
+ res.setHeader('Referrer-Policy', 'no-referrer');
43
+ res.setHeader('Content-Security-Policy', "default-src 'none'; base-uri 'none'; frame-ancestors 'none'");
44
+ if (req.method !== 'GET') { res.writeHead(405).end('Method not allowed'); return; }
45
+ let ok = false;
46
+ try {
47
+ await oauth.finish(parsed.query?.state, parsed.query?.code, parsed.query?.error);
48
+ ok = true;
49
+ } catch { /* Never reflect provider-controlled errors, codes, or tokens. */ }
50
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8' });
51
+ res.end('<!doctype html><html lang="en"><meta name="viewport" content="width=device-width,initial-scale=1"><title>MCP sign-in</title><h1>'
52
+ + (ok ? 'MCP sign-in complete' : 'MCP sign-in failed')
53
+ + '</h1><p>' + (ok ? 'Return to mouaif. You can now start this MCP server.' : 'The request expired, was declined, or could not be completed. Return to Settings and start sign-in again.')
54
+ + '</p><p><a href="/">Return to mouaif</a></p></html>');
55
+ }
56
+ module.exports = { handleMcpOAuth, handleMcpOAuthCallback };