@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,198 @@
1
+ /**
2
+ * src/server/projects-store.mjs
3
+ *
4
+ * v3.0.0 — Per-project state registry.
5
+ *
6
+ * Project identification: the project's path basename (e.g.,
7
+ * `/home/user/myapp` → `myapp`). If the basename collides, the full path
8
+ * becomes the id.
9
+ *
10
+ * Storage:
11
+ * ~/.config/opencode/projects.json — registry (list + active id)
12
+ * ~/.config/opencode/projects/<id>/ — per-project data dir
13
+ * tasks.json, plans.json, schedules.json, mods.json, state.json,
14
+ * sessions/ (chat sessions)
15
+ *
16
+ * The bizar core runtime (bizar package) and the dashboard both call into
17
+ * this module. All file ops are defensive — missing files / parse errors
18
+ * are silent.
19
+ */
20
+ import {
21
+ existsSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ readdirSync,
25
+ statSync,
26
+ mkdirSync,
27
+ } from 'node:fs';
28
+ import { join, basename, dirname } from 'node:path';
29
+ import { homedir } from 'node:os';
30
+ import { randomBytes } from 'node:crypto';
31
+
32
+ const HOME = homedir();
33
+ const OPENCODE_DIR = join(HOME, '.config', 'opencode');
34
+ const PROJECTS_FILE = join(OPENCODE_DIR, 'projects.json');
35
+ const PROJECTS_DIR = join(OPENCODE_DIR, 'projects');
36
+
37
+ function safeReadJSON(file, fallback = null) {
38
+ try {
39
+ if (!existsSync(file)) return fallback;
40
+ const text = readFileSync(file, 'utf8');
41
+ if (!text.trim()) return fallback;
42
+ return JSON.parse(text);
43
+ } catch {
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ function ensureProjectsDir() {
49
+ mkdirSync(PROJECTS_DIR, { recursive: true });
50
+ mkdirSync(OPENCODE_DIR, { recursive: true });
51
+ }
52
+
53
+ function loadRegistry() {
54
+ const data = safeReadJSON(PROJECTS_FILE, null);
55
+ if (!data || typeof data !== 'object') {
56
+ return { projects: [], active: null };
57
+ }
58
+ if (!Array.isArray(data.projects)) data.projects = [];
59
+ return data;
60
+ }
61
+
62
+ function saveRegistry(reg) {
63
+ ensureProjectsDir();
64
+ writeFileSync(PROJECTS_FILE, JSON.stringify(reg, null, 2) + '\n', 'utf8');
65
+ }
66
+
67
+ function projectIdFromPath(path) {
68
+ const b = basename(path);
69
+ return b || 'project';
70
+ }
71
+
72
+ function projectDir(id) {
73
+ return join(PROJECTS_DIR, id);
74
+ }
75
+
76
+ function readProjectFile(id, name, fallback) {
77
+ const file = join(projectDir(id), name);
78
+ return safeReadJSON(file, fallback);
79
+ }
80
+
81
+ function writeProjectFile(id, name, data) {
82
+ ensureProjectsDir();
83
+ const dir = projectDir(id);
84
+ mkdirSync(dir, { recursive: true });
85
+ writeFileSync(join(dir, name), JSON.stringify(data, null, 2) + '\n', 'utf8');
86
+ }
87
+
88
+ /**
89
+ * Public API.
90
+ */
91
+ export const projectsStore = {
92
+ HOME,
93
+ OPENCODE_DIR,
94
+ PROJECTS_DIR,
95
+ PROJECTS_FILE,
96
+
97
+ /** Return the full registry. */
98
+ list() {
99
+ const reg = loadRegistry();
100
+ // Augment with disk status (active/inactive/error)
101
+ const out = (reg.projects || []).map((p) => {
102
+ let status = 'inactive';
103
+ if (p.path && existsSync(p.path)) {
104
+ status = p.id === reg.active ? 'active' : 'inactive';
105
+ } else if (p.path) {
106
+ status = 'error';
107
+ }
108
+ return { ...p, status };
109
+ });
110
+ return { projects: out, active: reg.active };
111
+ },
112
+
113
+ /** Add a project by absolute path. Returns the new project entry. */
114
+ add(path, name) {
115
+ if (!path || typeof path !== 'string') {
116
+ throw new Error('path is required');
117
+ }
118
+ const id = projectIdFromPath(path);
119
+ const reg = loadRegistry();
120
+ const existing = reg.projects.find((p) => p.id === id);
121
+ const now = new Date().toISOString();
122
+ if (existing) {
123
+ existing.path = path;
124
+ existing.lastAccessed = now;
125
+ saveRegistry(reg);
126
+ return existing;
127
+ }
128
+ const entry = {
129
+ id,
130
+ name: name || id,
131
+ path,
132
+ lastAccessed: now,
133
+ status: 'inactive',
134
+ summary: '',
135
+ };
136
+ reg.projects.push(entry);
137
+ if (!reg.active) reg.active = id;
138
+ saveRegistry(reg);
139
+ // Make sure the per-project dir exists
140
+ ensureProjectsDir();
141
+ mkdirSync(projectDir(id), { recursive: true });
142
+ return entry;
143
+ },
144
+
145
+ /** Remove a project from the registry. Per-project data is NOT deleted. */
146
+ remove(id) {
147
+ const reg = loadRegistry();
148
+ reg.projects = reg.projects.filter((p) => p.id !== id);
149
+ if (reg.active === id) reg.active = reg.projects[0]?.id || null;
150
+ saveRegistry(reg);
151
+ return true;
152
+ },
153
+
154
+ /** Switch the active project. Returns the new active entry or null. */
155
+ activate(id) {
156
+ const reg = loadRegistry();
157
+ const found = reg.projects.find((p) => p.id === id);
158
+ if (!found) return null;
159
+ reg.active = id;
160
+ found.lastAccessed = new Date().toISOString();
161
+ saveRegistry(reg);
162
+ return found;
163
+ },
164
+
165
+ /** Get the active project entry, or null. */
166
+ active() {
167
+ const reg = loadRegistry();
168
+ return reg.projects.find((p) => p.id === reg.active) || null;
169
+ },
170
+
171
+ /** Touch the lastAccessed timestamp. */
172
+ touch(id) {
173
+ const reg = loadRegistry();
174
+ const found = reg.projects.find((p) => p.id === id);
175
+ if (found) {
176
+ found.lastAccessed = new Date().toISOString();
177
+ saveRegistry(reg);
178
+ }
179
+ },
180
+
181
+ /** Per-project file helpers. */
182
+ readProjectFile,
183
+ writeProjectFile,
184
+ projectDir,
185
+
186
+ /** Return path to the per-project data dir; ensure it exists. */
187
+ ensureProjectDir(id) {
188
+ ensureProjectsDir();
189
+ const dir = projectDir(id);
190
+ mkdirSync(dir, { recursive: true });
191
+ return dir;
192
+ },
193
+
194
+ /** Generate a short random id with prefix. */
195
+ genId(prefix = 'id') {
196
+ return prefix + '_' + randomBytes(6).toString('hex').slice(0, 10);
197
+ },
198
+ };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * src/server/providers-store.mjs
3
+ *
4
+ * v3.0.0 — OpenCode providers and MCPs management.
5
+ *
6
+ * Reads / writes the opencode.json at ~/.config/opencode/opencode.json
7
+ * under the `provider` and `mcp` keys.
8
+ *
9
+ * API keys are never echoed back in full — the response masks them.
10
+ */
11
+ import {
12
+ existsSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ mkdirSync,
16
+ } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+
20
+ const HOME = homedir();
21
+ const OPENCODE_JSON = join(HOME, '.config', 'opencode', 'opencode.json');
22
+
23
+ function safeReadJSON(file, fallback = {}) {
24
+ try {
25
+ if (!existsSync(file)) return fallback;
26
+ const text = readFileSync(file, 'utf8');
27
+ if (!text.trim()) return fallback;
28
+ return JSON.parse(text);
29
+ } catch {
30
+ return fallback;
31
+ }
32
+ }
33
+
34
+ function loadConfig() {
35
+ return safeReadJSON(OPENCODE_JSON, {});
36
+ }
37
+
38
+ function saveConfig(data) {
39
+ mkdirSync(dirname(OPENCODE_JSON), { recursive: true });
40
+ writeFileSync(OPENCODE_JSON, JSON.stringify(data, null, 2) + '\n', 'utf8');
41
+ }
42
+
43
+ function mask(value) {
44
+ if (typeof value !== 'string' || !value) return '';
45
+ if (value.length <= 8) return '***';
46
+ return value.slice(0, 4) + '***' + value.slice(-4);
47
+ }
48
+
49
+ function unmask(stored, incoming) {
50
+ // Incoming is the user-typed value. If it's the masked form, keep stored.
51
+ if (typeof incoming === 'string' && incoming.startsWith('***') && incoming.endsWith('***')) {
52
+ return stored;
53
+ }
54
+ return incoming;
55
+ }
56
+
57
+ export const providersStore = {
58
+ OPENCODE_JSON,
59
+
60
+ list() {
61
+ const cfg = loadConfig();
62
+ const providers = cfg.provider || {};
63
+ return Object.entries(providers).map(([id, p]) => ({
64
+ id,
65
+ name: p.name || id,
66
+ baseURL: p.baseURL || p.options?.baseURL || '',
67
+ apiKey: mask(p.apiKey || p.options?.apiKey || ''),
68
+ models: Array.isArray(p.models) ? p.models : [],
69
+ enabled: p.enabled !== false,
70
+ }));
71
+ },
72
+
73
+ get(id) {
74
+ return this.list().find((p) => p.id === id) || null;
75
+ },
76
+
77
+ add(input) {
78
+ if (!input || !input.id) throw new Error('id is required');
79
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(input.id)) {
80
+ throw new Error('invalid id');
81
+ }
82
+ const cfg = loadConfig();
83
+ cfg.provider = cfg.provider || {};
84
+ if (cfg.provider[input.id]) throw new Error(`provider "${input.id}" exists`);
85
+ cfg.provider[input.id] = {
86
+ name: input.name || input.id,
87
+ baseURL: input.baseURL || '',
88
+ apiKey: input.apiKey || '',
89
+ models: Array.isArray(input.models) ? input.models : [],
90
+ enabled: input.enabled !== false,
91
+ };
92
+ saveConfig(cfg);
93
+ return this.get(input.id);
94
+ },
95
+
96
+ update(id, patch) {
97
+ if (!patch || typeof patch !== 'object') throw new Error('patch required');
98
+ const cfg = loadConfig();
99
+ cfg.provider = cfg.provider || {};
100
+ const cur = cfg.provider[id];
101
+ if (!cur) throw new Error(`provider "${id}" not found`);
102
+ cfg.provider[id] = {
103
+ ...cur,
104
+ name: patch.name ?? cur.name,
105
+ baseURL: patch.baseURL ?? cur.baseURL,
106
+ apiKey: unmask(cur.apiKey, patch.apiKey),
107
+ models: Array.isArray(patch.models) ? patch.models : cur.models,
108
+ enabled: patch.enabled ?? cur.enabled,
109
+ };
110
+ saveConfig(cfg);
111
+ return this.get(id);
112
+ },
113
+
114
+ remove(id) {
115
+ const cfg = loadConfig();
116
+ if (!cfg.provider || !cfg.provider[id]) return false;
117
+ delete cfg.provider[id];
118
+ saveConfig(cfg);
119
+ return true;
120
+ },
121
+ };
122
+
123
+ export const mcpsStore = {
124
+ OPENCODE_JSON,
125
+
126
+ list() {
127
+ const cfg = loadConfig();
128
+ const mcps = cfg.mcp || {};
129
+ return Object.entries(mcps).map(([id, m]) => ({
130
+ id,
131
+ command: m.command || '',
132
+ args: Array.isArray(m.args) ? m.args : [],
133
+ env: m.env || {},
134
+ enabled: m.enabled !== false,
135
+ }));
136
+ },
137
+
138
+ get(id) {
139
+ return this.list().find((m) => m.id === id) || null;
140
+ },
141
+
142
+ add(input) {
143
+ if (!input || !input.id) throw new Error('id is required');
144
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(input.id)) {
145
+ throw new Error('invalid id');
146
+ }
147
+ const cfg = loadConfig();
148
+ cfg.mcp = cfg.mcp || {};
149
+ if (cfg.mcp[input.id]) throw new Error(`mcp "${input.id}" exists`);
150
+ cfg.mcp[input.id] = {
151
+ command: input.command || '',
152
+ args: Array.isArray(input.args) ? input.args : [],
153
+ env: input.env || {},
154
+ enabled: input.enabled !== false,
155
+ };
156
+ saveConfig(cfg);
157
+ return this.get(input.id);
158
+ },
159
+
160
+ update(id, patch) {
161
+ if (!patch || typeof patch !== 'object') throw new Error('patch required');
162
+ const cfg = loadConfig();
163
+ cfg.mcp = cfg.mcp || {};
164
+ const cur = cfg.mcp[id];
165
+ if (!cur) throw new Error(`mcp "${id}" not found`);
166
+ cfg.mcp[id] = {
167
+ command: patch.command ?? cur.command,
168
+ args: Array.isArray(patch.args) ? patch.args : cur.args,
169
+ env: patch.env || cur.env,
170
+ enabled: patch.enabled ?? cur.enabled,
171
+ };
172
+ saveConfig(cfg);
173
+ return this.get(id);
174
+ },
175
+
176
+ remove(id) {
177
+ const cfg = loadConfig();
178
+ if (!cfg.mcp || !cfg.mcp[id]) return false;
179
+ delete cfg.mcp[id];
180
+ saveConfig(cfg);
181
+ return true;
182
+ },
183
+ };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * src/server/schedules-runner.mjs
3
+ *
4
+ * v3.0.0 — Executes due schedules.
5
+ *
6
+ * Called by the service daemon (cli/service.mjs) on a tick. For each due
7
+ * schedule, this module runs the action and records the result via
8
+ * schedulesStore.recordRun().
9
+ *
10
+ * Action types:
11
+ * - command → spawn a child process
12
+ * - agent → log + append activity (we don't dispatch live agents from
13
+ * the service yet — that's wired in v3.1)
14
+ * - webhook → POST JSON to the URL
15
+ *
16
+ * v3 keeps the runner minimal. Errors are caught and recorded; the loop
17
+ * never throws.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+ import { projectsStore } from './projects-store.mjs';
24
+ import { schedulesStore } from './schedules-store.mjs';
25
+
26
+ const HOME = homedir();
27
+ const LOG_DIR = join(HOME, '.config', 'bizar');
28
+ const LOG_FILE = join(LOG_DIR, 'service.log');
29
+
30
+ function logLine(line) {
31
+ try {
32
+ mkdirSync(LOG_DIR, { recursive: true });
33
+ appendFileSync(LOG_FILE, line + '\n', 'utf8');
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+
39
+ async function runAction(action) {
40
+ if (!action || typeof action !== 'object') {
41
+ throw new Error('invalid action');
42
+ }
43
+ if (action.type === 'command') {
44
+ return runCommand(action);
45
+ }
46
+ if (action.type === 'webhook') {
47
+ return runWebhook(action);
48
+ }
49
+ if (action.type === 'agent') {
50
+ // For v3 we just log; real agent dispatch lives in v3.1+
51
+ logLine(`[schedule] agent dispatch (deferred to v3.1): ${action.target || '?'}`);
52
+ return { ok: true, note: 'agent dispatch deferred to v3.1' };
53
+ }
54
+ throw new Error(`unknown action type: ${action.type}`);
55
+ }
56
+
57
+ function runCommand(action) {
58
+ return new Promise((resolve) => {
59
+ const target = action.target || '';
60
+ if (!target) {
61
+ resolve({ ok: false, error: 'empty command target' });
62
+ return;
63
+ }
64
+ try {
65
+ const child = spawn(target, {
66
+ shell: true,
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ env: process.env,
69
+ });
70
+ let stdout = '';
71
+ let stderr = '';
72
+ child.stdout.on('data', (b) => (stdout += b.toString()));
73
+ child.stderr.on('data', (b) => (stderr += b.toString()));
74
+ child.on('error', (err) => {
75
+ resolve({ ok: false, error: err.message, stdout, stderr });
76
+ });
77
+ child.on('close', (code) => {
78
+ resolve({
79
+ ok: code === 0,
80
+ error: code === 0 ? null : `exit ${code}`,
81
+ stdout: stdout.slice(-2000),
82
+ stderr: stderr.slice(-2000),
83
+ });
84
+ });
85
+ } catch (err) {
86
+ resolve({ ok: false, error: err.message });
87
+ }
88
+ });
89
+ }
90
+
91
+ async function runWebhook(action) {
92
+ const url = action.target;
93
+ if (!url) return { ok: false, error: 'empty webhook URL' };
94
+ try {
95
+ const r = await fetch(url, {
96
+ method: action.method || 'POST',
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: action.body ? JSON.stringify(action.body) : undefined,
99
+ });
100
+ return { ok: r.ok, status: r.status };
101
+ } catch (err) {
102
+ return { ok: false, error: err.message };
103
+ }
104
+ }
105
+
106
+ export const schedulesRunner = {
107
+ LOG_FILE,
108
+
109
+ /** Run all currently-due schedules for every project. */
110
+ async tick() {
111
+ const reg = projectsStore.list();
112
+ const fired = [];
113
+ for (const project of reg.projects) {
114
+ const due = schedulesStore.due(project.id, Date.now());
115
+ for (const sched of due) {
116
+ const result = await this.runOne(project.id, sched);
117
+ fired.push({ projectId: project.id, scheduleId: sched.id, ...result });
118
+ }
119
+ }
120
+ return fired;
121
+ },
122
+
123
+ /** Run a single schedule immediately and record the result. */
124
+ async runOne(projectId, sched) {
125
+ const startedAt = new Date().toISOString();
126
+ logLine(
127
+ `[${startedAt}] run ${projectId}/${sched.id} (${sched.type}: ${sched.schedule})`,
128
+ );
129
+ try {
130
+ const res = await runAction(sched.action);
131
+ const updated = schedulesStore.recordRun(projectId, sched.id, {
132
+ result: res.ok ? 'success' : 'error',
133
+ error: res.error || null,
134
+ });
135
+ logLine(
136
+ `[${new Date().toISOString()}] done ${projectId}/${sched.id} → ${res.ok ? 'success' : 'error: ' + res.error}`,
137
+ );
138
+ return { ok: res.ok, schedule: updated, runResult: res };
139
+ } catch (err) {
140
+ const updated = schedulesStore.recordRun(projectId, sched.id, {
141
+ result: 'error',
142
+ error: err.message || String(err),
143
+ });
144
+ logLine(
145
+ `[${new Date().toISOString()}] failed ${projectId}/${sched.id}: ${err.message}`,
146
+ );
147
+ return { ok: false, schedule: updated, runResult: { error: err.message } };
148
+ }
149
+ },
150
+ };