super-backlog 1.0.3 → 1.1.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/README.md CHANGED
@@ -45,15 +45,14 @@ sbl init --models
45
45
 
46
46
  # Local install
47
47
  npm install super-backlog
48
- node ./node_modules/super-backlog/dist/cli.js init
48
+ node ./node_modules/super-backlog/dist/bin.js init
49
49
  ```
50
50
 
51
51
  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
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]` · `sbl serve [--port <n>] [--no-open]`. See `sbl help` for every flag.
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 --serve` exposes `/api/models` and `/api/models/discover`.
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 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.
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 server regenerates the dashboard while it runs; stop it with `Ctrl+C`. There is no static `dashboard.html` installed in your project.
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
  ![Project Dashboard](docs/assets/dashboard.png)
108
107
 
package/dist/bin.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // src/bin.ts
3
+ // Always-run CLI entry. Unlike src/cli.ts (a plain module that only exports
4
+ // HELP/runCli for tests), this file self-executes unconditionally so it
5
+ // works when invoked via a symlink (npm's POSIX global/npx/npm-link bins are
6
+ // symlinks, so comparing process.argv[1] against the module's own realpath
7
+ // -- as the old cli.ts guard did -- is false for every such install).
8
+ import process from 'node:process';
9
+ import { runCli } from './cli.js';
10
+ import { assertNode20 } from './lib/version.js';
11
+ assertNode20();
12
+ runCli(process.argv.slice(2))
13
+ .then((code) => {
14
+ process.exitCode = code;
15
+ })
16
+ .catch((err) => {
17
+ console.error(err instanceof Error ? err.message : String(err));
18
+ process.exitCode = 1;
19
+ });
package/dist/cli.js CHANGED
@@ -1,17 +1,18 @@
1
- #!/usr/bin/env node
2
1
  // src/cli.ts
2
+ // Pure library module: exports HELP/runCli for the always-run entry
3
+ // (src/bin.ts) and for tests. Never self-executes -- see src/bin.ts for why.
4
+ import { homedir } from 'node:os';
3
5
  import { parseArgs } from 'node:util';
4
6
  import process from 'node:process';
5
7
  import { runDashboard } from './commands/dashboard.js';
6
- import { runBacklogSubcommand } from './commands/backlog-alias.js';
7
8
  import { runDoctor } from './commands/doctor.js';
8
9
  import { runInit } from './commands/init.js';
9
10
  import { runModels } from './commands/models.js';
10
- import { runServe } from './commands/serve.js';
11
11
  import { runUninstall } from './commands/uninstall.js';
12
12
  import { runUpdate } from './commands/update.js';
13
- import { assertNode20, KIT_VERSION } from './lib/version.js';
14
- const HELP = `super-backlog (sbl) - equip any project with Backlog.md + Superpowers
13
+ import { KIT_VERSION } from './lib/version.js';
14
+ import { applyVersionHint, defaultFetchLatest } from './lib/version-check.js';
15
+ export const HELP = `super-backlog (sbl) - equip any project with Backlog.md + Superpowers
15
16
 
16
17
  Usage: sbl <command> [options]
17
18
 
@@ -19,10 +20,7 @@ Commands:
19
20
  init Install the kit into the current project
20
21
  uninstall Remove kit-managed files (project data kept unless --with-backlog)
21
22
  update Refresh kit-managed files and report upstream versions
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)
23
+ dashboard Start the project dashboard server (live-reload)
26
24
  models Manage the model router (show, enable, disable, discover)
27
25
  doctor Check the environment (node, PowerShell policy, backlog CLI)
28
26
 
@@ -42,11 +40,7 @@ uninstall options:
42
40
  update options:
43
41
  (none) Refreshes injected files, skills, hook; prints upstream versions
44
42
 
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:
43
+ dashboard options:
50
44
  --port <n> Port for the dashboard server (default: 6428)
51
45
  --no-open Do not open the dashboard browser automatically
52
46
 
@@ -59,7 +53,7 @@ Global options:
59
53
  Exit codes:
60
54
  0 ok | 1 usage/detection failure | 2 ownership or merge refusal
61
55
  3 upstream command failure | 4 success with warnings`;
62
- async function main(argv) {
56
+ export async function runCli(argv) {
63
57
  const [command, ...rest] = argv;
64
58
  if (command === '--version' || command === '-v') {
65
59
  console.log(KIT_VERSION);
@@ -69,6 +63,13 @@ async function main(argv) {
69
63
  console.log(HELP);
70
64
  return 0;
71
65
  }
66
+ await applyVersionHint(KIT_VERSION, {
67
+ home: homedir(),
68
+ now: () => new Date(),
69
+ fetchLatest: defaultFetchLatest,
70
+ log: (line) => console.error(line),
71
+ env: { ...process.env, SBL_SKIP_UPDATE_CHECK: process.env.SBL_SKIP_UPDATE_CHECK },
72
+ });
72
73
  switch (command) {
73
74
  case 'init': {
74
75
  const parsed = parseArgs({
@@ -121,24 +122,11 @@ async function main(argv) {
121
122
  positionals: parsed.positionals,
122
123
  });
123
124
  }
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
- }
125
+ case 'serve':
138
126
  case 'browser':
139
- return await runBacklogSubcommand(process.cwd(), 'browser', rest);
140
127
  case 'board':
141
- return await runBacklogSubcommand(process.cwd(), 'board', rest);
128
+ console.error(`error: "sbl ${command}" was removed; the live dashboard is \`sbl dashboard\``);
129
+ return 1;
142
130
  case 'models': {
143
131
  const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
144
132
  return await runModels(process.cwd(), {
@@ -154,12 +142,3 @@ async function main(argv) {
154
142
  return 1;
155
143
  }
156
144
  }
157
- assertNode20();
158
- main(process.argv.slice(2))
159
- .then((code) => {
160
- process.exitCode = code;
161
- })
162
- .catch((err) => {
163
- console.error(err instanceof Error ? err.message : String(err));
164
- process.exitCode = 1;
165
- });
@@ -1,39 +1,140 @@
1
- import { tmpdir } from 'node:os';
1
+ import { spawn } from 'node:child_process';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { homedir as osHomedir, tmpdir } from 'node:os';
2
4
  import { join } from 'node:path';
3
- import spawn from 'cross-spawn';
5
+ import process from 'node:process';
4
6
  import { collectDashboardData } from '../dashboard/data.js';
7
+ import { startHubServer } from '../dashboard/hub.js';
5
8
  import { renderDashboard } from '../dashboard/render.js';
6
- import { DASHBOARD_PORT, startServeServer } from '../dashboard/server.js';
9
+ import { DASHBOARD_PORT } from '../dashboard/server.js';
7
10
  import { atomicWrite } from '../lib/atomic.js';
8
- import { resolveBacklogBin } from '../lib/run.js';
11
+ import { clearHubState, isPidAlive, newHubToken, readHubState, writeHubState } from '../lib/hub-state.js';
12
+ import { projectSlug } from '../lib/slug.js';
9
13
  import { KIT_VERSION } from '../lib/version.js';
10
14
  async function regenerateInto(outPath, cwd) {
11
15
  const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
12
16
  atomicWrite(outPath, renderDashboard(data));
13
17
  }
14
- function spawnBacklogBrowser(cwd) {
15
- const bin = resolveBacklogBin(cwd);
16
- if (!bin) {
17
- console.warn('warning: backlog CLI not found; dashboard will serve without the Backlog browser');
18
- return;
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
+ }
19
35
  }
36
+ catch {
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
+ /**
85
+ * Builds the hub shutdown routine: close the hub handle, then clear the
86
+ * on-disk hub.json owned by `pid`. The returned function is idempotent --
87
+ * calling it more than once (e.g. once from a signal handler, once from the
88
+ * caller's own cleanup) only runs the underlying work once and every caller
89
+ * observes the same result.
90
+ */
91
+ export function createShutdown(hub, home, pid) {
92
+ let done = null;
93
+ return function shutdown() {
94
+ if (done === null) {
95
+ done = (async () => {
96
+ await hub.close();
97
+ clearHubState(home, pid);
98
+ })();
99
+ }
100
+ return done;
101
+ };
102
+ }
103
+ async function attachToHub(opts) {
104
+ let res;
20
105
  try {
21
- const child = spawn(bin, ['browser', '--no-open', '--non-interactive'], {
22
- cwd,
23
- detached: true,
24
- stdio: 'ignore',
106
+ res = await opts.attach(`http://127.0.0.1:${opts.port}/api/hub/register`, {
107
+ cwd: opts.cwd,
108
+ token: opts.token,
25
109
  });
26
- child.on('error', () => { });
27
- child.unref();
28
- console.log('started Backlog browser (dashboard still serves if browser fails)');
29
110
  }
30
- catch {
31
- console.warn('warning: failed to start Backlog browser; dashboard still serves');
111
+ catch (err) {
112
+ console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
113
+ return 1;
114
+ }
115
+ if (res.status === 401) {
116
+ console.error('error: hub token mismatch; stop the other hub or delete stale hub.json');
117
+ return 1;
118
+ }
119
+ if (res.status === 409) {
120
+ const json = res.json;
121
+ console.error(`error: slug collision between ${json.existingCwd ?? ''} and ${json.incomingCwd ?? ''}; change project_name in one backlog/config.yml`);
122
+ return 1;
123
+ }
124
+ if (res.status !== 200) {
125
+ console.error(`error: dashboard serve failed (register ${res.status})`);
126
+ return 1;
127
+ }
128
+ const json = res.json;
129
+ if (json.ok !== true || typeof json.url !== 'string') {
130
+ console.error('error: dashboard serve failed (invalid register response)');
131
+ return 1;
32
132
  }
133
+ if (!opts.noOpen)
134
+ opts.openBrowser(json.url);
135
+ return 0;
33
136
  }
34
- /** CLI entry for `sbl dashboard [--port N] [--no-open]`. Starts a local server
35
- * that watches backlog/ and regenerates a temp dashboard file on changes. */
36
- export async function runDashboard(cwd, args) {
137
+ export async function runDashboard(cwd, args, deps = {}) {
37
138
  const values = args.values;
38
139
  let port = DASHBOARD_PORT;
39
140
  if (values['port'] !== undefined) {
@@ -45,23 +146,95 @@ export async function runDashboard(cwd, args) {
45
146
  port = parsed;
46
147
  }
47
148
  const noOpen = values['no-open'] === true;
48
- const outPath = join(tmpdir(), `sbl-dashboard-${Date.now()}.html`);
149
+ const home = (deps.homedir ?? osHomedir)();
150
+ const startHub = deps.startHub ?? startHubServer;
151
+ const attach = deps.attach ?? defaultAttach;
152
+ const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
153
+ const pid = (deps.nowPid ?? (() => process.pid))();
154
+ let slugResult;
49
155
  try {
50
- await regenerateInto(outPath, cwd);
51
- console.log(`dashboard written: ${outPath}`);
52
- console.log(`serving dashboard at http://127.0.0.1:${port}/ (press Ctrl+C to stop)`);
53
- // Start backlog browser in parallel; don't await so the dashboard server can listen immediately.
54
- spawnBacklogBrowser(cwd);
55
- await startServeServer(cwd, {
56
- port,
57
- file: outPath,
58
- regenerate: () => regenerateInto(outPath, cwd),
59
- openBrowser: !noOpen,
60
- });
61
- return 0;
156
+ slugResult = projectSlug(cwd);
157
+ }
158
+ catch {
159
+ console.error('error: set project_name in backlog/config.yml');
160
+ return 1;
161
+ }
162
+ if (!slugResult.ok) {
163
+ console.error('error: set project_name in backlog/config.yml');
164
+ return 1;
165
+ }
166
+ const slug = slugResult.slug;
167
+ const state = readHubState(home);
168
+ if (state !== null && isPidAlive(state.pid)) {
169
+ try {
170
+ const status = await attach(`http://127.0.0.1:${state.port}/api/hub/status?token=${encodeURIComponent(state.token)}`, undefined);
171
+ if (status.status === 200) {
172
+ if (values['port'] !== undefined && port !== state.port) {
173
+ console.error(`error: a hub is already running on ${state.port}`);
174
+ return 1;
175
+ }
176
+ return await attachToHub({
177
+ cwd,
178
+ port: state.port,
179
+ token: state.token,
180
+ attach,
181
+ openBrowser,
182
+ noOpen,
183
+ });
184
+ }
185
+ }
186
+ catch {
187
+ }
188
+ }
189
+ if (values['port'] !== undefined) {
190
+ console.warn('warning: default bookmarks (:6428) will miss this hub');
191
+ }
192
+ const token = newHubToken();
193
+ const outPath = join(tmpdir(), `sbl-dashboard-${Date.now()}-${slug}.html`);
194
+ const regenerate = () => regenerateInto(outPath, cwd);
195
+ let hub;
196
+ try {
197
+ await regenerate();
198
+ hub = await startHub({ port, token });
62
199
  }
63
200
  catch (err) {
201
+ if (isEaddrinuse(err)) {
202
+ console.error(`error: port ${port} is in use`);
203
+ console.error('hint: pass --port as emergency only');
204
+ return 1;
205
+ }
64
206
  console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
65
207
  return 1;
66
208
  }
209
+ writeHubState(home, { pid, port: hub.port, token });
210
+ const result = hub.register({ cwd, file: outPath, regenerate });
211
+ if (!result.ok) {
212
+ await hub.close();
213
+ clearHubState(home, pid);
214
+ if (result.code === 409) {
215
+ console.error(`error: slug collision between ${result.existingCwd} and ${result.incomingCwd}; change project_name in one backlog/config.yml`);
216
+ return 1;
217
+ }
218
+ console.error(`error: dashboard serve failed (${result.message})`);
219
+ return 1;
220
+ }
221
+ console.log(`dashboard written: ${outPath}`);
222
+ console.log(`serving dashboard at ${result.url} (press Ctrl+C to stop)`);
223
+ if (!noOpen)
224
+ openBrowser(result.url);
225
+ const shutdown = createShutdown(hub, home, pid);
226
+ const onSignal = () => {
227
+ void shutdown();
228
+ };
229
+ process.once('SIGINT', onSignal);
230
+ process.once('SIGTERM', onSignal);
231
+ try {
232
+ await waitForClose(hub);
233
+ return 0;
234
+ }
235
+ finally {
236
+ process.removeListener('SIGINT', onSignal);
237
+ process.removeListener('SIGTERM', onSignal);
238
+ await shutdown();
239
+ }
67
240
  }
@@ -0,0 +1,287 @@
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
+ const ALLOWED_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
16
+ function isAllowedHost(headerValue) {
17
+ const value = Array.isArray(headerValue) ? headerValue[0] : headerValue;
18
+ if (typeof value !== 'string')
19
+ return false;
20
+ return ALLOWED_HOST.test(value.trim().toLowerCase());
21
+ }
22
+ function hasJsonContentType(req) {
23
+ const value = req.headers['content-type'];
24
+ const ct = Array.isArray(value) ? value[0] : value;
25
+ return typeof ct === 'string' && ct.toLowerCase().startsWith('application/json');
26
+ }
27
+ function projectUrl(port, slug) {
28
+ return `http://127.0.0.1:${port}/p/${slug}/`;
29
+ }
30
+ async function readBody(req) {
31
+ const chunks = [];
32
+ for await (const chunk of req) {
33
+ chunks.push(Buffer.from(chunk));
34
+ }
35
+ return Buffer.concat(chunks).toString('utf8');
36
+ }
37
+ function sendJson(res, status, body) {
38
+ res.writeHead(status, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify(body));
40
+ }
41
+ function sendText(res, status, body) {
42
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
43
+ res.end(body);
44
+ }
45
+ function serveFile(file, res) {
46
+ readFile(file)
47
+ .then((bytes) => {
48
+ res.writeHead(200, {
49
+ 'content-type': 'text/html; charset=utf-8',
50
+ 'cache-control': 'no-store',
51
+ });
52
+ res.end(bytes);
53
+ })
54
+ .catch(() => {
55
+ sendText(res, 404, 'dashboard not generated yet');
56
+ });
57
+ }
58
+ function generateDashboard(cwd, file) {
59
+ const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
60
+ atomicWrite(file, renderDashboard(data));
61
+ }
62
+ export async function startHubServer(opts) {
63
+ const projects = new Map();
64
+ const token = opts.token;
65
+ let port = 0;
66
+ let watchWarned = false;
67
+ function watchBacklog(cwd, reloader) {
68
+ const backlogDir = join(cwd, 'backlog');
69
+ if (recursiveWatchSupported(process.platform, process.versions.node)) {
70
+ try {
71
+ const watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
72
+ watcher.on('error', () => { });
73
+ return watcher;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ if (!watchWarned) {
80
+ watchWarned = true;
81
+ console.warn(WATCH_WARN);
82
+ }
83
+ return null;
84
+ }
85
+ function disposeEntry(entry) {
86
+ entry.reloader.cancel();
87
+ entry.broker.close();
88
+ entry.watcher?.close();
89
+ }
90
+ function register(project) {
91
+ let computed;
92
+ try {
93
+ computed = projectSlug(project.cwd);
94
+ }
95
+ catch {
96
+ return { ok: false, code: 400, message: 'invalid cwd' };
97
+ }
98
+ if (!computed.ok) {
99
+ return { ok: false, code: 400, message: 'empty slug' };
100
+ }
101
+ const slug = computed.slug;
102
+ if (slug === '') {
103
+ return { ok: false, code: 400, message: 'empty slug' };
104
+ }
105
+ let key;
106
+ try {
107
+ key = realpathKey(project.cwd);
108
+ }
109
+ catch {
110
+ return { ok: false, code: 400, message: 'invalid cwd' };
111
+ }
112
+ const existing = projects.get(slug);
113
+ if (existing && existing.realpath !== key) {
114
+ return { ok: false, code: 409, existingCwd: existing.cwd, incomingCwd: project.cwd };
115
+ }
116
+ const url = projectUrl(port, slug);
117
+ if (existing && existing.realpath === key) {
118
+ existing.cwd = project.cwd;
119
+ existing.file = project.file;
120
+ existing.reloader.cancel();
121
+ existing.watcher?.close();
122
+ existing.reloader = createDebouncedReloader(project.regenerate, () => existing.broker.broadcast('reload'), 300);
123
+ existing.watcher = watchBacklog(project.cwd, existing.reloader);
124
+ existing.modelApi = createModelApiHandler(project.cwd);
125
+ return { ok: true, slug, url };
126
+ }
127
+ const broker = createReloadBroker();
128
+ const reloader = createDebouncedReloader(project.regenerate, () => broker.broadcast('reload'), 300);
129
+ const entry = {
130
+ cwd: project.cwd,
131
+ realpath: key,
132
+ file: project.file,
133
+ broker,
134
+ reloader,
135
+ watcher: watchBacklog(project.cwd, reloader),
136
+ runApi: createRunApiHandler(project.cwd),
137
+ modelApi: createModelApiHandler(project.cwd),
138
+ };
139
+ projects.set(slug, entry);
140
+ return { ok: true, slug, url };
141
+ }
142
+ async function handle(req, res) {
143
+ if (!isAllowedHost(req.headers.host)) {
144
+ sendText(res, 403, 'forbidden');
145
+ return;
146
+ }
147
+ const method = req.method ?? 'GET';
148
+ if (method === 'POST' && !hasJsonContentType(req)) {
149
+ sendText(res, 415, 'unsupported media type: expected application/json');
150
+ return;
151
+ }
152
+ const parsed = new URL(req.url ?? '/', 'http://127.0.0.1');
153
+ const pathname = parsed.pathname;
154
+ if (pathname === '/' && method === 'GET') {
155
+ const links = [...projects.keys()]
156
+ .map((s) => `<li><a href="/p/${s}/">${s}</a></li>`)
157
+ .join('\n');
158
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
159
+ res.end(`<!doctype html><title>sbl hub</title><ul>${links}</ul>`);
160
+ return;
161
+ }
162
+ if (pathname === '/api/hub/status' && method === 'GET') {
163
+ if (parsed.searchParams.get('token') !== token) {
164
+ sendText(res, 401, 'unauthorized');
165
+ return;
166
+ }
167
+ sendJson(res, 200, { pid: process.pid, port });
168
+ return;
169
+ }
170
+ if (pathname === '/api/hub/register' && method === 'POST') {
171
+ let body;
172
+ try {
173
+ body = await readBody(req);
174
+ }
175
+ catch {
176
+ sendJson(res, 400, { error: 'failed to read body' });
177
+ return;
178
+ }
179
+ let payload;
180
+ try {
181
+ payload = JSON.parse(body);
182
+ }
183
+ catch {
184
+ sendJson(res, 400, { error: 'invalid json' });
185
+ return;
186
+ }
187
+ if (typeof payload !== 'object' || payload === null) {
188
+ sendJson(res, 400, { error: 'invalid json' });
189
+ return;
190
+ }
191
+ const rec = payload;
192
+ if (rec.token !== token) {
193
+ sendText(res, 401, 'unauthorized');
194
+ return;
195
+ }
196
+ if (typeof rec.cwd !== 'string') {
197
+ sendJson(res, 400, { ok: false, code: 400, message: 'cwd required' });
198
+ return;
199
+ }
200
+ const cwd = rec.cwd;
201
+ const slugResult = (() => {
202
+ try {
203
+ return projectSlug(cwd);
204
+ }
205
+ catch {
206
+ return { ok: false, reason: 'empty' };
207
+ }
208
+ })();
209
+ const slug = slugResult.ok ? slugResult.slug : 'project';
210
+ const file = join(tmpdir(), `sbl-dashboard-${Date.now()}-${slug}.html`);
211
+ const regenerate = () => generateDashboard(cwd, file);
212
+ try {
213
+ regenerate();
214
+ }
215
+ catch {
216
+ // still register; GET may 404 until a later refresh
217
+ }
218
+ const result = register({ cwd, file, regenerate });
219
+ sendJson(res, result.ok ? 200 : result.code, result);
220
+ return;
221
+ }
222
+ const scoped = /^\/p\/([^/]+)(\/.*)?$/.exec(pathname);
223
+ if (!scoped) {
224
+ sendText(res, 404, 'not found');
225
+ return;
226
+ }
227
+ const slug = scoped[1] ?? '';
228
+ const rest = scoped[2];
229
+ const entry = projects.get(slug);
230
+ if (!entry) {
231
+ sendText(res, 404, 'not found');
232
+ return;
233
+ }
234
+ if (rest === undefined) {
235
+ res.writeHead(302, { location: `/p/${slug}/` });
236
+ res.end();
237
+ return;
238
+ }
239
+ if (rest.startsWith('/api/')) {
240
+ req.url = rest;
241
+ if (rest === '/api/run') {
242
+ await entry.runApi(req, res);
243
+ return;
244
+ }
245
+ if (entry.broker.handler(req, res)) {
246
+ return;
247
+ }
248
+ await entry.modelApi(req, res);
249
+ return;
250
+ }
251
+ if (method === 'GET' && (rest === '/' || rest === '/index.html')) {
252
+ serveFile(entry.file, res);
253
+ return;
254
+ }
255
+ sendText(res, 404, 'not found');
256
+ }
257
+ const server = createServer((req, res) => {
258
+ void handle(req, res);
259
+ });
260
+ const requestedPort = opts.port ?? DASHBOARD_PORT;
261
+ port = await new Promise((resolvePort, rejectPort) => {
262
+ server.once('error', rejectPort);
263
+ server.listen(requestedPort, '127.0.0.1', () => {
264
+ const addr = server.address();
265
+ if (addr !== null && typeof addr === 'object')
266
+ resolvePort(addr.port);
267
+ else
268
+ resolvePort(requestedPort);
269
+ });
270
+ });
271
+ return {
272
+ server,
273
+ port,
274
+ register,
275
+ triggerReload(slug) {
276
+ projects.get(slug)?.reloader.trigger();
277
+ },
278
+ close() {
279
+ for (const entry of projects.values())
280
+ disposeEntry(entry);
281
+ projects.clear();
282
+ return new Promise((resolveClose) => {
283
+ server.close(() => resolveClose());
284
+ });
285
+ },
286
+ };
287
+ }
@@ -1,12 +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
5
  import crossSpawn from 'cross-spawn';
9
- import { createModelApiHandler } from '../models/dashboard-api.js';
10
6
  import { resolveBacklogBin } from '../lib/run.js';
11
7
  export const DASHBOARD_PORT = 6428;
12
8
  const WHITELIST = new Map([
@@ -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; changes inside <cwd>/backlog trigger
215
- * `regenerate()` debounced by 300ms. Pass port 0 for an ephemeral port (tests).
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 regenerate = opts.regenerate;
221
- const broker = createReloadBroker();
222
- const reloader = createDebouncedReloader(regenerate, () => broker.broadcast('reload'), 300);
223
- let watcher = null;
224
- const backlogDir = join(cwd, 'backlog');
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(`http://127.0.0.1:${port}/`);
214
+ openInBrowser(result.url);
277
215
  return {
278
- server,
279
- port,
216
+ server: hub.server,
217
+ port: hub.port,
280
218
  close() {
281
- reloader.cancel();
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,53 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, 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
+ const path = hubStatePath(home);
29
+ atomicWrite(path, JSON.stringify(state));
30
+ // hub.json carries the hub's auth token; keep it off other local accounts.
31
+ // win32 has no POSIX mode bits (ACLs govern access there instead).
32
+ if (process.platform !== 'win32') {
33
+ chmodSync(path, 0o600);
34
+ }
35
+ }
36
+ export function clearHubState(home, pid) {
37
+ const current = readHubState(home);
38
+ if (current === null || current.pid !== pid)
39
+ return;
40
+ rmSync(hubStatePath(home), { force: true });
41
+ }
42
+ export function isPidAlive(pid) {
43
+ try {
44
+ process.kill(pid, 0);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ export function newHubToken() {
52
+ return randomBytes(16).toString('hex');
53
+ }
@@ -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,122 @@
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 spawn from 'cross-spawn';
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
+ const nowMs = now.getTime();
49
+ if (t > nowMs)
50
+ return true; // clock skew: a future checkedAt can never be trusted
51
+ return nowMs - t > DAY_MS;
52
+ }
53
+ // child.stdout is typed as Readable, but the underlying pipe stream (a
54
+ // net.Socket on POSIX, a Pipe wrap on Windows) always exposes unref() at
55
+ // runtime; the DOM/Node stream typings just don't declare it.
56
+ function unrefStream(stream) {
57
+ stream?.unref?.();
58
+ }
59
+ export async function defaultFetchLatest() {
60
+ const work = new Promise((resolvePromise) => {
61
+ let child;
62
+ try {
63
+ child = spawn('npm', ['view', 'super-backlog', 'version'], {
64
+ cwd: process.cwd(),
65
+ stdio: ['ignore', 'pipe', 'ignore'],
66
+ });
67
+ }
68
+ catch {
69
+ resolvePromise(null);
70
+ return;
71
+ }
72
+ let out = '';
73
+ unrefStream(child.stdout);
74
+ child.stdout?.on('data', (chunk) => {
75
+ out += chunk.toString('utf8');
76
+ });
77
+ child.on('error', () => resolvePromise(null));
78
+ child.on('close', (code) => {
79
+ if (code !== 0) {
80
+ resolvePromise(null);
81
+ return;
82
+ }
83
+ const line = out.split(/\r?\n/).find((l) => l.trim() !== '');
84
+ const v = line?.trim();
85
+ resolvePromise(v === undefined || v === '' ? null : v);
86
+ });
87
+ child.unref();
88
+ });
89
+ let timer;
90
+ const timeout = new Promise((resolveTimeout) => {
91
+ timer = setTimeout(() => resolveTimeout(null), FETCH_TIMEOUT_MS);
92
+ timer.unref();
93
+ });
94
+ try {
95
+ return await Promise.race([work, timeout]);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ finally {
101
+ if (timer !== undefined)
102
+ clearTimeout(timer);
103
+ }
104
+ }
105
+ export async function applyVersionHint(installed, deps) {
106
+ if (deps.env.SBL_SKIP_UPDATE_CHECK)
107
+ return;
108
+ const cache = readCache(deps.home);
109
+ if (cache && isNewer(cache.latest, installed)) {
110
+ deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
111
+ }
112
+ if (!cache || isStale(cache.checkedAt, deps.now())) {
113
+ void deps
114
+ .fetchLatest()
115
+ .then((latest) => {
116
+ if (latest == null || latest === '')
117
+ return;
118
+ writeCache(deps.home, { checkedAt: deps.now().toISOString(), latest });
119
+ })
120
+ .catch(() => { });
121
+ }
122
+ }
@@ -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 currentCwd() {
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(currentCwd()), status: 'ok' }));
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(currentCwd());
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('/api/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: cmd }) })
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('/api/events');
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 --serve` (live dashboard) or
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.0.3",
3
+ "version": "1.1.1",
4
4
  "description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,8 +12,8 @@
12
12
  "node": ">=20"
13
13
  },
14
14
  "bin": {
15
- "sbl": "dist/cli.js",
16
- "super-backlog": "dist/cli.js"
15
+ "sbl": "dist/bin.js",
16
+ "super-backlog": "dist/bin.js"
17
17
  },
18
18
  "files": [
19
19
  "dist",
@@ -1,18 +0,0 @@
1
- import spawn from 'cross-spawn';
2
- import { resolveBacklogBin } from '../lib/run.js';
3
- /** Run a backlog.md subcommand by delegating to the resolved backlog binary. */
4
- export function runBacklogSubcommand(cwd, subcommand, args = []) {
5
- const bin = resolveBacklogBin(cwd);
6
- if (!bin) {
7
- console.error('error: backlog CLI not found; is backlog.md installed?');
8
- return Promise.resolve(1);
9
- }
10
- return new Promise((resolve) => {
11
- const child = spawn(bin, [subcommand, ...args], {
12
- cwd,
13
- stdio: 'inherit',
14
- });
15
- child.on('error', () => resolve(1));
16
- child.on('exit', (code) => resolve(code ?? 1));
17
- });
18
- }
@@ -1,3 +0,0 @@
1
- // src/commands/serve.ts
2
- // Deprecated alias: `sbl serve` behaves exactly like `sbl dashboard`.
3
- export { runDashboard as runServe } from './dashboard.js';