super-backlog 1.1.0 → 1.2.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 CHANGED
@@ -45,7 +45,7 @@ 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:
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,9 +1,8 @@
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.
3
4
  import { homedir } from 'node:os';
4
5
  import { parseArgs } from 'node:util';
5
- import { resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
6
  import process from 'node:process';
8
7
  import { runDashboard } from './commands/dashboard.js';
9
8
  import { runDoctor } from './commands/doctor.js';
@@ -11,7 +10,7 @@ import { runInit } from './commands/init.js';
11
10
  import { runModels } from './commands/models.js';
12
11
  import { runUninstall } from './commands/uninstall.js';
13
12
  import { runUpdate } from './commands/update.js';
14
- import { assertNode20, KIT_VERSION } from './lib/version.js';
13
+ import { KIT_VERSION } from './lib/version.js';
15
14
  import { applyVersionHint, defaultFetchLatest } from './lib/version-check.js';
16
15
  export const HELP = `super-backlog (sbl) - equip any project with Backlog.md + Superpowers
17
16
 
@@ -64,7 +63,7 @@ export async function runCli(argv) {
64
63
  console.log(HELP);
65
64
  return 0;
66
65
  }
67
- void applyVersionHint(KIT_VERSION, {
66
+ await applyVersionHint(KIT_VERSION, {
68
67
  home: homedir(),
69
68
  now: () => new Date(),
70
69
  fetchLatest: defaultFetchLatest,
@@ -143,15 +142,3 @@ export async function runCli(argv) {
143
142
  return 1;
144
143
  }
145
144
  }
146
- assertNode20();
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
- }
@@ -81,6 +81,25 @@ function waitForClose(hub) {
81
81
  hub.server.once('close', () => resolve());
82
82
  });
83
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
+ }
84
103
  async function attachToHub(opts) {
85
104
  let res;
86
105
  try {
@@ -150,6 +169,10 @@ export async function runDashboard(cwd, args, deps = {}) {
150
169
  try {
151
170
  const status = await attach(`http://127.0.0.1:${state.port}/api/hub/status?token=${encodeURIComponent(state.token)}`, undefined);
152
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
+ }
153
176
  return await attachToHub({
154
177
  cwd,
155
178
  port: state.port,
@@ -199,8 +222,9 @@ export async function runDashboard(cwd, args, deps = {}) {
199
222
  console.log(`serving dashboard at ${result.url} (press Ctrl+C to stop)`);
200
223
  if (!noOpen)
201
224
  openBrowser(result.url);
225
+ const shutdown = createShutdown(hub, home, pid);
202
226
  const onSignal = () => {
203
- void hub.close();
227
+ void shutdown();
204
228
  };
205
229
  process.once('SIGINT', onSignal);
206
230
  process.once('SIGTERM', onSignal);
@@ -211,6 +235,6 @@ export async function runDashboard(cwd, args, deps = {}) {
211
235
  finally {
212
236
  process.removeListener('SIGINT', onSignal);
213
237
  process.removeListener('SIGTERM', onSignal);
214
- clearHubState(home, pid);
238
+ await shutdown();
215
239
  }
216
240
  }
@@ -0,0 +1,98 @@
1
+ // src/dashboard/backlog-browser.ts
2
+ import { request } from 'node:http';
3
+ import { createServer } from 'node:net';
4
+ import crossSpawn from 'cross-spawn';
5
+ import { resolveBacklogBin } from '../lib/run.js';
6
+ function defaultSpawn(bin, args, cwd) {
7
+ const child = crossSpawn(bin, args, { cwd, stdio: 'ignore' });
8
+ return child;
9
+ }
10
+ function defaultGetFreePort() {
11
+ return new Promise((resolve, reject) => {
12
+ const srv = createServer();
13
+ srv.once('error', reject);
14
+ srv.listen(0, '127.0.0.1', () => {
15
+ const addr = srv.address();
16
+ const port = addr !== null && typeof addr === 'object' ? addr.port : 0;
17
+ srv.close(() => resolve(port));
18
+ });
19
+ });
20
+ }
21
+ function defaultProbe(url) {
22
+ return new Promise((resolve) => {
23
+ const r = request(url, { method: 'GET' }, (res) => {
24
+ res.resume();
25
+ resolve((res.statusCode ?? 500) < 500);
26
+ });
27
+ r.on('error', () => resolve(false));
28
+ r.end();
29
+ });
30
+ }
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+ /** Manages one `backlog browser` process for one project cwd. */
35
+ export function createBrowserManager(cwd, deps = {}) {
36
+ const resolveBin = deps.resolveBin ?? resolveBacklogBin;
37
+ const spawnFn = deps.spawnFn ?? defaultSpawn;
38
+ const getFreePort = deps.getFreePort ?? defaultGetFreePort;
39
+ const probe = deps.probe ?? defaultProbe;
40
+ const timeoutMs = deps.timeoutMs ?? 10_000;
41
+ const intervalMs = deps.intervalMs ?? 200;
42
+ let child = null;
43
+ let url = '';
44
+ let starting = null;
45
+ function alive() {
46
+ return child !== null && child.exitCode === null;
47
+ }
48
+ async function start() {
49
+ const bin = resolveBin(cwd);
50
+ if (!bin)
51
+ return { ok: false, code: 503, message: 'backlog cli not found' };
52
+ let port;
53
+ try {
54
+ port = await getFreePort();
55
+ }
56
+ catch {
57
+ return { ok: false, code: 500, message: 'no free port for the backlog browser' };
58
+ }
59
+ url = `http://127.0.0.1:${port}/`;
60
+ const spawned = spawnFn(bin, ['browser', '--port', String(port), '--no-open'], cwd);
61
+ let spawnFailed = false;
62
+ spawned.on('error', () => {
63
+ spawnFailed = true;
64
+ if (child === spawned)
65
+ child = null;
66
+ });
67
+ child = spawned;
68
+ const deadline = Date.now() + timeoutMs;
69
+ while (Date.now() < deadline) {
70
+ if (spawnFailed || spawned.exitCode !== null)
71
+ break;
72
+ if (await probe(url))
73
+ return { ok: true, url };
74
+ await sleep(intervalMs);
75
+ }
76
+ spawned.kill();
77
+ if (child === spawned)
78
+ child = null;
79
+ return { ok: false, code: 500, message: 'backlog browser did not start' };
80
+ }
81
+ return {
82
+ ensure() {
83
+ if (alive() && url !== '')
84
+ return Promise.resolve({ ok: true, url });
85
+ if (starting === null) {
86
+ starting = start().finally(() => {
87
+ starting = null;
88
+ });
89
+ }
90
+ return starting;
91
+ },
92
+ close() {
93
+ if (alive())
94
+ child?.kill();
95
+ child = null;
96
+ },
97
+ };
98
+ }
@@ -1,7 +1,9 @@
1
1
  // src/dashboard/data.ts
2
2
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
3
4
  import { basename, join } from 'node:path';
4
5
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
6
+ import { isNewerVersion } from '../lib/version-check.js';
5
7
  import { readSimpleKeys } from '../lib/yamlmini.js';
6
8
  function isRecord(v) {
7
9
  return typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -263,6 +265,18 @@ function readProjectIdentity(cwd) {
263
265
  const description = asString(cfg['description']) ?? asString(pkg?.['description']) ?? '';
264
266
  return { name, description };
265
267
  }
268
+ function readLatestVersion(home, kitVersion) {
269
+ try {
270
+ const raw = readFileSync(join(home, '.super-backlog', 'version-check.json'), 'utf8');
271
+ const parsed = JSON.parse(raw);
272
+ if (!isRecord(parsed) || typeof parsed.latest !== 'string')
273
+ return null;
274
+ return isNewerVersion(parsed.latest, kitVersion) ? parsed.latest : null;
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
266
280
  export function collectDashboardData(cwd, opts) {
267
281
  const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
268
282
  ? opts.today.trim()
@@ -271,6 +285,7 @@ export function collectDashboardData(cwd, opts) {
271
285
  project: readProjectIdentity(cwd),
272
286
  generatedAt: new Date().toISOString(),
273
287
  kitVersion: opts.kitVersion,
288
+ latestVersion: readLatestVersion(opts.home ?? homedir(), opts.kitVersion),
274
289
  statuses: [],
275
290
  milestones: [],
276
291
  tasks: [],
@@ -4,28 +4,26 @@ import { createServer } from 'node:http';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import process from 'node:process';
7
+ import { createBrowserManager } from './backlog-browser.js';
7
8
  import { collectDashboardData } from './data.js';
8
9
  import { renderDashboard } from './render.js';
9
- import { createDebouncedReloader, createReloadBroker, createRunApiHandler, DASHBOARD_PORT, recursiveWatchSupported, } from './server.js';
10
+ import { createDebouncedReloader, createReloadBroker, DASHBOARD_PORT, recursiveWatchSupported, } from './server.js';
10
11
  import { atomicWrite } from '../lib/atomic.js';
11
12
  import { projectSlug, realpathKey } from '../lib/slug.js';
12
13
  import { KIT_VERSION } from '../lib/version.js';
13
14
  import { createModelApiHandler } from '../models/dashboard-api.js';
14
15
  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;
16
+ const ALLOWED_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
17
+ function isAllowedHost(headerValue) {
18
+ const value = Array.isArray(headerValue) ? headerValue[0] : headerValue;
19
+ if (typeof value !== 'string')
20
+ return false;
21
+ return ALLOWED_HOST.test(value.trim().toLowerCase());
22
+ }
23
+ function hasJsonContentType(req) {
24
+ const value = req.headers['content-type'];
25
+ const ct = Array.isArray(value) ? value[0] : value;
26
+ return typeof ct === 'string' && ct.toLowerCase().startsWith('application/json');
29
27
  }
30
28
  function projectUrl(port, slug) {
31
29
  return `http://127.0.0.1:${port}/p/${slug}/`;
@@ -66,10 +64,30 @@ export async function startHubServer(opts) {
66
64
  const projects = new Map();
67
65
  const token = opts.token;
68
66
  let port = 0;
67
+ let watchWarned = false;
68
+ function watchBacklog(cwd, reloader) {
69
+ const backlogDir = join(cwd, 'backlog');
70
+ if (recursiveWatchSupported(process.platform, process.versions.node)) {
71
+ try {
72
+ const watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
73
+ watcher.on('error', () => { });
74
+ return watcher;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ if (!watchWarned) {
81
+ watchWarned = true;
82
+ console.warn(WATCH_WARN);
83
+ }
84
+ return null;
85
+ }
69
86
  function disposeEntry(entry) {
70
87
  entry.reloader.cancel();
71
88
  entry.broker.close();
72
89
  entry.watcher?.close();
90
+ entry.browser.close();
73
91
  }
74
92
  function register(project) {
75
93
  let computed;
@@ -82,7 +100,7 @@ export async function startHubServer(opts) {
82
100
  if (!computed.ok) {
83
101
  return { ok: false, code: 400, message: 'empty slug' };
84
102
  }
85
- const slug = project.slug ?? computed.slug;
103
+ const slug = computed.slug;
86
104
  if (slug === '') {
87
105
  return { ok: false, code: 400, message: 'empty slug' };
88
106
  }
@@ -117,14 +135,22 @@ export async function startHubServer(opts) {
117
135
  broker,
118
136
  reloader,
119
137
  watcher: watchBacklog(project.cwd, reloader),
120
- runApi: createRunApiHandler(project.cwd),
138
+ browser: createBrowserManager(project.cwd, opts.browserDeps),
121
139
  modelApi: createModelApiHandler(project.cwd),
122
140
  };
123
141
  projects.set(slug, entry);
124
142
  return { ok: true, slug, url };
125
143
  }
126
144
  async function handle(req, res) {
145
+ if (!isAllowedHost(req.headers.host)) {
146
+ sendText(res, 403, 'forbidden');
147
+ return;
148
+ }
127
149
  const method = req.method ?? 'GET';
150
+ if (method === 'POST' && !hasJsonContentType(req)) {
151
+ sendText(res, 415, 'unsupported media type: expected application/json');
152
+ return;
153
+ }
128
154
  const parsed = new URL(req.url ?? '/', 'http://127.0.0.1');
129
155
  const pathname = parsed.pathname;
130
156
  if (pathname === '/' && method === 'GET') {
@@ -214,8 +240,9 @@ export async function startHubServer(opts) {
214
240
  }
215
241
  if (rest.startsWith('/api/')) {
216
242
  req.url = rest;
217
- if (rest === '/api/run') {
218
- await entry.runApi(req, res);
243
+ if (rest === '/api/backlog-browser' && method === 'POST') {
244
+ const result = await entry.browser.ensure();
245
+ sendJson(res, result.ok ? 200 : result.code, result);
219
246
  return;
220
247
  }
221
248
  if (entry.broker.handler(req, res)) {
@@ -5,14 +5,14 @@ import { fileURLToPath } from 'node:url';
5
5
  /** The nine pipeline phases; must stay consistent with src/templates/workflow-block.md. */
6
6
  export const PIPELINE_PHASES = [
7
7
  { n: 1, name: 'Idea', gate: 'User states a need; capture it before doing anything else' },
8
- { n: 2, name: 'Brainstorming', gate: 'Explore intent, requirements and design before any creative work' },
8
+ { n: 2, name: 'Brainstorming', gate: 'Explore intent, requirements and design before any creative work', command: '/superpowers:brainstorming' },
9
9
  { n: 3, name: 'Design gate', gate: 'Human approves the design document' },
10
- { n: 4, name: 'Spec-to-backlog', gate: 'Decompose the approved design into reviewed tasks with acceptance criteria' },
11
- { n: 5, name: 'Review gate', gate: 'Human reviews specs and acceptance criteria before any code exists' },
12
- { n: 6, name: 'Plan-before-code', gate: 'A written implementation plan is approved by the human' },
13
- { n: 7, name: 'TDD implementation', gate: 'Failing test first, then code; one task per session/PR' },
14
- { n: 8, name: 'Verification & final summary', gate: 'Run tests/lint/typecheck; verification evidence before success claims' },
15
- { n: 9, name: 'Merge & archive', gate: 'Merge the branch, then close/archive the task via the backlog CLI' },
10
+ { n: 4, name: 'Spec-to-backlog', gate: 'Decompose the approved design into reviewed tasks with acceptance criteria', command: '/spec-to-backlog' },
11
+ { n: 5, name: 'Review gate', gate: 'Human reviews specs and acceptance criteria before any code exists', command: '/task-review-gate' },
12
+ { n: 6, name: 'Plan-before-code', gate: 'A written implementation plan is approved by the human', command: '/superpowers:writing-plans' },
13
+ { n: 7, name: 'TDD implementation', gate: 'Failing test first, then code; one task per session/PR', command: '/superpowers:subagent-driven-development' },
14
+ { n: 8, name: 'Verification & final summary', gate: 'Run tests/lint/typecheck; verification evidence before success claims', command: '/superpowers:verification-before-completion' },
15
+ { n: 9, name: 'Merge & archive', gate: 'Merge the branch, then close/archive the task via the backlog CLI', command: 'backlog task archive <id>' },
16
16
  ];
17
17
  function readTemplate() {
18
18
  const here = dirname(fileURLToPath(import.meta.url)); // src/dashboard at dev time, dist/dashboard at runtime
@@ -2,83 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { isAbsolute, join } from 'node:path';
4
4
  import process from 'node:process';
5
- import crossSpawn from 'cross-spawn';
6
- import { resolveBacklogBin } from '../lib/run.js';
7
5
  export const DASHBOARD_PORT = 6428;
8
- const WHITELIST = new Map([
9
- ['browser', ['browser']],
10
- ['board', ['board']],
11
- ]);
12
- function isRecord(v) {
13
- return typeof v === 'object' && v !== null && !Array.isArray(v);
14
- }
15
- async function readBody(req) {
16
- const chunks = [];
17
- for await (const chunk of req) {
18
- chunks.push(Buffer.from(chunk));
19
- }
20
- return Buffer.concat(chunks).toString('utf8');
21
- }
22
- /** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
23
- export function createRunApiHandler(cwd) {
24
- return async (req, res) => {
25
- if (req.url !== '/api/run') {
26
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
27
- res.end('not found');
28
- return;
29
- }
30
- if (req.method !== 'POST') {
31
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
32
- res.end('not found');
33
- return;
34
- }
35
- let body;
36
- try {
37
- body = await readBody(req);
38
- }
39
- catch {
40
- res.writeHead(400, { 'content-type': 'application/json' });
41
- res.end(JSON.stringify({ error: 'failed to read body' }));
42
- return;
43
- }
44
- let payload;
45
- try {
46
- payload = JSON.parse(body);
47
- }
48
- catch {
49
- res.writeHead(400, { 'content-type': 'application/json' });
50
- res.end(JSON.stringify({ error: 'invalid json' }));
51
- return;
52
- }
53
- if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
54
- res.writeHead(400, { 'content-type': 'application/json' });
55
- res.end(JSON.stringify({ error: 'unknown command' }));
56
- return;
57
- }
58
- const bin = resolveBacklogBin(cwd);
59
- if (!bin) {
60
- res.writeHead(503, { 'content-type': 'application/json' });
61
- res.end(JSON.stringify({ error: 'backlog cli not found' }));
62
- return;
63
- }
64
- const args = WHITELIST.get(payload.command);
65
- try {
66
- const child = crossSpawn(bin, args, {
67
- cwd,
68
- detached: true,
69
- stdio: 'ignore',
70
- });
71
- child.on('error', () => { });
72
- child.unref();
73
- res.writeHead(200, { 'content-type': 'application/json' });
74
- res.end(JSON.stringify({ ok: true }));
75
- }
76
- catch (err) {
77
- res.writeHead(500, { 'content-type': 'application/json' });
78
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
79
- }
80
- };
81
- }
82
6
  /** SSE broker: keeps a set of response objects and broadcasts named events. */
83
7
  export function createReloadBroker() {
84
8
  const clients = new Set();
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { chmodSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { atomicWrite } from './atomic.js';
@@ -25,7 +25,13 @@ export function readHubState(home) {
25
25
  }
26
26
  export function writeHubState(home, state) {
27
27
  mkdirSync(join(home, '.super-backlog'), { recursive: true });
28
- atomicWrite(hubStatePath(home), JSON.stringify(state));
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
+ }
29
35
  }
30
36
  export function clearHubState(home, pid) {
31
37
  const current = readHubState(home);
@@ -2,13 +2,14 @@
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import process from 'node:process';
5
- import { runCapture } from './run.js';
5
+ import spawn from 'cross-spawn';
6
6
  const DAY_MS = 24 * 60 * 60 * 1000;
7
7
  const FETCH_TIMEOUT_MS = 2000;
8
8
  function cachePath(home) {
9
9
  return join(home, '.super-backlog', 'version-check.json');
10
10
  }
11
- function isNewer(latest, installed) {
11
+ /** Triple-numeric semver compare; non-numeric parts are treated as not newer. */
12
+ export function isNewerVersion(latest, installed) {
12
13
  const a = latest.split('.').slice(0, 3).map(Number);
13
14
  const b = installed.split('.').slice(0, 3).map(Number);
14
15
  if (a.length < 3 || b.length < 3)
@@ -45,23 +46,50 @@ function isStale(checkedAt, now) {
45
46
  const t = Date.parse(checkedAt);
46
47
  if (Number.isNaN(t))
47
48
  return true;
48
- return now.getTime() - t > DAY_MS;
49
+ const nowMs = now.getTime();
50
+ if (t > nowMs)
51
+ return true; // clock skew: a future checkedAt can never be trusted
52
+ return nowMs - t > DAY_MS;
53
+ }
54
+ // child.stdout is typed as Readable, but the underlying pipe stream (a
55
+ // net.Socket on POSIX, a Pipe wrap on Windows) always exposes unref() at
56
+ // runtime; the DOM/Node stream typings just don't declare it.
57
+ function unrefStream(stream) {
58
+ stream?.unref?.();
49
59
  }
50
60
  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
+ const work = new Promise((resolvePromise) => {
62
+ let child;
63
+ try {
64
+ child = spawn('npm', ['view', 'super-backlog', 'version'], {
65
+ cwd: process.cwd(),
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ });
68
+ }
69
+ catch {
70
+ resolvePromise(null);
71
+ return;
72
+ }
73
+ let out = '';
74
+ unrefStream(child.stdout);
75
+ child.stdout?.on('data', (chunk) => {
76
+ out += chunk.toString('utf8');
77
+ });
78
+ child.on('error', () => resolvePromise(null));
79
+ child.on('close', (code) => {
80
+ if (code !== 0) {
81
+ resolvePromise(null);
82
+ return;
83
+ }
84
+ const line = out.split(/\r?\n/).find((l) => l.trim() !== '');
85
+ const v = line?.trim();
86
+ resolvePromise(v === undefined || v === '' ? null : v);
87
+ });
88
+ child.unref();
61
89
  });
62
90
  let timer;
63
- const timeout = new Promise((resolve) => {
64
- timer = setTimeout(() => resolve(null), FETCH_TIMEOUT_MS);
91
+ const timeout = new Promise((resolveTimeout) => {
92
+ timer = setTimeout(() => resolveTimeout(null), FETCH_TIMEOUT_MS);
65
93
  timer.unref();
66
94
  });
67
95
  try {
@@ -79,7 +107,7 @@ export async function applyVersionHint(installed, deps) {
79
107
  if (deps.env.SBL_SKIP_UPDATE_CHECK)
80
108
  return;
81
109
  const cache = readCache(deps.home);
82
- if (cache && isNewer(cache.latest, installed)) {
110
+ if (cache && isNewerVersion(cache.latest, installed)) {
83
111
  deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
84
112
  }
85
113
  if (!cache || isStale(cache.checkedAt, deps.now())) {
@@ -122,6 +122,38 @@
122
122
  #task-dialog[open] { animation: none; }
123
123
  }
124
124
  @keyframes sbl-dialog-in { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
125
+
126
+ /* ---------- Backlog browser overlay ---------- */
127
+ #backlog-dialog {
128
+ width: 96vw; height: 94vh; max-width: none; max-height: none; padding: 0;
129
+ display: none; flex-direction: column;
130
+ border: 1px solid var(--line-strong); border-radius: 14px;
131
+ background: var(--surface); color: var(--text);
132
+ box-shadow: 0 24px 80px rgba(0,0,0,.55);
133
+ }
134
+ #backlog-dialog[open] { display: flex; animation: sbl-dialog-in .18s ease-out; }
135
+ #backlog-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
136
+ @media (prefers-reduced-motion: reduce) {
137
+ #backlog-dialog[open] { animation: none; }
138
+ }
139
+ .backlog-dialog-bar {
140
+ display: flex; align-items: center; gap: 14px; padding: 8px 14px;
141
+ border-bottom: 1px solid var(--line-strong); background: var(--surface-2);
142
+ }
143
+ .backlog-dialog-title { font-weight: 700; font-size: .9rem; }
144
+ .backlog-dialog-bar a {
145
+ margin-left: auto; font-family: var(--mono); font-size: .72rem;
146
+ color: var(--muted); text-decoration: none;
147
+ }
148
+ .backlog-dialog-bar a:hover { color: var(--accent); }
149
+ #backlog-close {
150
+ font: inherit; font-size: 1.1rem; line-height: 1; cursor: pointer;
151
+ color: var(--muted); background: none; border: 1px solid transparent;
152
+ border-radius: 8px; padding: 4px 10px;
153
+ }
154
+ #backlog-close:hover { color: var(--text); border-color: var(--line); }
155
+ #backlog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
156
+ #backlog-frame { flex: 1; width: 100%; border: 0; background: var(--bg); }
125
157
  .dialog-content { padding: 22px 24px 28px; }
126
158
  .detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
127
159
  .detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
@@ -245,23 +277,75 @@
245
277
  .spark-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
246
278
 
247
279
  /* ---------- Pipeline stepper ---------- */
248
- .stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); margin: 4px 0 8px; }
249
- .step { position: relative; min-width: 0; padding: 0 3px; text-align: center; }
280
+ .stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 2px; margin: 4px 0 6px; }
281
+ .step {
282
+ position: relative; min-width: 0; padding: 10px 4px 12px; text-align: center;
283
+ background: none; border: 1px solid transparent; border-radius: 10px; cursor: pointer;
284
+ font: inherit; color: inherit; transition: background .15s ease, border-color .15s ease;
285
+ }
250
286
  .step::before {
251
- content: ""; position: absolute; top: 15px; left: -50%; width: 100%; height: 2px;
287
+ content: ""; position: absolute; top: 27px; left: -50%; width: 100%; height: 2px;
252
288
  background: var(--line-strong); z-index: 0;
253
289
  }
254
290
  .step:first-child::before { display: none; }
291
+ .step:hover { background: var(--surface-2); border-color: var(--line); }
292
+ .step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
293
+ .step[aria-expanded="true"] { background: var(--accent-dim); border-color: var(--accent); }
294
+ .step[aria-expanded="true"].gate { border-color: var(--warn); background: var(--warn-bg); }
255
295
  .step-num {
256
- position: relative; z-index: 1; width: 32px; height: 32px; margin: 0 auto 8px;
296
+ position: relative; z-index: 1; width: 34px; height: 34px; margin: 0 auto 10px;
257
297
  display: flex; align-items: center; justify-content: center;
258
- border-radius: 50%; font-family: var(--mono); font-size: .82rem; font-weight: 700;
298
+ border-radius: 50%; font-family: var(--mono); font-size: .9rem; font-weight: 700;
259
299
  background: var(--surface-2); border: 2px solid var(--line-strong); color: var(--muted);
260
300
  }
261
301
  .step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px rgba(255,180,84,.25); }
262
- .step-label { font-size: .63rem; color: var(--muted); line-height: 1.35; }
263
- .step-label b { display: block; color: var(--text); font-size: .71rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
264
- .step.gate .step-label b { color: var(--warn); }
302
+ .step-label {
303
+ display: block; font-size: .8rem; font-weight: 600; color: var(--text); line-height: 1.3;
304
+ overflow-wrap: break-word;
305
+ }
306
+ .step.gate .step-label { color: var(--warn); }
307
+
308
+ /* ---------- Phase detail panel ---------- */
309
+ #phase-detail {
310
+ margin: 2px 0 12px; padding: 14px 18px; border: 1px solid var(--line-strong);
311
+ border-left: 3px solid var(--accent); border-radius: 10px; background: var(--surface);
312
+ }
313
+ #phase-detail.open { animation: phasein .18s ease; }
314
+ #phase-detail.gate { border-left-color: var(--warn); }
315
+ @keyframes phasein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
316
+ .phase-detail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
317
+ .phase-detail-num { font-family: var(--mono); font-size: .85rem; font-weight: 700; color: var(--accent); }
318
+ #phase-detail.gate .phase-detail-num { color: var(--warn); }
319
+ .phase-detail-name { font-size: 1rem; font-weight: 700; }
320
+ .phase-detail-gate { margin: 0 0 10px; font-size: .88rem; line-height: 1.55; color: var(--muted); }
321
+ .phase-cmd {
322
+ display: inline-flex; align-items: center; gap: 10px; padding: 7px 12px;
323
+ font: inherit; cursor: pointer; border-radius: 8px;
324
+ background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--text);
325
+ transition: border-color .15s ease;
326
+ }
327
+ .phase-cmd:hover { border-color: var(--accent); }
328
+ .phase-cmd:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
329
+ .phase-cmd .cmd-line { font-family: var(--mono); font-size: .8rem; color: var(--accent); }
330
+ .phase-cmd .cmd-title { font-size: .72rem; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }
331
+ @media (prefers-reduced-motion: reduce) {
332
+ .step, .phase-cmd { transition: none; }
333
+ #phase-detail.open { animation: none; }
334
+ }
335
+
336
+ /* ---------- Sidebar version + update badge ---------- */
337
+ .side-version {
338
+ display: flex; align-items: center; gap: 8px; margin: 6px 0 2px;
339
+ font-family: var(--mono); font-size: .78rem; color: var(--dim);
340
+ }
341
+ .update-badge {
342
+ font: inherit; font-family: var(--mono); font-size: .68rem; cursor: pointer;
343
+ color: var(--warn); background: var(--warn-bg); border: 1px solid var(--warn);
344
+ border-radius: 999px; padding: 2px 9px; transition: box-shadow .15s ease;
345
+ }
346
+ .update-badge:hover { box-shadow: 0 0 10px rgba(255,180,84,.3); }
347
+ .update-badge:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
348
+ .update-badge .cmd-title { font-size: inherit; font-weight: 600; color: inherit; }
265
349
 
266
350
  /* ---------- Dependency flow ---------- */
267
351
  .sub-head {
@@ -302,6 +386,7 @@
302
386
  <aside class="sbl-side">
303
387
  <div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b></div>
304
388
  <div class="kicker">SUPERPOWERS × BACKLOG.MD</div>
389
+ <div class="side-version" id="side-version"><span>v__KIT_VERSION__</span></div>
305
390
  <nav aria-label="Dashboard sections">
306
391
  <a href="#sec-01"><span class="n">01</span>Board &amp; Quick Actions</a>
307
392
  <a href="#sec-02"><span class="n">02</span>Status</a>
@@ -322,14 +407,12 @@
322
407
  <main>
323
408
 
324
409
  <section id="sec-01">
325
- <div class="sec-head"><span class="sec-num">01</span><h2>Board &amp; Quick Actions</h2><span class="tagline">commands &middot; board mirror only</span></div>
410
+ <div class="sec-head"><span class="sec-num">01</span><h2>Board &amp; Quick Actions</h2><span class="tagline">the full Backlog.md UI &middot; one click away</span></div>
326
411
  <div id="quickactions" class="mount">
327
412
  <div class="cmd-row" id="cmd-buttons">
328
- <button type="button" class="cmd-btn" data-cmd="browser"><span class="cmd-title">Backlog Browser</span><span class="cmd-line">backlog browser</span></button>
329
- <button type="button" class="cmd-btn" data-cmd="board"><span class="cmd-title">Backlog Board</span><span class="cmd-line">backlog board</span></button>
330
- <button type="button" class="cmd-btn" data-copy="sbl dashboard"><span class="cmd-title">Live Dashboard</span><span class="cmd-line">sbl dashboard</span></button>
413
+ <button type="button" class="cmd-btn" id="backlog-btn"><span class="cmd-title">Backlog</span><span class="cmd-line">board &middot; tasks &middot; docs &middot; decisions</span></button>
331
414
  </div>
332
- <p class="hint">The board mirrors Backlog.md &mdash; it never writes execution state.</p>
415
+ <p class="hint">Opens the Backlog.md browser in an overlay &mdash; served locally per project, started on demand.</p>
333
416
  </div>
334
417
  <div id="drafts" class="mount">
335
418
  <h3 class="sub-head">Drafts</h3>
@@ -373,8 +456,9 @@
373
456
  </section>
374
457
 
375
458
  <section id="sec-05">
376
- <div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included</span></div>
459
+ <div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included &middot; click a step for details</span></div>
377
460
  <div id="stepper" class="mount"></div>
461
+ <div id="phase-detail" hidden></div>
378
462
  <h3 class="sub-head">Flow</h3>
379
463
  <div id="depgraph" class="mount"></div>
380
464
  </section>
@@ -399,6 +483,15 @@
399
483
  </div>
400
484
 
401
485
  <dialog id="task-dialog" aria-label="Task details"></dialog>
486
+
487
+ <dialog id="backlog-dialog" aria-label="Backlog browser">
488
+ <div class="backlog-dialog-bar">
489
+ <span class="backlog-dialog-title">Backlog</span>
490
+ <a id="backlog-open-tab" href="#" target="_blank" rel="noopener">open in new tab</a>
491
+ <button type="button" id="backlog-close" aria-label="Close">&#215;</button>
492
+ </div>
493
+ <iframe id="backlog-frame" title="Backlog.md browser"></iframe>
494
+ </dialog>
402
495
  <div id="sbl-tip" role="tooltip" hidden></div>
403
496
 
404
497
  <script type="application/json" id="sbl-data">__SBL_DATA_JSON__</script>
@@ -421,6 +514,16 @@
421
514
  badge.textContent = data.source === 'fallback-empty' ? 'no live data' : 'live backlog data';
422
515
  }
423
516
 
517
+ var sideVersion = $('#side-version');
518
+ if (sideVersion && data.latestVersion) {
519
+ var upd = el('button', 'update-badge');
520
+ upd.type = 'button';
521
+ upd.appendChild(el('span', 'cmd-title', 'v' + data.latestVersion + ' available'));
522
+ upd.setAttribute('data-tip', 'Update: npm i -g super-backlog (click to copy)');
523
+ upd.addEventListener('click', function () { copyCommand(upd, 'npm i -g super-backlog'); });
524
+ sideVersion.appendChild(upd);
525
+ }
526
+
424
527
  var KEYS = ['id', 'title', 'status', 'milestone', 'priority', 'assignee', 'updated'];
425
528
  var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null };
426
529
  function field(task, key) {
@@ -511,26 +614,51 @@
511
614
  /* ---------- Quick action buttons ---------- */
512
615
  function cmdFeedback(btn, text) {
513
616
  var title = btn.querySelector('.cmd-title');
514
- if (!title || btn.getAttribute('data-busy')) return;
515
- btn.setAttribute('data-busy', '1');
516
- var orig = title.textContent;
617
+ if (!title) return;
618
+ if (btn.__sblFbTimer) {
619
+ clearTimeout(btn.__sblFbTimer); /* newer feedback overrides the pending one */
620
+ } else {
621
+ btn.__sblFbOrig = title.textContent;
622
+ }
517
623
  title.textContent = text;
518
- setTimeout(function () { title.textContent = orig; btn.removeAttribute('data-busy'); }, 1200);
624
+ btn.__sblFbTimer = setTimeout(function () {
625
+ title.textContent = btn.__sblFbOrig;
626
+ btn.__sblFbTimer = null;
627
+ }, 1200);
519
628
  }
520
629
  function copyCommand(btn, command) {
521
630
  if (navigator.clipboard) navigator.clipboard.writeText(command).catch(function () {});
522
631
  cmdFeedback(btn, 'copied \u2713');
523
632
  }
524
- document.querySelectorAll('#cmd-buttons .cmd-btn').forEach(function (b) {
525
- b.addEventListener('click', function () {
526
- var copy = b.getAttribute('data-copy');
527
- if (copy) { copyCommand(b, copy); return; }
528
- var cmd = b.getAttribute('data-cmd');
529
- fetch('api/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command: cmd }) })
530
- .then(function (res) { if (!res.ok) throw new Error('run failed'); cmdFeedback(b, 'started \u2713'); })
531
- .catch(function () {});
633
+ var backlogBtn = document.getElementById('backlog-btn');
634
+ var backlogDialog = document.getElementById('backlog-dialog');
635
+ var backlogFrame = document.getElementById('backlog-frame');
636
+ var backlogTab = document.getElementById('backlog-open-tab');
637
+ function openBacklog(url) {
638
+ if (!backlogDialog || !backlogFrame) return;
639
+ if (backlogFrame.getAttribute('src') !== url) backlogFrame.setAttribute('src', url);
640
+ if (backlogTab) backlogTab.setAttribute('href', url);
641
+ backlogDialog.showModal();
642
+ }
643
+ if (backlogBtn) {
644
+ backlogBtn.addEventListener('click', function () {
645
+ cmdFeedback(backlogBtn, 'starting\u2026');
646
+ fetch('api/backlog-browser', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
647
+ .then(function (res) { return res.json().then(function (data) { return { ok: res.ok, data: data }; }); })
648
+ .then(function (r) {
649
+ if (!r.ok || !r.data || !r.data.url) throw new Error('start failed');
650
+ openBacklog(r.data.url);
651
+ })
652
+ .catch(function () { cmdFeedback(backlogBtn, 'failed \u2717'); });
532
653
  });
533
- });
654
+ }
655
+ var backlogClose = document.getElementById('backlog-close');
656
+ if (backlogClose && backlogDialog) {
657
+ backlogClose.addEventListener('click', function () { backlogDialog.close(); });
658
+ backlogDialog.addEventListener('click', function (e) {
659
+ if (e.target === backlogDialog) backlogDialog.close();
660
+ });
661
+ }
534
662
 
535
663
  /* ---------- Drafts ---------- */
536
664
  function renderDrafts(drafts) {
@@ -702,23 +830,60 @@
702
830
  mount.appendChild(svg);
703
831
  }
704
832
 
833
+ function renderPhaseDetail(panel, p, isGate) {
834
+ panel.textContent = '';
835
+ panel.classList.toggle('gate', isGate);
836
+ var head = el('div', 'phase-detail-head');
837
+ head.appendChild(el('span', 'phase-detail-num', String(p.n).padStart(2, '0')));
838
+ head.appendChild(el('span', 'phase-detail-name', p.name));
839
+ panel.appendChild(head);
840
+ panel.appendChild(el('p', 'phase-detail-gate', p.gate));
841
+ if (p.command) {
842
+ var cmd = el('button', 'phase-cmd');
843
+ cmd.type = 'button';
844
+ cmd.appendChild(el('span', 'cmd-line', p.command));
845
+ cmd.appendChild(el('span', 'cmd-title', 'copy'));
846
+ cmd.addEventListener('click', function () { copyCommand(cmd, p.command); });
847
+ panel.appendChild(cmd);
848
+ }
849
+ panel.hidden = false;
850
+ panel.classList.remove('open');
851
+ void panel.offsetWidth; /* restart the open animation on re-render */
852
+ panel.classList.add('open');
853
+ }
854
+
705
855
  function renderStepper(mount, phases) {
706
856
  mount.textContent = '';
707
857
  if (!phases || phases.length === 0) {
708
858
  mount.appendChild(el('p', 'hint', 'Pipeline unavailable.'));
709
859
  return;
710
860
  }
861
+ var panel = document.getElementById('phase-detail');
711
862
  var wrap = el('div', 'stepper');
863
+ var current = null;
712
864
  phases.forEach(function (p) {
713
- var step = el('div', 'step' + (/gate/i.test(String(p.name)) ? ' gate' : ''));
865
+ var isGate = /gate/i.test(String(p.name));
866
+ var step = el('button', 'step' + (isGate ? ' gate' : ''));
867
+ step.type = 'button';
714
868
  step.setAttribute('data-phase', p.n);
869
+ step.setAttribute('aria-expanded', 'false');
870
+ step.setAttribute('aria-controls', 'phase-detail');
715
871
  step.appendChild(el('div', 'step-num', String(p.n)));
716
- var lab = el('div', 'step-label');
717
- var b = document.createElement('b');
718
- b.textContent = p.name;
719
- lab.appendChild(b);
720
- lab.appendChild(document.createTextNode(p.gate));
721
- step.appendChild(lab);
872
+ step.appendChild(el('span', 'step-label', p.name));
873
+ step.addEventListener('click', function () {
874
+ if (!panel) return;
875
+ var wasOpen = current === step;
876
+ wrap.querySelectorAll('.step').forEach(function (s) { s.setAttribute('aria-expanded', 'false'); });
877
+ if (wasOpen) {
878
+ panel.hidden = true;
879
+ panel.classList.remove('open');
880
+ current = null;
881
+ return;
882
+ }
883
+ step.setAttribute('aria-expanded', 'true');
884
+ current = step;
885
+ renderPhaseDetail(panel, p, isGate);
886
+ });
722
887
  wrap.appendChild(step);
723
888
  });
724
889
  mount.appendChild(wrap);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "1.1.0",
3
+ "version": "1.2.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": {
@@ -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
- }