jeo-code 0.12.18 → 0.12.21
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 +28 -1
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +2 -1
- package/src/agent/engine.ts +55 -11
- package/src/agent/loop-guards.ts +101 -21
- package/src/agent/sandbox/hands-source.text +590 -590
- package/src/agent/task-tool.ts +4 -1
- package/src/agent/workspace-snapshot.ts +209 -0
- package/src/cli/runner.ts +1 -1
- package/src/commands/launch.ts +32 -13
- package/src/commands/messenger.ts +27 -7
- package/src/commands/update.ts +30 -1
- package/src/messenger/discord-setup.ts +42 -72
- package/src/messenger/gateway.ts +20 -8
- package/src/messenger/launcher.ts +68 -0
- package/src/messenger/runtime.ts +255 -0
- package/src/messenger/service.ts +40 -2
- package/src/messenger/session-pool.ts +50 -4
- package/src/messenger/types.ts +8 -0
- package/src/util/slack.ts +40 -0
|
@@ -4,19 +4,19 @@ import * as path from "node:path";
|
|
|
4
4
|
import { DEFAULT_MESSENGER_TOOLS, loadMessengerConfig, messengerProfilePrivatePaths, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
5
5
|
import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
|
|
6
6
|
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
7
|
-
import { installMessengerService, messengerServicePlan } from "./service";
|
|
7
|
+
import { installMessengerService, messengerServicePlan, restartMessengerService } from "./service";
|
|
8
|
+
import { MESSENGER_RUN_SCRIPT_POSIX, MESSENGER_RUN_SCRIPT_WINDOWS, MESSENGER_TOKEN_FILE, messengerExecutablePath, messengerLauncherContent, writePrivateFile } from "./launcher";
|
|
9
|
+
import { MESSENGER_PACKAGE, messengerRuntimeCommand, messengerRuntimePlan, messengerRuntimeRoot, provisionMessengerRuntime, pruneMessengerRuntimes, readMessengerProfiles, registerMessengerProfile, type MessengerRuntime, type MessengerRuntimeDeps } from "./runtime";
|
|
8
10
|
import { isEnoent, isRecord } from "./types";
|
|
9
11
|
import { jeoHomeDir, notifyDir, notifySessionsDir } from "../agent/notify/paths";
|
|
10
12
|
import { createSandboxPolicy } from "../agent/sandbox/policy";
|
|
11
13
|
import type { SandboxConfig } from "../agent/sandbox/types";
|
|
12
14
|
|
|
13
15
|
export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
|
|
14
|
-
const TOKEN_FILE =
|
|
16
|
+
const TOKEN_FILE = MESSENGER_TOKEN_FILE;
|
|
15
17
|
/** Fresh-setup workspace: a jeo-owned directory in the user's home, outside every sandbox-private root. */
|
|
16
18
|
const WORKSPACE_DIR = "jeo-discord-workspace";
|
|
17
19
|
const WORKSPACE_MARKER = ".jeo-workspace";
|
|
18
|
-
const RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
19
|
-
const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
20
20
|
const MESSAGE_CONTENT = 1 << 18;
|
|
21
21
|
const MESSAGE_CONTENT_LIMITED = 1 << 19;
|
|
22
22
|
/** The only application flags a bot token may change: the limited presence, member, and message intents. */
|
|
@@ -87,9 +87,15 @@ export interface DiscordSetupDeps {
|
|
|
87
87
|
WebSocket?: typeof WebSocket;
|
|
88
88
|
env?: Record<string, string | undefined>;
|
|
89
89
|
sleep?: (ms: number) => Promise<void>;
|
|
90
|
-
/** argv that starts this jeo build;
|
|
90
|
+
/** argv that starts this jeo build; an explicit value is an external runtime that `jeo update` leaves alone. */
|
|
91
91
|
jeoCommand?: string[];
|
|
92
|
+
/** Runtime the running jeo offers; defaults to `messengerRuntimePlan()`. Ignored when `jeoCommand` is set. */
|
|
93
|
+
runtime?: MessengerRuntime;
|
|
94
|
+
runtimeDeps?: MessengerRuntimeDeps;
|
|
92
95
|
installService?: typeof installMessengerService;
|
|
96
|
+
restartService?: typeof restartMessengerService;
|
|
97
|
+
/** Registry of messenger profiles `jeo update` refreshes; defaults to the private notify directory. */
|
|
98
|
+
profilesPath?: string;
|
|
93
99
|
/** Cancels invite polling and the remaining setup work (the CLI wires Ctrl+C here). */
|
|
94
100
|
signal?: AbortSignal;
|
|
95
101
|
/** Injectable platform/path separator for cross-platform release tests. */
|
|
@@ -105,6 +111,7 @@ export interface DiscordSetupResult {
|
|
|
105
111
|
allowedUserIds: string[];
|
|
106
112
|
allowedChannelIds: string[];
|
|
107
113
|
runScript: string;
|
|
114
|
+
runtime: MessengerRuntime;
|
|
108
115
|
service?: string;
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -469,51 +476,8 @@ export async function probeDiscordGateway(
|
|
|
469
476
|
}
|
|
470
477
|
}
|
|
471
478
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
475
|
-
return `#!/bin/sh
|
|
476
|
-
# Generated by \`jeo messenger setup\`: starts this profile's Discord gateway.
|
|
477
|
-
set -eu
|
|
478
|
-
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
479
|
-
token_file="$dir/${TOKEN_FILE}"
|
|
480
|
-
case "$(ls -ln "$token_file" | cut -c5-10)" in
|
|
481
|
-
------) ;;
|
|
482
|
-
*) echo "${TOKEN_FILE} must be readable by its owner only (chmod 600)" >&2; exit 1 ;;
|
|
483
|
-
esac
|
|
484
|
-
PATH=${quote(envPath)}
|
|
485
|
-
export PATH
|
|
486
|
-
${tokenEnv}=$(cat "$token_file")
|
|
487
|
-
export ${tokenEnv}
|
|
488
|
-
exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
489
|
-
`;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
|
|
493
|
-
export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
|
|
494
|
-
if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
|
|
495
|
-
const literal = (value: string, label: string): string => {
|
|
496
|
-
if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
|
|
497
|
-
if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
|
|
498
|
-
return value.replace(/%/g, "%%");
|
|
499
|
-
};
|
|
500
|
-
const quote = (value: string): string => `"${literal(value, "command")}"`;
|
|
501
|
-
return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Stage `content` beside `file`, run `validate` on the staged copy, and only then replace `file`; failure leaves `file` untouched. */
|
|
505
|
-
async function writePrivate(file: string, content: string, mode: number, validate?: (staged: string) => Promise<void>): Promise<void> {
|
|
506
|
-
const temporary = `${file}.${process.pid}.tmp`;
|
|
507
|
-
try {
|
|
508
|
-
await Bun.write(temporary, content);
|
|
509
|
-
await fs.chmod(temporary, mode);
|
|
510
|
-
await validate?.(temporary);
|
|
511
|
-
await fs.rename(temporary, file);
|
|
512
|
-
} catch (error) {
|
|
513
|
-
await fs.unlink(temporary).catch(() => undefined);
|
|
514
|
-
throw error;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
479
|
+
export { currentJeoCommand, discordRunCommand, discordRunScript, messengerExecutablePath } from "./launcher";
|
|
480
|
+
const writePrivate = writePrivateFile;
|
|
517
481
|
|
|
518
482
|
async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
|
|
519
483
|
let text: string;
|
|
@@ -587,16 +551,6 @@ async function createDefaultWorkspace(admit: (workspace: string) => Promise<void
|
|
|
587
551
|
return workspace;
|
|
588
552
|
}
|
|
589
553
|
|
|
590
|
-
export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
|
|
591
|
-
return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
|
|
595
|
-
const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
|
|
596
|
-
const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
|
|
597
|
-
return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
554
|
/** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
|
|
601
555
|
export async function setupDiscordMessenger(
|
|
602
556
|
options: DiscordSetupOptions,
|
|
@@ -612,10 +566,11 @@ export async function setupDiscordMessenger(
|
|
|
612
566
|
if (options.service && platform !== "darwin" && platform !== "linux") {
|
|
613
567
|
throw new Error("Background services are supported only on macOS and Linux");
|
|
614
568
|
}
|
|
615
|
-
const runScript = path.join(directory, platform === "win32" ?
|
|
569
|
+
const runScript = path.join(directory, platform === "win32" ? MESSENGER_RUN_SCRIPT_WINDOWS : MESSENGER_RUN_SCRIPT_POSIX);
|
|
616
570
|
const delimiter = deps.pathDelimiter ?? path.delimiter;
|
|
617
571
|
const envPath = messengerExecutablePath(process.execPath, env.PATH, delimiter, platform);
|
|
618
|
-
const
|
|
572
|
+
const runtime: MessengerRuntime = deps.jeoCommand ? { kind: "external", command: deps.jeoCommand } : (deps.runtime ?? messengerRuntimePlan());
|
|
573
|
+
const command = messengerRuntimeCommand(directory, runtime, process.execPath);
|
|
619
574
|
const existing = await readExistingConfig(configPath);
|
|
620
575
|
const sandbox = existing?.sandbox !== undefined ? parseMessengerSandbox(existing.sandbox) : parseMessengerSandbox({
|
|
621
576
|
image: await io.ask("Existing local Linux sandbox image (no image will be pulled): "),
|
|
@@ -629,7 +584,7 @@ export async function setupDiscordMessenger(
|
|
|
629
584
|
delete existing.independentSessions;
|
|
630
585
|
io.say("Removed obsolete independentSessions setting. Discord threads now keep one persistent session.");
|
|
631
586
|
}
|
|
632
|
-
|
|
587
|
+
messengerLauncherContent(command, path.basename(configPath), DISCORD_TOKEN_ENV, envPath, platform);
|
|
633
588
|
|
|
634
589
|
const token = await resolveToken(env, tokenPath, io);
|
|
635
590
|
throwIfAborted(deps.signal);
|
|
@@ -670,7 +625,7 @@ export async function setupDiscordMessenger(
|
|
|
670
625
|
throwIfAborted(deps.signal);
|
|
671
626
|
const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
|
|
672
627
|
const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
|
|
673
|
-
|
|
628
|
+
messengerLauncherContent(command, path.basename(configPath), tokenEnv, envPath, platform);
|
|
674
629
|
|
|
675
630
|
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
676
631
|
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
@@ -708,25 +663,39 @@ export async function setupDiscordMessenger(
|
|
|
708
663
|
}
|
|
709
664
|
});
|
|
710
665
|
throwIfAborted(deps.signal);
|
|
711
|
-
await
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
);
|
|
666
|
+
const registered = (await readMessengerProfiles(deps.profilesPath)).find(record => record.configPath === configPath);
|
|
667
|
+
if (runtime.kind === "managed") {
|
|
668
|
+
io.say(`Installing the messenger runtime ${MESSENGER_PACKAGE}@${runtime.version} under ${messengerRuntimeRoot(directory, runtime.version)}…`);
|
|
669
|
+
await provisionMessengerRuntime(directory, runtime.version, deps.runtimeDeps);
|
|
670
|
+
throwIfAborted(deps.signal);
|
|
671
|
+
}
|
|
672
|
+
await writePrivate(runScript, messengerLauncherContent(command, path.basename(configPath), tokenEnv, envPath, platform), 0o700);
|
|
718
673
|
throwIfAborted(deps.signal);
|
|
674
|
+
io.say(runtime.kind === "managed"
|
|
675
|
+
? `The launcher runs the pinned runtime ${MESSENGER_PACKAGE}@${runtime.version}; \`jeo update\` installs new versions here and restarts the service.`
|
|
676
|
+
: `The launcher runs ${command.join(" ")}; \`jeo update\` leaves it unchanged. Run setup from an installed jeo to pin a managed runtime.`);
|
|
719
677
|
|
|
720
678
|
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
|
|
721
679
|
throwIfAborted(deps.signal);
|
|
722
680
|
io.say("Discord Gateway accepted the bot session and intents.");
|
|
723
681
|
|
|
682
|
+
const plan = messengerServicePlan(directory, runScript);
|
|
724
683
|
let service: string | undefined;
|
|
684
|
+
let serviceLabel = registered?.service;
|
|
725
685
|
if (options.service) {
|
|
726
|
-
service = await (deps.installService ?? installMessengerService)(
|
|
686
|
+
service = await (deps.installService ?? installMessengerService)(plan);
|
|
687
|
+
serviceLabel = plan.label;
|
|
727
688
|
throwIfAborted(deps.signal);
|
|
728
689
|
io.say(`Gateway running as ${service}`);
|
|
690
|
+
} else if ((await (deps.restartService ?? restartMessengerService)(plan, platform)) === "restarted") {
|
|
691
|
+
// A rewritten launcher only takes effect on the next start; an installed service restarts now rather than running stale code.
|
|
692
|
+
serviceLabel = plan.label;
|
|
693
|
+
io.say(`Restarted ${plan.label} on the new launcher.`);
|
|
729
694
|
}
|
|
695
|
+
throwIfAborted(deps.signal);
|
|
696
|
+
const removedRuntimes = await pruneMessengerRuntimes(directory, [runtime, registered?.runtime].flatMap(item => (item?.kind === "managed" ? [item.version] : [])));
|
|
697
|
+
if (removedRuntimes.length) io.say(`Removed stale messenger runtimes: ${removedRuntimes.join(", ")}.`);
|
|
698
|
+
await registerMessengerProfile({ configPath, runScript, runtime, ...(serviceLabel ? { service: serviceLabel } : {}), updatedAt: new Date().toISOString() }, deps.profilesPath);
|
|
730
699
|
if (dmChannel) {
|
|
731
700
|
try {
|
|
732
701
|
await api("POST", `/channels/${dmChannel}/messages`, { content: DM_TEST_MESSAGE });
|
|
@@ -747,6 +716,7 @@ export async function setupDiscordMessenger(
|
|
|
747
716
|
allowedUserIds: userIds,
|
|
748
717
|
allowedChannelIds,
|
|
749
718
|
runScript,
|
|
719
|
+
runtime,
|
|
750
720
|
...(service ? { service } : {}),
|
|
751
721
|
};
|
|
752
722
|
}
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
MessengerMessage,
|
|
8
8
|
MessengerReply,
|
|
9
9
|
} from "./types";
|
|
10
|
-
import { MessengerFatalError } from "./types";
|
|
10
|
+
import { MessengerFatalError, MessengerUnverifiedReplyError } from "./types";
|
|
11
11
|
|
|
12
12
|
const MAX_MESSAGE_LENGTH = 32_000;
|
|
13
13
|
const MAX_QUEUED_MESSAGES = 100;
|
|
@@ -275,6 +275,12 @@ export interface MessengerGatewayOptions {
|
|
|
275
275
|
concurrency?: number;
|
|
276
276
|
/** Authenticated human controls bypass model queues, including busy sessions. */
|
|
277
277
|
sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
|
|
278
|
+
/**
|
|
279
|
+
* Operator log for a request that failed before or during delivery. Users see the generic failure text, except
|
|
280
|
+
* an unverified completion, whose marked answer is delivered; this is the one place the cause is observable,
|
|
281
|
+
* and it receives the conversation, never message text.
|
|
282
|
+
*/
|
|
283
|
+
onRequestError?: (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver") => void;
|
|
278
284
|
}
|
|
279
285
|
|
|
280
286
|
export function createMessengerGateway(
|
|
@@ -293,6 +299,9 @@ export function createMessengerGateway(
|
|
|
293
299
|
}
|
|
294
300
|
const seen = new Set<string>();
|
|
295
301
|
const queue: PendingMessage[] = [];
|
|
302
|
+
const reportError = (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver"): void => {
|
|
303
|
+
try { options.onRequestError?.(message, error, stage); } catch { /* Operator logging never affects the request. */ }
|
|
304
|
+
};
|
|
296
305
|
const activeKeys = new Set<string>();
|
|
297
306
|
const turns = new Set<Promise<void>>();
|
|
298
307
|
/** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
|
|
@@ -363,18 +372,21 @@ export function createMessengerGateway(
|
|
|
363
372
|
return;
|
|
364
373
|
}
|
|
365
374
|
if (signal.aborted) return;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
375
|
+
reportError(message, error, "reply");
|
|
376
|
+
text = error instanceof MessengerUnverifiedReplyError
|
|
377
|
+
? error.reply
|
|
378
|
+
: error instanceof DiscordModelSelectionError
|
|
379
|
+
? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
|
|
380
|
+
: "The request could not be completed. Please try again.";
|
|
369
381
|
}
|
|
370
382
|
if (!signal.aborted && text.trim()) {
|
|
371
383
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
372
384
|
await adapter.send(message, text, signal);
|
|
373
385
|
}
|
|
374
386
|
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
375
|
-
} catch {
|
|
387
|
+
} catch (error) {
|
|
376
388
|
failed = true;
|
|
377
|
-
if (!signal.aborted) status("failure");
|
|
389
|
+
if (!signal.aborted) { status("failure"); reportError(message, error, "deliver"); }
|
|
378
390
|
} finally {
|
|
379
391
|
await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
|
|
380
392
|
resolve();
|
|
@@ -441,8 +453,8 @@ export function createMessengerGateway(
|
|
|
441
453
|
try {
|
|
442
454
|
destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
|
|
443
455
|
key = messengerConversationKey(destination);
|
|
444
|
-
} catch {
|
|
445
|
-
if (!effectiveSignal.aborted) status("failure");
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (!effectiveSignal.aborted) { status("failure"); reportError(message, error, "prepare"); }
|
|
446
458
|
return;
|
|
447
459
|
} finally {
|
|
448
460
|
preparing--;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const MESSENGER_TOKEN_FILE = "discord-token";
|
|
5
|
+
export const MESSENGER_RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
6
|
+
export const MESSENGER_RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
7
|
+
|
|
8
|
+
/** argv that starts the running jeo build: the compiled binary alone, or the runtime plus its entry script. */
|
|
9
|
+
export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
|
|
10
|
+
return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
|
|
14
|
+
const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
|
|
15
|
+
const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
|
|
16
|
+
return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The token is read from the 0600 file at launch, so it never lands in this script or a service definition. */
|
|
20
|
+
export function discordRunScript(command: string[], configName: string, tokenEnv: string, envPath: string): string {
|
|
21
|
+
const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
22
|
+
return `#!/bin/sh
|
|
23
|
+
# Generated by \`jeo messenger setup\`: starts this profile's Discord gateway.
|
|
24
|
+
set -eu
|
|
25
|
+
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
26
|
+
token_file="$dir/${MESSENGER_TOKEN_FILE}"
|
|
27
|
+
case "$(ls -ln "$token_file" | cut -c5-10)" in
|
|
28
|
+
------) ;;
|
|
29
|
+
*) echo "${MESSENGER_TOKEN_FILE} must be readable by its owner only (chmod 600)" >&2; exit 1 ;;
|
|
30
|
+
esac
|
|
31
|
+
PATH=${quote(envPath)}
|
|
32
|
+
export PATH
|
|
33
|
+
${tokenEnv}=$(cat "$token_file")
|
|
34
|
+
export ${tokenEnv}
|
|
35
|
+
exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
|
|
40
|
+
export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
|
|
41
|
+
if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
|
|
42
|
+
const literal = (value: string, label: string): string => {
|
|
43
|
+
if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
|
|
44
|
+
if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
|
|
45
|
+
return value.replace(/%/g, "%%");
|
|
46
|
+
};
|
|
47
|
+
const quote = (value: string): string => `"${literal(value, "command")}"`;
|
|
48
|
+
return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${MESSENGER_TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Launcher text for `platform`; Windows input validation happens here, so callers can check before any side effect. */
|
|
52
|
+
export function messengerLauncherContent(command: string[], configName: string, tokenEnv: string, envPath: string, platform: NodeJS.Platform): string {
|
|
53
|
+
return platform === "win32" ? discordRunCommand(command, configName, tokenEnv) : discordRunScript(command, configName, tokenEnv, envPath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stage `content` beside `file`, run `validate` on the staged copy, and only then replace `file`; failure leaves `file` untouched. */
|
|
57
|
+
export async function writePrivateFile(file: string, content: string, mode: number, validate?: (staged: string) => Promise<void>): Promise<void> {
|
|
58
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
await Bun.write(temporary, content);
|
|
61
|
+
await fs.chmod(temporary, mode);
|
|
62
|
+
await validate?.(temporary);
|
|
63
|
+
await fs.rename(temporary, file);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
await fs.unlink(temporary).catch(() => undefined);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import pkg from "../../package.json";
|
|
4
|
+
import { notifyDir } from "../agent/notify/paths";
|
|
5
|
+
import { loadMessengerConfig } from "./config";
|
|
6
|
+
import { currentJeoCommand, messengerExecutablePath, messengerLauncherContent, MESSENGER_RUN_SCRIPT_POSIX, MESSENGER_RUN_SCRIPT_WINDOWS, writePrivateFile } from "./launcher";
|
|
7
|
+
import { messengerServicePlan, restartMessengerService } from "./service";
|
|
8
|
+
import { isEnoent, isRecord } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A messenger profile runs its gateway and workers from one of two runtimes:
|
|
12
|
+
*
|
|
13
|
+
* - `managed`: a private copy of the published `jeo-code` package pinned to one version under
|
|
14
|
+
* `<profile>/runtime/<version>` (jeopi's `runtime-<version>` layout). `jeo messenger setup` and
|
|
15
|
+
* `jeo update` provision it, rewrite the launcher and restart the service, so the gateway never
|
|
16
|
+
* depends on a mutable global install or a source checkout that can move away underneath it.
|
|
17
|
+
* - `external`: an explicit argv (a source checkout or compiled binary). Updates leave it alone.
|
|
18
|
+
*/
|
|
19
|
+
export type MessengerRuntime =
|
|
20
|
+
| { kind: "managed"; version: string }
|
|
21
|
+
| { kind: "external"; command: string[] };
|
|
22
|
+
|
|
23
|
+
export interface MessengerProfileRecord {
|
|
24
|
+
configPath: string;
|
|
25
|
+
runScript: string;
|
|
26
|
+
runtime: MessengerRuntime;
|
|
27
|
+
/** Service label when a user service was installed for the profile. */
|
|
28
|
+
service?: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MESSENGER_PACKAGE = "jeo-code";
|
|
33
|
+
export const MESSENGER_RUNTIME_DIR = "runtime";
|
|
34
|
+
export const MESSENGER_PROFILES_FILE = "messenger-profiles.json";
|
|
35
|
+
const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
36
|
+
|
|
37
|
+
/** `<prefix>/node_modules/jeo-code/src/cli.ts` is a package install (global or a managed runtime); anything else is a checkout or binary. */
|
|
38
|
+
export function messengerRuntimePlan(entry = Bun.main, execPath = process.execPath, version = pkg.version): MessengerRuntime {
|
|
39
|
+
const command = currentJeoCommand(entry, execPath);
|
|
40
|
+
const parts = command.length === 2 ? command[1]!.replaceAll("\\", "/").split("/") : [];
|
|
41
|
+
const installed = parts.length >= 4 && parts.at(-1) === "cli.ts" && parts.at(-2) === "src" && parts.at(-3) === MESSENGER_PACKAGE && parts.at(-4) === "node_modules";
|
|
42
|
+
return installed && VERSION.test(version) ? { kind: "managed", version } : { kind: "external", command };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function messengerRuntimeRoot(profileDir: string, version: string): string {
|
|
46
|
+
if (!VERSION.test(version)) throw new Error(`Invalid messenger runtime version: ${version}`);
|
|
47
|
+
return path.join(profileDir, MESSENGER_RUNTIME_DIR, version);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The argv a launcher uses for `runtime`; pure, so callers can validate it before provisioning. */
|
|
51
|
+
export function messengerRuntimeCommand(profileDir: string, runtime: MessengerRuntime, execPath = process.execPath): string[] {
|
|
52
|
+
if (runtime.kind === "external") return runtime.command;
|
|
53
|
+
return [execPath, path.join(messengerRuntimeRoot(profileDir, runtime.version), "node_modules", MESSENGER_PACKAGE, "src", "cli.ts")];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function installedRuntimeVersion(root: string): Promise<string | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const manifest: unknown = await Bun.file(path.join(root, "node_modules", MESSENGER_PACKAGE, "package.json")).json();
|
|
59
|
+
if (!isRecord(manifest) || typeof manifest.version !== "string") return undefined;
|
|
60
|
+
await fs.access(path.join(root, "node_modules", MESSENGER_PACKAGE, "src", "cli.ts"));
|
|
61
|
+
return manifest.version;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (isEnoent(error) || error instanceof SyntaxError) return undefined;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `bun install` the pinned package; `--force` recovers a registry manifest bun cached before a fresh publish. */
|
|
69
|
+
async function installWithBun(root: string, execPath: string): Promise<void> {
|
|
70
|
+
let failure = "";
|
|
71
|
+
for (const extra of [[], ["--force"]]) {
|
|
72
|
+
const proc = Bun.spawn([execPath, "install", "--no-progress", "--no-summary", ...extra], { cwd: root, stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
73
|
+
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
74
|
+
if (code === 0) return;
|
|
75
|
+
failure = stderr.trim().split("\n").slice(-3).join(" ").trim() || `bun install exited with code ${code}`;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(failure);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MessengerRuntimeDeps {
|
|
81
|
+
/** Install `jeo-code@version` into `root` (its `package.json` is already written). Defaults to `bun install`. */
|
|
82
|
+
install?: (root: string, version: string) => Promise<void>;
|
|
83
|
+
execPath?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Idempotent: an intact runtime for `version` is reused; a partial one is replaced. Failure leaves no runtime behind. */
|
|
87
|
+
export async function provisionMessengerRuntime(profileDir: string, version: string, deps: MessengerRuntimeDeps = {}): Promise<string[]> {
|
|
88
|
+
const root = messengerRuntimeRoot(profileDir, version);
|
|
89
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
90
|
+
const command = messengerRuntimeCommand(profileDir, { kind: "managed", version }, execPath);
|
|
91
|
+
if ((await installedRuntimeVersion(root)) === version) return command;
|
|
92
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
93
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
94
|
+
await Bun.write(path.join(root, "package.json"), `${JSON.stringify({ name: "jeo-messenger-runtime", private: true, dependencies: { [MESSENGER_PACKAGE]: version } }, null, 2)}\n`);
|
|
95
|
+
try {
|
|
96
|
+
await (deps.install ?? (dir => installWithBun(dir, execPath)))(root, version);
|
|
97
|
+
const installed = await installedRuntimeVersion(root);
|
|
98
|
+
if (installed !== version) throw new Error(`the runtime contains ${MESSENGER_PACKAGE}@${installed ?? "nothing"}`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
101
|
+
throw new Error(`Could not install the messenger runtime ${MESSENGER_PACKAGE}@${version}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
102
|
+
}
|
|
103
|
+
return command;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Remove runtime versions other than `keep` (the active one and its predecessor, for rollback). Returns removed versions. */
|
|
107
|
+
export async function pruneMessengerRuntimes(profileDir: string, keep: string[]): Promise<string[]> {
|
|
108
|
+
const directory = path.join(profileDir, MESSENGER_RUNTIME_DIR);
|
|
109
|
+
let entries: string[];
|
|
110
|
+
try { entries = await fs.readdir(directory); }
|
|
111
|
+
catch (error) { if (isEnoent(error)) return []; throw error; }
|
|
112
|
+
const removed: string[] = [];
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (keep.includes(entry) || !VERSION.test(entry)) continue;
|
|
115
|
+
await fs.rm(path.join(directory, entry), { recursive: true, force: true });
|
|
116
|
+
removed.push(entry);
|
|
117
|
+
}
|
|
118
|
+
return removed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function messengerProfilesPath(): string {
|
|
122
|
+
return path.join(notifyDir(), MESSENGER_PROFILES_FILE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseRuntime(value: unknown): MessengerRuntime | undefined {
|
|
126
|
+
if (!isRecord(value)) return undefined;
|
|
127
|
+
if (value.kind === "managed" && typeof value.version === "string" && VERSION.test(value.version)) return { kind: "managed", version: value.version };
|
|
128
|
+
if (value.kind === "external" && Array.isArray(value.command) && value.command.length && value.command.every(item => typeof item === "string" && item)) {
|
|
129
|
+
return { kind: "external", command: value.command as string[] };
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Records whose profile no longer exists are dropped on read, so a deleted profile never fails a later refresh. */
|
|
135
|
+
export async function readMessengerProfiles(file = messengerProfilesPath()): Promise<MessengerProfileRecord[]> {
|
|
136
|
+
let raw: unknown;
|
|
137
|
+
try { raw = await Bun.file(file).json(); }
|
|
138
|
+
catch (error) { if (isEnoent(error) || error instanceof SyntaxError) return []; throw error; }
|
|
139
|
+
if (!Array.isArray(raw)) return [];
|
|
140
|
+
const records: MessengerProfileRecord[] = [];
|
|
141
|
+
for (const item of raw) {
|
|
142
|
+
if (!isRecord(item) || typeof item.configPath !== "string" || typeof item.runScript !== "string" || !path.isAbsolute(item.configPath)) continue;
|
|
143
|
+
const runtime = parseRuntime(item.runtime);
|
|
144
|
+
if (!runtime || !(await fs.stat(item.configPath).then(stat => stat.isFile(), () => false))) continue;
|
|
145
|
+
records.push({
|
|
146
|
+
configPath: item.configPath,
|
|
147
|
+
runScript: item.runScript,
|
|
148
|
+
runtime,
|
|
149
|
+
...(typeof item.service === "string" && item.service ? { service: item.service } : {}),
|
|
150
|
+
updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : new Date(0).toISOString(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return records;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function registerMessengerProfile(record: MessengerProfileRecord, file = messengerProfilesPath()): Promise<void> {
|
|
157
|
+
const records = (await readMessengerProfiles(file)).filter(item => item.configPath !== record.configPath);
|
|
158
|
+
records.push(record);
|
|
159
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
160
|
+
await writePrivateFile(file, `${JSON.stringify(records, null, 2)}\n`, 0o600);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface MessengerRefreshDeps {
|
|
164
|
+
/** The runtime the running jeo offers; defaults to `messengerRuntimePlan()`. */
|
|
165
|
+
runtime?: MessengerRuntime;
|
|
166
|
+
runtimeDeps?: MessengerRuntimeDeps;
|
|
167
|
+
restartService?: typeof restartMessengerService;
|
|
168
|
+
profilesPath?: string;
|
|
169
|
+
env?: Record<string, string | undefined>;
|
|
170
|
+
platform?: NodeJS.Platform;
|
|
171
|
+
pathDelimiter?: string;
|
|
172
|
+
execPath?: string;
|
|
173
|
+
now?: () => Date;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface MessengerRefreshResult {
|
|
177
|
+
configPath: string;
|
|
178
|
+
runtime: MessengerRuntime;
|
|
179
|
+
service: "restarted" | "not installed";
|
|
180
|
+
removedRuntimes: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Point one profile's launcher at the runtime the running jeo offers, restart its installed service and prune
|
|
185
|
+
* stale runtimes. A managed runtime is provisioned before the launcher changes, so a failed install leaves the
|
|
186
|
+
* previous launcher and service untouched.
|
|
187
|
+
*/
|
|
188
|
+
export async function refreshMessengerProfile(configPath: string, deps: MessengerRefreshDeps = {}): Promise<MessengerRefreshResult> {
|
|
189
|
+
const platform = deps.platform ?? process.platform;
|
|
190
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
191
|
+
const runtime = deps.runtime ?? messengerRuntimePlan();
|
|
192
|
+
const resolvedConfig = path.resolve(configPath);
|
|
193
|
+
const directory = path.dirname(resolvedConfig);
|
|
194
|
+
const config = await loadMessengerConfig(resolvedConfig);
|
|
195
|
+
const tokenEnv = config.platforms.discord?.tokenEnv ?? "JEO_DISCORD_BOT_TOKEN";
|
|
196
|
+
const configName = path.basename(resolvedConfig);
|
|
197
|
+
const envPath = messengerExecutablePath(execPath, (deps.env ?? process.env).PATH, deps.pathDelimiter ?? path.delimiter, platform);
|
|
198
|
+
// Validate launcher inputs for the planned command before provisioning anything.
|
|
199
|
+
messengerLauncherContent(messengerRuntimeCommand(directory, runtime, execPath), configName, tokenEnv, envPath, platform);
|
|
200
|
+
const previous = (await readMessengerProfiles(deps.profilesPath)).find(record => record.configPath === resolvedConfig);
|
|
201
|
+
const command = runtime.kind === "managed" ? await provisionMessengerRuntime(directory, runtime.version, { execPath, ...deps.runtimeDeps }) : runtime.command;
|
|
202
|
+
const runScript = path.join(directory, platform === "win32" ? MESSENGER_RUN_SCRIPT_WINDOWS : MESSENGER_RUN_SCRIPT_POSIX);
|
|
203
|
+
await writePrivateFile(runScript, messengerLauncherContent(command, configName, tokenEnv, envPath, platform), 0o700);
|
|
204
|
+
const plan = messengerServicePlan(directory, runScript);
|
|
205
|
+
const service = await (deps.restartService ?? restartMessengerService)(plan, platform);
|
|
206
|
+
const keep = [runtime, previous?.runtime].flatMap(item => (item?.kind === "managed" ? [item.version] : []));
|
|
207
|
+
const removedRuntimes = runtime.kind === "managed" ? await pruneMessengerRuntimes(directory, keep) : [];
|
|
208
|
+
await registerMessengerProfile({
|
|
209
|
+
configPath: resolvedConfig,
|
|
210
|
+
runScript,
|
|
211
|
+
runtime,
|
|
212
|
+
...(service === "restarted" ? { service: plan.label } : previous?.service ? { service: previous.service } : {}),
|
|
213
|
+
updatedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
214
|
+
}, deps.profilesPath);
|
|
215
|
+
return { configPath: resolvedConfig, runtime, service, removedRuntimes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `jeo messenger update`: refresh the named profiles, or every registered managed one. Without explicit
|
|
220
|
+
* paths, a profile on an external runtime (a checkout or binary the operator chose) is reported and left
|
|
221
|
+
* alone; naming it with `--config` re-pins it to the running jeo. Failures are reported per profile.
|
|
222
|
+
*/
|
|
223
|
+
export async function refreshMessengerProfiles(
|
|
224
|
+
configPaths: string[] | undefined,
|
|
225
|
+
say: (line: string) => void,
|
|
226
|
+
deps: MessengerRefreshDeps = {},
|
|
227
|
+
): Promise<{ refreshed: number; failed: number; skipped: number }> {
|
|
228
|
+
const explicit = Boolean(configPaths?.length);
|
|
229
|
+
const records = explicit ? [] : await readMessengerProfiles(deps.profilesPath);
|
|
230
|
+
const targets = explicit ? configPaths!.map(file => path.resolve(file)) : records.filter(record => record.runtime.kind === "managed").map(record => record.configPath);
|
|
231
|
+
let skipped = 0;
|
|
232
|
+
for (const record of records) {
|
|
233
|
+
if (record.runtime.kind !== "external") continue;
|
|
234
|
+
skipped++;
|
|
235
|
+
say(`${record.configPath}: launcher runs ${record.runtime.command.join(" ")} (external); left unchanged. Pass --config to re-pin it.`);
|
|
236
|
+
}
|
|
237
|
+
if (!targets.length && !skipped) {
|
|
238
|
+
say("No messenger profiles are registered; run `jeo messenger setup --config <file>` first.");
|
|
239
|
+
return { refreshed: 0, failed: 0, skipped: 0 };
|
|
240
|
+
}
|
|
241
|
+
let refreshed = 0;
|
|
242
|
+
let failed = 0;
|
|
243
|
+
for (const configPath of targets) {
|
|
244
|
+
try {
|
|
245
|
+
const result = await refreshMessengerProfile(configPath, deps);
|
|
246
|
+
refreshed++;
|
|
247
|
+
const where = result.runtime.kind === "managed" ? `${MESSENGER_PACKAGE}@${result.runtime.version} (managed runtime)` : `${result.runtime.command.join(" ")} (external; \`jeo update\` leaves it unchanged)`;
|
|
248
|
+
say(`${configPath}: launcher runs ${where}; service ${result.service}${result.removedRuntimes.length ? `; removed runtimes ${result.removedRuntimes.join(", ")}` : ""}.`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
failed++;
|
|
251
|
+
say(`${configPath}: not refreshed — ${error instanceof Error ? error.message : String(error)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { refreshed, failed, skipped };
|
|
255
|
+
}
|