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/chat.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getCurrentBranch, resolveBranchNameById } from './git.mjs';
|
|
5
|
+
import { findProjectById, scanProjectsRoot, getProjectSettings } from './projects.mjs';
|
|
6
|
+
|
|
7
|
+
const KIRO_CLI = process.env.AGSERVER_KIRO_CLI || 'kiro-cli';
|
|
8
|
+
|
|
9
|
+
// Set of worktree dirs currently alive in this process
|
|
10
|
+
const activeWorktrees = new Set();
|
|
11
|
+
|
|
12
|
+
// Per-worktree kiro-cli session state: worktreeDir -> { queue, pendingCount }
|
|
13
|
+
const kiroSessions = new Map();
|
|
14
|
+
|
|
15
|
+
function stripAnsi(str) {
|
|
16
|
+
return str.replace(/\x1b\[[0-9;?]*[mGKHFlABCDJsu]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseKiroOutput(stdout, stderr) {
|
|
20
|
+
const clean = stripAnsi(stdout);
|
|
21
|
+
const lines = clean.split('\n');
|
|
22
|
+
let credits, elapsed;
|
|
23
|
+
const metaMatch = stripAnsi(stderr).match(/Credits:\s*([\d.]+).*Time:\s*(\S+)/);
|
|
24
|
+
if (metaMatch) { credits = parseFloat(metaMatch[1]); elapsed = metaMatch[2]; }
|
|
25
|
+
const responseLines = [];
|
|
26
|
+
for (const line of lines) responseLines.push(line.replace(/^>\s?/, ''));
|
|
27
|
+
while (responseLines.length && !responseLines[0].trim()) responseLines.shift();
|
|
28
|
+
while (responseLines.length && !responseLines[responseLines.length - 1].trim()) responseLines.pop();
|
|
29
|
+
return { response: responseLines.join('\n'), credits, elapsed };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isOwnedWorktreeDir(dirPath, baseName) {
|
|
33
|
+
const name = path.basename(dirPath);
|
|
34
|
+
if (name.startsWith(`${baseName}_wt_`)) return true;
|
|
35
|
+
if (name.startsWith(`${baseName}__wt-`)) return true;
|
|
36
|
+
if (name.startsWith('chatagent-test-') && (dirPath.startsWith('/tmp') || dirPath.startsWith('/private/tmp'))) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function branchToDir(branchName) {
|
|
41
|
+
return branchName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function worktreeDirForBranch(repoPath, branchName) {
|
|
45
|
+
return path.join(path.dirname(repoPath), `${path.basename(repoPath)}_wt_${branchToDir(branchName)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function syncWorktrees(repoPath) {
|
|
49
|
+
const baseName = path.basename(repoPath);
|
|
50
|
+
const parentDir = path.dirname(repoPath);
|
|
51
|
+
let entries = [];
|
|
52
|
+
try {
|
|
53
|
+
const raw = execFileSync('git', ['-C', repoPath, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
54
|
+
entries = raw.trim().split(/\n\n+/).map(block => {
|
|
55
|
+
const wtLine = block.split('\n').find(l => l.startsWith('worktree '));
|
|
56
|
+
return wtLine ? wtLine.slice('worktree '.length).trim() : null;
|
|
57
|
+
}).filter(Boolean);
|
|
58
|
+
} catch { /* best effort */ }
|
|
59
|
+
|
|
60
|
+
for (const wtDir of entries.slice(1)) {
|
|
61
|
+
if (!isOwnedWorktreeDir(wtDir, baseName) || activeWorktrees.has(wtDir)) continue;
|
|
62
|
+
try { execFileSync('git', ['-C', repoPath, 'worktree', 'remove', '--force', wtDir], { stdio: 'ignore' }); } catch { /* best effort */ }
|
|
63
|
+
if (fs.existsSync(wtDir)) try { fs.rmSync(wtDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
64
|
+
}
|
|
65
|
+
try { execFileSync('git', ['-C', repoPath, 'worktree', 'prune'], { stdio: 'ignore' }); } catch { /* best effort */ }
|
|
66
|
+
try {
|
|
67
|
+
for (const name of fs.readdirSync(parentDir)) {
|
|
68
|
+
if (!name.startsWith(`${baseName}_wt_`) && !name.startsWith(`${baseName}__wt-`)) continue;
|
|
69
|
+
const fullPath = path.join(parentDir, name);
|
|
70
|
+
if (activeWorktrees.has(fullPath)) continue;
|
|
71
|
+
try { fs.rmSync(fullPath, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
72
|
+
}
|
|
73
|
+
} catch { /* best effort */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function disposeWorktrees(repoPath) {
|
|
77
|
+
const baseName = path.basename(repoPath);
|
|
78
|
+
const results = [];
|
|
79
|
+
let entries = [];
|
|
80
|
+
try {
|
|
81
|
+
const raw = execFileSync('git', ['-C', repoPath, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
82
|
+
entries = raw.trim().split(/\n\n+/).map(block => {
|
|
83
|
+
const lines = block.split('\n');
|
|
84
|
+
const wtLine = lines.find(l => l.startsWith('worktree '));
|
|
85
|
+
const branchLine = lines.find(l => l.startsWith('branch '));
|
|
86
|
+
return { dir: wtLine ? wtLine.slice('worktree '.length).trim() : null, branch: branchLine ? branchLine.slice('branch '.length).trim().replace(/^refs\/heads\//, '') : null };
|
|
87
|
+
}).filter(e => e.dir);
|
|
88
|
+
} catch { /* best effort */ }
|
|
89
|
+
|
|
90
|
+
for (const { dir: wtDir, branch } of entries.slice(1)) {
|
|
91
|
+
if (!isOwnedWorktreeDir(wtDir, baseName)) continue;
|
|
92
|
+
let pushed = false, pushError;
|
|
93
|
+
if (branch) {
|
|
94
|
+
try { execFileSync('git', ['-C', wtDir, 'push', 'origin', branch], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 30000 }); pushed = true; }
|
|
95
|
+
catch (e) { pushError = e.message; }
|
|
96
|
+
}
|
|
97
|
+
try { execFileSync('git', ['-C', repoPath, 'worktree', 'remove', '--force', wtDir], { stdio: 'ignore' }); } catch { /* best effort */ }
|
|
98
|
+
if (fs.existsSync(wtDir)) try { fs.rmSync(wtDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
99
|
+
activeWorktrees.delete(wtDir);
|
|
100
|
+
kiroSessions.delete(wtDir);
|
|
101
|
+
results.push({ dir: wtDir, branch, pushed, error: pushError });
|
|
102
|
+
}
|
|
103
|
+
try { execFileSync('git', ['-C', repoPath, 'worktree', 'prune'], { stdio: 'ignore' }); } catch { /* best effort */ }
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getOrCreateWorktree(repoPath, branchName) {
|
|
108
|
+
const worktreeDir = worktreeDirForBranch(repoPath, branchName);
|
|
109
|
+
if (activeWorktrees.has(worktreeDir)) return worktreeDir;
|
|
110
|
+
const sha = execFileSync('git', ['-C', repoPath, 'rev-parse', branchName], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
111
|
+
execFileSync('git', ['-C', repoPath, 'worktree', 'add', '--detach', worktreeDir, sha], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
112
|
+
activeWorktrees.add(worktreeDir);
|
|
113
|
+
return worktreeDir;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function teardownWorktree(repoPath, worktreeDir) {
|
|
117
|
+
activeWorktrees.delete(worktreeDir);
|
|
118
|
+
kiroSessions.delete(worktreeDir);
|
|
119
|
+
try { execFileSync('git', ['-C', repoPath, 'worktree', 'remove', '--force', worktreeDir], { stdio: 'ignore' }); } catch { /* best effort */ }
|
|
120
|
+
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runKiroCliOnce(worktreeDir, message, isResume) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const args = ['chat', '--no-interactive', '--trust-all-tools', '--wrap', 'never'];
|
|
126
|
+
if (isResume) args.push('--resume');
|
|
127
|
+
args.push(message);
|
|
128
|
+
const child = spawn(KIRO_CLI, args, { cwd: worktreeDir, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
129
|
+
let stdout = '', stderr = '';
|
|
130
|
+
child.stdout.on('data', c => { stdout += c.toString(); });
|
|
131
|
+
child.stderr.on('data', c => { stderr += c.toString(); });
|
|
132
|
+
child.on('close', code => {
|
|
133
|
+
if (code !== 0 && !stdout) { reject(new Error(`kiro-cli exited with code ${code}: ${stderr.slice(0, 200)}`)); return; }
|
|
134
|
+
resolve(parseKiroOutput(stdout, stderr));
|
|
135
|
+
});
|
|
136
|
+
child.on('error', reject);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function enqueueKiroCli(repoPath, worktreeDir, message, direct = false) {
|
|
141
|
+
let session = kiroSessions.get(worktreeDir);
|
|
142
|
+
if (!session) { session = { queue: Promise.resolve(), pendingCount: 0 }; kiroSessions.set(worktreeDir, session); }
|
|
143
|
+
const isResume = session.pendingCount > 0;
|
|
144
|
+
session.pendingCount++;
|
|
145
|
+
const resultPromise = session.queue.then(() => runKiroCliOnce(worktreeDir, message, isResume));
|
|
146
|
+
session.queue = resultPromise.catch(() => {}).then(() => {
|
|
147
|
+
session.pendingCount--;
|
|
148
|
+
if (!direct && session.pendingCount === 0) teardownWorktree(repoPath, worktreeDir);
|
|
149
|
+
});
|
|
150
|
+
return resultPromise;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const MAX_ATTACHMENTS = 10;
|
|
154
|
+
|
|
155
|
+
function buildKiroMessage(context, messages, attachments) {
|
|
156
|
+
const parts = [];
|
|
157
|
+
if (context.filePath) parts.push(`Active file: ${context.filePath}`);
|
|
158
|
+
if (context.fileContent) parts.push(`File content:\n\`\`\`\n${context.fileContent.slice(0, 12000)}\n\`\`\``);
|
|
159
|
+
if (context.fileDiff) parts.push(`Diff:\n\`\`\`diff\n${context.fileDiff.slice(0, 8000)}\n\`\`\``);
|
|
160
|
+
const safeAttachments = Array.isArray(attachments) ? attachments.slice(0, MAX_ATTACHMENTS) : [];
|
|
161
|
+
for (const att of safeAttachments) parts.push(`Attached file: ${att.path}\n\`\`\`\n${att.content.slice(0, 8000)}\n\`\`\``);
|
|
162
|
+
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
|
163
|
+
if (lastUser) parts.push(lastUser.content);
|
|
164
|
+
return parts.join('\n\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveWorktree(context) {
|
|
168
|
+
const project = findProjectById(context.projectId);
|
|
169
|
+
if (!project) return { error: 'project_not_found' };
|
|
170
|
+
const branchName = context.branchId
|
|
171
|
+
? resolveBranchNameById(project.repoPath, context.branchId)
|
|
172
|
+
: getCurrentBranch(project.repoPath);
|
|
173
|
+
if (!branchName) return { error: 'branch_not_found' };
|
|
174
|
+
|
|
175
|
+
const { worktreeEnabled } = getProjectSettings(context.projectId);
|
|
176
|
+
if (!worktreeEnabled) {
|
|
177
|
+
// Direct mode: run kiro-cli in the repo dir, no worktree created
|
|
178
|
+
return { project, branchName, worktreeDir: project.repoPath, direct: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const worktreeDir = getOrCreateWorktree(project.repoPath, branchName);
|
|
183
|
+
return { project, branchName, worktreeDir, direct: false };
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { error: `worktree_setup_failed: ${e.message}`, repoPath: project.repoPath, branchName };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function handleChatStream(req, res, jsonFn) {
|
|
190
|
+
let body = '';
|
|
191
|
+
req.on('data', c => { body += c; });
|
|
192
|
+
req.on('end', () => {
|
|
193
|
+
let parsed;
|
|
194
|
+
try { parsed = JSON.parse(body); } catch { jsonFn(res, 400, { error: 'invalid_json' }); return; }
|
|
195
|
+
const { context = {}, messages = [], attachments = [] } = parsed;
|
|
196
|
+
const resolved = resolveWorktree(context);
|
|
197
|
+
if (resolved.error) {
|
|
198
|
+
console.error(`[chat/stream] ${resolved.error} project=${context.projectId} branch=${resolved.branchName}`);
|
|
199
|
+
jsonFn(res, resolved.error === 'project_not_found' || resolved.error === 'branch_not_found' ? 404 : 500, { error: resolved.error });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const message = buildKiroMessage(context, messages, attachments);
|
|
203
|
+
if (!message.trim()) { jsonFn(res, 400, { error: 'empty_message' }); return; }
|
|
204
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, x-agserver-key' });
|
|
205
|
+
enqueueKiroCli(resolved.project.repoPath, resolved.worktreeDir, message, resolved.direct)
|
|
206
|
+
.then(({ response, credits, elapsed }) => {
|
|
207
|
+
if (response) res.write(JSON.stringify({ delta: response }) + '\n');
|
|
208
|
+
res.write(JSON.stringify({ done: true, credits, elapsed }) + '\n');
|
|
209
|
+
res.end();
|
|
210
|
+
})
|
|
211
|
+
.catch(err => {
|
|
212
|
+
console.error(`[chat/stream] kiro-cli error project=${context.projectId} err=${err}`);
|
|
213
|
+
res.write(JSON.stringify({ error: String(err), done: true }) + '\n');
|
|
214
|
+
res.end();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function handleChatMessage(req, res, jsonFn) {
|
|
220
|
+
let body = '';
|
|
221
|
+
req.on('data', c => { body += c; });
|
|
222
|
+
req.on('end', () => {
|
|
223
|
+
let parsed;
|
|
224
|
+
try { parsed = JSON.parse(body); } catch { jsonFn(res, 400, { error: 'invalid_json' }); return; }
|
|
225
|
+
const { context = {}, messages = [], attachments = [] } = parsed;
|
|
226
|
+
const resolved = resolveWorktree(context);
|
|
227
|
+
if (resolved.error) {
|
|
228
|
+
jsonFn(res, resolved.error === 'project_not_found' || resolved.error === 'branch_not_found' ? 404 : 500, { error: resolved.error });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const message = buildKiroMessage(context, messages, attachments);
|
|
232
|
+
if (!message.trim()) { jsonFn(res, 400, { error: 'empty_message' }); return; }
|
|
233
|
+
enqueueKiroCli(resolved.project.repoPath, resolved.worktreeDir, message, resolved.direct)
|
|
234
|
+
.then(({ response, credits, elapsed }) => jsonFn(res, 200, { content: response, credits, elapsed }))
|
|
235
|
+
.catch(err => {
|
|
236
|
+
console.error(`[chat/message] kiro-cli error project=${context.projectId} err=${err}`);
|
|
237
|
+
jsonFn(res, 502, { error: String(err) });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function handleWorktreesDispose(req, res, jsonFn) {
|
|
243
|
+
let body = '';
|
|
244
|
+
req.on('data', c => { body += c; });
|
|
245
|
+
req.on('end', () => {
|
|
246
|
+
let parsed = {};
|
|
247
|
+
if (body.trim()) {
|
|
248
|
+
try { parsed = JSON.parse(body); } catch { jsonFn(res, 400, { error: 'invalid_json' }); return; }
|
|
249
|
+
}
|
|
250
|
+
const { projectId } = parsed;
|
|
251
|
+
const targets = projectId ? [findProjectById(projectId)].filter(Boolean) : scanProjectsRoot();
|
|
252
|
+
const report = {};
|
|
253
|
+
for (const p of targets) report[p.id] = disposeWorktrees(p.repoPath);
|
|
254
|
+
jsonFn(res, 200, { disposed: report });
|
|
255
|
+
});
|
|
256
|
+
}
|
package/server/cli.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { startServer } from './index.mjs';
|
|
7
|
+
|
|
8
|
+
function generateApiKey() {
|
|
9
|
+
return randomBytes(24).toString('base64url'); // 32 URL-safe chars, cryptographically random
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function die(msg) {
|
|
15
|
+
console.error(`\n error: ${msg}\n`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function warn(msg) {
|
|
20
|
+
console.warn(` warn: ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
console.log(`
|
|
25
|
+
Usage: agserver [path] [options]
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
path Root folder containing git repos (default: current dir)
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
-p, --port <port> Port to listen on (default: 8765)
|
|
32
|
+
-H, --host <host> Host to bind (default: 127.0.0.1)
|
|
33
|
+
-k, --key <key> API key (default: auto-generated)
|
|
34
|
+
--public Bind to 0.0.0.0 (shorthand)
|
|
35
|
+
--version Print version and exit
|
|
36
|
+
-h, --help Show this help
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
agserver ~/projects
|
|
40
|
+
agserver ~/projects --port 9000 --public
|
|
41
|
+
agserver . -p 8765 -k my-secret-key-here
|
|
42
|
+
`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── parse args ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
let projectsRoot = null;
|
|
50
|
+
let port = null;
|
|
51
|
+
let host = null;
|
|
52
|
+
let apiKey = null;
|
|
53
|
+
let publicBind = false;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
const a = args[i];
|
|
57
|
+
if (a === '-h' || a === '--help') usage();
|
|
58
|
+
else if (a === '--version') {
|
|
59
|
+
const { API_VERSION } = await import('./index.mjs');
|
|
60
|
+
console.log(API_VERSION);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
else if (a === '--public') publicBind = true;
|
|
64
|
+
else if ((a === '-p' || a === '--port') && args[i + 1]) port = Number(args[++i]);
|
|
65
|
+
else if ((a === '-H' || a === '--host') && args[i + 1]) host = args[++i];
|
|
66
|
+
else if ((a === '-k' || a === '--key') && args[i + 1]) apiKey = args[++i];
|
|
67
|
+
else if (!a.startsWith('-')) projectsRoot = a;
|
|
68
|
+
else die(`unknown option: ${a}\nRun agserver --help for usage.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── resolve projects root ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const resolvedRoot = path.resolve(projectsRoot || process.env.AGSERVER_PROJECTS_ROOT || process.cwd());
|
|
74
|
+
|
|
75
|
+
if (!fs.existsSync(resolvedRoot)) die(`path does not exist: ${resolvedRoot}`);
|
|
76
|
+
if (!fs.statSync(resolvedRoot).isDirectory()) die(`path is not a directory: ${resolvedRoot}`);
|
|
77
|
+
|
|
78
|
+
process.env.AGSERVER_PROJECTS_ROOT = resolvedRoot;
|
|
79
|
+
|
|
80
|
+
// ── preflight: kiro-cli ───────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const KIRO_CLI = process.env.AGSERVER_KIRO_CLI || 'kiro-cli';
|
|
83
|
+
let kiroPreflight = { ok: false, version: null, authed: false };
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const ver = execFileSync(KIRO_CLI, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
|
|
87
|
+
kiroPreflight.ok = true;
|
|
88
|
+
kiroPreflight.version = ver;
|
|
89
|
+
} catch {
|
|
90
|
+
// not installed or not on PATH
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (kiroPreflight.ok) {
|
|
94
|
+
// Check if kiro-cli is authenticated (best-effort: run a no-op or check config)
|
|
95
|
+
try {
|
|
96
|
+
execFileSync(KIRO_CLI, ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
|
|
97
|
+
kiroPreflight.authed = true;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
const out = e.stdout || e.stderr || '';
|
|
100
|
+
// treat as authed if the command doesn't exist (older kiro-cli without auth subcommand)
|
|
101
|
+
kiroPreflight.authed = !out.toLowerCase().includes('not logged') && !out.toLowerCase().includes('unauthenticated');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── startup banner ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(' agserver');
|
|
109
|
+
console.log(` projects root : ${resolvedRoot}`);
|
|
110
|
+
console.log(` port : ${port ?? Number(process.env.AGSERVER_PORT || 8765)}`);
|
|
111
|
+
|
|
112
|
+
if (!kiroPreflight.ok) {
|
|
113
|
+
warn(`kiro-cli not found on PATH (looked for: ${KIRO_CLI})`);
|
|
114
|
+
warn('Chat endpoints (/api/chat/*) will fail until kiro-cli is installed.');
|
|
115
|
+
warn('Install: https://kiro.dev/docs/cli');
|
|
116
|
+
console.log('');
|
|
117
|
+
} else if (!kiroPreflight.authed) {
|
|
118
|
+
warn(`kiro-cli found (${kiroPreflight.version}) but may not be authenticated.`);
|
|
119
|
+
warn('Run: kiro-cli auth login');
|
|
120
|
+
console.log('');
|
|
121
|
+
} else {
|
|
122
|
+
console.log(` kiro-cli : ${kiroPreflight.version} ✓`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── start ─────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
if (publicBind && !host) host = '0.0.0.0';
|
|
128
|
+
if (apiKey) process.env.AGSERVER_API_KEY = apiKey;
|
|
129
|
+
|
|
130
|
+
// Always generate a strong random key if none provided — never fall back to the known default
|
|
131
|
+
const resolvedKey = apiKey ?? process.env.AGSERVER_API_KEY ?? generateApiKey();
|
|
132
|
+
|
|
133
|
+
startServer({
|
|
134
|
+
port: port ?? undefined,
|
|
135
|
+
host: host ?? undefined,
|
|
136
|
+
apiKey: resolvedKey,
|
|
137
|
+
allowWeakKey: false,
|
|
138
|
+
});
|
package/server/files.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { runGit, runGitRaw, runGitRawBuffer, IGNORE_DIRS, IGNORE_FILES, parseLsTreeEntry } from './git.mjs';
|
|
5
|
+
|
|
6
|
+
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg']);
|
|
7
|
+
const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v1';
|
|
8
|
+
const MAX_FILE_BYTES = 400_000;
|
|
9
|
+
|
|
10
|
+
export function isLfsPointer(content) {
|
|
11
|
+
return typeof content === 'string' && content.trimStart().startsWith(LFS_POINTER_PREFIX);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isTextFile(filePath, size) {
|
|
15
|
+
if (size > MAX_FILE_BYTES) return false;
|
|
16
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
17
|
+
const textExt = new Set([
|
|
18
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.mdc', '.txt',
|
|
19
|
+
'.css', '.scss', '.sass', '.html', '.yml', '.yaml', '.xml', '.toml',
|
|
20
|
+
'.sh', '.zsh', '.bash', '.env', '.gitignore', '.npmrc',
|
|
21
|
+
]);
|
|
22
|
+
if (textExt.has(ext)) return true;
|
|
23
|
+
return size < 100_000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function looksLikeBinaryBuffer(buffer) {
|
|
27
|
+
if (!buffer || buffer.length === 0) return false;
|
|
28
|
+
const scanLen = Math.min(buffer.length, 4096);
|
|
29
|
+
let suspicious = 0;
|
|
30
|
+
for (let i = 0; i < scanLen; i++) {
|
|
31
|
+
const b = buffer[i];
|
|
32
|
+
if (b === 0) return true;
|
|
33
|
+
if (b < 7 || (b > 14 && b < 32) || b === 127) suspicious++;
|
|
34
|
+
}
|
|
35
|
+
return suspicious / scanLen > 0.15;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function smudgeLfsPointer(repoPath, pointerText) {
|
|
39
|
+
try {
|
|
40
|
+
return execFileSync('git', ['-C', repoPath, 'lfs', 'smudge'], {
|
|
41
|
+
input: pointerText, encoding: 'buffer', stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024,
|
|
42
|
+
});
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeRepoSubPath(subPath) {
|
|
47
|
+
const normalized = path.posix.normalize(`/${subPath || ''}`).replace(/^\/+/, '');
|
|
48
|
+
if (normalized === '.' || normalized === '/') return '';
|
|
49
|
+
if (normalized.startsWith('..')) throw new Error('path_outside_repo');
|
|
50
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
51
|
+
if (parts.some(p => p === '.' || p === '..')) throw new Error('path_outside_repo');
|
|
52
|
+
if (parts.some(p => IGNORE_DIRS.has(p))) throw new Error('path_blocked');
|
|
53
|
+
return parts.join('/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveSafePath(repoPath, subPath) {
|
|
57
|
+
const normalized = normalizeRepoSubPath(subPath);
|
|
58
|
+
const target = path.resolve(repoPath, normalized || '.');
|
|
59
|
+
const safeRoot = path.resolve(repoPath);
|
|
60
|
+
if (!(target === safeRoot || target.startsWith(`${safeRoot}${path.sep}`))) throw new Error('path_outside_repo');
|
|
61
|
+
const relative = path.relative(safeRoot, target);
|
|
62
|
+
return { target, relative: relative ? relative.split(path.sep).join('/') : '' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readFileContent(repoPath, filePath) {
|
|
66
|
+
const { target, relative } = resolveSafePath(repoPath, filePath);
|
|
67
|
+
const stat = fs.statSync(target);
|
|
68
|
+
if (!stat.isFile()) throw new Error('not_a_file');
|
|
69
|
+
const ext = path.extname(relative).toLowerCase();
|
|
70
|
+
if (IMAGE_EXTS.has(ext) || ext === '.pdf') {
|
|
71
|
+
const raw = fs.readFileSync(target);
|
|
72
|
+
return { path: relative, content: raw.toString('base64'), size: stat.size, binary: false, image: IMAGE_EXTS.has(ext) || undefined, pdf: ext === '.pdf' || undefined, truncated: false };
|
|
73
|
+
}
|
|
74
|
+
const raw = fs.readFileSync(target);
|
|
75
|
+
const binary = !isTextFile(target, stat.size) || looksLikeBinaryBuffer(raw);
|
|
76
|
+
if (binary) return { path: relative, content: '[Binary file preview is not available in this viewer.]', size: stat.size, binary: true, truncated: false };
|
|
77
|
+
let content = raw.toString('utf8');
|
|
78
|
+
let truncated = false;
|
|
79
|
+
if (content.length > 200_000) { content = `${content.slice(0, 200_000)}\n\n[Preview truncated for large file.]`; truncated = true; }
|
|
80
|
+
return { path: relative, content, size: stat.size, binary: false, truncated };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readFileContentFromRef(repoPath, ref, filePath) {
|
|
84
|
+
const relative = normalizeRepoSubPath(filePath);
|
|
85
|
+
if (!relative) throw new Error('missing_file_path');
|
|
86
|
+
const fileSpec = `${ref}:${relative}`;
|
|
87
|
+
if (runGit(repoPath, ['cat-file', '-e', fileSpec], 'missing') === 'missing') throw new Error('file_not_found');
|
|
88
|
+
const size = Number(runGit(repoPath, ['cat-file', '-s', fileSpec], '0')) || 0;
|
|
89
|
+
const ext = path.extname(relative).toLowerCase();
|
|
90
|
+
if (IMAGE_EXTS.has(ext) || ext === '.pdf') {
|
|
91
|
+
const raw = runGitRawBuffer(repoPath, ['show', fileSpec]);
|
|
92
|
+
const asText = raw.toString('utf8');
|
|
93
|
+
if (isLfsPointer(asText)) {
|
|
94
|
+
const smudged = smudgeLfsPointer(repoPath, asText);
|
|
95
|
+
if (smudged) return { path: relative, content: smudged.toString('base64'), size, binary: false, image: IMAGE_EXTS.has(ext) || undefined, pdf: ext === '.pdf' || undefined, truncated: false };
|
|
96
|
+
return { path: relative, content: '', size, binary: false, lfs: true, truncated: false };
|
|
97
|
+
}
|
|
98
|
+
return { path: relative, content: raw.toString('base64'), size, binary: false, image: IMAGE_EXTS.has(ext) || undefined, pdf: ext === '.pdf' || undefined, truncated: false };
|
|
99
|
+
}
|
|
100
|
+
const raw = runGitRawBuffer(repoPath, ['show', fileSpec]);
|
|
101
|
+
const binary = !isTextFile(relative, size) || looksLikeBinaryBuffer(raw);
|
|
102
|
+
if (binary) return { path: relative, content: '[Binary file preview is not available in this viewer.]', size, binary: true, truncated: false };
|
|
103
|
+
let content = raw.toString('utf8');
|
|
104
|
+
if (isLfsPointer(content)) {
|
|
105
|
+
const smudged = smudgeLfsPointer(repoPath, content);
|
|
106
|
+
if (smudged) {
|
|
107
|
+
if (looksLikeBinaryBuffer(smudged)) return { path: relative, content: smudged.toString('base64'), size, binary: false, image: IMAGE_EXTS.has(ext) || undefined, truncated: false };
|
|
108
|
+
content = smudged.toString('utf8');
|
|
109
|
+
} else {
|
|
110
|
+
return { path: relative, content: '', size, binary: false, lfs: true, truncated: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let truncated = false;
|
|
114
|
+
if (content.length > 200_000) { content = `${content.slice(0, 200_000)}\n\n[Preview truncated for large file.]`; truncated = true; }
|
|
115
|
+
return { path: relative, content, size, binary: false, truncated };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function listDirChunk(repoPath, subPath, levels) {
|
|
119
|
+
const { target, relative } = resolveSafePath(repoPath, subPath);
|
|
120
|
+
if (!fs.statSync(target).isDirectory()) return [];
|
|
121
|
+
const entries = fs.readdirSync(target, { withFileTypes: true })
|
|
122
|
+
.filter(e => e.isDirectory() ? !IGNORE_DIRS.has(e.name) : !IGNORE_FILES.has(e.name))
|
|
123
|
+
.sort((a, b) => {
|
|
124
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
125
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
126
|
+
return a.name.localeCompare(b.name);
|
|
127
|
+
});
|
|
128
|
+
return entries.map(entry => {
|
|
129
|
+
const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
130
|
+
const childAbs = path.join(target, entry.name);
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
let hasChildren = true, children;
|
|
133
|
+
if (levels > 1) { children = listDirChunk(repoPath, childRelative, levels - 1); }
|
|
134
|
+
else { try { hasChildren = fs.readdirSync(childAbs).length > 0; } catch { hasChildren = false; } }
|
|
135
|
+
return { name: entry.name, path: childRelative, type: 'dir', hasChildren, children };
|
|
136
|
+
}
|
|
137
|
+
const fileStat = fs.statSync(childAbs);
|
|
138
|
+
return { name: entry.name, path: childRelative, type: 'file', size: fileStat.size, ext: path.extname(entry.name).toLowerCase(), hasChildren: false };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function listDirChunkFromRef(repoPath, ref, subPath, levels) {
|
|
143
|
+
const relative = normalizeRepoSubPath(subPath);
|
|
144
|
+
const treeSpec = relative ? `${ref}:${relative}` : `${ref}:`;
|
|
145
|
+
const lsRaw = runGitRaw(repoPath, ['ls-tree', '-z', '--long', treeSpec], '');
|
|
146
|
+
if (!lsRaw) return [];
|
|
147
|
+
return lsRaw.split('\0').filter(Boolean)
|
|
148
|
+
.map(parseLsTreeEntry).filter(Boolean)
|
|
149
|
+
.filter(e => e.objectType === 'tree' ? !IGNORE_DIRS.has(e.name) : !IGNORE_FILES.has(e.name))
|
|
150
|
+
.sort((a, b) => {
|
|
151
|
+
if (a.objectType === 'tree' && b.objectType !== 'tree') return -1;
|
|
152
|
+
if (a.objectType !== 'tree' && b.objectType === 'tree') return 1;
|
|
153
|
+
return a.name.localeCompare(b.name);
|
|
154
|
+
})
|
|
155
|
+
.map(entry => {
|
|
156
|
+
const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
157
|
+
if (entry.objectType === 'tree') {
|
|
158
|
+
let hasChildren = true, children;
|
|
159
|
+
if (levels > 1) { children = listDirChunkFromRef(repoPath, ref, childRelative, levels - 1); }
|
|
160
|
+
else { hasChildren = Boolean(runGitRaw(repoPath, ['ls-tree', '-z', '--long', `${ref}:${childRelative}`], '')); }
|
|
161
|
+
return { name: entry.name, path: childRelative, type: 'dir', hasChildren, children };
|
|
162
|
+
}
|
|
163
|
+
return { name: entry.name, path: childRelative, type: 'file', size: entry.size ?? 0, ext: path.extname(entry.name).toLowerCase(), hasChildren: false };
|
|
164
|
+
});
|
|
165
|
+
}
|