@bakit-org/ai-sdlc-cli 0.0.0-stage → 0.1.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.
Files changed (61) hide show
  1. package/README.md +93 -2
  2. package/bin/ai-sdlc.js +13 -0
  3. package/docs/cli.md +115 -0
  4. package/docs/tui.md +87 -0
  5. package/lib/apply-plan.js +61 -0
  6. package/lib/bundle.js +120 -0
  7. package/lib/cli-args.js +89 -0
  8. package/lib/cli-output.js +36 -0
  9. package/lib/command-plan.js +28 -0
  10. package/lib/commands.js +173 -0
  11. package/lib/config.js +42 -0
  12. package/lib/doctor.js +123 -0
  13. package/lib/errors.js +19 -0
  14. package/lib/fs-safe.js +133 -0
  15. package/lib/github-auth.js +115 -0
  16. package/lib/github-checks.js +43 -0
  17. package/lib/github-errors.js +39 -0
  18. package/lib/github-http.js +107 -0
  19. package/lib/github-release.js +156 -0
  20. package/lib/journal.js +117 -0
  21. package/lib/manifest.js +64 -0
  22. package/lib/merge-claude-md.js +63 -0
  23. package/lib/merge-settings.js +125 -0
  24. package/lib/path-rules.js +23 -0
  25. package/lib/plan-files.js +86 -0
  26. package/lib/plan.js +78 -0
  27. package/lib/project-picker.js +117 -0
  28. package/lib/release-source.js +30 -0
  29. package/lib/semver.js +27 -0
  30. package/lib/termination-cleanup.js +22 -0
  31. package/lib/tui/cancelled.js +16 -0
  32. package/lib/tui/colors.js +33 -0
  33. package/lib/tui/flows/init-flow.js +126 -0
  34. package/lib/tui/flows/intro.js +12 -0
  35. package/lib/tui/flows/menu-flow.js +51 -0
  36. package/lib/tui/flows/pick-project-step.js +42 -0
  37. package/lib/tui/flows/preflight.js +80 -0
  38. package/lib/tui/flows/project-choices.js +64 -0
  39. package/lib/tui/flows/recent-projects.js +76 -0
  40. package/lib/tui/flows/result-views.js +89 -0
  41. package/lib/tui/flows/review-view.js +45 -0
  42. package/lib/tui/flows/session-options.js +7 -0
  43. package/lib/tui/flows/terminal-views.js +17 -0
  44. package/lib/tui/keys.js +182 -0
  45. package/lib/tui/launch.js +19 -0
  46. package/lib/tui/logo-fonts.js +29 -0
  47. package/lib/tui/logo.js +98 -0
  48. package/lib/tui/sanitize.js +27 -0
  49. package/lib/tui/terminal-guards.js +58 -0
  50. package/lib/tui/terminal.js +196 -0
  51. package/lib/tui/text-width.js +122 -0
  52. package/lib/tui/theme.js +84 -0
  53. package/lib/tui/widgets/confirm.js +56 -0
  54. package/lib/tui/widgets/input-box.js +88 -0
  55. package/lib/tui/widgets/panel.js +31 -0
  56. package/lib/tui/widgets/path-input.js +110 -0
  57. package/lib/tui/widgets/progress-bar.js +17 -0
  58. package/lib/tui/widgets/select-list.js +89 -0
  59. package/lib/tui/widgets/spinner.js +41 -0
  60. package/lib/version-check.js +38 -0
  61. package/package.json +32 -4
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ // Preflight checks for installing from the payload repository: which credential
3
+ // source is available (never its value) and whether the repository is readable.
4
+ // Each check is an async () => item | null, as the wizard's `extraChecks` expects.
5
+
6
+ function githubChecks(client) {
7
+ let info = null;
8
+ const describe = async (signal) => {
9
+ info = info || (await client.describe({ signal }));
10
+ return info;
11
+ };
12
+
13
+ const tokenCheck = async ({ signal } = {}) => {
14
+ const { source, defaultBase, base } = await describe(signal);
15
+ if (source) return { id: 'github-token', status: 'ok', label: 'GitHub token', detail: `found via ${source}`, segment: 'github token' };
16
+ return {
17
+ id: 'github-token', status: 'fail', label: 'GitHub token',
18
+ detail: defaultBase
19
+ ? 'not found: set AI_SDLC_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN, run `gh auth login`, or configure a git credential helper; or pass --from-bundle <file>'
20
+ : `not found: ${base} needs AI_SDLC_GITHUB_TOKEN (other tokens are only used for the public GitHub API); or pass --from-bundle <file>`,
21
+ segment: 'github token: missing',
22
+ };
23
+ };
24
+
25
+ const accessCheck = async ({ signal } = {}) => {
26
+ const { repo, source } = await describe(signal);
27
+ if (!source) return null;
28
+ try {
29
+ await client.checkAccess({ signal });
30
+ return { id: 'github-access', status: 'ok', label: `Access to ${repo}`, detail: 'read access confirmed', segment: 'repo access' };
31
+ } catch (err) {
32
+ if (signal && signal.aborted) throw signal.reason;
33
+ const hint = err.message.includes('--from-bundle') ? '' : '; or pass --from-bundle <file> to use a local bundle';
34
+ return {
35
+ id: 'github-access', status: 'fail', label: `Access to ${repo}`, detail: `${err.message}${hint}`, segment: 'repo access: failed',
36
+ };
37
+ }
38
+ };
39
+
40
+ return [tokenCheck, accessCheck];
41
+ }
42
+
43
+ module.exports = { githubChecks };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ // Turns a non-success GitHub response into a CliError with an actionable message.
3
+ // Response bodies are never echoed: they are not needed and could carry request details.
4
+ const { CliError } = require('./errors');
5
+ const { OFFLINE_HINT } = require('./github-http');
6
+
7
+ const header = (res, name) => (res.headers && res.headers.get(name)) || '';
8
+
9
+ function resetText(res) {
10
+ const epoch = Number(header(res, 'x-ratelimit-reset'));
11
+ if (Number.isFinite(epoch) && epoch > 0) return `resets at ${new Date(epoch * 1000).toISOString().replace('T', ' ').slice(0, 16)} UTC`;
12
+ const wait = Number(header(res, 'retry-after'));
13
+ if (Number.isFinite(wait) && wait > 0) return `try again in ${Math.ceil(wait)}s`;
14
+ return 'try again later';
15
+ }
16
+
17
+ const isRateLimited = (res) => res.status === 429 || (res.status === 403 && (header(res, 'x-ratelimit-remaining') === '0' || header(res, 'retry-after') !== ''));
18
+
19
+ function failureFor(res, { repo, source, notFound }) {
20
+ if (res.status === 401) {
21
+ return new CliError(`GitHub rejected the token from ${source} (invalid or expired); renew it, or run \`gh auth login\` and try again`);
22
+ }
23
+ if (res.status === 403 && header(res, 'x-github-sso')) {
24
+ return new CliError(`${repo} sits behind SAML single sign-on: authorize the token for the organization (GitHub: Settings > Developer settings > Personal access tokens > Configure SSO), then try again`);
25
+ }
26
+ if (isRateLimited(res)) return new CliError(`GitHub rate limit reached; ${resetText(res)}. Use --from-bundle <file> to install without GitHub`);
27
+ if (res.status === 404 && notFound) {
28
+ const err = new CliError(notFound);
29
+ err.kind = 'not-found';
30
+ return err;
31
+ }
32
+ if (res.status === 403 || res.status === 404) {
33
+ return new CliError(`no read access to ${repo} — ask the repo owner to grant read access: https://github.com/${repo} (or install from a local file with --from-bundle <file>)`);
34
+ }
35
+ if (res.status >= 500) return new CliError(`GitHub is unavailable (HTTP ${res.status}); try again later. ${OFFLINE_HINT}`);
36
+ return new CliError(`unexpected response from GitHub (HTTP ${res.status})`);
37
+ }
38
+
39
+ module.exports = { failureFor };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ // One HTTP request against the GitHub API with timeouts, retries and manual
3
+ // redirect handling. The Authorization header goes only to the API origin: an
4
+ // asset download that redirects to a storage host is fetched without it.
5
+ const { CliError } = require('./errors');
6
+ const { isAllowedUrl } = require('./config');
7
+
8
+ const MAX_REDIRECTS = 5;
9
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
10
+ const OFFLINE_HINT = 'check your network, or install from a local file with --from-bundle <file>';
11
+
12
+ // Sleeps, but wakes early (rejecting with the signal's reason) when the signal aborts.
13
+ function sleepMs(ms, signal) {
14
+ return new Promise((resolve, reject) => {
15
+ if (signal && signal.aborted) return reject(signal.reason);
16
+ const onAbort = () => {
17
+ clearTimeout(timer);
18
+ reject(signal.reason);
19
+ };
20
+ const timer = setTimeout(() => {
21
+ if (signal) signal.removeEventListener('abort', onAbort);
22
+ resolve();
23
+ }, ms);
24
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
25
+ return undefined;
26
+ });
27
+ }
28
+
29
+ class NetworkError extends Error {}
30
+
31
+ async function readCapped(res, maxBytes) {
32
+ const declared = Number(res.headers.get('content-length'));
33
+ if (declared > maxBytes) throw new CliError(`GitHub response is larger than the ${maxBytes} byte limit`);
34
+ const chunks = [];
35
+ let total = 0;
36
+ if (res.body) {
37
+ for await (const chunk of res.body) {
38
+ total += chunk.length;
39
+ if (total > maxBytes) throw new CliError(`GitHub response is larger than the ${maxBytes} byte limit`);
40
+ chunks.push(chunk);
41
+ }
42
+ }
43
+ return Buffer.concat(chunks);
44
+ }
45
+
46
+ // A single attempt, following redirects by hand.
47
+ async function attempt(startUrl, o, signal) {
48
+ const base = new URL(o.apiBase);
49
+ let url = new URL(startUrl);
50
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
51
+ const headers = { 'User-Agent': 'ai-sdlc-cli', Accept: o.accept };
52
+ if (url.origin === base.origin) {
53
+ headers['X-GitHub-Api-Version'] = '2022-11-28';
54
+ if (o.token) headers.Authorization = `Bearer ${o.token}`;
55
+ }
56
+ const res = await o.fetchImpl(url, { redirect: 'manual', headers, signal });
57
+ if (!REDIRECTS.has(res.status)) return { status: res.status, headers: res.headers, body: await readCapped(res, o.maxBytes) };
58
+ const location = res.headers.get('location');
59
+ if (res.body) await res.body.cancel().catch(() => {});
60
+ if (!location) throw new CliError('GitHub sent a redirect without a target');
61
+ url = new URL(location, url);
62
+ if (!isAllowedUrl(url, o.apiBase)) throw new CliError(`refusing to follow a redirect to an unsafe address (${url.protocol}//${url.host})`);
63
+ }
64
+ throw new CliError('too many redirects from GitHub');
65
+ }
66
+
67
+ function networkMessage(err, timedOut, host, timeoutMs) {
68
+ if (timedOut) return `request to ${host} timed out after ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`}; ${OFFLINE_HINT}`;
69
+ const code = (err && err.cause && err.cause.code) || (err && err.code);
70
+ return `cannot reach ${host}${typeof code === 'string' ? ` (${code})` : ''}; ${OFFLINE_HINT}`;
71
+ }
72
+
73
+ // Resolves { status, headers, body } for any final response (4xx included).
74
+ // Network errors, timeouts and 5xx are retried `retries` times with backoff.
75
+ async function request(url, options) {
76
+ const o = {
77
+ accept: 'application/vnd.github+json', fetchImpl: globalThis.fetch, timeoutMs: 20000, retries: 2, backoffMs: 400, maxBytes: 5 * 1024 * 1024,
78
+ ...Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined)),
79
+ };
80
+ if (typeof o.fetchImpl !== 'function') throw new CliError('this Node.js has no fetch; Node 18 or newer is required');
81
+ const host = new URL(url).host;
82
+ let last;
83
+ for (let n = 0; n <= o.retries; n += 1) {
84
+ if (n > 0) await sleepMs(o.backoffMs * 2 ** (n - 1), o.signal);
85
+ if (o.signal && o.signal.aborted) throw o.signal.reason;
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), o.timeoutMs);
88
+ const onCancel = () => controller.abort();
89
+ if (o.signal) o.signal.addEventListener('abort', onCancel, { once: true });
90
+ try {
91
+ const res = await attempt(url, o, controller.signal);
92
+ if (res.status < 500 || n === o.retries) return res;
93
+ last = res;
94
+ } catch (err) {
95
+ if (o.signal && o.signal.aborted) throw o.signal.reason;
96
+ if (err instanceof CliError) throw err;
97
+ last = new NetworkError(networkMessage(err, controller.signal.aborted, host, o.timeoutMs));
98
+ if (n === o.retries) throw new CliError(last.message);
99
+ } finally {
100
+ clearTimeout(timer);
101
+ if (o.signal) o.signal.removeEventListener('abort', onCancel);
102
+ }
103
+ }
104
+ return last;
105
+ }
106
+
107
+ module.exports = { request, OFFLINE_HINT };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+ // Reads release bundles from the payload repository over the GitHub REST API
3
+ // using the user's own credentials. Only Node's built-in fetch is used.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { CliError, UsageError } = require('./errors');
8
+ const { payloadRepo, apiBase, isDefaultApiBase } = require('./config');
9
+ const { resolveToken, redact, NO_TOKEN_MESSAGE, customBaseMessage } = require('./github-auth');
10
+ const { onTermination } = require('./termination-cleanup');
11
+ const { request } = require('./github-http');
12
+ const { failureFor } = require('./github-errors');
13
+ const semver = require('./semver');
14
+
15
+ const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
16
+ const SAFE_NAME = /^[A-Za-z0-9._-]{1,200}$/;
17
+ const OCTET = 'application/octet-stream';
18
+
19
+ const stripV = (tag) => String(tag).replace(/^v/, '');
20
+
21
+ // Candidate release tags for a requested version: as typed, then with/without the leading "v".
22
+ function tagCandidates(version) {
23
+ const v = String(version).trim();
24
+ if (!semver.isReleaseTag(v)) throw new UsageError('--version must be a release version such as 1.2.0 or v1.2.0');
25
+ return [v, v.startsWith('v') ? v.slice(1) : `v${v}`];
26
+ }
27
+
28
+ // deps: { env, runCommand, fetchImpl, proc, releaseOptions: { timeoutMs, retries, backoffMs, tmpDir } }
29
+ // Every method takes an optional { signal } (AbortSignal): aborting stops requests, backoff waits and child processes.
30
+ function createGithubClient(deps = {}) {
31
+ const env = deps.env || process.env;
32
+ const proc = deps.proc || process;
33
+ const tuning = deps.releaseOptions || {};
34
+ let session = null;
35
+
36
+ // Token and endpoint, resolved once. Throws CliError when no credentials exist.
37
+ async function load(signal) {
38
+ if (!session) {
39
+ const repo = payloadRepo(env);
40
+ const base = apiBase(env);
41
+ const found = await resolveToken({ env, runCommand: deps.runCommand, signal, explicitOnly: !isDefaultApiBase(base) });
42
+ session = { repo, base, token: found ? found.token : null, source: found ? found.source : null };
43
+ }
44
+ return session;
45
+ }
46
+
47
+ async function connect(signal) {
48
+ const s = await load(signal);
49
+ if (!s.token) throw new CliError(isDefaultApiBase(s.base) ? NO_TOKEN_MESSAGE : customBaseMessage(s.base));
50
+ return s;
51
+ }
52
+
53
+ async function call(urlPath, { accept, maxBytes, notFound, signal } = {}) {
54
+ const s = await connect(signal);
55
+ let res;
56
+ try {
57
+ res = await request(`${s.base}${urlPath}`, {
58
+ token: s.token, apiBase: s.base, accept, maxBytes, signal, fetchImpl: deps.fetchImpl, ...tuning,
59
+ });
60
+ } catch (err) {
61
+ if (signal && signal.aborted) throw signal.reason;
62
+ // Whatever went wrong, the token must not travel with the message.
63
+ throw new CliError(redact(err.message, s.token), err instanceof CliError ? err.exitCode : 1);
64
+ }
65
+ if (res.status === 200) return res.body;
66
+ throw failureFor(res, { repo: s.repo, source: s.source, notFound });
67
+ }
68
+
69
+ const json = (buf, what) => {
70
+ try {
71
+ return JSON.parse(buf.toString('utf8'));
72
+ } catch {
73
+ throw new CliError(`GitHub sent an unreadable ${what}`);
74
+ }
75
+ };
76
+
77
+ async function checkAccess({ signal } = {}) {
78
+ const s = await connect(signal);
79
+ await call(`/repos/${s.repo}`, { signal });
80
+ return { repo: s.repo, source: s.source };
81
+ }
82
+
83
+ // The release for `version` (a tag) or the latest one.
84
+ async function findRelease(version, { signal } = {}) {
85
+ const tags = version ? tagCandidates(version) : [];
86
+ const s = await connect(signal);
87
+ const missing = (what) => `${what} in ${s.repo}`;
88
+ if (!version) {
89
+ return json(await call(`/repos/${s.repo}/releases/latest`, { signal, notFound: missing('no release has been published') }), 'release');
90
+ }
91
+ for (let i = 0; i < tags.length; i += 1) {
92
+ try {
93
+ return json(await call(`/repos/${s.repo}/releases/tags/${encodeURIComponent(tags[i])}`, { signal, notFound: missing(`release ${version} was not found`) }), 'release');
94
+ } catch (err) {
95
+ if (i === tags.length - 1 || err.kind !== 'not-found') throw err;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function pickAssets(release, repo) {
102
+ const tag = release && typeof release.tag_name === 'string' ? release.tag_name : '';
103
+ const assets = release && Array.isArray(release.assets) ? release.assets.filter((a) => a && SAFE_NAME.test(a.name || '') && Number.isInteger(a.id)) : [];
104
+ const bundles = assets.filter((a) => /^ai-sdlc-.+\.bundle\.json$/.test(a.name));
105
+ const bundle = bundles.find((a) => a.name === `ai-sdlc-${stripV(tag)}.bundle.json`) || (bundles.length === 1 ? bundles[0] : null);
106
+ if (!tag || !bundle) throw new CliError(`release ${tag || '(unnamed)'} in ${repo} has no bundle asset (expected ai-sdlc-<version>.bundle.json)`);
107
+ const sidecar = assets.find((a) => a.name === `${bundle.name}.sha256`);
108
+ if (!sidecar) throw new CliError(`release ${tag} in ${repo} has no checksum asset (${bundle.name}.sha256); refusing to install unverified`);
109
+ return { tag, bundle, sidecar };
110
+ }
111
+
112
+ // Downloads bundle + checksum into a private temp directory. Returns { bundlePath, version, cleanup }.
113
+ // The directory is also removed if the process is stopped with SIGINT/SIGTERM before cleanup() runs.
114
+ async function downloadBundle(version, { signal } = {}) {
115
+ const s = await connect(signal);
116
+ const release = await findRelease(version, { signal });
117
+ const { tag, bundle, sidecar } = pickAssets(release, s.repo);
118
+ const asset = (a, maxBytes) => call(`/repos/${s.repo}/releases/assets/${a.id}`, { signal, accept: OCTET, maxBytes, notFound: `release asset ${a.name} could not be downloaded` });
119
+ const dir = fs.mkdtempSync(path.join(tuning.tmpDir || os.tmpdir(), 'ai-sdlc-dl-'));
120
+ const remove = () => fs.rmSync(dir, { recursive: true, force: true });
121
+ const off = onTermination(proc, remove);
122
+ const cleanup = () => {
123
+ off();
124
+ remove();
125
+ };
126
+ try {
127
+ const bundlePath = path.join(dir, bundle.name);
128
+ fs.writeFileSync(bundlePath, await asset(bundle, MAX_BUNDLE_BYTES), { mode: 0o600 });
129
+ fs.writeFileSync(`${bundlePath}.sha256`, await asset(sidecar, 4096), { mode: 0o600 });
130
+ return { bundlePath, version: stripV(tag), cleanup };
131
+ } catch (err) {
132
+ cleanup();
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ // Where the credentials came from (never the value) and which repo/base is used; source is null when none were found.
138
+ async function describe({ signal } = {}) {
139
+ const s = await load(signal);
140
+ return { repo: s.repo, base: s.base, defaultBase: isDefaultApiBase(s.base), source: s.source };
141
+ }
142
+
143
+ return { checkAccess, findRelease, downloadBundle, describe };
144
+ }
145
+
146
+ // The `fetchLatestBundle` plug for release-source.js.
147
+ const createReleaseFetcher = (deps) => {
148
+ const client = deps.client || createGithubClient(deps);
149
+ return async (opts = {}) => {
150
+ if (opts.version) tagCandidates(opts.version);
151
+ await client.checkAccess({ signal: opts.signal });
152
+ return client.downloadBundle(opts.version, { signal: opts.signal });
153
+ };
154
+ };
155
+
156
+ module.exports = { createGithubClient, createReleaseFetcher, stripV, tagCandidates };
package/lib/journal.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { CliError } = require('./errors');
5
+ const { JOURNAL_REL } = require('./path-rules');
6
+ const { resolveInside, writeAtomic, pruneEmptyDirs, assertRelPath } = require('./fs-safe');
7
+
8
+ // The journal doubles as the per-project run lock (created exclusively) and as
9
+ // the recovery record: before applying, it holds the previous content of every
10
+ // path the run will touch, so an interrupted run can be rolled back exactly.
11
+
12
+ function pidAlive(pid) {
13
+ if (!Number.isInteger(pid) || pid <= 0) return false;
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ } catch (err) {
18
+ return err.code === 'EPERM';
19
+ }
20
+ }
21
+
22
+ function readJournal(abs) {
23
+ try {
24
+ const value = JSON.parse(fs.readFileSync(abs, 'utf8'));
25
+ return value && typeof value === 'object' ? value : null;
26
+ } catch (_) {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ // entries: [{ path, before }] where before is base64 content or null (file did not exist).
32
+ function checkEntries(entries) {
33
+ if (!Array.isArray(entries)) throw new CliError('the interrupted-run journal is unreadable; inspect it, delete it by hand, then re-run');
34
+ for (const e of entries) {
35
+ if (!e || typeof e.path !== 'string' || (e.before !== null && typeof e.before !== 'string')) {
36
+ throw new CliError('the interrupted-run journal has a malformed entry; inspect it, delete it by hand, then re-run');
37
+ }
38
+ assertRelPath(e.path, 'journal path');
39
+ if (e.path.toLowerCase().split('/').includes('.git')) throw new CliError('the interrupted-run journal points into .git; refusing to replay it');
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ // Puts every entry back as it was, newest first. Returns a list of problems (empty when clean).
45
+ function rollbackEntries(project, entries) {
46
+ const problems = [];
47
+ for (const e of [...entries].reverse()) {
48
+ try {
49
+ const abs = resolveInside(project, e.path);
50
+ if (e.before === null) {
51
+ try {
52
+ fs.rmSync(abs, { force: true });
53
+ } catch (err) {
54
+ if (err.code !== 'ENOTDIR') throw err; // a parent is a plain file, so nothing was ever created here
55
+ }
56
+ pruneEmptyDirs(project, path.dirname(abs));
57
+ } else writeAtomic(abs, Buffer.from(e.before, 'base64'));
58
+ } catch (err) {
59
+ problems.push(`${e.path}: ${err.message}`);
60
+ }
61
+ }
62
+ return problems;
63
+ }
64
+
65
+ function recoverStale(project, abs, force) {
66
+ const journal = readJournal(abs);
67
+ if (journal && pidAlive(journal.pid)) {
68
+ throw new CliError(`another ai-sdlc run (pid ${journal.pid}) is in progress in this project; wait for it to finish`);
69
+ }
70
+ if (!force) {
71
+ throw new CliError(`${JOURNAL_REL} found: an earlier run was interrupted. Run "ai-sdlc doctor", then retry with --force to roll its changes back`);
72
+ }
73
+ if (journal && journal.entries !== null && journal.entries !== undefined) {
74
+ const problems = rollbackEntries(project, checkEntries(journal.entries));
75
+ if (problems.length) throw new CliError(`rolling back the interrupted run failed: ${problems.join('; ')}`);
76
+ }
77
+ fs.rmSync(abs, { force: true });
78
+ }
79
+
80
+ // Takes the project lock for one mutating run. Throws if another run holds it,
81
+ // or if a stale journal exists and `force` is not set (with force it is rolled back first).
82
+ function acquireLock(project, op, { force = false } = {}) {
83
+ const abs = resolveInside(project, JOURNAL_REL);
84
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
85
+ let fd;
86
+ for (let attempt = 0; attempt < 2 && fd === undefined; attempt += 1) {
87
+ try {
88
+ fd = fs.openSync(abs, 'wx');
89
+ } catch (err) {
90
+ if (err.code !== 'EEXIST') throw err;
91
+ recoverStale(project, abs, force);
92
+ }
93
+ }
94
+ if (fd === undefined) throw new CliError('could not take the project lock; another run is starting at the same time');
95
+ try {
96
+ fs.writeSync(fd, `${JSON.stringify({ op, pid: process.pid, entries: null })}\n`);
97
+ } finally {
98
+ fs.closeSync(fd);
99
+ }
100
+ let kept = false;
101
+ return {
102
+ record(entries) {
103
+ writeAtomic(abs, Buffer.from(`${JSON.stringify({ op, pid: process.pid, entries })}\n`));
104
+ },
105
+ // Leave the journal in place (a rollback failed and needs --force recovery).
106
+ keep() {
107
+ kept = true;
108
+ },
109
+ release() {
110
+ if (kept) return;
111
+ fs.rmSync(abs, { force: true });
112
+ pruneEmptyDirs(project, path.dirname(abs));
113
+ },
114
+ };
115
+ }
116
+
117
+ module.exports = { acquireLock, rollbackEntries };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ const { CliError } = require('./errors');
3
+ const { assertRelPath, inspect } = require('./fs-safe');
4
+ const { MANIFEST_REL, JOURNAL_REL, installPathProblem } = require('./path-rules');
5
+ const semver = require('./semver');
6
+
7
+ const MANIFEST_SCHEMA = 1;
8
+
9
+ function bad(why) {
10
+ throw new CliError(`installed manifest ${MANIFEST_REL} is invalid: ${why}`);
11
+ }
12
+
13
+ function validate(m) {
14
+ if (!m || typeof m !== 'object' || Array.isArray(m)) bad('not an object');
15
+ if (m.tool !== 'ai-sdlc-cli' || m.schema !== MANIFEST_SCHEMA) bad('unknown tool or schema');
16
+ if (!semver.isStrict(m.payload_version)) bad('payload_version is missing or not a version');
17
+ if (!Array.isArray(m.files)) bad('files missing');
18
+ for (const f of m.files) {
19
+ if (!f || (f.class !== 'managed' && f.class !== 'user-once') || !/^[0-9a-f]{64}$/.test(f.sha256 || '')) bad('bad file entry');
20
+ assertRelPath(f.path, 'manifest path');
21
+ const why = installPathProblem(f.path, f.class);
22
+ if (why) bad(`${f.path}: ${why}`);
23
+ }
24
+ if (!Array.isArray(m.hooks)) bad('hooks missing');
25
+ for (const h of m.hooks) {
26
+ if (!h || typeof h.event !== 'string' || typeof h.command !== 'string') bad('bad hook entry');
27
+ }
28
+ const b = m.block;
29
+ if (b !== null && (!b || typeof b.created !== 'boolean' || typeof b.prefix !== 'string' || typeof b.eol !== 'string')) bad('bad block record');
30
+ const s = m.settings;
31
+ if (s !== null && (!s || typeof s.created !== 'boolean' || (s.original !== null && typeof s.original !== 'string'))) bad('bad settings record');
32
+ return m;
33
+ }
34
+
35
+ // Returns null when the project has no manifest.
36
+ function readManifest(project) {
37
+ const f = inspect(project, MANIFEST_REL);
38
+ if (!f.exists) return null;
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(f.bytes.toString('utf8'));
42
+ } catch (err) {
43
+ bad(`not JSON (${err.message})`);
44
+ }
45
+ return validate(parsed);
46
+ }
47
+
48
+ function buildManifest({ cliVersion, payloadVersion, payloadSchema, files, block, hooks, settings }) {
49
+ return {
50
+ tool: 'ai-sdlc-cli',
51
+ schema: MANIFEST_SCHEMA,
52
+ cli_version: cliVersion,
53
+ payload_version: payloadVersion,
54
+ payload_schema: payloadSchema,
55
+ files: [...files].sort((a, b) => (a.path < b.path ? -1 : 1)),
56
+ block: block || null,
57
+ hooks,
58
+ settings: settings || null,
59
+ };
60
+ }
61
+
62
+ const serializeManifest = (m) => Buffer.from(`${JSON.stringify(m, null, 2)}\n`, 'utf8');
63
+
64
+ module.exports = { MANIFEST_REL, JOURNAL_REL, readManifest, buildManifest, serializeManifest };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ const { CliError } = require('./errors');
3
+
4
+ const BEGIN = '<!-- ai-sdlc:begin -->';
5
+ const END = '<!-- ai-sdlc:end -->';
6
+
7
+ function findSpan(text) {
8
+ const b = text.indexOf(BEGIN);
9
+ const e = text.indexOf(END);
10
+ if (b === -1 && e === -1) return null;
11
+ if (b === -1 || e === -1 || e < b) {
12
+ throw new CliError('CLAUDE.md has an unbalanced ai-sdlc marker pair; fix it by hand and re-run. Nothing was changed.');
13
+ }
14
+ return { start: b, end: e + END.length };
15
+ }
16
+
17
+ const detectEol = (text) => (text.includes('\r\n') ? '\r\n' : '\n');
18
+
19
+ function renderBlock(body, eol) {
20
+ const lines = body.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n');
21
+ return [BEGIN, ...lines, END].join(eol);
22
+ }
23
+
24
+ // Inserts or refreshes the managed block. `info` is what an earlier call
25
+ // returned; it records exactly the separator added so removal can restore the
26
+ // original bytes. Returns { text, info }.
27
+ function upsertBlock(current, body, info) {
28
+ if (current === null) {
29
+ return { text: `${renderBlock(body, '\n')}\n`, info: { created: true, prefix: '', eol: '\n' } };
30
+ }
31
+ const span = findSpan(current);
32
+ if (span) {
33
+ const eol = info ? info.eol : detectEol(current);
34
+ const text = current.slice(0, span.start) + renderBlock(body, eol) + current.slice(span.end);
35
+ return { text, info: info || { created: false, prefix: '', eol } };
36
+ }
37
+ const eol = detectEol(current);
38
+ let prefix = '';
39
+ if (current !== '') prefix = current.endsWith('\n') ? eol : eol + eol;
40
+ return {
41
+ text: `${current}${prefix}${renderBlock(body, eol)}${eol}`,
42
+ info: { created: false, prefix, eol },
43
+ };
44
+ }
45
+
46
+ // Strips the managed block. text === null means "the CLI created this file and
47
+ // nothing else is in it, so delete it".
48
+ function removeBlock(current, info) {
49
+ if (current === null) return { text: null, changed: false };
50
+ const span = findSpan(current);
51
+ if (!span) return { text: current, changed: false };
52
+ let { start, end } = span;
53
+ if (current.startsWith(info.eol, end)) end += info.eol.length;
54
+ if (info.prefix && current.slice(start - info.prefix.length, start) === info.prefix) start -= info.prefix.length;
55
+ const text = current.slice(0, start) + current.slice(end);
56
+ return { text: text === '' && info.created ? null : text, changed: true };
57
+ }
58
+
59
+ function hasBlock(current) {
60
+ return current !== null && findSpan(current) !== null;
61
+ }
62
+
63
+ module.exports = { BEGIN, END, upsertBlock, removeBlock, hasBlock };