@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/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 = "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`";
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
@@ -140,7 +140,8 @@ if (process.env.HTTP_API_PORT) {
140
140
  process.env.HTTP_API_KEY || crypto.randomUUID(),
141
141
  trace,
142
142
  queue,
143
- sessions
143
+ sessions,
144
+ process.env.HTTP_API_HOST
144
145
  );
145
146
  eventAdapters.push(httpAdapter);
146
147
  }
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
- if (!config.repos || Object.keys(config.repos).length === 0) {
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
- // Collect repos
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
- let addMore = true;
235
- while (addMore) {
236
- process.stdout.write("\n Add a repo:\n");
237
- const name = await ask(rl, "Name");
238
- if (!name) break;
239
- const url = await ask(rl, "Git URL");
240
- const branch = (await ask(rl, "Default branch [main]")) || "main";
241
- repos[name] = { url, defaultBranch: branch };
242
-
243
- const again = await ask(rl, "Add another repo? (y/n)");
244
- addMore = again.toLowerCase() === "y";
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. Run 'ove start' when you're ready.\n\n");
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<void> {
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
  }