@adriandmitroca/relay 0.0.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.
@@ -0,0 +1,14 @@
1
+ import { cors } from "hono/cors";
2
+
3
+ export const corsMiddleware = cors({
4
+ origin: (origin) => {
5
+ if (!origin) return origin;
6
+ try {
7
+ const url = new URL(origin);
8
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return origin;
9
+ } catch {}
10
+ return null;
11
+ },
12
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
13
+ allowHeaders: ["Content-Type"],
14
+ });
@@ -0,0 +1,31 @@
1
+ import { Hono } from "hono";
2
+ import { corsMiddleware } from "./middleware.ts";
3
+ import { createIssueRoutes } from "./issues.ts";
4
+ import { createConfigRoutes } from "./config.ts";
5
+ import type { IssueDB, IssueRow, ConfigDB } from "../db.ts";
6
+ import type { CallbackData } from "../constants.ts";
7
+
8
+ type ActionHandler = (data: CallbackData) => Promise<{ ok: boolean; error?: string; issue?: IssueRow }>;
9
+ type StatusProvider = () => Array<{ workspace: string; telegram: boolean; sources: string[] }>;
10
+
11
+ export interface RouterDeps {
12
+ getDB: () => IssueDB;
13
+ getConfigDB: () => ConfigDB;
14
+ getActionHandler: () => ActionHandler | null;
15
+ getStatusProvider: () => StatusProvider | null;
16
+ onConfigChange: () => void;
17
+ }
18
+
19
+ export function createRouter(deps: RouterDeps): Hono {
20
+ const app = new Hono();
21
+
22
+ app.use("*", corsMiddleware);
23
+
24
+ const issueRoutes = createIssueRoutes(deps.getDB, deps.getActionHandler, deps.getStatusProvider);
25
+ const configRoutes = createConfigRoutes(deps.getConfigDB, deps.onConfigChange);
26
+
27
+ app.route("/", issueRoutes);
28
+ app.route("/", configRoutes);
29
+
30
+ return app;
31
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Daemon } from "./daemon.ts";
4
+ import { setLogLevel } from "./utils/logger.ts";
5
+ import { join, dirname } from "node:path";
6
+ import { unlinkSync } from "node:fs";
7
+ import pkg from "../package.json" with { type: "json" };
8
+
9
+ const VERSION = pkg.version;
10
+ const NPM_PACKAGE = pkg.name; // "@adriandmitroca/relay"
11
+ const BOLD = "\x1b[1m";
12
+ const RESET = "\x1b[0m";
13
+ const GREEN = "\x1b[32m";
14
+ const YELLOW = "\x1b[33m";
15
+ const RED = "\x1b[31m";
16
+ const DIM = "\x1b[2m";
17
+
18
+ function getFlag(name: string): string | undefined {
19
+ const idx = process.argv.indexOf(`--${name}`);
20
+ if (idx !== -1 && process.argv[idx + 1]) return process.argv[idx + 1]!;
21
+ return undefined;
22
+ }
23
+
24
+ function resolveConfigPath(): string {
25
+ return getFlag("config") ?? join(process.cwd(), "config.json");
26
+ }
27
+
28
+ function getCommand(): string {
29
+ const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
30
+ return args[0] ?? "help";
31
+ }
32
+
33
+ async function fetchLatestVersion(): Promise<string | null> {
34
+ try {
35
+ const res = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`, {
36
+ signal: AbortSignal.timeout(3000),
37
+ });
38
+ if (!res.ok) return null;
39
+ const data = await res.json() as { version: string };
40
+ return data.version ?? null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Silently check for updates and print a one-liner if a newer version is available. */
47
+ async function checkForUpdates() {
48
+ const latest = await fetchLatestVersion();
49
+ if (latest && latest !== VERSION) {
50
+ console.log(`${DIM}relay v${latest} is available (you have v${VERSION}). Run: relay update${RESET}`);
51
+ }
52
+ }
53
+
54
+ async function cmdStart() {
55
+ const configPath = resolveConfigPath();
56
+ const pidFile = join(process.cwd(), "relay.pid");
57
+ const logLevel = getFlag("log-level");
58
+ if (logLevel) setLogLevel(logLevel as "debug" | "info" | "warn" | "error");
59
+
60
+ // Check for existing instance
61
+ const pid = Bun.file(pidFile);
62
+ if (await pid.exists()) {
63
+ const existingPid = (await pid.text()).trim();
64
+ const check = await Bun.$`kill -0 ${existingPid}`.quiet().nothrow();
65
+ if (check.exitCode === 0) {
66
+ console.error(`${RED}Daemon already running (PID ${existingPid}). Use 'relay stop' first.${RESET}`);
67
+ process.exit(1);
68
+ }
69
+ }
70
+
71
+ // Write PID
72
+ await Bun.write(pidFile, String(process.pid));
73
+
74
+ // Silent update check in background
75
+ checkForUpdates().catch(() => {});
76
+
77
+ const daemon = new Daemon(configPath);
78
+
79
+ const cleanup = () => {
80
+ try { unlinkSync(pidFile); } catch {}
81
+ };
82
+ process.on("exit", cleanup);
83
+
84
+ try {
85
+ await daemon.start();
86
+ } catch (err) {
87
+ console.error(`${RED}Failed to start: ${err}${RESET}`);
88
+ cleanup();
89
+ process.exit(1);
90
+ }
91
+ }
92
+
93
+ async function cmdStop() {
94
+ const pidFile = join(process.cwd(), "relay.pid");
95
+ const pid = Bun.file(pidFile);
96
+ if (!(await pid.exists())) {
97
+ console.log(`${YELLOW}No running daemon found.${RESET}`);
98
+ return;
99
+ }
100
+
101
+ const existingPid = (await pid.text()).trim();
102
+ const result = await Bun.$`kill ${existingPid}`.quiet().nothrow();
103
+ if (result.exitCode === 0) {
104
+ console.log(`${GREEN}Sent stop signal to daemon (PID ${existingPid}).${RESET}`);
105
+ } else {
106
+ console.log(`${YELLOW}Daemon not running. Cleaning up PID file.${RESET}`);
107
+ }
108
+ await Bun.$`rm -f ${pidFile}`.quiet().nothrow();
109
+ }
110
+
111
+ async function cmdUpdate() {
112
+ console.log(`Checking for updates...`);
113
+
114
+ const latest = await fetchLatestVersion();
115
+ if (!latest) {
116
+ console.error(`${RED}Could not reach npm registry. Check your connection.${RESET}`);
117
+ process.exit(1);
118
+ }
119
+
120
+ if (latest === VERSION) {
121
+ console.log(`${GREEN}Already on latest (v${VERSION}).${RESET}`);
122
+ return;
123
+ }
124
+
125
+ console.log(`Updating v${VERSION} → v${latest}...`);
126
+
127
+ // Try bun first (native for this tool), fall back to npm
128
+ const bunCheck = await Bun.$`which bun`.quiet().nothrow();
129
+ const installCmd = bunCheck.exitCode === 0
130
+ ? `bun install -g ${NPM_PACKAGE}@latest`
131
+ : `npm install -g ${NPM_PACKAGE}@latest`;
132
+
133
+ const result = await Bun.$`sh -c ${installCmd}`.nothrow();
134
+ if (result.exitCode === 0) {
135
+ console.log(`${GREEN}Updated to v${latest}. Restart relay for changes to take effect.${RESET}`);
136
+ } else {
137
+ console.error(`${RED}Update failed. Run manually: ${installCmd}${RESET}`);
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ function showHelp() {
143
+ console.log(`
144
+ ${BOLD}Relay${RESET} v${VERSION} — Autonomous SWE agent
145
+
146
+ ${BOLD}Usage:${RESET}
147
+ relay start Start the daemon (opens dashboard at localhost:7842)
148
+ relay stop Stop the daemon
149
+ relay update Update to the latest version
150
+ relay version Show version
151
+ relay help Show this help
152
+
153
+ ${BOLD}Options:${RESET}
154
+ --config <path> Path to config.json (default: ./config.json)
155
+ --log-level <level> Override log level (debug/info/warn/error)
156
+
157
+ ${BOLD}Getting started:${RESET}
158
+ bunx relayd Try without installing
159
+ bun install -g relayd Install globally
160
+ relay start Start — configure via the setup wizard at localhost:7842
161
+ `);
162
+ }
163
+
164
+ // --- Main ---
165
+ const cmd = getCommand();
166
+
167
+ switch (cmd) {
168
+ case "start":
169
+ await cmdStart();
170
+ break;
171
+ case "stop":
172
+ await cmdStop();
173
+ break;
174
+ case "update":
175
+ await cmdUpdate();
176
+ break;
177
+ case "version":
178
+ console.log(`relay v${VERSION}`);
179
+ break;
180
+ case "help":
181
+ default:
182
+ showHelp();
183
+ break;
184
+ }
package/src/config.ts ADDED
@@ -0,0 +1,195 @@
1
+ import { logger } from "./utils/logger.ts";
2
+
3
+ export interface SentrySourceConfig {
4
+ authToken: string;
5
+ org: string;
6
+ project: string;
7
+ }
8
+
9
+ export interface AsanaSourceConfig {
10
+ accessToken: string;
11
+ projectGid: string;
12
+ severityFieldGid?: string;
13
+ }
14
+
15
+ export interface LinearSourceConfig {
16
+ apiKey: string;
17
+ teamId: string;
18
+ projectId?: string;
19
+ statusFilter?: string[];
20
+ assigneeFilter?: string;
21
+ labelFilter?: string[];
22
+ priorityFilter?: number[];
23
+ onAcceptTransition?: boolean;
24
+ }
25
+
26
+ export interface JiraSourceConfig {
27
+ host: string;
28
+ email: string;
29
+ apiToken: string;
30
+ projectKey: string;
31
+ filterMode: "simple" | "jql";
32
+ statusFilter?: string[];
33
+ assigneeFilter?: string;
34
+ issueTypeFilter?: string[];
35
+ priorityFilter?: string[];
36
+ labelFilter?: string[];
37
+ jql?: string;
38
+ onAcceptTransition?: string;
39
+ }
40
+
41
+ export interface ProjectConfig {
42
+ key: string;
43
+ repoPath: string;
44
+ baseBranch: string;
45
+ testCommand?: string;
46
+ sources: {
47
+ sentry?: SentrySourceConfig;
48
+ asana?: AsanaSourceConfig;
49
+ linear?: LinearSourceConfig;
50
+ jira?: JiraSourceConfig;
51
+ };
52
+ }
53
+
54
+ export interface WorkspaceConfig {
55
+ key: string;
56
+ telegram?: { botToken: string; chatId: string };
57
+ projects: ProjectConfig[];
58
+ }
59
+
60
+ export interface Config {
61
+ workspaces: WorkspaceConfig[];
62
+ pollIntervalSeconds: number;
63
+ maxConcurrency: number;
64
+ claudeTimeout: number;
65
+ triageTimeout: number;
66
+ triage: boolean;
67
+ logLevel: "debug" | "info" | "warn" | "error";
68
+ allowedTools: string[];
69
+ }
70
+
71
+ const DEFAULTS = {
72
+ pollIntervalSeconds: 300,
73
+ maxConcurrency: 2,
74
+ claudeTimeout: 300_000,
75
+ triageTimeout: 120_000,
76
+ triage: true,
77
+ logLevel: "info" as const,
78
+ allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
79
+ };
80
+
81
+ export async function loadConfig(configPath: string): Promise<Config> {
82
+ const file = Bun.file(configPath);
83
+ if (!(await file.exists())) {
84
+ throw new Error(`Config not found at ${configPath}. Run 'relay init' to create one.`);
85
+ }
86
+
87
+ let raw: Record<string, unknown>;
88
+ try {
89
+ raw = await file.json();
90
+ } catch {
91
+ throw new Error(`Config at ${configPath} is not valid JSON. Check for syntax errors.`);
92
+ }
93
+
94
+ const config: Config = {
95
+ workspaces: (raw.workspaces as WorkspaceConfig[]) ?? [],
96
+ pollIntervalSeconds: (raw.pollIntervalSeconds as number) ?? DEFAULTS.pollIntervalSeconds,
97
+ maxConcurrency: (raw.maxConcurrency as number) ?? DEFAULTS.maxConcurrency,
98
+ claudeTimeout: (raw.claudeTimeout as number) ?? DEFAULTS.claudeTimeout,
99
+ triageTimeout: (raw.triageTimeout as number) ?? DEFAULTS.triageTimeout,
100
+ triage: (raw.triage as boolean) ?? DEFAULTS.triage,
101
+ logLevel: (raw.logLevel as Config["logLevel"]) ?? DEFAULTS.logLevel,
102
+ allowedTools: (raw.allowedTools as string[]) ?? DEFAULTS.allowedTools,
103
+ };
104
+
105
+ validate(config);
106
+ return config;
107
+ }
108
+
109
+ export function findProjectConfig(config: Config, projectKey: string): { workspace: WorkspaceConfig; project: ProjectConfig } | null {
110
+ for (const ws of config.workspaces) {
111
+ const project = ws.projects.find((p) => p.key === projectKey);
112
+ if (project) return { workspace: ws, project };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ export function allProjects(config: Config): Array<{ workspace: WorkspaceConfig; project: ProjectConfig }> {
118
+ const result: Array<{ workspace: WorkspaceConfig; project: ProjectConfig }> = [];
119
+ for (const ws of config.workspaces) {
120
+ for (const p of ws.projects) {
121
+ result.push({ workspace: ws, project: p });
122
+ }
123
+ }
124
+ return result;
125
+ }
126
+
127
+ function validate(config: Config) {
128
+ if (!config.workspaces.length) {
129
+ throw new Error("Config: 'workspaces' must contain at least one workspace.");
130
+ }
131
+
132
+ const wsKeys = new Set<string>();
133
+ const projectKeys = new Set<string>();
134
+
135
+ for (const ws of config.workspaces) {
136
+ if (!ws.key) throw new Error("Config: each workspace must have a 'key'.");
137
+ if (wsKeys.has(ws.key)) throw new Error(`Config: duplicate workspace key '${ws.key}'.`);
138
+ wsKeys.add(ws.key);
139
+
140
+ if (ws.telegram) {
141
+ if (!ws.telegram.botToken) {
142
+ throw new Error(`Config: workspace '${ws.key}' telegram config must have 'botToken'.`);
143
+ }
144
+ if (!ws.telegram.chatId) {
145
+ throw new Error(`Config: workspace '${ws.key}' telegram config must have 'chatId'.`);
146
+ }
147
+ } else {
148
+ logger.info("Workspace has no Telegram config — web dashboard only", { workspace: ws.key });
149
+ }
150
+
151
+ if (!ws.projects.length) {
152
+ throw new Error(`Config: workspace '${ws.key}' must have at least one project.`);
153
+ }
154
+
155
+ for (const p of ws.projects) {
156
+ if (!p.key) throw new Error(`Config: each project must have a 'key'.`);
157
+ if (!p.repoPath) throw new Error(`Config: project '${p.key}' must have a 'repoPath'.`);
158
+ if (!p.baseBranch) throw new Error(`Config: project '${p.key}' must have a 'baseBranch'.`);
159
+ if (projectKeys.has(p.key)) throw new Error(`Config: duplicate project key '${p.key}'.`);
160
+ projectKeys.add(p.key);
161
+
162
+ if (!p.sources) {
163
+ p.sources = {};
164
+ }
165
+ if (p.sources.sentry) {
166
+ if (!p.sources.sentry.authToken) throw new Error(`Config: project '${p.key}' sentry source needs 'authToken'.`);
167
+ if (!p.sources.sentry.org || !p.sources.sentry.project) throw new Error(`Config: project '${p.key}' sentry source needs 'org' and 'project'.`);
168
+ }
169
+ if (p.sources.asana) {
170
+ if (!p.sources.asana.accessToken) throw new Error(`Config: project '${p.key}' asana source needs 'accessToken'.`);
171
+ if (!p.sources.asana.projectGid) throw new Error(`Config: project '${p.key}' asana source needs 'projectGid'.`);
172
+ }
173
+ if (p.sources.linear) {
174
+ if (!p.sources.linear.apiKey) throw new Error(`Config: project '${p.key}' linear source needs 'apiKey'.`);
175
+ if (!p.sources.linear.teamId) throw new Error(`Config: project '${p.key}' linear source needs 'teamId'.`);
176
+ }
177
+ if (p.sources.jira) {
178
+ if (!p.sources.jira.host) throw new Error(`Config: project '${p.key}' jira source needs 'host'.`);
179
+ if (!p.sources.jira.email) throw new Error(`Config: project '${p.key}' jira source needs 'email'.`);
180
+ if (!p.sources.jira.apiToken) throw new Error(`Config: project '${p.key}' jira source needs 'apiToken'.`);
181
+ if (!p.sources.jira.projectKey) throw new Error(`Config: project '${p.key}' jira source needs 'projectKey'.`);
182
+ }
183
+ }
184
+ }
185
+
186
+ if (config.pollIntervalSeconds <= 0) {
187
+ throw new Error("Config: 'pollIntervalSeconds' must be positive.");
188
+ }
189
+ if (config.maxConcurrency <= 0) {
190
+ throw new Error("Config: 'maxConcurrency' must be positive.");
191
+ }
192
+
193
+ const totalProjects = config.workspaces.reduce((n, ws) => n + ws.projects.length, 0);
194
+ logger.debug("Config loaded and validated", { workspaces: config.workspaces.length, projects: totalProjects });
195
+ }
@@ -0,0 +1,21 @@
1
+ export const FAILURE_HINTS: Record<string, string> = {
2
+ "triage:timeout": "Triage timed out. Try increasing triageTimeout in config.",
3
+ "triage:claude_failed": "Triage failed — Claude returned an unexpected response.",
4
+ "fix:timeout": "Task timed out. The issue may be too complex. Try a smaller scope or increase claudeTimeout.",
5
+ "fix:no_changes": "Claude finished but made no file changes. Add more context to the issue description and retry.",
6
+ "fix:tests_failed": "Changes were made but tests failed. Review the diff and retry.",
7
+ "fix:commit_failed": "Auto-commit failed. Check repo permissions and git config.",
8
+ "fix:claude_failed": "Claude exited with an error during implementation.",
9
+ "worktree:create_failed": "Failed to create a git worktree. Check repoPath and disk space.",
10
+ "accept:push_failed": "Branch push failed. Check remote permissions and run gh auth status.",
11
+ "accept:pr_failed": "Branch pushed but PR creation failed. Run gh auth status.",
12
+ "pipeline:unexpected": "An unexpected error occurred. Check relay logs for details.",
13
+ };
14
+
15
+ export type CallbackAction = "accept" | "discard" | "skip" | "fix" | "retry";
16
+
17
+ export interface CallbackData {
18
+ action: CallbackAction;
19
+ source: string;
20
+ sourceId: string;
21
+ }