@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only database manager. MVP supports SQLite via better-sqlite3 if present
|
|
3
|
+
* and Postgres via 'pg' if present; both are optional peer deps loaded lazily.
|
|
4
|
+
* If a driver is missing, the tools return a clear, actionable error instead of
|
|
5
|
+
* crashing the server.
|
|
6
|
+
*/
|
|
7
|
+
const WRITE_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|REPLACE|MERGE|VACUUM)\b/i;
|
|
8
|
+
export function isReadOnlyQuery(sql) {
|
|
9
|
+
const trimmed = sql.trim().replace(/;+\s*$/, '');
|
|
10
|
+
if (WRITE_KEYWORDS.test(trimmed))
|
|
11
|
+
return false;
|
|
12
|
+
return /^(SELECT|WITH|EXPLAIN|PRAGMA|SHOW)\b/i.test(trimmed);
|
|
13
|
+
}
|
|
14
|
+
const SECRET_COL = /pass(word)?|secret|token|api[_-]?key|private[_-]?key/i;
|
|
15
|
+
export function maskRow(row) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [k, v] of Object.entries(row)) {
|
|
18
|
+
out[k] = SECRET_COL.test(k) ? '[MASKED]' : v;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export class DbManager {
|
|
23
|
+
connections = new Map();
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
handles = new Map();
|
|
26
|
+
list() {
|
|
27
|
+
return [...this.connections.values()];
|
|
28
|
+
}
|
|
29
|
+
require(id) {
|
|
30
|
+
const conn = this.connections.get(id);
|
|
31
|
+
const handle = this.handles.get(id);
|
|
32
|
+
if (!conn || !handle)
|
|
33
|
+
throw new Error(`Unknown db connection: ${id}`);
|
|
34
|
+
return { conn, handle };
|
|
35
|
+
}
|
|
36
|
+
async connect(id, kind, target) {
|
|
37
|
+
if (/prod|production/i.test(target)) {
|
|
38
|
+
throw new Error('Refusing to connect to a production-looking database target.');
|
|
39
|
+
}
|
|
40
|
+
if (kind === 'sqlite') {
|
|
41
|
+
const mod = await this.tryImport('better-sqlite3', 'SQLite');
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
const Database = mod.default ?? mod;
|
|
44
|
+
const db = new Database(target, { readonly: true, fileMustExist: true });
|
|
45
|
+
this.handles.set(id, db);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const mod = await this.tryImport('pg', 'Postgres');
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
const Client = mod.Client;
|
|
51
|
+
const client = new Client({ connectionString: target });
|
|
52
|
+
await client.connect();
|
|
53
|
+
this.handles.set(id, client);
|
|
54
|
+
}
|
|
55
|
+
const conn = { id, kind, target, readonly: true };
|
|
56
|
+
this.connections.set(id, conn);
|
|
57
|
+
return conn;
|
|
58
|
+
}
|
|
59
|
+
async listTables(id) {
|
|
60
|
+
const { conn, handle } = this.require(id);
|
|
61
|
+
if (conn.kind === 'sqlite') {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
const rows = handle
|
|
64
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
65
|
+
.all();
|
|
66
|
+
return rows.map((r) => r.name);
|
|
67
|
+
}
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
const res = await handle.query("SELECT table_name FROM information_schema.tables WHERE table_schema='public' ORDER BY table_name");
|
|
70
|
+
return res.rows.map((r) => r.table_name);
|
|
71
|
+
}
|
|
72
|
+
async describeTable(id, table) {
|
|
73
|
+
const { conn, handle } = this.require(id);
|
|
74
|
+
if (!/^[A-Za-z0-9_]+$/.test(table))
|
|
75
|
+
throw new Error('Invalid table name');
|
|
76
|
+
if (conn.kind === 'sqlite') {
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
return handle.prepare(`PRAGMA table_info(${table})`).all();
|
|
79
|
+
}
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
|
+
const res = await handle.query('SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name=$1 ORDER BY ordinal_position', [table]);
|
|
82
|
+
return res.rows;
|
|
83
|
+
}
|
|
84
|
+
async queryReadonly(id, sql, limit = 200) {
|
|
85
|
+
if (!isReadOnlyQuery(sql))
|
|
86
|
+
throw new Error('Only read-only queries (SELECT/EXPLAIN/WITH) are allowed.');
|
|
87
|
+
const { conn, handle } = this.require(id);
|
|
88
|
+
let rows;
|
|
89
|
+
if (conn.kind === 'sqlite') {
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
rows = handle.prepare(sql).all().slice(0, limit);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
const res = await handle.query(sql);
|
|
96
|
+
rows = res.rows.slice(0, limit);
|
|
97
|
+
}
|
|
98
|
+
return rows.map(maskRow);
|
|
99
|
+
}
|
|
100
|
+
async explain(id, sql) {
|
|
101
|
+
const cleaned = sql.trim().replace(/;+\s*$/, '');
|
|
102
|
+
return this.queryReadonly(id, `EXPLAIN ${cleaned}`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Execute a single write statement (INSERT/UPDATE/DELETE) against a dev
|
|
106
|
+
* connection. Refuses read-only-looking statements and DDL; migrations go
|
|
107
|
+
* through {@link runMigration}. HIGH-risk: gated by policy/approval upstream.
|
|
108
|
+
*/
|
|
109
|
+
async write(id, sql) {
|
|
110
|
+
if (isReadOnlyQuery(sql)) {
|
|
111
|
+
throw new Error('db_write expects a write statement (INSERT/UPDATE/DELETE), not a read-only query.');
|
|
112
|
+
}
|
|
113
|
+
const { conn, handle } = this.require(id);
|
|
114
|
+
if (conn.readonly) {
|
|
115
|
+
throw new Error(`Connection ${id} is read-only; db_write is not permitted.`);
|
|
116
|
+
}
|
|
117
|
+
if (conn.kind === 'sqlite') {
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
const info = handle.prepare(sql).run();
|
|
120
|
+
return { changes: Number(info.changes ?? 0) };
|
|
121
|
+
}
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
123
|
+
const res = await handle.query(sql);
|
|
124
|
+
return { changes: Number(res.rowCount ?? 0) };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Run a multi-statement migration script in a single transaction. HIGH-risk:
|
|
128
|
+
* gated by policy/approval upstream.
|
|
129
|
+
*/
|
|
130
|
+
async runMigration(id, sql) {
|
|
131
|
+
const { conn, handle } = this.require(id);
|
|
132
|
+
if (conn.readonly) {
|
|
133
|
+
throw new Error(`Connection ${id} is read-only; db_run_migration is not permitted.`);
|
|
134
|
+
}
|
|
135
|
+
if (conn.kind === 'sqlite') {
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
137
|
+
handle.exec(sql);
|
|
138
|
+
return { applied: true };
|
|
139
|
+
}
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
141
|
+
const client = handle;
|
|
142
|
+
try {
|
|
143
|
+
await client.query('BEGIN');
|
|
144
|
+
await client.query(sql);
|
|
145
|
+
await client.query('COMMIT');
|
|
146
|
+
return { applied: true };
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async tryImport(mod, label) {
|
|
154
|
+
try {
|
|
155
|
+
return await import(mod);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new Error(`${label} driver "${mod}" is not installed. Run: npm install ${mod} (optional dependency).`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join, isAbsolute } from 'node:path';
|
|
5
|
+
import { MessageBuffer, encodeMessage, isResponse, SYMBOL_KIND, lspSeverity, } from '../lsp/protocol.js';
|
|
6
|
+
/** Built-in language server definitions. Overridable via config. */
|
|
7
|
+
export const DEFAULT_LANGUAGE_SERVERS = [
|
|
8
|
+
{
|
|
9
|
+
id: 'typescript',
|
|
10
|
+
command: 'typescript-language-server',
|
|
11
|
+
args: ['--stdio'],
|
|
12
|
+
extensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'],
|
|
13
|
+
languageId: 'typescript',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: 'python',
|
|
17
|
+
command: 'pyright-langserver',
|
|
18
|
+
args: ['--stdio'],
|
|
19
|
+
extensions: ['py', 'pyi'],
|
|
20
|
+
languageId: 'python',
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
export const DEFAULT_LSP_CONFIG = {
|
|
24
|
+
enabled: true,
|
|
25
|
+
requestTimeoutMs: 15_000,
|
|
26
|
+
};
|
|
27
|
+
/** A live, initialized language-server connection. */
|
|
28
|
+
class LspConnection {
|
|
29
|
+
def;
|
|
30
|
+
root;
|
|
31
|
+
requestTimeoutMs;
|
|
32
|
+
child;
|
|
33
|
+
nextId = 1;
|
|
34
|
+
pending = new Map();
|
|
35
|
+
buffer;
|
|
36
|
+
opened = new Set();
|
|
37
|
+
/** Latest published diagnostics per file URI. */
|
|
38
|
+
diagnostics = new Map();
|
|
39
|
+
exited = false;
|
|
40
|
+
constructor(def, root, requestTimeoutMs) {
|
|
41
|
+
this.def = def;
|
|
42
|
+
this.root = root;
|
|
43
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
44
|
+
this.child = spawn(def.command, def.args, { cwd: root });
|
|
45
|
+
this.buffer = new MessageBuffer((msg) => this.onMessage(msg));
|
|
46
|
+
this.child.stdout.on('data', (c) => this.buffer.append(c));
|
|
47
|
+
this.child.stderr.on('data', () => { }); // language servers log noisily; ignore
|
|
48
|
+
this.child.on('exit', () => {
|
|
49
|
+
this.exited = true;
|
|
50
|
+
for (const [, p] of this.pending) {
|
|
51
|
+
clearTimeout(p.timer);
|
|
52
|
+
p.reject(new Error(`${this.def.id} language server exited`));
|
|
53
|
+
}
|
|
54
|
+
this.pending.clear();
|
|
55
|
+
});
|
|
56
|
+
this.child.on('error', () => {
|
|
57
|
+
this.exited = true;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
onMessage(msg) {
|
|
61
|
+
if (isResponse(msg)) {
|
|
62
|
+
const p = this.pending.get(msg.id);
|
|
63
|
+
if (!p)
|
|
64
|
+
return;
|
|
65
|
+
this.pending.delete(msg.id);
|
|
66
|
+
clearTimeout(p.timer);
|
|
67
|
+
if (msg.error)
|
|
68
|
+
p.reject(new Error(msg.error.message));
|
|
69
|
+
else
|
|
70
|
+
p.resolve(msg.result);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Notification from the server.
|
|
74
|
+
if ('method' in msg && msg.method === 'textDocument/publishDiagnostics') {
|
|
75
|
+
const params = msg.params;
|
|
76
|
+
if (params?.uri)
|
|
77
|
+
this.diagnostics.set(params.uri, params.diagnostics ?? []);
|
|
78
|
+
}
|
|
79
|
+
// Server->client requests (e.g. workspace/configuration) are answered with
|
|
80
|
+
// a null result so the server doesn't stall waiting on us.
|
|
81
|
+
if ('method' in msg && 'id' in msg) {
|
|
82
|
+
this.send({ jsonrpc: '2.0', id: msg.id, result: null });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
send(msg) {
|
|
86
|
+
if (this.exited)
|
|
87
|
+
return;
|
|
88
|
+
this.child.stdin.write(encodeMessage(msg));
|
|
89
|
+
}
|
|
90
|
+
request(method, params) {
|
|
91
|
+
if (this.exited)
|
|
92
|
+
return Promise.reject(new Error(`${this.def.id} server not running`));
|
|
93
|
+
const id = this.nextId++;
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
this.pending.delete(id);
|
|
97
|
+
reject(new Error(`LSP request ${method} timed out after ${this.requestTimeoutMs}ms`));
|
|
98
|
+
}, this.requestTimeoutMs);
|
|
99
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
100
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
notify(method, params) {
|
|
104
|
+
this.send({ jsonrpc: '2.0', method, params });
|
|
105
|
+
}
|
|
106
|
+
async initialize() {
|
|
107
|
+
await this.request('initialize', {
|
|
108
|
+
processId: process.pid,
|
|
109
|
+
rootUri: pathToFileURL(this.root).href,
|
|
110
|
+
capabilities: {
|
|
111
|
+
textDocument: {
|
|
112
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
113
|
+
definition: {},
|
|
114
|
+
references: {},
|
|
115
|
+
rename: {},
|
|
116
|
+
publishDiagnostics: {},
|
|
117
|
+
},
|
|
118
|
+
workspace: { symbol: {}, workspaceFolders: true },
|
|
119
|
+
},
|
|
120
|
+
workspaceFolders: [{ uri: pathToFileURL(this.root).href, name: 'root' }],
|
|
121
|
+
});
|
|
122
|
+
this.notify('initialized', {});
|
|
123
|
+
}
|
|
124
|
+
/** Ensure a file is open on the server (didOpen once per URI). */
|
|
125
|
+
ensureOpen(relativePath) {
|
|
126
|
+
const abs = isAbsolute(relativePath) ? relativePath : join(this.root, relativePath);
|
|
127
|
+
const uri = pathToFileURL(abs).href;
|
|
128
|
+
if (this.opened.has(uri))
|
|
129
|
+
return uri;
|
|
130
|
+
let text = '';
|
|
131
|
+
try {
|
|
132
|
+
text = readFileSync(abs, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
text = '';
|
|
136
|
+
}
|
|
137
|
+
this.notify('textDocument/didOpen', {
|
|
138
|
+
textDocument: { uri, languageId: this.def.languageId, version: 1, text },
|
|
139
|
+
});
|
|
140
|
+
this.opened.add(uri);
|
|
141
|
+
return uri;
|
|
142
|
+
}
|
|
143
|
+
dispose() {
|
|
144
|
+
try {
|
|
145
|
+
this.send({ jsonrpc: '2.0', id: this.nextId++, method: 'shutdown', params: null });
|
|
146
|
+
this.notify('exit', null);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* ignore */
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
this.child.kill('SIGTERM');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
this.exited = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Manages native LSP connections, one per (language, projectRoot). Lazily
|
|
162
|
+
* spawns and initializes a server on first use, reusing it afterwards. When the
|
|
163
|
+
* configured binary is not installed, callers get a clear "unavailable" signal
|
|
164
|
+
* and degrade gracefully (Serena adapter / regex search).
|
|
165
|
+
*/
|
|
166
|
+
export class LspManager {
|
|
167
|
+
config;
|
|
168
|
+
connections = new Map();
|
|
169
|
+
servers;
|
|
170
|
+
constructor(config = DEFAULT_LSP_CONFIG) {
|
|
171
|
+
this.config = config;
|
|
172
|
+
this.config = config ?? DEFAULT_LSP_CONFIG;
|
|
173
|
+
this.servers = this.config.servers ?? DEFAULT_LANGUAGE_SERVERS;
|
|
174
|
+
}
|
|
175
|
+
/** Pick the server definition that handles a given file path, if any. */
|
|
176
|
+
serverForPath(relativePath) {
|
|
177
|
+
const ext = relativePath.split('.').pop()?.toLowerCase() ?? '';
|
|
178
|
+
return this.servers.find((s) => s.extensions.includes(ext));
|
|
179
|
+
}
|
|
180
|
+
serverById(id) {
|
|
181
|
+
return this.servers.find((s) => s.id === id);
|
|
182
|
+
}
|
|
183
|
+
isEnabled() {
|
|
184
|
+
return this.config.enabled;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Get (or lazily create + initialize) a connection for a language at a root.
|
|
188
|
+
* Returns null if LSP is disabled or the binary cannot be spawned.
|
|
189
|
+
*/
|
|
190
|
+
async ensure(def, root) {
|
|
191
|
+
if (!this.config.enabled)
|
|
192
|
+
return null;
|
|
193
|
+
const key = `${def.id}::${root}`;
|
|
194
|
+
const existing = this.connections.get(key);
|
|
195
|
+
if (existing)
|
|
196
|
+
return existing;
|
|
197
|
+
try {
|
|
198
|
+
const conn = new LspConnection(def, root, this.config.requestTimeoutMs);
|
|
199
|
+
await conn.initialize();
|
|
200
|
+
this.connections.set(key, conn);
|
|
201
|
+
return conn;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null; // binary missing or init failed -> caller falls back
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
disposeAll() {
|
|
208
|
+
for (const [, c] of this.connections)
|
|
209
|
+
c.dispose();
|
|
210
|
+
this.connections.clear();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Normalize an LSP Location into a {file, line, column} record (1-based line). */
|
|
214
|
+
export function normalizeLocation(loc) {
|
|
215
|
+
const l = loc;
|
|
216
|
+
if (!l?.uri || !l.range?.start)
|
|
217
|
+
return null;
|
|
218
|
+
let file = l.uri;
|
|
219
|
+
try {
|
|
220
|
+
file = fileURLToPath(l.uri);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* keep raw uri */
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
file,
|
|
227
|
+
line: (l.range.start.line ?? 0) + 1,
|
|
228
|
+
column: (l.range.start.character ?? 0) + 1,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Flatten a documentSymbol tree (hierarchical or flat) into named entries. */
|
|
232
|
+
export function flattenSymbols(symbols) {
|
|
233
|
+
const out = [];
|
|
234
|
+
const visit = (nodes) => {
|
|
235
|
+
for (const n of nodes) {
|
|
236
|
+
const s = n;
|
|
237
|
+
if (s?.name) {
|
|
238
|
+
const line = (s.range?.start?.line ?? s.location?.range?.start?.line ?? 0) + 1;
|
|
239
|
+
out.push({ name: s.name, kind: SYMBOL_KIND[s.kind ?? 0] ?? 'symbol', line });
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(s?.children))
|
|
242
|
+
visit(s.children);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
if (Array.isArray(symbols))
|
|
246
|
+
visit(symbols);
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/** Convert LSP diagnostics for a file into the shared error-item shape. */
|
|
250
|
+
export function normalizeDiagnostics(uri, diags) {
|
|
251
|
+
let file = uri;
|
|
252
|
+
try {
|
|
253
|
+
file = fileURLToPath(uri);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* keep raw uri */
|
|
257
|
+
}
|
|
258
|
+
return diags.map((d) => {
|
|
259
|
+
const x = d;
|
|
260
|
+
return {
|
|
261
|
+
file,
|
|
262
|
+
line: (x.range?.start?.line ?? 0) + 1,
|
|
263
|
+
column: (x.range?.start?.character ?? 0) + 1,
|
|
264
|
+
severity: lspSeverity(x.severity),
|
|
265
|
+
message: x.message ?? '',
|
|
266
|
+
...(x.code !== undefined ? { code: String(x.code) } : {}),
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
/**
|
|
4
|
+
* Manages long-running child processes (dev servers, watchers, compose).
|
|
5
|
+
*/
|
|
6
|
+
export class ProcessManager {
|
|
7
|
+
sessions = new Map();
|
|
8
|
+
maxBuffer = 1_000_000;
|
|
9
|
+
start(command, cwd, shell) {
|
|
10
|
+
const sessionId = `proc_${randomUUID().slice(0, 8)}`;
|
|
11
|
+
const child = spawn(shell, ['-lc', command], {
|
|
12
|
+
cwd,
|
|
13
|
+
env: process.env,
|
|
14
|
+
});
|
|
15
|
+
const session = {
|
|
16
|
+
sessionId,
|
|
17
|
+
pid: child.pid,
|
|
18
|
+
command,
|
|
19
|
+
cwd,
|
|
20
|
+
status: 'running',
|
|
21
|
+
exitCode: null,
|
|
22
|
+
startedAt: Date.now(),
|
|
23
|
+
child,
|
|
24
|
+
output: '',
|
|
25
|
+
cursor: 0,
|
|
26
|
+
waiters: [],
|
|
27
|
+
};
|
|
28
|
+
const wake = () => {
|
|
29
|
+
const ws = session.waiters;
|
|
30
|
+
session.waiters = [];
|
|
31
|
+
for (const w of ws)
|
|
32
|
+
w();
|
|
33
|
+
};
|
|
34
|
+
const append = (chunk) => {
|
|
35
|
+
session.output += chunk.toString('utf8');
|
|
36
|
+
if (session.output.length > this.maxBuffer) {
|
|
37
|
+
session.output = session.output.slice(-this.maxBuffer);
|
|
38
|
+
}
|
|
39
|
+
wake();
|
|
40
|
+
};
|
|
41
|
+
child.stdout.on('data', append);
|
|
42
|
+
child.stderr.on('data', append);
|
|
43
|
+
child.on('exit', (code) => {
|
|
44
|
+
session.status = session.status === 'killed' ? 'killed' : 'exited';
|
|
45
|
+
session.exitCode = code;
|
|
46
|
+
wake();
|
|
47
|
+
});
|
|
48
|
+
this.sessions.set(sessionId, session);
|
|
49
|
+
return this.publicView(session);
|
|
50
|
+
}
|
|
51
|
+
read(sessionId) {
|
|
52
|
+
const s = this.require(sessionId);
|
|
53
|
+
const out = s.output.slice(s.cursor);
|
|
54
|
+
s.cursor = s.output.length;
|
|
55
|
+
return { output: out, status: s.status, cursor: s.cursor };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Long-poll read: resolve as soon as new output is available or the process
|
|
59
|
+
* exits, or after `timeoutMs` with whatever (possibly empty) output arrived.
|
|
60
|
+
* This backs streaming tails without busy-waiting. `done` is true once the
|
|
61
|
+
* process has exited and all buffered output has been drained.
|
|
62
|
+
*/
|
|
63
|
+
readUntil(sessionId, timeoutMs = 2000, signal) {
|
|
64
|
+
const s = this.require(sessionId);
|
|
65
|
+
const drain = () => {
|
|
66
|
+
const output = s.output.slice(s.cursor);
|
|
67
|
+
s.cursor = s.output.length;
|
|
68
|
+
const done = s.status !== 'running' && s.cursor >= s.output.length;
|
|
69
|
+
return { output, status: s.status, cursor: s.cursor, done };
|
|
70
|
+
};
|
|
71
|
+
// Immediate return if there is already new output, the process is finished,
|
|
72
|
+
// or the caller has already cancelled (P6).
|
|
73
|
+
if (s.output.length > s.cursor || s.status !== 'running' || signal?.aborted) {
|
|
74
|
+
return Promise.resolve(drain());
|
|
75
|
+
}
|
|
76
|
+
return new Promise((resolveOut) => {
|
|
77
|
+
let settled = false;
|
|
78
|
+
const finish = () => {
|
|
79
|
+
if (settled)
|
|
80
|
+
return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
if (signal)
|
|
84
|
+
signal.removeEventListener('abort', finish);
|
|
85
|
+
resolveOut(drain());
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
88
|
+
// Wake immediately on cancellation so a long tail does not block the
|
|
89
|
+
// client after it has cancelled the request.
|
|
90
|
+
if (signal)
|
|
91
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
92
|
+
s.waiters.push(finish);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
write(sessionId, input) {
|
|
96
|
+
const s = this.require(sessionId);
|
|
97
|
+
if (s.status !== 'running')
|
|
98
|
+
throw new Error('Process is not running');
|
|
99
|
+
s.child.stdin.write(input.endsWith('\n') ? input : input + '\n');
|
|
100
|
+
}
|
|
101
|
+
stop(sessionId) {
|
|
102
|
+
const s = this.require(sessionId);
|
|
103
|
+
if (s.status === 'running') {
|
|
104
|
+
s.child.kill('SIGTERM');
|
|
105
|
+
s.status = 'killed';
|
|
106
|
+
}
|
|
107
|
+
return this.publicView(s);
|
|
108
|
+
}
|
|
109
|
+
kill(sessionId) {
|
|
110
|
+
const s = this.require(sessionId);
|
|
111
|
+
if (s.status === 'running') {
|
|
112
|
+
s.child.kill('SIGKILL');
|
|
113
|
+
s.status = 'killed';
|
|
114
|
+
}
|
|
115
|
+
return this.publicView(s);
|
|
116
|
+
}
|
|
117
|
+
list() {
|
|
118
|
+
return [...this.sessions.values()].map((s) => this.publicView(s));
|
|
119
|
+
}
|
|
120
|
+
isManaged(sessionId) {
|
|
121
|
+
return this.sessions.has(sessionId);
|
|
122
|
+
}
|
|
123
|
+
require(sessionId) {
|
|
124
|
+
const s = this.sessions.get(sessionId);
|
|
125
|
+
if (!s)
|
|
126
|
+
throw new Error(`Unknown process session: ${sessionId}`);
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
publicView(s) {
|
|
130
|
+
return {
|
|
131
|
+
sessionId: s.sessionId,
|
|
132
|
+
pid: s.pid,
|
|
133
|
+
command: s.command,
|
|
134
|
+
cwd: s.cwd,
|
|
135
|
+
status: s.status,
|
|
136
|
+
exitCode: s.exitCode,
|
|
137
|
+
startedAt: s.startedAt,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|