jeo-code 0.12.18 → 0.12.20

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.
@@ -1,4 +1,6 @@
1
+ import { existsSync } from "node:fs";
1
2
  import pkg from "../../package.json";
3
+ import { messengerProfilesPath } from "../messenger/runtime";
2
4
 
3
5
  /**
4
6
  * Compares two semver-ish version strings.
@@ -38,6 +40,13 @@ export interface UpdateDeps {
38
40
  install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
39
41
  /** Display release notes after a successful self-update (best-effort, no-op in tests). */
40
42
  showWhatsNew?: () => void;
43
+ /**
44
+ * Re-pin registered messenger profiles to the freshly installed jeo and restart their services
45
+ * (`jeo messenger update` of the NEW binary). Returns null when no profile is registered, else whether
46
+ * every profile was refreshed. `quiet` keeps the child's output off stdout for `--json`. Optional in
47
+ * tests; failures never fail the update.
48
+ */
49
+ refreshMessengers?: (quiet: boolean) => boolean | null;
41
50
  /** Version of the `jeo` actually on PATH, read after install to catch a silent no-op
42
51
  * (installed but PATH still points at a different/older binary). Optional in tests. */
43
52
  activeVersion?: () => string | null;
@@ -129,6 +138,20 @@ export const defaultDeps: UpdateDeps = {
129
138
  } catch {
130
139
  // Notes are a courtesy; a spawn failure must never fail the update.
131
140
  }
141
+ },
142
+ refreshMessengers: quiet => {
143
+ // Only profiles registered by `jeo messenger setup` have runtimes to move; everyone else sees nothing.
144
+ if (!existsSync(messengerProfilesPath())) return null;
145
+ try {
146
+ if (!quiet) console.log("Refreshing messenger runtimes...");
147
+ // The NEW binary knows its own launcher format and version, so it performs the refresh.
148
+ const proc = Bun.spawnSync(["jeo", "messenger", "update"], quiet ? { stdout: "ignore", stderr: "ignore" } : { stdout: "inherit", stderr: "inherit" });
149
+ if (!proc.success && !quiet) console.error("Messenger runtimes were not all refreshed; run 'jeo messenger update' to retry.");
150
+ return proc.success;
151
+ } catch {
152
+ if (!quiet) console.error("Messenger runtimes were not refreshed; run 'jeo messenger update' to retry.");
153
+ return false;
154
+ }
132
155
  }
133
156
  };
134
157
 
@@ -310,11 +333,13 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
310
333
  const active = deps.activeVersion?.();
311
334
  const stale = !!(active && latest && compareVersions(active, latest) < 0);
312
335
  if (hasJson) {
336
+ const messengersRefreshed = stale ? null : (deps.refreshMessengers?.(true) ?? null);
313
337
  console.log(JSON.stringify({
314
338
  current,
315
339
  latest,
316
340
  upToDate: false,
317
341
  installed: !stale,
342
+ ...(messengersRefreshed === null ? {} : { messengersRefreshed }),
318
343
  ...(stale ? { activeVersion: active, error: "active jeo still reports an older version" } : {}),
319
344
  }));
320
345
  if (stale) process.exitCode = 1;
@@ -327,6 +352,7 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
327
352
  process.exitCode = 1;
328
353
  } else {
329
354
  console.log(`Successfully installed jeo-code@${latest}`);
355
+ deps.refreshMessengers?.(false);
330
356
  deps.showWhatsNew?.();
331
357
  }
332
358
  } else {
@@ -337,15 +363,18 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
337
363
  const recovered = deps.activeVersion?.();
338
364
  if (recovered && latest && compareVersions(recovered, latest) >= 0) {
339
365
  if (hasJson) {
366
+ const messengersRefreshed = deps.refreshMessengers?.(true) ?? null;
340
367
  console.log(JSON.stringify({
341
368
  current,
342
369
  latest,
343
370
  upToDate: false,
344
371
  installed: true,
345
- recovered: true
372
+ recovered: true,
373
+ ...(messengersRefreshed === null ? {} : { messengersRefreshed })
346
374
  }));
347
375
  } else {
348
376
  console.log(`Successfully installed jeo-code@${latest} (package manager reported an error, but the runtime verified clean).`);
377
+ deps.refreshMessengers?.(false);
349
378
  deps.showWhatsNew?.();
350
379
  }
351
380
  } else {
@@ -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
  }
@@ -275,6 +275,11 @@ 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 only ever see the generic failure
280
+ * text, so this is the one place the cause is observable; it receives the conversation, never message text.
281
+ */
282
+ onRequestError?: (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver") => void;
278
283
  }
279
284
 
280
285
  export function createMessengerGateway(
@@ -293,6 +298,9 @@ export function createMessengerGateway(
293
298
  }
294
299
  const seen = new Set<string>();
295
300
  const queue: PendingMessage[] = [];
301
+ const reportError = (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver"): void => {
302
+ try { options.onRequestError?.(message, error, stage); } catch { /* Operator logging never affects the request. */ }
303
+ };
296
304
  const activeKeys = new Set<string>();
297
305
  const turns = new Set<Promise<void>>();
298
306
  /** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
@@ -363,6 +371,7 @@ export function createMessengerGateway(
363
371
  return;
364
372
  }
365
373
  if (signal.aborted) return;
374
+ reportError(message, error, "reply");
366
375
  text = error instanceof DiscordModelSelectionError
367
376
  ? "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
377
  : "The request could not be completed. Please try again.";
@@ -372,9 +381,9 @@ export function createMessengerGateway(
372
381
  await adapter.send(message, text, signal);
373
382
  }
374
383
  if (!signal.aborted) status(failed ? "failure" : "success");
375
- } catch {
384
+ } catch (error) {
376
385
  failed = true;
377
- if (!signal.aborted) status("failure");
386
+ if (!signal.aborted) { status("failure"); reportError(message, error, "deliver"); }
378
387
  } finally {
379
388
  await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
380
389
  resolve();
@@ -441,8 +450,8 @@ export function createMessengerGateway(
441
450
  try {
442
451
  destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
443
452
  key = messengerConversationKey(destination);
444
- } catch {
445
- if (!effectiveSignal.aborted) status("failure");
453
+ } catch (error) {
454
+ if (!effectiveSignal.aborted) { status("failure"); reportError(message, error, "prepare"); }
446
455
  return;
447
456
  } finally {
448
457
  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
+ }