@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,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { publicView, summarize } = require('./plan');
|
|
3
|
+
|
|
4
|
+
// Actions that change nothing are folded into a count in human output.
|
|
5
|
+
const QUIET = new Set(['unchanged', 'keep-existing', 'forget']);
|
|
6
|
+
|
|
7
|
+
function planLines(plan) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
const quiet = plan.actions.filter((a) => QUIET.has(a.action)).length;
|
|
10
|
+
for (const a of plan.actions.filter((x) => !QUIET.has(x.action))) {
|
|
11
|
+
lines.push(` ${a.action.padEnd(13)} ${a.path}${a.detail ? ` (${a.detail})` : ''}`);
|
|
12
|
+
}
|
|
13
|
+
if (quiet) lines.push(` (${quiet} already in place or user-owned, untouched)`);
|
|
14
|
+
const counts = Object.entries(summarize(plan.actions)).map(([k, v]) => `${v} ${k}`);
|
|
15
|
+
lines.push(` summary: ${counts.join(', ')}`);
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function planResult(command, project, plan, extra) {
|
|
20
|
+
return {
|
|
21
|
+
ok: true,
|
|
22
|
+
command,
|
|
23
|
+
project,
|
|
24
|
+
payloadVersion: plan.payloadVersion,
|
|
25
|
+
actions: plan.actions.map(publicView),
|
|
26
|
+
summary: summarize(plan.actions),
|
|
27
|
+
...extra,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function doctorLines(report) {
|
|
32
|
+
const tag = { ok: '[ok] ', warn: '[warn]', fail: '[FAIL]', info: '[info]' };
|
|
33
|
+
return report.checks.map((c) => `${tag[c.status]} ${c.name}: ${c.detail}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { planLines, planResult, doctorLines, QUIET };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { CliError } = require('./errors');
|
|
4
|
+
const { readManifest } = require('./manifest');
|
|
5
|
+
const { planSync, planRemove } = require('./plan');
|
|
6
|
+
|
|
7
|
+
// Reads the installed manifest, applies the install/update/uninstall
|
|
8
|
+
// preconditions and plans the run. Read-only: nothing is written.
|
|
9
|
+
function buildCommandPlan({ command, flags, project, bundle, cliVersion }) {
|
|
10
|
+
const prior = readManifest(project);
|
|
11
|
+
if (command === 'install' && prior && !flags.force) {
|
|
12
|
+
throw new CliError(`already installed (payload ${prior.payload_version}); use "ai-sdlc update", or --force to reinstall`);
|
|
13
|
+
}
|
|
14
|
+
if (command !== 'install' && !prior) throw new CliError(`ai-sdlc is not installed in ${project}`);
|
|
15
|
+
const plan = command === 'uninstall'
|
|
16
|
+
? planRemove({ project, prior })
|
|
17
|
+
: planSync({ project, bundle, prior, force: flags.force, cliVersion, op: command });
|
|
18
|
+
return { prior, plan };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Stable fingerprint of what a plan would do (actions, paths and the bytes to write),
|
|
22
|
+
// to tell whether a plan made earlier still matches the project.
|
|
23
|
+
function planSignature(plan) {
|
|
24
|
+
const rows = plan.actions.map((a) => [a.kind, a.action, a.path, a.detail, a.write ? crypto.createHash('sha256').update(a.write).digest('hex') : null, Boolean(a.remove)]);
|
|
25
|
+
return crypto.createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { buildCommandPlan, planSignature };
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const pkg = require('../package.json');
|
|
4
|
+
const { CliError, UsageError } = require('./errors');
|
|
5
|
+
const { parseArgs, validateFlags, HELP } = require('./cli-args');
|
|
6
|
+
const { loadReleaseBundle } = require('./release-source');
|
|
7
|
+
const { createGithubClient, createReleaseFetcher } = require('./github-release');
|
|
8
|
+
const { JOURNAL_REL } = require('./path-rules');
|
|
9
|
+
const { acquireLock } = require('./journal');
|
|
10
|
+
const { inspect } = require('./fs-safe');
|
|
11
|
+
const { buildCommandPlan } = require('./command-plan');
|
|
12
|
+
const { applyPlan } = require('./apply-plan');
|
|
13
|
+
const { createPrompter, confirm, pickProject } = require('./project-picker');
|
|
14
|
+
const { runDoctor } = require('./doctor');
|
|
15
|
+
const { planLines, planResult, doctorLines } = require('./cli-output');
|
|
16
|
+
const { shouldUseTui, shouldRunWizard } = require('./tui/launch');
|
|
17
|
+
|
|
18
|
+
const MUTATING = new Set(['install', 'update', 'uninstall']);
|
|
19
|
+
|
|
20
|
+
function makeIo(env) {
|
|
21
|
+
const github = env.github || createGithubClient({
|
|
22
|
+
env: env.env || process.env, runCommand: env.runCommand, fetchImpl: env.fetch, releaseOptions: env.releaseOptions, proc: env.proc,
|
|
23
|
+
});
|
|
24
|
+
const stdin = env.stdin || process.stdin;
|
|
25
|
+
const stdout = env.stdout || process.stdout;
|
|
26
|
+
return {
|
|
27
|
+
stdin,
|
|
28
|
+
stdout,
|
|
29
|
+
stderr: env.stderr || process.stderr,
|
|
30
|
+
cwd: env.cwd || process.cwd(),
|
|
31
|
+
home: env.home || os.homedir(),
|
|
32
|
+
isTTY: env.isTTY !== undefined ? env.isTTY : Boolean(stdin.isTTY && stdout.isTTY),
|
|
33
|
+
probePython: env.probePython,
|
|
34
|
+
github,
|
|
35
|
+
fetchLatestBundle: env.fetchLatestBundle || createReleaseFetcher({ client: github }),
|
|
36
|
+
env: env.env || process.env,
|
|
37
|
+
proc: env.proc || process,
|
|
38
|
+
tui: env.tui,
|
|
39
|
+
execFile: env.execFile,
|
|
40
|
+
extraChecks: env.extraChecks,
|
|
41
|
+
escapeMs: env.escapeMs,
|
|
42
|
+
confirmGuardMs: env.confirmGuardMs,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Without a terminal nothing may be asked, so the answers must be on the command line.
|
|
47
|
+
function requireNonInteractiveInputs(command, flags, io) {
|
|
48
|
+
const interactive = io.isTTY && !flags.json;
|
|
49
|
+
if (interactive) return true;
|
|
50
|
+
if (!flags.project) throw new UsageError('--project <path> is required when not running in an interactive terminal');
|
|
51
|
+
if (MUTATING.has(command) && !flags.dryRun && !flags.yes) {
|
|
52
|
+
throw new UsageError('--yes is required to apply changes when not running in an interactive terminal (or use --dry-run)');
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function runProjectCommand(command, flags, io, say) {
|
|
58
|
+
const interactive = requireNonInteractiveInputs(command, flags, io);
|
|
59
|
+
const log = (line = '') => (flags.json ? undefined : say.stdout.write(`${line}\n`));
|
|
60
|
+
// On a terminal, update/doctor/uninstall print coloured result views instead of plain lines.
|
|
61
|
+
const view = command !== 'install' && shouldUseTui(flags, io) ? require('./tui/flows/terminal-views').createViews(io) : null;
|
|
62
|
+
const emit = (lines) => say.stdout.write(`${lines.join('\n')}\n`);
|
|
63
|
+
const prompter = interactive ? createPrompter({ input: io.stdin, output: io.stdout }) : null;
|
|
64
|
+
let lock = null;
|
|
65
|
+
try {
|
|
66
|
+
let bundle = null;
|
|
67
|
+
if (command === 'install' || command === 'update' || (command === 'doctor' && flags.fromBundle)) {
|
|
68
|
+
bundle = await loadReleaseBundle({ fromBundle: flags.fromBundle, version: flags.version, cwd: io.cwd }, { fetchLatestBundle: io.fetchLatestBundle }, { cliVersion: pkg.version });
|
|
69
|
+
}
|
|
70
|
+
const bundleWarnings = bundle ? [...bundle.warnings] : [];
|
|
71
|
+
const target = await pickProject({
|
|
72
|
+
project: flags.project, root: flags.root, cwd: io.cwd, home: io.home, prompter,
|
|
73
|
+
output: flags.json ? { write() {} } : say.stdout,
|
|
74
|
+
});
|
|
75
|
+
const project = target.dir;
|
|
76
|
+
const warnings = target.hasGit ? bundleWarnings : [...bundleWarnings, `${project} has no .git directory`];
|
|
77
|
+
if (!flags.json) for (const w of bundleWarnings) say.stderr.write(`warning: ${w}\n`);
|
|
78
|
+
|
|
79
|
+
if (command === 'doctor') {
|
|
80
|
+
const report = runDoctor({ project, nodeVersion: process.version, bundleVersion: bundle && bundle.version, probePython: io.probePython });
|
|
81
|
+
if (flags.json) say.stdout.write(`${JSON.stringify({ ...report, command, project, warnings }, null, 2)}\n`);
|
|
82
|
+
else if (view) emit(view.doctor({ project, report, warnings: warnings.filter((w) => !bundleWarnings.includes(w)) }));
|
|
83
|
+
else {
|
|
84
|
+
log(`ai-sdlc doctor: ${project}`);
|
|
85
|
+
doctorLines(report).forEach((l) => log(l));
|
|
86
|
+
}
|
|
87
|
+
return report.ok ? 0 : 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Real runs hold the project lock (and the recovery journal) from here on.
|
|
91
|
+
if (!flags.dryRun) lock = acquireLock(project, command, { force: Boolean(flags.force) });
|
|
92
|
+
else if (inspect(project, JOURNAL_REL).exists) warnings.push(`${JOURNAL_REL} exists: an earlier run was interrupted or another run is active`);
|
|
93
|
+
|
|
94
|
+
const { plan } = buildCommandPlan({ command, flags, project, bundle, cliVersion: pkg.version });
|
|
95
|
+
|
|
96
|
+
if (view && !flags.json) emit(view.plan({ command, project, plan, dryRun: Boolean(flags.dryRun) }));
|
|
97
|
+
else {
|
|
98
|
+
log(`ai-sdlc ${command}${flags.dryRun ? ' (dry run, nothing will be written)' : ''}`);
|
|
99
|
+
log(` project: ${project}`);
|
|
100
|
+
log(` payload: ${plan.payloadVersion}`);
|
|
101
|
+
planLines(plan).forEach((l) => log(l));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = planResult(command, project, plan, { dryRun: Boolean(flags.dryRun), warnings });
|
|
105
|
+
if (flags.dryRun) return emitJson(flags, say, result, 0);
|
|
106
|
+
|
|
107
|
+
if (!flags.yes) {
|
|
108
|
+
if (!(await confirm(prompter, 'Apply these changes?'))) throw new CliError('not confirmed; nothing was changed');
|
|
109
|
+
}
|
|
110
|
+
applyPlan(project, plan, command, lock);
|
|
111
|
+
if (view && !flags.json) emit(view.done({ command, plan }));
|
|
112
|
+
else {
|
|
113
|
+
log('done.');
|
|
114
|
+
if (plan.actions.some((a) => a.action === 'write-new')) log('Review the *.new files next to the files you modified and merge by hand.');
|
|
115
|
+
}
|
|
116
|
+
return emitJson(flags, say, result, 0);
|
|
117
|
+
} finally {
|
|
118
|
+
if (lock) lock.release();
|
|
119
|
+
if (prompter) prompter.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function emitJson(flags, say, result, code) {
|
|
124
|
+
if (flags.json) say.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
125
|
+
return code;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Messages can embed repo-controlled paths: on a terminal show control characters as text (line breaks kept).
|
|
129
|
+
const forTerminal = (text, stream) => (stream && stream.isTTY ? String(text).split('\n').map((l) => require('./tui/sanitize').sanitize(l)).join('\n') : text);
|
|
130
|
+
const runWizard = (flags, io) => require('./tui/flows/init-flow').runInitFlow({ flags, io, cliVersion: pkg.version });
|
|
131
|
+
|
|
132
|
+
// Bare `ai-sdlc` in a terminal: pick an action, then run it like the typed command.
|
|
133
|
+
async function runMenu(flags, io, say) {
|
|
134
|
+
const { command, installedHere } = await require('./tui/flows/menu-flow').chooseAction({ io });
|
|
135
|
+
if (command === 'quit') return 0;
|
|
136
|
+
if (command === 'install' && shouldRunWizard(flags, io)) return runWizard(flags, io);
|
|
137
|
+
const target = installedHere && !flags.project && command !== 'install' ? { ...flags, project: io.cwd } : flags;
|
|
138
|
+
return runProjectCommand(command, target, io, say);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function run(argv, env = {}) {
|
|
142
|
+
const io = makeIo(env);
|
|
143
|
+
const say = { stdout: io.stdout, stderr: io.stderr };
|
|
144
|
+
let flags = { json: argv.includes('--json') };
|
|
145
|
+
try {
|
|
146
|
+
const parsed = parseArgs(argv);
|
|
147
|
+
flags = parsed.flags;
|
|
148
|
+
validateFlags(parsed.command, flags);
|
|
149
|
+
switch (parsed.command) {
|
|
150
|
+
case 'version':
|
|
151
|
+
if (flags.check) return await require('./version-check').runVersionCheck(flags, io, say);
|
|
152
|
+
say.stdout.write(flags.json ? `${JSON.stringify({ version: pkg.version })}\n` : `ai-sdlc-cli ${pkg.version}\n`);
|
|
153
|
+
return 0;
|
|
154
|
+
case 'help':
|
|
155
|
+
say.stdout.write(HELP);
|
|
156
|
+
return 0;
|
|
157
|
+
case null:
|
|
158
|
+
if (shouldUseTui(flags, io)) return await runMenu(flags, io, say);
|
|
159
|
+
say.stderr.write(HELP);
|
|
160
|
+
return 2;
|
|
161
|
+
default:
|
|
162
|
+
if (flags.init && shouldRunWizard(flags, io)) return await runWizard(flags, io);
|
|
163
|
+
return await runProjectCommand(parsed.command, flags, io, say);
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const code = err instanceof CliError ? err.exitCode : 1;
|
|
167
|
+
if (flags.json) say.stdout.write(`${JSON.stringify({ ok: false, error: err.message, exitCode: code })}\n`);
|
|
168
|
+
else say.stderr.write(`ai-sdlc: ${forTerminal(err instanceof CliError || err.code ? err.message : err.stack, say.stderr)}\n`);
|
|
169
|
+
return code;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { run };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { UsageError } = require('./errors');
|
|
3
|
+
|
|
4
|
+
// Where the release bundles live. Overridable per process for tests and mirrors.
|
|
5
|
+
const DEFAULT_PAYLOAD_REPO = 'bakit-org/ai-sdlc-kit';
|
|
6
|
+
const DEFAULT_API_BASE = 'https://api.github.com';
|
|
7
|
+
|
|
8
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
9
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
10
|
+
|
|
11
|
+
function payloadRepo(env = process.env) {
|
|
12
|
+
const repo = (env.AI_SDLC_PAYLOAD_REPO || '').trim() || DEFAULT_PAYLOAD_REPO;
|
|
13
|
+
if (!REPO_RE.test(repo) || repo.includes('..')) throw new UsageError('AI_SDLC_PAYLOAD_REPO must look like <owner>/<repo>');
|
|
14
|
+
return repo;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The API base must be https; plain http is allowed only for a loopback host (local test servers).
|
|
18
|
+
function apiBase(env = process.env) {
|
|
19
|
+
const raw = (env.AI_SDLC_GITHUB_API || '').trim() || DEFAULT_API_BASE;
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new UsageError('AI_SDLC_GITHUB_API is not a valid URL');
|
|
25
|
+
}
|
|
26
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
|
|
27
|
+
if (!secure || url.username || url.password) throw new UsageError('AI_SDLC_GITHUB_API must be an https URL (http only for localhost)');
|
|
28
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Whether a URL may be fetched given the configured API base (https, or loopback http when the base itself is loopback).
|
|
32
|
+
function isAllowedUrl(url, base) {
|
|
33
|
+
if (url.username || url.password) return false;
|
|
34
|
+
if (url.protocol === 'https:') return true;
|
|
35
|
+
return url.protocol === 'http:' && LOOPBACK.has(url.hostname) && new URL(base).protocol === 'http:';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tokens found on the machine (gh, git credential helper, GH_TOKEN, GITHUB_TOKEN) belong to github.com
|
|
39
|
+
// and are only used against the public API; any other base needs an explicit AI_SDLC_GITHUB_TOKEN.
|
|
40
|
+
const isDefaultApiBase = (base) => base === DEFAULT_API_BASE;
|
|
41
|
+
|
|
42
|
+
module.exports = { DEFAULT_PAYLOAD_REPO, DEFAULT_API_BASE, payloadRepo, apiBase, isAllowedUrl, isDefaultApiBase };
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
const { inspect, utf8Text } = require('./fs-safe');
|
|
4
|
+
const { readManifest } = require('./manifest');
|
|
5
|
+
const { JOURNAL_REL, MANIFEST_REL } = require('./path-rules');
|
|
6
|
+
const { SETTINGS_REL, parseSettings, hasHook } = require('./merge-settings');
|
|
7
|
+
const { hasBlock } = require('./merge-claude-md');
|
|
8
|
+
const semver = require('./semver');
|
|
9
|
+
|
|
10
|
+
const MIN_NODE_MAJOR = 18;
|
|
11
|
+
// $CLAUDE_PROJECT_DIR/x, "$CLAUDE_PROJECT_DIR"/x and ${CLAUDE_PROJECT_DIR}/x, quoted or not.
|
|
12
|
+
const HOOK_SCRIPT = /(?:\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR)"?\/([^\s"'`;|&)]+)/g;
|
|
13
|
+
|
|
14
|
+
function defaultProbePython() {
|
|
15
|
+
for (const bin of ['python3', 'python']) {
|
|
16
|
+
const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
|
17
|
+
if (!r.error && r.status === 0) return (r.stdout || r.stderr).trim();
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Read-only health report. Never writes; checks are { name, status, detail }
|
|
23
|
+
// with status ok | warn | fail | info. A check that throws (symlink, permissions,
|
|
24
|
+
// bad encoding) becomes a fail entry instead of aborting the report.
|
|
25
|
+
function runDoctor({ project, nodeVersion, bundleVersion, probePython = defaultProbePython }) {
|
|
26
|
+
const checks = [];
|
|
27
|
+
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
28
|
+
const guard = (name, fn) => {
|
|
29
|
+
try {
|
|
30
|
+
fn();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
add(name, 'fail', err.message);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const major = Number(String(nodeVersion).replace(/^v/, '').split('.')[0]);
|
|
37
|
+
add('node', major >= MIN_NODE_MAJOR ? 'ok' : 'fail', `${nodeVersion} (needs >= ${MIN_NODE_MAJOR})`);
|
|
38
|
+
|
|
39
|
+
guard('journal', () => {
|
|
40
|
+
if (inspect(project, JOURNAL_REL).exists) {
|
|
41
|
+
add('journal', 'fail', `${JOURNAL_REL} exists: a run is active or was interrupted; if no run is active, retry the command with --force to roll it back`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let manifest = null;
|
|
46
|
+
guard('manifest', () => {
|
|
47
|
+
manifest = readManifest(project);
|
|
48
|
+
if (!manifest) add('manifest', 'fail', `${MANIFEST_REL} not found: ai-sdlc is not installed in ${project}`);
|
|
49
|
+
else add('manifest', 'ok', `payload ${manifest.payload_version}, ${manifest.files.length} tracked file(s)`);
|
|
50
|
+
});
|
|
51
|
+
if (!manifest) return finish(checks);
|
|
52
|
+
|
|
53
|
+
const managed = manifest.files.filter((f) => f.class === 'managed');
|
|
54
|
+
let intact = 0;
|
|
55
|
+
for (const f of managed) {
|
|
56
|
+
guard('managed file', () => {
|
|
57
|
+
const disk = inspect(project, f.path);
|
|
58
|
+
if (!disk.exists) add('managed file', 'fail', `missing: ${f.path}`);
|
|
59
|
+
else if (disk.hash !== f.sha256) add('managed file', 'warn', `modified locally: ${f.path}`);
|
|
60
|
+
else intact += 1;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
add('managed files', intact === managed.length ? 'ok' : 'warn', `${intact}/${managed.length} intact`);
|
|
64
|
+
|
|
65
|
+
guard('hooks', () => checkHooks(project, manifest, add));
|
|
66
|
+
|
|
67
|
+
if (manifest.block) {
|
|
68
|
+
guard('CLAUDE.md block', () => {
|
|
69
|
+
const text = utf8Text(inspect(project, 'CLAUDE.md'), 'CLAUDE.md');
|
|
70
|
+
const present = text !== null && hasBlock(text);
|
|
71
|
+
add('CLAUDE.md block', present ? 'ok' : 'fail', present ? 'router block present' : 'router block missing from CLAUDE.md');
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const scaffolds = manifest.files.filter((f) => f.class === 'user-once');
|
|
76
|
+
const lost = [];
|
|
77
|
+
for (const f of scaffolds) {
|
|
78
|
+
guard('scaffold', () => {
|
|
79
|
+
if (!inspect(project, f.path).exists) lost.push(f.path);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
add('scaffolds', lost.length ? 'warn' : 'ok', lost.length ? `missing: ${lost.join(', ')}` : `${scaffolds.length} present`);
|
|
83
|
+
|
|
84
|
+
if (bundleVersion) {
|
|
85
|
+
const cmp = semver.compare(manifest.payload_version, bundleVersion);
|
|
86
|
+
add('version', cmp === 0 ? 'ok' : 'warn', cmp === 0 ? `up to date (${bundleVersion})` : `installed ${manifest.payload_version}, bundle ${bundleVersion}: run update`);
|
|
87
|
+
} else {
|
|
88
|
+
add('version', 'info', `installed ${manifest.payload_version}; pass --from-bundle to compare with a bundle`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (managed.some((f) => f.path.endsWith('.py'))) {
|
|
92
|
+
guard('python', () => {
|
|
93
|
+
const found = probePython();
|
|
94
|
+
add('python', found ? 'info' : 'warn', found ? `${found} (optional helper scripts)` : 'optional: some helper scripts need Python 3, which was not found');
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return finish(checks);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkHooks(project, manifest, add) {
|
|
101
|
+
if (!manifest.hooks.length) return;
|
|
102
|
+
const file = inspect(project, SETTINGS_REL);
|
|
103
|
+
const settings = parseSettings(utf8Text(file, SETTINGS_REL));
|
|
104
|
+
for (const h of manifest.hooks) {
|
|
105
|
+
const registered = hasHook(settings, h);
|
|
106
|
+
add('hook', registered ? 'ok' : 'fail', `${h.event}: ${registered ? 'registered' : 'not registered'} (${h.command})`);
|
|
107
|
+
for (const m of h.command.matchAll(HOOK_SCRIPT)) {
|
|
108
|
+
let present = false;
|
|
109
|
+
try {
|
|
110
|
+
present = inspect(project, m[1]).exists;
|
|
111
|
+
} catch (_) {
|
|
112
|
+
/* unsafe or unreadable path counts as missing */
|
|
113
|
+
}
|
|
114
|
+
if (!present) add('hook script', 'fail', `missing or unusable: ${m[1]}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function finish(checks) {
|
|
120
|
+
return { checks, ok: !checks.some((c) => c.status === 'fail') };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { runDoctor };
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// exitCode: 1 = the operation failed, 2 = the command line was wrong.
|
|
4
|
+
class CliError extends Error {
|
|
5
|
+
constructor(message, exitCode = 1) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'CliError';
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class UsageError extends CliError {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message, 2);
|
|
15
|
+
this.name = 'UsageError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { CliError, UsageError };
|
package/lib/fs-safe.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { CliError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
8
|
+
|
|
9
|
+
// Install paths arrive from a downloaded bundle or a manifest, so they are
|
|
10
|
+
// treated as hostile: relative, forward-slash, no dot segments.
|
|
11
|
+
function assertRelPath(rel, what = 'path') {
|
|
12
|
+
const bad = (why) => {
|
|
13
|
+
throw new CliError(`unsafe ${what} "${String(rel)}": ${why}`);
|
|
14
|
+
};
|
|
15
|
+
if (typeof rel !== 'string' || rel === '') bad('empty');
|
|
16
|
+
if (rel.includes('\0')) bad('contains a NUL byte');
|
|
17
|
+
if (rel.includes('\\')) bad('contains a backslash');
|
|
18
|
+
if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) bad('is absolute');
|
|
19
|
+
for (const seg of rel.split('/')) {
|
|
20
|
+
if (seg === '' || seg === '.' || seg === '..') bad('has an empty, "." or ".." segment');
|
|
21
|
+
}
|
|
22
|
+
return rel;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isInside(root, target) {
|
|
26
|
+
const r = path.relative(root, target);
|
|
27
|
+
return r !== '' && r !== '..' && !r.startsWith(`..${path.sep}`) && !path.isAbsolute(r);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolves rel under root and proves, through symlinks, that the result stays
|
|
31
|
+
// inside root. `root` must already be a real path.
|
|
32
|
+
function resolveInside(root, rel) {
|
|
33
|
+
assertRelPath(rel);
|
|
34
|
+
const abs = path.join(root, ...rel.split('/'));
|
|
35
|
+
if (!isInside(root, abs)) throw new CliError(`"${rel}" resolves outside the project`);
|
|
36
|
+
let probe = abs;
|
|
37
|
+
for (;;) {
|
|
38
|
+
try {
|
|
39
|
+
fs.lstatSync(probe);
|
|
40
|
+
break;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
|
|
43
|
+
const up = path.dirname(probe);
|
|
44
|
+
if (up === probe) break;
|
|
45
|
+
probe = up;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let real;
|
|
49
|
+
try {
|
|
50
|
+
real = fs.realpathSync(probe);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new CliError(`cannot resolve "${rel}": ${err.code || err.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (real !== root && !isInside(root, real)) {
|
|
55
|
+
throw new CliError(`"${rel}" resolves outside the project through a symlink`);
|
|
56
|
+
}
|
|
57
|
+
return abs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Reads one project file without following symlinks.
|
|
61
|
+
function inspect(root, rel) {
|
|
62
|
+
const abs = resolveInside(root, rel);
|
|
63
|
+
let st;
|
|
64
|
+
try {
|
|
65
|
+
st = fs.lstatSync(abs);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return { abs, exists: false, bytes: null, hash: null };
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
if (st.isSymbolicLink()) {
|
|
71
|
+
throw new CliError(
|
|
72
|
+
`${rel} is a symlink and ai-sdlc will not write through it. Replace it with a regular file holding the same content, or run against the directory that contains the real file.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (!st.isFile()) throw new CliError(`expected a regular file at ${rel}`);
|
|
76
|
+
const bytes = fs.readFileSync(abs);
|
|
77
|
+
return { abs, exists: true, bytes, hash: sha256(bytes) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Text of an inspected file, or null when absent. Files that do not survive a
|
|
81
|
+
// UTF-8 round trip are refused so that rewriting them can never corrupt bytes.
|
|
82
|
+
function utf8Text(file, rel) {
|
|
83
|
+
if (!file.exists) return null;
|
|
84
|
+
const text = file.bytes.toString('utf8');
|
|
85
|
+
if (!Buffer.from(text, 'utf8').equals(file.bytes)) {
|
|
86
|
+
throw new CliError(`${rel} is not valid UTF-8; ai-sdlc will not rewrite it. Nothing was changed.`);
|
|
87
|
+
}
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Replaces abs through a temporary file; an existing file keeps its permission bits.
|
|
92
|
+
function writeAtomic(abs, data) {
|
|
93
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
94
|
+
const tmp = `${abs}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
95
|
+
try {
|
|
96
|
+
let mode = null;
|
|
97
|
+
try {
|
|
98
|
+
mode = fs.statSync(abs).mode & 0o7777;
|
|
99
|
+
} catch (_) {
|
|
100
|
+
/* new file: default mode */
|
|
101
|
+
}
|
|
102
|
+
fs.writeFileSync(tmp, data);
|
|
103
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
104
|
+
fs.renameSync(tmp, abs);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
fs.rmSync(tmp, { force: true });
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Removes now-empty directories above `start`, never the project root itself
|
|
112
|
+
// and never a symlink (a linked .claude is the user's to keep).
|
|
113
|
+
function pruneEmptyDirs(root, start) {
|
|
114
|
+
let dir = start;
|
|
115
|
+
while (isInside(root, dir)) {
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
if (!fs.lstatSync(dir).isDirectory()) return;
|
|
119
|
+
entries = fs.readdirSync(dir);
|
|
120
|
+
} catch (_) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (entries.length) return;
|
|
124
|
+
fs.rmdirSync(dir);
|
|
125
|
+
dir = path.dirname(dir);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function realProject(dir) {
|
|
130
|
+
return fs.realpathSync(dir);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { sha256, assertRelPath, isInside, resolveInside, inspect, utf8Text, writeAtomic, pruneEmptyDirs, realProject };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Finds a GitHub token from the user's existing credentials. The token value is
|
|
3
|
+
// only ever held in memory and sent as a request header: it is never printed,
|
|
4
|
+
// logged or stored, and `redact` scrubs it from any text that might carry it.
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const ENV_SOURCES = ['AI_SDLC_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'];
|
|
8
|
+
const COMMAND_TIMEOUT_MS = 8000;
|
|
9
|
+
const MAX_OUTPUT = 16 * 1024;
|
|
10
|
+
const MAX_TOKEN = 1024;
|
|
11
|
+
|
|
12
|
+
// A header-safe token: printable ASCII without spaces (rules out header injection).
|
|
13
|
+
const plausible = (t) => typeof t === 'string' && t.length > 0 && t.length <= MAX_TOKEN && /^[\x21-\x7e]+$/.test(t);
|
|
14
|
+
|
|
15
|
+
// Runs a command without a shell. Never rejects: failure, timeout, abort and a
|
|
16
|
+
// missing program all resolve with code null. { input, env, timeoutMs, signal } are optional.
|
|
17
|
+
function runCommand(cmd, args, { input, env, timeoutMs = COMMAND_TIMEOUT_MS, signal } = {}) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let out = '';
|
|
20
|
+
let settled = false;
|
|
21
|
+
let child;
|
|
22
|
+
const stop = () => {
|
|
23
|
+
if (child) child.kill();
|
|
24
|
+
done(null);
|
|
25
|
+
};
|
|
26
|
+
const done = (code) => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true;
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (signal) signal.removeEventListener('abort', stop);
|
|
31
|
+
resolve({ code, stdout: out });
|
|
32
|
+
};
|
|
33
|
+
const timer = setTimeout(stop, timeoutMs);
|
|
34
|
+
if (signal) {
|
|
35
|
+
if (signal.aborted) return done(null);
|
|
36
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
child = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return done(null);
|
|
42
|
+
}
|
|
43
|
+
child.on('error', () => done(null));
|
|
44
|
+
child.on('close', (code) => done(code));
|
|
45
|
+
child.stdout.on('data', (c) => {
|
|
46
|
+
if (out.length < MAX_OUTPUT) out += c;
|
|
47
|
+
});
|
|
48
|
+
child.stdin.on('error', () => {});
|
|
49
|
+
child.stdin.end(input || '');
|
|
50
|
+
return undefined;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fromEnv = (env) => {
|
|
55
|
+
for (const name of ENV_SOURCES) {
|
|
56
|
+
const value = (env[name] || '').trim();
|
|
57
|
+
if (plausible(value)) return { token: value, source: name };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function fromGh(env, run, signal) {
|
|
63
|
+
const r = await run('gh', ['auth', 'token', '--hostname', 'github.com'], { env: { ...env, GH_PROMPT_DISABLED: '1', NO_COLOR: '1' }, signal });
|
|
64
|
+
const token = r && r.code === 0 ? String(r.stdout || '').trim() : '';
|
|
65
|
+
return plausible(token) ? { token, source: 'gh auth login' } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Neither the terminal nor any askpass program (GIT_ASKPASS, core.askPass, SSH_ASKPASS) may be
|
|
69
|
+
// started: the lookup has to stay silent.
|
|
70
|
+
const GIT_ARGS = ['-c', 'core.askPass=', '-c', 'credential.interactive=false', 'credential', 'fill'];
|
|
71
|
+
|
|
72
|
+
async function fromGitCredential(env, run, signal) {
|
|
73
|
+
const r = await run('git', GIT_ARGS, {
|
|
74
|
+
input: 'protocol=https\nhost=github.com\n\n',
|
|
75
|
+
env: { ...env, GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' },
|
|
76
|
+
signal,
|
|
77
|
+
});
|
|
78
|
+
if (!r || r.code !== 0) return null;
|
|
79
|
+
const line = String(r.stdout || '').split(/\r?\n/).find((l) => l.startsWith('password='));
|
|
80
|
+
const token = line ? line.slice('password='.length).trim() : '';
|
|
81
|
+
return plausible(token) ? { token, source: 'git credential helper' } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Order: env vars, `gh auth token`, `git credential fill`. Resolves { token, source } or null.
|
|
85
|
+
// With `explicitOnly` (a non-default API base) only AI_SDLC_GITHUB_TOKEN counts, so credentials
|
|
86
|
+
// that belong to github.com are never offered to another host. An aborted signal rejects with its reason.
|
|
87
|
+
async function resolveToken({ env = process.env, runCommand: run = runCommand, signal, explicitOnly = false } = {}) {
|
|
88
|
+
const stopIfAborted = () => {
|
|
89
|
+
if (signal && signal.aborted) throw signal.reason;
|
|
90
|
+
};
|
|
91
|
+
if (explicitOnly) {
|
|
92
|
+
const value = (env.AI_SDLC_GITHUB_TOKEN || '').trim();
|
|
93
|
+
return plausible(value) ? { token: value, source: 'AI_SDLC_GITHUB_TOKEN' } : null;
|
|
94
|
+
}
|
|
95
|
+
const found = fromEnv(env) || (await fromGh(env, run, signal)) || (stopIfAborted(), await fromGitCredential(env, run, signal));
|
|
96
|
+
stopIfAborted();
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const NO_TOKEN_MESSAGE = 'no GitHub credentials found: set AI_SDLC_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN, run `gh auth login`, '
|
|
101
|
+
+ 'or configure a git credential helper for github.com (or install from a local file with --from-bundle <file>)';
|
|
102
|
+
|
|
103
|
+
const customBaseMessage = (base) => `no credentials for the API at ${base}: set AI_SDLC_GITHUB_TOKEN `
|
|
104
|
+
+ '(tokens from GH_TOKEN, GITHUB_TOKEN, `gh` or git credential helpers are only used for https://api.github.com), or use --from-bundle <file>';
|
|
105
|
+
|
|
106
|
+
// Removes every occurrence of the token from text (and its URL-encoded form).
|
|
107
|
+
function redact(text, token) {
|
|
108
|
+
let s = String(text);
|
|
109
|
+
if (token) {
|
|
110
|
+
for (const form of [token, encodeURIComponent(token)]) s = s.split(form).join('[redacted]');
|
|
111
|
+
}
|
|
112
|
+
return s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { resolveToken, redact, runCommand, NO_TOKEN_MESSAGE, customBaseMessage, ENV_SOURCES };
|