@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,54 @@
1
+ /**
2
+ * Errors the client raises itself, as opposed to AlpRpcError, which carries an error alpd returned.
3
+ * Portable: no Node built-ins, process or Buffer (plans/reference/ALPD.md §3, docs/alp/client-sdk.md).
4
+ */
5
+
6
+ const BRAND = Symbol.for('@anhlp/alp-client/AlpClientError');
7
+
8
+ /**
9
+ * Why a request or a connection ended on the client side.
10
+ * - `closed`: this client was closed locally with close().
11
+ * - `disconnected`: the transport closed or failed. A request that was already sent may or may not
12
+ * have run on the daemon; the client never resends it.
13
+ * - `protocol`: the peer sent something that is not ALP JSON-RPC, so the client closed the connection.
14
+ * - `send_failed`: the transport refused the frame; the request never left this client.
15
+ * - `timeout` / `aborted`: the caller stopped waiting. Only the local wait ends; the daemon is not told.
16
+ * - `overloaded`: too many requests are waiting for an answer already; nothing was sent.
17
+ */
18
+ export const CLIENT_ERROR = Object.freeze({
19
+ closed: 'closed',
20
+ disconnected: 'disconnected',
21
+ protocol: 'protocol',
22
+ sendFailed: 'send_failed',
23
+ timeout: 'timeout',
24
+ aborted: 'aborted',
25
+ overloaded: 'overloaded',
26
+ });
27
+
28
+ export class AlpClientError extends Error {
29
+ /**
30
+ * @param {string} message
31
+ * @param {{ reason: 'closed' | 'disconnected' | 'protocol' | 'send_failed' | 'timeout' | 'aborted' | 'overloaded', cause?: unknown, data?: unknown }} details
32
+ */
33
+ constructor(message, { reason, cause, data }) {
34
+ super(message, cause === undefined ? undefined : { cause });
35
+ this.name = 'AlpClientError';
36
+ /** @type {'closed' | 'disconnected' | 'protocol' | 'send_failed' | 'timeout' | 'aborted' | 'overloaded'} */
37
+ this.reason = reason;
38
+ /** @type {unknown} */
39
+ this.data = data;
40
+ Object.defineProperty(this, BRAND, { value: true });
41
+ }
42
+ }
43
+
44
+ /**
45
+ * True for an AlpClientError, also one thrown by another copy of this module (a bundle may carry
46
+ * two); `reason` narrows the check.
47
+ * @param {unknown} error
48
+ * @param {string} [reason]
49
+ * @returns {error is AlpClientError}
50
+ */
51
+ export function isAlpClientError(error, reason) {
52
+ return typeof error === 'object' && error !== null && /** @type {any} */ (error)[BRAND] === true
53
+ && (reason === undefined || /** @type {any} */ (error).reason === reason);
54
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Newline-delimited JSON framing for stream transports (a Unix socket, a named pipe, a TCP
3
+ * stream). Portable: strings in, strings out; the stream owner decodes bytes to text first.
4
+ * Message transports such as a WebSocket carry whole frames and need none of this.
5
+ */
6
+
7
+ /** UTF-8 length of `text` without Buffer or TextEncoder; an unpaired surrogate counts as U+FFFD (3 bytes). @param {string} text */
8
+ export function utf8Length(text) {
9
+ let bytes = 0;
10
+ for (let index = 0; index < text.length; index++) {
11
+ const code = text.charCodeAt(index);
12
+ if (code < 0x80) bytes += 1;
13
+ else if (code < 0x800) bytes += 2;
14
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length && (text.charCodeAt(index + 1) & 0xfc00) === 0xdc00) { bytes += 4; index++; }
15
+ else bytes += 3;
16
+ }
17
+ return bytes;
18
+ }
19
+
20
+ /**
21
+ * Splits decoded text chunks into frames. `push` returns the complete frames in order, blank
22
+ * lines skipped, and throws once a frame (complete or still arriving) exceeds `maxFrame` bytes;
23
+ * the decoder is unusable after that. Each character is measured once, so a frame that arrives
24
+ * in many chunks costs linear time.
25
+ * @param {{ maxFrame: number }} options
26
+ * @returns {{ push(chunk: string): string[], readonly buffered: number }}
27
+ */
28
+ export function createLineDecoder({ maxFrame }) {
29
+ /** @type {string[]} */
30
+ let parts = [];
31
+ let bytes = 0;
32
+ let failed = false;
33
+ const overflow = () => { failed = true; parts = []; bytes = 0; return new RangeError(`Frame exceeds ${maxFrame} bytes`); };
34
+ return {
35
+ push(chunk) {
36
+ if (failed) throw new RangeError('Line decoder already failed');
37
+ /** @type {string[]} */
38
+ const frames = [];
39
+ let start = 0;
40
+ for (let index = chunk.indexOf('\n'); index >= 0; index = chunk.indexOf('\n', start)) {
41
+ const piece = chunk.slice(start, index);
42
+ bytes += utf8Length(piece);
43
+ if (bytes > maxFrame) throw overflow();
44
+ parts.push(piece);
45
+ const line = parts.length === 1 ? parts[0] : parts.join('');
46
+ parts = [];
47
+ bytes = 0;
48
+ start = index + 1;
49
+ if (line.trim()) frames.push(line);
50
+ }
51
+ if (start < chunk.length) {
52
+ const rest = chunk.slice(start);
53
+ bytes += utf8Length(rest);
54
+ if (bytes > maxFrame) throw overflow();
55
+ parts.push(rest);
56
+ }
57
+ return frames;
58
+ },
59
+ get buffered() { return bytes; },
60
+ };
61
+ }
@@ -0,0 +1,16 @@
1
+ import { PROTOCOL_VERSION } from '@anhlp/alp-protocol';
2
+
3
+ /** @typedef {{ name: string, version: string }} ClientInfo */
4
+
5
+ /**
6
+ * The version handshake every ALP connection starts with (plans/reference/ALPD.md §3): alpd
7
+ * answers nothing else before it. Shared by the Node `connect` and portable clients, so a change
8
+ * to the handshake (Task 06) lands in one place.
9
+ * @param {{ request(method: string, params?: unknown, options?: import('./client.js').RequestOptions): Promise<any> }} client
10
+ * @param {ClientInfo} [info]
11
+ * @param {import('./client.js').RequestOptions} [options]
12
+ * @returns {Promise<{ protocolVersion: number } & Record<string, unknown>>}
13
+ */
14
+ export function hello(client, info = { name: 'alp', version: '0' }, options) {
15
+ return client.request('daemon.hello', { protocolVersion: PROTOCOL_VERSION, client: info }, options);
16
+ }
@@ -0,0 +1,226 @@
1
+ import { AlpRpcError, ERROR } from '@anhlp/alp-protocol';
2
+ import { AlpRpcClient } from './client.js';
3
+ import { AlpClientError } from './errors.js';
4
+
5
+ /**
6
+ * Keeps an ALP connection up: connects, runs the caller's `onConnect` (handshake, reattach and
7
+ * replay as the protocol defines them), and after a disconnect connects again with exponential
8
+ * backoff until stopped. A connection counts as up only once it has stayed open `stableMs`; one
9
+ * that drops sooner (a gateway closing right after the handshake) counts as a failed attempt, so
10
+ * backoff and `maxAttempts` still apply. It never resends a request: requests in flight when the connection drops
11
+ * reject as `disconnected`, and requests made while it is down reject at once. Whether and how to
12
+ * retry belongs to the caller, who knows which methods are safe to repeat.
13
+ */
14
+
15
+ /**
16
+ * @typedef {import('./client.js').AlpTransport} AlpTransport
17
+ * @typedef {import('./client.js').AlpRpcClientOptions} AlpRpcClientOptions
18
+ * @typedef {import('./client.js').RequestOptions} RequestOptions
19
+ * @typedef {import('./client.js').AbortSignalLike} AbortSignalLike
20
+ * @typedef {'connecting' | 'open' | 'waiting' | 'stopped'} ReconnectState
21
+ * @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitter?: number }} Backoff
22
+ * @typedef {{
23
+ * connect(context: { attempt: number, signal: AbortSignalLike }): Promise<AlpTransport>,
24
+ * onConnect?(client: AlpRpcClient, context: { attempt: number, reconnect: boolean, signal: AbortSignalLike }): unknown,
25
+ * onDisconnect?(error: Error, context: { reconnect: boolean }): void,
26
+ * backoff?: Backoff,
27
+ * maxAttempts?: number,
28
+ * stableMs?: number,
29
+ * shouldRetry?(error: Error): boolean,
30
+ * client?: AlpRpcClientOptions,
31
+ * random?: () => number,
32
+ * }} ReconnectOptions
33
+ * @typedef {{
34
+ * readonly state: ReconnectState,
35
+ * readonly client: AlpRpcClient | undefined,
36
+ * readonly attempt: number,
37
+ * ready(options?: { signal?: AbortSignalLike }): Promise<AlpRpcClient>,
38
+ * request<T = any>(method: string, params?: unknown, options?: RequestOptions): Promise<T>,
39
+ * onEvent(listener: (envelope: any) => void): () => boolean,
40
+ * onNotification(listener: (method: string, params: any) => void): () => boolean,
41
+ * onState(listener: (state: ReconnectState, error?: Error) => void): () => boolean,
42
+ * stop(): void,
43
+ * }} ReconnectingClient
44
+ */
45
+
46
+ /** The delay before attempt `attempt` (1-based), with ±jitter. @param {number} attempt @param {Backoff} backoff @param {() => number} random */
47
+ export function backoffDelay(attempt, { initialMs = 250, maxMs = 10_000, factor = 2, jitter = 0.2 } = {}, random = Math.random) {
48
+ const base = Math.min(maxMs, initialMs * factor ** Math.max(0, attempt - 1));
49
+ return Math.max(0, Math.round(base * (1 + jitter * (2 * random() - 1))));
50
+ }
51
+
52
+ /**
53
+ * Whether a failed connect or onConnect is worth another attempt: everything but alpd refusing the
54
+ * handshake (ERROR.protocol, an unsupported protocol version), which retrying cannot fix.
55
+ * @param {Error} error
56
+ */
57
+ export function retryable(error) {
58
+ return !(error instanceof AlpRpcError && error.code === ERROR.protocol);
59
+ }
60
+
61
+ /** The platform's AbortController, whose signal fetch and AbortSignal.any accept; a minimal one where there is none. */
62
+ function controller() {
63
+ if (typeof globalThis.AbortController === 'function') {
64
+ const native = new globalThis.AbortController();
65
+ return { signal: /** @type {AbortSignalLike} */ (native.signal), /** @param {unknown} reason */ abort: reason => native.abort(reason) };
66
+ }
67
+ /** @type {Set<() => void>} */
68
+ const listeners = new Set();
69
+ const signal = {
70
+ aborted: false,
71
+ /** @type {unknown} */ reason: undefined,
72
+ /** @param {'abort'} _type @param {() => void} listener */
73
+ addEventListener(_type, listener) { listeners.add(listener); },
74
+ /** @param {'abort'} _type @param {() => void} listener */
75
+ removeEventListener(_type, listener) { listeners.delete(listener); },
76
+ };
77
+ return {
78
+ signal,
79
+ /** @param {unknown} reason */
80
+ abort(reason) {
81
+ if (signal.aborted) return;
82
+ signal.aborted = true;
83
+ signal.reason = reason;
84
+ for (const listener of [...listeners]) listener();
85
+ listeners.clear();
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * @param {ReconnectOptions} options
92
+ * @returns {ReconnectingClient}
93
+ */
94
+ export function createReconnectingClient({ connect, onConnect, onDisconnect, backoff = {}, maxAttempts = Infinity, stableMs = 5_000, shouldRetry = retryable, client: clientOptions, random = Math.random }) {
95
+ if (typeof connect !== 'function') throw new TypeError('createReconnectingClient needs connect()');
96
+ /** @type {ReconnectState} */
97
+ let state = 'connecting';
98
+ /** @type {AlpRpcClient | undefined} */
99
+ let current;
100
+ let attempt = 0;
101
+ let connectedBefore = false;
102
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
103
+ let timer;
104
+ // One run for the client's whole life: stop() is final and aborts it.
105
+ const run = controller();
106
+ /** @type {Set<(envelope: any) => void>} */
107
+ const events = new Set();
108
+ /** @type {Set<(method: string, params: any) => void>} */
109
+ const notifications = new Set();
110
+ /** @type {Set<(state: ReconnectState, error?: Error) => void>} */
111
+ const states = new Set();
112
+ /** @type {Set<{ resolve(client: AlpRpcClient): void, reject(error: Error): void }>} */
113
+ const waiters = new Set();
114
+
115
+ const onListenerError = clientOptions?.onListenerError ?? (error => { setTimeout(() => { throw error; }, 0); });
116
+ /** @param {Function} listener @param {unknown[]} args */
117
+ const dispatch = (listener, ...args) => { try { listener(...args); } catch (error) { onListenerError(error); } };
118
+
119
+ /** @param {ReconnectState} next @param {Error} [error] */
120
+ const enter = (next, error) => {
121
+ state = next;
122
+ for (const listener of [...states]) {
123
+ dispatch(listener, next, error);
124
+ // A listener that called stop() has moved the state on; later ones must not hear a stale one.
125
+ if (state !== next) return;
126
+ }
127
+ if (next === 'open' && current) for (const waiter of [...waiters]) { waiters.delete(waiter); waiter.resolve(current); }
128
+ if (next === 'stopped') for (const waiter of [...waiters]) { waiters.delete(waiter); waiter.reject(error ?? new AlpClientError('ALP connection stopped', { reason: 'closed' })); }
129
+ };
130
+
131
+ /** @param {Error} error */
132
+ const retry = error => {
133
+ if (state === 'stopped') return;
134
+ if (attempt >= maxAttempts || !shouldRetry(error)) return enter('stopped', error);
135
+ enter('waiting', error);
136
+ if (state !== 'waiting') return;
137
+ timer = setTimeout(() => { timer = undefined; void open(); }, backoffDelay(attempt, backoff, random));
138
+ };
139
+
140
+ const open = async () => {
141
+ if (state === 'stopped') return;
142
+ attempt++;
143
+ enter('connecting');
144
+ const { signal } = run;
145
+ if (state !== 'connecting' || signal.aborted) return;
146
+ /** @type {AlpRpcClient | undefined} */
147
+ let candidate;
148
+ // stop() during the handshake closes the connection being set up: its pending requests reject
149
+ // and nothing more is sent on it.
150
+ const closeCandidate = () => candidate?.close();
151
+ signal.addEventListener('abort', closeCandidate, { once: true });
152
+ try {
153
+ const transport = await connect({ attempt, signal });
154
+ if (signal.aborted) { try { transport.close(); } catch {} return; }
155
+ candidate = new AlpRpcClient(transport, clientOptions);
156
+ // Listeners go on before onConnect so a replay it requests reaches them.
157
+ // Each caller listener runs on its own, as on AlpRpcClient: one that throws does not stop the rest.
158
+ candidate.onEvent(envelope => { for (const listener of [...events]) dispatch(listener, envelope); });
159
+ candidate.onNotification((method, params) => { for (const listener of [...notifications]) dispatch(listener, method, params); });
160
+ if (onConnect) await onConnect(candidate, { attempt, reconnect: connectedBefore, signal });
161
+ if (signal.aborted) { candidate.close(); return; }
162
+ if (candidate.closed) throw new AlpClientError('ALP connection closed during onConnect', { reason: 'disconnected' });
163
+ } catch (error) {
164
+ candidate?.close();
165
+ if (signal.aborted) return;
166
+ return retry(error instanceof Error ? error : new Error(String(error)));
167
+ } finally {
168
+ signal.removeEventListener('abort', closeCandidate);
169
+ }
170
+ const connected = /** @type {AlpRpcClient} */ (candidate);
171
+ current = connected;
172
+ const reconnect = connectedBefore;
173
+ connectedBefore = true;
174
+ const openedAt = Date.now();
175
+ connected.onClose(error => {
176
+ if (current !== connected) return;
177
+ current = undefined;
178
+ // Only a connection that stayed up starts the next one from a fresh backoff.
179
+ if (Date.now() - openedAt >= stableMs) attempt = 0;
180
+ dispatch(() => onDisconnect?.(error, { reconnect }));
181
+ retry(error);
182
+ });
183
+ enter('open');
184
+ };
185
+
186
+ void open();
187
+
188
+ return {
189
+ get state() { return state; },
190
+ get client() { return current; },
191
+ get attempt() { return attempt; },
192
+ ready({ signal } = {}) {
193
+ if (state === 'open' && current) return Promise.resolve(current);
194
+ if (state === 'stopped') return Promise.reject(new AlpClientError('ALP connection stopped', { reason: 'closed' }));
195
+ return new Promise((resolve, reject) => {
196
+ const waiter = {
197
+ /** @param {AlpRpcClient} client */ resolve: client => { signal?.removeEventListener('abort', onAbort); resolve(client); },
198
+ /** @param {Error} error */ reject: error => { signal?.removeEventListener('abort', onAbort); reject(error); },
199
+ };
200
+ const onAbort = () => { waiters.delete(waiter); reject(new AlpClientError('Waiting for the ALP connection was aborted', { reason: 'aborted', cause: signal?.reason })); };
201
+ if (signal?.aborted) return onAbort();
202
+ waiters.add(waiter);
203
+ signal?.addEventListener('abort', onAbort, { once: true });
204
+ });
205
+ },
206
+ request(method, params, options) {
207
+ if (state !== 'open' || !current) return Promise.reject(new AlpClientError('ALP daemon connection closed', { reason: state === 'stopped' ? 'closed' : 'disconnected' }));
208
+ return current.request(method, params, options);
209
+ },
210
+ onEvent(listener) { events.add(listener); return () => events.delete(listener); },
211
+ onNotification(listener) { notifications.add(listener); return () => notifications.delete(listener); },
212
+ onState(listener) { states.add(listener); return () => states.delete(listener); },
213
+ stop() {
214
+ if (state === 'stopped') return;
215
+ if (timer !== undefined) { clearTimeout(timer); timer = undefined; }
216
+ run.abort(new AlpClientError('ALP connection stopped', { reason: 'closed' }));
217
+ const last = current;
218
+ current = undefined;
219
+ enter('stopped');
220
+ last?.close();
221
+ events.clear();
222
+ notifications.clear();
223
+ states.clear();
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,16 @@
1
+ /** The longest delay setTimeout honours; a longer one fires at once, in browsers, Node and Hermes. */
2
+ export const MAX_TIMER_MS = 2_147_483_647;
3
+
4
+ /**
5
+ * A timeout option as a setTimeout delay. `undefined` means no timer: no timeout given, Infinity,
6
+ * or a wait longer than any timer can (about 24.8 days). Anything that is not a non-negative
7
+ * number is a TypeError, so a typo cannot turn into an instant timeout.
8
+ * @param {unknown} value
9
+ * @param {string} [name]
10
+ * @returns {number | undefined}
11
+ */
12
+ export function timerDelay(value, name = 'timeoutMs') {
13
+ if (value === undefined || value === Infinity) return undefined;
14
+ if (typeof value !== 'number' || Number.isNaN(value) || value < 0) throw new TypeError(`${name} must be a non-negative number of milliseconds`);
15
+ return value > MAX_TIMER_MS ? undefined : value;
16
+ }
package/src/service.js ADDED
@@ -0,0 +1,154 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { runCommand } from './command.js';
6
+
7
+ /**
8
+ * alpd as a user service (ALPD §37): a LaunchAgent on macOS, a systemd user unit on
9
+ * Linux. The service manager starts alpd at login and starts it again after a crash;
10
+ * a clean stop (`alp daemon stop`, exit 0) stays stopped until `alp daemon start`.
11
+ * Paths can be redirected for tests: ALP_SERVICE_DIR for the definition's directory,
12
+ * ALP_LAUNCHCTL and ALP_SYSTEMCTL for the managers.
13
+ */
14
+
15
+ /** Variables alpd reads, carried into the service when the installing shell sets them. */
16
+ const CARRIED = ['ALP_RUN_LOG_DIR', 'ALP_CODEX_BIN', 'ALP_CLAUDE_BIN', 'ALP_ANTIGRAVITY_BIN', 'ALP_GH_BIN'];
17
+
18
+ const escapeXml = value => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
19
+ const quoteUnit = value => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
20
+
21
+ /**
22
+ * Where and how alpd for `home` is defined as a service on `platform`, or undefined
23
+ * where ALP has no service support. Each ALP_HOME gets its own service.
24
+ */
25
+ export function serviceFor({ home, env = process.env, platform = process.platform, uid = process.getuid?.() ?? 0 }) {
26
+ const custom = path.resolve(home) !== path.join(os.homedir(), '.alp');
27
+ const suffix = custom ? createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, 8) : '';
28
+ if (platform === 'darwin') {
29
+ const label = `com.alp.alpd${suffix ? `.${suffix}` : ''}`;
30
+ const directory = env.ALP_SERVICE_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
31
+ const launchctl = env.ALP_LAUNCHCTL || 'launchctl';
32
+ const target = `gui/${uid}/${label}`;
33
+ const file = path.join(directory, `${label}.plist`);
34
+ return {
35
+ kind: 'launchd', name: label, file,
36
+ install: [[launchctl, ['bootout', target], { ignore: true }], [launchctl, ['bootstrap', `gui/${uid}`, file]]],
37
+ start: [[launchctl, ['kickstart', target]]],
38
+ uninstall: [[launchctl, ['bootout', target], { ignore: true }]],
39
+ };
40
+ }
41
+ if (platform === 'linux') {
42
+ const name = `alpd${suffix ? `-${suffix}` : ''}`;
43
+ const directory = env.ALP_SERVICE_DIR || path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
44
+ const systemctl = env.ALP_SYSTEMCTL || 'systemctl';
45
+ return {
46
+ kind: 'systemd', name, file: path.join(directory, `${name}.service`),
47
+ install: [[systemctl, ['--user', 'daemon-reload']], [systemctl, ['--user', 'enable', '--now', `${name}.service`]]],
48
+ start: [[systemctl, ['--user', 'start', `${name}.service`]]],
49
+ uninstall: [[systemctl, ['--user', 'disable', '--now', `${name}.service`], { ignore: true }]],
50
+ afterUninstall: [[systemctl, ['--user', 'daemon-reload'], { ignore: true }]],
51
+ };
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** The service definition that runs `entry` with `execPath` for `home`. */
57
+ export function serviceDefinition(service, { home, entry, execPath = process.execPath, env = process.env }) {
58
+ const variables = { ALP_HOME: path.resolve(home), PATH: env.PATH ?? '' };
59
+ for (const name of CARRIED) if (env[name]) variables[name] = env[name];
60
+ const program = [execPath, entry, '--service'];
61
+ const fatal = path.join(path.resolve(home), 'logs', 'alpd.service.log');
62
+ if (service.kind === 'launchd') {
63
+ return `<?xml version="1.0" encoding="UTF-8"?>
64
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
65
+ <plist version="1.0">
66
+ <dict>
67
+ <key>Label</key>
68
+ <string>${escapeXml(service.name)}</string>
69
+ <key>ProgramArguments</key>
70
+ <array>
71
+ ${program.map(arg => ` <string>${escapeXml(arg)}</string>`).join('\n')}
72
+ </array>
73
+ <key>EnvironmentVariables</key>
74
+ <dict>
75
+ ${Object.entries(variables).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}
76
+ </dict>
77
+ <key>RunAtLoad</key>
78
+ <true/>
79
+ <key>KeepAlive</key>
80
+ <dict>
81
+ <key>Crashed</key>
82
+ <true/>
83
+ <key>SuccessfulExit</key>
84
+ <false/>
85
+ </dict>
86
+ <key>ThrottleInterval</key>
87
+ <integer>10</integer>
88
+ <key>ProcessType</key>
89
+ <string>Background</string>
90
+ <key>StandardOutPath</key>
91
+ <string>${escapeXml(fatal)}</string>
92
+ <key>StandardErrorPath</key>
93
+ <string>${escapeXml(fatal)}</string>
94
+ </dict>
95
+ </plist>
96
+ `;
97
+ }
98
+ return `[Unit]
99
+ Description=ALP daemon (alpd) for ${path.resolve(home)}
100
+
101
+ [Service]
102
+ ExecStart=${program.map(quoteUnit).join(' ')}
103
+ ${Object.entries(variables).map(([key, value]) => `Environment=${quoteUnit(`${key}=${value}`)}`).join('\n')}
104
+ Restart=on-failure
105
+ RestartSec=10
106
+ StandardOutput=append:${fatal}
107
+ StandardError=append:${fatal}
108
+
109
+ [Install]
110
+ WantedBy=default.target
111
+ `;
112
+ }
113
+
114
+ /** The program an installed definition runs: [node, alpd.js], or undefined when it is not installed. */
115
+ export async function installedProgram(service) {
116
+ const text = await readFile(service.file, 'utf8').catch(() => undefined);
117
+ if (text === undefined) return undefined;
118
+ if (service.kind === 'launchd') {
119
+ const array = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] ?? '';
120
+ const unescape = value => value.replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
121
+ return [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map(match => unescape(match[1])).slice(0, 2);
122
+ }
123
+ const line = text.match(/^ExecStart=(.*)$/m)?.[1] ?? '';
124
+ return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(match => match[1].replace(/\\(.)/g, '$1')).slice(0, 2);
125
+ }
126
+
127
+ async function runAll(steps, run) {
128
+ for (const [command, args, { ignore = false } = {}] of steps) {
129
+ const result = await run(command, args);
130
+ if (result.code !== 0 && !ignore) throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
131
+ }
132
+ }
133
+
134
+ /** Writes the definition and loads it, which starts alpd. */
135
+ export async function installService(service, options, run = runCommand) {
136
+ await mkdir(path.dirname(service.file), { recursive: true });
137
+ await mkdir(path.join(path.resolve(options.home), 'logs'), { recursive: true, mode: 0o700 });
138
+ await writeFile(service.file, serviceDefinition(service, options), { mode: 0o644 });
139
+ await runAll(service.install, run);
140
+ }
141
+
142
+ /** Starts an installed service's alpd. */
143
+ export async function startService(service, run = runCommand) {
144
+ await runAll(service.start, run);
145
+ }
146
+
147
+ /** Unloads the service, which stops its alpd, and removes the definition; false when none was installed. */
148
+ export async function uninstallService(service, run = runCommand) {
149
+ if (await installedProgram(service) === undefined) return false;
150
+ await runAll(service.uninstall, run);
151
+ await unlink(service.file).catch(error => { if (error.code !== 'ENOENT') throw error; });
152
+ await runAll(service.afterUninstall ?? [], run);
153
+ return true;
154
+ }