@bakit-org/ai-sdlc-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +97 -2
- package/bin/ai-sdlc.js +13 -0
- package/docs/cli.md +115 -0
- package/docs/tui.md +87 -0
- package/lib/apply-plan.js +61 -0
- package/lib/bundle.js +120 -0
- package/lib/cli-args.js +89 -0
- package/lib/cli-output.js +36 -0
- package/lib/command-plan.js +28 -0
- package/lib/commands.js +173 -0
- package/lib/config.js +42 -0
- package/lib/doctor.js +123 -0
- package/lib/errors.js +19 -0
- package/lib/fs-safe.js +133 -0
- package/lib/github-auth.js +115 -0
- package/lib/github-checks.js +43 -0
- package/lib/github-errors.js +39 -0
- package/lib/github-http.js +107 -0
- package/lib/github-release.js +156 -0
- package/lib/journal.js +117 -0
- package/lib/manifest.js +64 -0
- package/lib/merge-claude-md.js +63 -0
- package/lib/merge-settings.js +125 -0
- package/lib/path-rules.js +23 -0
- package/lib/plan-files.js +86 -0
- package/lib/plan.js +78 -0
- package/lib/project-picker.js +117 -0
- package/lib/release-source.js +30 -0
- package/lib/semver.js +27 -0
- package/lib/termination-cleanup.js +22 -0
- package/lib/tui/cancelled.js +16 -0
- package/lib/tui/colors.js +33 -0
- package/lib/tui/flows/init-flow.js +126 -0
- package/lib/tui/flows/intro.js +12 -0
- package/lib/tui/flows/menu-flow.js +51 -0
- package/lib/tui/flows/pick-project-step.js +42 -0
- package/lib/tui/flows/preflight.js +80 -0
- package/lib/tui/flows/project-choices.js +64 -0
- package/lib/tui/flows/recent-projects.js +76 -0
- package/lib/tui/flows/result-views.js +89 -0
- package/lib/tui/flows/review-view.js +45 -0
- package/lib/tui/flows/session-options.js +7 -0
- package/lib/tui/flows/terminal-views.js +17 -0
- package/lib/tui/keys.js +182 -0
- package/lib/tui/launch.js +19 -0
- package/lib/tui/logo-fonts.js +29 -0
- package/lib/tui/logo.js +98 -0
- package/lib/tui/sanitize.js +27 -0
- package/lib/tui/terminal-guards.js +58 -0
- package/lib/tui/terminal.js +196 -0
- package/lib/tui/text-width.js +122 -0
- package/lib/tui/theme.js +84 -0
- package/lib/tui/widgets/confirm.js +56 -0
- package/lib/tui/widgets/input-box.js +88 -0
- package/lib/tui/widgets/panel.js +31 -0
- package/lib/tui/widgets/path-input.js +110 -0
- package/lib/tui/widgets/progress-bar.js +17 -0
- package/lib/tui/widgets/select-list.js +89 -0
- package/lib/tui/widgets/spinner.js +41 -0
- package/lib/version-check.js +38 -0
- package/package.json +32 -4
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The guided install: banner, environment check, project, review, progress, result.
|
|
3
|
+
// Nothing touches the project until the review is confirmed; the apply layer is
|
|
4
|
+
// only called after that.
|
|
5
|
+
const { CliError } = require('../../errors');
|
|
6
|
+
const { loadReleaseBundle } = require('../../release-source');
|
|
7
|
+
const { githubChecks } = require('../../github-checks');
|
|
8
|
+
const { validateTarget } = require('../../project-picker');
|
|
9
|
+
const { buildCommandPlan, planSignature } = require('../../command-plan');
|
|
10
|
+
const { acquireLock } = require('../../journal');
|
|
11
|
+
const { applyPlan } = require('../../apply-plan');
|
|
12
|
+
const { inspect } = require('../../fs-safe');
|
|
13
|
+
const { JOURNAL_REL } = require('../../path-rules');
|
|
14
|
+
const { createTheme } = require('../theme');
|
|
15
|
+
const { withSession, Cancelled, BACK } = require('../terminal');
|
|
16
|
+
const { truncateMiddle } = require('../text-width');
|
|
17
|
+
const { sanitize } = require('../sanitize');
|
|
18
|
+
const { withSpinner } = require('../widgets/spinner');
|
|
19
|
+
const { createConfirm } = require('../widgets/confirm');
|
|
20
|
+
const { renderProgress } = require('../widgets/progress-bar');
|
|
21
|
+
const { runPreflight, statusLine } = require('./preflight');
|
|
22
|
+
const { bannerLines } = require('./intro');
|
|
23
|
+
const { sessionOptions } = require('./session-options');
|
|
24
|
+
const { pickProjectStep } = require('./pick-project-step');
|
|
25
|
+
const { reviewBody } = require('./review-view');
|
|
26
|
+
const { installedPanel } = require('./result-views');
|
|
27
|
+
const { readRecent, rememberProject } = require('./recent-projects');
|
|
28
|
+
const { shortPath } = require('./project-choices');
|
|
29
|
+
|
|
30
|
+
const CHANGED_NOTE = 'The project changed after the plan was made; this is the updated plan.';
|
|
31
|
+
|
|
32
|
+
function preflightLines(theme, items) {
|
|
33
|
+
return items.map((i) => {
|
|
34
|
+
const mark = theme.sym[{ ok: 'ok', warn: 'warn', fail: 'err' }[i.status]];
|
|
35
|
+
return ` ${theme.paint(theme.tone(i.status), mark)} ${sanitize(i.label)} ${theme.paint('dim', sanitize(i.detail))}`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function wizard({ session, flags, io, cliVersion }) {
|
|
40
|
+
const { theme } = session;
|
|
41
|
+
const preflight = await withSpinner(session, 'Checking your environment...', (signal) => runPreflight({
|
|
42
|
+
flags, cwd: io.cwd, execFile: io.execFile, env: io.env, signal, extraChecks: io.extraChecks || (flags.fromBundle ? [] : githubChecks(io.github)),
|
|
43
|
+
}));
|
|
44
|
+
session.commit([...bannerLines(session), ` ${theme.paint('dim', statusLine(theme, preflight.items))}`, '', ...preflightLines(theme, preflight.items), '']);
|
|
45
|
+
|
|
46
|
+
// A failed token or access check ends the run here; the detail already names the way out (--from-bundle).
|
|
47
|
+
const blocked = preflight.items.find((i) => i.status === 'fail' && /^github-/.test(i.id));
|
|
48
|
+
if (blocked) throw new CliError(`${blocked.label}: ${blocked.detail}`);
|
|
49
|
+
const bundle = await withSpinner(session, flags.fromBundle ? 'Reading the bundle...' : 'Downloading the kit release...', (signal) => loadReleaseBundle(
|
|
50
|
+
{ fromBundle: flags.fromBundle, version: flags.version, cwd: io.cwd, signal }, { fetchLatestBundle: io.fetchLatestBundle }, { cliVersion },
|
|
51
|
+
));
|
|
52
|
+
for (const w of bundle.warnings) session.commit([` ${theme.paint('warn', `${theme.sym.warn} ${sanitize(w)}`)}`]);
|
|
53
|
+
|
|
54
|
+
const recents = readRecent({ env: io.env, home: io.home });
|
|
55
|
+
let notice = '';
|
|
56
|
+
for (;;) {
|
|
57
|
+
const target = flags.project ? validateTarget(flags.project, { cwd: io.cwd, home: io.home }) : await pickProjectStep({ session, cwd: io.cwd, home: io.home, root: flags.root, recents, notice });
|
|
58
|
+
notice = '';
|
|
59
|
+
if (target === BACK) throw new Cancelled('escape');
|
|
60
|
+
let plan;
|
|
61
|
+
try {
|
|
62
|
+
({ plan } = buildCommandPlan({ command: 'install', flags, project: target.dir, bundle, cliVersion }));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// For example "already installed": say so and let the user pick another project.
|
|
65
|
+
if (flags.project || !(err instanceof CliError)) throw err;
|
|
66
|
+
notice = err.message;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if ((await reviewAndInstall({ session, flags, io, cliVersion, bundle, target, plan })) === BACK) {
|
|
70
|
+
if (flags.project) throw new Cancelled('escape');
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Review loop: if the project no longer matches the reviewed plan once the lock is held, review again.
|
|
78
|
+
async function reviewAndInstall({ session, flags, io, cliVersion, bundle, target, plan: first }) {
|
|
79
|
+
let plan = first;
|
|
80
|
+
const warnings = inspect(target.dir, JOURNAL_REL).exists ? [`${JOURNAL_REL} exists: an earlier run was interrupted or another run is active`] : [];
|
|
81
|
+
for (;;) {
|
|
82
|
+
const current = plan;
|
|
83
|
+
const review = createConfirm({
|
|
84
|
+
title: 'Review', confirmLabel: 'Install', detailsToggle: true, guardMs: io.confirmGuardMs,
|
|
85
|
+
body: (state, ctx) => reviewBody({ theme: ctx.theme, project: truncateMiddle(sanitize(shortPath(target.dir, io.home)), ctx.width - 30), plan: current, bundle, hasGit: target.hasGit, warnings }, state),
|
|
86
|
+
});
|
|
87
|
+
if ((await session.run(review)) === BACK) return BACK;
|
|
88
|
+
const outcome = install({ session, flags, io, cliVersion, bundle, project: target.dir, reviewed: current });
|
|
89
|
+
if (!outcome.changed) return 0;
|
|
90
|
+
plan = outcome.plan;
|
|
91
|
+
if (!warnings.includes(CHANGED_NOTE)) warnings.push(CHANGED_NOTE);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Lock, plan again under the lock, apply with a progress bar, release.
|
|
96
|
+
// Returns { changed: true, plan } without writing when the fresh plan differs from the reviewed one.
|
|
97
|
+
function install({ session, flags, io, cliVersion, bundle, project, reviewed }) {
|
|
98
|
+
const lock = acquireLock(project, 'install', { force: Boolean(flags.force) });
|
|
99
|
+
try {
|
|
100
|
+
const { plan } = buildCommandPlan({ command: 'install', flags, project, bundle, cliVersion });
|
|
101
|
+
if (planSignature(plan) !== planSignature(reviewed)) return { changed: true, plan };
|
|
102
|
+
const shown = truncateMiddle(sanitize(shortPath(project, io.home)), session.ctx().width - 14);
|
|
103
|
+
session.commit([` ${session.theme.paint('ok', session.theme.sym.ok)} Project ${session.theme.paint('dim', shown)}`]);
|
|
104
|
+
const show = ({ done, total, path: rel }) => session.draw((ctx) => [
|
|
105
|
+
'', ` Installing ai-sdlc ${sanitize(bundle.version)}`, renderProgress({ theme: ctx.theme, width: ctx.width, done, total, label: sanitize(rel || 'preparing') }),
|
|
106
|
+
]);
|
|
107
|
+
applyPlan(project, plan, 'install', lock, { onProgress: show });
|
|
108
|
+
rememberProject(project, { env: io.env, home: io.home });
|
|
109
|
+
session.commit(installedPanel({ theme: session.theme, width: session.ctx().width, project: shortPath(project, io.home), plan, version: bundle.version }));
|
|
110
|
+
return { changed: false };
|
|
111
|
+
} finally {
|
|
112
|
+
lock.release();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runInitFlow({ flags, io, cliVersion }) {
|
|
117
|
+
const theme = createTheme({ env: io.env, stream: io.stdout });
|
|
118
|
+
try {
|
|
119
|
+
return await withSession(sessionOptions(io, theme), (session) => wizard({ session, flags, io, cliVersion }));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err instanceof Cancelled) throw new CliError(err.reason === 'interrupt' ? 'interrupted; nothing was changed' : 'cancelled; nothing was changed');
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { runInitFlow };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The header every interactive screen starts with: banner, then a dim status line.
|
|
3
|
+
const { bannerFor } = require('../logo');
|
|
4
|
+
|
|
5
|
+
const indent = (lines) => lines.map((l) => ` ${l}`);
|
|
6
|
+
|
|
7
|
+
function bannerLines(session) {
|
|
8
|
+
const { theme } = session;
|
|
9
|
+
return ['', ...indent(bannerFor(session.columns, theme.level, { unicode: theme.unicode })), ''];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { bannerLines, indent };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Bare `ai-sdlc` in a terminal: banner, what the current folder looks like,
|
|
3
|
+
// and a short list of actions.
|
|
4
|
+
const { CliError } = require('../../errors');
|
|
5
|
+
const { createTheme } = require('../theme');
|
|
6
|
+
const { withSession, Cancelled, BACK } = require('../terminal');
|
|
7
|
+
const { createSelectList } = require('../widgets/select-list');
|
|
8
|
+
const { bannerLines } = require('./intro');
|
|
9
|
+
const { sessionOptions } = require('./session-options');
|
|
10
|
+
const { truncateMiddle } = require('../text-width');
|
|
11
|
+
const { sanitize } = require('../sanitize');
|
|
12
|
+
const { projectState, shortPath } = require('./project-choices');
|
|
13
|
+
|
|
14
|
+
const ACTIONS = [
|
|
15
|
+
{ label: 'Install', hint: 'set up ai-sdlc in a project', value: 'install', hotkey: '1' },
|
|
16
|
+
{ label: 'Update', hint: 'bring an installed project up to date', value: 'update', hotkey: '2' },
|
|
17
|
+
{ label: 'Doctor', hint: 'check an installed project', value: 'doctor', hotkey: '3' },
|
|
18
|
+
{ label: 'Uninstall', hint: 'remove what install added', value: 'uninstall', hotkey: '4' },
|
|
19
|
+
{ label: 'Quit', hint: '', value: 'quit', hotkey: '5' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function stateLines(theme, state, cwd, home, width) {
|
|
23
|
+
let status;
|
|
24
|
+
if (state.invalid) status = theme.paint('warn', `${theme.sym.warn} manifest cannot be trusted: ${truncateMiddle(state.invalid, Math.max(20, width - 40))}`);
|
|
25
|
+
else if (state.version) status = theme.paint('ok', `${theme.sym.ok} ai-sdlc ${sanitize(state.version)} installed`);
|
|
26
|
+
else {
|
|
27
|
+
status = theme.paint('dim', 'ai-sdlc not installed');
|
|
28
|
+
if (!state.hasGit) status += ` ${theme.paint('warn', `${theme.sym.warn} no .git`)}`;
|
|
29
|
+
}
|
|
30
|
+
return [` ${theme.paint('dim', 'Folder')} ${truncateMiddle(sanitize(shortPath(cwd, home)), width - 12)}`, ` ${theme.paint('dim', 'Status')} ${status}`];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Resolves to { command, installedHere }. Quit and Esc give command 'quit'.
|
|
34
|
+
async function chooseAction({ io }) {
|
|
35
|
+
const theme = createTheme({ env: io.env, stream: io.stdout });
|
|
36
|
+
const state = projectState(io.cwd);
|
|
37
|
+
try {
|
|
38
|
+
const command = await withSession(sessionOptions(io, theme), async (session) => {
|
|
39
|
+
session.commit([...bannerLines(session), ...stateLines(theme, state, io.cwd, io.home, session.ctx().width), '']);
|
|
40
|
+
const picked = await session.run(createSelectList({ title: 'What would you like to do?', items: ACTIONS, filterable: false, maxVisible: ACTIONS.length }));
|
|
41
|
+
session.clear();
|
|
42
|
+
return picked === BACK ? 'quit' : picked.item.value;
|
|
43
|
+
});
|
|
44
|
+
return { command, installedHere: Boolean(state.version) && !state.invalid };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err instanceof Cancelled) throw new CliError(err.reason === 'interrupt' ? 'interrupted; nothing was changed' : 'cancelled; nothing was changed');
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { chooseAction, ACTIONS };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// "Which project?" step: filterable list of repositories and recent projects,
|
|
3
|
+
// with an "Enter a path..." entry that opens a path field with Tab completion.
|
|
4
|
+
const { UsageError } = require('../../errors');
|
|
5
|
+
const { validateTarget } = require('../../project-picker');
|
|
6
|
+
const { BACK } = require('../terminal');
|
|
7
|
+
const { createSelectList } = require('../widgets/select-list');
|
|
8
|
+
const { createPathInput } = require('../widgets/path-input');
|
|
9
|
+
const { buildProjectItems, PATH_CHOICE } = require('./project-choices');
|
|
10
|
+
|
|
11
|
+
// Resolves to { dir, hasGit } or BACK.
|
|
12
|
+
async function pickProjectStep({ session, cwd, home, root, recents, notice: initialNotice = '' }) {
|
|
13
|
+
const items = buildProjectItems({ cwd, root, home, recents, theme: session.theme });
|
|
14
|
+
const maxVisible = Math.max(3, Math.min(10, session.rows - 10));
|
|
15
|
+
const validate = (text) => validateTarget(text, { cwd, home });
|
|
16
|
+
let notice = initialNotice;
|
|
17
|
+
let mode = 'list';
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (mode === 'list') {
|
|
20
|
+
const picked = await session.run(createSelectList({ title: 'Select the project to install into', items, maxVisible, notice, placeholder: 'Type to filter projects' }));
|
|
21
|
+
if (picked === BACK) return BACK;
|
|
22
|
+
if (picked.item.value === PATH_CHOICE) {
|
|
23
|
+
mode = 'path';
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return validate(picked.item.value);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (!(err instanceof UsageError)) throw err;
|
|
30
|
+
notice = err.message;
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
const typed = await session.run(createPathInput({ cwd, home, validate }));
|
|
34
|
+
if (typed === BACK) {
|
|
35
|
+
mode = 'list';
|
|
36
|
+
notice = '';
|
|
37
|
+
} else return typed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { pickProjectStep };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Environment checks shown before the project picker. Items are
|
|
3
|
+
// { id, status: 'ok' | 'warn' | 'fail', label, detail, segment? }.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFile: nodeExecFile } = require('child_process');
|
|
7
|
+
const { sanitize } = require('../sanitize');
|
|
8
|
+
const { payloadRepo, apiBase, isDefaultApiBase } = require('../../config');
|
|
9
|
+
|
|
10
|
+
const MIN_NODE_MAJOR = 18;
|
|
11
|
+
|
|
12
|
+
function probeGit(execFile, signal) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
execFile('git', ['--version'], { timeout: 4000, windowsHide: true, signal }, (err, stdout) => {
|
|
15
|
+
resolve(err ? null : sanitize(String(stdout).trim().replace(/^git version\s*/i, '')).slice(0, 80));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// "owner/repo", plus the API base when it is not the public GitHub API (so it is never a surprise).
|
|
21
|
+
function releaseSource(env) {
|
|
22
|
+
try {
|
|
23
|
+
const repo = payloadRepo(env);
|
|
24
|
+
const base = apiBase(env);
|
|
25
|
+
return { repo, custom: !isDefaultApiBase(base), text: isDefaultApiBase(base) ? repo : `${repo} via ${base}` };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return { custom: false, text: err.message };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sourceItem(flags, cwd, env) {
|
|
32
|
+
if (flags.fromBundle) {
|
|
33
|
+
const file = path.resolve(cwd, flags.fromBundle);
|
|
34
|
+
const found = fs.existsSync(file);
|
|
35
|
+
return {
|
|
36
|
+
id: 'source', status: found ? 'ok' : 'fail', label: 'Kit source: bundle file',
|
|
37
|
+
detail: sanitize(found ? path.basename(file) : `not found: ${file}`), segment: found ? 'kit source: bundle file' : 'kit source: missing',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const src = releaseSource(env);
|
|
41
|
+
return {
|
|
42
|
+
id: 'source', status: 'ok', label: 'Kit source: GitHub release',
|
|
43
|
+
detail: sanitize(src.text), segment: src.custom ? 'kit source: GitHub release (custom API)' : 'kit source: GitHub release',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `extraChecks` is the plug-in point for further checks (the wizard adds the GitHub
|
|
48
|
+
// token and repository access checks when no local bundle is given). Each is an async (context) => item | null and runs after
|
|
49
|
+
// the built-in ones; an exception becomes a warning item instead of aborting.
|
|
50
|
+
async function runPreflight({ flags = {}, cwd = process.cwd(), nodeVersion = process.version, execFile = nodeExecFile, extraChecks = [], env = process.env, signal } = {}) {
|
|
51
|
+
const major = Number(String(nodeVersion).replace(/^v/, '').split('.')[0]);
|
|
52
|
+
const git = await probeGit(execFile, signal);
|
|
53
|
+
const items = [
|
|
54
|
+
{ id: 'node', status: major >= MIN_NODE_MAJOR ? 'ok' : 'fail', label: 'Node.js', detail: `${nodeVersion} (needs >= ${MIN_NODE_MAJOR})`, segment: `node ${major}` },
|
|
55
|
+
{ id: 'git', status: git ? 'ok' : 'warn', label: 'git', detail: git || 'not found; only used to recognise repositories', segment: 'git' },
|
|
56
|
+
sourceItem(flags, cwd, env),
|
|
57
|
+
];
|
|
58
|
+
for (const check of extraChecks) {
|
|
59
|
+
try {
|
|
60
|
+
const item = await check({ flags, cwd, signal });
|
|
61
|
+
if (item) items.push(item);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (signal && signal.aborted) throw signal.reason;
|
|
64
|
+
items.push({ id: 'extra', status: 'warn', label: 'Additional check', detail: sanitize(err.message) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { items };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// "Using: node 24 | git ✔ | kit source: bundle file"
|
|
71
|
+
function statusLine(theme, items) {
|
|
72
|
+
const parts = items.filter((i) => i.segment).map((i) => {
|
|
73
|
+
if (i.id !== 'node' && i.id !== 'git' && i.id !== 'source') return sanitize(i.segment);
|
|
74
|
+
if (i.id === 'git') return `git ${i.status === 'ok' ? theme.sym.ok : theme.sym.warn}`;
|
|
75
|
+
return i.segment;
|
|
76
|
+
});
|
|
77
|
+
return `Using: ${parts.join(' | ')}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { runPreflight, statusLine, MIN_NODE_MAJOR };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Candidate projects for the picker: remembered ones first, then repositories
|
|
3
|
+
// found next to the working directory (or under --root). Everything shown is
|
|
4
|
+
// data from disk, so it is sanitized before it reaches a widget.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { findRepos } = require('../../project-picker');
|
|
8
|
+
const { readManifest } = require('../../manifest');
|
|
9
|
+
const { sanitize } = require('../sanitize');
|
|
10
|
+
|
|
11
|
+
const PATH_CHOICE = Symbol('enter-a-path');
|
|
12
|
+
const MAX_CANDIDATES = 200;
|
|
13
|
+
|
|
14
|
+
// { hasGit, version, invalid }: version is the installed payload version or null;
|
|
15
|
+
// invalid carries the reason when the manifest exists but cannot be trusted.
|
|
16
|
+
function projectState(dir) {
|
|
17
|
+
const state = { hasGit: fs.existsSync(path.join(dir, '.git')), version: null, invalid: null };
|
|
18
|
+
try {
|
|
19
|
+
const m = readManifest(dir);
|
|
20
|
+
state.version = m ? m.payload_version : null;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
state.invalid = sanitize(err.message);
|
|
23
|
+
}
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const shortPath = (dir, home) => (home && (dir === home || dir.startsWith(home + path.sep)) ? `~${dir.slice(home.length)}` : dir);
|
|
28
|
+
|
|
29
|
+
function badgeFor(state, theme) {
|
|
30
|
+
if (!state.hasGit) return { badge: `no .git ${theme.sym.warn}`, tone: 'warn' };
|
|
31
|
+
if (state.invalid) return { badge: `manifest invalid ${theme.sym.warn}`, tone: 'warn' };
|
|
32
|
+
if (state.version) return { badge: `installed v${sanitize(state.version)} ${theme.sym.arrow} update`, tone: 'ok' };
|
|
33
|
+
return { badge: 'not installed', tone: 'dim' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function realOrSelf(p) {
|
|
37
|
+
try {
|
|
38
|
+
return fs.realpathSync(p);
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Volumes on Windows and macOS are usually case-insensitive: the same folder spelled twice is one folder.
|
|
45
|
+
const dedupeKey = (p) => (process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p);
|
|
46
|
+
|
|
47
|
+
// Returns picker items ending with the sticky "Enter a path..." entry (at most MAX_CANDIDATES projects).
|
|
48
|
+
function buildProjectItems({ cwd, root, home, recents = [], theme }) {
|
|
49
|
+
const bases = root ? [path.resolve(cwd, root)] : [cwd, path.dirname(cwd)];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const items = [];
|
|
52
|
+
for (const dir of [...recents, ...findRepos(bases, MAX_CANDIDATES)]) {
|
|
53
|
+
if (items.length >= MAX_CANDIDATES) break;
|
|
54
|
+
const real = realOrSelf(dir);
|
|
55
|
+
const key = dedupeKey(real);
|
|
56
|
+
if (seen.has(key)) continue;
|
|
57
|
+
seen.add(key);
|
|
58
|
+
items.push({ label: path.basename(real) || real, hint: shortPath(real, home), value: real, ...badgeFor(projectState(real), theme) });
|
|
59
|
+
}
|
|
60
|
+
items.push({ label: `Enter a path${theme.sym.ellipsis}`, hint: '', value: PATH_CHOICE, sticky: true });
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { buildProjectItems, projectState, shortPath, badgeFor, PATH_CHOICE, MAX_CANDIDATES };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Remembers project paths (and nothing else) between runs in a small JSON file
|
|
3
|
+
// under the user's config directory. Every failure is ignored: this is a
|
|
4
|
+
// convenience, never a reason for a run to fail.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const MAX_RECENT = 12;
|
|
11
|
+
const MAX_EXAMINED = 40; // entries checked on disk per read, so a dead path cannot stall startup
|
|
12
|
+
|
|
13
|
+
// XDG_CONFIG_HOME counts only when absolute, as the XDG specification says.
|
|
14
|
+
function configDir(env = process.env, home = os.homedir(), platform = process.platform) {
|
|
15
|
+
if (env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)) return path.join(env.XDG_CONFIG_HOME, 'ai-sdlc');
|
|
16
|
+
if (platform === 'win32' && env.APPDATA) return path.join(env.APPDATA, 'ai-sdlc');
|
|
17
|
+
return path.join(home, '.config', 'ai-sdlc');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const recentFile = (opts = {}) => path.join(configDir(opts.env, opts.home, opts.platform), 'recent.json');
|
|
21
|
+
|
|
22
|
+
function readList(opts) {
|
|
23
|
+
const parsed = JSON.parse(fs.readFileSync(recentFile(opts), 'utf8'));
|
|
24
|
+
return Array.isArray(parsed && parsed.projects) ? parsed.projects.filter((p) => typeof p === 'string' && path.isAbsolute(p)) : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Absolute paths of remembered projects that still exist, newest first.
|
|
28
|
+
function readRecent(opts = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const p of readList(opts).slice(0, MAX_EXAMINED)) {
|
|
32
|
+
try {
|
|
33
|
+
if (fs.statSync(p).isDirectory()) out.push(p);
|
|
34
|
+
} catch (_) {
|
|
35
|
+
/* gone: skip */
|
|
36
|
+
}
|
|
37
|
+
if (out.length >= MAX_RECENT) break;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Private to the user: the folder is created 0700 and the file 0600 (no effect on Windows).
|
|
46
|
+
function writePrivate(file, text) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
fs.rmSync(tmp, { force: true });
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Puts `dir` first in the list. Returns true when the file was written.
|
|
59
|
+
function rememberProject(dir, opts = {}) {
|
|
60
|
+
try {
|
|
61
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir)) return false;
|
|
62
|
+
let current = [];
|
|
63
|
+
try {
|
|
64
|
+
current = readList(opts);
|
|
65
|
+
} catch (_) {
|
|
66
|
+
/* no file yet, or unreadable: start a new list */
|
|
67
|
+
}
|
|
68
|
+
const projects = [dir, ...current.filter((p) => p !== dir)].slice(0, MAX_RECENT);
|
|
69
|
+
writePrivate(recentFile(opts), `${JSON.stringify({ projects }, null, 2)}\n`);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (_) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { configDir, recentFile, readRecent, rememberProject };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Static views (frames are plain string[]) for results: doctor checklist,
|
|
3
|
+
// plan listing, and the closing panels. No input handling here.
|
|
4
|
+
const { summarize } = require('../../plan');
|
|
5
|
+
const { QUIET } = require('../../cli-output');
|
|
6
|
+
const { padEnd, truncateMiddle } = require('../text-width');
|
|
7
|
+
const { sanitize } = require('../sanitize');
|
|
8
|
+
const { renderPanel } = require('../widgets/panel');
|
|
9
|
+
|
|
10
|
+
const ACTION_TONE = {
|
|
11
|
+
create: 'ok', restore: 'ok', replace: 'accent', update: 'accent', 'write-new': 'warn', 'skip-deleted': 'warn',
|
|
12
|
+
'keep-modified': 'warn', delete: 'err', remove: 'err', 'keep-existing': 'dim', unchanged: 'dim', forget: 'dim',
|
|
13
|
+
};
|
|
14
|
+
const STATUS_SYMBOL = { ok: 'ok', warn: 'warn', fail: 'err', info: 'info' };
|
|
15
|
+
|
|
16
|
+
const counts = (actions) => Object.entries(summarize(actions)).map(([k, v]) => `${v} ${sanitize(k)}`).join(', ');
|
|
17
|
+
|
|
18
|
+
function checkRow(theme, check, nameWidth) {
|
|
19
|
+
const tone = theme.tone(check.status);
|
|
20
|
+
const mark = theme.sym[STATUS_SYMBOL[check.status] || 'info'];
|
|
21
|
+
return ` ${theme.paint(tone, mark)} ${padEnd(sanitize(check.name), nameWidth)} ${theme.paint('dim', sanitize(check.detail))}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function doctorView({ theme, width, project, report, warnings = [] }) {
|
|
25
|
+
const nameWidth = Math.max(0, ...report.checks.map((c) => sanitize(c.name).length));
|
|
26
|
+
const by = (s) => report.checks.filter((c) => c.status === s).length;
|
|
27
|
+
const failed = by('fail');
|
|
28
|
+
const warned = by('warn');
|
|
29
|
+
const tone = failed ? 'err' : warned ? 'warn' : 'ok';
|
|
30
|
+
const verdict = failed ? 'Problems found' : warned ? 'Healthy, with warnings' : 'Healthy';
|
|
31
|
+
return [
|
|
32
|
+
'',
|
|
33
|
+
` ${theme.paint('bold', 'ai-sdlc doctor')} ${theme.paint('dim', truncateMiddle(sanitize(project), width - 20))}`,
|
|
34
|
+
'',
|
|
35
|
+
...report.checks.map((c) => checkRow(theme, c, nameWidth)),
|
|
36
|
+
...warnings.map((w) => ` ${theme.paint('warn', theme.sym.warn)} ${sanitize(w)}`),
|
|
37
|
+
'',
|
|
38
|
+
...renderPanel({
|
|
39
|
+
theme, width, tone, title: verdict,
|
|
40
|
+
lines: [`${report.checks.length} checks: ${by('ok')} ok, ${warned} warning(s), ${failed} failed`],
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Action rows, quiet ones folded into one line. `limit` caps the rows (null = all).
|
|
46
|
+
function planRows(theme, plan, limit = null) {
|
|
47
|
+
const loud = plan.actions.filter((a) => !QUIET.has(a.action));
|
|
48
|
+
const shown = limit === null ? loud : loud.slice(0, limit);
|
|
49
|
+
const rows = shown.map((a) => {
|
|
50
|
+
const tone = ACTION_TONE[a.action] || 'dim';
|
|
51
|
+
return ` ${theme.paint(tone, theme.sym.bullet)} ${theme.paint(tone, padEnd(sanitize(a.action), 13))} ${sanitize(a.path)}${a.detail ? theme.paint('dim', ` (${sanitize(a.detail)})`) : ''}`;
|
|
52
|
+
});
|
|
53
|
+
if (shown.length < loud.length) rows.push(theme.paint('dim', ` ${theme.sym.ellipsis} ${loud.length - shown.length} more`));
|
|
54
|
+
const quiet = plan.actions.length - loud.length;
|
|
55
|
+
if (quiet) rows.push(theme.paint('dim', ` (${quiet} already in place or user-owned, untouched)`));
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function planView({ theme, width, command, project, plan, dryRun }) {
|
|
60
|
+
return [
|
|
61
|
+
'',
|
|
62
|
+
` ${theme.paint('bold', `ai-sdlc ${command}`)}${dryRun ? theme.paint('warn', ' dry run, nothing will be written') : ''}`,
|
|
63
|
+
` ${theme.paint('dim', `project: ${truncateMiddle(sanitize(project), width - 14)}`)}`,
|
|
64
|
+
` ${theme.paint('dim', `payload: ${sanitize(plan.payloadVersion)}`)}`,
|
|
65
|
+
'',
|
|
66
|
+
...planRows(theme, plan),
|
|
67
|
+
'',
|
|
68
|
+
` ${theme.paint('dim', `summary: ${counts(plan.actions)}`)}`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function doneView({ theme, width, command, plan }) {
|
|
73
|
+
const lines = [`${command} finished: ${counts(plan.actions)}`];
|
|
74
|
+
if (plan.actions.some((a) => a.action === 'write-new')) lines.push('Review the *.new files next to the files you modified and merge by hand.');
|
|
75
|
+
return ['', ...renderPanel({ theme, width, tone: 'ok', title: `${theme.sym.ok} Done`, lines })];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The wizard's closing panel: what was written and what to do next.
|
|
79
|
+
function installedPanel({ theme, width, project, plan, version }) {
|
|
80
|
+
const written = plan.actions.filter((a) => a.write).length;
|
|
81
|
+
const lines = [`ai-sdlc ${sanitize(version)} installed into ${truncateMiddle(sanitize(project), width - 30)}`, `${written} file(s) written (${counts(plan.actions)})`, '', 'Next steps:', ' 1. Open this folder in Claude Code (terminal or the VS Code extension).'];
|
|
82
|
+
let n = 2;
|
|
83
|
+
if (plan.actions.some((a) => a.path === 'ai-sdlc/project.md')) lines.push(` ${n++}. Fill in ai-sdlc/project.md with what your project is about.`);
|
|
84
|
+
lines.push(` ${n++}. Run \`ai-sdlc doctor\` in the project to check the install.`);
|
|
85
|
+
if (plan.actions.some((a) => a.action === 'write-new')) lines.push('', 'Review the *.new files next to files you modified and merge by hand.');
|
|
86
|
+
return ['', ...renderPanel({ theme, width, tone: 'ok', title: `${theme.sym.ok} Installed`, lines })];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { doctorView, planView, planRows, doneView, installedPanel, counts };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Body of the review screen: what the run will do, grouped by file class.
|
|
3
|
+
const { padEnd } = require('../text-width');
|
|
4
|
+
const { sanitize } = require('../sanitize');
|
|
5
|
+
const { planRows } = require('./result-views');
|
|
6
|
+
|
|
7
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
8
|
+
|
|
9
|
+
// Per-class tallies: { managed: { total, by: { create: 2 } }, 'user-once': {...} }.
|
|
10
|
+
function tallyByClass(plan, bundle) {
|
|
11
|
+
const classOf = new Map(((bundle && bundle.files) || []).map((f) => [f.path, f.class]));
|
|
12
|
+
const out = { managed: { total: 0, by: {} }, 'user-once': { total: 0, by: {} } };
|
|
13
|
+
for (const a of plan.actions.filter((x) => x.kind === 'file')) {
|
|
14
|
+
const cls = classOf.get(a.action === 'write-new' ? a.path.replace(/\.new$/, '') : a.path) || 'managed';
|
|
15
|
+
if (a.action !== 'write-new') out[cls].total += 1;
|
|
16
|
+
out[cls].by[a.action] = (out[cls].by[a.action] || 0) + 1;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const breakdown = (by) => Object.entries(by).map(([k, v]) => `${sanitize(k)} ${v}`).join(', ');
|
|
22
|
+
|
|
23
|
+
// ctx: { theme, project, plan, bundle, hasGit, warnings }
|
|
24
|
+
function reviewBody({ theme, project, plan, bundle, hasGit, warnings = [] }, state) {
|
|
25
|
+
const dim = (s) => theme.paint('dim', s);
|
|
26
|
+
const tally = tallyByClass(plan, bundle);
|
|
27
|
+
const block = plan.actions.find((a) => a.kind === 'block');
|
|
28
|
+
const settings = plan.actions.find((a) => a.kind === 'settings');
|
|
29
|
+
const hookCount = bundle ? bundle.hooks.length : 0;
|
|
30
|
+
const row = (name, text, extra) => ` ${theme.paint('accent', padEnd(name, 10))} ${padEnd(text, 18)} ${dim(extra)}`;
|
|
31
|
+
|
|
32
|
+
const lines = [`${theme.paint('bold', `ai-sdlc ${sanitize(plan.payloadVersion)}`)} ${theme.sym.arrow} ${sanitize(project)}`, ''];
|
|
33
|
+
lines.push(row('managed', plural(tally.managed.total, 'file'), breakdown(tally.managed.by)));
|
|
34
|
+
lines.push(row('user-once', plural(tally['user-once'].total, 'starter file'), breakdown(tally['user-once'].by)));
|
|
35
|
+
lines.push(row('block', 'CLAUDE.md block', block ? sanitize(block.action) : 'unchanged'));
|
|
36
|
+
lines.push(row('hooks', plural(hookCount, 'hook'), `.claude/settings.json ${settings ? sanitize(settings.action) : 'unchanged'}`));
|
|
37
|
+
lines.push('', dim(' CLAUDE.md: only the marked ai-sdlc block is managed; the rest of the file is never touched.'));
|
|
38
|
+
lines.push(dim(' Starter files are created only if missing and never overwritten.'));
|
|
39
|
+
if (!hasGit) lines.push('', ` ${theme.paint('warn', `${theme.sym.warn} no .git directory here; this may not be a repository root`)}`);
|
|
40
|
+
for (const w of warnings) lines.push(` ${theme.paint('warn', `${theme.sym.warn} ${sanitize(w)}`)}`);
|
|
41
|
+
if (state.expanded) lines.push('', theme.paint('bold', ' Full list'), ...planRows(theme, plan));
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { reviewBody, tallyByClass };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Options for a terminal session built from the command's io.
|
|
3
|
+
const sessionOptions = (io, theme) => ({
|
|
4
|
+
stdin: io.stdin, stdout: io.stdout, stderr: io.stderr, theme, proc: io.proc, escapeMs: io.escapeMs,
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
module.exports = { sessionOptions };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Result views for update/doctor/uninstall when stdout is a terminal.
|
|
3
|
+
// Each returns lines for the caller to print; nothing here reads input.
|
|
4
|
+
const { createTheme } = require('../theme');
|
|
5
|
+
const views = require('./result-views');
|
|
6
|
+
|
|
7
|
+
function createViews(io) {
|
|
8
|
+
const theme = createTheme({ env: io.env, stream: io.stdout });
|
|
9
|
+
const width = Math.max(40, (io.stdout.columns || 80) - 1);
|
|
10
|
+
return {
|
|
11
|
+
doctor: (data) => views.doctorView({ theme, width, ...data }),
|
|
12
|
+
plan: (data) => views.planView({ theme, width, ...data }),
|
|
13
|
+
done: (data) => views.doneView({ theme, width, ...data }),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { createViews };
|