@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/CLAUDE.md +4 -3
- package/README.md +38 -6
- package/bin/ove.ts +21 -2
- package/config.example.json +3 -0
- package/docs/examples.md +65 -3
- package/docs/favicon.ico +0 -0
- package/docs/index.html +25 -8
- package/docs/plans/2026-02-21-codex-runner-design.md +51 -0
- package/docs/plans/2026-02-21-codex-runner-plan.md +475 -0
- package/package.json +1 -1
- package/src/config.test.ts +52 -2
- package/src/config.ts +39 -1
- package/src/index.ts +76 -12
- package/src/router.test.ts +25 -0
- package/src/router.ts +11 -0
- package/src/runner.ts +1 -0
- package/src/runners/codex.test.ts +85 -0
- package/src/runners/codex.ts +137 -0
- package/src/setup.test.ts +87 -20
- package/src/setup.ts +180 -54
- package/docs/CNAME +0 -1
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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\
|
|
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("
|
|
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, "
|
|
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({
|