super-backlog 0.6.0 → 0.7.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/dist/cli.js CHANGED
@@ -29,10 +29,12 @@ init options:
29
29
  --no-models Explicitly opt out of the model router
30
30
  --no-dashboard Skip generating the project dashboard
31
31
  --no-refresh-hook Skip the post-commit dashboard freshness hook
32
+ --fix-all Repair environment problems automatically (no prompts)
32
33
  --dry-run Show what would be done without writing anything
33
34
 
34
35
  uninstall options:
35
36
  --with-backlog Also permanently delete the backlog/ data directory
37
+ --fix-all Also remove the global npm package (no prompts)
36
38
 
37
39
  update options:
38
40
  (none) Refreshes injected files, skills, hook; prints upstream versions
@@ -75,6 +77,7 @@ async function main(argv) {
75
77
  'no-models': { type: 'boolean' },
76
78
  'no-dashboard': { type: 'boolean' },
77
79
  'no-refresh-hook': { type: 'boolean' },
80
+ 'fix-all': { type: 'boolean' },
78
81
  'dry-run': { type: 'boolean' },
79
82
  },
80
83
  });
@@ -87,7 +90,7 @@ async function main(argv) {
87
90
  const parsed = parseArgs({
88
91
  args: rest,
89
92
  allowPositionals: true,
90
- options: { 'with-backlog': { type: 'boolean' } },
93
+ options: { 'with-backlog': { type: 'boolean' }, 'fix-all': { type: 'boolean' } },
91
94
  });
92
95
  return runUninstall(process.cwd(), {
93
96
  values: parsed.values,
@@ -1,11 +1,16 @@
1
1
  // src/commands/init.ts
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readSync } from 'node:fs';
3
3
  import { basename, join, resolve } from 'node:path';
4
+ import process from 'node:process';
4
5
  import { detectPackageManager } from '../lib/pm.js';
5
6
  import { getEffectiveExecutionPolicy, isBlockingExecutionPolicy, policyWarningLines, } from '../lib/powershell.js';
7
+ import { runPreflight } from '../lib/preflight.js';
8
+ import { runDoctor } from './doctor.js';
6
9
  import { KIT_VERSION } from '../lib/version.js';
7
10
  import { executeActions, InvalidJsonError, RefusalError, UpstreamError } from '../init/execute.js';
8
11
  import { planInit } from '../init/planner.js';
12
+ /** Units that make sense before init; install-type fixes belong to init itself. */
13
+ const INIT_PREFLIGHT_UNITS = ['node-version', 'execution-policy', 'npm-command'];
9
14
  const HARNESS_VALUES = ['opencode', 'claude'];
10
15
  const PM_VALUES = ['auto', 'npm', 'pnpm', 'bun', 'skip'];
11
16
  function isHarness(value) {
@@ -43,7 +48,23 @@ function maybePrintPolicyWarning() {
43
48
  }
44
49
  }
45
50
  }
46
- export async function runInit(cwd, args) {
51
+ /** Synchronous Y/n prompt; defaults to "no" when stdin is not interactive. */
52
+ export function promptYesNo(question) {
53
+ if (!process.stdin.isTTY)
54
+ return false;
55
+ process.stdout.write(`${question} [y/N] `);
56
+ const buf = Buffer.alloc(64);
57
+ let n = 0;
58
+ try {
59
+ n = readSync(process.stdin.fd, buf, 0, 64, null);
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ const answer = buf.subarray(0, n).toString('utf8').trim().toLowerCase();
65
+ return answer === 'y' || answer === 'yes';
66
+ }
67
+ export async function runInit(cwd, args, deps = {}) {
47
68
  const harnesses = [];
48
69
  const rawHarnesses = args.values.harness;
49
70
  const listed = Array.isArray(rawHarnesses)
@@ -109,6 +130,25 @@ export async function runInit(cwd, args) {
109
130
  maybePrintPolicyWarning();
110
131
  return plan.warnings.length > 0 ? 4 : 0;
111
132
  }
133
+ const preflightRun = deps.preflight ?? runPreflight;
134
+ const preflightResult = preflightRun(cwd, {
135
+ units: INIT_PREFLIGHT_UNITS,
136
+ fixAll: args.values['fix-all'] === true,
137
+ confirm: deps.confirm ?? promptYesNo,
138
+ log: (line) => console.log(line),
139
+ });
140
+ const failed = preflightResult.reports.filter((r) => r.status === 'failed');
141
+ if (failed.length > 0) {
142
+ for (const f of failed) {
143
+ console.error(`error: preflight fix failed (${f.id}): ${f.detail}`);
144
+ if (f.manualCommand !== undefined) {
145
+ console.error(` fix it manually: ${f.manualCommand}`);
146
+ }
147
+ }
148
+ return 1;
149
+ }
150
+ // needs-manual reports (declined or non-interactive consent) are surfaced with
151
+ // their manual command by preflight but must not change the exit code.
112
152
  try {
113
153
  const result = await executeActions(cwd, plan.actions, {
114
154
  version: KIT_VERSION,
@@ -119,8 +159,14 @@ export async function runInit(cwd, args) {
119
159
  console.log(`super-backlog init complete - planned ${plan.actions.length}, applied ${result.applied}, skipped ${result.skipped}`);
120
160
  for (const warning of warnings)
121
161
  console.log(`warning: ${warning}`);
122
- maybePrintPolicyWarning();
123
- return warnings.length > 0 ? 4 : 0;
162
+ const doctor = deps.doctor ?? ((c) => runDoctor(c));
163
+ console.log('post-install verification (doctor):');
164
+ const doctorCode = doctor(cwd);
165
+ const unverified = doctorCode !== 0;
166
+ if (unverified) {
167
+ console.log('warning: post-install verification reported warnings (see doctor output above)');
168
+ }
169
+ return warnings.length > 0 || unverified ? 4 : 0;
124
170
  }
125
171
  catch (err) {
126
172
  if (err instanceof UpstreamError) {
@@ -1,14 +1,17 @@
1
1
  // src/commands/uninstall.ts
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { existsSync, readFileSync, rmSync } from 'node:fs';
3
4
  import { join } from 'node:path';
5
+ import process from 'node:process';
4
6
  import { findGitDir, POINTER_HEADING_RE } from '../init/execute.js';
5
7
  import { atomicWrite } from '../lib/atomic.js';
6
- import { removeGuardHook, removeRefreshHook } from '../lib/hooks.js';
8
+ import { GUARD_RE, REFRESH_RE, removeGuardHook, removeRefreshHook } from '../lib/hooks.js';
7
9
  import { stripOwned } from '../lib/markers.js';
8
10
  import { PLUGIN_SPEC } from '../lib/opencode.js';
9
11
  import { isOwnedSkillFile } from '../lib/ownership.js';
10
12
  import { WANTED_SCRIPTS } from '../lib/pkgjson.js';
11
13
  import { uninstallModelRouter } from '../models/uninstall.js';
14
+ import { promptYesNo } from './init.js';
12
15
  const OWNED_SKILL_DIRS = [
13
16
  '.opencode/skill/spec-to-backlog',
14
17
  '.opencode/skill/backlog-status-report',
@@ -133,9 +136,90 @@ function uninstallPluginEntry(cwd, config, report) {
133
136
  report.push({ verdict: 'skipped', label: 'opencode.json plugin entry (no kit entry)' });
134
137
  }
135
138
  }
136
- export function runUninstall(cwd, args) {
139
+ /** Probes for kit-owned artifacts that survived the uninstall. */
140
+ export function verifyRemnants(cwd) {
141
+ const remnants = [];
142
+ const agentsPath = join(cwd, 'AGENTS.md');
143
+ try {
144
+ if (existsSync(agentsPath) && stripOwned(readFileSync(agentsPath, 'utf8')).removed) {
145
+ remnants.push('AGENTS.md managed block');
146
+ }
147
+ }
148
+ catch {
149
+ remnants.push('AGENTS.md (unreadable)');
150
+ }
151
+ const claudePath = join(cwd, 'CLAUDE.md');
152
+ try {
153
+ if (existsSync(claudePath) && POINTER_HEADING_RE.test(readFileSync(claudePath, 'utf8'))) {
154
+ remnants.push('CLAUDE.md pointer section');
155
+ }
156
+ }
157
+ catch {
158
+ remnants.push('CLAUDE.md (unreadable)');
159
+ }
160
+ for (const rel of OWNED_SKILL_DIRS) {
161
+ const skillMd = join(cwd, ...rel.split('/'), 'SKILL.md');
162
+ if (existsSync(skillMd) && isOwnedSkillFile(readFileSync(skillMd, 'utf8'))) {
163
+ remnants.push(`${rel}/`);
164
+ }
165
+ }
166
+ const ocPath = join(cwd, 'opencode.json');
167
+ if (existsSync(ocPath)) {
168
+ try {
169
+ const config = JSON.parse(readFileSync(ocPath, 'utf8'));
170
+ const raw = config.plugin;
171
+ const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];
172
+ if (list.some((entry) => typeof entry === 'string' && entry === PLUGIN_SPEC)) {
173
+ remnants.push('opencode.json plugin entry');
174
+ }
175
+ }
176
+ catch {
177
+ // unparsable config was already reported during validation
178
+ }
179
+ }
180
+ const gitDir = findGitDir(cwd);
181
+ if (gitDir) {
182
+ const pre = join(gitDir, 'hooks', 'pre-commit');
183
+ if (existsSync(pre) && GUARD_RE.test(readFileSync(pre, 'utf8'))) {
184
+ remnants.push('git pre-commit guard hook');
185
+ }
186
+ const post = join(gitDir, 'hooks', 'post-commit');
187
+ if (existsSync(post) && REFRESH_RE.test(readFileSync(post, 'utf8'))) {
188
+ remnants.push('git post-commit dashboard-refresh hook');
189
+ }
190
+ }
191
+ const dashboardPath = join(cwd, 'dashboard.html');
192
+ if (existsSync(dashboardPath) && isKitDashboard(readFileSync(dashboardPath, 'utf8'))) {
193
+ remnants.push('dashboard.html');
194
+ }
195
+ return remnants;
196
+ }
197
+ function defaultRemoveGlobal() {
198
+ const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
199
+ const r = spawnSync(cmd, ['uninstall', '-g', 'super-backlog'], {
200
+ encoding: 'utf8',
201
+ windowsHide: true,
202
+ shell: process.platform === 'win32',
203
+ });
204
+ if (r.error)
205
+ return 1;
206
+ return r.status ?? 1;
207
+ }
208
+ export function runUninstall(cwd, args, deps = {}) {
137
209
  const withBacklog = args.values['with-backlog'] === true;
210
+ const fixAll = args.values['fix-all'] === true;
138
211
  const report = [];
212
+ // A failing step must not abort the remaining steps: collect it as an error
213
+ // line and continue. The JSON validation above stays fail-fast on purpose.
214
+ const attempt = (label, fn) => {
215
+ try {
216
+ fn();
217
+ }
218
+ catch (err) {
219
+ const msg = err instanceof Error ? err.message : String(err);
220
+ report.push({ verdict: 'error', label: `${label} (${msg})` });
221
+ }
222
+ };
139
223
  // validate both JSON files up front - no mutation happens unless parsing succeeds
140
224
  let pkg = null;
141
225
  const pkgPath = join(cwd, 'package.json');
@@ -160,113 +244,133 @@ export function runUninstall(cwd, args) {
160
244
  }
161
245
  }
162
246
  const agentsPath = join(cwd, 'AGENTS.md');
163
- if (!existsSync(agentsPath)) {
164
- report.push({ verdict: 'skipped', label: 'AGENTS.md managed block (file not found)' });
165
- }
166
- else {
167
- const stripped = stripOwned(readFileSync(agentsPath, 'utf8'));
168
- if (stripped.removed) {
169
- atomicWrite(agentsPath, stripped.content);
170
- report.push({ verdict: 'removed', label: 'AGENTS.md managed block' });
247
+ attempt('AGENTS.md managed block', () => {
248
+ if (!existsSync(agentsPath)) {
249
+ report.push({ verdict: 'skipped', label: 'AGENTS.md managed block (file not found)' });
171
250
  }
172
251
  else {
173
- report.push({ verdict: 'skipped', label: 'AGENTS.md managed block (none found)' });
252
+ const stripped = stripOwned(readFileSync(agentsPath, 'utf8'));
253
+ if (stripped.removed) {
254
+ atomicWrite(agentsPath, stripped.content);
255
+ report.push({ verdict: 'removed', label: 'AGENTS.md managed block' });
256
+ }
257
+ else {
258
+ report.push({ verdict: 'skipped', label: 'AGENTS.md managed block (none found)' });
259
+ }
174
260
  }
175
- }
261
+ });
176
262
  const claudePath = join(cwd, 'CLAUDE.md');
177
- if (!existsSync(claudePath)) {
178
- report.push({ verdict: 'skipped', label: 'CLAUDE.md pointer section (file not found)' });
179
- }
180
- else {
181
- const res = removePointerSection(readFileSync(claudePath, 'utf8'));
182
- if (res.removed) {
183
- atomicWrite(claudePath, res.content);
184
- report.push({ verdict: 'removed', label: 'CLAUDE.md pointer section' });
263
+ attempt('CLAUDE.md pointer section', () => {
264
+ if (!existsSync(claudePath)) {
265
+ report.push({ verdict: 'skipped', label: 'CLAUDE.md pointer section (file not found)' });
185
266
  }
186
267
  else {
187
- report.push({ verdict: 'skipped', label: 'CLAUDE.md pointer section (none found)' });
268
+ const res = removePointerSection(readFileSync(claudePath, 'utf8'));
269
+ if (res.removed) {
270
+ atomicWrite(claudePath, res.content);
271
+ report.push({ verdict: 'removed', label: 'CLAUDE.md pointer section' });
272
+ }
273
+ else {
274
+ report.push({ verdict: 'skipped', label: 'CLAUDE.md pointer section (none found)' });
275
+ }
188
276
  }
189
- }
277
+ });
190
278
  for (const rel of OWNED_SKILL_DIRS) {
191
- const abs = join(cwd, ...rel.split('/'));
192
- const skillMd = join(abs, 'SKILL.md');
193
- if (!existsSync(skillMd)) {
194
- report.push(existsSync(abs)
195
- ? { verdict: 'kept', label: `${rel}/ (no SKILL.md - left untouched)` }
196
- : { verdict: 'skipped', label: `${rel}/ (not found)` });
279
+ attempt(`${rel}/`, () => {
280
+ const abs = join(cwd, ...rel.split('/'));
281
+ const skillMd = join(abs, 'SKILL.md');
282
+ if (!existsSync(skillMd)) {
283
+ report.push(existsSync(abs)
284
+ ? { verdict: 'kept', label: `${rel}/ (no SKILL.md - left untouched)` }
285
+ : { verdict: 'skipped', label: `${rel}/ (not found)` });
286
+ }
287
+ else if (isOwnedSkillFile(readFileSync(skillMd, 'utf8'))) {
288
+ rmSync(abs, { recursive: true, force: true });
289
+ report.push({ verdict: 'removed', label: `${rel}/` });
290
+ }
291
+ else {
292
+ report.push({
293
+ verdict: 'kept',
294
+ label: `${rel}/ (SKILL.md not managed by super-backlog)`,
295
+ });
296
+ }
297
+ });
298
+ }
299
+ attempt('package.json scripts/devDependencies', () => {
300
+ uninstallPackageJson(cwd, pkg, withBacklog, report);
301
+ });
302
+ attempt('opencode.json plugin entry', () => {
303
+ uninstallPluginEntry(cwd, opencodeConfig, report);
304
+ });
305
+ const gitDir = findGitDir(cwd);
306
+ attempt('git pre-commit guard hook', () => {
307
+ if (!gitDir) {
308
+ report.push({ verdict: 'skipped', label: 'git pre-commit guard hook (no .git directory)' });
309
+ }
310
+ else if (!existsSync(join(gitDir, 'hooks', 'pre-commit'))) {
311
+ report.push({ verdict: 'skipped', label: 'git pre-commit guard hook (not installed)' });
197
312
  }
198
- else if (isOwnedSkillFile(readFileSync(skillMd, 'utf8'))) {
199
- rmSync(abs, { recursive: true, force: true });
200
- report.push({ verdict: 'removed', label: `${rel}/` });
313
+ else if (removeGuardHook(gitDir)) {
314
+ report.push({ verdict: 'removed', label: 'git pre-commit guard hook' });
201
315
  }
202
316
  else {
203
317
  report.push({
204
318
  verdict: 'kept',
205
- label: `${rel}/ (SKILL.md not managed by super-backlog)`,
319
+ label: 'git pre-commit hook (no super-backlog guard block)',
206
320
  });
207
321
  }
208
- }
209
- uninstallPackageJson(cwd, pkg, withBacklog, report);
210
- uninstallPluginEntry(cwd, opencodeConfig, report);
211
- const gitDir = findGitDir(cwd);
212
- if (!gitDir) {
213
- report.push({ verdict: 'skipped', label: 'git pre-commit guard hook (no .git directory)' });
214
- }
215
- else if (!existsSync(join(gitDir, 'hooks', 'pre-commit'))) {
216
- report.push({ verdict: 'skipped', label: 'git pre-commit guard hook (not installed)' });
217
- }
218
- else if (removeGuardHook(gitDir)) {
219
- report.push({ verdict: 'removed', label: 'git pre-commit guard hook' });
220
- }
221
- else {
222
- report.push({
223
- verdict: 'kept',
224
- label: 'git pre-commit hook (no super-backlog guard block)',
225
- });
226
- }
227
- if (!gitDir) {
228
- report.push({ verdict: 'skipped', label: 'git post-commit dashboard-refresh hook (no .git directory)' });
229
- }
230
- else if (!existsSync(join(gitDir, 'hooks', 'post-commit'))) {
231
- report.push({ verdict: 'skipped', label: 'git post-commit dashboard-refresh hook (not installed)' });
232
- }
233
- else if (removeRefreshHook(gitDir)) {
234
- report.push({ verdict: 'removed', label: 'git post-commit dashboard-refresh hook' });
235
- }
236
- else {
237
- report.push({
238
- verdict: 'kept',
239
- label: 'git post-commit hook (no super-backlog dashboard-refresh block)',
240
- });
241
- }
322
+ });
323
+ attempt('git post-commit dashboard-refresh hook', () => {
324
+ if (!gitDir) {
325
+ report.push({ verdict: 'skipped', label: 'git post-commit dashboard-refresh hook (no .git directory)' });
326
+ }
327
+ else if (!existsSync(join(gitDir, 'hooks', 'post-commit'))) {
328
+ report.push({ verdict: 'skipped', label: 'git post-commit dashboard-refresh hook (not installed)' });
329
+ }
330
+ else if (removeRefreshHook(gitDir)) {
331
+ report.push({ verdict: 'removed', label: 'git post-commit dashboard-refresh hook' });
332
+ }
333
+ else {
334
+ report.push({
335
+ verdict: 'kept',
336
+ label: 'git post-commit hook (no super-backlog dashboard-refresh block)',
337
+ });
338
+ }
339
+ });
242
340
  const dashboardPath = join(cwd, 'dashboard.html');
243
- if (!existsSync(dashboardPath)) {
244
- report.push({ verdict: 'skipped', label: 'dashboard.html (not found)' });
245
- }
246
- else if (isKitDashboard(readFileSync(dashboardPath, 'utf8'))) {
247
- rmSync(dashboardPath);
248
- report.push({ verdict: 'removed', label: 'dashboard.html' });
249
- }
250
- else {
251
- report.push({ verdict: 'kept', label: 'dashboard.html (not generated by super-backlog)' });
252
- }
341
+ attempt('dashboard.html', () => {
342
+ if (!existsSync(dashboardPath)) {
343
+ report.push({ verdict: 'skipped', label: 'dashboard.html (not found)' });
344
+ }
345
+ else if (isKitDashboard(readFileSync(dashboardPath, 'utf8'))) {
346
+ rmSync(dashboardPath);
347
+ report.push({ verdict: 'removed', label: 'dashboard.html' });
348
+ }
349
+ else {
350
+ report.push({ verdict: 'kept', label: 'dashboard.html (not generated by super-backlog)' });
351
+ }
352
+ });
253
353
  let dataDeleted = false;
254
354
  const backlogDir = join(cwd, 'backlog');
255
- if (!existsSync(backlogDir)) {
256
- report.push({ verdict: 'skipped', label: 'backlog/ (not found)' });
257
- }
258
- else if (withBacklog) {
259
- rmSync(backlogDir, { recursive: true, force: true });
260
- dataDeleted = true;
261
- report.push({ verdict: 'removed', label: 'backlog/ (project task data)' });
262
- }
263
- else {
264
- report.push({
265
- verdict: 'kept',
266
- label: 'backlog/ (project task data preserved - pass --with-backlog to delete)',
267
- });
268
- }
269
- uninstallModelRouter(cwd, report);
355
+ attempt('backlog/ (project task data)', () => {
356
+ if (!existsSync(backlogDir)) {
357
+ report.push({ verdict: 'skipped', label: 'backlog/ (not found)' });
358
+ }
359
+ else if (withBacklog) {
360
+ rmSync(backlogDir, { recursive: true, force: true });
361
+ dataDeleted = true;
362
+ report.push({ verdict: 'removed', label: 'backlog/ (project task data)' });
363
+ }
364
+ else {
365
+ report.push({
366
+ verdict: 'kept',
367
+ label: 'backlog/ (project task data preserved - pass --with-backlog to delete)',
368
+ });
369
+ }
370
+ });
371
+ attempt('model router', () => {
372
+ uninstallModelRouter(cwd, report);
373
+ });
270
374
  console.log('super-backlog uninstall');
271
375
  for (const line of report)
272
376
  console.log(`${line.verdict}: ${line.label}`);
@@ -277,5 +381,34 @@ export function runUninstall(cwd, args) {
277
381
  console.log('was permanently removed. This cannot be undone.');
278
382
  console.log('============================================================');
279
383
  }
280
- return 0;
384
+ // verification pass: prove nothing kit-owned survives (or say what does)
385
+ const remnants = verifyRemnants(cwd);
386
+ if (remnants.length === 0) {
387
+ console.log('verification: clean');
388
+ }
389
+ else {
390
+ console.log('verification: leftover kit artifacts');
391
+ for (const remnant of remnants)
392
+ console.log(` - ${remnant}`);
393
+ }
394
+ // global self-removal as the very last step
395
+ const removeGlobal = deps.removeGlobal ?? defaultRemoveGlobal;
396
+ const confirm = deps.confirm ?? promptYesNo;
397
+ let removalFailed = false;
398
+ if (fixAll || confirm('Remove the global super-backlog npm package as well?')) {
399
+ const status = removeGlobal();
400
+ if (status !== 0) {
401
+ removalFailed = true;
402
+ console.log('error: global package removal failed');
403
+ console.log(' manual: npm uninstall -g super-backlog');
404
+ }
405
+ else {
406
+ console.log('removed: global npm package super-backlog');
407
+ }
408
+ }
409
+ else {
410
+ console.log('note: global npm package kept - remove it with: npm uninstall -g super-backlog');
411
+ }
412
+ const hadError = report.some((line) => line.verdict === 'error');
413
+ return hadError || removalFailed ? 1 : 0;
281
414
  }
@@ -0,0 +1,233 @@
1
+ // src/lib/preflight.ts
2
+ // Check -> Fix -> Verify units that repair a broken super-backlog environment.
3
+ // System-changing fixes (node install, execution policy) require consent or fixAll;
4
+ // safe fixes run unconditionally. Every fix is verified and reports a manual
5
+ // fallback command on failure.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ import { delimiter, join } from 'node:path';
9
+ import process from 'node:process';
10
+ import { getEffectiveExecutionPolicy, isBlockingExecutionPolicy, } from './powershell.js';
11
+ import { resolveBacklogBin } from './run.js';
12
+ const defaultExecutor = (cmd, args) => {
13
+ // shell on win32 — .cmd/.ps1 shims (npm.cmd etc.) are not directly executable.
14
+ // Safe only because callers pass constant args; never pass user input here.
15
+ const r = spawnSync(cmd, args, {
16
+ encoding: 'utf8',
17
+ windowsHide: true,
18
+ shell: process.platform === 'win32',
19
+ });
20
+ if (r.error)
21
+ return { status: null, stdout: '', stderr: String(r.error.message) };
22
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
23
+ };
24
+ function parseMajor(version) {
25
+ if (version === null)
26
+ return null;
27
+ const major = Number(version.replace(/^v/, '').split('.')[0]);
28
+ return Number.isNaN(major) ? null : major;
29
+ }
30
+ function allowed(ctx, question) {
31
+ if (ctx.fixAll)
32
+ return true;
33
+ return ctx.confirm?.(question) === true;
34
+ }
35
+ function checkNodeVersion(ctx) {
36
+ const id = 'node-version';
37
+ const version = ctx.getNodeVersion();
38
+ const major = parseMajor(version);
39
+ if (major !== null && major >= 20) {
40
+ return { id, status: 'ok', detail: `node v${version} (>= 20)` };
41
+ }
42
+ const installArgs = ctx.platform === 'win32'
43
+ ? { cmd: 'winget', args: ['install', '-e', '--id', 'OpenJS.NodeJS', '--accept-source-agreements', '--accept-package-agreements'] }
44
+ : { cmd: 'brew', args: ['install', 'node'] };
45
+ const manual = `${installArgs.cmd} ${installArgs.args.join(' ')}`;
46
+ if (!allowed(ctx, `Node.js >= 20 is required (found ${version ?? 'none'}). Install it now?`)) {
47
+ return { id, status: 'needs-manual', detail: `node ${version ?? 'not found'} is too old or missing`, manualCommand: manual };
48
+ }
49
+ const r = ctx.executor(installArgs.cmd, installArgs.args);
50
+ if (r.status !== 0) {
51
+ return { id, status: 'failed', detail: `node install failed: ${r.stderr.trim() || 'unknown error'}`, manualCommand: manual };
52
+ }
53
+ const after = parseMajor(ctx.getNodeVersion());
54
+ if (after !== null && after >= 20) {
55
+ return { id, status: 'fixed', detail: 'node installed via package manager' };
56
+ }
57
+ return {
58
+ id,
59
+ status: 'failed',
60
+ detail: 'node still not >= 20 after install (open a new terminal and retry)',
61
+ manualCommand: 'install Node.js >= 20 manually: https://nodejs.org/en/download/',
62
+ };
63
+ }
64
+ function checkExecutionPolicy(ctx) {
65
+ const id = 'execution-policy';
66
+ if (ctx.platform !== 'win32') {
67
+ return { id, status: 'skipped', detail: 'not Windows' };
68
+ }
69
+ const policy = ctx.getPolicy();
70
+ if (policy === null) {
71
+ return { id, status: 'skipped', detail: 'policy not detectable' };
72
+ }
73
+ if (!isBlockingExecutionPolicy(policy)) {
74
+ return { id, status: 'ok', detail: `execution policy: ${policy}` };
75
+ }
76
+ const manual = 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
77
+ if (!allowed(ctx, `PowerShell execution policy "${policy}" blocks npm/npx/sbl shims. Set CurrentUser to RemoteSigned?`)) {
78
+ return { id, status: 'needs-manual', detail: `PowerShell execution policy "${policy}" blocks .ps1 shims`, manualCommand: manual };
79
+ }
80
+ const r = ctx.executor('powershell.exe', [
81
+ '-NoProfile',
82
+ '-NonInteractive',
83
+ '-Command',
84
+ `${manual} -Force`,
85
+ ]);
86
+ if (r.status !== 0) {
87
+ return { id, status: 'failed', detail: `Set-ExecutionPolicy failed: ${r.stderr.trim() || 'unknown error'}`, manualCommand: manual };
88
+ }
89
+ const after = ctx.getPolicy();
90
+ if (!isBlockingExecutionPolicy(after)) {
91
+ return { id, status: 'fixed', detail: 'execution policy set to RemoteSigned (CurrentUser)' };
92
+ }
93
+ return { id, status: 'failed', detail: `policy still "${after ?? 'unknown'}" after fix`, manualCommand: manual };
94
+ }
95
+ function checkNpmCommand(ctx) {
96
+ const id = 'npm-command';
97
+ const probe = ctx.executor('npm', ['--version']);
98
+ if (probe.status === 0) {
99
+ ctx.npmCmd = 'npm';
100
+ return { id, status: 'ok', detail: `npm ${probe.stdout.trim()}` };
101
+ }
102
+ if (ctx.platform === 'win32') {
103
+ const fallback = ctx.executor('npm.cmd', ['--version']);
104
+ if (fallback.status === 0) {
105
+ ctx.npmCmd = 'npm.cmd';
106
+ return { id, status: 'fixed', detail: 'npm not directly callable; using npm.cmd shim' };
107
+ }
108
+ }
109
+ return {
110
+ id,
111
+ status: 'failed',
112
+ detail: 'npm is not callable',
113
+ manualCommand: 'reinstall Node.js (bundles npm): https://nodejs.org/en/download/',
114
+ };
115
+ }
116
+ function checkPartialInstall(ctx) {
117
+ const id = 'partial-install';
118
+ const pkgJson = join(ctx.cwd, 'package.json');
119
+ if (!ctx.exists(pkgJson)) {
120
+ return { id, status: 'skipped', detail: 'no package.json (init installs upstream itself)' };
121
+ }
122
+ const pkgDir = join(ctx.cwd, 'node_modules', 'super-backlog');
123
+ const shim = join(ctx.cwd, 'node_modules', '.bin', ctx.platform === 'win32' ? 'sbl.cmd' : 'sbl');
124
+ if (!ctx.exists(pkgDir) || ctx.exists(shim)) {
125
+ return { id, status: 'ok', detail: 'local install consistent (or absent)' };
126
+ }
127
+ const r = ctx.executor(ctx.npmCmd, ['install']);
128
+ if (r.status !== 0) {
129
+ return { id, status: 'failed', detail: `npm install failed: ${r.stderr.trim() || 'unknown error'}`, manualCommand: 'npm install' };
130
+ }
131
+ if (ctx.exists(shim)) {
132
+ return { id, status: 'fixed', detail: 'repaired partial install (missing .bin shim restored)' };
133
+ }
134
+ return { id, status: 'failed', detail: 'partial install persists after npm install', manualCommand: 'npm install' };
135
+ }
136
+ function checkBacklogBin(ctx) {
137
+ const id = 'backlog-bin';
138
+ if (!ctx.exists(join(ctx.cwd, 'package.json'))) {
139
+ return { id, status: 'skipped', detail: 'no package.json (init installs upstream itself)' };
140
+ }
141
+ const found = ctx.resolveBacklog(ctx.cwd);
142
+ if (found !== null) {
143
+ return { id, status: 'ok', detail: `backlog CLI at ${found}` };
144
+ }
145
+ const r = ctx.executor(ctx.npmCmd, ['install']);
146
+ if (r.status !== 0) {
147
+ return { id, status: 'failed', detail: `npm install failed: ${r.stderr.trim() || 'unknown error'}`, manualCommand: 'npm install' };
148
+ }
149
+ const after = ctx.resolveBacklog(ctx.cwd);
150
+ if (after !== null) {
151
+ return { id, status: 'fixed', detail: 'backlog CLI restored via npm install' };
152
+ }
153
+ return { id, status: 'failed', detail: 'backlog CLI still not resolvable after npm install', manualCommand: 'npm install' };
154
+ }
155
+ function checkSblOnPath(ctx) {
156
+ const id = 'sbl-on-path';
157
+ if (ctx.lookupCommand('sbl') !== null) {
158
+ return { id, status: 'ok', detail: 'sbl resolvable on PATH' };
159
+ }
160
+ const binProbe = ctx.executor(ctx.npmCmd, ['bin', '-g']);
161
+ if (binProbe.status !== 0 || binProbe.stdout.trim() === '') {
162
+ return {
163
+ id,
164
+ status: 'failed',
165
+ detail: 'sbl not on PATH and npm global bin directory could not be determined',
166
+ manualCommand: 'run "npm bin -g", add the printed directory to your PATH, open a new terminal',
167
+ };
168
+ }
169
+ const binDir = binProbe.stdout.trim().split(/\r?\n/)[0];
170
+ const current = ctx.getEnv('PATH') ?? '';
171
+ ctx.setEnv('PATH', current === '' ? binDir : `${binDir}${delimiter}${current}`);
172
+ if (ctx.lookupCommand('sbl') !== null) {
173
+ return { id, status: 'fixed', detail: `session PATH refreshed with ${binDir}` };
174
+ }
175
+ return {
176
+ id,
177
+ status: 'failed',
178
+ detail: `sbl still not resolvable after adding ${binDir} to the session PATH`,
179
+ manualCommand: `add "${binDir}" to your user PATH permanently and open a new terminal`,
180
+ };
181
+ }
182
+ export function runPreflight(cwd, deps = {}) {
183
+ const platform = deps.platform ?? process.platform;
184
+ const executor = deps.executor ?? defaultExecutor;
185
+ const ctx = {
186
+ platform,
187
+ cwd,
188
+ executor,
189
+ getNodeVersion: deps.getNodeVersion ?? (() => process.versions.node),
190
+ getPolicy: deps.getPolicy ??
191
+ (() => getEffectiveExecutionPolicy({ platform, executor })),
192
+ lookupCommand: deps.lookupCommand ??
193
+ ((name) => {
194
+ const probe = executor(platform === 'win32' ? 'where' : 'which', [name]);
195
+ if (probe.status !== 0)
196
+ return null;
197
+ const first = probe.stdout.split(/\r?\n/).find(Boolean);
198
+ if (!first)
199
+ return null;
200
+ return first.trim().replace(/\.ps1$/i, '.cmd');
201
+ }),
202
+ setEnv: deps.setEnv ?? ((name, value) => { process.env[name] = value; }),
203
+ getEnv: deps.getEnv ?? ((name) => process.env[name] ?? null),
204
+ resolveBacklog: deps.resolveBacklog ?? resolveBacklogBin,
205
+ exists: deps.exists ?? existsSync,
206
+ log: deps.log ?? ((line) => console.log(line)),
207
+ confirm: deps.confirm,
208
+ fixAll: deps.fixAll ?? false,
209
+ npmCmd: platform === 'win32' ? 'npm.cmd' : 'npm',
210
+ };
211
+ const unitNames = [
212
+ ['node-version', checkNodeVersion],
213
+ ['execution-policy', checkExecutionPolicy],
214
+ ['npm-command', checkNpmCommand],
215
+ ['partial-install', checkPartialInstall],
216
+ ['backlog-bin', checkBacklogBin],
217
+ ['sbl-on-path', checkSblOnPath],
218
+ ];
219
+ const units = deps.units === undefined
220
+ ? unitNames.map(([, fn]) => fn)
221
+ : unitNames.filter(([name]) => deps.units.includes(name)).map(([, fn]) => fn);
222
+ const reports = [];
223
+ for (const unit of units) {
224
+ const report = unit(ctx);
225
+ reports.push(report);
226
+ ctx.log(`[${report.status}] ${report.id}: ${report.detail}`);
227
+ if (report.manualCommand !== undefined && report.status !== 'ok') {
228
+ ctx.log(` manual: ${report.manualCommand}`);
229
+ }
230
+ }
231
+ const ok = reports.every((r) => r.status === 'ok' || r.status === 'fixed' || r.status === 'skipped');
232
+ return { reports, ok };
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {