@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.
- package/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +28 -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 +33 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.js +587 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +533 -0
- package/dist/integration-lock.d.ts +35 -0
- package/dist/integration-lock.js +179 -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 +19 -0
- package/dist/portable.js +33 -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/registry.d.ts +211 -0
- package/dist/rpc/registry.js +145 -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 +14 -0
- package/dist/supervise.js +87 -0
- package/dist/websocket.d.ts +51 -0
- package/dist/websocket.js +190 -0
- package/package.json +174 -4
- 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 +596 -0
- package/src/integration-lock.js +179 -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 +28 -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/registry.js +161 -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 +14 -0
- package/src/supervise.js +92 -0
- 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
|
+
}
|