@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.
Files changed (30) hide show
  1. package/README.md +110 -0
  2. package/bin/openboard.js +116 -0
  3. package/package.json +35 -0
  4. package/packages/client/dist/assets/index-CIdk7SfN.css +1 -0
  5. package/packages/client/dist/assets/index-HJtFAWwr.js +203 -0
  6. package/packages/client/dist/index.html +15 -0
  7. package/packages/server/dist/agents/agent-queue.js +175 -0
  8. package/packages/server/dist/agents/agent-runner.js +18 -0
  9. package/packages/server/dist/agents/agent.interface.js +1 -0
  10. package/packages/server/dist/agents/codereview.agent.js +113 -0
  11. package/packages/server/dist/agents/dummy.agent.js +49 -0
  12. package/packages/server/dist/agents/opencode.agent.js +184 -0
  13. package/packages/server/dist/agents/opencode.events.js +348 -0
  14. package/packages/server/dist/db/database.js +176 -0
  15. package/packages/server/dist/gh-worker.js +82 -0
  16. package/packages/server/dist/index.js +81 -0
  17. package/packages/server/dist/repositories/board.repository.js +72 -0
  18. package/packages/server/dist/repositories/column-config.repository.js +30 -0
  19. package/packages/server/dist/repositories/column.repository.js +36 -0
  20. package/packages/server/dist/repositories/comment.repository.js +35 -0
  21. package/packages/server/dist/repositories/ticket.repository.js +171 -0
  22. package/packages/server/dist/routes/boards.router.js +33 -0
  23. package/packages/server/dist/routes/column-config.router.js +44 -0
  24. package/packages/server/dist/routes/columns.router.js +45 -0
  25. package/packages/server/dist/routes/system.router.js +173 -0
  26. package/packages/server/dist/routes/tickets.router.js +88 -0
  27. package/packages/server/dist/sse.js +43 -0
  28. package/packages/server/dist/types.js +2 -0
  29. package/packages/server/dist/utils/opencode.js +11 -0
  30. 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,2 @@
1
+ // Shared types between server and client (duplicated intentionally for zero-dep simplicity)
2
+ export {};
@@ -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
+ }