jeopi-cli 16.6.13 → 16.6.17
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/CHANGELOG.md +30 -0
- package/README.md +3 -1
- package/dist/cli.js +4158 -4105
- package/dist/types/cli/messenger-cli.d.ts +14 -3
- package/dist/types/commands/messenger.d.ts +21 -1
- package/dist/types/messenger/discord-setup.d.ts +48 -0
- package/dist/types/messenger/discord.d.ts +3 -0
- package/dist/types/messenger/gateway.d.ts +7 -0
- package/dist/types/messenger/service.d.ts +19 -0
- package/dist/types/messenger/session-pool.d.ts +38 -0
- package/dist/types/messenger/session-worker.d.ts +49 -0
- package/dist/types/messenger/types.d.ts +4 -0
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +99 -26
- package/src/cli.ts +13 -0
- package/src/commands/messenger.ts +26 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/messenger/discord-setup.ts +563 -0
- package/src/messenger/discord.ts +213 -54
- package/src/messenger/gateway.ts +170 -91
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +366 -0
- package/src/messenger/session-worker.ts +79 -0
- package/src/messenger/session.ts +5 -4
- package/src/messenger/types.ts +4 -0
package/src/messenger/gateway.ts
CHANGED
|
@@ -141,24 +141,27 @@ function createActivityReporter(
|
|
|
141
141
|
closing ??= (async () => {
|
|
142
142
|
closed = true;
|
|
143
143
|
wake?.();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} catch (error) {
|
|
151
|
-
const wait =
|
|
152
|
-
error instanceof MessengerHttpError && error.status === 429
|
|
153
|
-
? (error.retryAfterMs ?? 1_000)
|
|
154
|
-
: 1_000;
|
|
155
|
-
// One bounded retry: removal holds the request queue.
|
|
156
|
-
if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
|
|
157
|
-
logger.warn("Messenger activity removal failed", { platform: destination.platform });
|
|
144
|
+
try {
|
|
145
|
+
await pump;
|
|
146
|
+
if (!shown) return;
|
|
147
|
+
for (let attempt = 0; ; attempt++) {
|
|
148
|
+
try {
|
|
149
|
+
await update(undefined);
|
|
158
150
|
return;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const wait =
|
|
153
|
+
error instanceof MessengerHttpError && error.status === 429
|
|
154
|
+
? (error.retryAfterMs ?? 1_000)
|
|
155
|
+
: 1_000;
|
|
156
|
+
if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
|
|
157
|
+
logger.warn("Messenger activity removal failed", { platform: destination.platform });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
await Bun.sleep(wait);
|
|
159
161
|
}
|
|
160
|
-
await Bun.sleep(wait);
|
|
161
162
|
}
|
|
163
|
+
} finally {
|
|
164
|
+
adapter.releaseActivity?.(destination);
|
|
162
165
|
}
|
|
163
166
|
})();
|
|
164
167
|
return closing;
|
|
@@ -177,6 +180,28 @@ export function messengerConversationKey(message: MessengerMessage): string {
|
|
|
177
180
|
]);
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
/** Stable per-conversation route shared by the persisted session file and its session process. */
|
|
184
|
+
export function messengerSessionRoute(cwd: string, message: MessengerMessage): string {
|
|
185
|
+
return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Discord creates a thread with the original message's ID. Reserve that conversation key before
|
|
190
|
+
* preparing it so follow-ups cannot overtake the request while thread creation is in flight.
|
|
191
|
+
*/
|
|
192
|
+
function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
193
|
+
if (
|
|
194
|
+
message.platform === "discord" &&
|
|
195
|
+
adapter.prepare &&
|
|
196
|
+
!message.direct &&
|
|
197
|
+
!message.threadId &&
|
|
198
|
+
!message.parentChannelId
|
|
199
|
+
) {
|
|
200
|
+
return messengerConversationKey({ ...message, threadId: message.messageId });
|
|
201
|
+
}
|
|
202
|
+
return messengerConversationKey(message);
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
|
|
181
206
|
const platform = config.platforms[message.platform];
|
|
182
207
|
return Boolean(
|
|
@@ -201,6 +226,8 @@ interface PendingMessage {
|
|
|
201
226
|
adapter: MessengerAdapter;
|
|
202
227
|
signal: AbortSignal;
|
|
203
228
|
resolve: () => void;
|
|
229
|
+
/** Requests sharing a key never run at the same time. */
|
|
230
|
+
key: string;
|
|
204
231
|
}
|
|
205
232
|
|
|
206
233
|
export interface MessengerGateway {
|
|
@@ -211,6 +238,11 @@ export interface MessengerGateway {
|
|
|
211
238
|
export interface MessengerGatewayOptions {
|
|
212
239
|
/** Minimum spacing between in-place activity edits for one request. */
|
|
213
240
|
activityIntervalMs?: number;
|
|
241
|
+
/**
|
|
242
|
+
* Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
|
|
243
|
+
* order; above 1 the reply implementation must accept concurrent turns for different conversations.
|
|
244
|
+
*/
|
|
245
|
+
concurrency?: number;
|
|
214
246
|
}
|
|
215
247
|
|
|
216
248
|
export function createMessengerGateway(
|
|
@@ -220,6 +252,7 @@ export function createMessengerGateway(
|
|
|
220
252
|
options: MessengerGatewayOptions = {},
|
|
221
253
|
): MessengerGateway {
|
|
222
254
|
const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
|
|
255
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 1));
|
|
223
256
|
const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
|
|
224
257
|
if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
|
|
225
258
|
for (const platform of Object.keys(config.platforms)) {
|
|
@@ -228,90 +261,139 @@ export function createMessengerGateway(
|
|
|
228
261
|
}
|
|
229
262
|
const seen = new Set<string>();
|
|
230
263
|
const queue: PendingMessage[] = [];
|
|
264
|
+
const activeKeys = new Set<string>();
|
|
265
|
+
const turns = new Set<Promise<void>>();
|
|
266
|
+
/** Settles when the latest request of a prepared conversation has delivered its reply. */
|
|
267
|
+
const conversationTails = new Map<string, Promise<void>>();
|
|
231
268
|
const lifetime = new AbortController();
|
|
232
|
-
let draining: Promise<void> | undefined;
|
|
233
|
-
let workerActive = false;
|
|
234
269
|
let running = false;
|
|
235
270
|
let fatalError: MessengerFatalError | undefined;
|
|
236
271
|
let statusInFlight = 0;
|
|
237
272
|
|
|
238
|
-
async function
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
},
|
|
261
|
-
() => {
|
|
262
|
-
statusInFlight--;
|
|
263
|
-
},
|
|
264
|
-
);
|
|
265
|
-
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
266
|
-
try {
|
|
267
|
-
await Promise.race([operation, stopped.promise]);
|
|
268
|
-
} catch {
|
|
269
|
-
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
270
|
-
} finally {
|
|
271
|
-
clearTimeout(timer);
|
|
272
|
-
statusSignal.removeEventListener("abort", onAbort);
|
|
273
|
-
}
|
|
273
|
+
async function processRequest(pending: PendingMessage): Promise<void> {
|
|
274
|
+
const { message, adapter, signal, resolve } = pending;
|
|
275
|
+
let failed = false;
|
|
276
|
+
let activity = NO_ACTIVITY;
|
|
277
|
+
let release = (): void => {};
|
|
278
|
+
let tail: Promise<void> | undefined;
|
|
279
|
+
let conversation = "";
|
|
280
|
+
let statusChain = Promise.resolve();
|
|
281
|
+
let processingStarted = false;
|
|
282
|
+
const status = (state: "processing" | "success" | "failure"): void => {
|
|
283
|
+
if (!adapter.status) return;
|
|
284
|
+
statusChain = statusChain.then(async () => {
|
|
285
|
+
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
286
|
+
const timeout = new AbortController();
|
|
287
|
+
const statusSignal = AbortSignal.any([signal, timeout.signal]);
|
|
288
|
+
const stopped = Promise.withResolvers<void>();
|
|
289
|
+
const onAbort = (): void => stopped.resolve();
|
|
290
|
+
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
291
|
+
statusInFlight++;
|
|
292
|
+
const operation = Promise.resolve().then(() => {
|
|
293
|
+
if (state === "processing") processingStarted = true;
|
|
294
|
+
return adapter.status!(message, state, statusSignal);
|
|
274
295
|
});
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
296
|
+
void operation.then(
|
|
297
|
+
() => {
|
|
298
|
+
statusInFlight--;
|
|
299
|
+
},
|
|
300
|
+
() => {
|
|
301
|
+
statusInFlight--;
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
282
305
|
try {
|
|
283
|
-
|
|
284
|
-
} catch
|
|
285
|
-
failed
|
|
286
|
-
if (error instanceof MessengerFatalError) {
|
|
287
|
-
fatalError = error;
|
|
288
|
-
status("failure");
|
|
289
|
-
lifetime.abort();
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
if (signal.aborted) continue;
|
|
293
|
-
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
294
|
-
text = "The request could not be completed. Please try again.";
|
|
306
|
+
await Promise.race([operation, stopped.promise]);
|
|
307
|
+
} catch {
|
|
308
|
+
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
295
309
|
} finally {
|
|
296
|
-
|
|
297
|
-
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
statusSignal.removeEventListener("abort", onAbort);
|
|
298
312
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
try {
|
|
316
|
+
if (signal.aborted) return;
|
|
317
|
+
status("processing");
|
|
318
|
+
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
319
|
+
// Answer and deliver in arrival order per prepared conversation, so a follow-up in a new thread can
|
|
320
|
+
// neither overtake nor interleave with the reply of the request that created it.
|
|
321
|
+
conversation = messengerConversationKey(destination);
|
|
322
|
+
const previous = conversationTails.get(conversation);
|
|
323
|
+
const done = Promise.withResolvers<void>();
|
|
324
|
+
release = done.resolve;
|
|
325
|
+
tail = done.promise;
|
|
326
|
+
conversationTails.set(conversation, tail);
|
|
327
|
+
await previous;
|
|
328
|
+
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
329
|
+
let text: string;
|
|
330
|
+
try {
|
|
331
|
+
text = await reply(destination, signal, activity.report);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
failed = true;
|
|
334
|
+
if (error instanceof MessengerFatalError) {
|
|
335
|
+
fatalError = error;
|
|
307
336
|
status("failure");
|
|
337
|
+
lifetime.abort();
|
|
338
|
+
return;
|
|
308
339
|
}
|
|
340
|
+
if (signal.aborted) return;
|
|
341
|
+
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
342
|
+
text = "The request could not be completed. Please try again.";
|
|
309
343
|
} finally {
|
|
310
|
-
|
|
311
|
-
|
|
344
|
+
// Retire the status line alongside reply delivery rather than delaying the reply on it.
|
|
345
|
+
void activity.close();
|
|
346
|
+
}
|
|
347
|
+
if (!signal.aborted && text.trim()) {
|
|
348
|
+
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
349
|
+
await adapter.send(destination, text, signal);
|
|
312
350
|
}
|
|
351
|
+
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
352
|
+
} catch {
|
|
353
|
+
if (!signal.aborted) {
|
|
354
|
+
logger.warn("Messenger reply delivery failed", { platform: message.platform });
|
|
355
|
+
status("failure");
|
|
356
|
+
}
|
|
357
|
+
} finally {
|
|
358
|
+
await activity.close();
|
|
359
|
+
if (signal.aborted && processingStarted && adapter.clearStatus && statusInFlight < MAX_STATUS_IN_FLIGHT) {
|
|
360
|
+
statusInFlight++;
|
|
361
|
+
const operation = Promise.resolve().then(() =>
|
|
362
|
+
adapter.clearStatus!(message, AbortSignal.timeout(STATUS_TIMEOUT_MS)),
|
|
363
|
+
);
|
|
364
|
+
void operation.then(
|
|
365
|
+
() => statusInFlight--,
|
|
366
|
+
() => statusInFlight--,
|
|
367
|
+
);
|
|
368
|
+
try {
|
|
369
|
+
await Promise.race([operation, Bun.sleep(STATUS_TIMEOUT_MS)]);
|
|
370
|
+
} catch {
|
|
371
|
+
logger.warn("Messenger cancelled status removal failed", { platform: message.platform });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
release();
|
|
375
|
+
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
376
|
+
resolve();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Start queued requests in arrival order while capacity allows, skipping conversations already running. */
|
|
381
|
+
function schedule(): void {
|
|
382
|
+
for (let index = 0; index < queue.length && turns.size < concurrency; ) {
|
|
383
|
+
const pending = queue[index]!;
|
|
384
|
+
if (activeKeys.has(pending.key)) {
|
|
385
|
+
index++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
queue.splice(index, 1);
|
|
389
|
+
activeKeys.add(pending.key);
|
|
390
|
+
const turn = processRequest(pending).finally(() => {
|
|
391
|
+
activeKeys.delete(pending.key);
|
|
392
|
+
turns.delete(turn);
|
|
393
|
+
schedule();
|
|
394
|
+
});
|
|
395
|
+
turns.add(turn);
|
|
313
396
|
}
|
|
314
|
-
workerActive = false;
|
|
315
397
|
}
|
|
316
398
|
|
|
317
399
|
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
@@ -326,11 +408,8 @@ export function createMessengerGateway(
|
|
|
326
408
|
if (oldest !== undefined) seen.delete(oldest);
|
|
327
409
|
}
|
|
328
410
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
329
|
-
queue.push({ message, adapter, signal: effectiveSignal, resolve });
|
|
330
|
-
|
|
331
|
-
workerActive = true;
|
|
332
|
-
draining = drain();
|
|
333
|
-
}
|
|
411
|
+
queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
|
|
412
|
+
schedule();
|
|
334
413
|
return promise;
|
|
335
414
|
}
|
|
336
415
|
|
|
@@ -362,7 +441,7 @@ export function createMessengerGateway(
|
|
|
362
441
|
} finally {
|
|
363
442
|
lifetime.abort();
|
|
364
443
|
await Promise.allSettled(starts);
|
|
365
|
-
await
|
|
444
|
+
while (turns.size > 0) await Promise.allSettled(turns);
|
|
366
445
|
signal.removeEventListener("abort", abort);
|
|
367
446
|
}
|
|
368
447
|
},
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
|
|
6
|
+
/** A per-user background service that keeps one messenger profile's gateway running. */
|
|
7
|
+
export interface MessengerServicePlan {
|
|
8
|
+
/** Stable service name derived from the profile directory. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Profile directory: working directory and home of the run script and log. */
|
|
11
|
+
directory: string;
|
|
12
|
+
runScript: string;
|
|
13
|
+
logFile: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function messengerServicePlan(directory: string, runScript: string): MessengerServicePlan {
|
|
17
|
+
const slug =
|
|
18
|
+
path
|
|
19
|
+
.basename(directory)
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
22
|
+
.replace(/^-+|-+$/g, "") || "default";
|
|
23
|
+
return {
|
|
24
|
+
label: `dev.jeopi.messenger.${slug}`,
|
|
25
|
+
directory,
|
|
26
|
+
runScript,
|
|
27
|
+
logFile: path.join(directory, "gateway.log"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function xml(value: string): string {
|
|
32
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** launchd job: starts at login, restarts after a crash (throttled), logs to the profile directory. */
|
|
36
|
+
export function launchdPlist(plan: MessengerServicePlan): string {
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
39
|
+
<plist version="1.0">
|
|
40
|
+
<dict>
|
|
41
|
+
<key>Label</key><string>${xml(plan.label)}</string>
|
|
42
|
+
<key>ProgramArguments</key>
|
|
43
|
+
<array><string>/bin/sh</string><string>${xml(plan.runScript)}</string></array>
|
|
44
|
+
<key>WorkingDirectory</key><string>${xml(plan.directory)}</string>
|
|
45
|
+
<key>RunAtLoad</key><true/>
|
|
46
|
+
<key>KeepAlive</key><true/>
|
|
47
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
48
|
+
<key>StandardOutPath</key><string>${xml(plan.logFile)}</string>
|
|
49
|
+
<key>StandardErrorPath</key><string>${xml(plan.logFile)}</string>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** systemd --user unit with the same lifecycle as the launchd job. */
|
|
56
|
+
export function systemdUnit(plan: MessengerServicePlan): string {
|
|
57
|
+
const quoted = (value: string): string => `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
58
|
+
return `[Unit]
|
|
59
|
+
Description=jeopi messenger gateway (${plan.label})
|
|
60
|
+
After=network-online.target
|
|
61
|
+
|
|
62
|
+
[Service]
|
|
63
|
+
WorkingDirectory=${quoted(plan.directory)}
|
|
64
|
+
ExecStart=/bin/sh ${quoted(plan.runScript)}
|
|
65
|
+
Restart=always
|
|
66
|
+
RestartSec=30
|
|
67
|
+
StandardOutput=append:${plan.logFile}
|
|
68
|
+
StandardError=append:${plan.logFile}
|
|
69
|
+
|
|
70
|
+
[Install]
|
|
71
|
+
WantedBy=default.target
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Install (or replace) and start the service, then confirm the platform reports it running.
|
|
77
|
+
* Returns a one-line description of where it was installed.
|
|
78
|
+
*/
|
|
79
|
+
export async function installMessengerService(
|
|
80
|
+
plan: MessengerServicePlan,
|
|
81
|
+
platform: NodeJS.Platform = process.platform,
|
|
82
|
+
home: string = os.homedir(),
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
if (platform === "darwin") {
|
|
85
|
+
const file = path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
|
|
86
|
+
await Bun.write(file, launchdPlist(plan));
|
|
87
|
+
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
88
|
+
await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
|
|
89
|
+
// bootstrap can fail briefly while a replaced job is still exiting.
|
|
90
|
+
let loaded = false;
|
|
91
|
+
for (let attempt = 0; attempt < 5 && !loaded; attempt++) {
|
|
92
|
+
if (attempt) await Bun.sleep(1_000);
|
|
93
|
+
loaded = (await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode === 0;
|
|
94
|
+
}
|
|
95
|
+
if (!loaded) throw new Error(`launchctl could not load ${file}`);
|
|
96
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
97
|
+
const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
|
|
98
|
+
if (status.exitCode === 0 && /\bstate = running\b/.test(status.text()))
|
|
99
|
+
return `launchd job ${plan.label} (${file})`;
|
|
100
|
+
await Bun.sleep(500);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
|
|
103
|
+
}
|
|
104
|
+
if (platform === "linux") {
|
|
105
|
+
const unit = `${plan.label}.service`;
|
|
106
|
+
const file = path.join(home, ".config", "systemd", "user", unit);
|
|
107
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
|
+
await Bun.write(file, systemdUnit(plan));
|
|
109
|
+
const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
|
|
110
|
+
const enable = await $`systemctl --user enable ${unit}`.quiet().nothrow();
|
|
111
|
+
const restart = await $`systemctl --user restart ${unit}`.quiet().nothrow();
|
|
112
|
+
if (reload.exitCode !== 0 || enable.exitCode !== 0 || restart.exitCode !== 0) {
|
|
113
|
+
throw new Error(`systemctl --user could not start ${unit}`);
|
|
114
|
+
}
|
|
115
|
+
const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
|
|
116
|
+
if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
|
|
117
|
+
return `systemd user unit ${unit} (${file})`;
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Background services are supported on macOS (launchd) and Linux (systemd --user); run ${plan.runScript} under your own process manager`,
|
|
121
|
+
);
|
|
122
|
+
}
|