@openboard/start 1.0.20 → 1.0.22

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 +2 -4
  2. package/bin/openboard.js +23 -0
  3. package/package.json +1 -1
  4. package/packages/client/README.md +30 -0
  5. package/packages/client/dist/assets/index-B5k_YB6Y.css +0 -0
  6. package/packages/client/dist/assets/{index-B5qL8ybM.js → index-D6WEx7Lr.js} +17 -17
  7. package/packages/client/dist/index.html +1 -1
  8. package/packages/server/dist/agents/agent-queue.js +0 -0
  9. package/packages/server/dist/agents/agent-runner.js +0 -0
  10. package/packages/server/dist/agents/agent.interface.js +0 -0
  11. package/packages/server/dist/agents/codereview.agent.js +8 -22
  12. package/packages/server/dist/agents/dummy.agent.js +0 -0
  13. package/packages/server/dist/agents/opencode.agent.js +31 -66
  14. package/packages/server/dist/agents/opencode.events.js +12 -54
  15. package/packages/server/dist/db/database.js +0 -0
  16. package/packages/server/dist/gh-worker.js +0 -0
  17. package/packages/server/dist/index.js +0 -0
  18. package/packages/server/dist/repositories/board.repository.js +20 -4
  19. package/packages/server/dist/repositories/column-config.repository.js +0 -0
  20. package/packages/server/dist/repositories/column.repository.js +0 -0
  21. package/packages/server/dist/repositories/comment.repository.js +0 -0
  22. package/packages/server/dist/repositories/ticket.repository.js +0 -0
  23. package/packages/server/dist/routes/boards.router.js +4 -4
  24. package/packages/server/dist/routes/column-config.router.js +0 -0
  25. package/packages/server/dist/routes/columns.router.js +0 -0
  26. package/packages/server/dist/routes/tickets.router.js +0 -0
  27. package/packages/server/dist/sse.js +0 -0
  28. package/packages/server/dist/types.js +0 -0
  29. package/packages/server/dist/utils/opencode.js +11 -0
  30. package/packages/server/dist/utils/os.js +73 -0
@@ -6,7 +6,7 @@
6
6
  <title>OpenBoard</title>
7
7
  <meta name="description" content="A local project management board — create boards, columns, and tickets." />
8
8
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9
- <script type="module" crossorigin src="/assets/index-B5qL8ybM.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-D6WEx7Lr.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-B5k_YB6Y.css">
11
11
  </head>
12
12
  <body>
File without changes
File without changes
File without changes
@@ -1,27 +1,10 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk';
2
1
  import { ticketRepository } from '../repositories/ticket.repository.js';
2
+ import { boardRepository } from '../repositories/board.repository.js';
3
3
  import { commentRepository } from '../repositories/comment.repository.js';
4
4
  import { setupOpencodeEventListener } from './opencode.events.js';
5
- import { execFile, exec } from 'child_process';
6
- import { promisify } from 'util';
7
- const execAsync = promisify(exec);
8
- const execFileAsync = promisify(execFile);
9
- // Helper function to execute commands robustly on Windows
10
- async function runCmd(cmd, args, cwd) {
11
- console.log(`[codereview-agent] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
12
- try {
13
- return await execFileAsync(cmd, args, { cwd });
14
- }
15
- catch (e) {
16
- if (e.code === 'ENOENT') {
17
- console.log(`[codereview-agent] ENOENT finding binary. Trying fallback exec...`);
18
- return await execAsync(`${cmd} ${args.join(' ')}`, { cwd });
19
- }
20
- throw e;
21
- }
22
- }
5
+ import { createBoardScopedClient } from '../utils/opencode.js';
6
+ import { runCmd, normalizePathForOS } from '../utils/os.js';
23
7
  const opencodePort = process.env.OPENCODE_PORT || 4096;
24
- const opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${opencodePort}` });
25
8
  const activeSessions = {};
26
9
  export class CodeReviewAgent {
27
10
  async run(ticket, config) {
@@ -46,6 +29,9 @@ export class CodeReviewAgent {
46
29
  });
47
30
  return;
48
31
  }
32
+ // Create a board-scoped Opencode client for this ticket
33
+ const board = boardRepository.findById(ticket.board_id);
34
+ const opencodeClient = createBoardScopedClient(board?.path);
49
35
  ticketRepository.updateAgentSession(ticket.id, {
50
36
  column_id: ticket.column_id,
51
37
  agent_type: 'code_review',
@@ -62,7 +48,7 @@ export class CodeReviewAgent {
62
48
  delete activeSessions[ticket.id];
63
49
  }
64
50
  // Resolve workspace path — code review runs in the main workspace (no new worktree needed).
65
- let workspacePath = process.cwd();
51
+ let workspacePath = normalizePathForOS(board?.path || process.cwd());
66
52
  try {
67
53
  const session = await opencodeClient.session.create({
68
54
  body: { title: `Code Review for Ticket: ${ticket.title}` },
@@ -91,7 +77,7 @@ export class CodeReviewAgent {
91
77
  // Fetch GH token so the LLM environment can execute `gh` commands
92
78
  let ghTokenEnv = '';
93
79
  try {
94
- const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], workspacePath);
80
+ const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], workspacePath, 'codereview-agent');
95
81
  if (ghToken.trim()) {
96
82
  ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
97
83
  }
File without changes
@@ -1,54 +1,12 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk';
2
1
  import { ticketRepository } from '../repositories/ticket.repository.js';
2
+ import { boardRepository } from '../repositories/board.repository.js';
3
3
  import { commentRepository } from '../repositories/comment.repository.js';
4
4
  import { setupOpencodeEventListener } from './opencode.events.js';
5
- import { execFile, exec } from 'child_process';
6
- import { promisify } from 'util';
7
- import os from 'os';
5
+ import { createBoardScopedClient } from '../utils/opencode.js';
6
+ import { runCmd, normalizePathForOS } from '../utils/os.js';
8
7
  import path from 'path';
9
8
  import fs from 'fs';
10
- const execAsync = promisify(exec);
11
- const execFileAsync = promisify(execFile);
12
- // Cache the gh token so we only fetch it once per server process
13
- let cachedGhToken = null;
14
- async function getGhToken(cwd) {
15
- if (cachedGhToken !== null)
16
- return cachedGhToken;
17
- try {
18
- const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd });
19
- cachedGhToken = stdout.trim() || null;
20
- }
21
- catch {
22
- cachedGhToken = null;
23
- }
24
- return cachedGhToken;
25
- }
26
- // Helper function to execute commands robustly on Windows.
27
- // Automatically injects GH_TOKEN for any `gh` subcommand.
28
- async function runCmd(cmd, args, cwd) {
29
- console.log(`[opencode-agent] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
30
- let extraEnv = {};
31
- if (cmd === 'gh') {
32
- const token = await getGhToken(cwd);
33
- if (token)
34
- extraEnv['GH_TOKEN'] = token;
35
- }
36
- const env = Object.keys(extraEnv).length > 0 ? { ...process.env, ...extraEnv } : undefined;
37
- try {
38
- return await execFileAsync(cmd, args, { cwd, ...(env && { env }) });
39
- }
40
- catch (e) {
41
- if (e.code === 'ENOENT') {
42
- console.log(`[opencode-agent] ENOENT finding binary. Trying fallback exec...`);
43
- const envPrefix = extraEnv['GH_TOKEN'] ? (process.platform === 'win32' ? `set GH_TOKEN=${extraEnv['GH_TOKEN']}&& ` : `GH_TOKEN=${extraEnv['GH_TOKEN']} `) : '';
44
- return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd });
45
- }
46
- throw e;
47
- }
48
- }
49
- // Central OpenCode client connecting to the user's running OpenCode server
50
9
  const opencodePort = process.env.OPENCODE_PORT || 4096;
51
- const opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${opencodePort}` });
52
10
  // Track active session IDs by ticket ID to prevent/cancel overlaps
53
11
  const activeSessions = {};
54
12
  export class OpencodeAgent {
@@ -61,20 +19,12 @@ export class OpencodeAgent {
61
19
  status: 'processing',
62
20
  port: Number(opencodePort)
63
21
  });
22
+ // Create a board-scoped Opencode client for this ticket
23
+ const board = boardRepository.findById(ticket.board_id);
24
+ const opencodeClient = createBoardScopedClient(board?.path);
64
25
  // Prevent duplicate runs blocking new requests: replace the old session.
65
- if (activeSessions[ticket.id]) {
66
- console.log(`[opencode-agent] Session already running for ticket ${ticket.id}. Aborting old session to start a fresh one.`);
67
- try {
68
- // Ignore typescript error if abort typing differs slightly in SDK version
69
- await opencodeClient.session.abort({ path: { id: activeSessions[ticket.id] } });
70
- }
71
- catch (e) {
72
- console.error(`[opencode-agent] Error aborting previous session:`, e);
73
- }
74
- delete activeSessions[ticket.id];
75
- }
76
- // 1. Find Worktree (Use the directory where openboard was started)
77
- let originalWorkspacePath = process.cwd();
26
+ // 1. Find Worktree (Use the board's designated path)
27
+ let originalWorkspacePath = normalizePathForOS(board?.path || process.cwd());
78
28
  // 2. Set up Worktree
79
29
  // If this ticket already has a PR, we reuse the existing branch and worktree.
80
30
  // Worktree paths are derived from branch name so they are stable across re-runs.
@@ -87,7 +37,7 @@ export class OpencodeAgent {
87
37
  // Ticket was already worked on — reuse the existing branch & worktree
88
38
  console.log(`[opencode-agent] Found existing PR ${existingPrUrl}. Fetching branch name.`);
89
39
  try {
90
- const { stdout: prDataStr } = await runCmd('gh', ['pr', 'view', existingPrUrl, '--json', 'headRefName'], originalWorkspacePath);
40
+ const { stdout: prDataStr } = await runCmd('gh', ['pr', 'view', existingPrUrl, '--json', 'headRefName'], originalWorkspacePath, 'opencode-agent');
91
41
  const prData = JSON.parse(prDataStr);
92
42
  if (!prData.headRefName)
93
43
  throw new Error('Could not parse headRefName from PR');
@@ -97,13 +47,13 @@ export class OpencodeAgent {
97
47
  console.warn(`[opencode-agent] Could not read PR branch name, will create a new branch.`, ghErr.message);
98
48
  branchName = `ticket-${ticket.id}-${Date.now()}`;
99
49
  }
100
- worktreePath = path.join(os.tmpdir(), 'openboard-worktrees', branchName);
101
50
  }
102
51
  else {
103
52
  // Fresh ticket — create a new branch and worktree
104
53
  branchName = `ticket-${ticket.id}-${Date.now()}`;
105
- worktreePath = path.join(os.tmpdir(), 'openboard-worktrees', branchName);
106
54
  }
55
+ // Use a local folder within the board path for worktrees
56
+ worktreePath = normalizePathForOS(path.join(originalWorkspacePath, '.openboard-worktrees', branchName));
107
57
  try {
108
58
  if (fs.existsSync(worktreePath)) {
109
59
  // Worktree directory already on disk — just reuse it, no git command needed
@@ -112,12 +62,27 @@ export class OpencodeAgent {
112
62
  else if (existingPrUrl) {
113
63
  // Branch exists but worktree was cleaned up — check out the branch into the path
114
64
  console.log(`[opencode-agent] Checking out existing branch ${branchName} into new worktree at ${worktreePath}`);
115
- await runCmd('git', ['worktree', 'add', worktreePath, branchName], originalWorkspacePath);
65
+ await runCmd('git', ['worktree', 'add', worktreePath, branchName], originalWorkspacePath, 'opencode-agent');
116
66
  }
117
67
  else {
118
- // Completely new — create branch and worktree together
119
- console.log(`[opencode-agent] Creating new worktree at ${worktreePath} on branch ${branchName}`);
120
- await runCmd('git', ['worktree', 'add', '-b', branchName, worktreePath], originalWorkspacePath);
68
+ // Completely new — check if the repo is empty (no commits yet)
69
+ let isRepoEmpty = false;
70
+ try {
71
+ await runCmd('git', ['rev-parse', 'HEAD'], originalWorkspacePath, 'opencode-agent');
72
+ }
73
+ catch (e) {
74
+ // if rev-parse HEAD fails, it usually means the repo has no commits
75
+ isRepoEmpty = true;
76
+ console.log(`[opencode-agent] Repository appears to be empty. Using --orphan for worktree.`);
77
+ }
78
+ if (isRepoEmpty) {
79
+ console.log(`[opencode-agent] Creating new orphan worktree at ${worktreePath} on branch ${branchName}`);
80
+ await runCmd('git', ['worktree', 'add', '--orphan', '-b', branchName, worktreePath], originalWorkspacePath, 'opencode-agent');
81
+ }
82
+ else {
83
+ console.log(`[opencode-agent] Creating new worktree at ${worktreePath} on branch ${branchName}`);
84
+ await runCmd('git', ['worktree', 'add', '-b', branchName, worktreePath], originalWorkspacePath, 'opencode-agent');
85
+ }
121
86
  }
122
87
  }
123
88
  catch (e) {
@@ -171,7 +136,7 @@ export class OpencodeAgent {
171
136
  // Fetch GH token so the LLM environment can execute `gh` commands
172
137
  let ghTokenEnv = '';
173
138
  try {
174
- const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], originalWorkspacePath);
139
+ const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], originalWorkspacePath, 'opencode-agent');
175
140
  if (ghToken.trim()) {
176
141
  ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
177
142
  }
@@ -1,53 +1,11 @@
1
1
  import { ticketRepository } from '../repositories/ticket.repository.js';
2
2
  import { commentRepository } from '../repositories/comment.repository.js';
3
3
  import { agentQueue } from './agent-queue.js';
4
- import { execFile, exec } from 'child_process';
5
- import { promisify } from 'util';
6
- import fs from 'fs';
7
- const execAsync = promisify(exec);
8
- const execFileAsync = promisify(execFile);
9
- // Cache the gh token so we only fetch it once per server process
10
- let cachedGhToken = null;
11
- async function getGhToken(cwd) {
12
- if (cachedGhToken !== null)
13
- return cachedGhToken;
14
- try {
15
- const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd, shell: true });
16
- cachedGhToken = stdout.trim() || null;
17
- }
18
- catch {
19
- cachedGhToken = null;
20
- }
21
- return cachedGhToken;
22
- }
23
- // Helper function to execute commands robustly on Windows.
24
- // Automatically injects GH_TOKEN for any `gh` subcommand.
25
- async function runCmd(cmd, args, cwd) {
26
- console.log(`[opencode-events] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
27
- // Safety check for CWD to avoid obscure ENOENT shell errors
28
- if (!fs.existsSync(cwd)) {
29
- throw new Error(`Directory does not exist: ${cwd}`);
30
- }
31
- let extraEnv = {};
32
- if (cmd === 'gh') {
33
- const token = await getGhToken(cwd);
34
- if (token)
35
- extraEnv['GH_TOKEN'] = token;
36
- }
37
- const env = Object.keys(extraEnv).length > 0 ? { ...process.env, ...extraEnv } : undefined;
38
- try {
39
- return await execFileAsync(cmd, args, { cwd, shell: true, ...(env && { env }) });
40
- }
41
- catch (e) {
42
- if (e.code === 'ENOENT') {
43
- console.log(`[opencode-events] ENOENT with shell: true. Trying fallback exec...`);
44
- const envPrefix = extraEnv['GH_TOKEN'] ? `GH_TOKEN=${extraEnv['GH_TOKEN']} ` : '';
45
- return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd });
46
- }
47
- throw e;
48
- }
49
- }
50
- export async function setupOpencodeEventListener(events, opencodeClient, sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName, agentType = 'opencode') {
4
+ import { runCmd, getGhToken } from '../utils/os.js';
5
+ import { createBoardScopedClient } from '../utils/opencode.js';
6
+ export async function setupOpencodeEventListener(events, _unused, // kept for signature compatibility if called elsewhere
7
+ sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName, agentType = 'opencode') {
8
+ const opencodeClient = createBoardScopedClient(originalWorkspacePath);
51
9
  const processedMessages = new Set();
52
10
  const activeParts = new Map();
53
11
  let rawSessionCost = 0;
@@ -200,7 +158,7 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
200
158
  const prUrlSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
201
159
  const prUrl = prUrlSession?.pr_url;
202
160
  if (prUrl) {
203
- const { stdout: prStatus } = await runCmd('gh', ['pr', 'view', prUrl, '--json', 'comments'], worktreePath);
161
+ const { stdout: prStatus } = await runCmd('gh', ['pr', 'view', prUrl, '--json', 'comments'], worktreePath, 'opencode-events');
204
162
  const comments = JSON.parse(prStatus).comments;
205
163
  // Find the latest comment that contains a decision
206
164
  let reviewDecision = 'NONE';
@@ -275,11 +233,11 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
275
233
  // Commit, push, and create PR before moving ticket
276
234
  try {
277
235
  console.log(`[opencode-agent] Checking for changes in worktree ${worktreePath}`);
278
- const { stdout: statusOut } = await runCmd('git', ['status', '--porcelain'], worktreePath);
236
+ const { stdout: statusOut } = await runCmd('git', ['status', '--porcelain'], worktreePath, 'opencode-events');
279
237
  if (statusOut.trim()) {
280
238
  console.log(`[opencode-agent] Changes found for ticket ${ticket.id}. Committing and pushing.`);
281
- await runCmd('git', ['add', '.'], worktreePath);
282
- await runCmd('git', ['commit', '-m', `"${ticket.title.replace(/"/g, '\\"')}"`], worktreePath);
239
+ await runCmd('git', ['add', '.'], worktreePath, 'opencode-events');
240
+ await runCmd('git', ['commit', '-m', `"${ticket.title.replace(/"/g, '\\"')}"`], worktreePath, 'opencode-events');
283
241
  // Inject GH_TOKEN into remote URL for git push authentication
284
242
  const token = await getGhToken(worktreePath);
285
243
  if (token) {
@@ -288,14 +246,14 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
288
246
  const remoteUrl = remoteUrlOut.trim();
289
247
  if (remoteUrl.startsWith('https://github.com/')) {
290
248
  const authedUrl = remoteUrl.replace('https://github.com/', `https://x-access-token:${token}@github.com/`);
291
- await runCmd('git', ['remote', 'set-url', 'origin', authedUrl], worktreePath);
249
+ await runCmd('git', ['remote', 'set-url', 'origin', authedUrl], worktreePath, 'opencode-events');
292
250
  }
293
251
  }
294
252
  catch (urlErr) {
295
253
  console.warn(`[opencode-agent] Could not set authenticated remote URL:`, urlErr);
296
254
  }
297
255
  }
298
- await runCmd('git', ['push', '-u', 'origin', branchName], worktreePath);
256
+ await runCmd('git', ['push', '-u', 'origin', branchName], worktreePath, 'opencode-events');
299
257
  // Check if a PR already exists for this ticket (fetch fresh from DB)
300
258
  const freshTicket = ticketRepository.findById(ticket.id) || ticket;
301
259
  const existingPrUrlSession = [...(freshTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
@@ -304,7 +262,7 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
304
262
  updateSessionComment(`🚀 **Pull Request Updated**\n\nThe agent has updated the existing PR:\n${prUrl}${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'pr');
305
263
  }
306
264
  else {
307
- const { stdout: prOut } = await runCmd('gh', ['pr', 'create', '--title', `"${ticket.title.replace(/"/g, '\\"')}"`, '--body', `"Automated PR from OpenCode Agent for ticket #${ticket.id}"`], worktreePath);
265
+ const { stdout: prOut } = await runCmd('gh', ['pr', 'create', '--title', `"${ticket.title.replace(/"/g, '\\"')}"`, '--body', `"Automated PR from OpenCode Agent for ticket #${ticket.id}"`], worktreePath, 'opencode-events');
308
266
  prUrl = prOut.trim();
309
267
  updateSessionComment(`🚀 **Pull Request Created**\n\nThe agent has proposed the following changes in a PR. Check it out:\n${prUrl}${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'pr');
310
268
  }
File without changes
File without changes
File without changes
@@ -23,11 +23,22 @@ export const boardRepository = {
23
23
  workspaces
24
24
  };
25
25
  },
26
- create(name, workspaces = []) {
26
+ findByPath(path) {
27
+ const db = getDb();
28
+ const board = db.prepare('SELECT * FROM boards WHERE path = ?').get(path);
29
+ if (!board)
30
+ return undefined;
31
+ const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(board.id);
32
+ return {
33
+ ...board,
34
+ workspaces
35
+ };
36
+ },
37
+ create(name, path, workspaces = []) {
27
38
  const boardId = randomUUID();
28
39
  const db = getDb();
29
40
  db.transaction(() => {
30
- db.prepare('INSERT INTO boards (id, name) VALUES (?, ?)').run(boardId, name);
41
+ db.prepare('INSERT INTO boards (id, name, path) VALUES (?, ?, ?)').run(boardId, name, path || null);
31
42
  for (const ws of workspaces) {
32
43
  db.prepare('INSERT INTO board_workspaces (id, board_id, type, path) VALUES (?, ?, ?, ?)')
33
44
  .run(randomUUID(), boardId, ws.type, ws.path);
@@ -35,10 +46,15 @@ export const boardRepository = {
35
46
  })();
36
47
  return this.findById(boardId);
37
48
  },
38
- update(id, name, workspaces) {
49
+ update(id, name, path, workspaces) {
39
50
  const db = getDb();
40
51
  db.transaction(() => {
41
- db.prepare('UPDATE boards SET name = ? WHERE id = ?').run(name, id);
52
+ if (path !== undefined) {
53
+ db.prepare('UPDATE boards SET name = ?, path = ? WHERE id = ?').run(name, path, id);
54
+ }
55
+ else {
56
+ db.prepare('UPDATE boards SET name = ? WHERE id = ?').run(name, id);
57
+ }
42
58
  if (workspaces) {
43
59
  // Simple sync: delete all and re-create
44
60
  db.prepare('DELETE FROM board_workspaces WHERE board_id = ?').run(id);
@@ -6,18 +6,18 @@ router.get('/', (_req, res) => {
6
6
  res.json(boardRepository.findAll());
7
7
  });
8
8
  router.post('/', (req, res) => {
9
- const { name, workspaces } = req.body;
9
+ const { name, path, workspaces } = req.body;
10
10
  if (!name?.trim()) {
11
11
  res.status(400).json({ error: 'Name is required' });
12
12
  return;
13
13
  }
14
- const board = boardRepository.create(name.trim(), workspaces);
14
+ const board = boardRepository.create(name.trim(), path, workspaces);
15
15
  sseManager.emitGlobal('board:created', board);
16
16
  res.status(201).json(board);
17
17
  });
18
18
  router.patch('/:id', (req, res) => {
19
- const { name, workspaces } = req.body;
20
- const board = boardRepository.update(req.params.id, name?.trim(), workspaces);
19
+ const { name, path, workspaces } = req.body;
20
+ const board = boardRepository.update(req.params.id, name?.trim(), path, workspaces);
21
21
  if (!board) {
22
22
  res.status(404).json({ error: 'Board not found' });
23
23
  return;
File without changes
File without changes
File without changes
File without changes
@@ -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
+ }