@polderlabs/bizar-dash 3.0.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.
Files changed (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * src/server/diagnostics-store.mjs
3
+ *
4
+ * v3.0.0 — Health check, version info, and last-N errors.
5
+ *
6
+ * Returns a single snapshot for the diagnostics card in the dashboard.
7
+ */
8
+ import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { projectsStore } from './projects-store.mjs';
12
+ import { modsLoader } from './mods-loader.mjs';
13
+ import { agentsStore } from './agents-store.mjs';
14
+ import { providersStore, mcpsStore } from './providers-store.mjs';
15
+ import { tasksStore } from './tasks-store.mjs';
16
+ import { schedulesStore } from './schedules-store.mjs';
17
+
18
+ const HOME = homedir();
19
+ const SERVICE_LOG = join(HOME, '.config', 'bizar', 'service.log');
20
+ const SERVICE_PID = join(HOME, '.config', 'bizar', 'service.pid');
21
+
22
+ const startedAt = Date.now();
23
+
24
+ function readRecentErrors() {
25
+ if (!existsSync(SERVICE_LOG)) return [];
26
+ try {
27
+ const text = readFileSync(SERVICE_LOG, 'utf8');
28
+ const lines = text.split(/\r?\n/);
29
+ const errors = [];
30
+ for (let i = lines.length - 1; i >= 0 && errors.length < 10; i--) {
31
+ const line = lines[i];
32
+ if (!line.trim()) continue;
33
+ if (/\b(failed|error|err)\b/i.test(line)) {
34
+ errors.push({ line, ts: parseLogTs(line) });
35
+ }
36
+ }
37
+ return errors;
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ function parseLogTs(line) {
44
+ const m = /^\[([^\]]+)\]/.exec(line);
45
+ return m ? m[1] : null;
46
+ }
47
+
48
+ function checkPidAlive(pid) {
49
+ if (!pid) return false;
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function serviceStatus() {
59
+ if (!existsSync(SERVICE_PID)) return { running: false };
60
+ try {
61
+ const pid = parseInt(readFileSync(SERVICE_PID, 'utf8').trim(), 10);
62
+ if (!Number.isFinite(pid)) return { running: false, error: 'bad PID file' };
63
+ const alive = checkPidAlive(pid);
64
+ return { running: alive, pid, pidFile: SERVICE_PID };
65
+ } catch (err) {
66
+ return { running: false, error: err.message };
67
+ }
68
+ }
69
+
70
+ export const diagnosticsStore = {
71
+ /** Build the full diagnostics snapshot. */
72
+ snapshot(extra = {}) {
73
+ const projects = projectsStore.list();
74
+ const active = projectsStore.active();
75
+ const tasks = active ? tasksStore.loadTasks(active.id) : [];
76
+ const schedules = active ? schedulesStore.list(active.id) : [];
77
+ return {
78
+ version: '3.0.0',
79
+ uptimeMs: Date.now() - startedAt,
80
+ uptime: Math.floor((Date.now() - startedAt) / 1000),
81
+ nodeVersion: process.version,
82
+ platform: process.platform,
83
+ memory: {
84
+ rss: process.memoryUsage().rss,
85
+ heapUsed: process.memoryUsage().heapUsed,
86
+ heapTotal: process.memoryUsage().heapTotal,
87
+ },
88
+ counts: {
89
+ agents: agentsStore.list().length,
90
+ plans: 0, // plans are not in our scope yet; leave at 0
91
+ tasks: tasks.length,
92
+ projects: projects.projects.length,
93
+ activeProject: active?.id || null,
94
+ mods: modsLoader.list().length,
95
+ schedules: schedules.length,
96
+ providers: providersStore.list().length,
97
+ mcps: mcpsStore.list().length,
98
+ },
99
+ errors: readRecentErrors(),
100
+ service: serviceStatus(),
101
+ ...extra,
102
+ };
103
+ },
104
+
105
+ /** Run a set of health checks and return per-subsystem status. */
106
+ health() {
107
+ const checks = [];
108
+ const projectsFile = projectsStore.PROJECTS_FILE;
109
+ checks.push({
110
+ name: 'projects-registry',
111
+ ok: existsSync(dirname(projectsFile)),
112
+ detail: dirname(projectsFile),
113
+ });
114
+ checks.push({
115
+ name: 'opencode-agents',
116
+ ok: existsSync(agentsStore.AGENTS_DIR),
117
+ detail: agentsStore.AGENTS_DIR,
118
+ });
119
+ checks.push({
120
+ name: 'opencode-config',
121
+ ok: existsSync(providersStore.OPENCODE_JSON),
122
+ detail: providersStore.OPENCODE_JSON,
123
+ });
124
+ const modsDir = modsLoader.MOD_DIR || modsLoader.MOD_DIR;
125
+ checks.push({
126
+ name: 'mods-dir',
127
+ ok: true, // the dir is auto-created; this is informational
128
+ detail: modsLoader.MOD_DIR || modsLoader.MOD_DIR,
129
+ });
130
+ return {
131
+ ts: new Date().toISOString(),
132
+ checks,
133
+ };
134
+ },
135
+ };
136
+
137
+ // Add MOD_DIR alias for older access
138
+ modsLoader.MOD_DIR = modsLoader.MOD_DIR || modsLoader.MODS_DIR;
@@ -0,0 +1,361 @@
1
+ /**
2
+ * src/server/mods-loader.mjs
3
+ *
4
+ * v3.0.0 — Mods system for Bizar.
5
+ *
6
+ * A mod is a folder with a `mod.json` manifest. The loader scans
7
+ * `~/.config/bizar/mods/<mod-id>/` and exposes a list of valid mods.
8
+ *
9
+ * Mods can contribute:
10
+ * - agents (markdown files with frontmatter)
11
+ * - commands (markdown files with frontmatter)
12
+ * - routes (Node.js modules that export `register({ app, state })`)
13
+ * - views (declarative metadata — actual rendering done by host)
14
+ * - tui (declarative metadata)
15
+ * - hooks (declarative metadata)
16
+ *
17
+ * For v3 MVP, the loader only:
18
+ * - Lists installed mods (manifest)
19
+ * - Enables / disables (writes enabled flag back to mod.json)
20
+ * - Installs a mod from a local path (copies files into the mods dir)
21
+ * - Uninstalls (removes the mod folder)
22
+ *
23
+ * The dashboard can render a "Hello" tab for any mod whose manifest type
24
+ * is `view` and that registers a route. v3 keeps this minimal.
25
+ */
26
+ import {
27
+ existsSync,
28
+ readFileSync,
29
+ writeFileSync,
30
+ readdirSync,
31
+ statSync,
32
+ mkdirSync,
33
+ rmSync,
34
+ cpSync,
35
+ unlinkSync,
36
+ } from 'node:fs';
37
+ import { join, basename } from 'node:path';
38
+ import { homedir } from 'node:os';
39
+
40
+ const HOME = homedir();
41
+ const BIZAR_HOME = join(HOME, '.config', 'bizar');
42
+ const MODS_DIR = join(BIZAR_HOME, 'mods');
43
+
44
+ function safeReadJSON(file, fallback = null) {
45
+ try {
46
+ if (!existsSync(file)) return fallback;
47
+ const text = readFileSync(file, 'utf8');
48
+ if (!text.trim()) return fallback;
49
+ return JSON.parse(text);
50
+ } catch {
51
+ return fallback;
52
+ }
53
+ }
54
+
55
+ function listModFolders() {
56
+ if (!existsSync(MODS_DIR)) return [];
57
+ return readdirSync(MODS_DIR)
58
+ .map((name) => {
59
+ const full = join(MODS_DIR, name);
60
+ const st = statSync(full);
61
+ if (!st.isDirectory()) return null;
62
+ return { id: name, dir: full };
63
+ })
64
+ .filter(Boolean);
65
+ }
66
+
67
+ function loadMod({ id, dir }) {
68
+ const manifest = safeReadJSON(join(dir, 'mod.json'), null);
69
+ if (!manifest || typeof manifest !== 'object') {
70
+ return {
71
+ id,
72
+ name: id,
73
+ version: '?',
74
+ author: '',
75
+ description: '(no valid mod.json)',
76
+ enabled: false,
77
+ type: 'unknown',
78
+ error: 'missing or invalid mod.json',
79
+ path: dir,
80
+ };
81
+ }
82
+ // Files — list the entry points that exist
83
+ const files = [];
84
+ for (const sub of ['agents', 'commands', 'routes', 'views', 'tui', 'hooks', 'web']) {
85
+ const subPath = join(dir, sub);
86
+ if (existsSync(subPath)) {
87
+ try {
88
+ for (const f of readdirSync(subPath)) {
89
+ files.push({ category: sub, name: f, path: join(sub, f) });
90
+ }
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ }
95
+ }
96
+ return {
97
+ id: manifest.id || id,
98
+ name: manifest.name || id,
99
+ version: manifest.version || '0.0.0',
100
+ author: manifest.author || '',
101
+ description: manifest.description || '',
102
+ bizar: manifest.bizar || '*',
103
+ type: manifest.type || 'full',
104
+ enabled: manifest.enabled !== false,
105
+ permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
106
+ entry: manifest.entry || {},
107
+ files,
108
+ path: dir,
109
+ installedAt: manifest.installedAt || null,
110
+ };
111
+ }
112
+
113
+ export const modsLoader = {
114
+ HOME,
115
+ BIZAR_HOME,
116
+ MODS_DIR,
117
+
118
+ /** Ensure the mods directory exists. */
119
+ ensureModsDir() {
120
+ mkdirSync(MODS_DIR, { recursive: true });
121
+ },
122
+
123
+ /** List all installed mods. */
124
+ list() {
125
+ this.ensureModsDir();
126
+ return listModFolders().map(loadMod);
127
+ },
128
+
129
+ /** Read a single mod by id. */
130
+ get(id) {
131
+ const dir = join(MODS_DIR, id);
132
+ if (!existsSync(dir)) return null;
133
+ return loadMod({ id, dir });
134
+ },
135
+
136
+ /** Persist a mod's enabled flag to its mod.json. */
137
+ setEnabled(id, enabled) {
138
+ const dir = join(MODS_DIR, id);
139
+ if (!existsSync(dir)) return null;
140
+ const manifest = safeReadJSON(join(dir, 'mod.json'), null);
141
+ if (!manifest) return null;
142
+ manifest.enabled = !!enabled;
143
+ writeFileSync(join(dir, 'mod.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
144
+ return loadMod({ id, dir });
145
+ },
146
+
147
+ /**
148
+ * Install a mod from a local path. Copies the folder into
149
+ * `~/.config/bizar/mods/<id>/`. The id is the source folder's basename.
150
+ */
151
+ installFromPath(sourcePath) {
152
+ if (!existsSync(sourcePath)) {
153
+ throw new Error(`source path does not exist: ${sourcePath}`);
154
+ }
155
+ const st = statSync(sourcePath);
156
+ if (!st.isDirectory()) {
157
+ throw new Error(`source must be a directory: ${sourcePath}`);
158
+ }
159
+ const id = basename(sourcePath);
160
+ const target = join(MODS_DIR, id);
161
+ this.ensureModsDir();
162
+ if (existsSync(target)) {
163
+ throw new Error(`mod "${id}" already installed`);
164
+ }
165
+ // Read the manifest first to verify
166
+ const manifest = safeReadJSON(join(sourcePath, 'mod.json'), null);
167
+ if (!manifest) {
168
+ throw new Error('source has no mod.json manifest');
169
+ }
170
+ cpSync(sourcePath, target, { recursive: true });
171
+ // Stamp installation time
172
+ const fresh = safeReadJSON(join(target, 'mod.json'), {});
173
+ fresh.installedAt = new Date().toISOString();
174
+ fresh.id = fresh.id || id;
175
+ writeFileSync(join(target, 'mod.json'), JSON.stringify(fresh, null, 2) + '\n', 'utf8');
176
+ return loadMod({ id, dir: target });
177
+ },
178
+
179
+ /** Remove a mod folder. */
180
+ uninstall(id) {
181
+ const dir = join(MODS_DIR, id);
182
+ if (!existsSync(dir)) return false;
183
+ rmSync(dir, { recursive: true, force: true });
184
+ return true;
185
+ },
186
+
187
+ /** List files inside a mod. */
188
+ listFiles(id) {
189
+ const dir = join(MODS_DIR, id);
190
+ if (!existsSync(dir)) return [];
191
+ const out = [];
192
+ function walk(p, prefix) {
193
+ let entries;
194
+ try {
195
+ entries = readdirSync(p, { withFileTypes: true });
196
+ } catch {
197
+ return;
198
+ }
199
+ for (const e of entries) {
200
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
201
+ if (e.isDirectory()) walk(join(p, e.name), rel);
202
+ else if (e.isFile()) {
203
+ out.push({ path: rel, size: statSync(join(p, e.name)).size });
204
+ }
205
+ }
206
+ }
207
+ walk(dir, '');
208
+ return out;
209
+ },
210
+
211
+ /** Read a file inside a mod (path relative to mod root). */
212
+ readFile(id, relPath) {
213
+ const dir = join(MODS_DIR, id);
214
+ const full = join(dir, relPath);
215
+ if (!full.startsWith(dir)) {
216
+ throw new Error('path escapes mod root');
217
+ }
218
+ if (!existsSync(full)) return null;
219
+ return readFileSync(full, 'utf8');
220
+ },
221
+
222
+ /** Write a file inside a mod. */
223
+ writeFile(id, relPath, content) {
224
+ const dir = join(MODS_DIR, id);
225
+ const full = join(dir, relPath);
226
+ if (!full.startsWith(dir)) {
227
+ throw new Error('path escapes mod root');
228
+ }
229
+ mkdirSync(dirnameSafe(full), { recursive: true });
230
+ writeFileSync(full, content, 'utf8');
231
+ return true;
232
+ },
233
+
234
+ /**
235
+ * Load Express routers from enabled mods that declare entry.route.
236
+ *
237
+ * Each route file must default-export one of:
238
+ * - An express.Router
239
+ * - A function (app, ctx) => void that registers routes on the passed router
240
+ *
241
+ * Because mods live outside the package tree, their route files cannot
242
+ * `import { Router } from 'express'` directly (no node_modules). We solve
243
+ * this by reading the route file as text, prepending an inline import for
244
+ * express, and then evaluating it using a vm.Module sandbox.
245
+ *
246
+ * Returns { id, router, mountPath } for each loaded mod.
247
+ *
248
+ * @param {object} ctx - context passed to each route handler
249
+ * @param {Function} ctx.broadcast
250
+ * @param {object} ctx.state
251
+ * @param {string} ctx.projectRoot
252
+ * @param {string} ctx.opencodeConfigDir
253
+ */
254
+ async loadModRouters(ctx = {}) {
255
+ const mods = this.list().filter((m) => m.enabled && m.entry?.route);
256
+ const results = [];
257
+ const { createRequire } = await import('node:module');
258
+ const _require = createRequire(import.meta.url);
259
+ let expressMod;
260
+ try {
261
+ expressMod = _require('express');
262
+ } catch {
263
+ console.warn('[mods-loader] could not preload express for mod routes');
264
+ return results;
265
+ }
266
+ // Resolve express's main entry as an absolute path for import injection
267
+ const expressPath = _require.resolve('express');
268
+ for (const mod of mods) {
269
+ let router;
270
+ try {
271
+ const routePath = join(mod.path, mod.entry.route);
272
+ if (!existsSync(routePath)) continue;
273
+ const routeSource = readFileSync(routePath, 'utf8');
274
+ // Write a temp file that injects the express import before the user's code.
275
+ // This lets mod route files use `import { Router } from 'express'` even though
276
+ // they live outside the package tree (no node_modules).
277
+ const { tmpdir } = await import('node:os');
278
+ const { join: joinPath } = await import('node:path');
279
+ const tmpFile = joinPath(tmpdir(), `bizar-mod-route-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`);
280
+ const injectedSource = `import express from ${JSON.stringify('file://' + expressPath)};\n${routeSource}`;
281
+ writeFileSync(tmpFile, injectedSource, 'utf8');
282
+ try {
283
+ const modImport = await import(/* @vite-ignore */ `file://${tmpFile}?t=${Date.now()}`);
284
+ const exported = modImport.default;
285
+ if (typeof exported === 'function') {
286
+ router = expressMod.Router();
287
+ exported({ router }, ctx);
288
+ } else if (exported && typeof exported === 'object' && typeof exported.handle === 'function') {
289
+ router = exported;
290
+ } else {
291
+ console.warn(`[mods-loader] ${mod.id} entry.route exported unexpected type: ${typeof exported}`);
292
+ continue;
293
+ }
294
+ } finally {
295
+ // Clean up temp file
296
+ try { unlinkSync(tmpFile); } catch { /* ignore */ }
297
+ }
298
+ } catch (err) {
299
+ console.error(`[mods-loader] failed to load router for ${mod.id}:`, err.message);
300
+ continue;
301
+ }
302
+ if (router) results.push({ id: mod.id, router, mountPath: `/api/mods/${mod.id}` });
303
+ }
304
+ return results;
305
+ },
306
+
307
+ /**
308
+ * List available views for enabled mods.
309
+ * Scans for:
310
+ * - web/index.html (iframe-able web view)
311
+ * - views/registry.json (static tab registration)
312
+ *
313
+ * Returns [{ id, modId, kind: 'iframe'|'tab', label?, icon?, path?, description? }]
314
+ */
315
+ listModViews() {
316
+ const mods = this.list().filter((m) => m.enabled);
317
+ const views = [];
318
+ for (const mod of mods) {
319
+ // Check web/index.html
320
+ const webIndex = join(mod.path, 'web', 'index.html');
321
+ if (existsSync(webIndex)) {
322
+ views.push({
323
+ id: `${mod.id}:web`,
324
+ modId: mod.id,
325
+ kind: 'iframe',
326
+ label: mod.name,
327
+ description: mod.description || 'Mod web view',
328
+ path: webIndex,
329
+ url: null, // resolved server-side when served
330
+ });
331
+ }
332
+ // Check views/registry.json
333
+ const registryPath = join(mod.path, 'views', 'registry.json');
334
+ if (existsSync(registryPath)) {
335
+ try {
336
+ const reg = JSON.parse(readFileSync(registryPath, 'utf8'));
337
+ for (const view of reg.views || []) {
338
+ views.push({
339
+ id: `${mod.id}:${view.id}`,
340
+ modId: mod.id,
341
+ kind: 'tab',
342
+ label: view.label || view.id,
343
+ icon: view.icon || 'Puzzle',
344
+ description: view.description || '',
345
+ component: view.component || null,
346
+ path: join(mod.path, 'views', view.component || ''),
347
+ });
348
+ }
349
+ } catch {
350
+ /* ignore malformed registry */
351
+ }
352
+ }
353
+ }
354
+ return views;
355
+ },
356
+ };
357
+
358
+ function dirnameSafe(p) {
359
+ const idx = p.lastIndexOf('/');
360
+ return idx === -1 ? '.' : p.slice(0, idx);
361
+ }