agserver 1.0.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/README.md +448 -0
- package/package.json +20 -0
- package/schema/contract.md +109 -0
- package/server/auth.mjs +72 -0
- package/server/chat.mjs +256 -0
- package/server/cli.mjs +138 -0
- package/server/files.mjs +165 -0
- package/server/git.mjs +328 -0
- package/server/index.mjs +225 -0
- package/server/projects.mjs +220 -0
package/server/git.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export const IGNORE_DIRS = new Set([
|
|
5
|
+
'.git', 'node_modules', '.expo', '.expo-shared', '.npm-cache', 'dist', 'ios', 'android',
|
|
6
|
+
]);
|
|
7
|
+
export const IGNORE_FILES = new Set(['iphone-lan-qr.png', 'iphone-tunnel-qr.png']);
|
|
8
|
+
|
|
9
|
+
export function runGitRaw(repoPath, args, fallback = '') {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('git', ['-C', repoPath, ...args], {
|
|
12
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024,
|
|
13
|
+
});
|
|
14
|
+
} catch { return fallback; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function runGitRawBuffer(repoPath, args, fallback = Buffer.alloc(0)) {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync('git', ['-C', repoPath, ...args], {
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024,
|
|
21
|
+
});
|
|
22
|
+
} catch { return fallback; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runGit(repoPath, args, fallback = '') {
|
|
26
|
+
return runGitRaw(repoPath, args, fallback).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isGitRepo(repoPath) {
|
|
30
|
+
return runGit(repoPath, ['rev-parse', '--is-inside-work-tree'], 'false') === 'true';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getCurrentBranch(repoPath) {
|
|
34
|
+
return runGit(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'], 'HEAD');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getDefaultBaseBranch(repoPath) {
|
|
38
|
+
for (const c of ['main', 'master']) {
|
|
39
|
+
if (runGit(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${c}`], 'missing') !== 'missing') return c;
|
|
40
|
+
}
|
|
41
|
+
const remoteHead = runGit(repoPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], '');
|
|
42
|
+
if (remoteHead.startsWith('origin/')) {
|
|
43
|
+
if (runGit(repoPath, ['show-ref', '--verify', '--quiet', `refs/remotes/${remoteHead}`], 'missing') !== 'missing') return remoteHead;
|
|
44
|
+
}
|
|
45
|
+
for (const c of ['develop', 'dev']) {
|
|
46
|
+
if (runGit(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${c}`], 'missing') !== 'missing') return c;
|
|
47
|
+
}
|
|
48
|
+
return getCurrentBranch(repoPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function makeBranchId(name) {
|
|
52
|
+
return `branch-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function toProjectId(raw) {
|
|
56
|
+
return String(raw || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listBranchRefs(repoPath) {
|
|
60
|
+
const raw = runGit(repoPath, [
|
|
61
|
+
'for-each-ref', '--sort=-committerdate',
|
|
62
|
+
'--format=%(refname:short)|%(committerdate:unix)|%(committerdate:relative)|%(subject)',
|
|
63
|
+
'refs/heads',
|
|
64
|
+
], '');
|
|
65
|
+
return raw.split('\n').map(l => l.trim()).filter(Boolean).map(line => {
|
|
66
|
+
const [name, unixStr, relativeDate, subject] = line.split('|');
|
|
67
|
+
return { name, id: makeBranchId(name), unix: Number(unixStr || 0), relativeDate: relativeDate || 'unknown', subject: subject || `Work in progress on ${name}` };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listGitTags(repoPath, limit = 120) {
|
|
72
|
+
const raw = runGit(repoPath, [
|
|
73
|
+
'for-each-ref', '--sort=-creatordate', '--count', String(limit),
|
|
74
|
+
'--format=%(refname:short)|%(creatordate:relative)|%(subject)', 'refs/tags',
|
|
75
|
+
], '');
|
|
76
|
+
return raw.split('\n').map(l => l.trim()).filter(Boolean).map(line => {
|
|
77
|
+
const [name, lastChange, subject] = line.split('|');
|
|
78
|
+
return { id: `tag-${toProjectId(name)}`, name, lastChange: lastChange || 'unknown', subject: subject || '' };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveBranchNameById(repoPath, branchId) {
|
|
83
|
+
if (!branchId) return null;
|
|
84
|
+
if (branchId.startsWith('tag-')) {
|
|
85
|
+
const tag = listGitTags(repoPath).find(t => t.id === branchId);
|
|
86
|
+
if (tag) return tag.name;
|
|
87
|
+
}
|
|
88
|
+
return listBranchRefs(repoPath).find(r => r.id === branchId)?.name ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function listRevSet(repoPath, refName, limit = 400) {
|
|
92
|
+
const raw = runGit(repoPath, ['rev-list', '--max-count', String(limit), refName], '');
|
|
93
|
+
return new Set(raw.split('\n').map(l => l.trim()).filter(Boolean));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildGitGraph(repoPath, selectedBranch, baseBranch) {
|
|
97
|
+
const isSameBranch = selectedBranch === baseBranch;
|
|
98
|
+
const selectedHead = runGit(repoPath, ['rev-parse', selectedBranch], '');
|
|
99
|
+
const baseHead = isSameBranch ? selectedHead : runGit(repoPath, ['rev-parse', baseBranch], '');
|
|
100
|
+
|
|
101
|
+
const logArgs = isSameBranch
|
|
102
|
+
? ['log', '--date=short', '--pretty=format:%H|%h|%an|%ad|%P|%s', '--max-count', '500', selectedBranch]
|
|
103
|
+
: ['log', '--date=short', '--pretty=format:%H|%h|%an|%ad|%P|%s', '--max-count', '600', selectedBranch, baseBranch];
|
|
104
|
+
const logRaw = runGit(repoPath, logArgs, '');
|
|
105
|
+
|
|
106
|
+
// Parse all commits newest-first
|
|
107
|
+
const commits = [];
|
|
108
|
+
for (const line of logRaw.split('\n').map(l => l.trim()).filter(Boolean)) {
|
|
109
|
+
const parts = line.split('|');
|
|
110
|
+
const hash = parts[0] || '';
|
|
111
|
+
commits.push({
|
|
112
|
+
hash,
|
|
113
|
+
shortHash: parts[1] || hash.slice(0, 7),
|
|
114
|
+
author: parts[2] || 'Unknown',
|
|
115
|
+
authoredAt: parts[3] || 'n/a',
|
|
116
|
+
parents: (parts[4] || '').trim() ? parts[4].trim().split(' ') : [],
|
|
117
|
+
subject: parts.slice(5).join('|') || '(no subject)',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build a script for @gitgraph/js imperative API (oldest-first)
|
|
122
|
+
const script = buildGitgraphScript(commits, selectedBranch, baseBranch, isSameBranch, selectedHead, baseHead);
|
|
123
|
+
|
|
124
|
+
return { selectedBranch, baseBranch, isSameBranch, selectedHead, baseHead, script };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function esc(str) {
|
|
128
|
+
return JSON.stringify(String(str));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Generates imperative @gitgraph/js JS that creates the graph correctly.
|
|
132
|
+
// commits: newest-first array from git log
|
|
133
|
+
function buildGitgraphScript(commits, selectedBranch, baseBranch, isSameBranch, selectedHead, baseHead) {
|
|
134
|
+
if (!commits.length) return '';
|
|
135
|
+
|
|
136
|
+
// Reverse to oldest-first for imperative API
|
|
137
|
+
const ordered = [...commits].reverse();
|
|
138
|
+
const hashIndex = new Map(ordered.map((c, i) => [c.hash, i]));
|
|
139
|
+
|
|
140
|
+
// Assign each commit to a branch by walking from branch heads backwards
|
|
141
|
+
// following first-parent chains (same as git's branch membership)
|
|
142
|
+
const commitBranch = new Map(); // hash -> branch name
|
|
143
|
+
|
|
144
|
+
function walkBranch(startHash, branchName) {
|
|
145
|
+
let h = startHash;
|
|
146
|
+
while (h && !commitBranch.has(h)) {
|
|
147
|
+
if (!hashIndex.has(h)) break;
|
|
148
|
+
commitBranch.set(h, branchName);
|
|
149
|
+
const c = ordered[hashIndex.get(h)];
|
|
150
|
+
h = c.parents[0] || null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Walk selected branch first (higher priority), then base
|
|
155
|
+
walkBranch(selectedHead, selectedBranch);
|
|
156
|
+
if (!isSameBranch) walkBranch(baseHead, baseBranch);
|
|
157
|
+
|
|
158
|
+
// Any remaining commits (reachable via merge parents) get assigned to base
|
|
159
|
+
for (const c of ordered) {
|
|
160
|
+
if (!commitBranch.has(c.hash)) {
|
|
161
|
+
commitBranch.set(c.hash, isSameBranch ? selectedBranch : baseBranch);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const lines = [];
|
|
166
|
+
// Compact template: tight spacing, thin lines, small dots
|
|
167
|
+
lines.push(`var g = GitgraphJS.createGitgraph(document.getElementById('g'), {
|
|
168
|
+
template: GitgraphJS.templateExtend(GitgraphJS.TemplateName.Metro, {
|
|
169
|
+
colors: ['#f97316','#3b82f6','#10b981','#8b5cf6','#ec4899','#64748b'],
|
|
170
|
+
branch: {
|
|
171
|
+
lineWidth: 2,
|
|
172
|
+
spacing: 22,
|
|
173
|
+
label: { font: 'bold 11px -apple-system,sans-serif', borderRadius: 4 }
|
|
174
|
+
},
|
|
175
|
+
commit: {
|
|
176
|
+
spacing: 26,
|
|
177
|
+
dot: { size: 5 },
|
|
178
|
+
message: { font: '12px -apple-system,sans-serif', displayAuthor: false, displayHash: false }
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
});`);
|
|
182
|
+
lines.push(`var branches = {};`);
|
|
183
|
+
|
|
184
|
+
// Create branch objects — base first so it gets color index 0 (orange), selected gets index 1 (blue)
|
|
185
|
+
const branchNames = isSameBranch ? [selectedBranch] : [baseBranch, selectedBranch];
|
|
186
|
+
for (const name of branchNames) {
|
|
187
|
+
lines.push(`branches[${esc(name)}] = g.branch(${esc(name)});`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const committed = new Set(); // hashes already committed to graph
|
|
191
|
+
|
|
192
|
+
for (const c of ordered) {
|
|
193
|
+
const branch = commitBranch.get(c.hash) || (isSameBranch ? selectedBranch : baseBranch);
|
|
194
|
+
const subject = c.subject.replace(/`/g, "'");
|
|
195
|
+
|
|
196
|
+
// If this is a merge commit (2 parents), emit merge from the other parent's branch
|
|
197
|
+
if (c.parents.length >= 2) {
|
|
198
|
+
const otherParentHash = c.parents[1];
|
|
199
|
+
const otherBranch = commitBranch.get(otherParentHash);
|
|
200
|
+
if (otherBranch && otherBranch !== branch && committed.has(otherParentHash)) {
|
|
201
|
+
lines.push(`branches[${esc(branch)}].merge({ branch: branches[${esc(otherBranch)}], commitOptions: { hash: ${esc(c.hash)}, subject: ${esc(subject)}, author: ${esc(c.author + ' <>')} } });`);
|
|
202
|
+
committed.add(c.hash);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
lines.push(`branches[${esc(branch)}].commit({ hash: ${esc(c.hash)}, subject: ${esc(subject)}, author: ${esc(c.author + ' <>')} });`);
|
|
208
|
+
committed.add(c.hash);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// WIP node on selected branch
|
|
212
|
+
lines.push(`branches[${esc(selectedBranch)}].commit({ hash: 'wip-node', subject: 'Working tree', author: 'You <>' });`);
|
|
213
|
+
|
|
214
|
+
return lines.join('\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function mapNameStatusToEntry(statusToken) {
|
|
218
|
+
const s = statusToken.toUpperCase();
|
|
219
|
+
if (s.startsWith('D')) return 'D';
|
|
220
|
+
if (s.startsWith('A')) return 'U';
|
|
221
|
+
if (s.startsWith('R')) return 'M';
|
|
222
|
+
return 'M';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function parseNameStatus(raw) {
|
|
226
|
+
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
227
|
+
let modified = 0, untracked = 0, deleted = 0, conflicts = 0;
|
|
228
|
+
const entries = [];
|
|
229
|
+
lines.forEach(line => {
|
|
230
|
+
const [token, a, b] = line.split('\t');
|
|
231
|
+
if (!token || !a) return;
|
|
232
|
+
const filePath = b || a;
|
|
233
|
+
const mapped = mapNameStatusToEntry(token);
|
|
234
|
+
if (mapped === 'D') deleted++; else if (mapped === 'U') untracked++; else modified++;
|
|
235
|
+
if (token.includes('U')) conflicts++;
|
|
236
|
+
entries.push({ path: filePath, status: mapped });
|
|
237
|
+
});
|
|
238
|
+
return { modified, untracked, deleted, conflicts, total: modified + untracked + deleted, entries };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function parseGitStatusPorcelain(repoPath) {
|
|
242
|
+
const raw = runGitRaw(repoPath, ['status', '--porcelain=v1', '--untracked-files=all'], '');
|
|
243
|
+
const lines = raw.split('\n').map(l => l.trimEnd()).filter(Boolean);
|
|
244
|
+
let modified = 0, untracked = 0, deleted = 0, conflicts = 0;
|
|
245
|
+
const entries = [];
|
|
246
|
+
lines.forEach(line => {
|
|
247
|
+
const status = line.slice(0, 2);
|
|
248
|
+
let filePath = line.slice(3).trim();
|
|
249
|
+
if (filePath.includes('->')) filePath = filePath.split('->').at(-1).trim();
|
|
250
|
+
let mappedStatus = 'M';
|
|
251
|
+
if (status.includes('D')) { mappedStatus = 'D'; deleted++; }
|
|
252
|
+
else if (status === '??') { mappedStatus = 'U'; untracked++; }
|
|
253
|
+
else modified++;
|
|
254
|
+
if (status.includes('U') || status === 'AA' || status === 'DD') conflicts++;
|
|
255
|
+
entries.push({ path: filePath, status: mappedStatus });
|
|
256
|
+
});
|
|
257
|
+
return { modified, untracked, deleted, conflicts, total: modified + untracked + deleted, entries };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function buildChangeTree(entries) {
|
|
261
|
+
const root = { type: 'dir', name: '', path: '', children: new Map() };
|
|
262
|
+
entries.forEach((entry, index) => {
|
|
263
|
+
const parts = entry.path.split('/').filter(Boolean);
|
|
264
|
+
let node = root;
|
|
265
|
+
parts.forEach((part, i) => {
|
|
266
|
+
const isFile = i === parts.length - 1;
|
|
267
|
+
const key = `${isFile ? 'f:' : 'd:'}${part}`;
|
|
268
|
+
if (!node.children.has(key)) {
|
|
269
|
+
const fullPath = node.path ? `${node.path}/${part}` : part;
|
|
270
|
+
node.children.set(key, { type: isFile ? 'file' : 'dir', name: part, path: fullPath, status: isFile ? entry.status : '', children: new Map(), fileStatus: isFile ? entry.status : '', order: index });
|
|
271
|
+
}
|
|
272
|
+
node = node.children.get(key);
|
|
273
|
+
if (isFile) node.fileStatus = entry.status;
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
const flat = [];
|
|
277
|
+
let id = 1;
|
|
278
|
+
const walk = (node, level) => {
|
|
279
|
+
const children = Array.from(node.children.values()).sort((a, b) => {
|
|
280
|
+
if (a.type === 'dir' && b.type === 'file') return -1;
|
|
281
|
+
if (a.type === 'file' && b.type === 'dir') return 1;
|
|
282
|
+
return a.name.localeCompare(b.name);
|
|
283
|
+
});
|
|
284
|
+
children.forEach(child => {
|
|
285
|
+
flat.push({ id: `c${id++}`, level, type: child.type, name: child.name, path: child.path, status: child.type === 'file' ? child.fileStatus : '' });
|
|
286
|
+
if (child.type === 'dir') walk(child, level + 1);
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
walk(root, 0);
|
|
290
|
+
return flat;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function truncatePatch(rawPatch) {
|
|
294
|
+
const max = 280_000;
|
|
295
|
+
if (!rawPatch) return { patch: '', patchTruncated: false };
|
|
296
|
+
if (rawPatch.length <= max) return { patch: rawPatch, patchTruncated: false };
|
|
297
|
+
return { patch: `${rawPatch.slice(0, max)}\n\n[Patch preview truncated for large changes.]`, patchTruncated: true };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function parseLsTreeEntry(rawLine) {
|
|
301
|
+
const m = rawLine.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/);
|
|
302
|
+
if (!m) return null;
|
|
303
|
+
return { mode: m[1], objectType: m[2], object: m[3], size: m[4] === '-' ? undefined : Number(m[4]), name: m[5] };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function hashToColor(input) {
|
|
307
|
+
const palette = ['#2563EB', '#059669', '#EA580C', '#8B5CF6', '#DB2777', '#4B5563'];
|
|
308
|
+
let h = 0;
|
|
309
|
+
for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) >>> 0;
|
|
310
|
+
return palette[h % palette.length];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function iconForBranchName(name) {
|
|
314
|
+
const lower = name.toLowerCase();
|
|
315
|
+
if (lower.includes('fix') || lower.includes('bug')) return 'bug-report';
|
|
316
|
+
if (lower.includes('feat')) return 'alt-route';
|
|
317
|
+
if (lower.includes('chore')) return 'build';
|
|
318
|
+
if (lower.includes('exp')) return 'science';
|
|
319
|
+
return 'call-split';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function getBranchContext(project, branchId) {
|
|
323
|
+
const currentBranch = getCurrentBranch(project.repoPath);
|
|
324
|
+
const resolved = branchId ? resolveBranchNameById(project.repoPath, branchId) : null;
|
|
325
|
+
if (branchId && !resolved) throw new Error('branch_not_found');
|
|
326
|
+
const selectedBranch = resolved || currentBranch;
|
|
327
|
+
return { currentBranch, selectedBranch, usingLiveFs: selectedBranch === currentBranch };
|
|
328
|
+
}
|
package/server/index.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
import { assertAuthorized, isWeakApiKey, listLanIps } from './auth.mjs';
|
|
5
|
+
import { runGit, runGitRawBuffer, getCurrentBranch, getDefaultBaseBranch, resolveBranchNameById, makeBranchId, truncatePatch, getBranchContext } from './git.mjs';
|
|
6
|
+
import { readFileContent, readFileContentFromRef, listDirChunk, listDirChunkFromRef, normalizeRepoSubPath, resolveSafePath, smudgeLfsPointer, isLfsPointer } from './files.mjs';
|
|
7
|
+
import { findProjectById, scanProjectsRoot, getProjectListData, getBranchesData, getWorkspaceData, getCommitDetailData, getCommitFileContent, getProjectSettings, setProjectSettings } from './projects.mjs';
|
|
8
|
+
import { handleChatStream, handleChatMessage, handleWorktreesDispose, syncWorktrees } from './chat.mjs';
|
|
9
|
+
|
|
10
|
+
export const API_VERSION = '1.0.0';
|
|
11
|
+
|
|
12
|
+
export function createServer({ PORT, HOST, API_KEY, API_KEY_IS_GENERATED, PRIVATE_IP_ONLY }) {
|
|
13
|
+
function json(res, status, payload) {
|
|
14
|
+
res.writeHead(status, {
|
|
15
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
16
|
+
'Cache-Control': 'no-store',
|
|
17
|
+
'Access-Control-Allow-Origin': '*',
|
|
18
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-agserver-key',
|
|
19
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
20
|
+
'x-agserver-version': API_VERSION,
|
|
21
|
+
});
|
|
22
|
+
res.end(JSON.stringify(payload));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseQuery(urlObj) {
|
|
26
|
+
const params = {};
|
|
27
|
+
urlObj.searchParams.forEach((value, key) => { params[key] = value; });
|
|
28
|
+
return params;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
if (req.method === 'OPTIONS') {
|
|
33
|
+
res.writeHead(204, {
|
|
34
|
+
'Access-Control-Allow-Origin': '*',
|
|
35
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-agserver-key',
|
|
36
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
37
|
+
});
|
|
38
|
+
res.end();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const urlObj = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
43
|
+
const pathname = urlObj.pathname;
|
|
44
|
+
|
|
45
|
+
if (pathname === '/health') {
|
|
46
|
+
json(res, 200, { ok: true, apiVersion: API_VERSION, host: HOST, port: PORT, privateIpOnly: PRIVATE_IP_ONLY, time: new Date().toISOString() });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!pathname.startsWith('/api/')) { json(res, 404, { error: 'not_found' }); return; }
|
|
51
|
+
if (!assertAuthorized(req, res, { API_KEY, PRIVATE_IP_ONLY, jsonFn: json })) return;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const q = parseQuery(urlObj);
|
|
55
|
+
|
|
56
|
+
if (pathname === '/api/project-list') { json(res, 200, getProjectListData()); return; }
|
|
57
|
+
|
|
58
|
+
if (pathname === '/api/project-branches') {
|
|
59
|
+
if (!q.projectId) { json(res, 400, { error: 'missing_project_id' }); return; }
|
|
60
|
+
const data = getBranchesData(q.projectId);
|
|
61
|
+
if (!data) { json(res, 404, { error: 'project_not_found' }); return; }
|
|
62
|
+
json(res, 200, data); return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (pathname === '/api/branch-workspace') {
|
|
66
|
+
if (!q.projectId || !q.branchId) { json(res, 400, { error: 'missing_project_or_branch' }); return; }
|
|
67
|
+
const data = getWorkspaceData(q.projectId, q.branchId);
|
|
68
|
+
if (!data) { json(res, 404, { error: 'workspace_not_found' }); return; }
|
|
69
|
+
json(res, 200, data); return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (pathname === '/api/git/commit') {
|
|
73
|
+
if (!q.projectId || !q.branchId || !q.commitId) { json(res, 400, { error: 'missing_project_branch_or_commit' }); return; }
|
|
74
|
+
const data = getCommitDetailData(q.projectId, q.branchId, q.commitId);
|
|
75
|
+
if (!data) { json(res, 404, { error: 'commit_not_found' }); return; }
|
|
76
|
+
json(res, 200, data); return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (pathname === '/api/git/commit-file') {
|
|
80
|
+
if (!q.projectId || !q.branchId || !q.commitId || !q.path) { json(res, 400, { error: 'missing_project_branch_commit_or_path' }); return; }
|
|
81
|
+
const data = getCommitFileContent(q.projectId, q.branchId, q.commitId, q.path);
|
|
82
|
+
if (!data) { json(res, 404, { error: 'commit_or_file_not_found' }); return; }
|
|
83
|
+
json(res, 200, data); return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (pathname === '/api/git/commit-file-diff') {
|
|
87
|
+
if (!q.projectId || !q.branchId || !q.commitId || !q.path) { json(res, 400, { error: 'missing_project_branch_commit_or_path' }); return; }
|
|
88
|
+
const project = findProjectById(q.projectId);
|
|
89
|
+
if (!project) { json(res, 404, { error: 'project_not_found' }); return; }
|
|
90
|
+
const branchName = resolveBranchNameById(project.repoPath, q.branchId);
|
|
91
|
+
if (!branchName) { json(res, 404, { error: 'branch_not_found' }); return; }
|
|
92
|
+
const relative = normalizeRepoSubPath(q.path);
|
|
93
|
+
let diffRaw = '';
|
|
94
|
+
if (q.commitId === 'working-tree') {
|
|
95
|
+
const currentBranch = getCurrentBranch(project.repoPath);
|
|
96
|
+
if (branchName === currentBranch) {
|
|
97
|
+
diffRaw = runGit(project.repoPath, ['diff', '--no-color', 'HEAD', '--', relative], '');
|
|
98
|
+
if (!diffRaw) diffRaw = runGit(project.repoPath, ['diff', '--no-color', '--', '/dev/null', relative], '');
|
|
99
|
+
} else {
|
|
100
|
+
const baseBranch = getDefaultBaseBranch(project.repoPath);
|
|
101
|
+
diffRaw = runGit(project.repoPath, ['diff', '--no-color', `${baseBranch}...${branchName}`, '--', relative], '');
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
diffRaw = runGit(project.repoPath, ['show', '--no-color', '--format=', q.commitId, '--', relative], '');
|
|
105
|
+
}
|
|
106
|
+
const { patch, patchTruncated } = truncatePatch(diffRaw);
|
|
107
|
+
json(res, 200, { diff: patch, truncated: patchTruncated }); return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (pathname === '/api/files/tree') {
|
|
111
|
+
if (!q.projectId) { json(res, 400, { error: 'missing_project_id' }); return; }
|
|
112
|
+
const project = findProjectById(q.projectId);
|
|
113
|
+
if (!project) { json(res, 404, { error: 'project_not_found' }); return; }
|
|
114
|
+
const branchContext = getBranchContext(project, q.branchId || '');
|
|
115
|
+
const levelNum = Math.max(1, Math.min(4, Number(q.levels) || 2));
|
|
116
|
+
const nodes = branchContext.usingLiveFs
|
|
117
|
+
? listDirChunk(project.repoPath, q.path || '', levelNum)
|
|
118
|
+
: listDirChunkFromRef(project.repoPath, branchContext.selectedBranch, q.path || '', levelNum);
|
|
119
|
+
json(res, 200, { projectId: q.projectId, branchId: q.branchId || makeBranchId(branchContext.selectedBranch), sourceBranch: branchContext.selectedBranch, usingLiveFs: branchContext.usingLiveFs, path: q.path || '', levels: levelNum, nodes }); return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (pathname === '/api/files/pdf') {
|
|
123
|
+
if (!q.projectId || !q.path) { json(res, 400, { error: 'missing_project_or_path' }); return; }
|
|
124
|
+
const project = findProjectById(q.projectId);
|
|
125
|
+
if (!project) { json(res, 404, { error: 'project_not_found' }); return; }
|
|
126
|
+
let rawBuffer;
|
|
127
|
+
if (q.commitId) {
|
|
128
|
+
const relative = normalizeRepoSubPath(q.path);
|
|
129
|
+
rawBuffer = runGitRawBuffer(project.repoPath, ['show', `${q.commitId}:${relative}`]);
|
|
130
|
+
if (!rawBuffer.length) { json(res, 404, { error: 'file_not_found' }); return; }
|
|
131
|
+
const asText = rawBuffer.toString('utf8');
|
|
132
|
+
if (isLfsPointer(asText)) { const s = smudgeLfsPointer(project.repoPath, asText); if (!s) { json(res, 404, { error: 'lfs_unavailable' }); return; } rawBuffer = s; }
|
|
133
|
+
} else {
|
|
134
|
+
const branchContext = getBranchContext(project, q.branchId || '');
|
|
135
|
+
if (branchContext.usingLiveFs) {
|
|
136
|
+
const { target } = resolveSafePath(project.repoPath, q.path);
|
|
137
|
+
rawBuffer = fs.readFileSync(target);
|
|
138
|
+
} else {
|
|
139
|
+
const relative = normalizeRepoSubPath(q.path);
|
|
140
|
+
rawBuffer = runGitRawBuffer(project.repoPath, ['show', `${branchContext.selectedBranch}:${relative}`]);
|
|
141
|
+
if (!rawBuffer.length) { json(res, 404, { error: 'file_not_found' }); return; }
|
|
142
|
+
const asText = rawBuffer.toString('utf8');
|
|
143
|
+
if (isLfsPointer(asText)) { const s = smudgeLfsPointer(project.repoPath, asText); if (!s) { json(res, 404, { error: 'lfs_unavailable' }); return; } rawBuffer = s; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': rawBuffer.length, 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'x-agserver-version': API_VERSION });
|
|
147
|
+
res.end(rawBuffer); return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (pathname === '/api/files/content') {
|
|
151
|
+
if (!q.projectId || !q.path) { json(res, 400, { error: 'missing_project_or_path' }); return; }
|
|
152
|
+
const project = findProjectById(q.projectId);
|
|
153
|
+
if (!project) { json(res, 404, { error: 'project_not_found' }); return; }
|
|
154
|
+
const branchContext = getBranchContext(project, q.branchId || '');
|
|
155
|
+
const data = branchContext.usingLiveFs
|
|
156
|
+
? readFileContent(project.repoPath, q.path)
|
|
157
|
+
: readFileContentFromRef(project.repoPath, branchContext.selectedBranch, q.path);
|
|
158
|
+
json(res, 200, data); return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (pathname === '/api/chat/stream' && req.method === 'POST') { handleChatStream(req, res, json); return; }
|
|
162
|
+
if (pathname === '/api/chat/message' && req.method === 'POST') { handleChatMessage(req, res, json); return; }
|
|
163
|
+
if (pathname === '/api/worktrees/dispose' && req.method === 'POST') { handleWorktreesDispose(req, res, json); return; }
|
|
164
|
+
|
|
165
|
+
if (pathname === '/api/project-settings') {
|
|
166
|
+
if (!q.projectId) { json(res, 400, { error: 'missing_project_id' }); return; }
|
|
167
|
+
if (!findProjectById(q.projectId)) { json(res, 404, { error: 'project_not_found' }); return; }
|
|
168
|
+
if (req.method === 'GET') {
|
|
169
|
+
json(res, 200, { projectId: q.projectId, settings: getProjectSettings(q.projectId) }); return;
|
|
170
|
+
}
|
|
171
|
+
if (req.method === 'POST') {
|
|
172
|
+
let body = '';
|
|
173
|
+
req.on('data', c => { body += c; });
|
|
174
|
+
req.on('end', () => {
|
|
175
|
+
let patch;
|
|
176
|
+
try { patch = JSON.parse(body); } catch { json(res, 400, { error: 'invalid_json' }); return; }
|
|
177
|
+
const updated = setProjectSettings(q.projectId, patch);
|
|
178
|
+
json(res, 200, { projectId: q.projectId, settings: updated });
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
json(res, 404, { error: 'not_found' });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
187
|
+
const code = typeof error === 'object' && error && 'code' in error ? error.code : '';
|
|
188
|
+
if (msg === 'branch_not_found' || msg === 'file_not_found') { json(res, 404, { error: msg }); return; }
|
|
189
|
+
if (['missing_file_path', 'not_a_file', 'path_outside_repo', 'path_blocked'].includes(msg) || code === 'ENOENT') { json(res, 400, { error: msg || 'invalid_request' }); return; }
|
|
190
|
+
json(res, 500, { error: 'server_error', message: msg });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return server;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function startServer(opts = {}) {
|
|
198
|
+
const PORT = opts.port ?? Number(process.env.AGSERVER_PORT || 8765);
|
|
199
|
+
const HOST = opts.host ?? process.env.AGSERVER_HOST ?? '127.0.0.1';
|
|
200
|
+
const ALLOW_WEAK_KEY = opts.allowWeakKey ?? process.env.AGSERVER_ALLOW_WEAK_KEY === '1';
|
|
201
|
+
const PRIVATE_IP_ONLY = opts.privateIpOnly ?? process.env.AGSERVER_PRIVATE_IP_ONLY === '1';
|
|
202
|
+
const API_KEY_IS_GENERATED = !opts.apiKey && !process.env.AGSERVER_API_KEY;
|
|
203
|
+
const API_KEY = opts.apiKey ?? process.env.AGSERVER_API_KEY ?? randomBytes(24).toString('base64url');
|
|
204
|
+
|
|
205
|
+
if (!ALLOW_WEAK_KEY && !API_KEY_IS_GENERATED && isWeakApiKey(API_KEY)) {
|
|
206
|
+
throw new Error('Weak AGSERVER_API_KEY — use at least 24 random characters, or set AGSERVER_ALLOW_WEAK_KEY=1.');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
for (const p of scanProjectsRoot()) {
|
|
211
|
+
try { syncWorktrees(p.repoPath); } catch { /* best effort */ }
|
|
212
|
+
}
|
|
213
|
+
} catch { /* best effort */ }
|
|
214
|
+
|
|
215
|
+
const server = createServer({ PORT, HOST, API_KEY, API_KEY_IS_GENERATED, PRIVATE_IP_ONLY, ALLOW_WEAK_KEY });
|
|
216
|
+
server.listen(PORT, HOST, () => {
|
|
217
|
+
const localIps = listLanIps();
|
|
218
|
+
console.log('[agserver] running');
|
|
219
|
+
console.log(`[agserver] host=${HOST} port=${PORT} apiVersion=${API_VERSION}`);
|
|
220
|
+
console.log(`[agserver] projects root=${process.env.AGSERVER_PROJECTS_ROOT || '/Volumes/Agentic/AGProject'}`);
|
|
221
|
+
console.log(`[agserver] local ips=${localIps.join(', ') || 'n/a'}`);
|
|
222
|
+
if (API_KEY_IS_GENERATED) console.log(`[agserver] api key: ${API_KEY}`);
|
|
223
|
+
});
|
|
224
|
+
return server;
|
|
225
|
+
}
|