@polderlabs/bizar 2.4.0 → 2.6.1
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 +39 -34
- 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/config/commands/bizar.md +13 -39
- 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
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/dashboard/state.mjs
|
|
3
|
+
*
|
|
4
|
+
* Aggregates every piece of state the dashboard surfaces. Each getter is
|
|
5
|
+
* defensive: missing files, missing directories, parse errors — all become
|
|
6
|
+
* sensible empty defaults rather than crashes.
|
|
7
|
+
*
|
|
8
|
+
* Data sources:
|
|
9
|
+
* - getOverview: counts + .bizar/activity.log tail
|
|
10
|
+
* - getChat: .bizar/sessions/*.jsonl (one JSON record per line)
|
|
11
|
+
* - getAgents: ~/.config/opencode/agents/*.md (frontmatter parse)
|
|
12
|
+
* - getPlans: scans plans/ (worktree) and ~/.config/opencode/plans/
|
|
13
|
+
* - getProjects: walks cwd for .bizar/PROJECT.md, also ~/Projects/*
|
|
14
|
+
* - getConfig: ~/.config/opencode/opencode.json (live)
|
|
15
|
+
* - getSettings: ~/.config/bizar/settings.json (created on demand)
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
statSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { join, basename, dirname } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { loadTasks } from './tasks-store.mjs';
|
|
28
|
+
|
|
29
|
+
const HOME = homedir();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {string} opts.projectRoot
|
|
34
|
+
* @param {string} opts.opencodeConfigDir
|
|
35
|
+
* @param {string} opts.bizarRoot
|
|
36
|
+
*/
|
|
37
|
+
export function createState({ projectRoot, opencodeConfigDir, bizarRoot }) {
|
|
38
|
+
const paths = {
|
|
39
|
+
projectRoot,
|
|
40
|
+
opencodeConfigDir,
|
|
41
|
+
bizarRoot,
|
|
42
|
+
opencodeJson: join(opencodeConfigDir, 'opencode.json'),
|
|
43
|
+
agentsDir: join(opencodeConfigDir, 'agents'),
|
|
44
|
+
commandsDir: join(opencodeConfigDir, 'commands-bizar'),
|
|
45
|
+
bizarDir: join(projectRoot, '.bizar'),
|
|
46
|
+
sessionsDir: join(projectRoot, '.bizar', 'sessions'),
|
|
47
|
+
activityLog: join(projectRoot, '.bizar', 'activity.log'),
|
|
48
|
+
plansDir: join(projectRoot, 'plans'),
|
|
49
|
+
globalPlansDir: join(opencodeConfigDir, 'plans'),
|
|
50
|
+
settingsFile: join(HOME, '.config', 'bizar', 'settings.json'),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function safeReadJSON(file, fallback = null) {
|
|
56
|
+
try {
|
|
57
|
+
if (!existsSync(file)) return fallback;
|
|
58
|
+
const text = readFileSync(file, 'utf8');
|
|
59
|
+
if (!text.trim()) return fallback;
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeReadText(file, fallback = '') {
|
|
67
|
+
try {
|
|
68
|
+
if (!existsSync(file)) return fallback;
|
|
69
|
+
return readFileSync(file, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listFiles(dir, ext) {
|
|
76
|
+
try {
|
|
77
|
+
if (!existsSync(dir)) return [];
|
|
78
|
+
return readdirSync(dir).filter((f) =>
|
|
79
|
+
ext ? f.endsWith(ext) : true,
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeStat(p) {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(p);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Minimal frontmatter parser: returns { frontmatter, body }. */
|
|
95
|
+
function parseFrontmatter(raw) {
|
|
96
|
+
if (!raw.startsWith('---')) return { frontmatter: {}, body: raw };
|
|
97
|
+
const end = raw.indexOf('\n---', 3);
|
|
98
|
+
if (end === -1) return { frontmatter: {}, body: raw };
|
|
99
|
+
const fmBlock = raw.slice(3, end).trim();
|
|
100
|
+
const body = raw.slice(end + 4).replace(/^\s+/, '');
|
|
101
|
+
const frontmatter = {};
|
|
102
|
+
for (const line of fmBlock.split(/\r?\n/)) {
|
|
103
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
104
|
+
if (!m) continue;
|
|
105
|
+
const key = m[1];
|
|
106
|
+
let val = m[2].trim();
|
|
107
|
+
// strip surrounding quotes
|
|
108
|
+
if (
|
|
109
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
110
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
111
|
+
) {
|
|
112
|
+
val = val.slice(1, -1);
|
|
113
|
+
}
|
|
114
|
+
frontmatter[key] = val;
|
|
115
|
+
}
|
|
116
|
+
return { frontmatter, body };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── public getters ────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function getOverview() {
|
|
122
|
+
const agents = getAgents();
|
|
123
|
+
const plans = getPlans();
|
|
124
|
+
const projects = getProjects();
|
|
125
|
+
|
|
126
|
+
const sessionsDir = paths.sessionsDir;
|
|
127
|
+
let sessionCount = 0;
|
|
128
|
+
if (existsSync(sessionsDir)) {
|
|
129
|
+
try {
|
|
130
|
+
sessionCount = readdirSync(sessionsDir).filter((f) =>
|
|
131
|
+
f.endsWith('.jsonl'),
|
|
132
|
+
).length;
|
|
133
|
+
} catch {
|
|
134
|
+
sessionCount = 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const recentActivity = [];
|
|
139
|
+
try {
|
|
140
|
+
if (existsSync(paths.activityLog)) {
|
|
141
|
+
const lines = readFileSync(paths.activityLog, 'utf8').split(/\r?\n/);
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
if (!line.trim()) continue;
|
|
144
|
+
try {
|
|
145
|
+
recentActivity.push(JSON.parse(line));
|
|
146
|
+
} catch {
|
|
147
|
+
// skip malformed lines
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
/* ignore */
|
|
153
|
+
}
|
|
154
|
+
// Last 30, newest first
|
|
155
|
+
recentActivity.reverse();
|
|
156
|
+
const trimmedActivity = recentActivity.slice(0, 30);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
counts: {
|
|
160
|
+
agents: agents.length,
|
|
161
|
+
plans: plans.length,
|
|
162
|
+
projects: projects.length,
|
|
163
|
+
sessions: sessionCount,
|
|
164
|
+
},
|
|
165
|
+
recentActivity: trimmedActivity,
|
|
166
|
+
versions: {
|
|
167
|
+
node: process.version,
|
|
168
|
+
platform: process.platform,
|
|
169
|
+
bizarRoot,
|
|
170
|
+
projectRoot,
|
|
171
|
+
},
|
|
172
|
+
generatedAt: new Date().toISOString(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getChat({ sessionId = null, limit = 200 } = {}) {
|
|
177
|
+
if (!existsSync(paths.sessionsDir)) return { messages: [], sessions: [] };
|
|
178
|
+
const allFiles = readdirSync(paths.sessionsDir).filter((f) =>
|
|
179
|
+
f.endsWith('.jsonl'),
|
|
180
|
+
);
|
|
181
|
+
const sessions = allFiles.map((f) => {
|
|
182
|
+
const st = safeStat(join(paths.sessionsDir, f));
|
|
183
|
+
return {
|
|
184
|
+
id: f.replace(/\.jsonl$/, ''),
|
|
185
|
+
file: f,
|
|
186
|
+
mtime: st ? st.mtimeMs : 0,
|
|
187
|
+
size: st ? st.size : 0,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
191
|
+
|
|
192
|
+
const target = sessionId
|
|
193
|
+
? allFiles.filter((f) => f === `${sessionId}.jsonl`)
|
|
194
|
+
: allFiles;
|
|
195
|
+
|
|
196
|
+
const messages = [];
|
|
197
|
+
for (const file of target) {
|
|
198
|
+
const full = join(paths.sessionsDir, file);
|
|
199
|
+
try {
|
|
200
|
+
const lines = readFileSync(full, 'utf8').split(/\r?\n/);
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
if (!line.trim()) continue;
|
|
203
|
+
try {
|
|
204
|
+
messages.push(JSON.parse(line));
|
|
205
|
+
} catch {
|
|
206
|
+
// skip malformed line
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// skip unreadable file
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// newest first, then truncate
|
|
215
|
+
messages.reverse();
|
|
216
|
+
return {
|
|
217
|
+
messages: messages.slice(0, limit),
|
|
218
|
+
sessions,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getAgents() {
|
|
223
|
+
const dir = paths.agentsDir;
|
|
224
|
+
if (!existsSync(dir)) return [];
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const file of readdirSync(dir)) {
|
|
227
|
+
if (!file.endsWith('.md')) continue;
|
|
228
|
+
const full = join(dir, file);
|
|
229
|
+
const raw = safeReadText(full);
|
|
230
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
231
|
+
const st = safeStat(full);
|
|
232
|
+
out.push({
|
|
233
|
+
name:
|
|
234
|
+
frontmatter.name ||
|
|
235
|
+
basename(file, '.md') ||
|
|
236
|
+
file.replace(/\.md$/, ''),
|
|
237
|
+
description: frontmatter.description || '',
|
|
238
|
+
model: frontmatter.model || '',
|
|
239
|
+
mode: frontmatter.mode || '',
|
|
240
|
+
file,
|
|
241
|
+
path: full,
|
|
242
|
+
mtime: st ? st.mtimeMs : 0,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readPlansFromDir(dir) {
|
|
250
|
+
if (!existsSync(dir)) return [];
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const entry of readdirSync(dir)) {
|
|
253
|
+
const full = join(dir, entry);
|
|
254
|
+
const st = safeStat(full);
|
|
255
|
+
if (!st || !st.isDirectory()) continue;
|
|
256
|
+
const meta = safeReadJSON(join(full, 'meta.json'), null);
|
|
257
|
+
const planPath = join(full, 'plan.mdx');
|
|
258
|
+
const planExists = existsSync(planPath);
|
|
259
|
+
out.push({
|
|
260
|
+
slug: entry,
|
|
261
|
+
title: meta?.title || entry,
|
|
262
|
+
status: meta?.status || 'draft',
|
|
263
|
+
source: dir === paths.plansDir ? 'worktree' : 'global',
|
|
264
|
+
elementCount: meta?.elementCount ?? null,
|
|
265
|
+
commentCount: meta?.commentCount ?? null,
|
|
266
|
+
mtime: st.mtimeMs,
|
|
267
|
+
planUrl: planExists ? `/${entry}/` : null,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getPlans() {
|
|
274
|
+
const a = readPlansFromDir(paths.plansDir);
|
|
275
|
+
const b = readPlansFromDir(paths.globalPlansDir);
|
|
276
|
+
const seen = new Set();
|
|
277
|
+
const out = [];
|
|
278
|
+
for (const p of [...a, ...b]) {
|
|
279
|
+
if (seen.has(p.slug)) continue;
|
|
280
|
+
seen.add(p.slug);
|
|
281
|
+
out.push(p);
|
|
282
|
+
}
|
|
283
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getProjects() {
|
|
288
|
+
const out = [];
|
|
289
|
+
// 1. Walk up from projectRoot looking for .bizar/PROJECT.md markers
|
|
290
|
+
let dir = projectRoot;
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
while (dir && dir !== dirname(dir) && !seen.has(dir)) {
|
|
293
|
+
seen.add(dir);
|
|
294
|
+
const marker = join(dir, '.bizar', 'PROJECT.md');
|
|
295
|
+
if (existsSync(marker)) {
|
|
296
|
+
const st = safeStat(marker);
|
|
297
|
+
const hindsight = join(dir, '.bizar', '.hindsight');
|
|
298
|
+
let hcount = 0;
|
|
299
|
+
if (existsSync(hindsight)) {
|
|
300
|
+
try {
|
|
301
|
+
hcount = readdirSync(hindsight).length;
|
|
302
|
+
} catch {
|
|
303
|
+
hcount = 0;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
out.push({
|
|
307
|
+
name: basename(dir),
|
|
308
|
+
path: dir,
|
|
309
|
+
projectMdSize: st ? st.size : 0,
|
|
310
|
+
hindsightCount: hcount,
|
|
311
|
+
mtime: st ? st.mtimeMs : 0,
|
|
312
|
+
active: dir === projectRoot,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
dir = dirname(dir);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 2. Also scan ~/Projects/* as a discovery surface
|
|
319
|
+
const projectsRoot = join(HOME, 'Projects');
|
|
320
|
+
if (existsSync(projectsRoot)) {
|
|
321
|
+
try {
|
|
322
|
+
for (const entry of readdirSync(projectsRoot)) {
|
|
323
|
+
const full = join(projectsRoot, entry);
|
|
324
|
+
const st = safeStat(full);
|
|
325
|
+
if (!st || !st.isDirectory()) continue;
|
|
326
|
+
if (out.some((p) => p.path === full)) continue;
|
|
327
|
+
const marker = join(full, '.bizar', 'PROJECT.md');
|
|
328
|
+
const hasMarker = existsSync(marker);
|
|
329
|
+
out.push({
|
|
330
|
+
name: entry,
|
|
331
|
+
path: full,
|
|
332
|
+
projectMdSize: hasMarker ? safeStat(marker)?.size || 0 : 0,
|
|
333
|
+
hindsightCount: 0,
|
|
334
|
+
mtime: st.mtimeMs,
|
|
335
|
+
active: false,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
out.sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0));
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getConfig() {
|
|
348
|
+
const data = safeReadJSON(paths.opencodeJson, null);
|
|
349
|
+
return {
|
|
350
|
+
path: paths.opencodeJson,
|
|
351
|
+
data,
|
|
352
|
+
raw: data === null ? '' : JSON.stringify(data, null, 2),
|
|
353
|
+
exists: existsSync(paths.opencodeJson),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getSettings() {
|
|
358
|
+
const defaults = {
|
|
359
|
+
theme: 'dark',
|
|
360
|
+
defaultAgent: 'odin',
|
|
361
|
+
defaultModel: '',
|
|
362
|
+
notifications: {
|
|
363
|
+
onAgentComplete: true,
|
|
364
|
+
onPlanApproval: true,
|
|
365
|
+
},
|
|
366
|
+
about: {
|
|
367
|
+
version: '2.5.0',
|
|
368
|
+
homepage: 'https://github.com/DrB0rk/BizarHarness',
|
|
369
|
+
license: 'MIT',
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
const existing = safeReadJSON(paths.settingsFile, null);
|
|
373
|
+
const merged = { ...defaults, ...(existing || {}) };
|
|
374
|
+
return {
|
|
375
|
+
path: paths.settingsFile,
|
|
376
|
+
data: merged,
|
|
377
|
+
exists: existsSync(paths.settingsFile),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function getTasks() {
|
|
382
|
+
return loadTasks();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── mutators ──────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
function setConfig(newData) {
|
|
388
|
+
mkdirSync(dirname(paths.opencodeJson), { recursive: true });
|
|
389
|
+
writeFileSync(
|
|
390
|
+
paths.opencodeJson,
|
|
391
|
+
JSON.stringify(newData, null, 2) + '\n',
|
|
392
|
+
'utf8',
|
|
393
|
+
);
|
|
394
|
+
return getConfig();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function setSettings(newData) {
|
|
398
|
+
mkdirSync(dirname(paths.settingsFile), { recursive: true });
|
|
399
|
+
writeFileSync(
|
|
400
|
+
paths.settingsFile,
|
|
401
|
+
JSON.stringify(newData, null, 2) + '\n',
|
|
402
|
+
'utf8',
|
|
403
|
+
);
|
|
404
|
+
return getSettings();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function appendActivity(event) {
|
|
408
|
+
try {
|
|
409
|
+
mkdirSync(paths.bizarDir, { recursive: true });
|
|
410
|
+
const record = {
|
|
411
|
+
ts: new Date().toISOString(),
|
|
412
|
+
...event,
|
|
413
|
+
};
|
|
414
|
+
writeFileSync(
|
|
415
|
+
paths.activityLog,
|
|
416
|
+
JSON.stringify(record) + '\n',
|
|
417
|
+
{ flag: 'a', encoding: 'utf8' },
|
|
418
|
+
);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.error('[dashboard state] appendActivity failed:', err);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
paths,
|
|
426
|
+
getOverview,
|
|
427
|
+
getChat,
|
|
428
|
+
getAgents,
|
|
429
|
+
getPlans,
|
|
430
|
+
getProjects,
|
|
431
|
+
getConfig,
|
|
432
|
+
getSettings,
|
|
433
|
+
getTasks,
|
|
434
|
+
setConfig,
|
|
435
|
+
setSettings,
|
|
436
|
+
appendActivity,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/dashboard/tasks-store.mjs
|
|
3
|
+
*
|
|
4
|
+
* Per-user task storage at ~/.config/bizar/tasks.json.
|
|
5
|
+
* In-process mutex (single-lock) to handle concurrent reads/writes.
|
|
6
|
+
*
|
|
7
|
+
* File format:
|
|
8
|
+
* {
|
|
9
|
+
* "version": 1,
|
|
10
|
+
* "tasks": [ ...task objects... ]
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Task object:
|
|
14
|
+
* {
|
|
15
|
+
* "id": "tsk_<8 base36 chars>",
|
|
16
|
+
* "title": "string",
|
|
17
|
+
* "description": "markdown string",
|
|
18
|
+
* "status": "queued" | "doing" | "done",
|
|
19
|
+
* "tags": ["string"],
|
|
20
|
+
* "priority": "low" | "normal" | "high",
|
|
21
|
+
* "createdAt": "ISO timestamp",
|
|
22
|
+
* "updatedAt": "ISO timestamp",
|
|
23
|
+
* "completedAt": "ISO timestamp | null
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { dirname } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { randomBytes } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
const HOME = homedir();
|
|
32
|
+
const TASKS_FILE = `${HOME}/.config/bizar/tasks.json`;
|
|
33
|
+
const TASKS_DIR = `${HOME}/.config/bizar`;
|
|
34
|
+
|
|
35
|
+
// ── mutex ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
let _busy = false;
|
|
38
|
+
const _waiters = [];
|
|
39
|
+
|
|
40
|
+
function acquire() {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
if (!_busy) {
|
|
43
|
+
_busy = true;
|
|
44
|
+
resolve();
|
|
45
|
+
} else {
|
|
46
|
+
_waiters.push({ resolve });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function release() {
|
|
52
|
+
if (_waiters.length > 0) {
|
|
53
|
+
const next = _waiters.shift();
|
|
54
|
+
next.resolve();
|
|
55
|
+
} else {
|
|
56
|
+
_busy = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── file helpers ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function safeReadJSON(file, fallback = null) {
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(file)) return fallback;
|
|
65
|
+
const text = readFileSync(file, 'utf8');
|
|
66
|
+
if (!text.trim()) return fallback;
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadStore() {
|
|
74
|
+
const raw = safeReadJSON(TASKS_FILE, null);
|
|
75
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.tasks)) {
|
|
76
|
+
return { version: 1, tasks: [] };
|
|
77
|
+
}
|
|
78
|
+
return raw;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function saveStore(store) {
|
|
82
|
+
mkdirSync(TASKS_DIR, { recursive: true });
|
|
83
|
+
writeFileSync(TASKS_FILE, JSON.stringify(store, null, 2) + '\n', 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── ID generation ────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function genId() {
|
|
89
|
+
// Use hex encoding (universally supported) and take first 8 chars
|
|
90
|
+
return 'tsk_' + randomBytes(8).toString('hex').slice(0, 8);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── public API ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** Returns all tasks, newest first. */
|
|
96
|
+
export function loadTasks() {
|
|
97
|
+
const store = loadStore();
|
|
98
|
+
return store.tasks.slice().sort((a, b) => {
|
|
99
|
+
// newest first
|
|
100
|
+
return new Date(b.createdAt) - new Date(a.createdAt);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Persists the full tasks array. Call after any mutation. */
|
|
105
|
+
export function saveTasks(tasks) {
|
|
106
|
+
saveStore({ version: 1, tasks });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a new task.
|
|
111
|
+
* @param {{ title, description?, status?, tags?, priority? }} opts
|
|
112
|
+
* @returns the created task object
|
|
113
|
+
*/
|
|
114
|
+
export async function createTask({ title, description = '', status = 'queued', tags = [], priority = 'normal' }) {
|
|
115
|
+
await acquire();
|
|
116
|
+
try {
|
|
117
|
+
const store = loadStore();
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
const task = {
|
|
120
|
+
id: genId(),
|
|
121
|
+
title,
|
|
122
|
+
description,
|
|
123
|
+
status,
|
|
124
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
125
|
+
priority: ['low', 'normal', 'high'].includes(priority) ? priority : 'normal',
|
|
126
|
+
createdAt: now,
|
|
127
|
+
updatedAt: now,
|
|
128
|
+
completedAt: status === 'done' ? now : null,
|
|
129
|
+
};
|
|
130
|
+
store.tasks.push(task);
|
|
131
|
+
saveStore(store);
|
|
132
|
+
return task;
|
|
133
|
+
} finally {
|
|
134
|
+
release();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Partially update a task by id.
|
|
140
|
+
* @param {string} id
|
|
141
|
+
* @param {object} patch - partial task fields
|
|
142
|
+
* @returns the updated task or null if not found
|
|
143
|
+
*/
|
|
144
|
+
export async function updateTask(id, patch = {}) {
|
|
145
|
+
await acquire();
|
|
146
|
+
try {
|
|
147
|
+
const store = loadStore();
|
|
148
|
+
const idx = store.tasks.findIndex((t) => t.id === id);
|
|
149
|
+
if (idx === -1) return null;
|
|
150
|
+
const task = { ...store.tasks[idx] };
|
|
151
|
+
|
|
152
|
+
// Apply allowed fields
|
|
153
|
+
if (typeof patch.title === 'string') task.title = patch.title.slice(0, 200);
|
|
154
|
+
if (typeof patch.description === 'string') task.description = patch.description;
|
|
155
|
+
if (typeof patch.status === 'string' && ['queued', 'doing', 'done'].includes(patch.status)) {
|
|
156
|
+
task.status = patch.status;
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(patch.tags)) task.tags = patch.tags;
|
|
159
|
+
if (['low', 'normal', 'high'].includes(patch.priority)) task.priority = patch.priority;
|
|
160
|
+
|
|
161
|
+
task.updatedAt = new Date().toISOString();
|
|
162
|
+
if (task.status === 'done' && !task.completedAt) {
|
|
163
|
+
task.completedAt = task.updatedAt;
|
|
164
|
+
} else if (task.status !== 'done') {
|
|
165
|
+
task.completedAt = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
store.tasks[idx] = task;
|
|
169
|
+
saveStore(store);
|
|
170
|
+
return task;
|
|
171
|
+
} finally {
|
|
172
|
+
release();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete a task by id.
|
|
178
|
+
* @param {string} id
|
|
179
|
+
* @returns true if deleted, false if not found
|
|
180
|
+
*/
|
|
181
|
+
export async function deleteTask(id) {
|
|
182
|
+
await acquire();
|
|
183
|
+
try {
|
|
184
|
+
const store = loadStore();
|
|
185
|
+
const idx = store.tasks.findIndex((t) => t.id === id);
|
|
186
|
+
if (idx === -1) return false;
|
|
187
|
+
store.tasks.splice(idx, 1);
|
|
188
|
+
saveStore(store);
|
|
189
|
+
return true;
|
|
190
|
+
} finally {
|
|
191
|
+
release();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Move a task to a new status column.
|
|
197
|
+
* @param {string} id
|
|
198
|
+
* @param {'queued'|'doing'|'done'} newStatus
|
|
199
|
+
* @returns the updated task or null if not found
|
|
200
|
+
*/
|
|
201
|
+
export async function moveTask(id, newStatus) {
|
|
202
|
+
return updateTask(id, { status: newStatus });
|
|
203
|
+
}
|