@gajae-gateway/cli 0.1.8 → 0.2.0

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/src/services.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { homeLayout } from "@gajae-gateway/home";
4
5
  import { parseRuntimeConfig, type RuntimeConfig, RuntimeConfigError } from "@gajae-gateway/protocol";
5
6
 
6
7
  const CONFIG_SCHEMA_VERSION = 1;
@@ -34,6 +35,10 @@ export interface InstallServicesOptions {
34
35
  /** Service manager to generate for; defaults to the running platform. */
35
36
  readonly platform?: ServicePlatform;
36
37
  readonly env?: NodeJS.ProcessEnv;
38
+ /** Render adapter units only when their adapter-<platform>.json exists. */
39
+ readonly configuredAdaptersOnly?: boolean;
40
+ /** Leave unchanged unit files untouched. */
41
+ readonly compareAndWrite?: boolean;
37
42
  /** Test seam for the `${SHELL} -lc` invocation used by the default path. */
38
43
  readonly loginPathRunner?: LoginPathRunner;
39
44
  /** Test seam for counting or inspecting the definition writes. */
@@ -55,9 +60,17 @@ interface ServiceSpec {
55
60
  readonly dependsOnGateway: boolean;
56
61
  }
57
62
 
63
+ export const BROKER_UNIT = "gajaeway-broker.service";
58
64
  export const GATEWAY_UNIT = "gajaeway-gateway.service";
59
65
 
60
66
  const SERVICE_SPECS: readonly ServiceSpec[] = [
67
+ {
68
+ id: "broker",
69
+ label: "dev.gajaeway.broker",
70
+ binary: "gajaeway-gateway",
71
+ args: ["broker"],
72
+ dependsOnGateway: false,
73
+ },
61
74
  {
62
75
  id: "gateway",
63
76
  label: "dev.gajaeway.gateway",
@@ -79,6 +92,13 @@ const SERVICE_SPECS: readonly ServiceSpec[] = [
79
92
  args: [],
80
93
  dependsOnGateway: true,
81
94
  },
95
+ {
96
+ id: "adapter-telegram",
97
+ label: "dev.gajaeway.adapter-telegram",
98
+ binary: "gajaeway-telegram",
99
+ args: [],
100
+ dependsOnGateway: true,
101
+ },
82
102
  { id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"], dependsOnGateway: true },
83
103
  ];
84
104
 
@@ -203,35 +223,50 @@ export function systemdUnitName(spec: ServiceSpec): string {
203
223
  return `gajaeway-${spec.id}.service`;
204
224
  }
205
225
 
226
+ /**
227
+ * `EnvironmentFile=` and `ConditionPathExists=` take one literal absolute path:
228
+ * systemd does not unquote them, so a quoted value is "not absolute" and the
229
+ * directive is silently ignored. Only `%` specifiers need escaping.
230
+ */
231
+ function unitPath(path: string): string {
232
+ if (!path.startsWith("/") || /[\n\r\0]/.test(path))
233
+ throw new Error(`unit path must be absolute and single-line: ${path}`);
234
+ return path.replaceAll("%", "%%");
235
+ }
236
+
237
+ function unitEnvironmentFile(path: string): string {
238
+ return `EnvironmentFile=-${unitPath(path)}`;
239
+ }
240
+
206
241
  export function renderSystemdUnit(spec: ServiceSpec, binDir: string, home: string, path: string): string {
207
242
  const execStart = [join(binDir, spec.binary), ...spec.args].join(" ");
208
243
  const unit = [
209
244
  "[Unit]",
210
245
  `Description=gajaeway ${spec.id}`,
211
- // BindsTo restarts and stops this unit with the gateway; PartOf propagates a
212
- // gateway restart even when this unit is started on its own; After orders
213
- // the boot so the adapter connects to a listening socket.
214
- ...(spec.dependsOnGateway ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`] : []),
246
+ ...(spec.id === "broker"
247
+ ? [`ConditionPathExists=${unitPath(homeLayout(home).brokerProvisionManifest)}`]
248
+ : spec.id === "gateway"
249
+ ? [`Wants=${BROKER_UNIT}`, `After=${BROKER_UNIT}`]
250
+ : spec.dependsOnGateway
251
+ ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`]
252
+ : []),
215
253
  "",
216
254
  "[Service]",
217
255
  `ExecStart=${execStart}`,
218
256
  `WorkingDirectory=${binDir}`,
219
257
  unitEnvironment("GAJAEWAY_HOME", home),
220
258
  unitEnvironment("PATH", path),
259
+ ...(spec.id === "broker" ? [unitEnvironmentFile(join(home, "broker.env"))] : []),
221
260
  "Restart=always",
222
- "RestartSec=2",
223
- // Only the gateway: SIGTERM its main process for an ordered shutdown, then
224
- // kill whatever is left in its cgroup so no child outlives the unit and
225
- // gets re-adopted by the next start (#183). The gateway exits on its own
226
- // inside 25s (SHUTDOWN_DEADLINE_MS in the gateway main); the unit's stop
227
- // window is pinned so that ceiling always fits (#225). The gateway moves a GJC broker
228
- // it autostarted into a scope of its own, so this never reaches the shared
229
- // broker or its session hosts.
230
- ...(spec.dependsOnGateway ? [] : ["KillMode=mixed", "TimeoutStopSec=30s"]),
261
+ spec.id === "broker" ? "RestartSec=2s" : "RestartSec=2",
262
+ ...(spec.id === "broker"
263
+ ? // The host propagates `gjc sdk broker run`'s exit code: 143 is its clean SIGTERM stop.
264
+ ["KillMode=control-group", "TimeoutStopSec=30s", "SuccessExitStatus=143"]
265
+ : spec.id === "gateway"
266
+ ? ["KillMode=mixed", "TimeoutStopSec=30s"]
267
+ : []),
231
268
  "",
232
269
  "[Install]",
233
- // Enabling the gateway pulls the whole stack in; a dependent is never
234
- // wanted by default.target on its own, because it cannot run without one.
235
270
  `WantedBy=${spec.dependsOnGateway ? GATEWAY_UNIT : "default.target"}`,
236
271
  "",
237
272
  ];
@@ -256,14 +291,16 @@ export function systemdUnitPath(spec: ServiceSpec, unitDir: string): string {
256
291
  /**
257
292
  * The ordered commands that realign the whole stack.
258
293
  *
259
- * systemd expresses the dependency itself, so one restart of the gateway unit
260
- * is the entire operation. launchd has no BindsTo/PartOf equivalent and
261
- * `WatchPaths` does not restart an already-running job, so the ordering is
262
- * explicit here instead — and `bootout` never appears, because removing a job
263
- * leaves it with no automatic recovery.
294
+ * systemd restarts the broker before the gateway; gateway dependencies restart
295
+ * their adapters. launchd has no dependency propagation, so every job is
296
+ * kicked explicitly in the same order.
264
297
  */
265
298
  export function restartStackCommands(platform: ServicePlatform, uid?: number): readonly (readonly string[])[] {
266
- if (platform === "linux") return [["systemctl", "--user", "restart", GATEWAY_UNIT]];
299
+ if (platform === "linux")
300
+ return [
301
+ ["systemctl", "--user", "restart", BROKER_UNIT],
302
+ ["systemctl", "--user", "restart", GATEWAY_UNIT],
303
+ ];
267
304
  if (platform === "darwin") {
268
305
  const target = uid ?? process.getuid?.() ?? 0;
269
306
  return restartOrder().map((spec) => ["launchctl", "kickstart", "-k", `gui/${target}/${spec.label}`]);
@@ -271,7 +308,7 @@ export function restartStackCommands(platform: ServicePlatform, uid?: number): r
271
308
  throw new Error(`unsupported service platform: ${platform}`);
272
309
  }
273
310
 
274
- /** The gateway first, then every job that holds a connection to it. */
311
+ /** Broker, gateway, then every job that holds a connection to the gateway. */
275
312
  export function restartOrder(): readonly ServiceSpec[] {
276
313
  return [
277
314
  ...SERVICE_SPECS.filter((spec) => !spec.dependsOnGateway),
@@ -336,7 +373,15 @@ export async function installServices(options: InstallServicesOptions): Promise<
336
373
  loginPathRunner: options.loginPathRunner,
337
374
  });
338
375
  await mkdir(targetDir, { recursive: true, mode: 0o700 });
339
- const definitions = SERVICE_SPECS.map((spec) =>
376
+ const specs: ServiceSpec[] = [];
377
+ for (const spec of SERVICE_SPECS) {
378
+ if (options.configuredAdaptersOnly && spec.id.startsWith("adapter-")) {
379
+ const adapter = spec.id.slice("adapter-".length);
380
+ if (!(await Bun.file(join(home, `adapter-${adapter}.json`)).exists())) continue;
381
+ }
382
+ specs.push(spec);
383
+ }
384
+ const definitions = specs.map((spec) =>
340
385
  platform === "darwin"
341
386
  ? {
342
387
  path: join(targetDir, `dev.gajaeway.${spec.id}.plist`),
@@ -346,6 +391,13 @@ export async function installServices(options: InstallServicesOptions): Promise<
346
391
  );
347
392
  const write = options.writeFile;
348
393
  for (const definition of definitions) {
394
+ if (options.compareAndWrite) {
395
+ try {
396
+ if ((await readFile(definition.path, "utf8")) === definition.contents) continue;
397
+ } catch (error) {
398
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
399
+ }
400
+ }
349
401
  if (write) await write(definition.path, definition.contents);
350
402
  else {
351
403
  await writeFile(definition.path, definition.contents, { encoding: "utf8", mode: 0o600 });
package/src/setup.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
2
+ import { chmod, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { printDiscordBotReady, printDiscordSetupGuide } from "./discord-setup";
4
5
 
5
6
  /**
6
7
  * `gajaeway setup`: guided credential setup for the platform adapters.
@@ -30,7 +31,7 @@ export const SETUP_ENV = {
30
31
  } as const;
31
32
 
32
33
  export const SETUP_USAGE =
33
- "usage: gajaeway setup [--from-env] [--adapters discord,slack,telegram] [--owner ID[,ID...]] [--discord-app-id ID] | setup --status";
34
+ "usage: gajaeway setup [--from-env] [--adapters discord,slack,telegram] [--owner ID[,ID...]] [--discord-app-id ID] | setup --status | setup --broker-agent [--from DIR]";
34
35
 
35
36
  /** Secret file names under `$GAJAEWAY_HOME/secrets`, matching docs/deployment.md. */
36
37
  export const SECRET_FILES = {
@@ -59,16 +60,19 @@ export function redact(text: string, secrets: Iterable<string>): string {
59
60
  }
60
61
 
61
62
  export interface ParsedSetupArgs {
62
- readonly mode: "setup" | "status";
63
+ readonly mode: "setup" | "status" | "broker-agent";
63
64
  readonly fromEnv: boolean;
64
65
  readonly adapters?: readonly SetupPlatform[];
65
66
  readonly owners: readonly string[];
66
67
  readonly discordAppId?: string;
68
+ readonly from?: string;
67
69
  }
68
70
 
69
71
  export function parseSetupArgs(args: readonly string[]): ParsedSetupArgs {
70
72
  let fromEnv = false;
71
73
  let status = false;
74
+ let brokerAgent = false;
75
+ let from: string | undefined;
72
76
  let adapters: SetupPlatform[] | undefined;
73
77
  const owners: string[] = [];
74
78
  let discordAppId: string | undefined;
@@ -76,7 +80,13 @@ export function parseSetupArgs(args: readonly string[]): ParsedSetupArgs {
76
80
  const flag = args[i] as string;
77
81
  if (flag === "--from-env") fromEnv = true;
78
82
  else if (flag === "--status") status = true;
79
- else if (flag === "--adapters" || flag === "--owner" || flag === "--discord-app-id") {
83
+ else if (flag === "--broker-agent") brokerAgent = true;
84
+ else if (flag === "--from") {
85
+ const value = args[++i];
86
+ if (value === undefined || value.trim() === "" || value.startsWith("--"))
87
+ throw new SetupError(`${flag} expects a value\n${SETUP_USAGE}`);
88
+ from = value;
89
+ } else if (flag === "--adapters" || flag === "--owner" || flag === "--discord-app-id") {
80
90
  const value = args[++i];
81
91
  if (value === undefined || value.trim() === "" || value.startsWith("--"))
82
92
  throw new SetupError(`${flag} expects a value\n${SETUP_USAGE}`);
@@ -89,14 +99,20 @@ export function parseSetupArgs(args: readonly string[]): ParsedSetupArgs {
89
99
  );
90
100
  } else throw new SetupError(`unknown option: ${flag.split("=")[0]}\n${SETUP_USAGE}`);
91
101
  }
92
- if (status && (fromEnv || adapters || owners.length > 0 || discordAppId))
102
+ if (status && (fromEnv || brokerAgent || from !== undefined || adapters || owners.length > 0 || discordAppId))
93
103
  throw new SetupError(`--status takes no other options\n${SETUP_USAGE}`);
104
+ if (brokerAgent && (fromEnv || adapters || owners.length > 0 || discordAppId))
105
+ throw new SetupError(
106
+ `--broker-agent is standalone and cannot be combined with adapter setup options\n${SETUP_USAGE}`,
107
+ );
108
+ if (from !== undefined && !brokerAgent) throw new SetupError(`--from requires --broker-agent\n${SETUP_USAGE}`);
94
109
  return {
95
- mode: status ? "status" : "setup",
110
+ mode: status ? "status" : brokerAgent ? "broker-agent" : "setup",
96
111
  fromEnv,
97
112
  ...(adapters ? { adapters } : {}),
98
113
  owners,
99
114
  ...(discordAppId ? { discordAppId } : {}),
115
+ ...(from === undefined ? {} : { from }),
100
116
  };
101
117
  }
102
118
 
@@ -182,6 +198,8 @@ export interface ValidatedIdentity {
182
198
  readonly summary: string;
183
199
  /** A user id the platform names as the app owner, when it names one. */
184
200
  readonly ownerId?: string;
201
+ /** Application ID (Discord only). */
202
+ readonly appId?: string;
185
203
  }
186
204
 
187
205
  async function requestJson(
@@ -241,7 +259,8 @@ export async function validateDiscord(
241
259
  const ownerId = !hasTeam && typeof owner?.id === "string" ? owner.id : undefined;
242
260
  return {
243
261
  platform: "discord",
244
- summary: `bot ${String(me.body.username ?? "?")} (${String(me.body.id ?? "?")}) of application ${appId}`,
262
+ summary: `bot named ${String(me.body.username ?? "?")} (${String(me.body.id ?? "?")}) of application ${appId}`,
263
+ appId,
245
264
  ...(ownerId ? { ownerId } : {}),
246
265
  };
247
266
  }
@@ -472,13 +491,16 @@ async function askRequiredSecret(prompter: Prompter, question: string): Promise<
472
491
  export async function tokensFromPrompts(
473
492
  prompter: Prompter,
474
493
  parsed: ParsedSetupArgs,
494
+ log?: (line: string) => void,
475
495
  ): Promise<{ tokens: SetupTokens; owners: string[] }> {
476
496
  const wanted = parsed.adapters ?? [];
477
497
  const enable = async (platform: SetupPlatform, label: string) =>
478
498
  parsed.adapters ? wanted.includes(platform) : await askYes(prompter, `Enable ${label}?`);
479
499
  const tokens: { -readonly [K in keyof SetupTokens]: SetupTokens[K] } = {};
480
500
  if (await enable("discord", "Discord")) {
481
- const token = await askRequiredSecret(prompter, "Discord bot token (input hidden): ");
501
+ const logger = log ?? (() => {});
502
+ printDiscordSetupGuide(logger);
503
+ const token = await askRequiredSecret(prompter, "Paste your Discord bot token here (input hidden): ");
482
504
  const appId = parsed.discordAppId ?? (await prompter.ask("Discord application id (optional): ")).trim();
483
505
  tokens.discord = { token, ...(appId ? { appId } : {}) };
484
506
  }
@@ -554,12 +576,40 @@ export function terminalPrompter(): Prompter {
554
576
  };
555
577
  }
556
578
 
579
+ export interface BrokerAgentRunResult {
580
+ readonly exitCode: number;
581
+ readonly stdout: string;
582
+ readonly stderr: string;
583
+ }
584
+
585
+ export type BrokerAgentRunner = (command: readonly string[]) => Promise<BrokerAgentRunResult>;
586
+
587
+ async function defaultBrokerAgentRunner(command: readonly string[]): Promise<BrokerAgentRunResult> {
588
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
589
+ const [stdout, stderr, exitCode] = await Promise.all([
590
+ new Response(child.stdout).text(),
591
+ new Response(child.stderr).text(),
592
+ child.exited,
593
+ ]);
594
+ return { exitCode, stdout, stderr };
595
+ }
596
+
597
+ async function setupBinDir(options: RunSetupOptions): Promise<string> {
598
+ if (options.binDir !== undefined) return options.binDir;
599
+ const env = options.env ?? process.env;
600
+ if (env.GAJAEWAY_BIN_DIR) return env.GAJAEWAY_BIN_DIR;
601
+ const executable = await realpath(process.execPath).catch(() => undefined);
602
+ if (executable !== undefined && basename(executable).startsWith("gajaeway")) return dirname(executable);
603
+ throw new SetupError("setup --broker-agent requires the installed gajaeway binary or an explicit bin directory");
604
+ }
557
605
  export interface RunSetupOptions {
558
606
  readonly home: string;
607
+ readonly binDir?: string;
559
608
  readonly env?: NodeJS.ProcessEnv;
560
609
  readonly fetch?: FetchLike;
561
610
  readonly prompter?: () => Prompter;
562
611
  readonly log?: (line: string) => void;
612
+ readonly runBrokerAgent?: BrokerAgentRunner;
563
613
  }
564
614
 
565
615
  /** The whole command. Returns the lines it printed so tests can assert nothing secret escaped. */
@@ -570,6 +620,23 @@ export async function runSetup(args: readonly string[], options: RunSetupOptions
570
620
  log((await enabledAdapters(options.home)).join(","));
571
621
  return;
572
622
  }
623
+ if (parsed.mode === "broker-agent") {
624
+ const command = [
625
+ join(await setupBinDir(options), "gajaeway-gateway"),
626
+ "broker-agent",
627
+ "provision",
628
+ "--mode",
629
+ "force",
630
+ ...(parsed.from === undefined ? [] : ["--from", parsed.from]),
631
+ ];
632
+ const result = await (options.runBrokerAgent ?? defaultBrokerAgentRunner)(command);
633
+ if (result.stdout.length > 0) process.stdout.write(result.stdout);
634
+ if (result.stderr.length > 0) process.stderr.write(result.stderr);
635
+ if (result.exitCode === 1 && /lease_held name=broker/.test(`${result.stdout}\n${result.stderr}`))
636
+ log("broker lease is held; stop it with: systemctl --user stop gajaeway-broker");
637
+ process.exitCode = result.exitCode;
638
+ return;
639
+ }
573
640
  let tokens: SetupTokens;
574
641
  let owners: string[];
575
642
  if (parsed.fromEnv) {
@@ -578,7 +645,7 @@ export async function runSetup(args: readonly string[], options: RunSetupOptions
578
645
  } else {
579
646
  const prompter = (options.prompter ?? terminalPrompter)();
580
647
  try {
581
- ({ tokens, owners } = await tokensFromPrompts(prompter, parsed));
648
+ ({ tokens, owners } = await tokensFromPrompts(prompter, parsed, log));
582
649
  } finally {
583
650
  prompter.close();
584
651
  }
@@ -587,6 +654,14 @@ export async function runSetup(args: readonly string[], options: RunSetupOptions
587
654
  try {
588
655
  const identities = await validateAll(tokens, options.fetch ?? ((input, init) => fetch(input, init)));
589
656
  for (const identity of identities) log(`${identity.platform}: token valid — ${identity.summary}`);
657
+
658
+ // Print Discord invite URL after successful validation
659
+ const discordIdentity = identities.find((i) => i.platform === "discord");
660
+ if (discordIdentity && tokens.discord && discordIdentity.appId) {
661
+ const botName = (discordIdentity.summary.match(/named (\S+)/) ?? [])[1];
662
+ printDiscordBotReady(log, discordIdentity.appId, botName);
663
+ }
664
+
590
665
  for (const identity of identities)
591
666
  if (identity.ownerId && owners.length === 0) {
592
667
  owners.push(identity.ownerId);
package/src/up.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { GajaewayClient } from "@gajae-gateway/sdk";
4
+ import type { RunSetupOptions } from "./setup";
5
+ import { enabledAdapters, runSetup } from "./setup";
6
+
7
+ export interface UpOptions extends Omit<RunSetupOptions, "home"> {
8
+ readonly home: string;
9
+ readonly socket?: string;
10
+ /** Skip the setup check and assume config exists. */
11
+ readonly skipSetup?: boolean;
12
+ /** Spawn runner for tests; defaults to Bun.spawn */
13
+ readonly spawn?: (cmd: string, args: string[], opts?: { cwd?: string }) => unknown;
14
+ }
15
+
16
+ /**
17
+ * `gajaeway up`: One-command setup and start for first-time users.
18
+ *
19
+ * Checks for existing config.json; if missing, runs interactive setup.
20
+ * Then starts the gateway daemon and enabled adapters in the background.
21
+ * Reports the gateway status and how to stop it.
22
+ */
23
+ export async function runUp(options: UpOptions): Promise<void> {
24
+ const log = options.log ?? ((line: string) => console.log(line));
25
+ const configPath = join(options.home, "config.json");
26
+
27
+ // Check if setup is needed
28
+ if (!options.skipSetup) {
29
+ let exists = false;
30
+ try {
31
+ await access(configPath);
32
+ exists = true;
33
+ } catch {
34
+ // File does not exist
35
+ }
36
+
37
+ if (!exists) {
38
+ log("No configuration found. Running setup...\n");
39
+ try {
40
+ // Run setup in default (interactive) mode
41
+ await runSetup([], {
42
+ home: options.home,
43
+ env: options.env,
44
+ fetch: options.fetch,
45
+ prompter: options.prompter,
46
+ log,
47
+ });
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ log(`Setup failed: ${message}`);
51
+ throw error;
52
+ }
53
+
54
+ log("\n✓ Configuration saved.\n");
55
+ }
56
+ }
57
+
58
+ // Check gateway status
59
+ const socket = options.socket ?? `${options.home}/gateway.sock`;
60
+ let isRunning = false;
61
+
62
+ try {
63
+ const client = await GajaewayClient.connectSocket(socket);
64
+ try {
65
+ await client.status();
66
+ isRunning = true;
67
+ } finally {
68
+ await client.close();
69
+ }
70
+ } catch {
71
+ // Gateway is not running; we'll try to start it
72
+ }
73
+
74
+ if (isRunning) {
75
+ log("✓ Gateway is already running.");
76
+ const adapters = await enabledAdapters(options.home);
77
+ log(`✓ Enabled adapters: ${adapters.join(", ")}`);
78
+ log("\nYou can now:");
79
+ log(" • Send a message to your bot on Discord/Slack/Telegram");
80
+ log(" • View status: gajaeway ops cycle");
81
+ log(" • Search memory: gajaeway memory search <query>");
82
+ log("\nStop with: gajaeway shutdown");
83
+ } else {
84
+ // Try to start the gateway and adapters
85
+ log("Starting gateway and adapters...");
86
+
87
+ // Detect if we're in the source checkout (has dist/gajaeway-gateway)
88
+ // vs. running from installed binaries
89
+ const isSourceCheckout = await access(join(options.home, "..", "..", "dist", "gajaeway-gateway"))
90
+ .then(() => true)
91
+ .catch(() => false);
92
+
93
+ const spawn = options.spawn ?? ((cmd: string, args: string[]) => Bun.spawn([cmd, ...args]));
94
+
95
+ try {
96
+ // Start gateway daemon
97
+ // Use bun packages/gateway/src/main.ts if in source checkout, else use binary
98
+ const gatewayCmd = isSourceCheckout ? "bun" : "gajaeway-gateway";
99
+ const gatewayArgs = isSourceCheckout ? ["packages/gateway/src/main.ts", "daemon"] : ["daemon"];
100
+
101
+ const gatewayProcess = spawn(gatewayCmd, gatewayArgs, { cwd: process.cwd() });
102
+
103
+ // Give gateway a moment to start
104
+ await new Promise((resolve) => setTimeout(resolve, 2000));
105
+
106
+ // Start enabled adapters
107
+ const adapters = await enabledAdapters(options.home);
108
+ for (const adapter of adapters) {
109
+ const adapterCmd = isSourceCheckout ? "dist/gajaeway-" + adapter : "gajaeway-" + adapter;
110
+ const adapterProcess = spawn(adapterCmd, [], { cwd: process.cwd() });
111
+ // Don't wait for adapter; let it run in background
112
+ }
113
+
114
+ log("\n✓ Gateway started.");
115
+ if (adapters.length > 0) {
116
+ log(`✓ Adapters started: ${adapters.join(", ")}`);
117
+ }
118
+
119
+ log("\nYou can now:");
120
+ log(" • Send a message to your bot on Discord/Slack/Telegram");
121
+ log(" • View status: gajaeway ops cycle");
122
+ log(" • Search memory: gajaeway memory search <query>");
123
+ log("\nStop with: gajaeway shutdown");
124
+ } catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ log(`Failed to start services: ${message}`);
127
+ log("\nAlternative: start manually in separate terminals:");
128
+ log(" Terminal 1: bun packages/gateway/src/main.ts daemon");
129
+ log(" Terminal 2: dist/gajaeway-discord");
130
+ throw error;
131
+ }
132
+ }
133
+ }