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
|
@@ -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 };
|