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.
@@ -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 = "discord-token";
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; defaults to the running runtime and entry. */
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
- /** The token is read from the 0600 file at launch, so it never lands in this script or a service definition. */
473
- export function discordRunScript(command: string[], configName: string, tokenEnv: string, envPath: string): string {
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" ? RUN_SCRIPT_WINDOWS : RUN_SCRIPT_POSIX);
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 command = deps.jeoCommand ?? currentJeoCommand();
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
- if (platform === "win32") discordRunCommand(command, path.basename(configPath), DISCORD_TOKEN_ENV);
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
- if (platform === "win32") discordRunCommand(command, path.basename(configPath), tokenEnv);
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 writePrivate(
712
- runScript,
713
- platform === "win32"
714
- ? discordRunCommand(command, path.basename(configPath), tokenEnv)
715
- : discordRunScript(command, path.basename(configPath), tokenEnv, envPath),
716
- 0o700,
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)(messengerServicePlan(directory, runScript));
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
  }
@@ -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
- text = error instanceof DiscordModelSelectionError
367
- ? "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."
368
- : "The request could not be completed. Please try again.";
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
+ }