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