@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/bin/ai-sdlc.js +13 -0
  4. package/docs/cli.md +115 -0
  5. package/docs/tui.md +87 -0
  6. package/lib/apply-plan.js +61 -0
  7. package/lib/bundle.js +120 -0
  8. package/lib/cli-args.js +89 -0
  9. package/lib/cli-output.js +36 -0
  10. package/lib/command-plan.js +28 -0
  11. package/lib/commands.js +173 -0
  12. package/lib/config.js +42 -0
  13. package/lib/doctor.js +123 -0
  14. package/lib/errors.js +19 -0
  15. package/lib/fs-safe.js +133 -0
  16. package/lib/github-auth.js +115 -0
  17. package/lib/github-checks.js +43 -0
  18. package/lib/github-errors.js +39 -0
  19. package/lib/github-http.js +107 -0
  20. package/lib/github-release.js +156 -0
  21. package/lib/journal.js +117 -0
  22. package/lib/manifest.js +64 -0
  23. package/lib/merge-claude-md.js +63 -0
  24. package/lib/merge-settings.js +125 -0
  25. package/lib/path-rules.js +23 -0
  26. package/lib/plan-files.js +86 -0
  27. package/lib/plan.js +78 -0
  28. package/lib/project-picker.js +117 -0
  29. package/lib/release-source.js +30 -0
  30. package/lib/semver.js +27 -0
  31. package/lib/termination-cleanup.js +22 -0
  32. package/lib/tui/cancelled.js +16 -0
  33. package/lib/tui/colors.js +33 -0
  34. package/lib/tui/flows/init-flow.js +126 -0
  35. package/lib/tui/flows/intro.js +12 -0
  36. package/lib/tui/flows/menu-flow.js +51 -0
  37. package/lib/tui/flows/pick-project-step.js +42 -0
  38. package/lib/tui/flows/preflight.js +80 -0
  39. package/lib/tui/flows/project-choices.js +64 -0
  40. package/lib/tui/flows/recent-projects.js +76 -0
  41. package/lib/tui/flows/result-views.js +89 -0
  42. package/lib/tui/flows/review-view.js +45 -0
  43. package/lib/tui/flows/session-options.js +7 -0
  44. package/lib/tui/flows/terminal-views.js +17 -0
  45. package/lib/tui/keys.js +182 -0
  46. package/lib/tui/launch.js +19 -0
  47. package/lib/tui/logo-fonts.js +29 -0
  48. package/lib/tui/logo.js +98 -0
  49. package/lib/tui/sanitize.js +27 -0
  50. package/lib/tui/terminal-guards.js +58 -0
  51. package/lib/tui/terminal.js +196 -0
  52. package/lib/tui/text-width.js +122 -0
  53. package/lib/tui/theme.js +84 -0
  54. package/lib/tui/widgets/confirm.js +56 -0
  55. package/lib/tui/widgets/input-box.js +88 -0
  56. package/lib/tui/widgets/panel.js +31 -0
  57. package/lib/tui/widgets/path-input.js +110 -0
  58. package/lib/tui/widgets/progress-bar.js +17 -0
  59. package/lib/tui/widgets/select-list.js +89 -0
  60. package/lib/tui/widgets/spinner.js +41 -0
  61. package/lib/version-check.js +38 -0
  62. package/package.json +32 -4
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+ const { isDeepStrictEqual } = require('util');
3
+ const { CliError } = require('./errors');
4
+
5
+ const SETTINGS_REL = '.claude/settings.json';
6
+
7
+ function stripBom(text) {
8
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
9
+ }
10
+
11
+ // Strict parse: anything unexpected aborts before a single byte is written.
12
+ function parseSettings(text) {
13
+ if (text === null) return {};
14
+ let value;
15
+ try {
16
+ value = JSON.parse(stripBom(text));
17
+ } catch (err) {
18
+ throw new CliError(`${SETTINGS_REL} is not valid JSON (${err.message}); fix it and re-run. Nothing was changed.`);
19
+ }
20
+ const wrong = (why) => {
21
+ throw new CliError(`${SETTINGS_REL}: ${why}; fix it and re-run. Nothing was changed.`);
22
+ };
23
+ if (!value || typeof value !== 'object' || Array.isArray(value)) wrong('top level must be an object');
24
+ if (value.hooks !== undefined) {
25
+ if (!value.hooks || typeof value.hooks !== 'object' || Array.isArray(value.hooks)) wrong('"hooks" must be an object');
26
+ for (const [event, list] of Object.entries(value.hooks)) {
27
+ if (!Array.isArray(list)) wrong(`hooks.${event} must be an array`);
28
+ }
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function detectFormat(text) {
34
+ if (text === null) return { eol: '\n', indent: ' ', trailing: true, bom: false };
35
+ const body = stripBom(text);
36
+ const indentMatch = /\n([ \t]+)\S/.exec(body);
37
+ let indent = ' ';
38
+ if (indentMatch) indent = indentMatch[1];
39
+ else if (!body.trim().includes('\n')) indent = '';
40
+ return { eol: body.includes('\r\n') ? '\r\n' : '\n', indent, trailing: /\n$/.test(body), bom: body !== text };
41
+ }
42
+
43
+ function serialize(obj, fmt) {
44
+ let out = JSON.stringify(obj, null, fmt.indent);
45
+ if (fmt.eol === '\r\n') out = out.replace(/\n/g, '\r\n');
46
+ if (fmt.trailing) out += fmt.eol;
47
+ return (fmt.bom ? '' : '') + out;
48
+ }
49
+
50
+ const entryMatcher = (h) => (h.matcher === undefined ? '' : h.matcher);
51
+ const sameCommand = (x, h) => x && x.type === 'command' && x.command === h.command;
52
+
53
+ function matchingEntries(obj, h) {
54
+ const list = (obj.hooks && obj.hooks[h.event]) || [];
55
+ return list.filter((e) => e && Array.isArray(e.hooks) && (e.matcher === undefined ? '' : e.matcher) === entryMatcher(h));
56
+ }
57
+
58
+ const hasHook = (obj, h) => matchingEntries(obj, h).some((e) => e.hooks.some((x) => sameCommand(x, h)));
59
+
60
+ function addHook(obj, h) {
61
+ if (hasHook(obj, h)) return false;
62
+ obj.hooks = obj.hooks || {};
63
+ obj.hooks[h.event] = obj.hooks[h.event] || [];
64
+ const entry = h.matcher === undefined ? {} : { matcher: h.matcher };
65
+ entry.hooks = [{ type: 'command', command: h.command }];
66
+ obj.hooks[h.event].push(entry);
67
+ return true;
68
+ }
69
+
70
+ // Removes the first occurrence of exactly this hook; other entries stay.
71
+ function removeHook(obj, h) {
72
+ for (const entry of matchingEntries(obj, h)) {
73
+ const i = entry.hooks.findIndex((x) => sameCommand(x, h));
74
+ if (i === -1) continue;
75
+ entry.hooks.splice(i, 1);
76
+ if (!entry.hooks.length) obj.hooks[h.event].splice(obj.hooks[h.event].indexOf(entry), 1);
77
+ return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ // Drops event arrays (and then "hooks") that an edit left empty.
83
+ function pruneEmpty(obj, events) {
84
+ if (!obj.hooks) return;
85
+ for (const ev of events) if (Array.isArray(obj.hooks[ev]) && !obj.hooks[ev].length) delete obj.hooks[ev];
86
+ if (!Object.keys(obj.hooks).length) delete obj.hooks;
87
+ }
88
+
89
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
90
+ const unb64 = (s) => Buffer.from(s, 'base64').toString('utf8');
91
+
92
+ // currentText: file text or null when absent. info: manifest.settings or null.
93
+ // Returns { text, info, added }; text === null means "delete the file".
94
+ function renderSettings(currentText, info, removeList, addList) {
95
+ if (!removeList.length && !addList.length) return { text: currentText, info, added: [] };
96
+ const fmt = detectFormat(currentText);
97
+ const obj = parseSettings(currentText);
98
+ // A hook that is both installed and shipped again stays where it is.
99
+ const sameHook = (a, b) => a.event === b.event && entryMatcher(a) === entryMatcher(b) && a.command === b.command;
100
+ const kept = removeList.filter((r) => addList.some((a) => sameHook(a, r)) && hasHook(obj, r));
101
+ const dropped = removeList.filter((r) => !kept.includes(r));
102
+ for (const h of dropped) removeHook(obj, h);
103
+ const added = addList.filter((h) => kept.some((k) => sameHook(k, h)) || addHook(obj, h));
104
+ const touched = [...new Set(dropped.map((h) => h.event))];
105
+ pruneEmpty(obj, touched);
106
+
107
+ if (isDeepStrictEqual(obj, parseSettings(currentText))) {
108
+ return { text: currentText, info: added.length ? info : null, added };
109
+ }
110
+ if (added.length) {
111
+ const nextInfo = info || { created: currentText === null, original: currentText === null ? null : b64(currentText) };
112
+ return { text: serialize(obj, fmt), info: nextInfo, added };
113
+ }
114
+ // No hooks of ours remain: give the file back the way it was before the install.
115
+ if (info && info.original !== null) {
116
+ const original = unb64(info.original);
117
+ const origObj = parseSettings(original);
118
+ pruneEmpty(origObj, touched);
119
+ if (isDeepStrictEqual(obj, origObj)) return { text: original, info: null, added };
120
+ }
121
+ if (info && info.created && !Object.keys(obj).length) return { text: null, info: null, added };
122
+ return { text: serialize(obj, fmt), info: null, added };
123
+ }
124
+
125
+ module.exports = { SETTINGS_REL, parseSettings, hasHook, renderSettings };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const MANIFEST_REL = '.claude/ai-sdlc.manifest.json';
4
+ const JOURNAL_REL = '.claude/ai-sdlc.journal.json';
5
+
6
+ // Paths the installer owns or that belong to the user's own tooling.
7
+ const RESERVED = new Set([MANIFEST_REL, JOURNAL_REL, '.claude/settings.json', '.claude/settings.local.json']);
8
+
9
+ // Class/location rules shared by freshly downloaded bundles and by the manifest
10
+ // of an earlier install (which is just as untrusted before something is deleted
11
+ // on its say-so). Returns a reason string, or null when the path is acceptable.
12
+ // Syntax checks (absolute, "..", backslash) live in fs-safe.assertRelPath.
13
+ function installPathProblem(rel, cls) {
14
+ const lower = rel.toLowerCase();
15
+ if (lower.split('/').includes('.git')) return 'paths inside .git are not allowed';
16
+ if (RESERVED.has(lower)) return 'reserved for the installer or the user';
17
+ if (cls === 'block') return lower === 'claude.md' ? null : 'only CLAUDE.md may use class "block"';
18
+ if (lower === 'claude.md') return 'CLAUDE.md must use class "block"';
19
+ if (cls === 'managed' && !rel.startsWith('.claude/')) return 'managed files must live under .claude/';
20
+ return null;
21
+ }
22
+
23
+ module.exports = { MANIFEST_REL, JOURNAL_REL, RESERVED, installPathProblem };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ const { inspect } = require('./fs-safe');
3
+
4
+ // An action is a plain record. `write` (Buffer) / `remove` make it a disk
5
+ // change; everything else is information only. Apply never decides anything.
6
+ function act(kind, action, rel, detail, extra) {
7
+ return { kind, action, path: rel, detail: detail || '', ...extra };
8
+ }
9
+
10
+ // Three-way comparison for payload files:
11
+ // old = hash recorded at last install, disk = what is there now, new = bundle.
12
+ // `reinstall` is true for install (even over a manifest) and false for update.
13
+ // Returns { actions, tracked } where tracked is the file list for the next manifest.
14
+ function planFiles({ project, bundle, prior, force, reinstall }) {
15
+ const actions = [];
16
+ const tracked = [];
17
+ const priorByPath = new Map((prior ? prior.files : []).map((f) => [f.path, f]));
18
+ const shipped = new Set(bundle.files.map((f) => f.path));
19
+
20
+ for (const f of bundle.files) {
21
+ const disk = inspect(project, f.path);
22
+ const old = priorByPath.get(f.path);
23
+
24
+ if (f.class === 'user-once') {
25
+ tracked.push({ path: f.path, sha256: f.sha256, class: 'user-once' });
26
+ if (disk.exists) actions.push(act('file', 'keep-existing', f.path, 'user-owned, never overwritten'));
27
+ else if (old && old.class === 'user-once' && !reinstall) {
28
+ // Tracked before and gone now: the user removed it, so an update must not bring it back.
29
+ actions.push(act('file', 'skip-deleted', f.path, 'you deleted this starter file; not re-created'));
30
+ } else actions.push(act('file', 'create', f.path, 'starter file', { write: f.bytes }));
31
+ continue;
32
+ }
33
+
34
+ const recorded = old && old.class === 'managed' ? old.sha256 : null;
35
+ const own = { path: f.path, sha256: f.sha256, class: 'managed' };
36
+ if (!disk.exists) {
37
+ tracked.push(own);
38
+ actions.push(act('file', recorded ? 'restore' : 'create', f.path, '', { write: f.bytes }));
39
+ } else if (disk.hash === f.sha256) {
40
+ tracked.push(own);
41
+ actions.push(act('file', 'unchanged', f.path));
42
+ } else if (recorded && disk.hash === recorded) {
43
+ tracked.push(own);
44
+ actions.push(act('file', 'replace', f.path, 'new payload version', { write: f.bytes }));
45
+ } else if (force) {
46
+ tracked.push(own);
47
+ actions.push(act('file', 'replace', f.path, 'overwritten (--force)', { write: f.bytes }));
48
+ } else {
49
+ // Locally modified (or a foreign file sits here): keep it, offer the new copy beside it.
50
+ if (recorded) tracked.push({ path: f.path, sha256: recorded, class: 'managed' });
51
+ actions.push(act('file', 'keep-modified', f.path, recorded ? 'locally modified' : 'a different file already exists'));
52
+ if (f.sha256 !== recorded) {
53
+ const newRel = `${f.path}.new`;
54
+ inspect(project, newRel);
55
+ actions.push(act('file', 'write-new', newRel, 'incoming version for manual merge', { write: f.bytes }));
56
+ }
57
+ }
58
+ }
59
+
60
+ for (const old of priorByPath.values()) {
61
+ if (old.class !== 'managed' || shipped.has(old.path)) continue;
62
+ const disk = inspect(project, old.path);
63
+ if (!disk.exists) actions.push(act('file', 'forget', old.path, 'no longer shipped, already gone'));
64
+ else if (disk.hash === old.sha256) actions.push(act('file', 'delete', old.path, 'no longer shipped', { remove: true }));
65
+ else actions.push(act('file', 'keep-modified', old.path, 'no longer shipped but locally modified; now yours'));
66
+ }
67
+ return { actions, tracked };
68
+ }
69
+
70
+ // Uninstall counterpart: only unmodified managed files are deleted.
71
+ function planRemoveFiles({ project, prior }) {
72
+ const actions = [];
73
+ for (const f of prior.files) {
74
+ if (f.class === 'user-once') {
75
+ actions.push(act('file', 'keep-existing', f.path, 'user-owned, left in place'));
76
+ continue;
77
+ }
78
+ const disk = inspect(project, f.path);
79
+ if (!disk.exists) actions.push(act('file', 'forget', f.path, 'already gone'));
80
+ else if (disk.hash === f.sha256) actions.push(act('file', 'delete', f.path, '', { remove: true }));
81
+ else actions.push(act('file', 'keep-modified', f.path, 'locally modified, left in place'));
82
+ }
83
+ return actions;
84
+ }
85
+
86
+ module.exports = { act, planFiles, planRemoveFiles };
package/lib/plan.js ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ const { inspect, utf8Text } = require('./fs-safe');
3
+ const { act, planFiles, planRemoveFiles } = require('./plan-files');
4
+ const { SETTINGS_REL, renderSettings } = require('./merge-settings');
5
+ const { upsertBlock, removeBlock } = require('./merge-claude-md');
6
+ const { MANIFEST_REL, buildManifest, serializeManifest } = require('./manifest');
7
+
8
+ const CLAUDE_MD = 'CLAUDE.md';
9
+ // Turns "what the file should contain" into an action (or nothing if unchanged).
10
+ function textAction(kind, rel, file, next, detail) {
11
+ const current = utf8Text(file, rel);
12
+ if (next === current) return null;
13
+ if (next === null) return act(kind, 'remove', rel, detail, { remove: true });
14
+ return act(kind, file.exists ? 'update' : 'create', rel, detail, { write: Buffer.from(next, 'utf8') });
15
+ }
16
+
17
+ function settingsStep(project, prior, removeList, addList) {
18
+ const file = inspect(project, SETTINGS_REL);
19
+ const out = renderSettings(utf8Text(file, SETTINGS_REL), prior ? prior.settings : null, removeList, addList);
20
+ const action = textAction('settings', SETTINGS_REL, file, out.text, `${out.added.length} hook(s) tracked`);
21
+ return { action, info: out.info, hooks: out.added };
22
+ }
23
+
24
+ // install and update are the same plan; `prior` (the installed manifest) selects the behaviour.
25
+ function planSync({ project, bundle, prior, force, cliVersion, op }) {
26
+ const { actions, tracked } = planFiles({ project, bundle, prior, force, reinstall: op === 'install' });
27
+ const settings = settingsStep(project, prior, prior ? prior.hooks : [], bundle.hooks);
28
+ if (settings.action) actions.push(settings.action);
29
+
30
+ let blockInfo = null;
31
+ const md = inspect(project, CLAUDE_MD);
32
+ if (bundle.block) {
33
+ const out = upsertBlock(utf8Text(md, CLAUDE_MD), bundle.block.body, prior ? prior.block : null);
34
+ blockInfo = out.info;
35
+ const a = textAction('block', CLAUDE_MD, md, out.text, 'ai-sdlc router block');
36
+ if (a) actions.push(a);
37
+ } else if (prior && prior.block) {
38
+ const out = removeBlock(utf8Text(md, CLAUDE_MD), prior.block);
39
+ const a = textAction('block', CLAUDE_MD, md, out.text, 'block no longer shipped');
40
+ if (a) actions.push(a);
41
+ }
42
+
43
+ const manifest = buildManifest({
44
+ cliVersion,
45
+ payloadVersion: bundle.version,
46
+ payloadSchema: bundle.payloadSchema,
47
+ files: tracked,
48
+ block: blockInfo,
49
+ hooks: settings.hooks,
50
+ settings: settings.info,
51
+ });
52
+ actions.push(act('manifest', prior ? 'update' : 'create', MANIFEST_REL, `payload ${bundle.version}`, { write: serializeManifest(manifest) }));
53
+ return { actions, payloadVersion: bundle.version };
54
+ }
55
+
56
+ function planRemove({ project, prior }) {
57
+ const actions = planRemoveFiles({ project, prior });
58
+ const settings = settingsStep(project, prior, prior.hooks, []);
59
+ if (settings.action) actions.push(settings.action);
60
+ if (prior.block) {
61
+ const md = inspect(project, CLAUDE_MD);
62
+ const a = textAction('block', CLAUDE_MD, md, removeBlock(utf8Text(md, CLAUDE_MD), prior.block).text, 'ai-sdlc router block');
63
+ if (a) actions.push(a);
64
+ }
65
+ actions.push(act('manifest', 'remove', MANIFEST_REL, '', { remove: true }));
66
+ return { actions, payloadVersion: prior.payload_version };
67
+ }
68
+
69
+ // Counts per action name, for the summary line.
70
+ function summarize(actions) {
71
+ const out = {};
72
+ for (const a of actions) out[a.action] = (out[a.action] || 0) + 1;
73
+ return out;
74
+ }
75
+
76
+ const publicView = (a) => ({ kind: a.kind, action: a.action, path: a.path, ...(a.detail ? { detail: a.detail } : {}) });
77
+
78
+ module.exports = { planSync, planRemove, summarize, publicView };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const readline = require('readline');
5
+ const { UsageError, CliError } = require('./errors');
6
+
7
+ // Line-oriented prompter over any readable stream (a TTY or scripted input).
8
+ function createPrompter({ input, output }) {
9
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
10
+ const lines = rl[Symbol.asyncIterator]();
11
+ return {
12
+ // Resolves to the trimmed answer, or null once input has ended.
13
+ async ask(question) {
14
+ output.write(question);
15
+ const next = await lines.next();
16
+ return next.done ? null : String(next.value).trim();
17
+ },
18
+ close() {
19
+ rl.close();
20
+ },
21
+ };
22
+ }
23
+
24
+ async function confirm(prompter, question) {
25
+ const answer = await prompter.ask(`${question} [y/N] `);
26
+ return answer !== null && /^y(es)?$/i.test(answer);
27
+ }
28
+
29
+ const hasGit = (dir) => fs.existsSync(path.join(dir, '.git'));
30
+
31
+ function expandHome(p, home) {
32
+ if (p === '~') return home;
33
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(home, p.slice(2));
34
+ return p;
35
+ }
36
+
37
+ // Accepts a path only if it is an existing directory that is not the home
38
+ // directory or a filesystem root. Returns { dir (real path), hasGit }.
39
+ function validateTarget(input, { cwd, home }) {
40
+ if (typeof input !== 'string' || !input.trim()) throw new UsageError('project path is empty');
41
+ const abs = path.resolve(cwd, expandHome(input.trim(), home));
42
+ let real;
43
+ try {
44
+ real = fs.realpathSync(abs);
45
+ } catch (_) {
46
+ throw new UsageError(`project path does not exist: ${abs}`);
47
+ }
48
+ if (!fs.statSync(real).isDirectory()) throw new UsageError(`project path is not a directory: ${abs}`);
49
+ if (path.parse(real).root === real) throw new UsageError('refusing to use a filesystem root as the project');
50
+ let realHome = null;
51
+ try {
52
+ realHome = fs.realpathSync(home);
53
+ } catch (_) {
54
+ /* no usable home: nothing to compare with */
55
+ }
56
+ if (realHome && realHome === real) throw new UsageError('refusing to install into the home directory; pick a project repository');
57
+ return { dir: real, hasGit: hasGit(real) };
58
+ }
59
+
60
+ // Repositories (directories with .git) directly under each base, plus the base itself.
61
+ // `limit` stops the scan once that many have been found.
62
+ function findRepos(bases, limit = Infinity) {
63
+ const found = new Map();
64
+ const add = (dir) => {
65
+ if (found.size < limit && hasGit(dir) && !found.has(dir)) found.set(dir, true);
66
+ };
67
+ for (const base of bases) {
68
+ let entries = [];
69
+ try {
70
+ entries = fs.readdirSync(base, { withFileTypes: true });
71
+ } catch (_) {
72
+ continue;
73
+ }
74
+ add(base);
75
+ for (const e of entries.filter((x) => x.isDirectory() && !x.name.startsWith('.') && x.name !== 'node_modules')) {
76
+ if (found.size >= limit) break;
77
+ add(path.join(base, e.name));
78
+ }
79
+ }
80
+ return [...found.keys()];
81
+ }
82
+
83
+ async function chooseInteractively({ root, cwd, home, prompter, output }) {
84
+ const bases = root ? [path.resolve(cwd, root)] : [cwd, path.dirname(cwd)];
85
+ const repos = findRepos(bases);
86
+ output.write('Select the project to install into:\n');
87
+ repos.forEach((dir, i) => output.write(` ${i + 1}) ${dir}\n`));
88
+ output.write(` p) enter a path\n q) cancel\n`);
89
+ for (let attempt = 0; attempt < 3; attempt += 1) {
90
+ const answer = await prompter.ask('> ');
91
+ if (answer === null || /^q/i.test(answer)) throw new CliError('cancelled; nothing was changed');
92
+ let candidate = null;
93
+ if (/^\d+$/.test(answer) && repos[Number(answer) - 1]) candidate = repos[Number(answer) - 1];
94
+ else if (/^p$/i.test(answer)) {
95
+ const typed = await prompter.ask('Project path: ');
96
+ if (typed === null) throw new CliError('cancelled; nothing was changed');
97
+ candidate = typed;
98
+ } else output.write('Not a valid choice.\n');
99
+ if (candidate === null) continue;
100
+ try {
101
+ return validateTarget(candidate, { cwd, home });
102
+ } catch (err) {
103
+ if (!(err instanceof UsageError)) throw err;
104
+ output.write(`${err.message}\n`);
105
+ }
106
+ }
107
+ throw new UsageError('no valid project selected');
108
+ }
109
+
110
+ // --project wins; otherwise an interactive choice. Warns when the target is not a git repository.
111
+ async function pickProject({ project, root, cwd, home, prompter, output }) {
112
+ const target = project ? validateTarget(project, { cwd, home }) : await chooseInteractively({ root, cwd, home, prompter, output });
113
+ if (!target.hasGit) output.write(`warning: ${target.dir} has no .git directory; it may not be a repository root\n`);
114
+ return target;
115
+ }
116
+
117
+ module.exports = { createPrompter, confirm, pickProject, validateTarget, findRepos, expandHome };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const { CliError, UsageError } = require('./errors');
4
+ const { loadBundle } = require('./bundle');
5
+
6
+ // Decides where the bundle comes from. Returns { bundlePath } or { bundleObject },
7
+ // optionally with `cleanup()` for temporary files and `version` (the release's version).
8
+ // `fetchLatestBundle(opts)` downloads a release; `opts.version` pins a tag, `opts.signal` cancels it.
9
+ async function resolveBundle(opts, { fetchLatestBundle } = {}) {
10
+ if (opts.fromBundle) return { bundlePath: path.resolve(opts.cwd || process.cwd(), opts.fromBundle) };
11
+ if (typeof fetchLatestBundle === 'function') return fetchLatestBundle(opts);
12
+ throw new UsageError('no release source is configured; use --from-bundle <file>');
13
+ }
14
+
15
+ // Resolve + load + verify, then remove any downloaded temporary file.
16
+ async function loadReleaseBundle(opts, deps, { cliVersion }) {
17
+ const source = await resolveBundle(opts, deps);
18
+ try {
19
+ const bundle = loadBundle(source, { cliVersion });
20
+ // A downloaded release must carry the payload version its tag announces.
21
+ if (source.version && bundle.version !== source.version) {
22
+ throw new CliError(`the release is tagged ${source.version} but its bundle contains payload ${bundle.version}; refusing to install (ask the repo owner to republish the release)`);
23
+ }
24
+ return bundle;
25
+ } finally {
26
+ if (typeof source.cleanup === 'function') source.cleanup();
27
+ }
28
+ }
29
+
30
+ module.exports = { resolveBundle, loadReleaseBundle };
package/lib/semver.js ADDED
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // Only plain major.minor.patch ordering is needed; pre-release tags are ignored.
4
+ function parse(version) {
5
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version).trim());
6
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
7
+ }
8
+
9
+ // Returns -1, 0 or 1; null when either side is not a version.
10
+ function compare(a, b) {
11
+ const pa = parse(a);
12
+ const pb = parse(b);
13
+ if (!pa || !pb) return null;
14
+ for (let i = 0; i < 3; i += 1) {
15
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
16
+ }
17
+ return 0;
18
+ }
19
+
20
+ // Strict semantic version text (no stray characters), used for versions that are shown to the user.
21
+ const STRICT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
22
+ const isStrict = (v) => typeof v === 'string' && v.length <= 64 && STRICT.test(v);
23
+
24
+ // A release version as typed by a user: 1.2.0 or v1.2.0 (optionally with a pre-release / build suffix).
25
+ const isReleaseTag = (v) => typeof v === 'string' && v.length <= 64 && /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(v);
26
+
27
+ module.exports = { parse, compare, isStrict, isReleaseTag };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ // Runs `fn` (synchronous cleanup) when the process is told to stop with SIGINT or SIGTERM,
3
+ // then exits with the conventional 130 / 143. Returns a function that removes the handlers.
4
+ function onTermination(proc, fn) {
5
+ const handler = (code) => () => {
6
+ try {
7
+ fn();
8
+ } finally {
9
+ proc.exit(code);
10
+ }
11
+ };
12
+ const onInt = handler(130);
13
+ const onTerm = handler(143);
14
+ proc.on('SIGINT', onInt);
15
+ proc.on('SIGTERM', onTerm);
16
+ return () => {
17
+ proc.removeListener('SIGINT', onInt);
18
+ proc.removeListener('SIGTERM', onTerm);
19
+ };
20
+ }
21
+
22
+ module.exports = { onTermination };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ // Returned by a screen when the user pressed Esc (or declined) at that step.
3
+ const BACK = Object.freeze({ back: true });
4
+
5
+ // reason: 'interrupt' (Ctrl-C), 'eof' (input closed) or 'escape' (backed out of the first step).
6
+ class Cancelled extends Error {
7
+ constructor(reason) {
8
+ super(reason === 'interrupt' ? 'interrupted' : 'cancelled');
9
+ this.name = 'Cancelled';
10
+ this.reason = reason;
11
+ }
12
+ }
13
+
14
+ const isCancelKey = (k) => k.ctrl && k.name === 'c';
15
+
16
+ module.exports = { BACK, Cancelled, isCancelKey };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ // Terminal colour capability + helpers. Honors NO_COLOR and non-TTY output.
3
+
4
+ // 0 = none, 1 = basic 16 colours, 3 = truecolor
5
+ function colorLevel(env = process.env, stream = process.stdout) {
6
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return 0;
7
+ if (env.FORCE_COLOR === '0') return 0;
8
+ if (!stream.isTTY && !env.FORCE_COLOR) return 0;
9
+ if (env.TERM === 'dumb') return 0;
10
+ const ct = String(env.COLORTERM || '').toLowerCase();
11
+ if (ct === 'truecolor' || ct === '24bit' || env.TERM_PROGRAM === 'iTerm.app' || env.WT_SESSION) return 3;
12
+ return 1;
13
+ }
14
+
15
+ const lerp = (a, b, t) => Math.round(a + (b - a) * t);
16
+
17
+ // Colour at position t (0..1) along a list of [r,g,b] stops.
18
+ function gradientAt(stops, t) {
19
+ if (stops.length === 1) return stops[0];
20
+ const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
21
+ const i = Math.min(Math.floor(scaled), stops.length - 2);
22
+ const f = scaled - i;
23
+ return stops[i].map((c, k) => lerp(c, stops[i + 1][k], f));
24
+ }
25
+
26
+ // Darken a colour toward a backdrop (used for drop shadows).
27
+ const shade = (rgb, amount = 0.3, backdrop = [36, 36, 56]) => rgb.map((c, k) => lerp(backdrop[k], c, amount));
28
+
29
+ const fg = (rgb) => `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
30
+ const bg = (rgb) => `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
31
+ const RESET = '\x1b[0m';
32
+
33
+ module.exports = { colorLevel, gradientAt, shade, fg, bg, RESET };