@anhlp/alp-client 0.0.0-beta.1
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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- 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 +33 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.js +587 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +422 -0
- package/dist/ipc.d.ts +42 -0
- package/dist/ipc.js +69 -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 +147 -0
- package/dist/portable.d.ts +18 -0
- package/dist/portable.js +27 -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/rpc/client.d.ts +82 -0
- package/dist/rpc/client.js +292 -0
- package/dist/rpc/errors.d.ts +42 -0
- package/dist/rpc/errors.js +34 -0
- package/dist/rpc/framing.d.ts +21 -0
- package/dist/rpc/framing.js +57 -0
- package/dist/rpc/handshake.d.ts +19 -0
- package/dist/rpc/handshake.js +8 -0
- package/dist/rpc/reconnect.d.ts +97 -0
- package/dist/rpc/reconnect.js +227 -0
- package/dist/rpc/timers.d.ts +11 -0
- package/dist/rpc/timers.js +11 -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/dist/websocket.d.ts +51 -0
- package/dist/websocket.js +190 -0
- package/package.json +168 -0
- package/src/acp-probe.js +46 -0
- package/src/antigravity-probe.js +106 -0
- package/src/command.js +18 -0
- package/src/doctor.d.ts +13 -0
- package/src/doctor.js +570 -0
- package/src/index.js +457 -0
- package/src/ipc.js +92 -0
- package/src/library-test.js +41 -0
- package/src/mcp-probe.js +118 -0
- package/src/portable.js +27 -0
- package/src/prompt.js +64 -0
- package/src/render.js +136 -0
- package/src/rpc/client.js +296 -0
- package/src/rpc/errors.js +54 -0
- package/src/rpc/framing.js +61 -0
- package/src/rpc/handshake.js +16 -0
- package/src/rpc/reconnect.js +226 -0
- package/src/rpc/timers.js +16 -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/websocket.js +259 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { open, readFile, realpath, stat, lstat, mkdir, readlink, unlink } 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 { scrubText } from '@anhlp/alp-core/log';
|
|
9
|
+
import { createSocketTransport } from './ipc.js';
|
|
10
|
+
import { AlpRpcClient } from './rpc/client.js';
|
|
11
|
+
import { hello } from './rpc/handshake.js';
|
|
12
|
+
export { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, encodeFrame } from '@anhlp/alp-protocol';
|
|
13
|
+
// The Node entry carries the whole portable API and the WebSocket transport too, so Node callers need one import.
|
|
14
|
+
export * from './portable.js';
|
|
15
|
+
export { connectWebSocket, createWebSocketTransport, redactUrl, CLOSE_CODE } from './websocket.js';
|
|
16
|
+
export { createSocketTransport } from './ipc.js';
|
|
17
|
+
|
|
18
|
+
export function alpHome(env = process.env) {
|
|
19
|
+
return path.resolve(env.ALP_HOME || path.join(os.homedir(), '.alp'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Windows uses named pipes; long Unix homes get a short socket in the temp directory. */
|
|
23
|
+
export function daemonPaths(home) {
|
|
24
|
+
const preferred = path.join(home, 'alpd.sock');
|
|
25
|
+
const socket = process.platform === 'win32'
|
|
26
|
+
? `\\\\.\\pipe\\alpd-${createHash('sha256').update(home).digest('hex').slice(0, 24)}`
|
|
27
|
+
: Buffer.byteLength(preferred) < 100
|
|
28
|
+
? preferred
|
|
29
|
+
: path.join(os.tmpdir(), `alpd-${process.getuid?.() ?? 'user'}-${createHash('sha256').update(home).digest('hex').slice(0, 12)}`, 'rpc.sock');
|
|
30
|
+
return { home, lock: path.join(home, 'alpd.lock'), socket, log: path.join(home, 'logs', 'alpd.log'), install: path.join(home, 'alpd.json') };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** IPC trusts the OS user, not every local process. Existing unsafe directories fail closed.
|
|
34
|
+
* Windows mode bits do not describe named-pipe ACLs; those remain an OS deployment assumption. */
|
|
35
|
+
async function assertSafeAncestors(directory) {
|
|
36
|
+
if (process.platform === 'win32') return;
|
|
37
|
+
const absolute = path.isAbsolute(directory) ? directory : `${process.cwd()}/${directory}`;
|
|
38
|
+
let current = path.parse(absolute).root;
|
|
39
|
+
const pending = absolute.slice(current.length).split('/');
|
|
40
|
+
let links = 0;
|
|
41
|
+
// Follow every link expansion, including intermediate targets that realpath hides.
|
|
42
|
+
// Check a directory before entering it; never create beneath an unsafe parent.
|
|
43
|
+
while (pending.length) {
|
|
44
|
+
const part = pending.shift();
|
|
45
|
+
if (!part || part === '.') continue;
|
|
46
|
+
if (part === '..') { current = path.dirname(current); continue; }
|
|
47
|
+
const candidate = path.join(current, part);
|
|
48
|
+
const info = await lstat(candidate).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
49
|
+
if (!info) { current = candidate; continue; }
|
|
50
|
+
const trustedOwner = info.uid === 0 || info.uid === process.getuid?.();
|
|
51
|
+
if (!trustedOwner) throw new Error('IPC requires ancestors owned by the current user or root');
|
|
52
|
+
if (info.isSymbolicLink()) {
|
|
53
|
+
if (++links > 40) throw new Error('IPC ancestor symlink loop');
|
|
54
|
+
const target = await readlink(candidate);
|
|
55
|
+
if (path.isAbsolute(target)) current = path.parse(target).root;
|
|
56
|
+
pending.unshift(...target.slice(path.isAbsolute(target) ? path.parse(target).root.length : 0).split('/'));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Sticky shared temp directories prevent users replacing each other's entries.
|
|
60
|
+
if (!info.isDirectory() || ((info.mode & 0o022) && !(info.mode & 0o1000))) {
|
|
61
|
+
throw new Error('IPC requires trusted ancestors without shared write permissions');
|
|
62
|
+
}
|
|
63
|
+
current = candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function assertPrivateDirectory(directory, { create = false } = {}) {
|
|
68
|
+
await assertSafeAncestors(directory);
|
|
69
|
+
if (create) await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
70
|
+
const info = await lstat(directory);
|
|
71
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('IPC requires a private directory, not a symlink');
|
|
72
|
+
if (process.platform !== 'win32' && (info.uid !== process.getuid?.() || (info.mode & 0o077) !== 0)) {
|
|
73
|
+
throw new Error('IPC directory must be owned by the current user with private permissions (0700)');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function assertPrivateSocket(socketPath) {
|
|
78
|
+
if (process.platform === 'win32') return;
|
|
79
|
+
await assertPrivateDirectory(path.dirname(socketPath));
|
|
80
|
+
const info = await lstat(socketPath);
|
|
81
|
+
if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o077) !== 0) {
|
|
82
|
+
throw new Error('IPC socket must be owned by the current user with private permissions (0600)');
|
|
83
|
+
}
|
|
84
|
+
return info;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isFile = file => stat(file).then(info => info.isFile(), () => false);
|
|
88
|
+
|
|
89
|
+
/** Global bin directories that a GUI app's PATH may lack. */
|
|
90
|
+
const BIN_DIRECTORIES = [path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.local', 'bin'), '/opt/homebrew/bin', '/usr/local/bin'];
|
|
91
|
+
|
|
92
|
+
/** The npm package that ships the `alp` command and alpd. */
|
|
93
|
+
const CLI_PACKAGES = ['@anhlp/alp', '@anhlp/alp-cli'];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Finds alpd.js for a client that cannot locate it beside itself, such as the Paseo
|
|
97
|
+
* plugin, which Paseo re-bundles: the first existing candidate, then where alpd last
|
|
98
|
+
* recorded itself ($ALP_HOME/alpd.json), then the ALP CLI (`alp`) on PATH.
|
|
99
|
+
*/
|
|
100
|
+
export async function findDaemonEntry({ home = alpHome(), env = process.env, candidates = /** @type {Array<string | undefined>} */ ([]) } = {}) {
|
|
101
|
+
for (const candidate of candidates) if (candidate && await isFile(candidate)) return candidate;
|
|
102
|
+
try {
|
|
103
|
+
const { entry } = JSON.parse(await readFile(daemonPaths(home).install, 'utf8'));
|
|
104
|
+
if (typeof entry === 'string' && await isFile(entry)) return entry;
|
|
105
|
+
} catch {}
|
|
106
|
+
for (const directory of [...(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...BIN_DIRECTORIES]) {
|
|
107
|
+
const cli = await realpath(path.join(directory, 'alp')).catch(() => undefined);
|
|
108
|
+
if (!cli || path.basename(cli) !== 'cli.js') continue;
|
|
109
|
+
const root = path.resolve(path.dirname(cli), '..');
|
|
110
|
+
const name = await readFile(path.join(root, 'package.json'), 'utf8').then(text => JSON.parse(text).name, () => undefined);
|
|
111
|
+
const entry = path.join(root, 'dist', 'alpd.js');
|
|
112
|
+
if (CLI_PACKAGES.includes(name) && await isFile(entry)) return entry;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Wall-clock boot time; a lock from an earlier boot is stale even if its pid is reused. */
|
|
118
|
+
export function bootTime() {
|
|
119
|
+
return Math.round((Date.now() - os.uptime() * 1000) / 1000);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function readLock(home) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(await readFile(daemonPaths(home).lock, 'utf8'));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function lockAlive(lock) {
|
|
132
|
+
if (!lock || !Number.isSafeInteger(lock.pid) || Math.abs((lock.bootTime ?? 0) - bootTime()) > 30) return false;
|
|
133
|
+
try {
|
|
134
|
+
process.kill(lock.pid, 0);
|
|
135
|
+
return true;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return error?.code === 'EPERM';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Takes alpd's single-instance lock for `home`: exclusive create; a lock whose pid or boot is
|
|
143
|
+
* gone is stale and replaced. alpd takes it to run; `alp backup` and `alp restore` take it with
|
|
144
|
+
* `maintenance` set, so no alpd starts on data they read or replace (ALPD §68).
|
|
145
|
+
* @param {string} home
|
|
146
|
+
* @param {{ version: string, protocolVersion: number, socket: string, maintenance?: string }} contents
|
|
147
|
+
*/
|
|
148
|
+
export async function acquireDaemonLock(home, contents) {
|
|
149
|
+
const file = daemonPaths(home).lock;
|
|
150
|
+
const lock = { pid: process.pid, startedAt: new Date().toISOString(), bootTime: bootTime(), uid: process.getuid?.() ?? -1, ready: false, ...contents };
|
|
151
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
const handle = await open(file, 'wx', 0o600);
|
|
154
|
+
await handle.writeFile(JSON.stringify(lock));
|
|
155
|
+
await handle.close();
|
|
156
|
+
return lock;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
159
|
+
const current = await readLock(home);
|
|
160
|
+
if (lockAlive(current)) throw new Error(current.maintenance ? `ALP is busy: alp ${current.maintenance} runs (pid ${current.pid}); try again when it finishes` : `ALP daemon already running (pid ${current.pid})`);
|
|
161
|
+
// Remove the stale lock only if nobody replaced it meanwhile.
|
|
162
|
+
const again = await readFile(file, 'utf8').catch(() => undefined);
|
|
163
|
+
if (again === undefined || JSON.stringify(current) === again || !lockAlive(JSON.parse(again))) await unlink(file).catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw new Error('Could not acquire the ALP daemon lock');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Lets go of alpd's lock if this process holds it. */
|
|
170
|
+
export async function releaseDaemonLock(home) {
|
|
171
|
+
const current = await readLock(home);
|
|
172
|
+
if (current?.pid === process.pid) await unlink(daemonPaths(home).lock).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One JSON-RPC 2.0 connection to alpd over a Unix socket or named pipe; events arrive as `event`
|
|
177
|
+
* notifications. The engine is the portable AlpRpcClient; this keeps the socket constructor the
|
|
178
|
+
* CLI, alpd's tests and the Paseo plugin use.
|
|
179
|
+
*/
|
|
180
|
+
export class AlpClient extends AlpRpcClient {
|
|
181
|
+
/**
|
|
182
|
+
* @param {import('node:net').Socket} socket a connected or connecting socket; the client owns it
|
|
183
|
+
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
184
|
+
*/
|
|
185
|
+
constructor(socket, { maxFrame, maxBufferedBytes, ...options } = {}) {
|
|
186
|
+
super(createSocketTransport(socket, { maxFrame, maxBufferedBytes }), { maxFrame, ...options });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A socket to alpd, once it is checked to be private to this user (the IPC checks above). @param {string} socketPath */
|
|
191
|
+
async function openSocket(socketPath) {
|
|
192
|
+
await assertPrivateSocket(socketPath);
|
|
193
|
+
return /** @type {Promise<import('node:net').Socket>} */ (new Promise((resolve, reject) => {
|
|
194
|
+
const candidate = net.createConnection(socketPath);
|
|
195
|
+
candidate.once('connect', () => { candidate.off('error', reject); resolve(candidate); });
|
|
196
|
+
candidate.once('error', reject);
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The transport to alpd's socket without the handshake, for an AlpRpcClient or a reconnecting
|
|
202
|
+
* client whose onConnect runs `hello`. `connect` is this plus `hello`.
|
|
203
|
+
* @param {string} socketPath
|
|
204
|
+
* @param {import('./ipc.js').SocketTransportOptions} [options]
|
|
205
|
+
*/
|
|
206
|
+
export async function connectSocket(socketPath, options) {
|
|
207
|
+
return createSocketTransport(await openSocket(socketPath), options);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Connects and performs the version handshake.
|
|
212
|
+
* @param {string} socketPath
|
|
213
|
+
* @param {{ name: string, version: string }} [client]
|
|
214
|
+
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
215
|
+
* @returns {Promise<AlpClient>}
|
|
216
|
+
*/
|
|
217
|
+
export async function connect(socketPath, client = { name: 'alp', version: '0' }, options = {}) {
|
|
218
|
+
const connection = new AlpClient(await openSocket(socketPath), options);
|
|
219
|
+
try {
|
|
220
|
+
await hello(connection, client);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
connection.close();
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
return connection;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Why a launcher failed, from the last structured log line it wrote to stderr: code and message, scrubbed. */
|
|
229
|
+
function launcherReason(text) {
|
|
230
|
+
const line = text.trim().split('\n').filter(Boolean).at(-1);
|
|
231
|
+
if (!line) return '';
|
|
232
|
+
let reason = line;
|
|
233
|
+
try {
|
|
234
|
+
const record = JSON.parse(line);
|
|
235
|
+
reason = `${record.code ? `${record.code}, ` : ''}${record.msg}${record.error?.message ? `: ${record.error.message}` : ''}`;
|
|
236
|
+
} catch {}
|
|
237
|
+
return `: ${scrubText(reason, { max: 600 })}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function tail(file, lines = 30) {
|
|
241
|
+
// The tail goes into an error message, so it is scrubbed like every other log output.
|
|
242
|
+
try { return scrubText((await readFile(file, 'utf8')).split('\n').slice(-lines).join('\n'), { max: 8000 }); } catch { return ''; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Returns the socket of a ready daemon, starting one when none runs.
|
|
247
|
+
* `entry` is the daemon script; it is started through its own detaching launcher so the
|
|
248
|
+
* daemon outlives the client that started it.
|
|
249
|
+
*/
|
|
250
|
+
export async function ensureDaemon({ home = alpHome(), entry = /** @type {string | undefined} */ (undefined), env = process.env, execPath = process.execPath, timeoutMs = 15_000 } = {}) {
|
|
251
|
+
const ready = async () => {
|
|
252
|
+
const lock = await readLock(home);
|
|
253
|
+
return lockAlive(lock) && lock.ready ? lock.socket : undefined;
|
|
254
|
+
};
|
|
255
|
+
const live = await ready();
|
|
256
|
+
if (live) return live;
|
|
257
|
+
const held = await readLock(home);
|
|
258
|
+
if (lockAlive(held) && held.maintenance) throw new Error(`ALP is busy: alp ${held.maintenance} runs (pid ${held.pid}); alpd starts again when it finishes`);
|
|
259
|
+
if (!lockAlive(held)) {
|
|
260
|
+
entry ??= await findDaemonEntry({ home, env });
|
|
261
|
+
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');
|
|
262
|
+
await new Promise((resolve, reject) => {
|
|
263
|
+
const launcher = spawn(execPath, [entry, '--detach'], {
|
|
264
|
+
env: { ...env, ALP_HOME: home, ELECTRON_RUN_AS_NODE: '1' },
|
|
265
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
266
|
+
windowsHide: true,
|
|
267
|
+
});
|
|
268
|
+
// The launcher writes only when it fails, and before alpd has a log to write to.
|
|
269
|
+
let failure = '';
|
|
270
|
+
launcher.stderr.on('data', chunk => { if (failure.length < 8000) failure += chunk; });
|
|
271
|
+
launcher.once('error', reject);
|
|
272
|
+
launcher.once('close', code => code === 0 ? resolve() : reject(new Error(`ALP daemon launcher exited (${code})${launcherReason(failure)}`)));
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const deadline = Date.now() + timeoutMs;
|
|
276
|
+
while (Date.now() < deadline) {
|
|
277
|
+
const socket = await ready();
|
|
278
|
+
if (socket) return socket;
|
|
279
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
280
|
+
}
|
|
281
|
+
const log = await tail(daemonPaths(home).log);
|
|
282
|
+
throw new Error(`ALP daemon did not become ready within ${timeoutMs} ms${log ? `\n${log}` : ''}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Registration descriptor field names, in priority order. */
|
|
286
|
+
const INTEGRATION_DESCRIPTOR_FIELDS = ['main', 'entry', 'path', 'command'];
|
|
287
|
+
|
|
288
|
+
async function readIntegrationDescriptor(file, baseDirs = []) {
|
|
289
|
+
try {
|
|
290
|
+
const raw = JSON.parse(await readFile(file, 'utf8'));
|
|
291
|
+
for (const field of INTEGRATION_DESCRIPTOR_FIELDS) {
|
|
292
|
+
const value = raw?.[field];
|
|
293
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
294
|
+
if (path.isAbsolute(value)) {
|
|
295
|
+
if (await isFile(value)) return value;
|
|
296
|
+
} else {
|
|
297
|
+
const bases = [path.dirname(file), ...baseDirs];
|
|
298
|
+
for (const base of bases) {
|
|
299
|
+
const resolved = path.resolve(base, value);
|
|
300
|
+
if (await isFile(resolved)) return resolved;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch {}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function integrationCandidateFromHome(home, name) {
|
|
309
|
+
const descriptors = [
|
|
310
|
+
path.join(home, 'integrations', `${name}.json`),
|
|
311
|
+
path.join(home, 'integrations', name, 'integration.json'),
|
|
312
|
+
];
|
|
313
|
+
for (const descriptor of descriptors) {
|
|
314
|
+
const entry = await readIntegrationDescriptor(descriptor, [home]);
|
|
315
|
+
if (entry) return entry;
|
|
316
|
+
}
|
|
317
|
+
// Legacy single-file registration.
|
|
318
|
+
const legacy = path.join(home, 'integrations', name, 'main.js');
|
|
319
|
+
return (await isFile(legacy)) ? legacy : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function integrationBinaryName(name) {
|
|
323
|
+
return name === 'web' ? 'alp-web-integration' : `alp-${name}-integration`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Distribution roots that may carry a bundled integration descriptor. */
|
|
327
|
+
async function distributionRoots() {
|
|
328
|
+
const roots = [];
|
|
329
|
+
const argv = process.argv[1] ? await realpath(process.argv[1]).catch(() => process.argv[1]) : undefined;
|
|
330
|
+
if (argv) {
|
|
331
|
+
// The CLI runs from <distribution>/packages/cli/dist, so the distribution root is a few levels
|
|
332
|
+
// up. Resolve the real path first so a symlinked global bin still finds its own root.
|
|
333
|
+
let directory = path.dirname(argv);
|
|
334
|
+
for (let depth = 0; depth < 4; depth += 1) { roots.push(path.resolve(directory, '..')); directory = path.resolve(directory, '..'); }
|
|
335
|
+
// Every package ancestor may carry a bundled descriptor (a full distribution, or the plugin
|
|
336
|
+
// package installed next to it).
|
|
337
|
+
for (let ancestor = path.dirname(argv); ; ancestor = path.dirname(ancestor)) {
|
|
338
|
+
if (await isFile(path.join(ancestor, 'package.json'))) roots.push(ancestor);
|
|
339
|
+
if (path.dirname(ancestor) === ancestor) break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Also consider the current working directory for full distributions.
|
|
343
|
+
roots.push(process.cwd());
|
|
344
|
+
return [...new Set(roots)];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function discoverIntegration(name = 'web', options = {}) {
|
|
348
|
+
// Explicit environment override always wins.
|
|
349
|
+
const envCandidates = [
|
|
350
|
+
process.env.ALP_WEB_INTEGRATION,
|
|
351
|
+
process.env.ALP_INTEGRATION_ENTRY,
|
|
352
|
+
name !== 'web' ? process.env[`ALP_${name.toUpperCase()}_INTEGRATION`] : undefined,
|
|
353
|
+
].filter(Boolean);
|
|
354
|
+
for (const candidate of envCandidates) if (await isFile(candidate)) return candidate;
|
|
355
|
+
|
|
356
|
+
const home = options?.home ? path.resolve(options.home) : alpHome();
|
|
357
|
+
const fromHome = await integrationCandidateFromHome(home, name);
|
|
358
|
+
if (fromHome) return fromHome;
|
|
359
|
+
|
|
360
|
+
// A full distribution may describe a bundled integration relative to its own root.
|
|
361
|
+
for (const root of await distributionRoots()) {
|
|
362
|
+
for (const descriptorPath of [path.join(root, 'integrations', `${name}.json`), path.join(root, 'integrations', name, 'integration.json')]) {
|
|
363
|
+
const entry = await readIntegrationDescriptor(descriptorPath, [root]);
|
|
364
|
+
if (entry) return entry;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Installed integration packages announce themselves on PATH.
|
|
369
|
+
const binary = integrationBinaryName(name);
|
|
370
|
+
for (const directory of [...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean), ...BIN_DIRECTORIES]) {
|
|
371
|
+
const candidate = await realpath(path.join(directory, binary)).catch(() => undefined);
|
|
372
|
+
if (candidate && await isFile(candidate)) return candidate;
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function integrationAlive(lock) {
|
|
378
|
+
if (!lock || !Number.isSafeInteger(lock.pid)) return false;
|
|
379
|
+
try { process.kill(lock.pid, 0); } catch (error) {
|
|
380
|
+
return error?.code === 'EPERM';
|
|
381
|
+
}
|
|
382
|
+
if (typeof lock.bootTime === 'number' && Math.abs(lock.bootTime - bootTime()) > 30) return false;
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function startIntegration(nameOrOptions = 'web') {
|
|
387
|
+
const options = typeof nameOrOptions === 'string' ? { name: nameOrOptions } : (nameOrOptions ?? {});
|
|
388
|
+
const name = options.name ?? 'web';
|
|
389
|
+
const fs = await import('node:fs/promises');
|
|
390
|
+
const pathMod = await import('node:path');
|
|
391
|
+
const home = options.home ? pathMod.resolve(options.home) : alpHome();
|
|
392
|
+
const lockPath = pathMod.join(home, 'integrations', `${name}.lock`);
|
|
393
|
+
const webPath = pathMod.join(home, `${name}.json`);
|
|
394
|
+
|
|
395
|
+
// Clear, actionable diagnostics for a disabled optional integration.
|
|
396
|
+
let settings = {};
|
|
397
|
+
try { settings = JSON.parse(await fs.readFile(pathMod.join(home, 'settings.json'), 'utf8')); } catch {}
|
|
398
|
+
if (settings?.web?.enabled === false) {
|
|
399
|
+
throw new Error('ALP web app is disabled by settings ("web.enabled": false).');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
403
|
+
const readLock = async () => {
|
|
404
|
+
try { return JSON.parse(await fs.readFile(lockPath, 'utf8')); } catch { return undefined; }
|
|
405
|
+
};
|
|
406
|
+
const readWeb = async () => {
|
|
407
|
+
try { return JSON.parse(await fs.readFile(webPath, 'utf8')); } catch { return undefined; }
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// The authoritative check: the ready integration lock and live web.json must name the same pid.
|
|
411
|
+
// A loser of a concurrent start accepts the winning pid after its own contender has exited or
|
|
412
|
+
// the lock already names the winner.
|
|
413
|
+
async function waitForReady() {
|
|
414
|
+
const deadline = Date.now() + 30_000;
|
|
415
|
+
while (Date.now() < deadline) {
|
|
416
|
+
const lock = await readLock();
|
|
417
|
+
if (lock && integrationAlive(lock) && lock.ready) {
|
|
418
|
+
const info = await readWeb();
|
|
419
|
+
if (info && info.pid === lock.pid) return lock;
|
|
420
|
+
}
|
|
421
|
+
await sleep(100);
|
|
422
|
+
}
|
|
423
|
+
const lock = await readLock();
|
|
424
|
+
if (lock && !lock.ready) throw new Error('ALP web integration started but did not become ready; see ~/.alp/logs/integrations/paseo.log');
|
|
425
|
+
if (!lock) throw new Error('ALP web app is provided by an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`) and run `alp web` again.');
|
|
426
|
+
throw new Error('ALP web integration did not publish a matching web.json lock');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const existing = await readLock();
|
|
430
|
+
if (existing && integrationAlive(existing)) {
|
|
431
|
+
return waitForReady();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const entry = await discoverIntegration(name, { home });
|
|
435
|
+
if (!entry) {
|
|
436
|
+
throw new Error('ALP web app is provided by an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`) and run `alp web` again.');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const { spawn } = await import('node:child_process');
|
|
440
|
+
// The child is detached with its stdio ignored, so it cannot rely on the parent's console. `--service`
|
|
441
|
+
// is the generic contract: the entry writes its own log (the path its error messages advertise) and
|
|
442
|
+
// keeps the process alive as a service. `--register` still prints to stdout and is not passed here.
|
|
443
|
+
const child = spawn(process.execPath, [entry, '--service'], { stdio: 'ignore', detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
444
|
+
let spawnError = null;
|
|
445
|
+
child.on('error', error => { spawnError = error; });
|
|
446
|
+
let exited = false;
|
|
447
|
+
child.on('exit', code => { if (code !== 0 && code !== null) exited = true; });
|
|
448
|
+
child.unref();
|
|
449
|
+
|
|
450
|
+
if (spawnError) throw spawnError;
|
|
451
|
+
// Give the process a moment to fail immediately on missing entry or bad permissions.
|
|
452
|
+
await sleep(150);
|
|
453
|
+
if (spawnError) throw spawnError;
|
|
454
|
+
if (exited) throw new Error('ALP web integration failed to start; see ~/.alp/logs/integrations/paseo.log');
|
|
455
|
+
|
|
456
|
+
return waitForReady();
|
|
457
|
+
}
|
package/src/ipc.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { MAX_FRAME } from '@anhlp/alp-protocol';
|
|
2
|
+
import { AlpClientError } from './rpc/errors.js';
|
|
3
|
+
import { createLineDecoder } from './rpc/framing.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The Node transport of the ALP client: a Unix socket or Windows named pipe carrying
|
|
7
|
+
* newline-delimited JSON (plans/reference/ALPD.md §3). Node-only; the portable engine
|
|
8
|
+
* (`@anhlp/alp-client/portable`) never imports it. Socket discovery, the private-socket checks
|
|
9
|
+
* and daemon startup stay in the Node entry (`connect`, `ensureDaemon`).
|
|
10
|
+
*
|
|
11
|
+
* @typedef {import('./rpc/client.js').AlpTransport} AlpTransport
|
|
12
|
+
* @typedef {import('./rpc/client.js').TransportClose} TransportClose
|
|
13
|
+
* @typedef {{ maxFrame?: number, maxBufferedBytes?: number }} SocketTransportOptions
|
|
14
|
+
* `maxBufferedBytes` bounds what may wait in the socket's write buffer; a send beyond it fails
|
|
15
|
+
* as `send_failed` instead of growing memory while the daemon does not read.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wraps a connected or still-connecting socket (writes made while it connects are queued by
|
|
20
|
+
* Node). The transport owns the socket from then on.
|
|
21
|
+
* @param {import('node:net').Socket} socket
|
|
22
|
+
* @param {SocketTransportOptions} [options]
|
|
23
|
+
* @returns {AlpTransport}
|
|
24
|
+
*/
|
|
25
|
+
export function createSocketTransport(socket, { maxFrame = MAX_FRAME, maxBufferedBytes = 4 * MAX_FRAME } = {}) {
|
|
26
|
+
const decoder = createLineDecoder({ maxFrame });
|
|
27
|
+
/** @type {Set<(frame: string) => void>} */
|
|
28
|
+
const messages = new Set();
|
|
29
|
+
/** @type {Set<(close: TransportClose) => void>} */
|
|
30
|
+
const closes = new Set();
|
|
31
|
+
/** @type {TransportClose | undefined} */
|
|
32
|
+
let ended;
|
|
33
|
+
/** @param {TransportClose} close */
|
|
34
|
+
const end = close => {
|
|
35
|
+
if (ended) return;
|
|
36
|
+
ended = close;
|
|
37
|
+
socket.off('data', onData);
|
|
38
|
+
messages.clear();
|
|
39
|
+
const listeners = [...closes];
|
|
40
|
+
closes.clear();
|
|
41
|
+
for (const listener of listeners) listener(close);
|
|
42
|
+
};
|
|
43
|
+
/** @param {string} chunk */
|
|
44
|
+
const onData = chunk => {
|
|
45
|
+
if (ended) return;
|
|
46
|
+
let frames;
|
|
47
|
+
try { frames = decoder.push(chunk); } catch {
|
|
48
|
+
const error = new AlpClientError('ALP daemon frame exceeds size limit', { reason: 'protocol' });
|
|
49
|
+
socket.destroy();
|
|
50
|
+
return end({ error });
|
|
51
|
+
}
|
|
52
|
+
for (const frame of frames) {
|
|
53
|
+
if (ended) return;
|
|
54
|
+
for (const listener of [...messages]) listener(frame);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
socket.setEncoding('utf8');
|
|
58
|
+
socket.on('data', onData);
|
|
59
|
+
socket.on('error', error => end({ error }));
|
|
60
|
+
socket.on('close', () => end({}));
|
|
61
|
+
if (socket.destroyed) end({});
|
|
62
|
+
// A socket alpd has half-closed (Node ends our side at once) or destroyed is gone, even before
|
|
63
|
+
// Node emits 'close' a tick later.
|
|
64
|
+
const gone = () => socket.destroyed || socket.writableEnded;
|
|
65
|
+
return {
|
|
66
|
+
get state() { return ended || gone() ? 'closed' : 'open'; },
|
|
67
|
+
send(frame) {
|
|
68
|
+
// Report the close now rather than on 'close', so the client is closed when the send fails.
|
|
69
|
+
if (!ended && gone()) end({});
|
|
70
|
+
if (ended) throw new AlpClientError('ALP daemon connection closed', { reason: 'send_failed' });
|
|
71
|
+
if (socket.writableLength + frame.length > maxBufferedBytes) throw new AlpClientError(`ALP socket write buffer is over ${maxBufferedBytes} bytes`, { reason: 'send_failed' });
|
|
72
|
+
socket.write(`${frame}\n`);
|
|
73
|
+
},
|
|
74
|
+
onMessage(listener) {
|
|
75
|
+
if (ended) return () => false;
|
|
76
|
+
messages.add(listener);
|
|
77
|
+
return () => messages.delete(listener);
|
|
78
|
+
},
|
|
79
|
+
onClose(listener) {
|
|
80
|
+
if (ended) return () => false;
|
|
81
|
+
closes.add(listener);
|
|
82
|
+
return () => closes.delete(listener);
|
|
83
|
+
},
|
|
84
|
+
close() {
|
|
85
|
+
// end() lets frames already written reach the daemon, as the client always did; once they
|
|
86
|
+
// have, the socket is destroyed, so a peer that never closes its side cannot hold it open.
|
|
87
|
+
// A side already ended (by this, or by Node after alpd's FIN) is destroyed by Node itself.
|
|
88
|
+
if (!socket.writableEnded) socket.end(() => socket.destroy());
|
|
89
|
+
end({});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -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
|
+
}
|