@musashishao/folderforge 1.2.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 +181 -0
- package/dist/adapters/child-mcp/client.js +114 -0
- package/dist/adapters/child-mcp/registry.js +66 -0
- package/dist/audit/audit-log.js +45 -0
- package/dist/audit/event-types.js +1 -0
- package/dist/core/config.js +211 -0
- package/dist/core/container.js +51 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/types.js +4 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/lsp/protocol.js +116 -0
- package/dist/main.js +190 -0
- package/dist/managers/db-manager.js +161 -0
- package/dist/managers/lsp-manager.js +269 -0
- package/dist/managers/process-manager.js +140 -0
- package/dist/policy/approvals.js +143 -0
- package/dist/policy/command-policy.js +99 -0
- package/dist/policy/glob-match.js +61 -0
- package/dist/policy/path-policy.js +73 -0
- package/dist/policy/policy-engine.js +156 -0
- package/dist/policy/rate-limiter.js +96 -0
- package/dist/policy/risk.js +112 -0
- package/dist/policy/secret-policy.js +132 -0
- package/dist/server/mcp-server.js +144 -0
- package/dist/server/transports/http.js +133 -0
- package/dist/server/transports/stdio.js +14 -0
- package/dist/tools/adapter-tools.js +62 -0
- package/dist/tools/browser-tools.js +76 -0
- package/dist/tools/build-tools.js +78 -0
- package/dist/tools/code-tools.js +250 -0
- package/dist/tools/coverage-tools.js +135 -0
- package/dist/tools/db-tools.js +130 -0
- package/dist/tools/diff-util.js +45 -0
- package/dist/tools/error-parser.js +57 -0
- package/dist/tools/file-tools.js +319 -0
- package/dist/tools/format-tools.js +118 -0
- package/dist/tools/git-tools.js +371 -0
- package/dist/tools/index.js +63 -0
- package/dist/tools/memory-tools.js +54 -0
- package/dist/tools/output-schemas.js +100 -0
- package/dist/tools/pagination.js +92 -0
- package/dist/tools/pkg-tools.js +260 -0
- package/dist/tools/process-tools.js +128 -0
- package/dist/tools/registry.js +194 -0
- package/dist/tools/schema-lock.js +152 -0
- package/dist/tools/search-tools.js +176 -0
- package/dist/tools/security-tools.js +147 -0
- package/dist/tools/terminal-tools.js +57 -0
- package/dist/tools/workspace-tools.js +186 -0
- package/dist/workspace/memory-store.js +67 -0
- package/dist/workspace/onboarding.js +46 -0
- package/dist/workspace/project-detector.js +95 -0
- package/dist/workspace/workspace-manager.js +106 -0
- package/docs/adapters.md +76 -0
- package/docs/architecture.md +66 -0
- package/docs/roadmap.md +172 -0
- package/docs/security.md +94 -0
- package/docs/tools.md +129 -0
- package/examples/claude-desktop.json +18 -0
- package/examples/codex.toml +18 -0
- package/examples/config.basic.yaml +37 -0
- package/examples/config.full.yaml +120 -0
- package/package.json +74 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for FolderForge.
|
|
3
|
+
*/
|
|
4
|
+
export class FolderForgeError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'FolderForgeError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class PolicyDeniedError extends FolderForgeError {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super('POLICY_DENIED', message);
|
|
15
|
+
this.name = 'PolicyDeniedError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class ApprovalRequiredError extends FolderForgeError {
|
|
19
|
+
approvalId;
|
|
20
|
+
constructor(message, approvalId) {
|
|
21
|
+
super('APPROVAL_REQUIRED', message);
|
|
22
|
+
this.name = 'ApprovalRequiredError';
|
|
23
|
+
this.approvalId = approvalId;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class PathEscapeError extends FolderForgeError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super('PATH_ESCAPE', message);
|
|
29
|
+
this.name = 'PathEscapeError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class WorkspaceNotActiveError extends FolderForgeError {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('NO_WORKSPACE', 'No active workspace. Call workspace_activate first.');
|
|
35
|
+
this.name = 'WorkspaceNotActiveError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { logger } from '../core/logger.js';
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
/** True when the bind host is loopback-only and therefore safe without a token. */
|
|
9
|
+
export function isLoopbackHost(host) {
|
|
10
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Local control-plane dashboard. Read-only views plus approval actions.
|
|
14
|
+
*
|
|
15
|
+
* Endpoints:
|
|
16
|
+
* GET / -> static dashboard (dashboard/static/index.html)
|
|
17
|
+
* GET /status -> server + workspace + policy snapshot
|
|
18
|
+
* GET /audit -> recent audit events
|
|
19
|
+
* GET /processes -> managed long-running processes
|
|
20
|
+
* GET /approvals -> pending + resolved approval requests
|
|
21
|
+
* POST /approvals/:id/approve -> approve (body: { scope?: 'once'|'session' })
|
|
22
|
+
* POST /approvals/:id/deny -> deny
|
|
23
|
+
*/
|
|
24
|
+
export function startDashboard(container, registry, opts) {
|
|
25
|
+
// Auth is enforced only when bound to a non-loopback address. A loopback bind
|
|
26
|
+
// is treated as trusted (same machine), matching the default 127.0.0.1.
|
|
27
|
+
const requireAuth = !isLoopbackHost(opts.host);
|
|
28
|
+
if (requireAuth && !opts.token) {
|
|
29
|
+
throw new Error('Dashboard bound to a non-loopback host requires a token');
|
|
30
|
+
}
|
|
31
|
+
const server = createServer((req, res) => {
|
|
32
|
+
if (requireAuth && !isAuthorized(req, opts.token)) {
|
|
33
|
+
res.writeHead(401, {
|
|
34
|
+
'content-type': 'application/json; charset=utf-8',
|
|
35
|
+
'www-authenticate': 'Bearer realm="folderforge-dashboard"',
|
|
36
|
+
});
|
|
37
|
+
res.end(JSON.stringify({ error: 'unauthorized', message: 'Valid bearer token required.' }));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
handle(req, res, container, registry).catch((err) => {
|
|
41
|
+
logger.error({ err: String(err) }, 'Dashboard request failed');
|
|
42
|
+
sendJson(res, 500, { error: 'internal_error', message: String(err) });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
server.listen(opts.port, opts.host, () => {
|
|
46
|
+
logger.info({ host: opts.host, port: opts.port, authRequired: requireAuth }, 'Dashboard listening');
|
|
47
|
+
});
|
|
48
|
+
return server;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Accept the token via either `Authorization: Bearer <token>` or a `?token=`
|
|
52
|
+
* query parameter (handy for opening the dashboard in a browser). Comparison is
|
|
53
|
+
* constant-time to avoid leaking the token length/prefix via timing.
|
|
54
|
+
*/
|
|
55
|
+
function isAuthorized(req, expected) {
|
|
56
|
+
const header = req.headers['authorization'];
|
|
57
|
+
let provided;
|
|
58
|
+
if (typeof header === 'string' && header.startsWith('Bearer ')) {
|
|
59
|
+
provided = header.slice('Bearer '.length).trim();
|
|
60
|
+
}
|
|
61
|
+
if (!provided) {
|
|
62
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
63
|
+
provided = url.searchParams.get('token') ?? undefined;
|
|
64
|
+
}
|
|
65
|
+
if (!provided)
|
|
66
|
+
return false;
|
|
67
|
+
return timingSafeEqualStr(provided, expected);
|
|
68
|
+
}
|
|
69
|
+
function timingSafeEqualStr(a, b) {
|
|
70
|
+
const ab = Buffer.from(a);
|
|
71
|
+
const bb = Buffer.from(b);
|
|
72
|
+
if (ab.length !== bb.length)
|
|
73
|
+
return false;
|
|
74
|
+
return timingSafeEqual(ab, bb);
|
|
75
|
+
}
|
|
76
|
+
async function handle(req, res, container, registry) {
|
|
77
|
+
const method = req.method ?? 'GET';
|
|
78
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
79
|
+
const path = url.pathname;
|
|
80
|
+
if (method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
81
|
+
return sendStatic(res);
|
|
82
|
+
}
|
|
83
|
+
if (method === 'GET' && path === '/status') {
|
|
84
|
+
const active = container.workspace.getActive();
|
|
85
|
+
return sendJson(res, 200, {
|
|
86
|
+
server: {
|
|
87
|
+
name: container.config.server.name,
|
|
88
|
+
transport: container.config.server.transport,
|
|
89
|
+
},
|
|
90
|
+
workspace: {
|
|
91
|
+
active: Boolean(active),
|
|
92
|
+
projectRoot: container.projectRoot(),
|
|
93
|
+
name: active?.name ?? null,
|
|
94
|
+
languageHints: active?.languageHints ?? [],
|
|
95
|
+
allowedDirectories: container.config.workspace.allowedDirectories,
|
|
96
|
+
},
|
|
97
|
+
policy: container.policy.describe(),
|
|
98
|
+
tools: {
|
|
99
|
+
active: registry.listActive().length,
|
|
100
|
+
total: registry.listAll().length,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (method === 'GET' && path === '/audit') {
|
|
105
|
+
const limit = clampInt(url.searchParams.get('limit'), 50, 1, 500);
|
|
106
|
+
return sendJson(res, 200, { entries: container.audit.recent(limit) });
|
|
107
|
+
}
|
|
108
|
+
if (method === 'GET' && path === '/processes') {
|
|
109
|
+
return sendJson(res, 200, { processes: container.processes.list() });
|
|
110
|
+
}
|
|
111
|
+
if (method === 'GET' && path === '/approvals') {
|
|
112
|
+
return sendJson(res, 200, {
|
|
113
|
+
pending: container.policy.approvals.pending(),
|
|
114
|
+
all: container.policy.approvals.all(),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// POST /approvals/:id/approve | /approvals/:id/deny
|
|
118
|
+
const approvalMatch = /^\/approvals\/([^/]+)\/(approve|deny)$/.exec(path);
|
|
119
|
+
if (method === 'POST' && approvalMatch) {
|
|
120
|
+
const id = approvalMatch[1];
|
|
121
|
+
const action = approvalMatch[2];
|
|
122
|
+
const body = await readJsonBody(req);
|
|
123
|
+
let result;
|
|
124
|
+
if (action === 'approve') {
|
|
125
|
+
const scope = body?.scope === 'session' ? 'session' : 'once';
|
|
126
|
+
result = container.policy.approvals.approve(id, scope);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
result = container.policy.approvals.deny(id);
|
|
130
|
+
}
|
|
131
|
+
if (!result) {
|
|
132
|
+
return sendJson(res, 404, { error: 'approval_not_found', id });
|
|
133
|
+
}
|
|
134
|
+
container.audit.record({
|
|
135
|
+
type: 'approval_resolved',
|
|
136
|
+
tool: result.tool,
|
|
137
|
+
risk: result.risk,
|
|
138
|
+
summary: `${action} (${result.state})`,
|
|
139
|
+
detail: { approvalId: id },
|
|
140
|
+
});
|
|
141
|
+
return sendJson(res, 200, { approval: result });
|
|
142
|
+
}
|
|
143
|
+
sendJson(res, 404, { error: 'not_found', path });
|
|
144
|
+
}
|
|
145
|
+
function sendStatic(res) {
|
|
146
|
+
const candidates = [
|
|
147
|
+
join(__dirname, 'static', 'index.html'),
|
|
148
|
+
join(process.cwd(), 'src', 'dashboard', 'static', 'index.html'),
|
|
149
|
+
];
|
|
150
|
+
for (const file of candidates) {
|
|
151
|
+
if (existsSync(file)) {
|
|
152
|
+
const html = readFileSync(file, 'utf8');
|
|
153
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
154
|
+
res.end(html);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
159
|
+
res.end('Dashboard static asset not found');
|
|
160
|
+
}
|
|
161
|
+
function sendJson(res, status, body) {
|
|
162
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
163
|
+
res.end(JSON.stringify(body, null, 2));
|
|
164
|
+
}
|
|
165
|
+
async function readJsonBody(req) {
|
|
166
|
+
return new Promise((resolveBody) => {
|
|
167
|
+
let raw = '';
|
|
168
|
+
req.on('data', (chunk) => {
|
|
169
|
+
raw += chunk;
|
|
170
|
+
if (raw.length > 1_000_000)
|
|
171
|
+
req.destroy();
|
|
172
|
+
});
|
|
173
|
+
req.on('end', () => {
|
|
174
|
+
if (!raw.trim())
|
|
175
|
+
return resolveBody(null);
|
|
176
|
+
try {
|
|
177
|
+
resolveBody(JSON.parse(raw));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
resolveBody(null);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
req.on('error', () => resolveBody(null));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function clampInt(value, fallback, min, max) {
|
|
187
|
+
const n = value ? Number(value) : NaN;
|
|
188
|
+
if (!Number.isFinite(n))
|
|
189
|
+
return fallback;
|
|
190
|
+
return Math.min(max, Math.max(min, Math.floor(n)));
|
|
191
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Language Server Protocol (LSP) transport primitives.
|
|
3
|
+
*
|
|
4
|
+
* LSP runs JSON-RPC 2.0 over a stream framed with HTTP-style headers:
|
|
5
|
+
*
|
|
6
|
+
* Content-Length: <bytes>\r\n
|
|
7
|
+
* \r\n
|
|
8
|
+
* <utf8 json body>
|
|
9
|
+
*
|
|
10
|
+
* This module is intentionally transport-pure: it knows how to *encode* an
|
|
11
|
+
* outgoing message and how to *incrementally decode* a byte stream into
|
|
12
|
+
* complete messages. It spawns nothing and touches no filesystem, which keeps
|
|
13
|
+
* the framing logic unit-testable without a real language server (the spawn /
|
|
14
|
+
* lifecycle layer lives in `managers/lsp-manager.ts`).
|
|
15
|
+
*/
|
|
16
|
+
/** Encode a JSON-RPC message into a framed LSP buffer (headers + body). */
|
|
17
|
+
export function encodeMessage(message) {
|
|
18
|
+
const body = Buffer.from(JSON.stringify(message), 'utf8');
|
|
19
|
+
const header = `Content-Length: ${body.length}\r\n\r\n`;
|
|
20
|
+
return Buffer.concat([Buffer.from(header, 'ascii'), body]);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Incremental decoder for the LSP framing. Feed it raw chunks as they arrive on
|
|
24
|
+
* the language server's stdout; it buffers partial frames and emits each
|
|
25
|
+
* complete JSON-RPC message via the `onMessage` callback. A single chunk may
|
|
26
|
+
* contain zero, one, or many messages, and a message may be split across
|
|
27
|
+
* chunks; both cases are handled.
|
|
28
|
+
*/
|
|
29
|
+
export class MessageBuffer {
|
|
30
|
+
onMessage;
|
|
31
|
+
buffer = Buffer.alloc(0);
|
|
32
|
+
constructor(onMessage) {
|
|
33
|
+
this.onMessage = onMessage;
|
|
34
|
+
}
|
|
35
|
+
append(chunk) {
|
|
36
|
+
this.buffer = this.buffer.length
|
|
37
|
+
? Buffer.concat([this.buffer, chunk])
|
|
38
|
+
: Buffer.from(chunk);
|
|
39
|
+
this.drain();
|
|
40
|
+
}
|
|
41
|
+
drain() {
|
|
42
|
+
// Loop because one append may complete several queued frames.
|
|
43
|
+
for (;;) {
|
|
44
|
+
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
45
|
+
if (headerEnd === -1)
|
|
46
|
+
return; // headers not fully received yet
|
|
47
|
+
const header = this.buffer.subarray(0, headerEnd).toString('ascii');
|
|
48
|
+
const length = parseContentLength(header);
|
|
49
|
+
if (length === null) {
|
|
50
|
+
// Malformed header block: drop it and resync past the separator so a
|
|
51
|
+
// single bad frame can't wedge the stream forever.
|
|
52
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const bodyStart = headerEnd + 4;
|
|
56
|
+
const bodyEnd = bodyStart + length;
|
|
57
|
+
if (this.buffer.length < bodyEnd)
|
|
58
|
+
return; // body not fully received yet
|
|
59
|
+
const body = this.buffer.subarray(bodyStart, bodyEnd).toString('utf8');
|
|
60
|
+
this.buffer = this.buffer.subarray(bodyEnd);
|
|
61
|
+
let parsed = null;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(body);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
parsed = null; // skip a non-JSON body but keep draining
|
|
67
|
+
}
|
|
68
|
+
if (parsed)
|
|
69
|
+
this.onMessage(parsed);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Parse the `Content-Length` value out of an LSP header block. */
|
|
74
|
+
export function parseContentLength(header) {
|
|
75
|
+
for (const line of header.split(/\r\n/)) {
|
|
76
|
+
const m = /^Content-Length:\s*(\d+)\s*$/i.exec(line);
|
|
77
|
+
if (m) {
|
|
78
|
+
const n = Number(m[1]);
|
|
79
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** Type guard: a message is a response when it carries an `id` and no `method`. */
|
|
85
|
+
export function isResponse(msg) {
|
|
86
|
+
return 'id' in msg && !('method' in msg);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* LSP SymbolKind enum (subset we surface). Maps numeric kinds returned by
|
|
90
|
+
* documentSymbol / workspaceSymbol to human-readable names.
|
|
91
|
+
*/
|
|
92
|
+
export const SYMBOL_KIND = {
|
|
93
|
+
1: 'file',
|
|
94
|
+
2: 'module',
|
|
95
|
+
3: 'namespace',
|
|
96
|
+
4: 'package',
|
|
97
|
+
5: 'class',
|
|
98
|
+
6: 'method',
|
|
99
|
+
7: 'property',
|
|
100
|
+
8: 'field',
|
|
101
|
+
9: 'constructor',
|
|
102
|
+
10: 'enum',
|
|
103
|
+
11: 'interface',
|
|
104
|
+
12: 'function',
|
|
105
|
+
13: 'variable',
|
|
106
|
+
14: 'constant',
|
|
107
|
+
23: 'struct',
|
|
108
|
+
};
|
|
109
|
+
/** LSP DiagnosticSeverity -> our severity vocabulary. */
|
|
110
|
+
export function lspSeverity(n) {
|
|
111
|
+
if (n === 1)
|
|
112
|
+
return 'error';
|
|
113
|
+
if (n === 2)
|
|
114
|
+
return 'warning';
|
|
115
|
+
return 'info';
|
|
116
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from './core/config.js';
|
|
3
|
+
import { Container } from './core/container.js';
|
|
4
|
+
import { buildRegistry, registerAdapterTools } from './tools/index.js';
|
|
5
|
+
import { createMcpServer } from './server/mcp-server.js';
|
|
6
|
+
import { startStdioTransport } from './server/transports/stdio.js';
|
|
7
|
+
import { startHttpTransport } from './server/transports/http.js';
|
|
8
|
+
import { startDashboard, isLoopbackHost } from './dashboard/server.js';
|
|
9
|
+
import { logger } from './core/logger.js';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
const VERSION = '1.0.0';
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = { stdio: false, http: false, dashboard: true };
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const a = argv[i];
|
|
16
|
+
const next = () => argv[++i];
|
|
17
|
+
switch (a) {
|
|
18
|
+
case '--project':
|
|
19
|
+
case '-p': {
|
|
20
|
+
const v = next();
|
|
21
|
+
if (v !== undefined)
|
|
22
|
+
args.project = v;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
case '--config':
|
|
26
|
+
case '-c': {
|
|
27
|
+
const v = next();
|
|
28
|
+
if (v !== undefined)
|
|
29
|
+
args.config = v;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case '--stdio':
|
|
33
|
+
args.stdio = true;
|
|
34
|
+
break;
|
|
35
|
+
case '--http':
|
|
36
|
+
args.http = true;
|
|
37
|
+
break;
|
|
38
|
+
case '--port':
|
|
39
|
+
args.port = Number(next());
|
|
40
|
+
break;
|
|
41
|
+
case '--host': {
|
|
42
|
+
const v = next();
|
|
43
|
+
if (v !== undefined)
|
|
44
|
+
args.host = v;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case '--dashboard-port':
|
|
48
|
+
args.dashboardPort = Number(next());
|
|
49
|
+
break;
|
|
50
|
+
case '--no-dashboard':
|
|
51
|
+
args.dashboard = false;
|
|
52
|
+
break;
|
|
53
|
+
case '--version':
|
|
54
|
+
case '-v':
|
|
55
|
+
process.stdout.write(`folderforge ${VERSION}\n`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
break;
|
|
58
|
+
case '--help':
|
|
59
|
+
case '-h':
|
|
60
|
+
printHelp();
|
|
61
|
+
process.exit(0);
|
|
62
|
+
break;
|
|
63
|
+
default:
|
|
64
|
+
if (a && a.startsWith('-')) {
|
|
65
|
+
logger.warn({ arg: a }, 'Unknown argument ignored');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return args;
|
|
70
|
+
}
|
|
71
|
+
function printHelp() {
|
|
72
|
+
process.stdout.write([
|
|
73
|
+
'FolderForge - local development control plane for AI coding agents',
|
|
74
|
+
'',
|
|
75
|
+
'Usage: folderforge [options]',
|
|
76
|
+
'',
|
|
77
|
+
'Options:',
|
|
78
|
+
' -p, --project <dir> Project root to activate (default: cwd)',
|
|
79
|
+
' -c, --config <file> Path to a YAML config file',
|
|
80
|
+
' --stdio Serve MCP over stdio (default for agent clients)',
|
|
81
|
+
' --http Serve MCP over Streamable HTTP',
|
|
82
|
+
' --port <n> HTTP MCP port (default 7331)',
|
|
83
|
+
' --host <addr> Bind address (default 127.0.0.1)',
|
|
84
|
+
' --dashboard-port <n> Dashboard port (default 7332)',
|
|
85
|
+
' --no-dashboard Disable the local dashboard',
|
|
86
|
+
' -v, --version Print version and exit',
|
|
87
|
+
' -h, --help Show this help',
|
|
88
|
+
'',
|
|
89
|
+
].join('\n'));
|
|
90
|
+
}
|
|
91
|
+
async function main() {
|
|
92
|
+
const args = parseArgs(process.argv.slice(2));
|
|
93
|
+
const config = loadConfig({
|
|
94
|
+
...(args.config !== undefined ? { configPath: args.config } : {}),
|
|
95
|
+
...(args.project !== undefined ? { projectRoot: args.project } : {}),
|
|
96
|
+
});
|
|
97
|
+
// CLI overrides for transport/ports.
|
|
98
|
+
if (args.http)
|
|
99
|
+
config.server.transport = 'http';
|
|
100
|
+
if (args.stdio)
|
|
101
|
+
config.server.transport = 'stdio';
|
|
102
|
+
if (args.host !== undefined)
|
|
103
|
+
config.server.http.host = args.host;
|
|
104
|
+
if (args.port !== undefined)
|
|
105
|
+
config.server.http.port = args.port;
|
|
106
|
+
if (args.dashboardPort !== undefined)
|
|
107
|
+
config.server.dashboard.port = args.dashboardPort;
|
|
108
|
+
const container = new Container(config);
|
|
109
|
+
const registry = buildRegistry(container);
|
|
110
|
+
// Wire enabled child MCP adapters (Serena, Playwright, ...). Each child tool is
|
|
111
|
+
// exposed namespaced (e.g. serena__find_symbol). Discovery never blocks startup.
|
|
112
|
+
try {
|
|
113
|
+
const added = await registerAdapterTools(container, registry);
|
|
114
|
+
if (added > 0)
|
|
115
|
+
logger.info({ added }, 'Registered child MCP adapter tools');
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
logger.warn({ err: String(err) }, 'Adapter tool registration failed; continuing without adapters');
|
|
119
|
+
}
|
|
120
|
+
const server = createMcpServer(registry, {
|
|
121
|
+
name: config.server.name,
|
|
122
|
+
version: VERSION,
|
|
123
|
+
roots: config.workspace.allowedDirectories,
|
|
124
|
+
});
|
|
125
|
+
container.audit.record({
|
|
126
|
+
type: 'server_start',
|
|
127
|
+
summary: `transport=${config.server.transport} tools=${registry.listAll().length}`,
|
|
128
|
+
detail: { version: VERSION, projectRoot: container.projectRoot() },
|
|
129
|
+
});
|
|
130
|
+
// Dashboard is independent of the MCP transport (and is safe to run alongside stdio).
|
|
131
|
+
if (args.dashboard) {
|
|
132
|
+
const dashHost = config.server.dashboard.host;
|
|
133
|
+
// A non-loopback bind exposes the control plane to the network, so it must be
|
|
134
|
+
// token-protected. Use the configured token or mint one and log it once.
|
|
135
|
+
let token = config.server.dashboard.token;
|
|
136
|
+
if (!isLoopbackHost(dashHost) && !token) {
|
|
137
|
+
token = randomBytes(24).toString('base64url');
|
|
138
|
+
logger.warn({ host: dashHost, token }, 'Dashboard bound to a non-loopback host; generated an auth token (set server.dashboard.token to pin it)');
|
|
139
|
+
}
|
|
140
|
+
startDashboard(container, registry, {
|
|
141
|
+
host: dashHost,
|
|
142
|
+
port: config.server.dashboard.port,
|
|
143
|
+
...(token ? { token } : {}),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (config.server.transport === 'http') {
|
|
147
|
+
const httpHost = config.server.http.host;
|
|
148
|
+
// Mirror the dashboard: a non-loopback bind must be token-protected. Use the
|
|
149
|
+
// configured token or mint one and log it once.
|
|
150
|
+
let httpToken = config.server.http.token;
|
|
151
|
+
if (!isLoopbackHost(httpHost) && !httpToken) {
|
|
152
|
+
httpToken = randomBytes(24).toString('base64url');
|
|
153
|
+
logger.warn({ host: httpHost, token: httpToken }, 'HTTP transport bound to a non-loopback host; generated an auth token (set server.http.token to pin it)');
|
|
154
|
+
}
|
|
155
|
+
await startHttpTransport(server, {
|
|
156
|
+
host: httpHost,
|
|
157
|
+
port: config.server.http.port,
|
|
158
|
+
...(httpToken ? { token: httpToken } : {}),
|
|
159
|
+
...(config.server.http.corsOrigins ? { corsOrigins: config.server.http.corsOrigins } : {}),
|
|
160
|
+
...(config.server.http.sessionTtlMs !== undefined
|
|
161
|
+
? { sessionTtlMs: config.server.http.sessionTtlMs }
|
|
162
|
+
: {}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await startStdioTransport(server);
|
|
167
|
+
}
|
|
168
|
+
const shutdown = async (signal) => {
|
|
169
|
+
logger.info({ signal }, 'Shutting down FolderForge');
|
|
170
|
+
try {
|
|
171
|
+
container.adapters.stopAll();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// ignore
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
await server.close();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// ignore
|
|
181
|
+
}
|
|
182
|
+
process.exit(0);
|
|
183
|
+
};
|
|
184
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
185
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
186
|
+
}
|
|
187
|
+
main().catch((err) => {
|
|
188
|
+
logger.error({ err: err instanceof Error ? err.stack : String(err) }, 'Fatal startup error');
|
|
189
|
+
process.exit(1);
|
|
190
|
+
});
|