@lovenyberg/ove 0.5.0 → 0.5.2
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/README.md +10 -3
- package/bun.lock +1 -1
- package/docs/index.html +194 -196
- package/docs/plans/2026-02-25-landing-page-harmonization-design.md +89 -0
- package/docs/plans/2026-02-25-landing-page-harmonization-plan.md +604 -0
- package/package.json +1 -1
- package/src/adapters/http.ts +5 -1
- package/src/handlers.ts +10 -1
- package/src/index.ts +2 -1
- package/src/router.ts +40 -0
- package/src/setup.ts +99 -20
package/src/handlers.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
1
2
|
import { parseMessage, buildContextualPrompt } from "./router";
|
|
2
3
|
import type { ParsedMessage } from "./router";
|
|
3
4
|
import { isAuthorized, getUserRepos, addRepo, addUser } from "./config";
|
|
@@ -332,6 +333,7 @@ async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, histor
|
|
|
332
333
|
await msg.updateStatus("Thinking...");
|
|
333
334
|
|
|
334
335
|
try {
|
|
336
|
+
mkdirSync(deps.config.reposDir, { recursive: true });
|
|
335
337
|
const discussRunner = deps.getRunner(deps.config.runner?.name);
|
|
336
338
|
const result = await discussRunner.run(
|
|
337
339
|
prompt,
|
|
@@ -452,7 +454,7 @@ async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, de
|
|
|
452
454
|
}
|
|
453
455
|
}
|
|
454
456
|
} else {
|
|
455
|
-
const reply =
|
|
457
|
+
const reply = `No repos configured yet. Add one with:\n\`init repo <name> <git-url> [branch]\`\nExample: \`init repo my-app git@github.com:user/my-app.git\``;
|
|
456
458
|
await msg.reply(reply);
|
|
457
459
|
return;
|
|
458
460
|
}
|
|
@@ -521,6 +523,13 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage)
|
|
|
521
523
|
return;
|
|
522
524
|
}
|
|
523
525
|
|
|
526
|
+
// If user has no repos, fall back to discuss mode so they can still chat
|
|
527
|
+
const userRepos = getUserRepos(deps.config, msg.userId);
|
|
528
|
+
if (userRepos.length === 0) {
|
|
529
|
+
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
530
|
+
return handleDiscuss(msg, { ...parsed, type: "discuss", args: { topic: parsed.rawText } }, history, deps);
|
|
531
|
+
}
|
|
532
|
+
|
|
524
533
|
// For all other types (free-form, review-pr, fix-issue, simplify, validate) — task dispatch
|
|
525
534
|
await handleTaskMessage(msg, parsed, deps);
|
|
526
535
|
};
|
package/src/index.ts
CHANGED
package/src/router.ts
CHANGED
|
@@ -116,6 +116,46 @@ export function parseMessage(text: string): ParsedMessage {
|
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Natural language repo setup: "clone org/repo", "setup org/repo", "add org/repo"
|
|
120
|
+
const naturalRepoMatch = trimmed.match(/^(?:clone|setup|add|init|use)\s+(?:(?:the|repo)\s+)?([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\s+(?:repo(?:sitory)?))?\s*$/i);
|
|
121
|
+
if (naturalRepoMatch) {
|
|
122
|
+
const slug = naturalRepoMatch[1];
|
|
123
|
+
const name = slug.split("/").pop()!;
|
|
124
|
+
const url = `git@github.com:${slug}.git`;
|
|
125
|
+
return {
|
|
126
|
+
type: "init-repo",
|
|
127
|
+
args: { name, url, branch: "main" },
|
|
128
|
+
rawText: trimmed,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Detect org/repo or GitHub URLs anywhere in a message that looks like a setup request
|
|
133
|
+
const setupIntent = /(?:clone|setup|add|init|use|start\s+(?:with|on)|work\s+(?:on|with))/i.test(lower);
|
|
134
|
+
if (setupIntent) {
|
|
135
|
+
const ghUrl = trimmed.match(/((?:git@github\.com:|https:\/\/github\.com\/)([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+?)(?:\.git)?)\b/);
|
|
136
|
+
if (ghUrl) {
|
|
137
|
+
const slug = ghUrl[2];
|
|
138
|
+
const name = slug.split("/").pop()!;
|
|
139
|
+
const url = ghUrl[1].endsWith(".git") ? ghUrl[1] : ghUrl[1] + ".git";
|
|
140
|
+
return {
|
|
141
|
+
type: "init-repo",
|
|
142
|
+
args: { name, url: url.startsWith("git@") ? url : `git@github.com:${slug}.git`, branch: "main" },
|
|
143
|
+
rawText: trimmed,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const slugMatch = trimmed.match(/\b([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\b/);
|
|
147
|
+
if (slugMatch && slugMatch[1].indexOf("/") > 0) {
|
|
148
|
+
const slug = slugMatch[1];
|
|
149
|
+
const name = slug.split("/").pop()!;
|
|
150
|
+
const url = `git@github.com:${slug}.git`;
|
|
151
|
+
return {
|
|
152
|
+
type: "init-repo",
|
|
153
|
+
args: { name, url, branch: "main" },
|
|
154
|
+
rawText: trimmed,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
119
159
|
const repoHint = trimmed.match(/(?:in|on)\s+(\S+?)[?.!,]*\s*$/i);
|
|
120
160
|
return { type: "free-form", repo: repoHint?.[1], args: {}, rawText: trimmed };
|
|
121
161
|
}
|
package/src/setup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { resolve, dirname } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import type { Config } from "./config";
|
|
@@ -59,7 +59,9 @@ export function validateConfig(opts?: { configPath?: string; envPath?: string })
|
|
|
59
59
|
if (existsSync(configPath)) {
|
|
60
60
|
try {
|
|
61
61
|
const config = JSON.parse(readFileSync(configPath, "utf-8")) as Partial<Config>;
|
|
62
|
-
|
|
62
|
+
const hasGithubSync = config.github?.orgs && config.github.orgs.length > 0;
|
|
63
|
+
const hasWildcardUser = Object.values(config.users || {}).some(u => u.repos?.includes("*"));
|
|
64
|
+
if (!hasGithubSync && !hasWildcardUser && (!config.repos || Object.keys(config.repos).length === 0)) {
|
|
63
65
|
issues.push("No repos configured");
|
|
64
66
|
}
|
|
65
67
|
if (!config.users || Object.keys(config.users).length === 0) {
|
|
@@ -209,6 +211,8 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
209
211
|
process.stdout.write("\n");
|
|
210
212
|
const port = (await ask(rl, "HTTP API port [3000]")) || "3000";
|
|
211
213
|
envValues.HTTP_API_PORT = port;
|
|
214
|
+
const host = await ask(rl, "Bind address [127.0.0.1] (0.0.0.0 for all interfaces)");
|
|
215
|
+
envValues.HTTP_API_HOST = host || "127.0.0.1";
|
|
212
216
|
const key = await ask(rl, "API key (leave empty to generate)");
|
|
213
217
|
envValues.HTTP_API_KEY = key || crypto.randomUUID();
|
|
214
218
|
}
|
|
@@ -225,23 +229,51 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
225
229
|
envValues.CLI_MODE = "true";
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
//
|
|
232
|
+
// Tracing
|
|
233
|
+
if (!fixing) {
|
|
234
|
+
process.stdout.write("\n");
|
|
235
|
+
const enableTrace = await ask(rl, "Enable task tracing? (y/n)");
|
|
236
|
+
if (enableTrace.toLowerCase() === "y") {
|
|
237
|
+
envValues.OVE_TRACE = "true";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Collect repos — GitHub sync or manual
|
|
229
242
|
const repos: Record<string, { url: string; defaultBranch: string }> = existingConfig.repos
|
|
230
243
|
? { ...existingConfig.repos }
|
|
231
244
|
: {};
|
|
245
|
+
let githubOrgs: string[] = existingConfig.github?.orgs || [];
|
|
246
|
+
let useWildcard = false;
|
|
232
247
|
|
|
233
248
|
if (needsRepos || needsConfigFile) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
const repoMode = await choose(rl, "How do you want to manage repos?", [
|
|
250
|
+
"Auto-discover from GitHub (uses gh CLI)",
|
|
251
|
+
"Add repos manually",
|
|
252
|
+
"Skip — add repos later via chat",
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
if (repoMode === 0) {
|
|
256
|
+
// GitHub auto-discovery
|
|
257
|
+
const orgs = await ask(rl, "GitHub orgs/users to sync (comma-separated, or empty for your repos)");
|
|
258
|
+
githubOrgs = orgs ? orgs.split(",").map(s => s.trim()).filter(Boolean) : [];
|
|
259
|
+
useWildcard = true;
|
|
260
|
+
process.stdout.write(" Repos will sync from GitHub on startup.\n");
|
|
261
|
+
} else if (repoMode === 1) {
|
|
262
|
+
let addMore = true;
|
|
263
|
+
while (addMore) {
|
|
264
|
+
process.stdout.write("\n Add a repo:\n");
|
|
265
|
+
const name = await ask(rl, "Name");
|
|
266
|
+
if (!name) break;
|
|
267
|
+
const url = await ask(rl, "Git URL");
|
|
268
|
+
const branch = (await ask(rl, "Default branch [main]")) || "main";
|
|
269
|
+
repos[name] = { url, defaultBranch: branch };
|
|
270
|
+
|
|
271
|
+
const again = await ask(rl, "Add another repo? (y/n)");
|
|
272
|
+
addMore = again.toLowerCase() === "y";
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
useWildcard = true;
|
|
276
|
+
process.stdout.write(" You can add repos later: 'clone org/repo' in chat.\n");
|
|
245
277
|
}
|
|
246
278
|
}
|
|
247
279
|
|
|
@@ -249,7 +281,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
249
281
|
const users: Record<string, { name: string; repos: string[] }> = existingConfig.users
|
|
250
282
|
? { ...existingConfig.users }
|
|
251
283
|
: {};
|
|
252
|
-
const repoNames = Object.keys(repos);
|
|
284
|
+
const repoNames = useWildcard ? ["*"] : Object.keys(repos);
|
|
253
285
|
|
|
254
286
|
if (needsUsers || needsConfigFile) {
|
|
255
287
|
// Ask for user name once
|
|
@@ -265,7 +297,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
265
297
|
if (userId) users[`slack:${userId}`] = { name: userName || "user", repos: repoNames };
|
|
266
298
|
}
|
|
267
299
|
if (has("Telegram")) {
|
|
268
|
-
const userId = await ask(rl, "Your Telegram user ID");
|
|
300
|
+
const userId = await ask(rl, "Your Telegram user ID (send /start to @userinfobot to find it)");
|
|
269
301
|
if (userId) users[`telegram:${userId}`] = { name: userName || "user", repos: repoNames };
|
|
270
302
|
}
|
|
271
303
|
if (has("Discord")) {
|
|
@@ -325,6 +357,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
325
357
|
if (has("HTTP API")) {
|
|
326
358
|
envLines.push("# HTTP API + Web UI");
|
|
327
359
|
envLines.push(`HTTP_API_PORT=${envValues.HTTP_API_PORT || "3000"}`);
|
|
360
|
+
envLines.push(`HTTP_API_HOST=${envValues.HTTP_API_HOST || "127.0.0.1"}`);
|
|
328
361
|
envLines.push(`HTTP_API_KEY=${envValues.HTTP_API_KEY || ""}`);
|
|
329
362
|
envLines.push("");
|
|
330
363
|
}
|
|
@@ -344,6 +377,13 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
344
377
|
envLines.push("");
|
|
345
378
|
}
|
|
346
379
|
|
|
380
|
+
// Tracing
|
|
381
|
+
if (envValues.OVE_TRACE) {
|
|
382
|
+
envLines.push("# Tracing");
|
|
383
|
+
envLines.push(`OVE_TRACE=${envValues.OVE_TRACE}`);
|
|
384
|
+
envLines.push("");
|
|
385
|
+
}
|
|
386
|
+
|
|
347
387
|
// Always include repos dir
|
|
348
388
|
envLines.push("# Repos directory");
|
|
349
389
|
envLines.push(`REPOS_DIR=${envValues.REPOS_DIR || "./repos"}`);
|
|
@@ -359,6 +399,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
359
399
|
repos,
|
|
360
400
|
users,
|
|
361
401
|
claude: existingConfig.claude || { maxTurns: 25 },
|
|
402
|
+
...(githubOrgs.length > 0 ? { github: { orgs: githubOrgs } } : existingConfig.github ? { github: existingConfig.github } : {}),
|
|
362
403
|
...(existingConfig.mcpServers ? { mcpServers: existingConfig.mcpServers } : {}),
|
|
363
404
|
...(existingConfig.cron ? { cron: existingConfig.cron } : {}),
|
|
364
405
|
};
|
|
@@ -367,15 +408,38 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
367
408
|
}
|
|
368
409
|
|
|
369
410
|
// Systemd service setup
|
|
411
|
+
let installedSystemd = false;
|
|
370
412
|
if (!fixing) {
|
|
371
413
|
const installService = await ask(rl, "Install as systemd service? (y/n)");
|
|
372
414
|
if (installService.toLowerCase() === "y") {
|
|
373
|
-
await installSystemdService(rl);
|
|
415
|
+
installedSystemd = await installSystemdService(rl);
|
|
374
416
|
}
|
|
375
417
|
}
|
|
376
418
|
|
|
377
419
|
if (!fixing) {
|
|
378
|
-
process.stdout.write("\n Nåväl
|
|
420
|
+
process.stdout.write("\n Nåväl.\n");
|
|
421
|
+
if (installedSystemd) {
|
|
422
|
+
process.stdout.write("\n Useful commands:\n");
|
|
423
|
+
process.stdout.write(" sudo systemctl status ove # check status\n");
|
|
424
|
+
process.stdout.write(" sudo journalctl -u ove -f # follow logs\n");
|
|
425
|
+
process.stdout.write(" sudo systemctl restart ove # restart\n");
|
|
426
|
+
process.stdout.write(" sudo systemctl stop ove # stop\n");
|
|
427
|
+
if (has("HTTP API")) {
|
|
428
|
+
const port = envValues.HTTP_API_PORT || "3000";
|
|
429
|
+
const host = envValues.HTTP_API_HOST || "127.0.0.1";
|
|
430
|
+
const displayHost = host === "0.0.0.0" ? "<your-ip>" : host;
|
|
431
|
+
process.stdout.write(`\n Web UI: http://${displayHost}:${port}\n`);
|
|
432
|
+
if (envValues.OVE_TRACE === "true") {
|
|
433
|
+
process.stdout.write(` Traces: http://${displayHost}:${port}/trace\n`);
|
|
434
|
+
}
|
|
435
|
+
if (host === "0.0.0.0") {
|
|
436
|
+
process.stdout.write(" (bound to all interfaces)\n");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
process.stdout.write("\n");
|
|
440
|
+
} else {
|
|
441
|
+
process.stdout.write(" Run 'ove start' when you're ready.\n\n");
|
|
442
|
+
}
|
|
379
443
|
} else {
|
|
380
444
|
process.stdout.write("\n");
|
|
381
445
|
}
|
|
@@ -384,7 +448,7 @@ export async function runSetup(opts?: { fixOnly?: string[] }): Promise<void> {
|
|
|
384
448
|
}
|
|
385
449
|
}
|
|
386
450
|
|
|
387
|
-
async function installSystemdService(rl: ReturnType<typeof createInterface>): Promise<
|
|
451
|
+
async function installSystemdService(rl: ReturnType<typeof createInterface>): Promise<boolean> {
|
|
388
452
|
const detectedUser = userInfo().username;
|
|
389
453
|
const detectedDir = resolve(".");
|
|
390
454
|
let detectedBun = "";
|
|
@@ -400,10 +464,22 @@ async function installSystemdService(rl: ReturnType<typeof createInterface>): Pr
|
|
|
400
464
|
|
|
401
465
|
if (!bunPath) {
|
|
402
466
|
process.stdout.write(" Could not find bun. Skipping service install.\n");
|
|
403
|
-
return;
|
|
467
|
+
return false;
|
|
404
468
|
}
|
|
405
469
|
|
|
406
470
|
const envPath = resolve(workDir, ".env");
|
|
471
|
+
|
|
472
|
+
// Build PATH that includes dirs for bun, claude, and gh
|
|
473
|
+
const pathDirs = new Set<string>();
|
|
474
|
+
for (const bin of [bunPath, "claude", "gh"]) {
|
|
475
|
+
let resolved = "";
|
|
476
|
+
try { resolved = execFileSync("which", [bin]).toString().trim(); } catch {}
|
|
477
|
+
if (resolved) pathDirs.add(dirname(resolved));
|
|
478
|
+
}
|
|
479
|
+
// Always include standard dirs
|
|
480
|
+
for (const d of ["/usr/local/bin", "/usr/bin", "/bin"]) pathDirs.add(d);
|
|
481
|
+
const pathLine = Array.from(pathDirs).join(":");
|
|
482
|
+
|
|
407
483
|
const service = `[Unit]
|
|
408
484
|
Description=Ove - Personal AI coding assistant
|
|
409
485
|
After=network.target
|
|
@@ -416,6 +492,7 @@ ExecStart=${bunPath} run src/index.ts
|
|
|
416
492
|
Restart=always
|
|
417
493
|
RestartSec=5
|
|
418
494
|
EnvironmentFile=${envPath}
|
|
495
|
+
Environment=PATH=${pathLine}
|
|
419
496
|
|
|
420
497
|
[Install]
|
|
421
498
|
WantedBy=multi-user.target
|
|
@@ -438,8 +515,10 @@ WantedBy=multi-user.target
|
|
|
438
515
|
execFileSync("sudo", ["systemctl", "start", "ove"]);
|
|
439
516
|
process.stdout.write(" Service started.\n");
|
|
440
517
|
}
|
|
518
|
+
return true;
|
|
441
519
|
} catch (err) {
|
|
442
520
|
process.stdout.write(` Failed to install service: ${err}\n`);
|
|
443
521
|
}
|
|
444
522
|
}
|
|
523
|
+
return false;
|
|
445
524
|
}
|