@polderlabs/bizar 2.3.0 → 2.6.0
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/cli/bin.mjs +73 -0
- package/cli/copy.mjs +42 -2
- package/cli/dashboard/api.mjs +473 -0
- package/cli/dashboard/browser.mjs +40 -0
- package/cli/dashboard/server.mjs +366 -0
- package/cli/dashboard/state.mjs +438 -0
- package/cli/dashboard/tasks-store.mjs +203 -0
- package/cli/dashboard/watcher.mjs +81 -0
- package/cli/dashboard.mjs +97 -0
- package/cli/install.mjs +17 -4
- package/config/commands/bizar.md +18 -0
- package/config/commands/plan.md +26 -0
- package/config/commands/visual-plan.md +15 -0
- package/config/opencode.json +259 -1
- package/dist/assets/index-BVvY22Gt.css +1 -0
- package/dist/assets/index-CO3c8O32.js +285 -0
- package/dist/assets/index-CO3c8O32.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +26 -2
- package/src/App.tsx +233 -0
- package/src/components/Button.tsx +55 -0
- package/src/components/Card.tsx +40 -0
- package/src/components/EmptyState.tsx +30 -0
- package/src/components/Modal.tsx +137 -0
- package/src/components/Spinner.tsx +19 -0
- package/src/components/StatusBadge.tsx +25 -0
- package/src/components/Tag.tsx +28 -0
- package/src/components/Toast.tsx +142 -0
- package/src/components/Topbar.tsx +88 -0
- package/src/index.html +17 -0
- package/src/lib/api.ts +71 -0
- package/src/lib/markdown.tsx +59 -0
- package/src/lib/types.ts +200 -0
- package/src/lib/utils.ts +79 -0
- package/src/lib/ws.ts +132 -0
- package/src/main.tsx +12 -0
- package/src/styles/main.css +2324 -0
- package/src/views/Agents.tsx +199 -0
- package/src/views/Chat.tsx +255 -0
- package/src/views/Config.tsx +250 -0
- package/src/views/Overview.tsx +267 -0
- package/src/views/Plans.tsx +667 -0
- package/src/views/Projects.tsx +155 -0
- package/src/views/Settings.tsx +253 -0
- package/src/views/Tasks.tsx +567 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
- package/config/opencode.json.template +0 -52
package/cli/bin.mjs
CHANGED
|
@@ -24,6 +24,7 @@ function showHelp() {
|
|
|
24
24
|
bizar plan <subcommand> Manage visual plans (new, open, list, delete, export, templates)
|
|
25
25
|
bizar test-gate Detect & run the project's test suite
|
|
26
26
|
bizar update Update opencode, bizar, and/or bizar-plugin
|
|
27
|
+
bizar dashboard [start|stop|status] Launch or control the web dashboard (v2.5.0+)
|
|
27
28
|
bizar --help Show this help
|
|
28
29
|
|
|
29
30
|
Install:
|
|
@@ -93,6 +94,75 @@ function showTestGateHelp() {
|
|
|
93
94
|
`);
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
function showDashboardHelp() {
|
|
98
|
+
console.log(`
|
|
99
|
+
bizar dashboard — Launch or control the Bizar web dashboard
|
|
100
|
+
|
|
101
|
+
Usage:
|
|
102
|
+
bizar dashboard Start the dashboard (default action = start)
|
|
103
|
+
bizar dashboard start Start the dashboard in the current process
|
|
104
|
+
bizar dashboard stop Kill the running dashboard (reads PID file)
|
|
105
|
+
bizar dashboard status Print port + URL of any running dashboard
|
|
106
|
+
|
|
107
|
+
Description:
|
|
108
|
+
Starts a local Express + WebSocket server on 127.0.0.1, opens the
|
|
109
|
+
user's default browser to the dashboard URL, and broadcasts live
|
|
110
|
+
file-change events from config/, agents/, commands-bizar/, .bizar/,
|
|
111
|
+
and plans/. The server binds loopback only — never expose it.
|
|
112
|
+
`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runDashboard(action) {
|
|
116
|
+
const sub = action || 'start';
|
|
117
|
+
const { launchDashboard, PORT_FILE, PID_FILE } = await import('./dashboard.mjs');
|
|
118
|
+
const { existsSync, readFileSync, unlinkSync } = await import('node:fs');
|
|
119
|
+
|
|
120
|
+
if (sub === 'status') {
|
|
121
|
+
if (existsSync(PORT_FILE)) {
|
|
122
|
+
const port = readFileSync(PORT_FILE, 'utf8').trim();
|
|
123
|
+
console.log(`Bizar dashboard is running at http://localhost:${port}/`);
|
|
124
|
+
if (existsSync(PID_FILE)) {
|
|
125
|
+
console.log(`PID: ${readFileSync(PID_FILE, 'utf8').trim()}`);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
console.log('No Bizar dashboard is running. Use: bizar dashboard start');
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (sub === 'stop') {
|
|
134
|
+
if (!existsSync(PID_FILE)) {
|
|
135
|
+
console.log('No Bizar dashboard is running.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
139
|
+
if (!Number.isFinite(pid)) {
|
|
140
|
+
console.log(`Bad PID file: ${PID_FILE}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 'SIGTERM');
|
|
145
|
+
console.log(`Stopped Bizar dashboard (pid ${pid}).`);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.log(`Could not stop dashboard (pid ${pid}): ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
try { unlinkSync(PORT_FILE); } catch { /* ignore */ }
|
|
150
|
+
try { unlinkSync(PID_FILE); } catch { /* ignore */ }
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (sub !== 'start') {
|
|
155
|
+
showDashboardHelp();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Start — keep the process alive so the server stays up
|
|
160
|
+
await launchDashboard();
|
|
161
|
+
console.log(chalk.dim(' Press Ctrl-C to stop the dashboard.'));
|
|
162
|
+
// Wait forever; SIGINT will exit the process.
|
|
163
|
+
await new Promise(() => {});
|
|
164
|
+
}
|
|
165
|
+
|
|
96
166
|
function showUpdateHelp() {
|
|
97
167
|
console.log(`
|
|
98
168
|
bizar update — Update opencode, bizar, and/or bizar-plugin
|
|
@@ -179,6 +249,9 @@ if (args.includes('--postinstall')) {
|
|
|
179
249
|
} else if (args[0] === 'plan') {
|
|
180
250
|
const planArgs = args.slice(1);
|
|
181
251
|
await runPlan(planArgs, {});
|
|
252
|
+
} else if (args[0] === 'dashboard') {
|
|
253
|
+
if (args.includes('--help') || args.includes('-h')) showDashboardHelp();
|
|
254
|
+
else await runDashboard(args[1]);
|
|
182
255
|
} else if (args.includes('--help') || args.includes('-h')) {
|
|
183
256
|
showHelp();
|
|
184
257
|
} else {
|
package/cli/copy.mjs
CHANGED
|
@@ -269,8 +269,11 @@ export async function installPluginBizar(projectRoot) {
|
|
|
269
269
|
try {
|
|
270
270
|
await access(srcDir, constants.F_OK);
|
|
271
271
|
} catch {
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
// The Bizar plugin now lives in a separate npm package
|
|
273
|
+
// (@polderlabs/bizar-plugin). The interactive installer copies it from
|
|
274
|
+
// there; the source tree no longer carries plugins/bizar/.
|
|
275
|
+
// Silent return — no warning needed.
|
|
276
|
+
return { copied: 0, errors: [] };
|
|
274
277
|
}
|
|
275
278
|
|
|
276
279
|
const destDir = join(projectRoot, '.opencode', 'plugins', 'bizar');
|
|
@@ -506,3 +509,40 @@ export async function installCommands() {
|
|
|
506
509
|
}
|
|
507
510
|
return count;
|
|
508
511
|
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Install Bizar-specific commands to commands-bizar/ (separate from ECC's
|
|
515
|
+
* commands/ symlink). This directory holds the Bizar plugin's own command
|
|
516
|
+
* templates: audit, explain, init, learn, plan, pr-review, tailscale-serve,
|
|
517
|
+
* visual-plan, and bizar.
|
|
518
|
+
*
|
|
519
|
+
* If dest is a symlink (e.g. the ECC installer symlinked it), skip with a
|
|
520
|
+
* friendly message — we don't want to follow a symlink and write into someone
|
|
521
|
+
* else's directory.
|
|
522
|
+
*
|
|
523
|
+
* @returns {Promise<number>} count of .md files copied
|
|
524
|
+
*/
|
|
525
|
+
export async function installCommandsBizar() {
|
|
526
|
+
const src = repoPath('config', 'commands');
|
|
527
|
+
const dest = join(opencodeConfigDir(), 'commands-bizar');
|
|
528
|
+
const { mkdirSync, readdirSync, copyFileSync, lstatSync } = await import('node:fs');
|
|
529
|
+
|
|
530
|
+
// Guard: skip if dest is a symlink (don't follow other packages' symlinks)
|
|
531
|
+
try {
|
|
532
|
+
const stat = lstatSync(dest);
|
|
533
|
+
if (stat.isSymbolicLink()) {
|
|
534
|
+
console.log('BizarHarness: commands-bizar/ is a symlink — skipping install.');
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
// dest does not exist — that's fine, we'll create it
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
mkdirSync(dest, { recursive: true });
|
|
542
|
+
let count = 0;
|
|
543
|
+
for (const file of readdirSync(src).filter(f => f.endsWith('.md'))) {
|
|
544
|
+
copyFileSync(join(src, file), join(dest, file));
|
|
545
|
+
count++;
|
|
546
|
+
}
|
|
547
|
+
return count;
|
|
548
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/dashboard/api.mjs
|
|
3
|
+
*
|
|
4
|
+
* REST surface for the dashboard. Every route:
|
|
5
|
+
* - Returns JSON
|
|
6
|
+
* - Returns { error, message } with appropriate status on failure
|
|
7
|
+
* - Never throws to Express — every handler is wrapped in try/catch
|
|
8
|
+
*
|
|
9
|
+
* The router is mounted at /api/* by cli/dashboard/server.mjs.
|
|
10
|
+
*/
|
|
11
|
+
import express from 'express';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, dirname, basename } from 'node:path';
|
|
14
|
+
import { createTask, updateTask, deleteTask, moveTask } from './tasks-store.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
* @param {ReturnType<import('./state.mjs').createState>} deps.state
|
|
19
|
+
* @param {ReturnType<import('./watcher.mjs').createWatcher>} deps.watcher
|
|
20
|
+
* @param {string} deps.projectRoot
|
|
21
|
+
* @param {string} deps.opencodeConfigDir
|
|
22
|
+
* @param {string} deps.bizarRoot
|
|
23
|
+
* @param {function} deps.broadcast - WS broadcast function ({ type, ... }) => void
|
|
24
|
+
*/
|
|
25
|
+
export function createApiRouter({
|
|
26
|
+
state,
|
|
27
|
+
watcher,
|
|
28
|
+
projectRoot,
|
|
29
|
+
opencodeConfigDir,
|
|
30
|
+
bizarRoot,
|
|
31
|
+
broadcast = () => {},
|
|
32
|
+
}) {
|
|
33
|
+
const router = express.Router();
|
|
34
|
+
|
|
35
|
+
/** Safe async handler — never throws past Express. */
|
|
36
|
+
const wrap =
|
|
37
|
+
(fn) =>
|
|
38
|
+
async (req, res, ...rest) => {
|
|
39
|
+
try {
|
|
40
|
+
await fn(req, res, ...rest);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const status = err?.status || 500;
|
|
43
|
+
res.status(status).json({
|
|
44
|
+
error: err?.code || 'internal_error',
|
|
45
|
+
message: err?.message || String(err),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── /api/overview ──────────────────────────────────────────────────────────
|
|
51
|
+
router.get('/overview', wrap(async (_req, res) => {
|
|
52
|
+
res.json(state.getOverview());
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// ── /api/snapshot ──────────────────────────────────────────────────────────
|
|
56
|
+
// Returns every dashboard panel in one round-trip — used by the React SPA
|
|
57
|
+
// for its initial load. Mirrors the WS snapshot payload shape.
|
|
58
|
+
router.get('/snapshot', wrap(async (_req, res) => {
|
|
59
|
+
res.json({
|
|
60
|
+
overview: state.getOverview(),
|
|
61
|
+
agents: state.getAgents(),
|
|
62
|
+
plans: state.getPlans(),
|
|
63
|
+
projects: state.getProjects(),
|
|
64
|
+
config: state.getConfig(),
|
|
65
|
+
settings: state.getSettings(),
|
|
66
|
+
tasks: state.getTasks(),
|
|
67
|
+
});
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// ── /api/chat ──────────────────────────────────────────────────────────────
|
|
71
|
+
router.get('/chat', wrap(async (req, res) => {
|
|
72
|
+
const sessionId = req.query.session
|
|
73
|
+
? String(req.query.session)
|
|
74
|
+
: null;
|
|
75
|
+
const limit = req.query.limit ? Number(req.query.limit) : 200;
|
|
76
|
+
res.json(state.getChat({ sessionId, limit }));
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
router.post('/chat', wrap(async (req, res) => {
|
|
80
|
+
const body = req.body || {};
|
|
81
|
+
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
|
82
|
+
const agent = typeof body.agent === 'string' ? body.agent : null;
|
|
83
|
+
if (!message) {
|
|
84
|
+
res.status(400).json({
|
|
85
|
+
error: 'bad_request',
|
|
86
|
+
message: 'message is required',
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
state.appendActivity({
|
|
91
|
+
kind: 'chat.message',
|
|
92
|
+
agent,
|
|
93
|
+
message: message.slice(0, 500),
|
|
94
|
+
});
|
|
95
|
+
res.status(202).json({
|
|
96
|
+
accepted: true,
|
|
97
|
+
agent,
|
|
98
|
+
queued: true,
|
|
99
|
+
note:
|
|
100
|
+
'Live agent dispatch runs in the opencode TUI; the dashboard ' +
|
|
101
|
+
'records and broadcasts the message. Open the TUI to dispatch it.',
|
|
102
|
+
});
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
// ── /api/agents ────────────────────────────────────────────────────────────
|
|
106
|
+
router.get('/agents', wrap(async (_req, res) => {
|
|
107
|
+
const agents = state.getAgents();
|
|
108
|
+
res.json({ agents });
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
router.post(
|
|
112
|
+
'/agents/:name/invoke',
|
|
113
|
+
wrap(async (req, res) => {
|
|
114
|
+
const name = req.params.name;
|
|
115
|
+
const prompt = (req.body && req.body.prompt) || '';
|
|
116
|
+
if (!prompt.trim()) {
|
|
117
|
+
res.status(400).json({
|
|
118
|
+
error: 'bad_request',
|
|
119
|
+
message: 'prompt is required',
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
state.appendActivity({
|
|
124
|
+
kind: 'agent.invoke',
|
|
125
|
+
agent: name,
|
|
126
|
+
prompt: String(prompt).slice(0, 500),
|
|
127
|
+
});
|
|
128
|
+
res.status(202).json({
|
|
129
|
+
accepted: true,
|
|
130
|
+
agent: name,
|
|
131
|
+
note:
|
|
132
|
+
'Agent dispatch is forwarded to the opencode TUI. The dashboard ' +
|
|
133
|
+
'records the invocation for the activity feed.',
|
|
134
|
+
});
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ── /api/plans ─────────────────────────────────────────────────────────────
|
|
139
|
+
router.get('/plans', wrap(async (_req, res) => {
|
|
140
|
+
res.json({ plans: state.getPlans() });
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
router.post('/plans', wrap(async (req, res) => {
|
|
144
|
+
const slug = req.body?.slug;
|
|
145
|
+
const title = req.body?.title || slug;
|
|
146
|
+
if (
|
|
147
|
+
typeof slug !== 'string' ||
|
|
148
|
+
!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)
|
|
149
|
+
) {
|
|
150
|
+
res.status(400).json({
|
|
151
|
+
error: 'bad_request',
|
|
152
|
+
message:
|
|
153
|
+
'slug must match ^[a-z0-9][a-z0-9-]{0,63}$',
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const plansDir = state.paths.plansDir;
|
|
158
|
+
const target = join(plansDir, slug);
|
|
159
|
+
if (existsSync(target)) {
|
|
160
|
+
res.status(409).json({
|
|
161
|
+
error: 'exists',
|
|
162
|
+
message: `plan "${slug}" already exists`,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
mkdirSync(target, { recursive: true });
|
|
167
|
+
writeFileSync(
|
|
168
|
+
join(target, 'meta.json'),
|
|
169
|
+
JSON.stringify(
|
|
170
|
+
{
|
|
171
|
+
slug,
|
|
172
|
+
title,
|
|
173
|
+
status: 'draft',
|
|
174
|
+
createdAt: new Date().toISOString(),
|
|
175
|
+
},
|
|
176
|
+
null,
|
|
177
|
+
2,
|
|
178
|
+
) + '\n',
|
|
179
|
+
'utf8',
|
|
180
|
+
);
|
|
181
|
+
state.appendActivity({ kind: 'plan.create', slug, title });
|
|
182
|
+
watcher.poke('change', target);
|
|
183
|
+
res.status(201).json({ slug, title, path: target });
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
router.get('/plans/:slug', wrap(async (req, res) => {
|
|
187
|
+
const slug = req.params.slug;
|
|
188
|
+
const local = join(state.paths.plansDir, slug);
|
|
189
|
+
const global = join(state.paths.globalPlansDir, slug);
|
|
190
|
+
const dir = existsSync(local) ? local : existsSync(global) ? global : null;
|
|
191
|
+
if (!dir) {
|
|
192
|
+
res.status(404).json({
|
|
193
|
+
error: 'not_found',
|
|
194
|
+
message: `plan "${slug}" not found`,
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const meta = readMeta(join(dir, 'meta.json'));
|
|
199
|
+
const planMdx = readMaybe(join(dir, 'plan.mdx'));
|
|
200
|
+
res.json({
|
|
201
|
+
slug,
|
|
202
|
+
dir,
|
|
203
|
+
meta,
|
|
204
|
+
planMdx,
|
|
205
|
+
});
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
router.get('/plans/:slug/canvas', wrap(async (req, res) => {
|
|
209
|
+
const slug = req.params.slug;
|
|
210
|
+
const local = join(state.paths.plansDir, slug);
|
|
211
|
+
const global = join(state.paths.globalPlansDir, slug);
|
|
212
|
+
const dir = existsSync(local) ? local : existsSync(global) ? global : null;
|
|
213
|
+
if (!dir) {
|
|
214
|
+
res.status(404).json({
|
|
215
|
+
error: 'not_found',
|
|
216
|
+
message: `plan "${slug}" not found`,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const planJson = join(dir, 'plan.json');
|
|
221
|
+
const canvas = readMaybe(planJson);
|
|
222
|
+
if (canvas === null) {
|
|
223
|
+
res.json({
|
|
224
|
+
slug,
|
|
225
|
+
dir,
|
|
226
|
+
canvas: {
|
|
227
|
+
schemaVersion: 2,
|
|
228
|
+
title: slug,
|
|
229
|
+
elements: [],
|
|
230
|
+
connections: [],
|
|
231
|
+
comments: [],
|
|
232
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
res.json({
|
|
239
|
+
slug,
|
|
240
|
+
dir,
|
|
241
|
+
canvas: JSON.parse(canvas),
|
|
242
|
+
});
|
|
243
|
+
} catch {
|
|
244
|
+
res.json({
|
|
245
|
+
slug,
|
|
246
|
+
dir,
|
|
247
|
+
canvas: {
|
|
248
|
+
schemaVersion: 2,
|
|
249
|
+
title: slug,
|
|
250
|
+
elements: [],
|
|
251
|
+
connections: [],
|
|
252
|
+
comments: [],
|
|
253
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
router.put('/plans/:slug', wrap(async (req, res) => {
|
|
260
|
+
const slug = req.params.slug;
|
|
261
|
+
const local = join(state.paths.plansDir, slug);
|
|
262
|
+
const global = join(state.paths.globalPlansDir, slug);
|
|
263
|
+
const dir = existsSync(local) ? local : existsSync(global) ? local : null;
|
|
264
|
+
if (!dir) {
|
|
265
|
+
res.status(404).json({
|
|
266
|
+
error: 'not_found',
|
|
267
|
+
message: `plan "${slug}" not found`,
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const body = req.body || {};
|
|
272
|
+
if (!body || typeof body !== 'object') {
|
|
273
|
+
res.status(400).json({
|
|
274
|
+
error: 'bad_request',
|
|
275
|
+
message: 'body must be an object',
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (body.meta && typeof body.meta === 'object') {
|
|
280
|
+
const existing = readMeta(join(dir, 'meta.json')) || {};
|
|
281
|
+
const merged = { ...existing, ...body.meta, updatedAt: new Date().toISOString() };
|
|
282
|
+
mkdirSync(dir, { recursive: true });
|
|
283
|
+
writeFileSync(
|
|
284
|
+
join(dir, 'meta.json'),
|
|
285
|
+
JSON.stringify(merged, null, 2) + '\n',
|
|
286
|
+
'utf8',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (typeof body.planMdx === 'string') {
|
|
290
|
+
writeFileSync(join(dir, 'plan.mdx'), body.planMdx, 'utf8');
|
|
291
|
+
}
|
|
292
|
+
state.appendActivity({ kind: 'plan.update', slug });
|
|
293
|
+
watcher.poke('change', dir);
|
|
294
|
+
res.json({ ok: true, slug });
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
// ── /api/projects ──────────────────────────────────────────────────────────
|
|
298
|
+
router.get('/projects', wrap(async (_req, res) => {
|
|
299
|
+
res.json({ projects: state.getProjects() });
|
|
300
|
+
}));
|
|
301
|
+
|
|
302
|
+
router.post(
|
|
303
|
+
'/projects/:name/activate',
|
|
304
|
+
wrap(async (req, res) => {
|
|
305
|
+
const name = req.params.name;
|
|
306
|
+
const projects = state.getProjects();
|
|
307
|
+
const target = projects.find((p) => p.name === name);
|
|
308
|
+
if (!target) {
|
|
309
|
+
res.status(404).json({
|
|
310
|
+
error: 'not_found',
|
|
311
|
+
message: `project "${name}" not found`,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
state.appendActivity({
|
|
316
|
+
kind: 'project.activate',
|
|
317
|
+
name,
|
|
318
|
+
path: target.path,
|
|
319
|
+
});
|
|
320
|
+
res.json({
|
|
321
|
+
activated: name,
|
|
322
|
+
path: target.path,
|
|
323
|
+
note:
|
|
324
|
+
'The dashboard exposes the project; the opencode TUI must be ' +
|
|
325
|
+
'restarted in the new directory to fully activate.',
|
|
326
|
+
});
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// ── /api/config ────────────────────────────────────────────────────────────
|
|
331
|
+
router.get('/config', wrap(async (_req, res) => {
|
|
332
|
+
res.json(state.getConfig());
|
|
333
|
+
}));
|
|
334
|
+
|
|
335
|
+
router.put('/config', wrap(async (req, res) => {
|
|
336
|
+
const body = req.body;
|
|
337
|
+
let parsed;
|
|
338
|
+
if (typeof body === 'string') {
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(body);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
res.status(400).json({
|
|
343
|
+
error: 'invalid_json',
|
|
344
|
+
message: err.message,
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
} else if (body && typeof body === 'object') {
|
|
349
|
+
parsed = body;
|
|
350
|
+
} else {
|
|
351
|
+
res.status(400).json({
|
|
352
|
+
error: 'bad_request',
|
|
353
|
+
message: 'body must be a JSON object or string',
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const updated = state.setConfig(parsed);
|
|
358
|
+
state.appendActivity({ kind: 'config.update' });
|
|
359
|
+
watcher.poke('change', state.paths.opencodeJson);
|
|
360
|
+
res.json(updated);
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
router.post('/config/reload', wrap(async (_req, res) => {
|
|
364
|
+
const snapshot = state.getConfig();
|
|
365
|
+
state.appendActivity({ kind: 'config.reload' });
|
|
366
|
+
res.json(snapshot);
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
// ── /api/settings ──────────────────────────────────────────────────────────
|
|
370
|
+
router.get('/settings', wrap(async (_req, res) => {
|
|
371
|
+
res.json(state.getSettings());
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
router.put('/settings', wrap(async (req, res) => {
|
|
375
|
+
const body = req.body;
|
|
376
|
+
if (!body || typeof body !== 'object') {
|
|
377
|
+
res.status(400).json({
|
|
378
|
+
error: 'bad_request',
|
|
379
|
+
message: 'body must be an object',
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const updated = state.setSettings(body);
|
|
384
|
+
state.appendActivity({ kind: 'settings.update' });
|
|
385
|
+
res.json(updated);
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
// ── /api/tasks ────────────────────────────────────────────────────────────
|
|
389
|
+
router.get('/tasks', wrap(async (_req, res) => {
|
|
390
|
+
res.json(state.getTasks());
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
router.post('/tasks', wrap(async (req, res) => {
|
|
394
|
+
const { title, description, status, tags, priority } = req.body || {};
|
|
395
|
+
if (!title || typeof title !== 'string' || title.length > 200) {
|
|
396
|
+
res.status(400).json({ error: 'bad_request', message: 'title required (1-200 chars)' });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const task = await createTask({ title, description, status, tags, priority });
|
|
400
|
+
broadcast({ type: 'tasks:change', task });
|
|
401
|
+
res.status(201).json(task);
|
|
402
|
+
}));
|
|
403
|
+
|
|
404
|
+
router.put('/tasks/:id', wrap(async (req, res) => {
|
|
405
|
+
const task = await updateTask(req.params.id, req.body || {});
|
|
406
|
+
if (!task) {
|
|
407
|
+
res.status(404).json({ error: 'not_found' });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
broadcast({ type: 'tasks:change', task });
|
|
411
|
+
res.json(task);
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
router.patch('/tasks/:id/status', wrap(async (req, res) => {
|
|
415
|
+
const { status } = req.body || {};
|
|
416
|
+
if (!['queued', 'doing', 'done'].includes(status)) {
|
|
417
|
+
res.status(400).json({ error: 'bad_request', message: 'invalid status' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const task = await moveTask(req.params.id, status);
|
|
421
|
+
if (!task) {
|
|
422
|
+
res.status(404).json({ error: 'not_found' });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
broadcast({ type: 'tasks:change', task });
|
|
426
|
+
res.json(task);
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
router.delete('/tasks/:id', wrap(async (req, res) => {
|
|
430
|
+
const ok = await deleteTask(req.params.id);
|
|
431
|
+
if (!ok) {
|
|
432
|
+
res.status(404).json({ error: 'not_found' });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
broadcast({ type: 'tasks:delete', id: req.params.id });
|
|
436
|
+
res.status(204).end();
|
|
437
|
+
}));
|
|
438
|
+
|
|
439
|
+
// ── health ────────────────────────────────────────────────────────────────
|
|
440
|
+
router.get('/health', (_req, res) => {
|
|
441
|
+
res.json({ ok: true, ts: Date.now() });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ── fallback ──────────────────────────────────────────────────────────────
|
|
445
|
+
router.use((req, res) => {
|
|
446
|
+
res.status(404).json({
|
|
447
|
+
error: 'not_found',
|
|
448
|
+
message: `no route for ${req.method} ${req.originalUrl}`,
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return router;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function readMeta(file) {
|
|
456
|
+
try {
|
|
457
|
+
if (!existsSync(file)) return null;
|
|
458
|
+
const txt = readFileSync(file, 'utf8');
|
|
459
|
+
if (!txt.trim()) return null;
|
|
460
|
+
return JSON.parse(txt);
|
|
461
|
+
} catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function readMaybe(file) {
|
|
467
|
+
try {
|
|
468
|
+
if (!existsSync(file)) return null;
|
|
469
|
+
return readFileSync(file, 'utf8');
|
|
470
|
+
} catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/dashboard/browser.mjs
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform best-effort browser launcher. On failure (no graphical
|
|
5
|
+
* session, headless server), we just log the URL so the user can open it
|
|
6
|
+
* manually.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
export async function launchBrowser(url) {
|
|
11
|
+
const platform = process.platform;
|
|
12
|
+
let cmd;
|
|
13
|
+
let args;
|
|
14
|
+
|
|
15
|
+
if (platform === 'darwin') {
|
|
16
|
+
cmd = 'open';
|
|
17
|
+
args = [url];
|
|
18
|
+
} else if (platform === 'win32') {
|
|
19
|
+
// Windows `start` is a shell builtin; spawn it via cmd.exe.
|
|
20
|
+
cmd = 'cmd';
|
|
21
|
+
args = ['/c', 'start', '""', url];
|
|
22
|
+
} else {
|
|
23
|
+
cmd = 'xdg-open';
|
|
24
|
+
args = [url];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const child = spawn(cmd, args, {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
});
|
|
32
|
+
child.on('error', () => {
|
|
33
|
+
/* swallowed — best effort */
|
|
34
|
+
});
|
|
35
|
+
child.unref();
|
|
36
|
+
} catch (_err) {
|
|
37
|
+
// Browser launch failed — print URL for manual opening
|
|
38
|
+
console.log(`Open ${url} in your browser`);
|
|
39
|
+
}
|
|
40
|
+
}
|