super-backlog 0.9.0 → 1.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.
- package/README.md +5 -5
- package/dist/cli.js +32 -13
- package/dist/commands/backlog-alias.js +21 -0
- package/dist/commands/dashboard.js +36 -35
- package/dist/commands/init.js +1 -7
- package/dist/commands/serve.js +3 -0
- package/dist/commands/update.js +1 -14
- package/dist/dashboard/data.js +26 -2
- package/dist/dashboard/server.js +182 -19
- package/dist/init/execute.js +1 -32
- package/dist/init/planner.js +0 -4
- package/dist/templates/dashboard.html +214 -231
- package/package.json +1 -1
- package/dist/dashboard/regen.js +0 -28
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ After installation:
|
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
54
|
npm run board # open the Backlog.md kanban board
|
|
55
|
+
sbl serve # dashboard server + Backlog browser with live reload
|
|
55
56
|
sbl dashboard --serve # live Project Dashboard on http://localhost:6428
|
|
56
57
|
```
|
|
57
58
|
|
|
@@ -70,11 +71,10 @@ sbl dashboard --serve # live Project Dashboard on http://localhost:6
|
|
|
70
71
|
| `.opencode/skill/<skill>/SKILL.md` (3 glue skills) | skill templates | fingerprint header line |
|
|
71
72
|
| `.claude/skills/<skill>/SKILL.md` | same templates | fingerprint header line |
|
|
72
73
|
| `package.json` scripts | `tasks` → `backlog task list`, `board` → `backlog board`, `browser` → `backlog browser`, `dashboard` → `super-backlog dashboard` (never overwrite existing values) | merged, add-only-if-absent |
|
|
73
|
-
| `dashboard.html` | generated Project Dashboard |
|
|
74
|
-
| `.git/hooks/post-commit` | dashboard freshness block — regenerates `dashboard.html` after commits that touch `backlog/` (default; opt out with `--no-refresh-hook`) | appended marker block |
|
|
74
|
+
| `dashboard.html` | generated Project Dashboard | not installed in user projects; generated on demand by `sbl dashboard` |
|
|
75
75
|
| `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
|
|
76
76
|
|
|
77
|
-
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--
|
|
77
|
+
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]` · `sbl serve [--port <n>] [--no-open]`. See `sbl help` for every flag.
|
|
78
78
|
|
|
79
79
|
## Model router (opt-in)
|
|
80
80
|
|
|
@@ -98,11 +98,11 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
|
|
|
98
98
|
|
|
99
99
|
## Project Dashboard
|
|
100
100
|
|
|
101
|
-
`sbl dashboard`
|
|
101
|
+
`sbl dashboard` starts a local server that serves a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Activity (30-day sparkline), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fonts — works offline when served locally. The server watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. `sbl serve` is a deprecated alias that behaves identically. `sbl dashboard` also launches the Backlog browser alongside the server so you can edit tasks while the dashboard updates.
|
|
102
102
|
|
|
103
103
|
### Keeping it fresh
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
Run `sbl dashboard` whenever you want a live view of the board. The server regenerates the dashboard while it runs; stop it with `Ctrl+C`. There is no static `dashboard.html` installed in your project.
|
|
106
106
|
|
|
107
107
|

|
|
108
108
|
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { runDashboard } from './commands/dashboard.js';
|
|
6
|
+
import { runBacklogSubcommand } from './commands/backlog-alias.js';
|
|
6
7
|
import { runDoctor } from './commands/doctor.js';
|
|
7
8
|
import { runInit } from './commands/init.js';
|
|
8
9
|
import { runModels } from './commands/models.js';
|
|
10
|
+
import { runServe } from './commands/serve.js';
|
|
9
11
|
import { runUninstall } from './commands/uninstall.js';
|
|
10
12
|
import { runUpdate } from './commands/update.js';
|
|
11
13
|
import { assertNode20, KIT_VERSION } from './lib/version.js';
|
|
@@ -17,18 +19,19 @@ Commands:
|
|
|
17
19
|
init Install the kit into the current project
|
|
18
20
|
uninstall Remove kit-managed files (project data kept unless --with-backlog)
|
|
19
21
|
update Refresh kit-managed files and report upstream versions
|
|
20
|
-
dashboard
|
|
22
|
+
dashboard Start the project dashboard server (live-reload + Backlog browser)
|
|
23
|
+
serve Deprecated alias for 'sbl dashboard'
|
|
24
|
+
browser Open the Backlog.md browser (delegates to backlog browser)
|
|
25
|
+
board Show the Backlog.md board (delegates to backlog board)
|
|
21
26
|
models Manage the model router (show, enable, disable, discover)
|
|
22
27
|
doctor Check the environment (node, PowerShell policy, backlog CLI)
|
|
23
28
|
|
|
24
|
-
init options:
|
|
29
|
+
init options:
|
|
25
30
|
--pm <auto|npm|pnpm|bun|skip> Package manager to use (default: auto)
|
|
26
31
|
--harness <opencode|claude> Target harness; repeatable or comma-separated (default: both)
|
|
27
32
|
--guard Install the integrity pre-commit hook (opt-in)
|
|
28
33
|
--models Install the model router config during init (opt-in)
|
|
29
34
|
--no-models Explicitly opt out of the model router
|
|
30
|
-
--no-dashboard Skip generating the project dashboard
|
|
31
|
-
--no-refresh-hook Skip the post-commit dashboard freshness hook
|
|
32
35
|
--fix-all Repair environment problems automatically (no prompts)
|
|
33
36
|
--dry-run Show what would be done without writing anything
|
|
34
37
|
|
|
@@ -39,11 +42,13 @@ uninstall options:
|
|
|
39
42
|
update options:
|
|
40
43
|
(none) Refreshes injected files, skills, hook; prints upstream versions
|
|
41
44
|
|
|
42
|
-
dashboard options:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
dashboard options:
|
|
46
|
+
--port <n> Port for the dashboard server (default: 6428)
|
|
47
|
+
--no-open Do not open the dashboard browser automatically
|
|
48
|
+
|
|
49
|
+
serve options:
|
|
50
|
+
--port <n> Port for the dashboard server (default: 6428)
|
|
51
|
+
--no-open Do not open the dashboard browser automatically
|
|
47
52
|
|
|
48
53
|
doctor options:
|
|
49
54
|
(none) Prints one [ok]/[warn]/[skip] line per check; exit 4 on any warn
|
|
@@ -75,8 +80,6 @@ async function main(argv) {
|
|
|
75
80
|
guard: { type: 'boolean' },
|
|
76
81
|
models: { type: 'boolean' },
|
|
77
82
|
'no-models': { type: 'boolean' },
|
|
78
|
-
'no-dashboard': { type: 'boolean' },
|
|
79
|
-
'no-refresh-hook': { type: 'boolean' },
|
|
80
83
|
'fix-all': { type: 'boolean' },
|
|
81
84
|
'dry-run': { type: 'boolean' },
|
|
82
85
|
},
|
|
@@ -109,10 +112,8 @@ async function main(argv) {
|
|
|
109
112
|
args: rest,
|
|
110
113
|
allowPositionals: true,
|
|
111
114
|
options: {
|
|
112
|
-
serve: { type: 'boolean' },
|
|
113
115
|
port: { type: 'string' },
|
|
114
116
|
'no-open': { type: 'boolean' },
|
|
115
|
-
out: { type: 'string' },
|
|
116
117
|
},
|
|
117
118
|
});
|
|
118
119
|
return await runDashboard(process.cwd(), {
|
|
@@ -120,6 +121,24 @@ async function main(argv) {
|
|
|
120
121
|
positionals: parsed.positionals,
|
|
121
122
|
});
|
|
122
123
|
}
|
|
124
|
+
case 'serve': {
|
|
125
|
+
const parsed = parseArgs({
|
|
126
|
+
args: rest,
|
|
127
|
+
allowPositionals: true,
|
|
128
|
+
options: {
|
|
129
|
+
port: { type: 'string' },
|
|
130
|
+
'no-open': { type: 'boolean' },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return await runServe(process.cwd(), {
|
|
134
|
+
values: parsed.values,
|
|
135
|
+
positionals: parsed.positionals,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
case 'browser':
|
|
139
|
+
return await runBacklogSubcommand(process.cwd(), 'browser', rest);
|
|
140
|
+
case 'board':
|
|
141
|
+
return await runBacklogSubcommand(process.cwd(), 'board', rest);
|
|
123
142
|
case 'models': {
|
|
124
143
|
const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
|
|
125
144
|
return await runModels(process.cwd(), {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/commands/backlog-alias.ts
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
5
|
+
/** Run a backlog.md subcommand by delegating to the resolved backlog binary. */
|
|
6
|
+
export function runBacklogSubcommand(cwd, subcommand, args = []) {
|
|
7
|
+
const bin = resolveBacklogBin(cwd);
|
|
8
|
+
if (!bin) {
|
|
9
|
+
console.error('error: backlog CLI not found; is backlog.md installed?');
|
|
10
|
+
return Promise.resolve(1);
|
|
11
|
+
}
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(bin, [subcommand, ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
});
|
|
18
|
+
child.on('error', () => resolve(1));
|
|
19
|
+
child.on('exit', (code) => resolve(code ?? 1));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -1,38 +1,41 @@
|
|
|
1
1
|
// src/commands/dashboard.ts
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
3
6
|
import { collectDashboardData } from '../dashboard/data.js';
|
|
4
7
|
import { renderDashboard } from '../dashboard/render.js';
|
|
5
8
|
import { DASHBOARD_PORT, startServeServer } from '../dashboard/server.js';
|
|
6
9
|
import { atomicWrite } from '../lib/atomic.js';
|
|
10
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
7
11
|
import { KIT_VERSION } from '../lib/version.js';
|
|
8
12
|
async function regenerateInto(outPath, cwd) {
|
|
9
13
|
const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
|
|
10
14
|
atomicWrite(outPath, renderDashboard(data));
|
|
11
15
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (typeof mod.startServeServer !== 'function') {
|
|
25
|
-
throw new Error('serve module does not export startServeServer');
|
|
26
|
-
}
|
|
27
|
-
await mod.startServeServer(cwd, {
|
|
28
|
-
port: DASHBOARD_PORT,
|
|
29
|
-
regenerate: () => regenerateInto(outPath, cwd),
|
|
30
|
-
openBrowser: true,
|
|
16
|
+
function spawnBacklogBrowser(cwd) {
|
|
17
|
+
const bin = resolveBacklogBin(cwd);
|
|
18
|
+
if (!bin) {
|
|
19
|
+
console.warn('warning: backlog CLI not found; dashboard will serve without the Backlog browser');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const child = spawn(bin, ['browser', '--no-open', '--non-interactive'], {
|
|
24
|
+
cwd,
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: 'ignore',
|
|
27
|
+
shell: process.platform === 'win32',
|
|
31
28
|
});
|
|
29
|
+
child.on('error', () => { });
|
|
30
|
+
child.unref();
|
|
31
|
+
console.log('started Backlog browser (dashboard still serves if browser fails)');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
console.warn('warning: failed to start Backlog browser; dashboard still serves');
|
|
32
35
|
}
|
|
33
|
-
return outPath;
|
|
34
36
|
}
|
|
35
|
-
/** CLI entry for `sbl dashboard [--
|
|
37
|
+
/** CLI entry for `sbl dashboard [--port N] [--no-open]`. Starts a local server
|
|
38
|
+
* that watches backlog/ and regenerates a temp dashboard file on changes. */
|
|
36
39
|
export async function runDashboard(cwd, args) {
|
|
37
40
|
const values = args.values;
|
|
38
41
|
let port = DASHBOARD_PORT;
|
|
@@ -44,26 +47,24 @@ export async function runDashboard(cwd, args) {
|
|
|
44
47
|
}
|
|
45
48
|
port = parsed;
|
|
46
49
|
}
|
|
47
|
-
const serve = values['serve'] === true;
|
|
48
50
|
const noOpen = values['no-open'] === true;
|
|
49
|
-
const
|
|
50
|
-
const outPath = isAbsolute(outFile) ? outFile : resolve(cwd, outFile);
|
|
51
|
+
const outPath = join(tmpdir(), `sbl-dashboard-${Date.now()}.html`);
|
|
51
52
|
try {
|
|
52
53
|
await regenerateInto(outPath, cwd);
|
|
53
54
|
console.log(`dashboard written: ${outPath}`);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
55
|
+
console.log(`serving dashboard at http://127.0.0.1:${port}/ (press Ctrl+C to stop)`);
|
|
56
|
+
// Start backlog browser in parallel; don't await so the dashboard server can listen immediately.
|
|
57
|
+
spawnBacklogBrowser(cwd);
|
|
58
|
+
await startServeServer(cwd, {
|
|
59
|
+
port,
|
|
60
|
+
file: outPath,
|
|
61
|
+
regenerate: () => regenerateInto(outPath, cwd),
|
|
62
|
+
openBrowser: !noOpen,
|
|
63
|
+
});
|
|
63
64
|
return 0;
|
|
64
65
|
}
|
|
65
66
|
catch (err) {
|
|
66
|
-
console.error(`error: dashboard
|
|
67
|
+
console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
|
|
67
68
|
return 1;
|
|
68
69
|
}
|
|
69
70
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -34,10 +34,6 @@ function describeAction(action) {
|
|
|
34
34
|
return 'install-model-router .super-backlog/models.json';
|
|
35
35
|
case 'install-guard-hook':
|
|
36
36
|
return 'install-guard-hook .git/hooks/pre-commit';
|
|
37
|
-
case 'install-refresh-hook':
|
|
38
|
-
return 'install-refresh-hook .git/hooks/post-commit';
|
|
39
|
-
case 'generate-dashboard':
|
|
40
|
-
return 'generate-dashboard';
|
|
41
37
|
case 'write':
|
|
42
38
|
return `write ${action.path}`;
|
|
43
39
|
}
|
|
@@ -96,8 +92,6 @@ export async function runInit(cwd, args, deps = {}) {
|
|
|
96
92
|
}
|
|
97
93
|
const pm = rawPm;
|
|
98
94
|
const guard = args.values.guard === true; // opt-in per spec D8
|
|
99
|
-
const dashboard = args.values['no-dashboard'] !== true;
|
|
100
|
-
const refreshHook = args.values['no-refresh-hook'] !== true; // default on, opt-out flag
|
|
101
95
|
const dryRun = args.values['dry-run'] === true;
|
|
102
96
|
const models = args.values.models === true ? true : args.values['no-models'] === true ? false : undefined;
|
|
103
97
|
const projectName = args.positionals[0] ?? basename(resolve(cwd));
|
|
@@ -121,7 +115,7 @@ export async function runInit(cwd, args, deps = {}) {
|
|
|
121
115
|
opencodeConfig,
|
|
122
116
|
pkgExists: existsSync(join(cwd, 'package.json')),
|
|
123
117
|
};
|
|
124
|
-
const opts = { projectName, harnesses, pm, guard,
|
|
118
|
+
const opts = { projectName, harnesses, pm, guard, skipInstall: false, models };
|
|
125
119
|
const plan = planInit(state, opts, KIT_VERSION);
|
|
126
120
|
if (dryRun) {
|
|
127
121
|
console.log(`dry-run for "${projectName}": ${plan.actions.length} action(s) planned, nothing written`);
|
package/dist/commands/update.js
CHANGED
|
@@ -4,7 +4,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { executeActions, findGitDir, InvalidJsonError, validateJsonFile, RefusalError, UpstreamError } from '../init/execute.js';
|
|
6
6
|
import { planInit } from '../init/planner.js';
|
|
7
|
-
import { GUARD_RE
|
|
7
|
+
import { GUARD_RE } from '../lib/hooks.js';
|
|
8
8
|
import { detectPackageManager } from '../lib/pm.js';
|
|
9
9
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
10
10
|
import { KIT_VERSION } from '../lib/version.js';
|
|
@@ -13,8 +13,6 @@ const REFRESH_KINDS = new Set([
|
|
|
13
13
|
'write-claude-pointer',
|
|
14
14
|
'copy-skills',
|
|
15
15
|
'install-guard-hook',
|
|
16
|
-
'install-refresh-hook',
|
|
17
|
-
'generate-dashboard',
|
|
18
16
|
]);
|
|
19
17
|
export function refreshActions(all) {
|
|
20
18
|
return all.filter((action) => REFRESH_KINDS.has(action.kind));
|
|
@@ -31,15 +29,6 @@ function guardHookInstalled(cwd) {
|
|
|
31
29
|
return false;
|
|
32
30
|
return GUARD_RE.test(readFileSync(hookPath, 'utf8'));
|
|
33
31
|
}
|
|
34
|
-
function refreshHookInstalled(cwd) {
|
|
35
|
-
const gitDir = findGitDir(cwd);
|
|
36
|
-
if (!gitDir)
|
|
37
|
-
return false;
|
|
38
|
-
const hookPath = join(gitDir, 'hooks', 'post-commit');
|
|
39
|
-
if (!existsSync(hookPath))
|
|
40
|
-
return false;
|
|
41
|
-
return REFRESH_RE.test(readFileSync(hookPath, 'utf8'));
|
|
42
|
-
}
|
|
43
32
|
export async function runUpdate(cwd, _args) {
|
|
44
33
|
// Up-front detection-failure check (mirrors uninstall): refuse before mutating anything.
|
|
45
34
|
for (const f of ['package.json', 'opencode.json']) {
|
|
@@ -72,8 +61,6 @@ export async function runUpdate(cwd, _args) {
|
|
|
72
61
|
harnesses: ['opencode', 'claude'],
|
|
73
62
|
pm: 'auto',
|
|
74
63
|
guard: guardHookInstalled(cwd),
|
|
75
|
-
refreshHook: refreshHookInstalled(cwd),
|
|
76
|
-
dashboard: existsSync(join(cwd, 'dashboard.html')),
|
|
77
64
|
skipInstall: false,
|
|
78
65
|
};
|
|
79
66
|
const plan = planInit(state, opts, KIT_VERSION);
|
package/dist/dashboard/data.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/dashboard/data.ts
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
5
5
|
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
@@ -159,7 +159,7 @@ export const BUILT_IN_GLOSSARY = [
|
|
|
159
159
|
{ term: 'Backlog.md', definition: 'File-based task management CLI owning specs, statuses and history under backlog/.' },
|
|
160
160
|
{ term: 'Superpowers', definition: 'The methodology skill set that decides HOW the work is done.' },
|
|
161
161
|
{ term: 'Pipeline', definition: 'The nine workflow phases from Idea to Merge & archive.' },
|
|
162
|
-
{ term: 'Freshness Hook', definition: '
|
|
162
|
+
{ term: 'Freshness Hook', definition: 'Run `sbl dashboard` to serve a live dashboard that reloads automatically while the server is running.' },
|
|
163
163
|
];
|
|
164
164
|
/** Split `## Term` headings plus their following non-heading block into entries; empty sections are skipped. */
|
|
165
165
|
export function parseGlossaryMarkdown(content) {
|
|
@@ -216,6 +216,29 @@ function readProjectGlossary(cwd) {
|
|
|
216
216
|
return [];
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
function readDraftFile(path) {
|
|
220
|
+
const keys = readSimpleKeys(path, ['id', 'title', 'status']);
|
|
221
|
+
const id = asString(keys.id);
|
|
222
|
+
const title = asString(keys.title);
|
|
223
|
+
const status = asString(keys.status);
|
|
224
|
+
if (!id || !title || !status)
|
|
225
|
+
return null;
|
|
226
|
+
return { id, title, status };
|
|
227
|
+
}
|
|
228
|
+
export function readDrafts(cwd) {
|
|
229
|
+
const draftsDir = join(cwd, 'backlog', 'drafts');
|
|
230
|
+
if (!existsSync(draftsDir))
|
|
231
|
+
return [];
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const entry of readdirSync(draftsDir, { withFileTypes: true })) {
|
|
234
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
235
|
+
continue;
|
|
236
|
+
const draft = readDraftFile(join(draftsDir, entry.name));
|
|
237
|
+
if (draft)
|
|
238
|
+
out.push(draft);
|
|
239
|
+
}
|
|
240
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
241
|
+
}
|
|
219
242
|
function readProjectIdentity(cwd) {
|
|
220
243
|
const cfg = readSimpleKeys(join(cwd, 'backlog', 'config.yml'), [
|
|
221
244
|
'project_name',
|
|
@@ -252,6 +275,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
252
275
|
milestones: [],
|
|
253
276
|
tasks: [],
|
|
254
277
|
deps: [],
|
|
278
|
+
drafts: readDrafts(cwd),
|
|
255
279
|
activity: computeActivity([], today),
|
|
256
280
|
glossary: mergeGlossary(readProjectGlossary(cwd)),
|
|
257
281
|
source: 'fallback-empty',
|
package/dist/dashboard/server.js
CHANGED
|
@@ -6,7 +6,182 @@ import { createServer } from 'node:http';
|
|
|
6
6
|
import { isAbsolute, join } from 'node:path';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { createModelApiHandler } from '../models/dashboard-api.js';
|
|
9
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
9
10
|
export const DASHBOARD_PORT = 6428;
|
|
11
|
+
const WHITELIST = new Map([
|
|
12
|
+
['browser', ['browser']],
|
|
13
|
+
['board', ['board']],
|
|
14
|
+
]);
|
|
15
|
+
function isRecord(v) {
|
|
16
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
17
|
+
}
|
|
18
|
+
async function readBody(req) {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of req) {
|
|
21
|
+
chunks.push(Buffer.from(chunk));
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
/** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
|
|
26
|
+
export function createRunApiHandler(cwd) {
|
|
27
|
+
return async (req, res) => {
|
|
28
|
+
if (req.url !== '/api/run') {
|
|
29
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
30
|
+
res.end('not found');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (req.method !== 'POST') {
|
|
34
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
35
|
+
res.end('not found');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = await readBody(req);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
44
|
+
res.end(JSON.stringify({ error: 'failed to read body' }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let payload;
|
|
48
|
+
try {
|
|
49
|
+
payload = JSON.parse(body);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
53
|
+
res.end(JSON.stringify({ error: 'invalid json' }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
|
|
57
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
58
|
+
res.end(JSON.stringify({ error: 'unknown command' }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const bin = resolveBacklogBin(cwd);
|
|
62
|
+
if (!bin) {
|
|
63
|
+
res.writeHead(503, { 'content-type': 'application/json' });
|
|
64
|
+
res.end(JSON.stringify({ error: 'backlog cli not found' }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const args = WHITELIST.get(payload.command);
|
|
68
|
+
try {
|
|
69
|
+
const child = spawn(bin, args, {
|
|
70
|
+
cwd,
|
|
71
|
+
detached: true,
|
|
72
|
+
stdio: 'ignore',
|
|
73
|
+
shell: process.platform === 'win32',
|
|
74
|
+
});
|
|
75
|
+
child.on('error', () => { });
|
|
76
|
+
child.unref();
|
|
77
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
78
|
+
res.end(JSON.stringify({ ok: true }));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
82
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** SSE broker: keeps a set of response objects and broadcasts named events. */
|
|
87
|
+
export function createReloadBroker() {
|
|
88
|
+
const clients = new Set();
|
|
89
|
+
let closed = false;
|
|
90
|
+
function handler(req, res) {
|
|
91
|
+
if (req.url !== '/api/events' || req.method !== 'GET')
|
|
92
|
+
return false;
|
|
93
|
+
res.writeHead(200, {
|
|
94
|
+
'content-type': 'text/event-stream',
|
|
95
|
+
'cache-control': 'no-cache',
|
|
96
|
+
connection: 'keep-alive',
|
|
97
|
+
});
|
|
98
|
+
res.write(':ok\n\n');
|
|
99
|
+
clients.add(res);
|
|
100
|
+
const cleanup = () => {
|
|
101
|
+
clients.delete(res);
|
|
102
|
+
};
|
|
103
|
+
req.on('close', cleanup);
|
|
104
|
+
req.on('error', cleanup);
|
|
105
|
+
res.on('close', cleanup);
|
|
106
|
+
res.on('error', cleanup);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function broadcast(event) {
|
|
110
|
+
if (closed)
|
|
111
|
+
return;
|
|
112
|
+
// A data line is mandatory: per the HTML standard, EventSource never
|
|
113
|
+
// dispatches an event whose data buffer is empty, so `event: x\n\n`
|
|
114
|
+
// alone would silently never reach addEventListener('x', ...) clients.
|
|
115
|
+
const message = `event: ${event}\ndata: {}\n\n`;
|
|
116
|
+
for (const client of clients) {
|
|
117
|
+
try {
|
|
118
|
+
client.write(message);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
clients.delete(client);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function clientCount() {
|
|
126
|
+
return clients.size;
|
|
127
|
+
}
|
|
128
|
+
function close() {
|
|
129
|
+
if (closed)
|
|
130
|
+
return;
|
|
131
|
+
closed = true;
|
|
132
|
+
for (const client of clients) {
|
|
133
|
+
try {
|
|
134
|
+
client.end();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
clients.clear();
|
|
141
|
+
}
|
|
142
|
+
return { handler, broadcast, clientCount, close };
|
|
143
|
+
}
|
|
144
|
+
/** Debounced wrapper around a regenerate callback; on success invokes onReload. */
|
|
145
|
+
export function createDebouncedReloader(regenerate, onReload, delayMs) {
|
|
146
|
+
let timer = null;
|
|
147
|
+
function trigger() {
|
|
148
|
+
if (!regenerate)
|
|
149
|
+
return;
|
|
150
|
+
if (timer !== null)
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
timer = setTimeout(() => {
|
|
153
|
+
timer = null;
|
|
154
|
+
void Promise.resolve()
|
|
155
|
+
.then(regenerate)
|
|
156
|
+
.then(() => {
|
|
157
|
+
onReload();
|
|
158
|
+
})
|
|
159
|
+
.catch(() => { });
|
|
160
|
+
}, delayMs);
|
|
161
|
+
}
|
|
162
|
+
function cancel() {
|
|
163
|
+
if (timer !== null) {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
timer = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { trigger, cancel };
|
|
169
|
+
}
|
|
170
|
+
function createApiHandler(cwd, broker) {
|
|
171
|
+
const modelApi = createModelApiHandler();
|
|
172
|
+
const runApi = createRunApiHandler(cwd);
|
|
173
|
+
return async (req, res) => {
|
|
174
|
+
const url = req.url ?? '/';
|
|
175
|
+
if (url === '/api/run') {
|
|
176
|
+
await runApi(req, res);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (broker.handler(req, res)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await modelApi(req, res);
|
|
183
|
+
};
|
|
184
|
+
}
|
|
10
185
|
export function recursiveWatchSupported(platform, nodeVersion) {
|
|
11
186
|
// Node 24 on Windows triggers a libuv assertion in recursive fs.watch:
|
|
12
187
|
// https://github.com/nodejs/node/issues/xxx (fs-event.c line 72)
|
|
@@ -43,25 +218,14 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
43
218
|
const file = opts.file ?? 'dashboard.html';
|
|
44
219
|
const filePath = isAbsolute(file) ? file : join(cwd, file);
|
|
45
220
|
const regenerate = opts.regenerate;
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
if (!regenerate)
|
|
49
|
-
return;
|
|
50
|
-
if (timer !== null)
|
|
51
|
-
clearTimeout(timer);
|
|
52
|
-
timer = setTimeout(() => {
|
|
53
|
-
timer = null;
|
|
54
|
-
void Promise.resolve()
|
|
55
|
-
.then(regenerate)
|
|
56
|
-
.catch(() => { }); // regeneration failures never kill the server
|
|
57
|
-
}, 300);
|
|
58
|
-
};
|
|
221
|
+
const broker = createReloadBroker();
|
|
222
|
+
const reloader = createDebouncedReloader(regenerate, () => broker.broadcast('reload'), 300);
|
|
59
223
|
let watcher = null;
|
|
60
224
|
const backlogDir = join(cwd, 'backlog');
|
|
61
225
|
if (recursiveWatchSupported(process.platform, process.versions.node)) {
|
|
62
226
|
try {
|
|
63
227
|
// recursive so subdirectory writes (e.g. backlog/tasks/*.md) fire on every platform
|
|
64
|
-
watcher = watch(backlogDir, { persistent: true, recursive: true },
|
|
228
|
+
watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
|
|
65
229
|
watcher.on('error', () => { }); // e.g. watched dir removed mid-session
|
|
66
230
|
}
|
|
67
231
|
catch {
|
|
@@ -71,10 +235,10 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
71
235
|
else {
|
|
72
236
|
console.warn('warning: live reload is disabled because Node 24+ on Windows cannot reliably watch directories recursively (libuv fs-event bug); use Node 22 or Linux/macOS for --serve');
|
|
73
237
|
}
|
|
74
|
-
const
|
|
238
|
+
const apiHandler = createApiHandler(cwd, broker);
|
|
75
239
|
const server = createServer((req, res) => {
|
|
76
240
|
if (req.url?.startsWith('/api/')) {
|
|
77
|
-
void
|
|
241
|
+
void apiHandler(req, res);
|
|
78
242
|
return;
|
|
79
243
|
}
|
|
80
244
|
const url = req.url ?? '/';
|
|
@@ -114,9 +278,8 @@ export async function startServeServer(cwd, opts = {}) {
|
|
|
114
278
|
server,
|
|
115
279
|
port,
|
|
116
280
|
close() {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
timer = null;
|
|
281
|
+
reloader.cancel();
|
|
282
|
+
broker.close();
|
|
120
283
|
watcher?.close();
|
|
121
284
|
watcher = null;
|
|
122
285
|
return new Promise((resolveClose) => {
|