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 +4 -1
- package/dist/commands/init.js +50 -4
- package/dist/commands/uninstall.js +225 -92
- package/dist/lib/preflight.js +233 -0
- package/package.json +1 -1
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,
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
199
|
-
|
|
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:
|
|
319
|
+
label: 'git pre-commit hook (no super-backlog guard block)',
|
|
206
320
|
});
|
|
207
321
|
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
+
}
|