@lovenyberg/ove 0.1.1 → 0.2.1

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/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { loadConfig, isAuthorized, getUserRepos } from "./config";
2
+ import { loadConfig, isAuthorized, getUserRepos, addRepo, addUser } from "./config";
3
3
  import { TaskQueue } from "./queue";
4
4
  import { RepoManager } from "./repos";
5
5
  import { ClaudeRunner } from "./runners/claude";
6
+ import { CodexRunner } from "./runners/codex";
6
7
  import { parseMessage, buildContextualPrompt } from "./router";
7
8
  import { SlackAdapter } from "./adapters/slack";
8
9
  import { WhatsAppAdapter } from "./adapters/whatsapp";
@@ -13,7 +14,7 @@ import { HttpApiAdapter } from "./adapters/http";
13
14
  import { GitHubAdapter } from "./adapters/github";
14
15
  import type { ChatAdapter, IncomingMessage } from "./adapters/types";
15
16
  import type { EventAdapter, IncomingEvent } from "./adapters/types";
16
- import type { AgentRunner, StatusEvent } from "./runner";
17
+ import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
17
18
  import { logger } from "./logger";
18
19
  import { SessionStore } from "./sessions";
19
20
  import { startCronLoop } from "./cron";
@@ -42,7 +43,38 @@ const queue = new TaskQueue(db);
42
43
  const repos = new RepoManager(config.reposDir);
43
44
  const sessions = new SessionStore(db);
44
45
  const schedules = new ScheduleStore(db);
45
- const runner: AgentRunner = new ClaudeRunner();
46
+ const runners = new Map<string, AgentRunner>();
47
+
48
+ function getRunner(name: string = "claude"): AgentRunner {
49
+ let r = runners.get(name);
50
+ if (!r) {
51
+ switch (name) {
52
+ case "codex":
53
+ r = new CodexRunner();
54
+ break;
55
+ case "claude":
56
+ default:
57
+ r = new ClaudeRunner();
58
+ break;
59
+ }
60
+ runners.set(name, r);
61
+ }
62
+ return r;
63
+ }
64
+
65
+ function getRunnerForRepo(repo: string): AgentRunner {
66
+ const repoRunner = config.repos[repo]?.runner;
67
+ const globalRunner = config.runner;
68
+ const name = repoRunner?.name || globalRunner?.name || "claude";
69
+ return getRunner(name);
70
+ }
71
+
72
+ function getRunnerOptsForRepo(repo: string, baseOpts: RunOptions): RunOptions {
73
+ const repoRunner = config.repos[repo]?.runner;
74
+ const globalRunner = config.runner;
75
+ const model = repoRunner?.model || globalRunner?.model;
76
+ return model ? { ...baseOpts, model } : baseOpts;
77
+ }
46
78
 
47
79
  // Reply callback map — stores original message for replying after task completion
48
80
  const pendingReplies = new Map<string, IncomingMessage>();
@@ -169,6 +201,7 @@ async function handleMessage(msg: IncomingMessage) {
169
201
  "• validate <repo> — run tests, unlike some people",
170
202
  "• discuss <topic> — I'll brainstorm, but no promises I'll be nice",
171
203
  "• create project <name> [with template <type>]",
204
+ "• init repo <name> <git-url> [branch] — set up a repo from chat",
172
205
  "• status / history / clear",
173
206
  "• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
174
207
  "• list schedules — see your scheduled tasks",
@@ -216,7 +249,7 @@ async function handleMessage(msg: IncomingMessage) {
216
249
  const userRepos = getUserRepos(config, msg.userId);
217
250
 
218
251
  if (userRepos.length === 0) {
219
- await msg.reply("You don't have access to any repos. Can't schedule what I can't reach.");
252
+ await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
220
253
  return;
221
254
  }
222
255
 
@@ -265,7 +298,8 @@ async function handleMessage(msg: IncomingMessage) {
265
298
  await msg.updateStatus("Thinking...");
266
299
 
267
300
  try {
268
- const result = await runner.run(
301
+ const discussRunner = getRunner(config.runner?.name);
302
+ const result = await discussRunner.run(
269
303
  prompt,
270
304
  config.reposDir,
271
305
  { maxTurns: 5 },
@@ -306,6 +340,32 @@ async function handleMessage(msg: IncomingMessage) {
306
340
  return;
307
341
  }
308
342
 
343
+ // Init repo — onboarding a new repo from chat
344
+ if (parsed.type === "init-repo") {
345
+ const { name, url, branch } = parsed.args;
346
+
347
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
348
+ await msg.reply("Repo name must be alphanumeric, dashes, or underscores. Try again.");
349
+ return;
350
+ }
351
+
352
+ if (config.repos[name]) {
353
+ // Repo exists — just grant access
354
+ addUser(config, msg.userId, msg.userId, [name]);
355
+ const reply = `Repo "${name}" already exists. I've added you to it. Go ahead.`;
356
+ await msg.reply(reply);
357
+ sessions.addMessage(msg.userId, "assistant", reply);
358
+ return;
359
+ }
360
+
361
+ addRepo(config, name, url, branch);
362
+ addUser(config, msg.userId, msg.userId, [name]);
363
+ const reply = `Fine. Added repo "${name}" (${url}, branch: ${branch}). You're good to go — ask me to do something on ${name}.`;
364
+ await msg.reply(reply);
365
+ sessions.addMessage(msg.userId, "assistant", reply);
366
+ return;
367
+ }
368
+
309
369
  // Need a repo for task commands
310
370
  if (!parsed.repo) {
311
371
  const userRepos = getUserRepos(config, msg.userId);
@@ -317,7 +377,8 @@ async function handleMessage(msg: IncomingMessage) {
317
377
  sessions.addMessage(msg.userId, "assistant", reply);
318
378
  return;
319
379
  } else {
320
- await msg.reply("You don't have access to any repos. Talk to someone about that.");
380
+ const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
381
+ await msg.reply(reply);
321
382
  return;
322
383
  }
323
384
  }
@@ -441,13 +502,16 @@ async function processTask(task: import("./queue").Task) {
441
502
  await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: config.mcpServers }));
442
503
  }
443
504
 
444
- const result = await runner.run(
505
+ const taskRunner = getRunnerForRepo(task.repo);
506
+ const runOpts = getRunnerOptsForRepo(task.repo, {
507
+ maxTurns: config.claude.maxTurns,
508
+ mcpConfigPath,
509
+ });
510
+
511
+ const result = await taskRunner.run(
445
512
  task.prompt,
446
513
  workDir,
447
- {
448
- maxTurns: config.claude.maxTurns,
449
- mcpConfigPath,
450
- },
514
+ runOpts,
451
515
  (event: StatusEvent) => {
452
516
  if (event.kind === "tool") {
453
517
  statusLog.push(`${event.tool}: ${event.input}`);
@@ -528,7 +592,7 @@ async function workerLoop() {
528
592
 
529
593
  // Main
530
594
  async function main() {
531
- logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: runner.name });
595
+ logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
532
596
 
533
597
  for (const adapter of adapters) {
534
598
  await adapter.start(handleMessage);
@@ -124,6 +124,31 @@ describe("parseMessage", () => {
124
124
  expect(result.args.scheduleId).toBe(1);
125
125
  });
126
126
 
127
+ it("parses init repo with SSH URL", () => {
128
+ const result = parseMessage("init repo my-app git@github.com:user/my-app.git");
129
+ expect(result.type).toBe("init-repo");
130
+ expect(result.args.name).toBe("my-app");
131
+ expect(result.args.url).toBe("git@github.com:user/my-app.git");
132
+ expect(result.args.branch).toBe("main");
133
+ });
134
+
135
+ it("parses setup repo with HTTPS URL", () => {
136
+ const result = parseMessage("setup repo my-app https://github.com/user/my-app.git");
137
+ expect(result.type).toBe("init-repo");
138
+ expect(result.args.url).toBe("https://github.com/user/my-app.git");
139
+ });
140
+
141
+ it("parses add repo with custom branch", () => {
142
+ const result = parseMessage("add repo my-app git@github.com:org/my-app.git develop");
143
+ expect(result.type).toBe("init-repo");
144
+ expect(result.args.branch).toBe("develop");
145
+ });
146
+
147
+ it("does not match init repo without URL", () => {
148
+ const result = parseMessage("init repo my-app");
149
+ expect(result.type).not.toBe("init-repo");
150
+ });
151
+
127
152
  it("falls back to free-form for unrecognized input", () => {
128
153
  const result = parseMessage("what does the auth middleware do in my-app");
129
154
  expect(result.type).toBe("free-form");
package/src/router.ts CHANGED
@@ -5,6 +5,7 @@ export type MessageType =
5
5
  | "validate"
6
6
  | "discuss"
7
7
  | "create-project"
8
+ | "init-repo"
8
9
  | "free-form"
9
10
  | "status"
10
11
  | "history"
@@ -84,6 +85,16 @@ export function parseMessage(text: string): ParsedMessage {
84
85
  return { type: "discuss", args: { topic: trimmed }, rawText: trimmed };
85
86
  }
86
87
 
88
+ // init repo <name> <url> [branch]
89
+ const initRepoMatch = trimmed.match(/^(?:init|setup|add)\s+repo\s+(\S+)\s+((?:git@|https:\/\/)\S+)(?:\s+(\S+))?$/i);
90
+ if (initRepoMatch) {
91
+ return {
92
+ type: "init-repo",
93
+ args: { name: initRepoMatch[1], url: initRepoMatch[2], branch: initRepoMatch[3] || "main" },
94
+ rawText: trimmed,
95
+ };
96
+ }
97
+
87
98
  const repoHint = trimmed.match(/(?:in|on)\s+(\S+)\s*$/i);
88
99
  return { type: "free-form", repo: repoHint?.[1], args: {}, rawText: trimmed };
89
100
  }
package/src/runner.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface RunOptions {
2
2
  maxTurns: number;
3
3
  mcpConfigPath?: string;
4
+ model?: string;
4
5
  }
5
6
 
6
7
  export interface RunResult {
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { CodexRunner, summarizeCodexItem } from "./codex";
3
+
4
+ describe("CodexRunner", () => {
5
+ const runner = new CodexRunner();
6
+
7
+ it("has correct name", () => {
8
+ expect(runner.name).toBe("codex");
9
+ });
10
+
11
+ it("builds correct args for a prompt", () => {
12
+ const args = runner.buildArgs("fix the bug", "/tmp/work", {
13
+ maxTurns: 25,
14
+ });
15
+ expect(args).toContain("exec");
16
+ expect(args).toContain("--json");
17
+ expect(args).toContain("--dangerously-bypass-approvals-and-sandbox");
18
+ expect(args).toContain("--skip-git-repo-check");
19
+ expect(args).toContain("--ephemeral");
20
+ expect(args).toContain("-C");
21
+ expect(args).toContain("/tmp/work");
22
+ expect(args).toContain("fix the bug");
23
+ });
24
+
25
+ it("includes model flag when provided", () => {
26
+ const args = runner.buildArgs("test", "/tmp/work", {
27
+ maxTurns: 25,
28
+ model: "o3",
29
+ });
30
+ expect(args).toContain("-m");
31
+ expect(args).toContain("o3");
32
+ });
33
+
34
+ it("omits model flag when not provided", () => {
35
+ const args = runner.buildArgs("test", "/tmp/work", { maxTurns: 25 });
36
+ expect(args).not.toContain("-m");
37
+ });
38
+
39
+ it("ignores mcpConfigPath (not supported by codex CLI)", () => {
40
+ const args = runner.buildArgs("test", "/tmp/work", {
41
+ maxTurns: 25,
42
+ mcpConfigPath: "/tmp/mcp.json",
43
+ });
44
+ expect(args).not.toContain("--mcp-config");
45
+ expect(args).not.toContain("/tmp/mcp.json");
46
+ });
47
+ });
48
+
49
+ describe("summarizeCodexItem", () => {
50
+ it("summarizes command_execution", () => {
51
+ expect(
52
+ summarizeCodexItem({ type: "command_execution", command: "bun test" })
53
+ ).toEqual({ tool: "shell", input: "bun test" });
54
+ });
55
+
56
+ it("summarizes file_change with paths", () => {
57
+ const result = summarizeCodexItem({
58
+ type: "file_change",
59
+ changes: [
60
+ { path: "src/a.ts", kind: "update" },
61
+ { path: "src/b.ts", kind: "add" },
62
+ ],
63
+ });
64
+ expect(result).toEqual({ tool: "file_change", input: "src/a.ts, src/b.ts" });
65
+ });
66
+
67
+ it("summarizes mcp_tool_call", () => {
68
+ const result = summarizeCodexItem({
69
+ type: "mcp_tool_call",
70
+ tool: "search",
71
+ arguments: '{"q":"hello"}',
72
+ });
73
+ expect(result).toEqual({ tool: "search", input: '{"q":"hello"}' });
74
+ });
75
+
76
+ it("returns null for agent_message", () => {
77
+ expect(
78
+ summarizeCodexItem({ type: "agent_message", text: "done" })
79
+ ).toBeNull();
80
+ });
81
+
82
+ it("returns null for unknown types", () => {
83
+ expect(summarizeCodexItem({ type: "reasoning", text: "thinking" })).toBeNull();
84
+ });
85
+ });
@@ -0,0 +1,137 @@
1
+ import type {
2
+ AgentRunner,
3
+ RunOptions,
4
+ RunResult,
5
+ StatusCallback,
6
+ } from "../runner";
7
+ import { logger } from "../logger";
8
+ import { which } from "bun";
9
+ import { realpathSync } from "node:fs";
10
+
11
+ export function summarizeCodexItem(
12
+ item: any
13
+ ): { tool: string; input: string } | null {
14
+ if (!item) return null;
15
+ switch (item.type) {
16
+ case "command_execution":
17
+ return { tool: "shell", input: item.command || "" };
18
+ case "file_change": {
19
+ const paths = (item.changes || [])
20
+ .map((c: any) => c.path)
21
+ .filter(Boolean)
22
+ .join(", ");
23
+ return { tool: "file_change", input: paths };
24
+ }
25
+ case "mcp_tool_call":
26
+ return {
27
+ tool: item.tool || "mcp",
28
+ input:
29
+ typeof item.arguments === "string"
30
+ ? item.arguments
31
+ : JSON.stringify(item.arguments || "").slice(0, 80),
32
+ };
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export class CodexRunner implements AgentRunner {
39
+ name = "codex";
40
+ private codexPath: string;
41
+
42
+ constructor() {
43
+ const found = which("codex");
44
+ this.codexPath = found ? realpathSync(found) : "codex";
45
+ }
46
+
47
+ buildArgs(prompt: string, workDir: string, opts: RunOptions): string[] {
48
+ const args = [
49
+ "exec",
50
+ "--json",
51
+ "--dangerously-bypass-approvals-and-sandbox",
52
+ "--skip-git-repo-check",
53
+ "--ephemeral",
54
+ "-C",
55
+ workDir,
56
+ ];
57
+ if (opts.model) args.push("-m", opts.model);
58
+ args.push(prompt);
59
+ return args;
60
+ }
61
+
62
+ async run(
63
+ prompt: string,
64
+ workDir: string,
65
+ opts: RunOptions,
66
+ onStatus?: StatusCallback
67
+ ): Promise<RunResult> {
68
+ const args = this.buildArgs(prompt, workDir, opts);
69
+ const startTime = Date.now();
70
+ logger.info("starting codex task", {
71
+ workDir,
72
+ model: opts.model,
73
+ codexPath: this.codexPath,
74
+ });
75
+
76
+ const proc = Bun.spawn([this.codexPath, ...args], {
77
+ cwd: workDir,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+
82
+ let lastAgentMessage: string | null = null;
83
+ let errorMessage: string | null = null;
84
+ const decoder = new TextDecoder();
85
+ const reader = proc.stdout.getReader();
86
+
87
+ try {
88
+ let buffer = "";
89
+ while (true) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ buffer += decoder.decode(value, { stream: true });
93
+ const lines = buffer.split("\n");
94
+ buffer = lines.pop() || "";
95
+ for (const line of lines) {
96
+ if (!line.trim()) continue;
97
+ try {
98
+ const event = JSON.parse(line);
99
+ if (
100
+ event.type === "item.completed" &&
101
+ event.item?.type === "agent_message"
102
+ ) {
103
+ lastAgentMessage = event.item.text || "";
104
+ if (onStatus) onStatus({ kind: "text", text: lastAgentMessage });
105
+ }
106
+ if (event.type === "item.started" && event.item) {
107
+ const summary = summarizeCodexItem(event.item);
108
+ if (summary && onStatus) {
109
+ onStatus({ kind: "tool", ...summary });
110
+ }
111
+ }
112
+ if (event.type === "turn.failed") {
113
+ errorMessage = event.error?.message || "Turn failed";
114
+ }
115
+ } catch {}
116
+ }
117
+ }
118
+ } finally {
119
+ reader.releaseLock();
120
+ }
121
+
122
+ const exitCode = await proc.exited;
123
+ const durationMs = Date.now() - startTime;
124
+
125
+ if (exitCode !== 0) {
126
+ const stderr = await new Response(proc.stderr).text();
127
+ const output = errorMessage || stderr || "Codex task failed";
128
+ logger.error("codex task failed", { exitCode, output, durationMs });
129
+ return { success: false, output, durationMs };
130
+ }
131
+
132
+ const finalOutput =
133
+ lastAgentMessage || "Task completed (no output)";
134
+ logger.info("codex task completed", { durationMs });
135
+ return { success: true, output: finalOutput, durationMs };
136
+ }
137
+ }
package/src/setup.test.ts CHANGED
@@ -4,29 +4,30 @@ import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { validateConfig } from "./setup";
6
6
 
7
+ const TRANSPORT_ENV_KEYS = [
8
+ "CLI_MODE", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN",
9
+ "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "WHATSAPP_ENABLED",
10
+ "HTTP_API_PORT", "HTTP_API_KEY", "GITHUB_POLL_REPOS",
11
+ ] as const;
12
+
7
13
  describe("validateConfig", () => {
8
14
  let dir: string;
9
- const origCliMode = process.env.CLI_MODE;
10
- const origSlackBot = process.env.SLACK_BOT_TOKEN;
11
- const origSlackApp = process.env.SLACK_APP_TOKEN;
15
+ const origEnv: Record<string, string | undefined> = {};
12
16
 
13
17
  beforeEach(() => {
14
18
  dir = mkdtempSync(join(tmpdir(), "ove-setup-test-"));
15
- // Clear env to avoid interference
16
- delete process.env.CLI_MODE;
17
- delete process.env.SLACK_BOT_TOKEN;
18
- delete process.env.SLACK_APP_TOKEN;
19
+ for (const key of TRANSPORT_ENV_KEYS) {
20
+ origEnv[key] = process.env[key];
21
+ delete process.env[key];
22
+ }
19
23
  });
20
24
 
21
25
  afterEach(() => {
22
26
  rmSync(dir, { recursive: true, force: true });
23
- // Restore env
24
- if (origCliMode !== undefined) process.env.CLI_MODE = origCliMode;
25
- else delete process.env.CLI_MODE;
26
- if (origSlackBot !== undefined) process.env.SLACK_BOT_TOKEN = origSlackBot;
27
- else delete process.env.SLACK_BOT_TOKEN;
28
- if (origSlackApp !== undefined) process.env.SLACK_APP_TOKEN = origSlackApp;
29
- else delete process.env.SLACK_APP_TOKEN;
27
+ for (const key of TRANSPORT_ENV_KEYS) {
28
+ if (origEnv[key] !== undefined) process.env[key] = origEnv[key];
29
+ else delete process.env[key];
30
+ }
30
31
  });
31
32
 
32
33
  it("reports missing config.json", () => {
@@ -105,14 +106,14 @@ describe("validateConfig", () => {
105
106
  expect(result.issues).toContain("SLACK_APP_TOKEN is a placeholder");
106
107
  });
107
108
 
108
- it("skips Slack token checks when CLI_MODE=true in env file", () => {
109
+ it("skips Slack validation when no Slack tokens present", () => {
109
110
  const configPath = join(dir, "config.json");
110
111
  const envPath = join(dir, ".env");
111
112
  writeFileSync(configPath, JSON.stringify({
112
113
  repos: { app: { url: "git@github.com:o/a.git", defaultBranch: "main" } },
113
114
  users: { "cli:local": { name: "test", repos: ["app"] } },
114
115
  }));
115
- writeFileSync(envPath, "CLI_MODE=true\nSLACK_BOT_TOKEN=xoxb-...\n");
116
+ writeFileSync(envPath, "CLI_MODE=true\n");
116
117
 
117
118
  const result = validateConfig({ configPath, envPath });
118
119
 
@@ -120,16 +121,82 @@ describe("validateConfig", () => {
120
121
  expect(result.issues).toEqual([]);
121
122
  });
122
123
 
123
- it("skips Slack token checks when CLI_MODE=true in process.env", () => {
124
+ it("still validates Slack placeholders even in CLI_MODE", () => {
125
+ const configPath = join(dir, "config.json");
126
+ const envPath = join(dir, ".env");
127
+ writeFileSync(configPath, JSON.stringify({
128
+ repos: { app: { url: "git@github.com:o/a.git", defaultBranch: "main" } },
129
+ users: { "cli:local": { name: "test", repos: ["app"] } },
130
+ }));
131
+ writeFileSync(envPath, "CLI_MODE=true\nSLACK_BOT_TOKEN=xoxb-...\n");
132
+
133
+ const result = validateConfig({ configPath, envPath });
134
+
135
+ expect(result.valid).toBe(false);
136
+ expect(result.issues).toContain("SLACK_BOT_TOKEN is a placeholder");
137
+ });
138
+
139
+ it("reports no transport configured", () => {
124
140
  const configPath = join(dir, "config.json");
125
141
  const envPath = join(dir, ".env");
126
142
  writeFileSync(configPath, JSON.stringify({
127
143
  repos: { app: { url: "git@github.com:o/a.git", defaultBranch: "main" } },
128
144
  users: { "cli:local": { name: "test", repos: ["app"] } },
129
145
  }));
130
- writeFileSync(envPath, "SLACK_BOT_TOKEN=xoxb-...\n");
146
+ writeFileSync(envPath, "REPOS_DIR=./repos\n");
147
+
148
+ const result = validateConfig({ configPath, envPath });
149
+
150
+ expect(result.valid).toBe(false);
151
+ expect(result.issues).toContain("No transport configured");
152
+ });
153
+
154
+ it("valid with Telegram-only config", () => {
155
+ const configPath = join(dir, "config.json");
156
+ const envPath = join(dir, ".env");
157
+ writeFileSync(configPath, JSON.stringify({
158
+ repos: { app: { url: "git@github.com:o/a.git", defaultBranch: "main" } },
159
+ users: { "telegram:123456": { name: "test", repos: ["app"] } },
160
+ }));
161
+ writeFileSync(envPath, "TELEGRAM_BOT_TOKEN=123456:ABC-DEF\n");
162
+
163
+ const result = validateConfig({ configPath, envPath });
164
+
165
+ expect(result.valid).toBe(true);
166
+ expect(result.issues).toEqual([]);
167
+ });
168
+
169
+ it("valid with Discord-only config", () => {
170
+ const configPath = join(dir, "config.json");
171
+ const envPath = join(dir, ".env");
172
+ writeFileSync(configPath, JSON.stringify({
173
+ repos: { app: { url: "git@github.com:o/a.git", defaultBranch: "main" } },
174
+ users: { "discord:987654": { name: "test", repos: ["app"] } },
175
+ }));
176
+ writeFileSync(envPath, "DISCORD_BOT_TOKEN=MTIz-abc\n");
177
+
178
+ const result = validateConfig({ configPath, envPath });
179
+
180
+ expect(result.valid).toBe(true);
181
+ expect(result.issues).toEqual([]);
182
+ });
183
+
184
+ it("valid with multiple transports", () => {
185
+ const configPath = join(dir, "config.json");
186
+ const envPath = join(dir, ".env");
187
+ writeFileSync(configPath, JSON.stringify({
188
+ repos: { app: { url: "git@github.com:o/a.git", defaultBranch: "main" } },
189
+ users: {
190
+ "slack:U123": { name: "test", repos: ["app"] },
191
+ "telegram:456": { name: "test", repos: ["app"] },
192
+ },
193
+ }));
194
+ writeFileSync(envPath, [
195
+ "SLACK_BOT_TOKEN=xoxb-real-token",
196
+ "SLACK_APP_TOKEN=xapp-real-token",
197
+ "TELEGRAM_BOT_TOKEN=123:ABC",
198
+ ].join("\n") + "\n");
131
199
 
132
- process.env.CLI_MODE = "true";
133
200
  const result = validateConfig({ configPath, envPath });
134
201
 
135
202
  expect(result.valid).toBe(true);
@@ -178,7 +245,7 @@ describe("validateConfig", () => {
178
245
  expect(result.issues).toContain("config.json is invalid JSON");
179
246
  });
180
247
 
181
- it("returns valid for complete config", () => {
248
+ it("returns valid for complete Slack config", () => {
182
249
  const configPath = join(dir, "config.json");
183
250
  const envPath = join(dir, ".env");
184
251
  writeFileSync(configPath, JSON.stringify({