@anhlp/alp-client 0.0.0-stage → 0.10.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 +8 -2
- package/dist/acp-probe.d.ts +44 -0
- package/dist/acp-probe.js +37 -0
- package/dist/antigravity-probe.d.ts +34 -0
- package/dist/antigravity-probe.js +95 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.js +32 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.js +386 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +391 -0
- package/dist/library-test.d.ts +94 -0
- package/dist/library-test.js +37 -0
- package/dist/mcp-probe.d.ts +24 -0
- package/dist/mcp-probe.js +144 -0
- package/dist/prompt.d.ts +32 -0
- package/dist/prompt.js +59 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +120 -0
- package/dist/service.d.ts +50 -0
- package/dist/service.js +143 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +146 -0
- package/dist/supervise.d.ts +12 -0
- package/dist/supervise.js +81 -0
- package/package.json +135 -4
- package/src/acp-probe.js +46 -0
- package/src/antigravity-probe.js +106 -0
- package/src/command.js +16 -0
- package/src/doctor.d.ts +13 -0
- package/src/doctor.js +375 -0
- package/src/index.js +408 -0
- package/src/library-test.js +41 -0
- package/src/mcp-probe.js +115 -0
- package/src/prompt.js +64 -0
- package/src/render.js +136 -0
- package/src/service.js +154 -0
- package/src/setup.js +150 -0
- package/src/supervise.d.ts +12 -0
- package/src/supervise.js +85 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, encodeFrame } from '@anhlp/alp-protocol';
|
|
9
|
+
export { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, encodeFrame } from '@anhlp/alp-protocol';
|
|
10
|
+
|
|
11
|
+
export function alpHome(env = process.env) {
|
|
12
|
+
return path.resolve(env.ALP_HOME || path.join(os.homedir(), '.alp'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Windows uses named pipes; long Unix homes get a short socket in the temp directory. */
|
|
16
|
+
export function daemonPaths(home) {
|
|
17
|
+
const preferred = path.join(home, 'alpd.sock');
|
|
18
|
+
const socket = process.platform === 'win32'
|
|
19
|
+
? `\\\\.\\pipe\\alpd-${createHash('sha256').update(home).digest('hex').slice(0, 24)}`
|
|
20
|
+
: Buffer.byteLength(preferred) < 100
|
|
21
|
+
? preferred
|
|
22
|
+
: path.join(os.tmpdir(), `alpd-${createHash('sha256').update(home).digest('hex').slice(0, 12)}.sock`);
|
|
23
|
+
return { home, lock: path.join(home, 'alpd.lock'), socket, log: path.join(home, 'logs', 'alpd.log'), install: path.join(home, 'alpd.json') };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isFile = file => stat(file).then(info => info.isFile(), () => false);
|
|
27
|
+
|
|
28
|
+
/** Global bin directories that a GUI app's PATH may lack. */
|
|
29
|
+
const BIN_DIRECTORIES = [path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.local', 'bin'), '/opt/homebrew/bin', '/usr/local/bin'];
|
|
30
|
+
|
|
31
|
+
/** The npm package that ships the `alp` command and alpd. */
|
|
32
|
+
const CLI_PACKAGES = ['@anhlp/alp', '@anhlp/alp-cli'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Finds alpd.js for a client that cannot locate it beside itself, such as the Paseo
|
|
36
|
+
* plugin, which Paseo re-bundles: the first existing candidate, then where alpd last
|
|
37
|
+
* recorded itself ($ALP_HOME/alpd.json), then the ALP CLI (`alp`) on PATH.
|
|
38
|
+
*/
|
|
39
|
+
export async function findDaemonEntry({ home = alpHome(), env = process.env, candidates = /** @type {Array<string | undefined>} */ ([]) } = {}) {
|
|
40
|
+
for (const candidate of candidates) if (candidate && await isFile(candidate)) return candidate;
|
|
41
|
+
try {
|
|
42
|
+
const { entry } = JSON.parse(await readFile(daemonPaths(home).install, 'utf8'));
|
|
43
|
+
if (typeof entry === 'string' && await isFile(entry)) return entry;
|
|
44
|
+
} catch {}
|
|
45
|
+
for (const directory of [...(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...BIN_DIRECTORIES]) {
|
|
46
|
+
const cli = await realpath(path.join(directory, 'alp')).catch(() => undefined);
|
|
47
|
+
if (!cli || path.basename(cli) !== 'cli.js') continue;
|
|
48
|
+
const root = path.resolve(path.dirname(cli), '..');
|
|
49
|
+
const name = await readFile(path.join(root, 'package.json'), 'utf8').then(text => JSON.parse(text).name, () => undefined);
|
|
50
|
+
const entry = path.join(root, 'dist', 'alpd.js');
|
|
51
|
+
if (CLI_PACKAGES.includes(name) && await isFile(entry)) return entry;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Wall-clock boot time; a lock from an earlier boot is stale even if its pid is reused. */
|
|
57
|
+
export function bootTime() {
|
|
58
|
+
return Math.round((Date.now() - os.uptime() * 1000) / 1000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readLock(home) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(await readFile(daemonPaths(home).lock, 'utf8'));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function lockAlive(lock) {
|
|
71
|
+
if (!lock || !Number.isSafeInteger(lock.pid) || Math.abs((lock.bootTime ?? 0) - bootTime()) > 30) return false;
|
|
72
|
+
try {
|
|
73
|
+
process.kill(lock.pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return error?.code === 'EPERM';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One JSON-RPC 2.0 connection to alpd; events arrive as `event` notifications. */
|
|
81
|
+
export class AlpClient {
|
|
82
|
+
#socket;
|
|
83
|
+
#sequence = 0;
|
|
84
|
+
#pending = new Map();
|
|
85
|
+
#events = new Set();
|
|
86
|
+
#closes = new Set();
|
|
87
|
+
#requestHandlers = new Set();
|
|
88
|
+
#closed = false;
|
|
89
|
+
|
|
90
|
+
constructor(socket) {
|
|
91
|
+
this.#socket = socket;
|
|
92
|
+
let buffer = '';
|
|
93
|
+
socket.setEncoding('utf8');
|
|
94
|
+
socket.on('data', chunk => {
|
|
95
|
+
buffer += chunk;
|
|
96
|
+
if (buffer.length > MAX_FRAME) { socket.destroy(new Error('ALP daemon frame exceeds size limit')); return; }
|
|
97
|
+
for (let index = buffer.indexOf('\n'); index >= 0; index = buffer.indexOf('\n')) {
|
|
98
|
+
const line = buffer.slice(0, index);
|
|
99
|
+
buffer = buffer.slice(index + 1);
|
|
100
|
+
if (line.trim()) this.#receive(line);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
socket.on('close', () => this.#finish(new Error('ALP daemon connection closed')));
|
|
104
|
+
socket.on('error', error => this.#finish(error));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#receive(line) {
|
|
108
|
+
let message;
|
|
109
|
+
try { message = JSON.parse(line); } catch { this.#socket.destroy(new Error('Invalid frame from ALP daemon')); return; }
|
|
110
|
+
if (message.method === 'event') {
|
|
111
|
+
for (const listener of this.#events) listener(message.params);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (message.method && (message.id !== undefined && message.id !== null) && !Object.prototype.hasOwnProperty.call(message, 'result') && !Object.prototype.hasOwnProperty.call(message, 'error')) {
|
|
115
|
+
const handler = [...this.#requestHandlers][0];
|
|
116
|
+
if (handler) {
|
|
117
|
+
Promise.resolve().then(async () => {
|
|
118
|
+
try {
|
|
119
|
+
const result = await handler(message.method, message.params ?? {});
|
|
120
|
+
this.#socket.write(encodeFrame({ jsonrpc: '2.0', id: message.id, result: result === undefined ? {} : result }));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const code = (error && typeof error.code === 'number') ? error.code : 1000;
|
|
123
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
124
|
+
const data = (error && error.data !== undefined) ? { data: error.data } : {};
|
|
125
|
+
this.#socket.write(encodeFrame({ jsonrpc: '2.0', id: message.id, error: { code, message: msg.length > 2000 ? msg.slice(0,2000)+'...' : msg, ...data } }));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#socket.write(encodeFrame({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Unknown method '${message.method}'` } }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const waiter = this.#pending.get(message.id);
|
|
134
|
+
if (!waiter) return;
|
|
135
|
+
this.#pending.delete(message.id);
|
|
136
|
+
if (message.error) waiter.reject(new AlpRpcError(message.error));
|
|
137
|
+
else waiter.resolve(message.result);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#finish(error) {
|
|
141
|
+
if (this.#closed) return;
|
|
142
|
+
this.#closed = true;
|
|
143
|
+
for (const waiter of this.#pending.values()) waiter.reject(error);
|
|
144
|
+
this.#pending.clear();
|
|
145
|
+
for (const listener of this.#closes) listener(error);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get closed() { return this.#closed; }
|
|
149
|
+
|
|
150
|
+
request(method, params = {}) {
|
|
151
|
+
if (this.#closed) return Promise.reject(new Error('ALP daemon connection closed'));
|
|
152
|
+
const id = ++this.#sequence;
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
this.#pending.set(id, { resolve, reject });
|
|
155
|
+
this.#socket.write(encodeFrame({ jsonrpc: '2.0', id, method, params }));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
onEvent(listener) {
|
|
160
|
+
this.#events.add(listener);
|
|
161
|
+
return () => this.#events.delete(listener);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onRequest(handler) {
|
|
165
|
+
this.#requestHandlers.add(handler);
|
|
166
|
+
return () => this.#requestHandlers.delete(handler);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
onClose(listener) {
|
|
170
|
+
this.#closes.add(listener);
|
|
171
|
+
return () => this.#closes.delete(listener);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
close() {
|
|
175
|
+
this.#socket.end();
|
|
176
|
+
this.#finish(new Error('ALP daemon connection closed'));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Connects and performs the version handshake. */
|
|
181
|
+
export async function connect(socketPath, client = { name: 'alp', version: '0' }) {
|
|
182
|
+
const socket = await new Promise((resolve, reject) => {
|
|
183
|
+
const candidate = net.createConnection(socketPath);
|
|
184
|
+
candidate.once('connect', () => { candidate.off('error', reject); resolve(candidate); });
|
|
185
|
+
candidate.once('error', reject);
|
|
186
|
+
});
|
|
187
|
+
const connection = new AlpClient(socket);
|
|
188
|
+
try {
|
|
189
|
+
await connection.request('daemon.hello', { protocolVersion: PROTOCOL_VERSION, client });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
connection.close();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
return connection;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function tail(file, lines = 30) {
|
|
198
|
+
try { return (await readFile(file, 'utf8')).split('\n').slice(-lines).join('\n'); } catch { return ''; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns the socket of a ready daemon, starting one when none runs.
|
|
203
|
+
* `entry` is the daemon script; it is started through its own detaching launcher so the
|
|
204
|
+
* daemon outlives the client that started it.
|
|
205
|
+
*/
|
|
206
|
+
export async function ensureDaemon({ home = alpHome(), entry = /** @type {string | undefined} */ (undefined), env = process.env, execPath = process.execPath, timeoutMs = 15_000 } = {}) {
|
|
207
|
+
const ready = async () => {
|
|
208
|
+
const lock = await readLock(home);
|
|
209
|
+
return lockAlive(lock) && lock.ready ? lock.socket : undefined;
|
|
210
|
+
};
|
|
211
|
+
const live = await ready();
|
|
212
|
+
if (live) return live;
|
|
213
|
+
if (!lockAlive(await readLock(home))) {
|
|
214
|
+
entry ??= await findDaemonEntry({ home, env });
|
|
215
|
+
if (!entry) throw new Error('ALP daemon is not running and alpd.js was not found. Install the ALP CLI and run `alp daemon start`, or set ALP_DAEMON_ENTRY to alpd.js');
|
|
216
|
+
await new Promise((resolve, reject) => {
|
|
217
|
+
const launcher = spawn(execPath, [entry, '--detach'], {
|
|
218
|
+
env: { ...env, ALP_HOME: home, ELECTRON_RUN_AS_NODE: '1' },
|
|
219
|
+
stdio: 'ignore',
|
|
220
|
+
windowsHide: true,
|
|
221
|
+
});
|
|
222
|
+
launcher.once('error', reject);
|
|
223
|
+
launcher.once('exit', code => code === 0 ? resolve() : reject(new Error(`ALP daemon launcher exited (${code})`)));
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const deadline = Date.now() + timeoutMs;
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
const socket = await ready();
|
|
229
|
+
if (socket) return socket;
|
|
230
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
231
|
+
}
|
|
232
|
+
const log = await tail(daemonPaths(home).log);
|
|
233
|
+
throw new Error(`ALP daemon did not become ready within ${timeoutMs} ms${log ? `\n${log}` : ''}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Registration descriptor field names, in priority order. */
|
|
237
|
+
const INTEGRATION_DESCRIPTOR_FIELDS = ['main', 'entry', 'path', 'command'];
|
|
238
|
+
|
|
239
|
+
async function readIntegrationDescriptor(file, baseDirs = []) {
|
|
240
|
+
try {
|
|
241
|
+
const raw = JSON.parse(await readFile(file, 'utf8'));
|
|
242
|
+
for (const field of INTEGRATION_DESCRIPTOR_FIELDS) {
|
|
243
|
+
const value = raw?.[field];
|
|
244
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
245
|
+
if (path.isAbsolute(value)) {
|
|
246
|
+
if (await isFile(value)) return value;
|
|
247
|
+
} else {
|
|
248
|
+
const bases = [path.dirname(file), ...baseDirs];
|
|
249
|
+
for (const base of bases) {
|
|
250
|
+
const resolved = path.resolve(base, value);
|
|
251
|
+
if (await isFile(resolved)) return resolved;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function integrationCandidateFromHome(home, name) {
|
|
260
|
+
const descriptors = [
|
|
261
|
+
path.join(home, 'integrations', `${name}.json`),
|
|
262
|
+
path.join(home, 'integrations', name, 'integration.json'),
|
|
263
|
+
];
|
|
264
|
+
for (const descriptor of descriptors) {
|
|
265
|
+
const entry = await readIntegrationDescriptor(descriptor, [home]);
|
|
266
|
+
if (entry) return entry;
|
|
267
|
+
}
|
|
268
|
+
// Legacy single-file registration.
|
|
269
|
+
const legacy = path.join(home, 'integrations', name, 'main.js');
|
|
270
|
+
return (await isFile(legacy)) ? legacy : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function integrationBinaryName(name) {
|
|
274
|
+
return name === 'web' ? 'alp-web-integration' : `alp-${name}-integration`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Distribution roots that may carry a bundled integration descriptor. */
|
|
278
|
+
async function distributionRoots() {
|
|
279
|
+
const roots = [];
|
|
280
|
+
const argv = process.argv[1] ? await realpath(process.argv[1]).catch(() => process.argv[1]) : undefined;
|
|
281
|
+
if (argv) {
|
|
282
|
+
// The CLI runs from <distribution>/packages/cli/dist, so the distribution root is a few levels
|
|
283
|
+
// up. Resolve the real path first so a symlinked global bin still finds its own root.
|
|
284
|
+
let directory = path.dirname(argv);
|
|
285
|
+
for (let depth = 0; depth < 4; depth += 1) { roots.push(path.resolve(directory, '..')); directory = path.resolve(directory, '..'); }
|
|
286
|
+
// Every package ancestor may carry a bundled descriptor (a full distribution, or the plugin
|
|
287
|
+
// package installed next to it).
|
|
288
|
+
for (let ancestor = path.dirname(argv); ; ancestor = path.dirname(ancestor)) {
|
|
289
|
+
if (await isFile(path.join(ancestor, 'package.json'))) roots.push(ancestor);
|
|
290
|
+
if (path.dirname(ancestor) === ancestor) break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Also consider the current working directory for full distributions.
|
|
294
|
+
roots.push(process.cwd());
|
|
295
|
+
return [...new Set(roots)];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function discoverIntegration(name = 'web', options = {}) {
|
|
299
|
+
// Explicit environment override always wins.
|
|
300
|
+
const envCandidates = [
|
|
301
|
+
process.env.ALP_WEB_INTEGRATION,
|
|
302
|
+
process.env.ALP_INTEGRATION_ENTRY,
|
|
303
|
+
name !== 'web' ? process.env[`ALP_${name.toUpperCase()}_INTEGRATION`] : undefined,
|
|
304
|
+
].filter(Boolean);
|
|
305
|
+
for (const candidate of envCandidates) if (await isFile(candidate)) return candidate;
|
|
306
|
+
|
|
307
|
+
const home = options?.home ? path.resolve(options.home) : alpHome();
|
|
308
|
+
const fromHome = await integrationCandidateFromHome(home, name);
|
|
309
|
+
if (fromHome) return fromHome;
|
|
310
|
+
|
|
311
|
+
// A full distribution may describe a bundled integration relative to its own root.
|
|
312
|
+
for (const root of await distributionRoots()) {
|
|
313
|
+
for (const descriptorPath of [path.join(root, 'integrations', `${name}.json`), path.join(root, 'integrations', name, 'integration.json')]) {
|
|
314
|
+
const entry = await readIntegrationDescriptor(descriptorPath, [root]);
|
|
315
|
+
if (entry) return entry;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Installed integration packages announce themselves on PATH.
|
|
320
|
+
const binary = integrationBinaryName(name);
|
|
321
|
+
for (const directory of [...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean), ...BIN_DIRECTORIES]) {
|
|
322
|
+
const candidate = await realpath(path.join(directory, binary)).catch(() => undefined);
|
|
323
|
+
if (candidate && await isFile(candidate)) return candidate;
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function integrationAlive(lock) {
|
|
329
|
+
if (!lock || !Number.isSafeInteger(lock.pid)) return false;
|
|
330
|
+
try { process.kill(lock.pid, 0); } catch (error) {
|
|
331
|
+
return error?.code === 'EPERM';
|
|
332
|
+
}
|
|
333
|
+
if (typeof lock.bootTime === 'number' && Math.abs(lock.bootTime - bootTime()) > 30) return false;
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function startIntegration(nameOrOptions = 'web') {
|
|
338
|
+
const options = typeof nameOrOptions === 'string' ? { name: nameOrOptions } : (nameOrOptions ?? {});
|
|
339
|
+
const name = options.name ?? 'web';
|
|
340
|
+
const fs = await import('node:fs/promises');
|
|
341
|
+
const pathMod = await import('node:path');
|
|
342
|
+
const home = options.home ? pathMod.resolve(options.home) : alpHome();
|
|
343
|
+
const lockPath = pathMod.join(home, 'integrations', `${name}.lock`);
|
|
344
|
+
const webPath = pathMod.join(home, `${name}.json`);
|
|
345
|
+
|
|
346
|
+
// Clear, actionable diagnostics for a disabled optional integration.
|
|
347
|
+
let settings = {};
|
|
348
|
+
try { settings = JSON.parse(await fs.readFile(pathMod.join(home, 'settings.json'), 'utf8')); } catch {}
|
|
349
|
+
if (settings?.web?.enabled === false) {
|
|
350
|
+
throw new Error('ALP web app is disabled by settings ("web.enabled": false).');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
354
|
+
const readLock = async () => {
|
|
355
|
+
try { return JSON.parse(await fs.readFile(lockPath, 'utf8')); } catch { return undefined; }
|
|
356
|
+
};
|
|
357
|
+
const readWeb = async () => {
|
|
358
|
+
try { return JSON.parse(await fs.readFile(webPath, 'utf8')); } catch { return undefined; }
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// The authoritative check: the ready integration lock and live web.json must name the same pid.
|
|
362
|
+
// A loser of a concurrent start accepts the winning pid after its own contender has exited or
|
|
363
|
+
// the lock already names the winner.
|
|
364
|
+
async function waitForReady() {
|
|
365
|
+
const deadline = Date.now() + 30_000;
|
|
366
|
+
while (Date.now() < deadline) {
|
|
367
|
+
const lock = await readLock();
|
|
368
|
+
if (lock && integrationAlive(lock) && lock.ready) {
|
|
369
|
+
const info = await readWeb();
|
|
370
|
+
if (info && info.pid === lock.pid) return lock;
|
|
371
|
+
}
|
|
372
|
+
await sleep(100);
|
|
373
|
+
}
|
|
374
|
+
const lock = await readLock();
|
|
375
|
+
if (lock && !lock.ready) throw new Error('ALP web integration started but did not become ready; see ~/.alp/logs/integrations/paseo.log');
|
|
376
|
+
if (!lock) throw new Error('ALP web app is provided by an optional integration package. Install it (for example `npm install -g alp-paseo-plugin`) and run `alp web` again.');
|
|
377
|
+
throw new Error('ALP web integration did not publish a matching web.json lock');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const existing = await readLock();
|
|
381
|
+
if (existing && integrationAlive(existing)) {
|
|
382
|
+
return waitForReady();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const entry = await discoverIntegration(name, { home });
|
|
386
|
+
if (!entry) {
|
|
387
|
+
throw new Error('ALP web app is provided by an optional integration package. Install it (for example `npm install -g alp-paseo-plugin`) and run `alp web` again.');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { spawn } = await import('node:child_process');
|
|
391
|
+
// The child is detached with its stdio ignored, so it cannot rely on the parent's console. `--service`
|
|
392
|
+
// is the generic contract: the entry writes its own log (the path its error messages advertise) and
|
|
393
|
+
// keeps the process alive as a service. `--register` still prints to stdout and is not passed here.
|
|
394
|
+
const child = spawn(process.execPath, [entry, '--service'], { stdio: 'ignore', detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
395
|
+
let spawnError = null;
|
|
396
|
+
child.on('error', error => { spawnError = error; });
|
|
397
|
+
let exited = false;
|
|
398
|
+
child.on('exit', code => { if (code !== 0 && code !== null) exited = true; });
|
|
399
|
+
child.unref();
|
|
400
|
+
|
|
401
|
+
if (spawnError) throw spawnError;
|
|
402
|
+
// Give the process a moment to fail immediately on missing entry or bad permissions.
|
|
403
|
+
await sleep(150);
|
|
404
|
+
if (spawnError) throw spawnError;
|
|
405
|
+
if (exited) throw new Error('ALP web integration failed to start; see ~/.alp/logs/integrations/paseo.log');
|
|
406
|
+
|
|
407
|
+
return waitForReady();
|
|
408
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { AlpError } from '@anhlp/alp-core/errors';
|
|
3
|
+
import { getEntry } from '@anhlp/alp-core/library-edit';
|
|
4
|
+
import { runHook, samplePayload } from '@anhlp/alp-core/hook-run';
|
|
5
|
+
import { normalizeMcp } from '@anhlp/alp-core/validation';
|
|
6
|
+
import { probeMcp } from './mcp-probe.js';
|
|
7
|
+
import { probeAcp } from './acp-probe.js';
|
|
8
|
+
import { probeAntigravity } from './antigravity-probe.js';
|
|
9
|
+
import { loadProvider } from '@anhlp/alp-core/providers';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tries an MCP server, a hook or an ACP provider out (ALPD §43, §46). An MCP server is
|
|
13
|
+
* started and its tools listed; a provider is started and initialized; a hook runs once with a sample payload for its event, in the project (or the
|
|
14
|
+
* library when there is none), with the variables ALP gives hooks.
|
|
15
|
+
*/
|
|
16
|
+
export async function testEntry(kind, name, { root, library, templates, scope, timeoutMs } = {}) {
|
|
17
|
+
const entry = await getEntry(kind, name, { root, library, templates, scope });
|
|
18
|
+
const home = entry.source === 'project' ? path.join(path.resolve(root), '.alp') : library;
|
|
19
|
+
if (kind === 'mcp') {
|
|
20
|
+
const server = normalizeMcp({ mcpServers: { [name]: entry.content.server } }, path.join(home, 'mcp'), `${name}.json`).mcpServers[name];
|
|
21
|
+
const result = await probeMcp(server, { cwd: root ? path.resolve(root) : library, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
22
|
+
return { kind, name, source: entry.source, ok: true, ...result };
|
|
23
|
+
}
|
|
24
|
+
if (kind === 'hooks') {
|
|
25
|
+
const hook = entry.content.hook;
|
|
26
|
+
const project = root ? path.resolve(root) : undefined;
|
|
27
|
+
const payload = samplePayload(hook.event, { project, agent: hook.match?.agent ?? 'main' });
|
|
28
|
+
const env = { ALP_EVENT: hook.event, ALP_SESSION: payload.session, ALP_AGENT: payload.agent, ALP_PROJECT: project ?? '', ALP_TASK: payload.task ?? '' };
|
|
29
|
+
const result = await runHook(hook, payload, { cwd: project ?? library, env, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
30
|
+
const ok = result.exitCode === 0 && !result.timedOut;
|
|
31
|
+
return { kind, name, source: entry.source, ok, ...(hook.blocking && !ok ? { wouldBlock: true } : {}), payload, ...result };
|
|
32
|
+
}
|
|
33
|
+
if (kind === 'providers') {
|
|
34
|
+
const provider = await loadProvider(library, name);
|
|
35
|
+
const result = provider.kind === 'antigravity'
|
|
36
|
+
? await probeAntigravity(provider, { cwd: root ? path.resolve(root) : library, ...(timeoutMs ? { timeoutMs } : {}) })
|
|
37
|
+
: await probeAcp(provider, { cwd: root ? path.resolve(root) : library, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
38
|
+
return { kind, name, source: entry.source, ok: true, ...result };
|
|
39
|
+
}
|
|
40
|
+
throw new AlpError('INVALID_KIND', `Only mcp servers, hooks and providers can be tested, not ${kind}`);
|
|
41
|
+
}
|
package/src/mcp-probe.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
|
|
4
|
+
const PROTOCOL = '2025-06-18';
|
|
5
|
+
const client = { name: 'alp', version: '1' };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Starts an MCP server once and lists its tools (ALPD §43), for the Test button and
|
|
9
|
+
* `alp mcp test`. `server` is a normalized definition: { command, args, env, cwd } for
|
|
10
|
+
* stdio, or { url, headers } for HTTP. The server is stopped afterwards.
|
|
11
|
+
* @returns {Promise<{ server?: { name?: string, version?: string }, tools: Array<{ name: string, description?: string }> }>}
|
|
12
|
+
*/
|
|
13
|
+
export async function probeMcp(server, { cwd, timeoutMs = 15_000 } = {}) {
|
|
14
|
+
const session = server.url ? await httpSession(server, timeoutMs) : stdioSession(server, cwd, timeoutMs);
|
|
15
|
+
try {
|
|
16
|
+
const init = await session.request('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: client });
|
|
17
|
+
await session.notify('notifications/initialized');
|
|
18
|
+
const tools = [];
|
|
19
|
+
let cursor;
|
|
20
|
+
for (let page = 0; page < 20; page++) {
|
|
21
|
+
const result = await session.request('tools/list', cursor ? { cursor } : {});
|
|
22
|
+
for (const tool of result.tools ?? []) tools.push({ name: tool.name, ...(tool.description ? { description: tool.description } : {}) });
|
|
23
|
+
cursor = result.nextCursor;
|
|
24
|
+
if (!cursor) break;
|
|
25
|
+
}
|
|
26
|
+
return { ...(init.serverInfo ? { server: { name: init.serverInfo.name, version: init.serverInfo.version } } : {}), tools };
|
|
27
|
+
} finally {
|
|
28
|
+
await session.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A JSON-RPC session with a process over stdio; ACP providers are probed with it too. */
|
|
33
|
+
export function stdioSession(server, cwd, timeoutMs) {
|
|
34
|
+
// Its own process group, so a launcher such as npx is stopped with what it started.
|
|
35
|
+
const child = spawn(server.command, server.args ?? [], { cwd: server.cwd ?? cwd, env: { ...process.env, ...(server.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
36
|
+
const signal = name => { try { process.kill(-child.pid, name); } catch { child.kill(name); } };
|
|
37
|
+
let stderr = '';
|
|
38
|
+
child.stderr.setEncoding('utf8');
|
|
39
|
+
child.stderr.on('data', chunk => { if (stderr.length < 4000) stderr += chunk; });
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
let exited;
|
|
42
|
+
const failAll = error => { for (const { reject } of pending.values()) reject(error); pending.clear(); };
|
|
43
|
+
child.on('error', error => { exited = error; failAll(error); });
|
|
44
|
+
child.on('exit', code => {
|
|
45
|
+
exited = new Error(`${server.command} exited (${code ?? 'signal'})${stderr.trim() ? `: ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''}`);
|
|
46
|
+
failAll(exited);
|
|
47
|
+
});
|
|
48
|
+
createInterface({ input: child.stdout }).on('line', line => {
|
|
49
|
+
let message;
|
|
50
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
51
|
+
const waiting = pending.get(message.id);
|
|
52
|
+
if (!waiting) return;
|
|
53
|
+
pending.delete(message.id);
|
|
54
|
+
if (message.error) waiting.reject(new Error(`${message.error.message ?? 'MCP error'} (${message.error.code})`));
|
|
55
|
+
else waiting.resolve(message.result ?? {});
|
|
56
|
+
});
|
|
57
|
+
child.stdin.on('error', () => {});
|
|
58
|
+
let id = 0;
|
|
59
|
+
return {
|
|
60
|
+
request(method, params) {
|
|
61
|
+
if (exited) return Promise.reject(exited);
|
|
62
|
+
const current = ++id;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(() => { pending.delete(current); reject(new Error(`${method} got no answer within ${timeoutMs / 1000} s`)); }, timeoutMs);
|
|
65
|
+
pending.set(current, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
|
66
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: current, method, params }) + '\n');
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
async notify(method) { if (!exited) child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n'); },
|
|
70
|
+
async close() {
|
|
71
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
72
|
+
const done = new Promise(resolve => child.once('exit', resolve));
|
|
73
|
+
child.stdin.end();
|
|
74
|
+
signal('SIGTERM');
|
|
75
|
+
const timer = setTimeout(() => signal('SIGKILL'), 2000);
|
|
76
|
+
await done;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function httpSession(server, timeoutMs) {
|
|
83
|
+
let sessionId;
|
|
84
|
+
let id = 0;
|
|
85
|
+
const post = async body => {
|
|
86
|
+
const response = await fetch(server.url, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { ...(server.headers ?? {}), 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(sessionId ? { 'mcp-session-id': sessionId } : {}), 'mcp-protocol-version': PROTOCOL },
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
91
|
+
});
|
|
92
|
+
sessionId = response.headers.get('mcp-session-id') ?? sessionId;
|
|
93
|
+
if (!response.ok) throw new Error(`${server.url} answered ${response.status} ${response.statusText}`);
|
|
94
|
+
return response;
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
async request(method, params) {
|
|
98
|
+
const current = ++id;
|
|
99
|
+
const response = await post({ jsonrpc: '2.0', id: current, method, params });
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
// A streamable HTTP server answers with JSON or with server-sent events.
|
|
102
|
+
const messages = (response.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
103
|
+
? text.split('\n').filter(line => line.startsWith('data:')).map(line => { try { return JSON.parse(line.slice(5)); } catch { return undefined; } })
|
|
104
|
+
: [JSON.parse(text)];
|
|
105
|
+
const message = messages.find(entry => entry?.id === current);
|
|
106
|
+
if (!message) throw new Error(`${method}: no answer in the response`);
|
|
107
|
+
if (message.error) throw new Error(`${message.error.message ?? 'MCP error'} (${message.error.code})`);
|
|
108
|
+
return message.result ?? {};
|
|
109
|
+
},
|
|
110
|
+
async notify(method) { await post({ jsonrpc: '2.0', method }); },
|
|
111
|
+
async close() {
|
|
112
|
+
if (sessionId) await fetch(server.url, { method: 'DELETE', headers: { ...(server.headers ?? {}), 'mcp-session-id': sessionId }, signal: AbortSignal.timeout(timeoutMs) }).catch(() => {});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const dim = text => `\x1b[2m${text}\x1b[22m`;
|
|
4
|
+
const cyan = text => `\x1b[36m${text}\x1b[39m`;
|
|
5
|
+
|
|
6
|
+
export class Cancelled extends Error {
|
|
7
|
+
constructor() { super('Setup cancelled.'); }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A select box in the terminal: ↑/↓ (or k/j) move, Enter picks, Ctrl-C or Esc cancels.
|
|
12
|
+
* Choices are { label, value, hint? }; `initial` is the value highlighted first.
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {{ message: string, choices: Array<{ label: string, value: T, hint?: string }>, initial?: T }} question
|
|
15
|
+
* @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream }} [io]
|
|
16
|
+
* @returns {Promise<T>}
|
|
17
|
+
*/
|
|
18
|
+
export function select({ message, choices, initial }, { input = process.stdin, output = process.stdout } = {}) {
|
|
19
|
+
let index = Math.max(0, choices.findIndex(choice => choice.value === initial));
|
|
20
|
+
let drawn = 0;
|
|
21
|
+
const draw = () => {
|
|
22
|
+
if (drawn) output.write(`\x1b[${drawn}A\x1b[0J`);
|
|
23
|
+
const lines = [`${cyan('?')} ${message} ${dim('(↑/↓, Enter)')}`, ...choices.map((choice, at) =>
|
|
24
|
+
at === index ? cyan(`❯ ${choice.label}`) + (choice.hint ? ` ${dim(choice.hint)}` : '') : ` ${choice.label}${choice.hint ? ` ${dim(choice.hint)}` : ''}`)];
|
|
25
|
+
output.write(lines.join('\n') + '\n');
|
|
26
|
+
drawn = lines.length;
|
|
27
|
+
};
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
emitKeypressEvents(input);
|
|
30
|
+
const raw = input.isRaw;
|
|
31
|
+
input.setRawMode?.(true);
|
|
32
|
+
input.resume();
|
|
33
|
+
output.write('\x1b[?25l');
|
|
34
|
+
const done = (settle, value) => {
|
|
35
|
+
input.off('keypress', onKey);
|
|
36
|
+
input.setRawMode?.(raw ?? false);
|
|
37
|
+
input.pause();
|
|
38
|
+
output.write(`\x1b[${drawn}A\x1b[0J\x1b[?25h`);
|
|
39
|
+
if (settle === resolve) output.write(`${cyan('✔')} ${message} ${cyan(choices[index].label)}\n`);
|
|
40
|
+
settle(value);
|
|
41
|
+
};
|
|
42
|
+
const onKey = (text, key = {}) => {
|
|
43
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'escape') return done(reject, new Cancelled());
|
|
44
|
+
if (key.name === 'return' || key.name === 'enter') return done(resolve, choices[index].value);
|
|
45
|
+
if (key.name === 'up' || key.name === 'k') index = (index + choices.length - 1) % choices.length;
|
|
46
|
+
else if (key.name === 'down' || key.name === 'j') index = (index + 1) % choices.length;
|
|
47
|
+
else return;
|
|
48
|
+
draw();
|
|
49
|
+
};
|
|
50
|
+
input.on('keypress', onKey);
|
|
51
|
+
draw();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A line of text, for a value the list does not offer.
|
|
57
|
+
* @param {string} message
|
|
58
|
+
* @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream }} [io]
|
|
59
|
+
*/
|
|
60
|
+
export async function text(message, { input = process.stdin, output = process.stdout } = {}) {
|
|
61
|
+
const { createInterface } = await import('node:readline/promises');
|
|
62
|
+
const rl = createInterface({ input, output });
|
|
63
|
+
try { return (await rl.question(`${cyan('?')} ${message} `)).trim(); } finally { rl.close(); }
|
|
64
|
+
}
|