super-backlog 1.0.0 → 1.1.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 -6
- package/dist/cli.js +29 -37
- package/dist/commands/backlog-alias.js +1 -4
- package/dist/commands/dashboard.js +181 -35
- package/dist/commands/uninstall.js +3 -6
- package/dist/commands/update.js +1 -2
- package/dist/dashboard/hub.js +263 -0
- package/dist/dashboard/server.js +19 -87
- package/dist/lib/hub-state.js +47 -0
- package/dist/lib/preflight.js +4 -13
- package/dist/lib/run.js +2 -5
- package/dist/lib/slug.js +25 -0
- package/dist/lib/version-check.js +95 -0
- package/dist/models/dashboard-api.js +3 -12
- package/dist/templates/dashboard.html +2 -2
- package/dist/templates/skill-backlog-status-report.md +1 -1
- package/package.json +6 -1
- package/dist/commands/serve.js +0 -3
package/README.md
CHANGED
|
@@ -52,8 +52,7 @@ After installation:
|
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
54
|
npm run board # open the Backlog.md kanban board
|
|
55
|
-
sbl
|
|
56
|
-
sbl dashboard --serve # live Project Dashboard on http://localhost:6428
|
|
55
|
+
sbl dashboard # live Project Dashboard on http://localhost:6428
|
|
57
56
|
```
|
|
58
57
|
|
|
59
58
|
`init` is idempotent — safe to re-run any time; re-running with a newer kit version is the upgrade path for all injected files.
|
|
@@ -74,7 +73,7 @@ sbl dashboard --serve # live Project Dashboard on http://localhost:6
|
|
|
74
73
|
| `dashboard.html` | generated Project Dashboard | not installed in user projects; generated on demand by `sbl dashboard` |
|
|
75
74
|
| `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
|
|
76
75
|
|
|
77
|
-
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]
|
|
76
|
+
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]`. See `sbl help` for every flag.
|
|
78
77
|
|
|
79
78
|
## Model router (opt-in)
|
|
80
79
|
|
|
@@ -92,17 +91,17 @@ When enabled:
|
|
|
92
91
|
|
|
93
92
|
- **OpenCode** — the plugin `sbl-model-router.js` rewrites `chat.params` for the `sbl-worker` (workhorse) and `sbl-worker-cheap` / `explore` (budget) agents.
|
|
94
93
|
- **Claude Code** — agent files carry a `model:` placeholder that is updated by a `SessionStart` hook based on your current main model.
|
|
95
|
-
- **Dashboard** — `sbl dashboard
|
|
94
|
+
- **Dashboard** — `sbl dashboard` exposes `/api/models` and `/api/models/discover`.
|
|
96
95
|
|
|
97
96
|
The router is fully owned by super-backlog and removed by `sbl uninstall`. See the [model router design](docs/superpowers/specs/2026-08-26-sbl-model-router-design.md) for details.
|
|
98
97
|
|
|
99
98
|
## Project Dashboard
|
|
100
99
|
|
|
101
|
-
`sbl dashboard` starts a local
|
|
100
|
+
`sbl dashboard` starts a local hub 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. Bookmark `http://127.0.0.1:6428/p/<project_name>/`. The hub watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. A second repo's `sbl dashboard` attaches to the same hub. `Ctrl+C` in the hub terminal stops all projects.
|
|
102
101
|
|
|
103
102
|
### Keeping it fresh
|
|
104
103
|
|
|
105
|
-
Run `sbl dashboard` whenever you want a live view of the board. The
|
|
104
|
+
Run `sbl dashboard` whenever you want a live view of the board. The hub regenerates the dashboard while it runs; stop it with `Ctrl+C` in the hub terminal. There is no static `dashboard.html` installed in your project.
|
|
106
105
|
|
|
107
106
|

|
|
108
107
|
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// src/cli.ts
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { parseArgs } from 'node:util';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
4
7
|
import process from 'node:process';
|
|
5
8
|
import { runDashboard } from './commands/dashboard.js';
|
|
6
|
-
import { runBacklogSubcommand } from './commands/backlog-alias.js';
|
|
7
9
|
import { runDoctor } from './commands/doctor.js';
|
|
8
10
|
import { runInit } from './commands/init.js';
|
|
9
11
|
import { runModels } from './commands/models.js';
|
|
10
|
-
import { runServe } from './commands/serve.js';
|
|
11
12
|
import { runUninstall } from './commands/uninstall.js';
|
|
12
13
|
import { runUpdate } from './commands/update.js';
|
|
13
14
|
import { assertNode20, KIT_VERSION } from './lib/version.js';
|
|
14
|
-
|
|
15
|
+
import { applyVersionHint, defaultFetchLatest } from './lib/version-check.js';
|
|
16
|
+
export const HELP = `super-backlog (sbl) - equip any project with Backlog.md + Superpowers
|
|
15
17
|
|
|
16
18
|
Usage: sbl <command> [options]
|
|
17
19
|
|
|
@@ -19,10 +21,7 @@ Commands:
|
|
|
19
21
|
init Install the kit into the current project
|
|
20
22
|
uninstall Remove kit-managed files (project data kept unless --with-backlog)
|
|
21
23
|
update Refresh kit-managed files and report upstream versions
|
|
22
|
-
dashboard Start the project dashboard server (live-reload
|
|
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)
|
|
24
|
+
dashboard Start the project dashboard server (live-reload)
|
|
26
25
|
models Manage the model router (show, enable, disable, discover)
|
|
27
26
|
doctor Check the environment (node, PowerShell policy, backlog CLI)
|
|
28
27
|
|
|
@@ -42,11 +41,7 @@ uninstall options:
|
|
|
42
41
|
update options:
|
|
43
42
|
(none) Refreshes injected files, skills, hook; prints upstream versions
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
--port <n> Port for the dashboard server (default: 6428)
|
|
47
|
-
--no-open Do not open the dashboard browser automatically
|
|
48
|
-
|
|
49
|
-
serve options:
|
|
44
|
+
dashboard options:
|
|
50
45
|
--port <n> Port for the dashboard server (default: 6428)
|
|
51
46
|
--no-open Do not open the dashboard browser automatically
|
|
52
47
|
|
|
@@ -59,7 +54,7 @@ Global options:
|
|
|
59
54
|
Exit codes:
|
|
60
55
|
0 ok | 1 usage/detection failure | 2 ownership or merge refusal
|
|
61
56
|
3 upstream command failure | 4 success with warnings`;
|
|
62
|
-
async function
|
|
57
|
+
export async function runCli(argv) {
|
|
63
58
|
const [command, ...rest] = argv;
|
|
64
59
|
if (command === '--version' || command === '-v') {
|
|
65
60
|
console.log(KIT_VERSION);
|
|
@@ -69,6 +64,13 @@ async function main(argv) {
|
|
|
69
64
|
console.log(HELP);
|
|
70
65
|
return 0;
|
|
71
66
|
}
|
|
67
|
+
void applyVersionHint(KIT_VERSION, {
|
|
68
|
+
home: homedir(),
|
|
69
|
+
now: () => new Date(),
|
|
70
|
+
fetchLatest: defaultFetchLatest,
|
|
71
|
+
log: (line) => console.error(line),
|
|
72
|
+
env: { ...process.env, SBL_SKIP_UPDATE_CHECK: process.env.SBL_SKIP_UPDATE_CHECK },
|
|
73
|
+
});
|
|
72
74
|
switch (command) {
|
|
73
75
|
case 'init': {
|
|
74
76
|
const parsed = parseArgs({
|
|
@@ -121,24 +123,11 @@ async function main(argv) {
|
|
|
121
123
|
positionals: parsed.positionals,
|
|
122
124
|
});
|
|
123
125
|
}
|
|
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
|
-
}
|
|
126
|
+
case 'serve':
|
|
138
127
|
case 'browser':
|
|
139
|
-
return await runBacklogSubcommand(process.cwd(), 'browser', rest);
|
|
140
128
|
case 'board':
|
|
141
|
-
|
|
129
|
+
console.error(`error: "sbl ${command}" was removed; the live dashboard is \`sbl dashboard\``);
|
|
130
|
+
return 1;
|
|
142
131
|
case 'models': {
|
|
143
132
|
const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
|
|
144
133
|
return await runModels(process.cwd(), {
|
|
@@ -155,11 +144,14 @@ async function main(argv) {
|
|
|
155
144
|
}
|
|
156
145
|
}
|
|
157
146
|
assertNode20();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
process.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
const entry = process.argv[1];
|
|
148
|
+
if (entry && fileURLToPath(import.meta.url) === resolve(entry)) {
|
|
149
|
+
runCli(process.argv.slice(2))
|
|
150
|
+
.then((code) => {
|
|
151
|
+
process.exitCode = code;
|
|
152
|
+
})
|
|
153
|
+
.catch((err) => {
|
|
154
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import process from 'node:process';
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
4
2
|
import { resolveBacklogBin } from '../lib/run.js';
|
|
5
3
|
/** Run a backlog.md subcommand by delegating to the resolved backlog binary. */
|
|
6
4
|
export function runBacklogSubcommand(cwd, subcommand, args = []) {
|
|
@@ -13,7 +11,6 @@ export function runBacklogSubcommand(cwd, subcommand, args = []) {
|
|
|
13
11
|
const child = spawn(bin, [subcommand, ...args], {
|
|
14
12
|
cwd,
|
|
15
13
|
stdio: 'inherit',
|
|
16
|
-
shell: process.platform === 'win32',
|
|
17
14
|
});
|
|
18
15
|
child.on('error', () => resolve(1));
|
|
19
16
|
child.on('exit', (code) => resolve(code ?? 1));
|
|
@@ -1,42 +1,121 @@
|
|
|
1
|
-
// src/commands/dashboard.ts
|
|
2
1
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
3
|
+
import { homedir as osHomedir, tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { collectDashboardData } from '../dashboard/data.js';
|
|
7
|
+
import { startHubServer } from '../dashboard/hub.js';
|
|
7
8
|
import { renderDashboard } from '../dashboard/render.js';
|
|
8
|
-
import { DASHBOARD_PORT
|
|
9
|
+
import { DASHBOARD_PORT } from '../dashboard/server.js';
|
|
9
10
|
import { atomicWrite } from '../lib/atomic.js';
|
|
10
|
-
import {
|
|
11
|
+
import { clearHubState, isPidAlive, newHubToken, readHubState, writeHubState } from '../lib/hub-state.js';
|
|
12
|
+
import { projectSlug } from '../lib/slug.js';
|
|
11
13
|
import { KIT_VERSION } from '../lib/version.js';
|
|
12
14
|
async function regenerateInto(outPath, cwd) {
|
|
13
15
|
const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
|
|
14
16
|
atomicWrite(outPath, renderDashboard(data));
|
|
15
17
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
function defaultOpenBrowser(url) {
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' })
|
|
22
|
+
.on('error', () => { })
|
|
23
|
+
.unref();
|
|
24
|
+
}
|
|
25
|
+
else if (process.platform === 'darwin') {
|
|
26
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' })
|
|
27
|
+
.on('error', () => { })
|
|
28
|
+
.unref();
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' })
|
|
32
|
+
.on('error', () => { })
|
|
33
|
+
.unref();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
21
37
|
}
|
|
38
|
+
}
|
|
39
|
+
function defaultAttach(url, body) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
43
|
+
const req = httpRequest({
|
|
44
|
+
host: u.hostname,
|
|
45
|
+
port: u.port,
|
|
46
|
+
path: `${u.pathname}${u.search}`,
|
|
47
|
+
method: payload === undefined ? 'GET' : 'POST',
|
|
48
|
+
headers: payload === undefined
|
|
49
|
+
? {}
|
|
50
|
+
: {
|
|
51
|
+
'content-type': 'application/json',
|
|
52
|
+
'content-length': Buffer.byteLength(payload),
|
|
53
|
+
},
|
|
54
|
+
}, (res) => {
|
|
55
|
+
let b = '';
|
|
56
|
+
res.setEncoding('utf8');
|
|
57
|
+
res.on('data', (c) => {
|
|
58
|
+
b += c;
|
|
59
|
+
});
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
let json = b;
|
|
62
|
+
try {
|
|
63
|
+
json = JSON.parse(b);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
}
|
|
67
|
+
resolve({ status: res.statusCode ?? 0, json });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
if (payload !== undefined)
|
|
72
|
+
req.write(payload);
|
|
73
|
+
req.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function isEaddrinuse(err) {
|
|
77
|
+
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
|
|
78
|
+
}
|
|
79
|
+
function waitForClose(hub) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
hub.server.once('close', () => resolve());
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function attachToHub(opts) {
|
|
85
|
+
let res;
|
|
22
86
|
try {
|
|
23
|
-
|
|
24
|
-
cwd,
|
|
25
|
-
|
|
26
|
-
stdio: 'ignore',
|
|
27
|
-
shell: process.platform === 'win32',
|
|
87
|
+
res = await opts.attach(`http://127.0.0.1:${opts.port}/api/hub/register`, {
|
|
88
|
+
cwd: opts.cwd,
|
|
89
|
+
token: opts.token,
|
|
28
90
|
});
|
|
29
|
-
child.on('error', () => { });
|
|
30
|
-
child.unref();
|
|
31
|
-
console.log('started Backlog browser (dashboard still serves if browser fails)');
|
|
32
91
|
}
|
|
33
|
-
catch {
|
|
34
|
-
console.
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
if (res.status === 401) {
|
|
97
|
+
console.error('error: hub token mismatch; stop the other hub or delete stale hub.json');
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (res.status === 409) {
|
|
101
|
+
const json = res.json;
|
|
102
|
+
console.error(`error: slug collision between ${json.existingCwd ?? ''} and ${json.incomingCwd ?? ''}; change project_name in one backlog/config.yml`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (res.status !== 200) {
|
|
106
|
+
console.error(`error: dashboard serve failed (register ${res.status})`);
|
|
107
|
+
return 1;
|
|
35
108
|
}
|
|
109
|
+
const json = res.json;
|
|
110
|
+
if (json.ok !== true || typeof json.url !== 'string') {
|
|
111
|
+
console.error('error: dashboard serve failed (invalid register response)');
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
if (!opts.noOpen)
|
|
115
|
+
opts.openBrowser(json.url);
|
|
116
|
+
return 0;
|
|
36
117
|
}
|
|
37
|
-
|
|
38
|
-
* that watches backlog/ and regenerates a temp dashboard file on changes. */
|
|
39
|
-
export async function runDashboard(cwd, args) {
|
|
118
|
+
export async function runDashboard(cwd, args, deps = {}) {
|
|
40
119
|
const values = args.values;
|
|
41
120
|
let port = DASHBOARD_PORT;
|
|
42
121
|
if (values['port'] !== undefined) {
|
|
@@ -48,23 +127,90 @@ export async function runDashboard(cwd, args) {
|
|
|
48
127
|
port = parsed;
|
|
49
128
|
}
|
|
50
129
|
const noOpen = values['no-open'] === true;
|
|
51
|
-
const
|
|
130
|
+
const home = (deps.homedir ?? osHomedir)();
|
|
131
|
+
const startHub = deps.startHub ?? startHubServer;
|
|
132
|
+
const attach = deps.attach ?? defaultAttach;
|
|
133
|
+
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
|
|
134
|
+
const pid = (deps.nowPid ?? (() => process.pid))();
|
|
135
|
+
let slugResult;
|
|
52
136
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
137
|
+
slugResult = projectSlug(cwd);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
console.error('error: set project_name in backlog/config.yml');
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
if (!slugResult.ok) {
|
|
144
|
+
console.error('error: set project_name in backlog/config.yml');
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const slug = slugResult.slug;
|
|
148
|
+
const state = readHubState(home);
|
|
149
|
+
if (state !== null && isPidAlive(state.pid)) {
|
|
150
|
+
try {
|
|
151
|
+
const status = await attach(`http://127.0.0.1:${state.port}/api/hub/status?token=${encodeURIComponent(state.token)}`, undefined);
|
|
152
|
+
if (status.status === 200) {
|
|
153
|
+
return await attachToHub({
|
|
154
|
+
cwd,
|
|
155
|
+
port: state.port,
|
|
156
|
+
token: state.token,
|
|
157
|
+
attach,
|
|
158
|
+
openBrowser,
|
|
159
|
+
noOpen,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (values['port'] !== undefined) {
|
|
167
|
+
console.warn('warning: default bookmarks (:6428) will miss this hub');
|
|
168
|
+
}
|
|
169
|
+
const token = newHubToken();
|
|
170
|
+
const outPath = join(tmpdir(), `sbl-dashboard-${Date.now()}-${slug}.html`);
|
|
171
|
+
const regenerate = () => regenerateInto(outPath, cwd);
|
|
172
|
+
let hub;
|
|
173
|
+
try {
|
|
174
|
+
await regenerate();
|
|
175
|
+
hub = await startHub({ port, token });
|
|
65
176
|
}
|
|
66
177
|
catch (err) {
|
|
178
|
+
if (isEaddrinuse(err)) {
|
|
179
|
+
console.error(`error: port ${port} is in use`);
|
|
180
|
+
console.error('hint: pass --port as emergency only');
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
67
183
|
console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
|
|
68
184
|
return 1;
|
|
69
185
|
}
|
|
186
|
+
writeHubState(home, { pid, port: hub.port, token });
|
|
187
|
+
const result = hub.register({ cwd, file: outPath, regenerate });
|
|
188
|
+
if (!result.ok) {
|
|
189
|
+
await hub.close();
|
|
190
|
+
clearHubState(home, pid);
|
|
191
|
+
if (result.code === 409) {
|
|
192
|
+
console.error(`error: slug collision between ${result.existingCwd} and ${result.incomingCwd}; change project_name in one backlog/config.yml`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
console.error(`error: dashboard serve failed (${result.message})`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
console.log(`dashboard written: ${outPath}`);
|
|
199
|
+
console.log(`serving dashboard at ${result.url} (press Ctrl+C to stop)`);
|
|
200
|
+
if (!noOpen)
|
|
201
|
+
openBrowser(result.url);
|
|
202
|
+
const onSignal = () => {
|
|
203
|
+
void hub.close();
|
|
204
|
+
};
|
|
205
|
+
process.once('SIGINT', onSignal);
|
|
206
|
+
process.once('SIGTERM', onSignal);
|
|
207
|
+
try {
|
|
208
|
+
await waitForClose(hub);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
process.removeListener('SIGINT', onSignal);
|
|
213
|
+
process.removeListener('SIGTERM', onSignal);
|
|
214
|
+
clearHubState(home, pid);
|
|
215
|
+
}
|
|
70
216
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
3
2
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
|
-
import process from 'node:process';
|
|
6
4
|
import { findGitDir, POINTER_HEADING_RE } from '../init/execute.js';
|
|
7
5
|
import { atomicWrite } from '../lib/atomic.js';
|
|
8
6
|
import { GUARD_RE, REFRESH_RE, removeGuardHook, removeRefreshHook } from '../lib/hooks.js';
|
|
@@ -195,11 +193,10 @@ export function verifyRemnants(cwd) {
|
|
|
195
193
|
return remnants;
|
|
196
194
|
}
|
|
197
195
|
function defaultRemoveGlobal() {
|
|
198
|
-
const cmd =
|
|
199
|
-
const r =
|
|
196
|
+
const cmd = 'npm';
|
|
197
|
+
const r = spawn.sync(cmd, ['uninstall', '-g', 'super-backlog'], {
|
|
200
198
|
encoding: 'utf8',
|
|
201
199
|
windowsHide: true,
|
|
202
|
-
shell: process.platform === 'win32',
|
|
203
200
|
});
|
|
204
201
|
if (r.error)
|
|
205
202
|
return 1;
|
package/dist/commands/update.js
CHANGED
|
@@ -100,13 +100,12 @@ export async function runUpdate(cwd, _args) {
|
|
|
100
100
|
warnings.push(`\`${bin} --version\` failed with exit code ${local.status}`);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
-
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
104
103
|
let published = null;
|
|
105
104
|
try {
|
|
106
105
|
// test seam: SBL_FORCE_OFFLINE makes e2e runs take the offline path deterministically
|
|
107
106
|
if (process.env.SBL_FORCE_OFFLINE)
|
|
108
107
|
throw new Error('forced offline');
|
|
109
|
-
const view = runCapture(npm, ['view', 'backlog.md', 'version'], cwd);
|
|
108
|
+
const view = runCapture('npm', ['view', 'backlog.md', 'version'], cwd);
|
|
110
109
|
if (view.status === 0)
|
|
111
110
|
published = firstLine(view.stdout);
|
|
112
111
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { collectDashboardData } from './data.js';
|
|
8
|
+
import { renderDashboard } from './render.js';
|
|
9
|
+
import { createDebouncedReloader, createReloadBroker, createRunApiHandler, DASHBOARD_PORT, recursiveWatchSupported, } from './server.js';
|
|
10
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
11
|
+
import { projectSlug, realpathKey } from '../lib/slug.js';
|
|
12
|
+
import { KIT_VERSION } from '../lib/version.js';
|
|
13
|
+
import { createModelApiHandler } from '../models/dashboard-api.js';
|
|
14
|
+
const WATCH_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 live reload';
|
|
15
|
+
function watchBacklog(cwd, reloader) {
|
|
16
|
+
const backlogDir = join(cwd, 'backlog');
|
|
17
|
+
if (recursiveWatchSupported(process.platform, process.versions.node)) {
|
|
18
|
+
try {
|
|
19
|
+
const watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
|
|
20
|
+
watcher.on('error', () => { });
|
|
21
|
+
return watcher;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
console.warn(WATCH_WARN);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function projectUrl(port, slug) {
|
|
31
|
+
return `http://127.0.0.1:${port}/p/${slug}/`;
|
|
32
|
+
}
|
|
33
|
+
async function readBody(req) {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
for await (const chunk of req) {
|
|
36
|
+
chunks.push(Buffer.from(chunk));
|
|
37
|
+
}
|
|
38
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
function sendJson(res, status, body) {
|
|
41
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
42
|
+
res.end(JSON.stringify(body));
|
|
43
|
+
}
|
|
44
|
+
function sendText(res, status, body) {
|
|
45
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
46
|
+
res.end(body);
|
|
47
|
+
}
|
|
48
|
+
function serveFile(file, res) {
|
|
49
|
+
readFile(file)
|
|
50
|
+
.then((bytes) => {
|
|
51
|
+
res.writeHead(200, {
|
|
52
|
+
'content-type': 'text/html; charset=utf-8',
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
});
|
|
55
|
+
res.end(bytes);
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
sendText(res, 404, 'dashboard not generated yet');
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function generateDashboard(cwd, file) {
|
|
62
|
+
const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
|
|
63
|
+
atomicWrite(file, renderDashboard(data));
|
|
64
|
+
}
|
|
65
|
+
export async function startHubServer(opts) {
|
|
66
|
+
const projects = new Map();
|
|
67
|
+
const token = opts.token;
|
|
68
|
+
let port = 0;
|
|
69
|
+
function disposeEntry(entry) {
|
|
70
|
+
entry.reloader.cancel();
|
|
71
|
+
entry.broker.close();
|
|
72
|
+
entry.watcher?.close();
|
|
73
|
+
}
|
|
74
|
+
function register(project) {
|
|
75
|
+
let computed;
|
|
76
|
+
try {
|
|
77
|
+
computed = projectSlug(project.cwd);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { ok: false, code: 400, message: 'invalid cwd' };
|
|
81
|
+
}
|
|
82
|
+
if (!computed.ok) {
|
|
83
|
+
return { ok: false, code: 400, message: 'empty slug' };
|
|
84
|
+
}
|
|
85
|
+
const slug = project.slug ?? computed.slug;
|
|
86
|
+
if (slug === '') {
|
|
87
|
+
return { ok: false, code: 400, message: 'empty slug' };
|
|
88
|
+
}
|
|
89
|
+
let key;
|
|
90
|
+
try {
|
|
91
|
+
key = realpathKey(project.cwd);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { ok: false, code: 400, message: 'invalid cwd' };
|
|
95
|
+
}
|
|
96
|
+
const existing = projects.get(slug);
|
|
97
|
+
if (existing && existing.realpath !== key) {
|
|
98
|
+
return { ok: false, code: 409, existingCwd: existing.cwd, incomingCwd: project.cwd };
|
|
99
|
+
}
|
|
100
|
+
const url = projectUrl(port, slug);
|
|
101
|
+
if (existing && existing.realpath === key) {
|
|
102
|
+
existing.cwd = project.cwd;
|
|
103
|
+
existing.file = project.file;
|
|
104
|
+
existing.reloader.cancel();
|
|
105
|
+
existing.watcher?.close();
|
|
106
|
+
existing.reloader = createDebouncedReloader(project.regenerate, () => existing.broker.broadcast('reload'), 300);
|
|
107
|
+
existing.watcher = watchBacklog(project.cwd, existing.reloader);
|
|
108
|
+
existing.modelApi = createModelApiHandler(project.cwd);
|
|
109
|
+
return { ok: true, slug, url };
|
|
110
|
+
}
|
|
111
|
+
const broker = createReloadBroker();
|
|
112
|
+
const reloader = createDebouncedReloader(project.regenerate, () => broker.broadcast('reload'), 300);
|
|
113
|
+
const entry = {
|
|
114
|
+
cwd: project.cwd,
|
|
115
|
+
realpath: key,
|
|
116
|
+
file: project.file,
|
|
117
|
+
broker,
|
|
118
|
+
reloader,
|
|
119
|
+
watcher: watchBacklog(project.cwd, reloader),
|
|
120
|
+
runApi: createRunApiHandler(project.cwd),
|
|
121
|
+
modelApi: createModelApiHandler(project.cwd),
|
|
122
|
+
};
|
|
123
|
+
projects.set(slug, entry);
|
|
124
|
+
return { ok: true, slug, url };
|
|
125
|
+
}
|
|
126
|
+
async function handle(req, res) {
|
|
127
|
+
const method = req.method ?? 'GET';
|
|
128
|
+
const parsed = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
129
|
+
const pathname = parsed.pathname;
|
|
130
|
+
if (pathname === '/' && method === 'GET') {
|
|
131
|
+
const links = [...projects.keys()]
|
|
132
|
+
.map((s) => `<li><a href="/p/${s}/">${s}</a></li>`)
|
|
133
|
+
.join('\n');
|
|
134
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
135
|
+
res.end(`<!doctype html><title>sbl hub</title><ul>${links}</ul>`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (pathname === '/api/hub/status' && method === 'GET') {
|
|
139
|
+
if (parsed.searchParams.get('token') !== token) {
|
|
140
|
+
sendText(res, 401, 'unauthorized');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
sendJson(res, 200, { pid: process.pid, port });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (pathname === '/api/hub/register' && method === 'POST') {
|
|
147
|
+
let body;
|
|
148
|
+
try {
|
|
149
|
+
body = await readBody(req);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
sendJson(res, 400, { error: 'failed to read body' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let payload;
|
|
156
|
+
try {
|
|
157
|
+
payload = JSON.parse(body);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
sendJson(res, 400, { error: 'invalid json' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
164
|
+
sendJson(res, 400, { error: 'invalid json' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const rec = payload;
|
|
168
|
+
if (rec.token !== token) {
|
|
169
|
+
sendText(res, 401, 'unauthorized');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (typeof rec.cwd !== 'string') {
|
|
173
|
+
sendJson(res, 400, { ok: false, code: 400, message: 'cwd required' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const cwd = rec.cwd;
|
|
177
|
+
const slugResult = (() => {
|
|
178
|
+
try {
|
|
179
|
+
return projectSlug(cwd);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return { ok: false, reason: 'empty' };
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
const slug = slugResult.ok ? slugResult.slug : 'project';
|
|
186
|
+
const file = join(tmpdir(), `sbl-dashboard-${Date.now()}-${slug}.html`);
|
|
187
|
+
const regenerate = () => generateDashboard(cwd, file);
|
|
188
|
+
try {
|
|
189
|
+
regenerate();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// still register; GET may 404 until a later refresh
|
|
193
|
+
}
|
|
194
|
+
const result = register({ cwd, file, regenerate });
|
|
195
|
+
sendJson(res, result.ok ? 200 : result.code, result);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const scoped = /^\/p\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
199
|
+
if (!scoped) {
|
|
200
|
+
sendText(res, 404, 'not found');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const slug = scoped[1] ?? '';
|
|
204
|
+
const rest = scoped[2];
|
|
205
|
+
const entry = projects.get(slug);
|
|
206
|
+
if (!entry) {
|
|
207
|
+
sendText(res, 404, 'not found');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (rest === undefined) {
|
|
211
|
+
res.writeHead(302, { location: `/p/${slug}/` });
|
|
212
|
+
res.end();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (rest.startsWith('/api/')) {
|
|
216
|
+
req.url = rest;
|
|
217
|
+
if (rest === '/api/run') {
|
|
218
|
+
await entry.runApi(req, res);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (entry.broker.handler(req, res)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
await entry.modelApi(req, res);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (method === 'GET' && (rest === '/' || rest === '/index.html')) {
|
|
228
|
+
serveFile(entry.file, res);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
sendText(res, 404, 'not found');
|
|
232
|
+
}
|
|
233
|
+
const server = createServer((req, res) => {
|
|
234
|
+
void handle(req, res);
|
|
235
|
+
});
|
|
236
|
+
const requestedPort = opts.port ?? DASHBOARD_PORT;
|
|
237
|
+
port = await new Promise((resolvePort, rejectPort) => {
|
|
238
|
+
server.once('error', rejectPort);
|
|
239
|
+
server.listen(requestedPort, '127.0.0.1', () => {
|
|
240
|
+
const addr = server.address();
|
|
241
|
+
if (addr !== null && typeof addr === 'object')
|
|
242
|
+
resolvePort(addr.port);
|
|
243
|
+
else
|
|
244
|
+
resolvePort(requestedPort);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
server,
|
|
249
|
+
port,
|
|
250
|
+
register,
|
|
251
|
+
triggerReload(slug) {
|
|
252
|
+
projects.get(slug)?.reloader.trigger();
|
|
253
|
+
},
|
|
254
|
+
close() {
|
|
255
|
+
for (const entry of projects.values())
|
|
256
|
+
disposeEntry(entry);
|
|
257
|
+
projects.clear();
|
|
258
|
+
return new Promise((resolveClose) => {
|
|
259
|
+
server.close(() => resolveClose());
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
// src/dashboard/server.ts
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { watch } from 'node:fs';
|
|
4
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
-
import { createServer } from 'node:http';
|
|
6
3
|
import { isAbsolute, join } from 'node:path';
|
|
7
4
|
import process from 'node:process';
|
|
8
|
-
import
|
|
5
|
+
import crossSpawn from 'cross-spawn';
|
|
9
6
|
import { resolveBacklogBin } from '../lib/run.js';
|
|
10
7
|
export const DASHBOARD_PORT = 6428;
|
|
11
8
|
const WHITELIST = new Map([
|
|
@@ -66,11 +63,10 @@ export function createRunApiHandler(cwd) {
|
|
|
66
63
|
}
|
|
67
64
|
const args = WHITELIST.get(payload.command);
|
|
68
65
|
try {
|
|
69
|
-
const child =
|
|
66
|
+
const child = crossSpawn(bin, args, {
|
|
70
67
|
cwd,
|
|
71
68
|
detached: true,
|
|
72
69
|
stdio: 'ignore',
|
|
73
|
-
shell: process.platform === 'win32',
|
|
74
70
|
});
|
|
75
71
|
child.on('error', () => { });
|
|
76
72
|
child.unref();
|
|
@@ -167,21 +163,6 @@ export function createDebouncedReloader(regenerate, onReload, delayMs) {
|
|
|
167
163
|
}
|
|
168
164
|
return { trigger, cancel };
|
|
169
165
|
}
|
|
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
|
-
}
|
|
185
166
|
export function recursiveWatchSupported(platform, nodeVersion) {
|
|
186
167
|
// Node 24 on Windows triggers a libuv assertion in recursive fs.watch:
|
|
187
168
|
// https://github.com/nodejs/node/issues/xxx (fs-event.c line 72)
|
|
@@ -211,80 +192,31 @@ function openInBrowser(url) {
|
|
|
211
192
|
}
|
|
212
193
|
}
|
|
213
194
|
/**
|
|
214
|
-
* Serve the latest dashboard bytes
|
|
215
|
-
*
|
|
195
|
+
* Serve the latest dashboard bytes via the hub at `/p/<slug>/`.
|
|
196
|
+
* Pass port 0 for an ephemeral port (tests).
|
|
216
197
|
*/
|
|
217
198
|
export async function startServeServer(cwd, opts = {}) {
|
|
199
|
+
const { startHubServer } = await import('./hub.js');
|
|
218
200
|
const file = opts.file ?? 'dashboard.html';
|
|
219
201
|
const filePath = isAbsolute(file) ? file : join(cwd, file);
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (recursiveWatchSupported(process.platform, process.versions.node)) {
|
|
226
|
-
try {
|
|
227
|
-
// recursive so subdirectory writes (e.g. backlog/tasks/*.md) fire on every platform
|
|
228
|
-
watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
|
|
229
|
-
watcher.on('error', () => { }); // e.g. watched dir removed mid-session
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
watcher = null; // no backlog dir -> no live reload; serving still works
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
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');
|
|
237
|
-
}
|
|
238
|
-
const apiHandler = createApiHandler(cwd, broker);
|
|
239
|
-
const server = createServer((req, res) => {
|
|
240
|
-
if (req.url?.startsWith('/api/')) {
|
|
241
|
-
void apiHandler(req, res);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const url = req.url ?? '/';
|
|
245
|
-
const method = req.method ?? 'GET';
|
|
246
|
-
if (method !== 'GET' || !(url === '/' || url === '/index.html')) {
|
|
247
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
248
|
-
res.end('not found');
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
readFile(filePath)
|
|
252
|
-
.then((bytes) => {
|
|
253
|
-
res.writeHead(200, {
|
|
254
|
-
'content-type': 'text/html; charset=utf-8',
|
|
255
|
-
'cache-control': 'no-store',
|
|
256
|
-
});
|
|
257
|
-
res.end(bytes);
|
|
258
|
-
})
|
|
259
|
-
.catch(() => {
|
|
260
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
261
|
-
res.end('dashboard not generated yet');
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
const requestedPort = opts.port ?? DASHBOARD_PORT;
|
|
265
|
-
const port = await new Promise((resolvePort, rejectPort) => {
|
|
266
|
-
server.once('error', rejectPort);
|
|
267
|
-
server.listen(requestedPort, '127.0.0.1', () => {
|
|
268
|
-
const addr = server.address();
|
|
269
|
-
if (addr !== null && typeof addr === 'object')
|
|
270
|
-
resolvePort(addr.port);
|
|
271
|
-
else
|
|
272
|
-
resolvePort(requestedPort);
|
|
273
|
-
});
|
|
202
|
+
const hub = await startHubServer({ port: opts.port ?? DASHBOARD_PORT, token: 'serve' });
|
|
203
|
+
const result = hub.register({
|
|
204
|
+
cwd,
|
|
205
|
+
file: filePath,
|
|
206
|
+
regenerate: opts.regenerate ?? (() => { }),
|
|
274
207
|
});
|
|
208
|
+
if (!result.ok) {
|
|
209
|
+
await hub.close();
|
|
210
|
+
const message = result.code === 400 ? result.message : `register failed (${result.code})`;
|
|
211
|
+
throw new Error(message);
|
|
212
|
+
}
|
|
275
213
|
if (opts.openBrowser)
|
|
276
|
-
openInBrowser(
|
|
214
|
+
openInBrowser(result.url);
|
|
277
215
|
return {
|
|
278
|
-
server,
|
|
279
|
-
port,
|
|
216
|
+
server: hub.server,
|
|
217
|
+
port: hub.port,
|
|
280
218
|
close() {
|
|
281
|
-
|
|
282
|
-
broker.close();
|
|
283
|
-
watcher?.close();
|
|
284
|
-
watcher = null;
|
|
285
|
-
return new Promise((resolveClose) => {
|
|
286
|
-
server.close(() => resolveClose());
|
|
287
|
-
});
|
|
219
|
+
return hub.close();
|
|
288
220
|
},
|
|
289
221
|
};
|
|
290
222
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { atomicWrite } from './atomic.js';
|
|
6
|
+
export function hubStatePath(home) {
|
|
7
|
+
return join(home, '.super-backlog', 'hub.json');
|
|
8
|
+
}
|
|
9
|
+
export function readHubState(home) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(readFileSync(hubStatePath(home), 'utf8'));
|
|
12
|
+
if (parsed === null ||
|
|
13
|
+
typeof parsed !== 'object' ||
|
|
14
|
+
typeof parsed.pid !== 'number' ||
|
|
15
|
+
typeof parsed.port !== 'number' ||
|
|
16
|
+
typeof parsed.token !== 'string') {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const { pid, port, token } = parsed;
|
|
20
|
+
return { pid, port, token };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function writeHubState(home, state) {
|
|
27
|
+
mkdirSync(join(home, '.super-backlog'), { recursive: true });
|
|
28
|
+
atomicWrite(hubStatePath(home), JSON.stringify(state));
|
|
29
|
+
}
|
|
30
|
+
export function clearHubState(home, pid) {
|
|
31
|
+
const current = readHubState(home);
|
|
32
|
+
if (current === null || current.pid !== pid)
|
|
33
|
+
return;
|
|
34
|
+
rmSync(hubStatePath(home), { force: true });
|
|
35
|
+
}
|
|
36
|
+
export function isPidAlive(pid) {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function newHubToken() {
|
|
46
|
+
return randomBytes(16).toString('hex');
|
|
47
|
+
}
|
package/dist/lib/preflight.js
CHANGED
|
@@ -3,19 +3,17 @@
|
|
|
3
3
|
// System-changing fixes (node install, execution policy) require consent or fixAll;
|
|
4
4
|
// safe fixes run unconditionally. Every fix is verified and reports a manual
|
|
5
5
|
// fallback command on failure.
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
6
|
import { existsSync } from 'node:fs';
|
|
8
7
|
import { delimiter, join } from 'node:path';
|
|
9
8
|
import process from 'node:process';
|
|
9
|
+
import spawn from 'cross-spawn';
|
|
10
10
|
import { getEffectiveExecutionPolicy, isBlockingExecutionPolicy, } from './powershell.js';
|
|
11
11
|
import { resolveBacklogBin } from './run.js';
|
|
12
12
|
const defaultExecutor = (cmd, args) => {
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
const r = spawnSync(cmd, args, {
|
|
13
|
+
// cross-spawn resolves .cmd shims on Windows without shell: true.
|
|
14
|
+
const r = spawn.sync(cmd, args, {
|
|
16
15
|
encoding: 'utf8',
|
|
17
16
|
windowsHide: true,
|
|
18
|
-
shell: process.platform === 'win32',
|
|
19
17
|
});
|
|
20
18
|
if (r.error)
|
|
21
19
|
return { status: null, stdout: '', stderr: String(r.error.message) };
|
|
@@ -99,13 +97,6 @@ function checkNpmCommand(ctx) {
|
|
|
99
97
|
ctx.npmCmd = 'npm';
|
|
100
98
|
return { id, status: 'ok', detail: `npm ${probe.stdout.trim()}` };
|
|
101
99
|
}
|
|
102
|
-
if (ctx.platform === 'win32') {
|
|
103
|
-
const fallback = ctx.executor('npm.cmd', ['--version']);
|
|
104
|
-
if (fallback.status === 0) {
|
|
105
|
-
ctx.npmCmd = 'npm.cmd';
|
|
106
|
-
return { id, status: 'fixed', detail: 'npm not directly callable; using npm.cmd shim' };
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
100
|
return {
|
|
110
101
|
id,
|
|
111
102
|
status: 'failed',
|
|
@@ -206,7 +197,7 @@ export function runPreflight(cwd, deps = {}) {
|
|
|
206
197
|
log: deps.log ?? ((line) => console.log(line)),
|
|
207
198
|
confirm: deps.confirm,
|
|
208
199
|
fixAll: deps.fixAll ?? false,
|
|
209
|
-
npmCmd:
|
|
200
|
+
npmCmd: 'npm',
|
|
210
201
|
};
|
|
211
202
|
const unitNames = [
|
|
212
203
|
['node-version', checkNodeVersion],
|
package/dist/lib/run.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import process from 'node:process';
|
|
4
|
+
import spawn from 'cross-spawn';
|
|
5
5
|
export function runCapture(cmd, args, cwd) {
|
|
6
|
-
|
|
7
|
-
const winShell = process.platform === 'win32';
|
|
8
|
-
const file = winShell && /\s/.test(cmd) ? `"${cmd}"` : cmd;
|
|
9
|
-
const r = spawnSync(file, args, { cwd, encoding: 'utf8', shell: winShell });
|
|
6
|
+
const r = spawn.sync(cmd, args, { cwd, encoding: 'utf8' });
|
|
10
7
|
if (r.error && (r.status === null || r.status === undefined)) {
|
|
11
8
|
return { status: 127, stdout: '', stderr: String(r.error.message) };
|
|
12
9
|
}
|
package/dist/lib/slug.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { readSimpleKeys } from './yamlmini.js';
|
|
5
|
+
export function realpathKey(cwd) {
|
|
6
|
+
const resolved = realpathSync(cwd);
|
|
7
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
8
|
+
}
|
|
9
|
+
export function sanitizeSlug(raw) {
|
|
10
|
+
const nk = raw.normalize('NFKD').replace(/\p{M}/gu, '');
|
|
11
|
+
return nk
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[_\s]+/g, '-')
|
|
14
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
15
|
+
.replace(/-+/g, '-')
|
|
16
|
+
.replace(/^-|-$/g, '');
|
|
17
|
+
}
|
|
18
|
+
export function projectSlug(cwd) {
|
|
19
|
+
const cfg = readSimpleKeys(join(cwd, 'backlog', 'config.yml'), ['project_name']);
|
|
20
|
+
const raw = (cfg.project_name && cfg.project_name.trim() !== '' ? cfg.project_name : basename(realpathSync(cwd)));
|
|
21
|
+
const slug = sanitizeSlug(raw);
|
|
22
|
+
if (slug === '')
|
|
23
|
+
return { ok: false, reason: 'empty' };
|
|
24
|
+
return { ok: true, slug };
|
|
25
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/lib/version-check.ts
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { runCapture } from './run.js';
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const FETCH_TIMEOUT_MS = 2000;
|
|
8
|
+
function cachePath(home) {
|
|
9
|
+
return join(home, '.super-backlog', 'version-check.json');
|
|
10
|
+
}
|
|
11
|
+
function isNewer(latest, installed) {
|
|
12
|
+
const a = latest.split('.').slice(0, 3).map(Number);
|
|
13
|
+
const b = installed.split('.').slice(0, 3).map(Number);
|
|
14
|
+
if (a.length < 3 || b.length < 3)
|
|
15
|
+
return false;
|
|
16
|
+
if (a.some((n) => !Number.isFinite(n)) || b.some((n) => !Number.isFinite(n)))
|
|
17
|
+
return false;
|
|
18
|
+
for (let i = 0; i < 3; i++) {
|
|
19
|
+
if (a[i] > b[i])
|
|
20
|
+
return true;
|
|
21
|
+
if (a[i] < b[i])
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
function readCache(home) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(cachePath(home), 'utf8'));
|
|
29
|
+
if (!parsed || typeof parsed !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
const rec = parsed;
|
|
32
|
+
if (typeof rec.checkedAt !== 'string' || typeof rec.latest !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
return { checkedAt: rec.checkedAt, latest: rec.latest };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function writeCache(home, cache) {
|
|
41
|
+
mkdirSync(join(home, '.super-backlog'), { recursive: true });
|
|
42
|
+
writeFileSync(cachePath(home), JSON.stringify(cache));
|
|
43
|
+
}
|
|
44
|
+
function isStale(checkedAt, now) {
|
|
45
|
+
const t = Date.parse(checkedAt);
|
|
46
|
+
if (Number.isNaN(t))
|
|
47
|
+
return true;
|
|
48
|
+
return now.getTime() - t > DAY_MS;
|
|
49
|
+
}
|
|
50
|
+
export async function defaultFetchLatest() {
|
|
51
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
52
|
+
const work = Promise.resolve().then(() => {
|
|
53
|
+
const r = runCapture(npm, ['view', 'super-backlog', 'version'], process.cwd());
|
|
54
|
+
if (r.status !== 0)
|
|
55
|
+
return null;
|
|
56
|
+
const line = r.stdout.split(/\r?\n/).find((l) => l.trim() !== '');
|
|
57
|
+
if (!line)
|
|
58
|
+
return null;
|
|
59
|
+
const v = line.trim();
|
|
60
|
+
return v === '' ? null : v;
|
|
61
|
+
});
|
|
62
|
+
let timer;
|
|
63
|
+
const timeout = new Promise((resolve) => {
|
|
64
|
+
timer = setTimeout(() => resolve(null), FETCH_TIMEOUT_MS);
|
|
65
|
+
timer.unref();
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
return await Promise.race([work, timeout]);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (timer !== undefined)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function applyVersionHint(installed, deps) {
|
|
79
|
+
if (deps.env.SBL_SKIP_UPDATE_CHECK)
|
|
80
|
+
return;
|
|
81
|
+
const cache = readCache(deps.home);
|
|
82
|
+
if (cache && isNewer(cache.latest, installed)) {
|
|
83
|
+
deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
|
|
84
|
+
}
|
|
85
|
+
if (!cache || isStale(cache.checkedAt, deps.now())) {
|
|
86
|
+
void deps
|
|
87
|
+
.fetchLatest()
|
|
88
|
+
.then((latest) => {
|
|
89
|
+
if (latest == null || latest === '')
|
|
90
|
+
return;
|
|
91
|
+
writeCache(deps.home, { checkedAt: deps.now().toISOString(), latest });
|
|
92
|
+
})
|
|
93
|
+
.catch(() => { });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
import process from 'node:process';
|
|
2
1
|
import { loadConfig } from './config.js';
|
|
3
2
|
import { discoverModels } from './discovery.js';
|
|
4
|
-
function
|
|
5
|
-
try {
|
|
6
|
-
return process.cwd();
|
|
7
|
-
}
|
|
8
|
-
catch {
|
|
9
|
-
return '.';
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
export function createModelApiHandler() {
|
|
3
|
+
export function createModelApiHandler(cwd) {
|
|
13
4
|
return async (req, res) => {
|
|
14
5
|
const url = req.url ?? '/';
|
|
15
6
|
const method = req.method ?? 'GET';
|
|
16
7
|
if (method === 'GET' && url === '/api/models') {
|
|
17
8
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
18
|
-
res.end(JSON.stringify({ config: loadConfig(
|
|
9
|
+
res.end(JSON.stringify({ config: loadConfig(cwd), status: 'ok' }));
|
|
19
10
|
return;
|
|
20
11
|
}
|
|
21
12
|
if (method === 'POST' && url === '/api/models/discover') {
|
|
22
|
-
const result = await discoverModels(
|
|
13
|
+
const result = await discoverModels(cwd);
|
|
23
14
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
24
15
|
res.end(JSON.stringify(result ?? { error: 'discovery failed' }));
|
|
25
16
|
return;
|
|
@@ -526,7 +526,7 @@
|
|
|
526
526
|
var copy = b.getAttribute('data-copy');
|
|
527
527
|
if (copy) { copyCommand(b, copy); return; }
|
|
528
528
|
var cmd = b.getAttribute('data-cmd');
|
|
529
|
-
fetch('
|
|
529
|
+
fetch('api/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: cmd }) })
|
|
530
530
|
.then(function (res) { if (!res.ok) throw new Error('run failed'); cmdFeedback(b, 'started \u2713'); })
|
|
531
531
|
.catch(function () {});
|
|
532
532
|
});
|
|
@@ -975,7 +975,7 @@
|
|
|
975
975
|
<script>
|
|
976
976
|
(function () {
|
|
977
977
|
if (!window.EventSource || (location.protocol !== 'http:' && location.protocol !== 'https:')) return;
|
|
978
|
-
var es = new EventSource('
|
|
978
|
+
var es = new EventSource('api/events');
|
|
979
979
|
es.addEventListener('reload', function () { location.reload(); });
|
|
980
980
|
es.addEventListener('error', function () {});
|
|
981
981
|
})();
|
|
@@ -21,7 +21,7 @@ Read-only summary of the Backlog.md data in this project.
|
|
|
21
21
|
- Every In Progress task: ID, title, open acceptance criteria
|
|
22
22
|
- Milestones with done/total
|
|
23
23
|
- Blocked or stale items worth flagging
|
|
24
|
-
4. Point to the visual surfaces: `sbl dashboard
|
|
24
|
+
4. Point to the visual surfaces: `sbl dashboard` (live dashboard) or
|
|
25
25
|
`backlog browser` (interactive Kanban).
|
|
26
26
|
|
|
27
27
|
## Boundaries
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-backlog",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"screenshot": "npm run build && node scripts/capture-dashboard.mjs"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
+
"@types/cross-spawn": "^6.0.6",
|
|
35
36
|
"@types/node": "^26.2.0",
|
|
36
37
|
"backlog.md": "^1.50.1",
|
|
37
38
|
"cspell": "^10.1.1",
|
|
@@ -52,9 +53,13 @@
|
|
|
52
53
|
},
|
|
53
54
|
"vitest": {
|
|
54
55
|
"vite": "^8.2.2",
|
|
56
|
+
"esbuild": "^0.25.0",
|
|
55
57
|
"@vitest/mocker": {
|
|
56
58
|
"vite": "^8.2.2"
|
|
57
59
|
}
|
|
58
60
|
}
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"cross-spawn": "^7.0.6"
|
|
59
64
|
}
|
|
60
65
|
}
|
package/dist/commands/serve.js
DELETED