@openboard/start 1.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.
- package/README.md +110 -0
- package/bin/openboard.js +116 -0
- package/package.json +35 -0
- package/packages/client/dist/assets/index-CIdk7SfN.css +1 -0
- package/packages/client/dist/assets/index-HJtFAWwr.js +203 -0
- package/packages/client/dist/index.html +15 -0
- package/packages/server/dist/agents/agent-queue.js +175 -0
- package/packages/server/dist/agents/agent-runner.js +18 -0
- package/packages/server/dist/agents/agent.interface.js +1 -0
- package/packages/server/dist/agents/codereview.agent.js +113 -0
- package/packages/server/dist/agents/dummy.agent.js +49 -0
- package/packages/server/dist/agents/opencode.agent.js +184 -0
- package/packages/server/dist/agents/opencode.events.js +348 -0
- package/packages/server/dist/db/database.js +176 -0
- package/packages/server/dist/gh-worker.js +82 -0
- package/packages/server/dist/index.js +81 -0
- package/packages/server/dist/repositories/board.repository.js +72 -0
- package/packages/server/dist/repositories/column-config.repository.js +30 -0
- package/packages/server/dist/repositories/column.repository.js +36 -0
- package/packages/server/dist/repositories/comment.repository.js +35 -0
- package/packages/server/dist/repositories/ticket.repository.js +171 -0
- package/packages/server/dist/routes/boards.router.js +33 -0
- package/packages/server/dist/routes/column-config.router.js +44 -0
- package/packages/server/dist/routes/columns.router.js +45 -0
- package/packages/server/dist/routes/system.router.js +173 -0
- package/packages/server/dist/routes/tickets.router.js +88 -0
- package/packages/server/dist/sse.js +43 -0
- package/packages/server/dist/types.js +2 -0
- package/packages/server/dist/utils/opencode.js +11 -0
- package/packages/server/dist/utils/os.js +73 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import fg from 'fast-glob';
|
|
6
|
+
import { normalizePathForOS } from '../utils/os.js';
|
|
7
|
+
const router = Router();
|
|
8
|
+
async function getWindowsDrives() {
|
|
9
|
+
const drives = [];
|
|
10
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
11
|
+
for (const letter of alphabet) {
|
|
12
|
+
const drive = `${letter}:`;
|
|
13
|
+
try {
|
|
14
|
+
fs.accessSync(drive + path.sep);
|
|
15
|
+
drives.push(drive);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Drive not accessible or doesn't exist
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return drives;
|
|
22
|
+
}
|
|
23
|
+
router.get('/browse', async (req, res) => {
|
|
24
|
+
let currentPath = normalizePathForOS(req.query.path || '');
|
|
25
|
+
try {
|
|
26
|
+
if (!currentPath && process.platform === 'win32') {
|
|
27
|
+
const drives = await getWindowsDrives();
|
|
28
|
+
const entries = drives.map(drive => ({
|
|
29
|
+
name: drive,
|
|
30
|
+
path: drive + '\\',
|
|
31
|
+
isRepo: false,
|
|
32
|
+
hasSrc: false,
|
|
33
|
+
hasPublic: false,
|
|
34
|
+
isDir: true
|
|
35
|
+
}));
|
|
36
|
+
res.json({ currentPath: '', entries });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!currentPath) {
|
|
40
|
+
currentPath = '/';
|
|
41
|
+
}
|
|
42
|
+
const items = await fs.promises.readdir(currentPath, { withFileTypes: true });
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
if (!item.isDirectory())
|
|
46
|
+
continue;
|
|
47
|
+
const fullPath = path.join(currentPath, item.name);
|
|
48
|
+
let isRepo = false;
|
|
49
|
+
let hasSrc = false;
|
|
50
|
+
let hasPublic = false;
|
|
51
|
+
try {
|
|
52
|
+
const subItems = await fs.promises.readdir(fullPath);
|
|
53
|
+
isRepo = subItems.includes('.git');
|
|
54
|
+
hasSrc = subItems.includes('src');
|
|
55
|
+
hasPublic = subItems.includes('public');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Ignore errors (e.g. permission denied)
|
|
59
|
+
}
|
|
60
|
+
entries.push({
|
|
61
|
+
name: item.name,
|
|
62
|
+
path: fullPath,
|
|
63
|
+
isRepo,
|
|
64
|
+
hasSrc,
|
|
65
|
+
hasPublic,
|
|
66
|
+
isDir: true
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Sorting: Repos first, then alphabetical
|
|
70
|
+
entries.sort((a, b) => {
|
|
71
|
+
if (a.isRepo && !b.isRepo)
|
|
72
|
+
return -1;
|
|
73
|
+
if (!a.isRepo && b.isRepo)
|
|
74
|
+
return 1;
|
|
75
|
+
return a.name.localeCompare(b.name);
|
|
76
|
+
});
|
|
77
|
+
res.json({ currentPath, entries });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
res.status(500).json({ error: error.message });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
router.get('/search', async (req, res) => {
|
|
84
|
+
const query = (req.query.query || '').toLowerCase();
|
|
85
|
+
const basePath = normalizePathForOS(req.query.basePath || '');
|
|
86
|
+
if (!query || !basePath) {
|
|
87
|
+
res.json([]);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const items = await fs.promises.readdir(basePath, { withFileTypes: true });
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
if (!item.isDirectory())
|
|
95
|
+
continue;
|
|
96
|
+
if (item.name.toLowerCase().includes(query)) {
|
|
97
|
+
const fullPath = path.join(basePath, item.name);
|
|
98
|
+
let isRepo = false;
|
|
99
|
+
let hasSrc = false;
|
|
100
|
+
let hasPublic = false;
|
|
101
|
+
try {
|
|
102
|
+
const subItems = await fs.promises.readdir(fullPath);
|
|
103
|
+
isRepo = subItems.includes('.git');
|
|
104
|
+
hasSrc = subItems.includes('src');
|
|
105
|
+
hasPublic = subItems.includes('public');
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
results.push({
|
|
109
|
+
name: item.name,
|
|
110
|
+
path: fullPath,
|
|
111
|
+
isRepo,
|
|
112
|
+
hasSrc,
|
|
113
|
+
hasPublic,
|
|
114
|
+
isDir: true
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
res.json(results);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
res.status(500).json({ error: error.message });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
router.get('/search/global', async (req, res) => {
|
|
125
|
+
const query = (req.query.query || '').toLowerCase();
|
|
126
|
+
if (!query || query.length < 2) {
|
|
127
|
+
res.json([]);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
// Start search from home directory
|
|
132
|
+
const homeDir = os.homedir();
|
|
133
|
+
// Use fast-glob to find directories matching query
|
|
134
|
+
// Limit depth and results for performance
|
|
135
|
+
const pattern = `*${query}*`;
|
|
136
|
+
const matches = await fg(pattern, {
|
|
137
|
+
cwd: homeDir,
|
|
138
|
+
onlyDirectories: true,
|
|
139
|
+
unique: true,
|
|
140
|
+
absolute: true,
|
|
141
|
+
deep: 3, // Depth limit
|
|
142
|
+
ignore: ['**/node_modules/**', '**/.*/**']
|
|
143
|
+
});
|
|
144
|
+
const results = [];
|
|
145
|
+
const limitedMatches = matches.slice(0, 50); // Result limit
|
|
146
|
+
for (const fullPath of limitedMatches) {
|
|
147
|
+
const name = path.basename(fullPath);
|
|
148
|
+
let isRepo = false;
|
|
149
|
+
let hasSrc = false;
|
|
150
|
+
let hasPublic = false;
|
|
151
|
+
try {
|
|
152
|
+
const subItems = await fs.promises.readdir(fullPath);
|
|
153
|
+
isRepo = subItems.includes('.git');
|
|
154
|
+
hasSrc = subItems.includes('src');
|
|
155
|
+
hasPublic = subItems.includes('public');
|
|
156
|
+
}
|
|
157
|
+
catch { }
|
|
158
|
+
results.push({
|
|
159
|
+
name,
|
|
160
|
+
path: fullPath,
|
|
161
|
+
isRepo,
|
|
162
|
+
hasSrc,
|
|
163
|
+
hasPublic,
|
|
164
|
+
isDir: true
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
res.json(results);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
res.status(500).json({ error: error.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
export { router as systemRouter };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
3
|
+
import { commentRepository } from '../repositories/comment.repository.js';
|
|
4
|
+
import { triggerAgent } from '../agents/agent-runner.js';
|
|
5
|
+
import { agentQueue } from '../agents/agent-queue.js';
|
|
6
|
+
const router = Router({ mergeParams: true });
|
|
7
|
+
// GET /api/boards/:boardId/tickets
|
|
8
|
+
router.get('/', (req, res) => {
|
|
9
|
+
res.json(ticketRepository.findByBoardId(req.params.boardId));
|
|
10
|
+
});
|
|
11
|
+
// POST /api/boards/:boardId/tickets
|
|
12
|
+
router.post('/', (req, res) => {
|
|
13
|
+
const { title, description, priority, columnId } = req.body;
|
|
14
|
+
if (!title?.trim() || !columnId) {
|
|
15
|
+
res.status(400).json({ error: 'title and columnId are required' });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const ticket = ticketRepository.create({
|
|
19
|
+
boardId: req.params.boardId,
|
|
20
|
+
columnId,
|
|
21
|
+
title: title.trim(),
|
|
22
|
+
description,
|
|
23
|
+
priority,
|
|
24
|
+
});
|
|
25
|
+
triggerAgent(ticket);
|
|
26
|
+
// Return latest DB state (might have agent_status: 'processing')
|
|
27
|
+
const latest = ticketRepository.findById(ticket.id) || ticket;
|
|
28
|
+
res.status(201).json(latest);
|
|
29
|
+
});
|
|
30
|
+
// PATCH /api/boards/:boardId/tickets/:id
|
|
31
|
+
router.patch('/:id', (req, res) => {
|
|
32
|
+
const { title, description, priority } = req.body;
|
|
33
|
+
const ticket = ticketRepository.update(req.params.id, { title, description, priority });
|
|
34
|
+
if (!ticket) {
|
|
35
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
res.json(ticket);
|
|
39
|
+
});
|
|
40
|
+
// PUT /api/boards/:boardId/tickets/:id/move
|
|
41
|
+
router.put('/:id/move', (req, res) => {
|
|
42
|
+
const { toColumnId, position } = req.body;
|
|
43
|
+
const ticket = ticketRepository.move(req.params.id, toColumnId, position);
|
|
44
|
+
if (!ticket) {
|
|
45
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
triggerAgent(ticket);
|
|
49
|
+
res.json(ticket);
|
|
50
|
+
});
|
|
51
|
+
// POST /api/boards/:boardId/tickets/:id/retry
|
|
52
|
+
router.post('/:id/retry', (req, res) => {
|
|
53
|
+
const ticket = ticketRepository.findById(req.params.id);
|
|
54
|
+
if (!ticket) {
|
|
55
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Only retry if it failed or hasn't started
|
|
59
|
+
// We pass force=true so that if it is in 'blocked' state, it gets cleared.
|
|
60
|
+
triggerAgent(ticket, true);
|
|
61
|
+
res.status(202).json({ status: 'retrying' });
|
|
62
|
+
});
|
|
63
|
+
// DELETE /api/boards/:boardId/tickets/:id
|
|
64
|
+
router.delete('/:id', (req, res) => {
|
|
65
|
+
ticketRepository.delete(req.params.id);
|
|
66
|
+
agentQueue.ping();
|
|
67
|
+
res.status(204).end();
|
|
68
|
+
});
|
|
69
|
+
// GET /api/boards/:boardId/tickets/:id/comments
|
|
70
|
+
router.get('/:id/comments', (req, res) => {
|
|
71
|
+
const comments = commentRepository.findByTicketId(req.params.id);
|
|
72
|
+
res.json(comments);
|
|
73
|
+
});
|
|
74
|
+
// POST /api/boards/:boardId/tickets/:id/comments
|
|
75
|
+
router.post('/:id/comments', (req, res) => {
|
|
76
|
+
const { content, author } = req.body;
|
|
77
|
+
if (!content?.trim()) {
|
|
78
|
+
res.status(400).json({ error: 'content is required' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const comment = commentRepository.create({
|
|
82
|
+
ticketId: req.params.id,
|
|
83
|
+
author,
|
|
84
|
+
content: content.trim(),
|
|
85
|
+
});
|
|
86
|
+
res.status(201).json(comment);
|
|
87
|
+
});
|
|
88
|
+
export { router as ticketsRouter };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class SseManager {
|
|
2
|
+
// boardId -> set of connected SSE clients
|
|
3
|
+
// Use '*' for board-level events (created/deleted) that all clients should see
|
|
4
|
+
clients = new Map();
|
|
5
|
+
subscribe(boardId, res) {
|
|
6
|
+
if (!this.clients.has(boardId)) {
|
|
7
|
+
this.clients.set(boardId, new Set());
|
|
8
|
+
}
|
|
9
|
+
this.clients.get(boardId).add(res);
|
|
10
|
+
}
|
|
11
|
+
unsubscribe(boardId, res) {
|
|
12
|
+
this.clients.get(boardId)?.delete(res);
|
|
13
|
+
}
|
|
14
|
+
emit(boardId, event, data) {
|
|
15
|
+
const targets = new Set();
|
|
16
|
+
// Collect subscribers for the specific board
|
|
17
|
+
this.clients.get(boardId)?.forEach(c => targets.add(c));
|
|
18
|
+
// Always also broadcast to '*' channel (global subscribers)
|
|
19
|
+
this.clients.get('*')?.forEach(c => targets.add(c));
|
|
20
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
21
|
+
targets.forEach(client => {
|
|
22
|
+
try {
|
|
23
|
+
client.write(payload);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Client disconnected — cleanup handled by 'close' event
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Broadcast to ALL connected clients (used for board-level events). */
|
|
31
|
+
emitGlobal(event, data) {
|
|
32
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
33
|
+
this.clients.forEach(set => {
|
|
34
|
+
set.forEach(client => {
|
|
35
|
+
try {
|
|
36
|
+
client.write(payload);
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore */ }
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const sseManager = new SseManager();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
2
|
+
const opencodePort = process.env.OPENCODE_PORT || 4096;
|
|
3
|
+
/**
|
|
4
|
+
* Creates an Opencode client instance scoped to a specific board directory.
|
|
5
|
+
*/
|
|
6
|
+
export function createBoardScopedClient(boardPath) {
|
|
7
|
+
return createOpencodeClient({
|
|
8
|
+
baseUrl: `http://127.0.0.1:${opencodePort}`,
|
|
9
|
+
directory: boardPath
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile, exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
/**
|
|
7
|
+
* Normalizes paths for mixed environments (e.g. WSL-style paths in a Windows process).
|
|
8
|
+
*/
|
|
9
|
+
export function normalizePathForOS(p) {
|
|
10
|
+
if (!p)
|
|
11
|
+
return p;
|
|
12
|
+
let normalized = p;
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
// Convert all forward slashes to backslashes first for consistency on Windows
|
|
15
|
+
normalized = normalized.replace(/\//g, '\\');
|
|
16
|
+
// Match \mnt\c\... or /mnt/c/... (case insensitive)
|
|
17
|
+
const mntMatch = normalized.match(/^[\\\/]mnt[\\\/]([a-z])([\\\/]|$)/i);
|
|
18
|
+
if (mntMatch) {
|
|
19
|
+
const drive = mntMatch[1].toUpperCase();
|
|
20
|
+
// Remove the /mnt/c/ prefix
|
|
21
|
+
const rest = normalized.substring(mntMatch[0].length);
|
|
22
|
+
normalized = `${drive}:\\${rest}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
normalized = normalized.replace(/\\/g, '/');
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
// Cache the gh token so we only fetch it once per server process
|
|
31
|
+
let cachedGhToken = null;
|
|
32
|
+
export async function getGhToken(cwd) {
|
|
33
|
+
if (cachedGhToken !== null)
|
|
34
|
+
return cachedGhToken;
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd: normalizePathForOS(cwd), shell: true });
|
|
37
|
+
cachedGhToken = stdout.trim() || null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
cachedGhToken = null;
|
|
41
|
+
}
|
|
42
|
+
return cachedGhToken;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Helper function to execute commands robustly across OS.
|
|
46
|
+
* Automatically injects GH_TOKEN for any `gh` subcommand and handles shell execution on Windows.
|
|
47
|
+
*/
|
|
48
|
+
export async function runCmd(cmd, args, cwd, prefix = '') {
|
|
49
|
+
const normalizedCwd = normalizePathForOS(cwd);
|
|
50
|
+
console.log(`[${prefix || 'os-util'}] Running: ${cmd} ${args.join(' ')} in cwd: ${normalizedCwd}`);
|
|
51
|
+
// Safety check for CWD
|
|
52
|
+
if (!fs.existsSync(normalizedCwd)) {
|
|
53
|
+
throw new Error(`Directory does not exist: ${normalizedCwd}`);
|
|
54
|
+
}
|
|
55
|
+
let extraEnv = {};
|
|
56
|
+
if (cmd === 'gh') {
|
|
57
|
+
const token = await getGhToken(normalizedCwd);
|
|
58
|
+
if (token)
|
|
59
|
+
extraEnv['GH_TOKEN'] = token;
|
|
60
|
+
}
|
|
61
|
+
const env = { ...process.env, ...extraEnv };
|
|
62
|
+
try {
|
|
63
|
+
// use shell: true to help find binaries in PATH on Windows
|
|
64
|
+
return await execFileAsync(cmd, args, { cwd: normalizedCwd, env, shell: true });
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (e.code === 'ENOENT') {
|
|
68
|
+
const envPrefix = extraEnv['GH_TOKEN'] ? (process.platform === 'win32' ? `set GH_TOKEN=${extraEnv['GH_TOKEN']}&& ` : `GH_TOKEN=${extraEnv['GH_TOKEN']} `) : '';
|
|
69
|
+
return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd: normalizedCwd });
|
|
70
|
+
}
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|