super-backlog 1.3.1 → 1.3.2

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
@@ -73,7 +73,9 @@ sbl dashboard # live Project Dashboard on http://localhost:642
73
73
  | `dashboard.html` | generated Project Dashboard | not installed in user projects; generated on demand by `sbl dashboard` |
74
74
  | `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
75
75
 
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.
76
+ Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]` (alias: `sbl db`). See `sbl help` for every flag.
77
+
78
+ `sbl update` first self-updates a globally installed CLI to the latest npm version and re-runs itself (opt out with `--no-self` or `SBL_SKIP_UPDATE_CHECK`), then refreshes injected files.
77
79
 
78
80
  ## Model router (opt-in)
79
81
 
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ Commands:
20
20
  init Install the kit into the current project
21
21
  uninstall Remove kit-managed files (project data kept unless --with-backlog)
22
22
  update Refresh kit-managed files and report upstream versions
23
- dashboard Start the project dashboard server (live-reload)
23
+ dashboard Start the project dashboard server (live-reload) (alias: db)
24
24
  models Manage the model router (show, enable, disable, discover)
25
25
  doctor Check the environment (node, PowerShell policy, backlog CLI)
26
26
 
@@ -38,7 +38,7 @@ uninstall options:
38
38
  --fix-all Also remove the global npm package (no prompts)
39
39
 
40
40
  update options:
41
- (none) Refreshes injected files, skills, hook; prints upstream versions
41
+ --no-self Skip self-updating the CLI before refreshing
42
42
 
43
43
  dashboard options:
44
44
  --port <n> Port for the dashboard server (default: 6428)
@@ -102,12 +102,17 @@ export async function runCli(argv) {
102
102
  });
103
103
  }
104
104
  case 'update': {
105
- const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
105
+ const parsed = parseArgs({
106
+ args: rest,
107
+ allowPositionals: true,
108
+ options: { 'no-self': { type: 'boolean' } },
109
+ });
106
110
  return await runUpdate(process.cwd(), {
107
111
  values: parsed.values,
108
112
  positionals: parsed.positionals,
109
113
  });
110
114
  }
115
+ case 'db':
111
116
  case 'dashboard': {
112
117
  const parsed = parseArgs({
113
118
  args: rest,
@@ -11,6 +11,9 @@ import { atomicWrite } from '../lib/atomic.js';
11
11
  import { clearHubState, isPidAlive, newHubToken, readHubState, writeHubState } from '../lib/hub-state.js';
12
12
  import { projectSlug } from '../lib/slug.js';
13
13
  import { KIT_VERSION } from '../lib/version.js';
14
+ /** Max time to wait for an outdated hub to exit after killPid, in 100ms polls. */
15
+ const STOP_POLL_MAX_ATTEMPTS = 50;
16
+ const STOP_POLL_INTERVAL_MS = 100;
14
17
  async function regenerateInto(outPath, cwd) {
15
18
  const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
16
19
  atomicWrite(outPath, renderDashboard(data));
@@ -151,6 +154,9 @@ export async function runDashboard(cwd, args, deps = {}) {
151
154
  const attach = deps.attach ?? defaultAttach;
152
155
  const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
153
156
  const pid = (deps.nowPid ?? (() => process.pid))();
157
+ const isAlive = deps.isAlive ?? isPidAlive;
158
+ const killPid = deps.killPid ?? ((p) => { process.kill(p); });
159
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
154
160
  let slugResult;
155
161
  try {
156
162
  slugResult = projectSlug(cwd);
@@ -165,22 +171,44 @@ export async function runDashboard(cwd, args, deps = {}) {
165
171
  }
166
172
  const slug = slugResult.slug;
167
173
  const state = readHubState(home);
168
- if (state !== null && isPidAlive(state.pid)) {
174
+ if (state !== null && isAlive(state.pid)) {
169
175
  try {
170
176
  const status = await attach(`http://127.0.0.1:${state.port}/api/hub/status?token=${encodeURIComponent(state.token)}`, undefined);
171
177
  if (status.status === 200) {
172
- if (values['port'] !== undefined && port !== state.port) {
173
- console.error(`error: a hub is already running on ${state.port}`);
178
+ const statusJson = typeof status.json === 'object' && status.json !== null
179
+ ? status.json
180
+ : undefined;
181
+ const liveVersion = typeof statusJson?.version === 'string' ? statusJson.version : undefined;
182
+ const effectiveVersion = liveVersion ?? state.version;
183
+ if (effectiveVersion === KIT_VERSION) {
184
+ if (values['port'] !== undefined && port !== state.port) {
185
+ console.error(`error: a hub is already running on ${state.port}`);
186
+ return 1;
187
+ }
188
+ return await attachToHub({
189
+ cwd,
190
+ port: state.port,
191
+ token: state.token,
192
+ attach,
193
+ openBrowser,
194
+ noOpen,
195
+ });
196
+ }
197
+ console.error(`hub v${effectiveVersion ?? 'unknown'} does not match this CLI (v${KIT_VERSION}) — restarting it`);
198
+ killPid(state.pid);
199
+ let dead = !isAlive(state.pid);
200
+ for (let attempt = 0; !dead && attempt < STOP_POLL_MAX_ATTEMPTS; attempt++) {
201
+ await sleep(STOP_POLL_INTERVAL_MS);
202
+ dead = !isAlive(state.pid);
203
+ }
204
+ if (dead) {
205
+ clearHubState(home, state.pid);
206
+ // fall through to the fresh-start path below
207
+ }
208
+ else {
209
+ console.error(`error: could not stop the outdated hub (pid ${state.pid}) — stop it manually and re-run`);
174
210
  return 1;
175
211
  }
176
- return await attachToHub({
177
- cwd,
178
- port: state.port,
179
- token: state.token,
180
- attach,
181
- openBrowser,
182
- noOpen,
183
- });
184
212
  }
185
213
  }
186
214
  catch {
@@ -206,7 +234,7 @@ export async function runDashboard(cwd, args, deps = {}) {
206
234
  console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
207
235
  return 1;
208
236
  }
209
- writeHubState(home, { pid, port: hub.port, token });
237
+ writeHubState(home, { pid, port: hub.port, token, version: KIT_VERSION });
210
238
  const result = hub.register({ cwd, file: outPath, regenerate });
211
239
  if (!result.ok) {
212
240
  await hub.close();
@@ -1,13 +1,86 @@
1
1
  // src/commands/update.ts
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { basename, join, resolve } from 'node:path';
4
4
  import process from 'node:process';
5
+ import spawn from 'cross-spawn';
5
6
  import { executeActions, findGitDir, InvalidJsonError, validateJsonFile, RefusalError, UpstreamError } from '../init/execute.js';
6
7
  import { planInit } from '../init/planner.js';
7
8
  import { GUARD_RE } from '../lib/hooks.js';
8
9
  import { detectPackageManager } from '../lib/pm.js';
9
10
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
11
+ import { detectInstallKind, runSelfUpdate } from '../lib/self-update.js';
10
12
  import { KIT_VERSION } from '../lib/version.js';
13
+ import { fetchLatestVersion } from '../lib/version-check.js';
14
+ /** Longer than the startup version-hint fetch: this one blocks `sbl update` directly. */
15
+ const SELF_UPDATE_FETCH_TIMEOUT_MS = 10000;
16
+ function resolveGlobalRoot(cwd) {
17
+ const r = runCapture('npm', ['root', '-g'], cwd);
18
+ if (r.status !== 0)
19
+ return null;
20
+ const line = firstLine(r.stdout);
21
+ return line === '' ? null : line;
22
+ }
23
+ function resolveBinRealPath() {
24
+ const argvBin = process.argv[1];
25
+ if (!argvBin)
26
+ return null;
27
+ try {
28
+ return realpathSync(argvBin);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Runs the self-update check/install and, when a new version was installed,
36
+ * re-execs the new binary once (env-guarded via SBL_SELF_UPDATED) and
37
+ * returns its exit code. Returns null when the caller should fall through
38
+ * to the normal refresh (no update available, offline, install failed, or
39
+ * the update was skipped entirely).
40
+ */
41
+ async function maybeSelfUpdate(cwd, args, override) {
42
+ const skip = args.values['no-self'] === true ||
43
+ Boolean(process.env.SBL_SELF_UPDATED) ||
44
+ Boolean(process.env.SBL_SKIP_UPDATE_CHECK) ||
45
+ Boolean(process.env.SBL_FORCE_OFFLINE);
46
+ if (skip)
47
+ return null;
48
+ const binRealPath = override.binRealPath !== undefined ? override.binRealPath : resolveBinRealPath();
49
+ const globalRoot = override.globalRoot !== undefined ? override.globalRoot : resolveGlobalRoot(cwd);
50
+ const installKind = binRealPath === null ? 'unknown' : detectInstallKind(binRealPath, cwd, globalRoot);
51
+ const fetchLatest = override.fetchLatest ?? (() => fetchLatestVersion(SELF_UPDATE_FETCH_TIMEOUT_MS));
52
+ const npmInstallGlobal = override.npmInstallGlobal ?? ((spec) => runCapture('npm', ['i', '-g', spec], cwd));
53
+ const result = await runSelfUpdate({
54
+ installed: KIT_VERSION,
55
+ fetchLatest,
56
+ installKind,
57
+ npmInstallGlobal,
58
+ log: (line) => console.log(line),
59
+ warn: (line) => console.warn(`warning: ${line}`),
60
+ });
61
+ if (result.kind !== 'updated')
62
+ return null;
63
+ // npm rewrites the global package's files in place, so process.argv[1]
64
+ // still points at a valid path after the install -- re-resolve anyway in
65
+ // case the realpath target moved (e.g. a version-pinned symlink).
66
+ const updatedBinPath = override.binRealPath !== undefined ? override.binRealPath : resolveBinRealPath();
67
+ if (updatedBinPath === null)
68
+ return null; // can't re-exec without a bin path; fall through on the old version
69
+ const spawnSelf = override.spawnSelf ??
70
+ ((binPath, rArgs, execCwd, env) => {
71
+ const r = spawn.sync(process.execPath, [binPath, 'update', ...rArgs], {
72
+ cwd: execCwd,
73
+ stdio: 'inherit',
74
+ env,
75
+ });
76
+ return { status: r.status ?? 1 };
77
+ });
78
+ const res = spawnSelf(updatedBinPath, args.positionals, cwd, {
79
+ ...process.env,
80
+ SBL_SELF_UPDATED: '1',
81
+ });
82
+ return res.status ?? 1;
83
+ }
11
84
  const REFRESH_KINDS = new Set([
12
85
  'inject-agents-block',
13
86
  'write-claude-pointer',
@@ -29,7 +102,10 @@ function guardHookInstalled(cwd) {
29
102
  return false;
30
103
  return GUARD_RE.test(readFileSync(hookPath, 'utf8'));
31
104
  }
32
- export async function runUpdate(cwd, _args) {
105
+ export async function runUpdate(cwd, args, selfUpdateOverride = {}) {
106
+ const selfUpdateExitCode = await maybeSelfUpdate(cwd, args, selfUpdateOverride);
107
+ if (selfUpdateExitCode !== null)
108
+ return selfUpdateExitCode;
33
109
  // Up-front detection-failure check (mirrors uninstall): refuse before mutating anything.
34
110
  for (const f of ['package.json', 'opencode.json']) {
35
111
  const p = join(cwd, f);
@@ -100,14 +176,17 @@ export async function runUpdate(cwd, _args) {
100
176
  warnings.push(`\`${bin} --version\` failed with exit code ${local.status}`);
101
177
  }
102
178
  }
179
+ const probePublished = selfUpdateOverride.probePublished ??
180
+ (() => {
181
+ // test seam: SBL_FORCE_OFFLINE makes e2e runs take the offline path deterministically
182
+ if (process.env.SBL_FORCE_OFFLINE)
183
+ throw new Error('forced offline');
184
+ const view = runCapture('npm', ['view', 'backlog.md', 'version'], cwd);
185
+ return view.status === 0 ? firstLine(view.stdout) : null;
186
+ });
103
187
  let published = null;
104
188
  try {
105
- // test seam: SBL_FORCE_OFFLINE makes e2e runs take the offline path deterministically
106
- if (process.env.SBL_FORCE_OFFLINE)
107
- throw new Error('forced offline');
108
- const view = runCapture('npm', ['view', 'backlog.md', 'version'], cwd);
109
- if (view.status === 0)
110
- published = firstLine(view.stdout);
189
+ published = probePublished();
111
190
  }
112
191
  catch {
113
192
  published = null;
@@ -166,7 +166,7 @@ export async function startHubServer(opts) {
166
166
  sendText(res, 401, 'unauthorized');
167
167
  return;
168
168
  }
169
- sendJson(res, 200, { pid: process.pid, port });
169
+ sendJson(res, 200, { pid: process.pid, port, version: KIT_VERSION });
170
170
  return;
171
171
  }
172
172
  if (pathname === '/api/hub/register' && method === 'POST') {
@@ -16,8 +16,8 @@ export function readHubState(home) {
16
16
  typeof parsed.token !== 'string') {
17
17
  return null;
18
18
  }
19
- const { pid, port, token } = parsed;
20
- return { pid, port, token };
19
+ const { pid, port, token, version } = parsed;
20
+ return typeof version === 'string' ? { pid, port, token, version } : { pid, port, token };
21
21
  }
22
22
  catch {
23
23
  return null;
@@ -0,0 +1,57 @@
1
+ // src/lib/self-update.ts
2
+ import { join } from 'node:path';
3
+ import process from 'node:process';
4
+ import { isNewerVersion } from './version-check.js';
5
+ /**
6
+ * Normalizes separators for prefix comparison and, on win32, case as well:
7
+ * `npm root -g` and `realpathSync` can disagree on drive-letter/path casing
8
+ * (`C:\` vs `c:\`) even for the same install, and Windows paths are
9
+ * case-insensitive anyway, so comparing case-sensitively there would
10
+ * misclassify a real global install as `unknown`. Takes an optional
11
+ * `caseInsensitive` override (default: `process.platform === 'win32'`) so
12
+ * the function stays pure and testable for both platforms' behavior.
13
+ */
14
+ function normalizeForCompare(p, caseInsensitive) {
15
+ const withForwardSlashes = p.replace(/\\/g, '/');
16
+ return caseInsensitive ? withForwardSlashes.toLowerCase() : withForwardSlashes;
17
+ }
18
+ function isUnder(path, root) {
19
+ return path === root || path.startsWith(`${root}/`);
20
+ }
21
+ /**
22
+ * Classifies the running binary's real path as a local (project
23
+ * node_modules) install, a global npm install, or unknown (treated the
24
+ * same as local: never mutated, hint only). Pure function -- callers
25
+ * gather binRealPath (from `process.argv[1]` + `realpathSync`) and
26
+ * globalRoot (from `npm root -g`, captured once, null on failure).
27
+ */
28
+ export function detectInstallKind(binRealPath, cwd, globalRoot, caseInsensitive = process.platform === 'win32') {
29
+ const bin = normalizeForCompare(binRealPath, caseInsensitive);
30
+ const localRoot = normalizeForCompare(join(cwd, 'node_modules'), caseInsensitive);
31
+ if (isUnder(bin, localRoot))
32
+ return 'local';
33
+ if (globalRoot !== null && isUnder(bin, normalizeForCompare(globalRoot, caseInsensitive)))
34
+ return 'global';
35
+ return 'unknown';
36
+ }
37
+ export async function runSelfUpdate(deps) {
38
+ const latest = await deps.fetchLatest();
39
+ if (latest === null) {
40
+ deps.warn('could not check for a newer super-backlog (offline?)');
41
+ return { kind: 'unavailable' };
42
+ }
43
+ if (!isNewerVersion(latest, deps.installed)) {
44
+ return { kind: 'current' };
45
+ }
46
+ if (deps.installKind !== 'global') {
47
+ deps.log(`a newer super-backlog (${latest}) is available; update the dependency yourself, e.g. npm i -D super-backlog@${latest}`);
48
+ return { kind: 'skipped-local', latest };
49
+ }
50
+ const result = deps.npmInstallGlobal(`super-backlog@${latest}`);
51
+ if (result.status === 0) {
52
+ deps.log(`self-updated to ${latest}, re-running update...`);
53
+ return { kind: 'updated', latest };
54
+ }
55
+ deps.warn(`self-update to ${latest} failed (npm install exited ${String(result.status)}); continuing with ${deps.installed}`);
56
+ return { kind: 'failed', latest };
57
+ }
@@ -57,7 +57,8 @@ function isStale(checkedAt, now) {
57
57
  function unrefStream(stream) {
58
58
  stream?.unref?.();
59
59
  }
60
- export async function defaultFetchLatest() {
60
+ /** Queries the npm registry for the latest published version, racing a timeout. */
61
+ export async function fetchLatestVersion(timeoutMs = FETCH_TIMEOUT_MS) {
61
62
  const work = new Promise((resolvePromise) => {
62
63
  let child;
63
64
  try {
@@ -89,7 +90,7 @@ export async function defaultFetchLatest() {
89
90
  });
90
91
  let timer;
91
92
  const timeout = new Promise((resolveTimeout) => {
92
- timer = setTimeout(() => resolveTimeout(null), FETCH_TIMEOUT_MS);
93
+ timer = setTimeout(() => resolveTimeout(null), timeoutMs);
93
94
  timer.unref();
94
95
  });
95
96
  try {
@@ -103,12 +104,15 @@ export async function defaultFetchLatest() {
103
104
  clearTimeout(timer);
104
105
  }
105
106
  }
107
+ export async function defaultFetchLatest() {
108
+ return fetchLatestVersion(FETCH_TIMEOUT_MS);
109
+ }
106
110
  export async function applyVersionHint(installed, deps) {
107
111
  if (deps.env.SBL_SKIP_UPDATE_CHECK)
108
112
  return;
109
113
  const cache = readCache(deps.home);
110
114
  if (cache && isNewerVersion(cache.latest, installed)) {
111
- deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
115
+ deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: sbl update (or npm i -g super-backlog)`);
112
116
  }
113
117
  if (!cache || isStale(cache.checkedAt, deps.now())) {
114
118
  void deps
@@ -659,7 +659,7 @@
659
659
  var upd = el('button', 'update-badge');
660
660
  upd.type = 'button';
661
661
  upd.appendChild(el('span', 'cmd-title', 'v' + data.latestVersion + ' available'));
662
- upd.setAttribute('data-tip', 'Update: npm i -g super-backlog (click to copy)');
662
+ upd.setAttribute('data-tip', 'Update: run sbl update (click copies npm i -g super-backlog)');
663
663
  upd.addEventListener('click', function () { copyCommand(upd, 'npm i -g super-backlog'); });
664
664
  sideVersion.appendChild(upd);
665
665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
5
5
  "license": "MIT",
6
6
  "repository": {