@anhlp/alp-client 0.0.0-stage → 0.0.1-beta.4

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.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +28 -2
  4. package/dist/acp-probe.d.ts +44 -0
  5. package/dist/acp-probe.js +37 -0
  6. package/dist/antigravity-probe.d.ts +34 -0
  7. package/dist/antigravity-probe.js +95 -0
  8. package/dist/command.d.ts +5 -0
  9. package/dist/command.js +33 -0
  10. package/dist/doctor.d.ts +13 -0
  11. package/dist/doctor.js +587 -0
  12. package/dist/index.d.ts +151 -0
  13. package/dist/index.js +533 -0
  14. package/dist/integration-lock.d.ts +35 -0
  15. package/dist/integration-lock.js +179 -0
  16. package/dist/ipc.d.ts +42 -0
  17. package/dist/ipc.js +69 -0
  18. package/dist/library-test.d.ts +94 -0
  19. package/dist/library-test.js +37 -0
  20. package/dist/mcp-probe.d.ts +24 -0
  21. package/dist/mcp-probe.js +147 -0
  22. package/dist/portable.d.ts +19 -0
  23. package/dist/portable.js +33 -0
  24. package/dist/prompt.d.ts +32 -0
  25. package/dist/prompt.js +59 -0
  26. package/dist/render.d.ts +6 -0
  27. package/dist/render.js +120 -0
  28. package/dist/rpc/client.d.ts +82 -0
  29. package/dist/rpc/client.js +292 -0
  30. package/dist/rpc/errors.d.ts +42 -0
  31. package/dist/rpc/errors.js +34 -0
  32. package/dist/rpc/framing.d.ts +21 -0
  33. package/dist/rpc/framing.js +57 -0
  34. package/dist/rpc/handshake.d.ts +19 -0
  35. package/dist/rpc/handshake.js +8 -0
  36. package/dist/rpc/reconnect.d.ts +97 -0
  37. package/dist/rpc/reconnect.js +227 -0
  38. package/dist/rpc/registry.d.ts +211 -0
  39. package/dist/rpc/registry.js +145 -0
  40. package/dist/rpc/timers.d.ts +11 -0
  41. package/dist/rpc/timers.js +11 -0
  42. package/dist/service.d.ts +50 -0
  43. package/dist/service.js +143 -0
  44. package/dist/setup.d.ts +2 -0
  45. package/dist/setup.js +146 -0
  46. package/dist/supervise.d.ts +14 -0
  47. package/dist/supervise.js +87 -0
  48. package/dist/websocket.d.ts +51 -0
  49. package/dist/websocket.js +190 -0
  50. package/package.json +174 -4
  51. package/src/acp-probe.js +46 -0
  52. package/src/antigravity-probe.js +106 -0
  53. package/src/command.js +18 -0
  54. package/src/doctor.d.ts +13 -0
  55. package/src/doctor.js +570 -0
  56. package/src/index.js +596 -0
  57. package/src/integration-lock.js +179 -0
  58. package/src/ipc.js +92 -0
  59. package/src/library-test.js +41 -0
  60. package/src/mcp-probe.js +118 -0
  61. package/src/portable.js +28 -0
  62. package/src/prompt.js +64 -0
  63. package/src/render.js +136 -0
  64. package/src/rpc/client.js +296 -0
  65. package/src/rpc/errors.js +54 -0
  66. package/src/rpc/framing.js +61 -0
  67. package/src/rpc/handshake.js +16 -0
  68. package/src/rpc/reconnect.js +226 -0
  69. package/src/rpc/registry.js +161 -0
  70. package/src/rpc/timers.js +16 -0
  71. package/src/service.js +154 -0
  72. package/src/setup.js +150 -0
  73. package/src/supervise.d.ts +14 -0
  74. package/src/supervise.js +92 -0
  75. package/src/websocket.js +259 -0
package/src/index.js ADDED
@@ -0,0 +1,596 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { open, readFile, writeFile, realpath, stat, lstat, mkdir, readlink, unlink, readdir, rename, rmdir, link } 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
+ /**
118
+ * Integration names are single lowercase path segments: they name `<home>/integrations/<name>.lock`,
119
+ * its guard and `<home>/<name>.json`. Returns the name, or throws a TypeError.
120
+ * @param {unknown} name
121
+ * @returns {string}
122
+ */
123
+ export function assertIntegrationName(name) {
124
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new TypeError(`Invalid integration name: ${JSON.stringify(name)}`);
125
+ return name;
126
+ }
127
+
128
+ /** Wall-clock boot time; a lock from an earlier boot is stale even if its pid is reused. */
129
+ export function bootTime() {
130
+ return Math.round((Date.now() - os.uptime() * 1000) / 1000);
131
+ }
132
+
133
+ export async function readLock(home) {
134
+ try {
135
+ return JSON.parse(await readFile(daemonPaths(home).lock, 'utf8'));
136
+ } catch (error) {
137
+ if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined;
138
+ throw error;
139
+ }
140
+ }
141
+
142
+ export function lockAlive(lock) {
143
+ if (!lock || !Number.isSafeInteger(lock.pid) || Math.abs((lock.bootTime ?? 0) - bootTime()) > 30) return false;
144
+ try {
145
+ process.kill(lock.pid, 0);
146
+ return true;
147
+ } catch (error) {
148
+ return error?.code === 'EPERM';
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Takes alpd's single-instance lock for `home`: exclusive create; a lock whose pid or boot is
154
+ * gone is stale and replaced. alpd takes it to run; `alp backup` and `alp restore` take it with
155
+ * `maintenance` set, so no alpd starts on data they read or replace (ALPD §68).
156
+ * @param {string} home
157
+ * @param {{ version: string, protocolVersion: number, socket: string, maintenance?: string }} contents
158
+ */
159
+ export async function acquireDaemonLock(home, contents) {
160
+ const file = daemonPaths(home).lock;
161
+ const lock = { pid: process.pid, startedAt: new Date().toISOString(), bootTime: bootTime(), uid: process.getuid?.() ?? -1, ready: false, ...contents };
162
+ for (let attempt = 0; attempt < 2; attempt++) {
163
+ try {
164
+ const handle = await open(file, 'wx', 0o600);
165
+ await handle.writeFile(JSON.stringify(lock));
166
+ await handle.close();
167
+ return lock;
168
+ } catch (error) {
169
+ if (error?.code !== 'EEXIST') throw error;
170
+ const current = await readLock(home);
171
+ 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})`);
172
+ // Remove the stale lock only if nobody replaced it meanwhile.
173
+ const again = await readFile(file, 'utf8').catch(() => undefined);
174
+ if (again === undefined || JSON.stringify(current) === again || !lockAlive(JSON.parse(again))) await unlink(file).catch(() => {});
175
+ }
176
+ }
177
+ throw new Error('Could not acquire the ALP daemon lock');
178
+ }
179
+
180
+ /** Lets go of alpd's lock if this process holds it. */
181
+ export async function releaseDaemonLock(home) {
182
+ const current = await readLock(home);
183
+ if (current?.pid === process.pid) await unlink(daemonPaths(home).lock).catch(() => {});
184
+ }
185
+
186
+ /**
187
+ * One JSON-RPC 2.0 connection to alpd over a Unix socket or named pipe; events arrive as `event`
188
+ * notifications. The engine is the portable AlpRpcClient; this keeps the socket constructor the
189
+ * CLI, alpd's tests and the Paseo plugin use.
190
+ */
191
+ export class AlpClient extends AlpRpcClient {
192
+ /** Host features captured by connect's handshake. @type {readonly string[]} */
193
+ capabilities = [];
194
+ /**
195
+ * @param {import('node:net').Socket} socket a connected or connecting socket; the client owns it
196
+ * @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
197
+ */
198
+ constructor(socket, { maxFrame, maxBufferedBytes, ...options } = {}) {
199
+ super(createSocketTransport(socket, { maxFrame, maxBufferedBytes }), { maxFrame, ...options });
200
+ }
201
+ }
202
+
203
+ /** A socket to alpd, once it is checked to be private to this user (the IPC checks above). @param {string} socketPath */
204
+ async function openSocket(socketPath) {
205
+ await assertPrivateSocket(socketPath);
206
+ return /** @type {Promise<import('node:net').Socket>} */ (new Promise((resolve, reject) => {
207
+ const candidate = net.createConnection(socketPath);
208
+ candidate.once('connect', () => { candidate.off('error', reject); resolve(candidate); });
209
+ candidate.once('error', reject);
210
+ }));
211
+ }
212
+
213
+ /**
214
+ * The transport to alpd's socket without the handshake, for an AlpRpcClient or a reconnecting
215
+ * client whose onConnect runs `hello`. `connect` is this plus `hello`.
216
+ * @param {string} socketPath
217
+ * @param {import('./ipc.js').SocketTransportOptions} [options]
218
+ */
219
+ export async function connectSocket(socketPath, options) {
220
+ return createSocketTransport(await openSocket(socketPath), options);
221
+ }
222
+
223
+ /**
224
+ * Connects and performs the version handshake.
225
+ * @param {string} socketPath
226
+ * @param {{ name: string, version: string }} [client]
227
+ * @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
228
+ * @returns {Promise<AlpClient>}
229
+ */
230
+ export async function connect(socketPath, client = { name: 'alp', version: '0' }, options = {}) {
231
+ const connection = new AlpClient(await openSocket(socketPath), options);
232
+ try {
233
+ const greeting = await hello(connection, client);
234
+ connection.capabilities = Object.freeze(Array.isArray(greeting.capabilities) ? greeting.capabilities.filter(value => typeof value === 'string') : []);
235
+ } catch (error) {
236
+ connection.close();
237
+ throw error;
238
+ }
239
+ return connection;
240
+ }
241
+
242
+ /** Why a launcher failed, from the last structured log line it wrote to stderr: code and message, scrubbed. */
243
+ function launcherReason(text) {
244
+ const line = text.trim().split('\n').filter(Boolean).at(-1);
245
+ if (!line) return '';
246
+ let reason = line;
247
+ try {
248
+ const record = JSON.parse(line);
249
+ reason = `${record.code ? `${record.code}, ` : ''}${record.msg}${record.error?.message ? `: ${record.error.message}` : ''}`;
250
+ } catch {}
251
+ return `: ${scrubText(reason, { max: 600 })}`;
252
+ }
253
+
254
+ async function tail(file, lines = 30) {
255
+ // The tail goes into an error message, so it is scrubbed like every other log output.
256
+ try { return scrubText((await readFile(file, 'utf8')).split('\n').slice(-lines).join('\n'), { max: 8000 }); } catch { return ''; }
257
+ }
258
+
259
+ /** Immutable admission identities live in filenames, including for service hard links. */
260
+ async function lifecycleOwners(home) {
261
+ const directory = path.join(home, 'state', 'alpd.starting');
262
+ const names = await readdir(directory).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
263
+ return names.map(token => {
264
+ const match = /^owner-(\d+)-(\d+)-(start|stop)-[a-f0-9-]+$/.exec(token);
265
+ if (!match) throw new Error('Invalid alpd startup admission owner');
266
+ return { token, pid: Number(match[1]), bootTime: Number(match[2]), action: match[3] };
267
+ });
268
+ }
269
+
270
+ export async function readDaemonLifecycle(home) {
271
+ return (await lifecycleOwners(home)).find(owner => lockAlive(owner));
272
+ }
273
+
274
+ /** Serializes requested starts and stops, including the time before alpd has a lock.
275
+ * Rename publishes a fully populated directory: an existing nonempty directory cannot
276
+ * be replaced. Reaping/release unlinks only its unique owner token, then rmdir; even
277
+ * racing stale reapers cannot remove a successor's nonempty directory.
278
+ */
279
+ export async function withDaemonLifecycle(home, action, run, { joinStart = false } = {}) {
280
+ try {
281
+ await assertPrivateDirectory(home, { create: true });
282
+ } catch (cause) {
283
+ // Admission now prepares the home before the launcher can report this failure.
284
+ // Preserve the operator-facing startup code at this earlier boundary.
285
+ throw Object.assign(new Error(`ALP_HOME_UNUSABLE, alpd failed to start while preparing its home: ${cause.message}`, { cause }), { code: 'ALP_HOME_UNUSABLE' });
286
+ }
287
+ const state = path.join(home, 'state');
288
+ await assertPrivateDirectory(state, { create: true });
289
+ const directory = path.join(state, 'alpd.starting');
290
+ const token = `owner-${process.pid}-${bootTime()}-${action}-${randomUUID()}`;
291
+ const candidate = path.join(state, `alpd.starting-${token}`);
292
+ await mkdir(candidate, { mode: 0o700 });
293
+ await writeFile(path.join(candidate, token), JSON.stringify({ pid: process.pid, bootTime: bootTime(), action }), { mode: 0o600 });
294
+ try {
295
+ let joined = false;
296
+ if (joinStart) {
297
+ const owner = await readDaemonLifecycle(home);
298
+ if (owner?.action === 'start') {
299
+ try {
300
+ // Linking the unique source token both validates this generation and
301
+ // pins its directory until this service has finished starting.
302
+ await link(path.join(directory, owner.token), path.join(directory, token));
303
+ joined = true;
304
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
305
+ }
306
+ }
307
+ while (!joined) {
308
+ try { await rename(candidate, directory); break; }
309
+ catch (error) {
310
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
311
+ for (const owner of await lifecycleOwners(home)) {
312
+ if (!lockAlive(owner)) await unlink(path.join(directory, owner.token)).catch(error => { if (error.code !== 'ENOENT') throw error; });
313
+ }
314
+ await rmdir(directory).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
315
+ await new Promise(resolve => setTimeout(resolve, 25));
316
+ }
317
+ }
318
+ try { return await run(); }
319
+ finally {
320
+ await unlink(path.join(directory, token));
321
+ await rmdir(directory).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
322
+ }
323
+ } finally {
324
+ await unlink(path.join(candidate, token)).catch(error => { if (error.code !== 'ENOENT') throw error; });
325
+ await rmdir(candidate).catch(error => { if (error.code !== 'ENOENT') throw error; });
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Returns the socket of a ready daemon, starting one when none runs.
331
+ * `entry` is the daemon script; it is started through its own detaching launcher so the
332
+ * daemon outlives the client that started it.
333
+ */
334
+ export async function ensureDaemon({ home = alpHome(), entry = /** @type {string | undefined} */ (undefined), env = process.env, execPath = process.execPath, timeoutMs = 15_000, lifecycleLocked = false } = {}) {
335
+ if (!lifecycleLocked) return withDaemonLifecycle(home, 'start', () => ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleLocked: true }));
336
+ const ready = async () => {
337
+ const lock = await readLock(home);
338
+ return lockAlive(lock) && lock.ready ? lock.socket : undefined;
339
+ };
340
+ const live = await ready();
341
+ if (live) return live;
342
+ const held = await readLock(home);
343
+ if (lockAlive(held) && held.maintenance) throw new Error(`ALP is busy: alp ${held.maintenance} runs (pid ${held.pid}); alpd starts again when it finishes`);
344
+ if (!lockAlive(held)) {
345
+ entry ??= await findDaemonEntry({ home, env });
346
+ 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');
347
+ await new Promise((resolve, reject) => {
348
+ const launcher = spawn(execPath, [entry, '--detach', `--startup-timeout=${timeoutMs}`], {
349
+ env: { ...env, ALP_HOME: home, ELECTRON_RUN_AS_NODE: '1' },
350
+ stdio: ['ignore', 'ignore', 'pipe'],
351
+ windowsHide: true,
352
+ });
353
+ // The launcher writes only when it fails, and before alpd has a log to write to.
354
+ let failure = '';
355
+ launcher.stderr.on('data', chunk => { if (failure.length < 8000) failure += chunk; });
356
+ launcher.once('error', reject);
357
+ launcher.once('close', code => code === 0 ? resolve() : reject(new Error(`ALP daemon launcher exited (${code})${launcherReason(failure)}`)));
358
+ });
359
+ }
360
+ const deadline = Date.now() + timeoutMs;
361
+ while (Date.now() < deadline) {
362
+ const socket = await ready();
363
+ if (socket) return socket;
364
+ await new Promise(resolve => setTimeout(resolve, 100));
365
+ }
366
+ const log = await tail(daemonPaths(home).log);
367
+ throw new Error(`ALP daemon did not become ready within ${timeoutMs} ms${log ? `\n${log}` : ''}`);
368
+ }
369
+
370
+ /** Registration descriptor field names, in priority order. */
371
+ const INTEGRATION_DESCRIPTOR_FIELDS = ['main', 'entry', 'path', 'command'];
372
+
373
+ async function readIntegrationDescriptor(file, baseDirs = []) {
374
+ try {
375
+ const raw = JSON.parse(await readFile(file, 'utf8'));
376
+ for (const field of INTEGRATION_DESCRIPTOR_FIELDS) {
377
+ const value = raw?.[field];
378
+ if (typeof value !== 'string' || !value.trim()) continue;
379
+ if (path.isAbsolute(value)) {
380
+ if (await isFile(value)) return value;
381
+ } else {
382
+ const bases = [path.dirname(file), ...baseDirs];
383
+ for (const base of bases) {
384
+ const resolved = path.resolve(base, value);
385
+ if (await isFile(resolved)) return resolved;
386
+ }
387
+ }
388
+ }
389
+ } catch {}
390
+ return undefined;
391
+ }
392
+
393
+ async function integrationCandidateFromHome(home, name) {
394
+ const descriptors = [
395
+ path.join(home, 'integrations', `${name}.json`),
396
+ path.join(home, 'integrations', name, 'integration.json'),
397
+ ];
398
+ for (const descriptor of descriptors) {
399
+ const entry = await readIntegrationDescriptor(descriptor, [home]);
400
+ if (entry) return entry;
401
+ }
402
+ // Legacy single-file registration.
403
+ const legacy = path.join(home, 'integrations', name, 'main.js');
404
+ return (await isFile(legacy)) ? legacy : undefined;
405
+ }
406
+
407
+ function integrationBinaryName(name) {
408
+ return `alp-${name}-integration`;
409
+ }
410
+
411
+ /** Distribution roots that may carry a bundled integration descriptor. */
412
+ async function distributionRoots() {
413
+ const roots = [];
414
+ const argv = process.argv[1] ? await realpath(process.argv[1]).catch(() => process.argv[1]) : undefined;
415
+ if (argv) {
416
+ // The CLI runs from <distribution>/packages/cli/dist, so the distribution root is a few levels
417
+ // up. Resolve the real path first so a symlinked global bin still finds its own root.
418
+ let directory = path.dirname(argv);
419
+ for (let depth = 0; depth < 4; depth += 1) { roots.push(path.resolve(directory, '..')); directory = path.resolve(directory, '..'); }
420
+ // Every package ancestor may carry a bundled descriptor (a full distribution, or the plugin
421
+ // package installed next to it).
422
+ for (let ancestor = path.dirname(argv); ; ancestor = path.dirname(ancestor)) {
423
+ if (await isFile(path.join(ancestor, 'package.json'))) roots.push(ancestor);
424
+ if (path.dirname(ancestor) === ancestor) break;
425
+ }
426
+ }
427
+ // Also consider the current working directory for full distributions.
428
+ roots.push(process.cwd());
429
+ return [...new Set(roots)];
430
+ }
431
+
432
+ /**
433
+ * The entry of integration `name`: an environment override, a descriptor under the home or a
434
+ * distribution root, or an `alp-<name>-integration` binary. Discovery never returns an entry for
435
+ * `web`: that name belongs to alp-web, which ships with the CLI and is started with its explicit
436
+ * entry. Everything once registered under `web` (the descriptor `integrations/web.json`, the
437
+ * `alp-web-integration` binary, `ALP_WEB_INTEGRATION`) is the Paseo integration from before its
438
+ * rename to `paseo`, which must never run under the web lock. `ALP_WEB_INTEGRATION` is read for
439
+ * `paseo` only, after `ALP_PASEO_INTEGRATION`, for one release (like the alp-web-integration alias).
440
+ * @param {string} [name]
441
+ * @param {{ home?: string }} [options]
442
+ */
443
+ export async function discoverIntegration(name = 'web', options = {}) {
444
+ // The name becomes a descriptor path under <home>/integrations, so it must stay one safe segment.
445
+ assertIntegrationName(name);
446
+ if (name === 'web') return undefined;
447
+ // Explicit environment override always wins: the integration's own, then the generic one.
448
+ const envCandidates = [
449
+ process.env[`ALP_${name.toUpperCase()}_INTEGRATION`],
450
+ name === 'paseo' ? process.env.ALP_WEB_INTEGRATION : undefined,
451
+ process.env.ALP_INTEGRATION_ENTRY,
452
+ ].filter(Boolean);
453
+ for (const candidate of envCandidates) if (await isFile(candidate)) return candidate;
454
+
455
+ const home = options?.home ? path.resolve(options.home) : alpHome();
456
+ const fromHome = await integrationCandidateFromHome(home, name);
457
+ if (fromHome) return fromHome;
458
+
459
+ // A full distribution may describe a bundled integration relative to its own root.
460
+ for (const root of await distributionRoots()) {
461
+ for (const descriptorPath of [path.join(root, 'integrations', `${name}.json`), path.join(root, 'integrations', name, 'integration.json')]) {
462
+ const entry = await readIntegrationDescriptor(descriptorPath, [root]);
463
+ if (entry) return entry;
464
+ }
465
+ }
466
+
467
+ // Installed integration packages announce themselves on PATH.
468
+ const binary = integrationBinaryName(name);
469
+ for (const directory of [...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean), ...BIN_DIRECTORIES]) {
470
+ const candidate = await realpath(path.join(directory, binary)).catch(() => undefined);
471
+ if (candidate && await isFile(candidate)) return candidate;
472
+ }
473
+ return undefined;
474
+ }
475
+
476
+ function integrationAlive(lock) {
477
+ if (!lock || !Number.isSafeInteger(lock.pid)) return false;
478
+ try { process.kill(lock.pid, 0); } catch (error) {
479
+ return error?.code === 'EPERM';
480
+ }
481
+ if (typeof lock.bootTime === 'number' && Math.abs(lock.bootTime - bootTime()) > 30) return false;
482
+ return true;
483
+ }
484
+
485
+ /**
486
+ * What startIntegration says about integration `name`, and where its service writes its log: alp-web
487
+ * (`web`) is part of the CLI, Paseo (`paseo`) an optional package, anything else named as is.
488
+ * @param {string} name
489
+ * @param {string} home
490
+ */
491
+ function integrationText(name, home) {
492
+ const logs = path.join(home, 'logs', 'integrations');
493
+ if (name === 'web') return {
494
+ what: 'alp web',
495
+ log: path.join(logs, 'alp-web.log'),
496
+ missing: 'alp web runs the alp-web service of the ALP CLI, which is started with its entry (packages/cli/dist/alp-web.js); run `alp web`',
497
+ };
498
+ if (name === 'paseo') return {
499
+ what: 'The Paseo integration',
500
+ log: path.join(logs, 'paseo.log'),
501
+ missing: 'The Paseo integration is an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`), then run the command again.',
502
+ };
503
+ return {
504
+ what: `ALP integration ${name}`,
505
+ log: path.join(logs, `${name}.log`),
506
+ missing: `ALP integration ${name} is not installed: no ALP_${name.toUpperCase()}_INTEGRATION, descriptor <home>/integrations/${name}.json or alp-${name}-integration binary was found.`,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Starts integration `name` (default `web`) detached with `--service` (then `args`) unless its lock
512
+ * already names a live owner, then waits until its ready lock and `<home>/<name>.json` name the same
513
+ * pid. `entry` names the script to run with this Node and skips descriptor, environment and PATH
514
+ * discovery; `web` (alp-web) is never discovered, so it needs its entry. `"web": { "enabled": false }`
515
+ * in the home's settings.json refuses `web` only.
516
+ * @param {string | { name?: string, entry?: string, home?: string, args?: string[] }} [nameOrOptions]
517
+ */
518
+ export async function startIntegration(nameOrOptions = 'web') {
519
+ const options = typeof nameOrOptions === 'string' ? { name: nameOrOptions } : (nameOrOptions ?? {});
520
+ const name = assertIntegrationName(options.name ?? 'web');
521
+ if (options.entry !== undefined && (typeof options.entry !== 'string' || !options.entry)) throw new TypeError('startIntegration entry must be a non-empty path');
522
+ const args = options.args ?? [];
523
+ if (!Array.isArray(args) || !args.every(arg => typeof arg === 'string')) throw new TypeError('startIntegration args must be a list of strings');
524
+ const fs = await import('node:fs/promises');
525
+ const pathMod = await import('node:path');
526
+ const home = options.home ? pathMod.resolve(options.home) : alpHome();
527
+ const text = integrationText(name, home);
528
+ // An explicit entry is the caller's own service script: it must exist even when a live owner is
529
+ // reused, and it replaces descriptor, environment and PATH discovery.
530
+ const explicit = options.entry !== undefined ? pathMod.resolve(options.entry) : undefined;
531
+ if (explicit !== undefined && !(await isFile(explicit))) throw new Error(`${text.what}: integration entry not found: ${explicit}`);
532
+ const lockPath = pathMod.join(home, 'integrations', `${name}.lock`);
533
+ const webPath = pathMod.join(home, `${name}.json`);
534
+ const started = explicit ? ` (entry ${explicit})` : '';
535
+
536
+ // web.enabled turns off alp web only; other integrations have their own switches.
537
+ if (name === 'web') {
538
+ let settings = {};
539
+ try { settings = JSON.parse(await fs.readFile(pathMod.join(home, 'settings.json'), 'utf8')); } catch {}
540
+ if (settings?.web?.enabled === false) throw new Error('alp web is disabled by settings ("web.enabled": false).');
541
+ }
542
+
543
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
544
+ const readLock = async () => {
545
+ try { return JSON.parse(await fs.readFile(lockPath, 'utf8')); } catch { return undefined; }
546
+ };
547
+ const readWeb = async () => {
548
+ try { return JSON.parse(await fs.readFile(webPath, 'utf8')); } catch { return undefined; }
549
+ };
550
+
551
+ // The authoritative check: the ready integration lock and live <name>.json must name the same pid.
552
+ // A loser of a concurrent start accepts the winning pid after its own contender has exited or
553
+ // the lock already names the winner.
554
+ async function waitForReady() {
555
+ const deadline = Date.now() + 30_000;
556
+ while (Date.now() < deadline) {
557
+ const lock = await readLock();
558
+ if (lock && integrationAlive(lock) && lock.ready) {
559
+ const info = await readWeb();
560
+ if (info && info.pid === lock.pid) return lock;
561
+ }
562
+ await sleep(100);
563
+ }
564
+ const lock = await readLock();
565
+ if (lock && !lock.ready) throw new Error(`${text.what}${started} started but did not become ready; see ${text.log}`);
566
+ if (!lock) throw new Error(explicit ? `${text.what}${started} did not start; see ${text.log}` : text.missing);
567
+ throw new Error(`${text.what}${started} did not publish a ${name}.json matching its lock; see ${text.log}`);
568
+ }
569
+
570
+ const existing = await readLock();
571
+ if (existing && integrationAlive(existing)) {
572
+ return waitForReady();
573
+ }
574
+
575
+ const entry = explicit ?? await discoverIntegration(name, { home });
576
+ if (!entry) throw new Error(text.missing);
577
+
578
+ const { spawn } = await import('node:child_process');
579
+ // The child is detached with its stdio ignored, so it cannot rely on the parent's console. `--service`
580
+ // is the generic contract: the entry writes its own log (the path its error messages advertise) and
581
+ // keeps the process alive as a service. `--register` still prints to stdout and is not passed here.
582
+ const child = spawn(process.execPath, [entry, '--service', ...args], { stdio: 'ignore', detached: true, env: { ...process.env, ALP_HOME: home } });
583
+ let spawnError = null;
584
+ child.on('error', error => { spawnError = error; });
585
+ let exited = false;
586
+ child.on('exit', code => { if (code !== 0 && code !== null) exited = true; });
587
+ child.unref();
588
+
589
+ if (spawnError) throw spawnError;
590
+ // Give the process a moment to fail immediately on missing entry or bad permissions.
591
+ await sleep(150);
592
+ if (spawnError) throw spawnError;
593
+ if (exited) throw new Error(`${text.what}${explicit ? started : ` (${entry})`} failed to start; see ${text.log}`);
594
+
595
+ return waitForReady();
596
+ }