@crystalplatforms/adm 0.2.3 → 0.3.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 (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +154 -161
  3. package/bin/adm +18 -21
  4. package/package.json +9 -22
  5. package/scripts/completion/adm.bash +12 -55
  6. package/scripts/completion/adm.zsh +21 -58
  7. package/scripts/install.js +8 -8
  8. package/src/commands/config.js +66 -0
  9. package/src/commands/dotfiles.js +49 -32
  10. package/src/commands/feedback.js +12 -0
  11. package/src/commands/help.js +33 -0
  12. package/src/commands/uninstall.js +73 -54
  13. package/src/commands/version.js +7 -0
  14. package/src/commands.js +26 -0
  15. package/src/config/index.js +32 -32
  16. package/src/data/extensions.json +358 -358
  17. package/src/engines.js +38 -0
  18. package/src/install/executor.js +263 -0
  19. package/src/install/hints.js +94 -0
  20. package/src/install/npm-prefix.js +110 -0
  21. package/src/install/plan.js +75 -0
  22. package/src/install/report.js +46 -0
  23. package/src/install/run.js +88 -0
  24. package/src/install/shell-paths.js +129 -0
  25. package/src/legacy-cleanup.js +96 -0
  26. package/src/pkg/builders.js +220 -0
  27. package/src/pkg/managers.js +59 -0
  28. package/src/pkg/package-names.js +229 -0
  29. package/src/pkg/script-urls.js +100 -0
  30. package/src/platform/index.js +129 -0
  31. package/src/platform/os-release.js +27 -0
  32. package/src/platform/shell.js +111 -0
  33. package/src/reports.js +37 -0
  34. package/src/router.js +157 -0
  35. package/src/setup/catalog.js +13 -13
  36. package/src/setup/git-step.js +128 -0
  37. package/src/setup/loader.js +108 -0
  38. package/src/setup/prompts.js +259 -0
  39. package/src/setup/registry.js +48 -0
  40. package/src/setup/spinner.js +67 -0
  41. package/src/setup/wizard.js +319 -195
  42. package/src/upgrade/index.js +499 -0
  43. package/src/utils/closest-match.js +36 -0
  44. package/src/utils/dotfiles-sync.js +89 -89
  45. package/src/utils/file-conflict-handler.js +30 -30
  46. package/bin/adm-tui +0 -13
  47. package/src/ai/knowledge.js +0 -61
  48. package/src/commands/connect.js +0 -76
  49. package/src/commands/issue.js +0 -80
  50. package/src/commands/pr.js +0 -55
  51. package/src/commands/theme.js +0 -103
  52. package/src/commands/upgrade.js +0 -114
  53. package/src/installer/git-config.js +0 -34
  54. package/src/installer/node-installer.js +0 -50
  55. package/src/installer/package-manager-installer.js +0 -31
  56. package/src/installer/ssh-setup.js +0 -31
  57. package/src/installer/system-packages.js +0 -49
  58. package/src/installers/index.js +0 -66
  59. package/src/integrations/ai-backend.js +0 -60
  60. package/src/integrations/ai-providers/registry.js +0 -261
  61. package/src/integrations/github.js +0 -107
  62. package/src/integrations/gitlab.js +0 -121
  63. package/src/plugins/context.js +0 -64
  64. package/src/plugins/loader.js +0 -50
  65. package/src/plugins/runner.js +0 -25
  66. package/src/setup/installer.js +0 -192
  67. package/src/shell/detector.js +0 -10
  68. package/src/tui/app-state.js +0 -374
  69. package/src/tui/app.js +0 -442
  70. package/src/tui/autocomplete.js +0 -57
  71. package/src/tui/commands/registry.js +0 -823
  72. package/src/tui/components/SetupScreen.js +0 -292
  73. package/src/ui/animations.js +0 -24
  74. package/src/ui/spinner.js +0 -34
  75. package/src/ui/theme/cyberpunk.js +0 -14
  76. package/src/ui/theme/dark.js +0 -14
  77. package/src/ui/theme/forest.js +0 -14
  78. package/src/ui/theme/light.js +0 -14
  79. package/src/ui/theme/monokai.js +0 -14
  80. package/src/ui/theme/nord.js +0 -14
  81. package/src/ui/theme.js +0 -49
  82. package/src/utils/command-history.js +0 -43
  83. package/src/utils/error-handler.js +0 -69
  84. package/src/utils/fuzzy-search.js +0 -20
  85. package/src/utils/github-error-handler.js +0 -26
  86. package/src/utils/keychain.js +0 -97
  87. package/src/utils/open-url.js +0 -15
  88. package/src/utils/terminal-detection.js +0 -26
  89. package/src/utils/token-encryption.js +0 -96
@@ -1,220 +1,344 @@
1
- const inquirer = require('inquirer');
2
1
  const chalk = require('chalk');
3
- const ora = require('ora');
4
- const { writeConfig, readConfig } = require('../config');
5
- const { detectShell } = require('../shell/detector');
6
- const { isNodeInstalled, installNode, getNodeVersion } = require('../installer/node-installer');
7
- const { isPnpmInstalled, installPnpm } = require('../installer/package-manager-installer');
8
- const { configureGit, getCurrentGitConfig } = require('../installer/git-config');
9
- const { sshKeyExists, generateSshKey, getPublicKey } = require('../installer/ssh-setup');
10
- const { detectPlatform, isBrewInstalled, installBrew, isAptInstalled, installAptPackages } = require('../installer/system-packages');
11
-
12
- async function runSetup(options = {}) {
13
- const dry = process.env.ADM_DRY_RUN === '1' || options.dryRun;
14
- const shell = detectShell();
15
-
16
- console.log(chalk.cyan('\n ADM Setup Wizard\n'));
17
- console.log(chalk.gray(` Detected shell: ${shell}`));
18
- console.log(chalk.gray(` Detected OS: ${detectPlatform()}\n`));
19
-
20
- if (dry) {
21
- return { dryRun: true, shell, platform: detectPlatform(), planned: getFullPlan() };
22
- }
2
+ const { loadCatalog, getToolsByCategories } = require('./catalog');
3
+ const { detectEnvironment } = require('../platform');
4
+ const { buildToolPlan, withFallbackRoute } = require('../pkg/builders');
5
+ const { isInstalled, executeInstall } = require('../install/executor');
6
+ const { renderResults } = require('../install/report');
7
+ const { appendInstalled } = require('./registry');
8
+ const { runGitStep } = require('./git-step');
9
+ const { readConfig } = require('../config');
10
+ const { checkboxPrompt, confirmPrompt } = require('./prompts');
11
+ const { loader } = require('./loader');
12
+ const { saveReport } = require('../reports');
13
+ const { getVersion } = require('../commands/version');
23
14
 
24
- // Step 1: System package manager
25
- const platform = detectPlatform();
26
- if (platform === 'darwin' && !isBrewInstalled()) {
27
- const { installBrew: wantBrew } = await inquirer.prompt([{
28
- type: 'confirm', name: 'installBrew', message: 'Install Homebrew?', default: true,
29
- }]);
30
- if (wantBrew) {
31
- const s = ora('Installing Homebrew...').start();
32
- try { await installBrew(); s.succeed('Homebrew installed'); }
33
- catch (e) { s.fail(`Homebrew install failed: ${e.message}`); }
34
- }
35
- } else if (platform === 'linux' && !isAptInstalled()) {
36
- const { installApt } = await inquirer.prompt([{
37
- type: 'confirm', name: 'installApt', message: 'Install base apt packages (git, curl, build-essential)?', default: true,
38
- }]);
39
- if (installApt) {
40
- const s = ora('Installing apt packages...').start();
41
- try { await installAptPackages(); s.succeed('Apt packages installed'); }
42
- catch (e) { s.fail(`Apt install failed: ${e.message}`); }
15
+ // The interactive setup wizard (#30): banner → Preparing… → categories →
16
+ // Scanning… → tools → install → "Worked for…" → report → git step.
17
+ //
18
+ // Acceptance-critical details:
19
+ // - the banner (CrystalPlatforms ADM-CLI vX.Y.Z) is printed ONCE and stays
20
+ // on top for the whole session
21
+ // - all "work" phases use the dots_12 loader: Preparing… (startup),
22
+ // Scanning… (status probes) and every install line
23
+ // - install lines carry the full command in parentheses and the whole
24
+ // install process collapses into one "Worked for <time>" line when it
25
+ // ends — the report below stays
26
+ // - the tools screen marks installed tools "✓ …" but keeps them
27
+ // selectable — picking one triggers a REINSTALL (`reinstalled`) (#34)
28
+ // - manual/link tools stay selectable: no automatic route → `manual`
29
+ // entry with a copyable hint (guidance, not a failure — owner decision)
30
+ // - every report is persisted to <configDir>/reports/ (owner decision);
31
+ // a missing config dir produces a loud ⚠ line, never a silent skip
32
+ //
33
+ // All I/O is injectable (prompt, execFunc, spinnerFactory, configDir,
34
+ // stdout, env) so the whole flow is testable without a TTY.
35
+
36
+ // Pre-checks which tools are already installed (per statusSource probe).
37
+ async function collectStatuses(entries, opts = {}) {
38
+ const map = new Map();
39
+ for (const entry of entries) {
40
+ let installed = false;
41
+ try {
42
+ installed = entry.status === 'planned' ? await isInstalled(entry, opts) : false;
43
+ } catch {
44
+ installed = false;
43
45
  }
46
+ map.set(entry.id, { entry, installed });
44
47
  }
48
+ return map;
49
+ }
45
50
 
46
- // Step 2: Node.js
47
- const nodeStatus = isNodeInstalled();
48
- let nodeVersion = nodeStatus.version;
49
- if (!nodeStatus.installed) {
50
- const { nodeChoice } = await inquirer.prompt([{
51
- type: 'list',
52
- name: 'nodeChoice',
53
- message: 'Node.js is not installed. How would you like to install it?',
54
- choices: [
55
- { name: 'Install latest LTS (via nvm)', value: 'lts' },
56
- { name: 'Install specific version', value: 'custom' },
57
- { name: 'Skip Node.js installation', value: 'skip' },
58
- ],
59
- default: 'lts',
60
- }]);
61
- let customVersion;
62
- if (nodeChoice === 'custom') {
63
- ({ customVersion } = await inquirer.prompt([{
64
- type: 'input', name: 'customVersion', message: 'Enter Node.js version (e.g. 18.17.0):',
65
- }]));
51
+ // Builds the tools checkbox choices with status marks (#30 story 8).
52
+ // Everything stays selectable (#34): installed tools carry a "✓" mark (and
53
+ // reinstall on pick), manual/link tools keep a clean label (and report as
54
+ // `manual` guidance on pick) — no disabled rows, no locked tools.
55
+ function toolChoices(tools, statuses) {
56
+ return tools.map(tool => {
57
+ const st = statuses.get(tool.id);
58
+ if (st && st.installed) {
59
+ return { name: `✓ ${tool.name}`, value: tool.id, checked: false };
66
60
  }
67
- if (nodeChoice !== 'skip') {
68
- const s = ora(`Installing Node.js ${nodeChoice === 'custom' ? customVersion : 'LTS'}...`).start();
69
- try {
70
- await installNode(nodeChoice === 'custom' ? customVersion : '--lts');
71
- nodeVersion = getNodeVersion();
72
- s.succeed(`Node.js installed: ${nodeVersion}`);
73
- } catch (e) {
74
- s.fail(`Node.js install failed: ${e.message}`);
75
- }
61
+ return { name: tool.name, value: tool.id, checked: false };
62
+ });
63
+ }
64
+
65
+ function fmtDuration(ms) {
66
+ const s = Math.max(1, Math.round(ms / 1000));
67
+ if (s < 60) return `${s}s`;
68
+ const m = Math.floor(s / 60);
69
+ const rest = s % 60;
70
+ if (m < 60) return `${m}m ${rest}s`;
71
+ const h = Math.floor(m / 60);
72
+ return `${h}h ${m % 60}m ${rest}s`;
73
+ }
74
+
75
+ // Startup phases are FAST (a few ms) — without this the loader would flash
76
+ // invisibly. Owner decision: Preparing… must actually be seen.
77
+ async function ensureVisible(since, minMs) {
78
+ const left = minMs - (Date.now() - since);
79
+ if (left > 0) await new Promise(r => setTimeout(r, left));
80
+ }
81
+
82
+ async function runWizard(opts = {}) {
83
+ const {
84
+ log = s => process.stdout.write(s + '\n'),
85
+ prompt = require('inquirer').prompt,
86
+ // Custom checkbox + loader: both repaint atomically (single stdout
87
+ // write per frame) — inquirer/ora flicker badly on plain consoles.
88
+ checkbox = checkboxPrompt,
89
+ confirm = confirmPrompt, // y/n prompt (injectable for tests)
90
+ spinnerFactory = text => loader(text),
91
+ execFunc,
92
+ home,
93
+ configDir,
94
+ stdout = process.stdout,
95
+ env: envOverride, // injectable for tests (defaults to real detection)
96
+ } = opts;
97
+
98
+ // Ctrl+C in a prompt → graceful exit, exit code 0.
99
+ const guard = p => p.catch(e => {
100
+ if (e && e.code === 'ADM_CANCELLED') {
101
+ log('Cancelled — nothing was changed.');
102
+ return null;
76
103
  }
77
- } else {
78
- console.log(chalk.green(` Node.js ${nodeVersion} already installed`));
104
+ throw e;
105
+ });
106
+
107
+ // Cursor-position collapse (owner fix, 0.3.x): instead of COUNTING lines
108
+ // to erase (inquirer leftovers, wrapped frames or child-process output can
109
+ // skew any count), remember the cursor position before a phase and wipe
110
+ // from that exact spot down afterwards.
111
+ const cursorSave = () => { if (stdout.isTTY) stdout.write('\x1b[s'); };
112
+ const cursorRestoreClear = () => { if (stdout.isTTY) stdout.write('\x1b[u\x1b[J'); };
113
+
114
+ // ── banner — printed once, stays on top for the whole session ─────
115
+ log(chalk.bold(`CrystalPlatforms ADM-CLI v${getVersion()}`));
116
+
117
+ // ── Preparing… — catalog, environment and saved config under the loader
118
+ const prepStart = Date.now();
119
+ const prep = spinnerFactory('Preparing...');
120
+ prep.start();
121
+ const env = envOverride || detectEnvironment();
122
+ const catalog = loadCatalog();
123
+ let declinedBefore = false;
124
+ try {
125
+ const saved = await readConfig(configDir);
126
+ declinedBefore = Boolean(saved && saved.git && saved.git.setupDeclined);
127
+ } catch {
128
+ declinedBefore = false;
79
129
  }
130
+ await ensureVisible(prepStart, 600); // the phase is real but fast — keep it visible
131
+ prep.stop();
80
132
 
81
- // Step 3: Package managers
82
- const pnpmStatus = isPnpmInstalled();
83
- if (!pnpmStatus.installed) {
84
- const { wantPnpm } = await inquirer.prompt([{
85
- type: 'confirm', name: 'wantPnpm', message: 'Install pnpm?', default: true,
86
- }]);
87
- if (wantPnpm) {
88
- const s = ora('Installing pnpm...').start();
89
- try { await installPnpm(); s.succeed('pnpm installed'); }
90
- catch (e) { s.fail(`pnpm install failed: ${e.message}`); }
91
- }
92
- } else {
93
- console.log(chalk.green(` pnpm ${pnpmStatus.version} already installed`));
133
+ // ── 1. categories — ALL on one screen (story 6) ─────────────────
134
+ const categories = await guard(checkbox({
135
+ name: 'categories',
136
+ message: 'Choose categories...',
137
+ choices: catalog.map(c => ({ name: c.name, value: c.id })),
138
+ pageSize: catalog.length,
139
+ }));
140
+ if (categories === null) return 0;
141
+ if (!categories || categories.length === 0) {
142
+ log('Nothing selected — nothing to do.');
143
+ return 0;
94
144
  }
145
+ log(`${chalk.green('✓')} Chosen ${categories.length} categor${categories.length === 1 ? 'y' : 'ies'}.`);
146
+
147
+ // ── 2. tools for the selected categories, with status (Scanning…) ──
148
+ const scanStart = Date.now();
149
+ const scan = spinnerFactory('Scanning...');
150
+ scan.start();
151
+ const tools = getToolsByCategories(categories);
152
+ const statuses = await collectStatuses(tools.map(tool => ({
153
+ id: tool.id,
154
+ name: tool.name,
155
+ category: tool.id,
156
+ method: tool.installMethod,
157
+ ...buildToolPlan(tool, env),
158
+ })), { execFunc, home });
159
+ await ensureVisible(scanStart, 500); // keep the loader + Scanning… visible
160
+ scan.stop();
95
161
 
96
- // Step 4: Optional tools
97
- const { tools } = await inquirer.prompt([{
98
- type: 'checkbox',
162
+ const selectedIds = await guard(checkbox({
99
163
  name: 'tools',
100
- message: 'Select optional tools to install:',
101
- choices: [
102
- { name: 'Vite', value: 'vite', checked: false },
103
- { name: 'esbuild', value: 'esbuild', checked: false },
104
- { name: 'TypeScript (tsc)', value: 'typescript', checked: false },
105
- { name: 'Docker (via system pkg)', value: 'docker', checked: false },
106
- { name: 'GitHub CLI (gh)', value: 'gh', checked: false },
107
- ],
108
- }]);
109
- if (tools.length > 0) {
110
- const s = ora(`Installing: ${tools.join(', ')}...`).start();
111
- try {
112
- for (const tool of tools) {
113
- if (['vite', 'esbuild', 'typescript'].includes(tool)) {
114
- const { execSync } = require('child_process');
115
- execSync(`npm install -g ${tool}`, { stdio: 'pipe' });
116
- } else if (tool === 'docker') {
117
- const { installSystemPackage } = require('../installer/system-packages');
118
- await installSystemPackage('docker.io');
119
- } else if (tool === 'gh') {
120
- const { installSystemPackage } = require('../installer/system-packages');
121
- await installSystemPackage('gh');
122
- }
123
- }
124
- s.succeed(`Installed: ${tools.join(', ')}`);
125
- } catch (e) {
126
- s.fail(`Tool install failed: ${e.message}`);
164
+ message: 'Choose tools to install...',
165
+ choices: toolChoices(tools, statuses),
166
+ pageSize: Math.min(Math.max(tools.length, 10), 15),
167
+ }));
168
+ if (selectedIds === null) return 0;
169
+ if (!selectedIds || selectedIds.length === 0) {
170
+ log('Nothing selected — nothing to do.');
171
+ return 0;
172
+ }
173
+ log(`${chalk.green('✓')} Chosen ${selectedIds.length} tool${selectedIds.length === 1 ? '' : 's'} to install.`);
174
+
175
+ // Everything the user picked is attempted (#34):
176
+ // - an installed pick forces a REINSTALL (executor skips its
177
+ // "already installed" probe) and lands as `reinstalled` in the report
178
+ // - tools with NO route on this platform (manual after the fallback,
179
+ // link-only extensions) run nothing — they land in the report as
180
+ // `manual` entries with a copyable hint. Owner decision (0.3.0 HITL):
181
+ // a missing route is GUIDANCE, not a failure.
182
+ const toInstall = (selectedIds || [])
183
+ .map(id => statuses.get(id))
184
+ .filter(st => st && st.entry)
185
+ .map(st => {
186
+ const entry = withFallbackRoute(st.entry, env);
187
+ entry.force = Boolean(st.installed);
188
+ return entry;
189
+ });
190
+
191
+ // ── split picks: runnable vs. guidance-only ─────────────────────
192
+ const manualResults = [];
193
+ const runnable = [];
194
+ for (const entry of toInstall) {
195
+ if (entry.status === 'manual') {
196
+ manualResults.push({
197
+ id: entry.id, name: entry.name, category: entry.category, method: entry.method,
198
+ status: 'manual', hint: entry.reason,
199
+ });
200
+ } else if (entry.status === 'link') {
201
+ manualResults.push({
202
+ id: entry.id, name: entry.name, category: entry.category, method: entry.method,
203
+ status: 'manual', hint: `install from: ${entry.url || '(no link known)'}`,
204
+ });
205
+ } else {
206
+ runnable.push(entry);
127
207
  }
128
208
  }
129
209
 
130
- // Step 5: Git config
131
- const currentGit = getCurrentGitConfig();
132
- const gitAnswers = await inquirer.prompt([
133
- {
134
- type: 'input', name: 'name', message: 'Git user.name:',
135
- default: currentGit.name || undefined,
136
- },
137
- {
138
- type: 'input', name: 'email', message: 'Git user.email:',
139
- default: currentGit.email || undefined,
140
- },
141
- {
142
- type: 'confirm', name: 'gpgsign', message: 'Enable GPG commit signing?',
143
- default: currentGit.gpgsign === 'true',
144
- },
145
- ]);
146
- await configureGit(gitAnswers);
147
-
148
- // Step 6: SSH key
149
- if (!sshKeyExists()) {
150
- const { wantSsh } = await inquirer.prompt([{
151
- type: 'confirm', name: 'wantSsh', message: 'Generate SSH key (ED25519)?', default: true,
152
- }]);
153
- if (wantSsh) {
154
- const result = generateSshKey(gitAnswers.email || 'adm@local');
155
- if (result.skipped) {
156
- console.log(chalk.yellow(` ${result.reason}`));
157
- } else {
158
- console.log(chalk.green(` SSH key generated: ${result.keyPath}`));
159
- console.log(chalk.gray(` Public key: ${getPublicKey(result.keyPath).substring(0, 60)}...`));
210
+ // ── Windows runtime question (HITL finding, 0.3.0) ──────────────
211
+ // ADM always runs under Node. On Windows, installing Node.js spawns an
212
+ // MSI whose Restart Manager restarts running node.exe processes — it can
213
+ // kill THIS session mid-run. Owner decision: ASK, and on confirmation
214
+ // really install (the risk belongs to whoever answered "yes"). POSIX is
215
+ // safe (nvm installs into ~/.nvm, nothing restarts processes).
216
+ if ((env.os || process.platform) === 'win32') {
217
+ const nodeIdx = runnable.findIndex(e => e.id === 'node');
218
+ if (nodeIdx >= 0) {
219
+ let confirmed = false;
220
+ const nodeMsg = 'Installing Node.js from inside ADM can restart this session (the Windows installer restarts running node.exe). Install anyway?';
221
+ try {
222
+ // the confirm atomically replaces its question with the done-format
223
+ // line (single write — owner fix: no flicker on y/n)
224
+ confirmed = await confirm({
225
+ message: nodeMsg,
226
+ defaultValue: false,
227
+ answerLine: ok => ok
228
+ ? `${chalk.green('✓')} Node.js install confirmed.`
229
+ : `${chalk.yellow('❗')} Node.js install declined — manual command in the report.`,
230
+ });
231
+ } catch {
232
+ confirmed = false; // prompt cancelled → treat as declined
233
+ log(`${chalk.yellow('❗')} Node.js install declined — manual command in the report.`);
234
+ }
235
+ if (!confirmed) {
236
+ const [entry] = runnable.splice(nodeIdx, 1);
237
+ manualResults.push({
238
+ id: entry.id, name: entry.name, category: entry.category, method: entry.method,
239
+ status: 'manual',
240
+ hint: 'close ADM, then run in a separate terminal: winget install --id OpenJS.NodeJS.LTS --exact --accept-source-agreements --accept-package-agreements',
241
+ });
160
242
  }
161
243
  }
162
- } else {
163
- console.log(chalk.green(' SSH key already exists'));
164
244
  }
165
245
 
166
- // Step 7: Dotfiles (optional)
167
- const { dotfilesUrl } = await inquirer.prompt([{
168
- type: 'input', name: 'dotfilesUrl', message: 'Dotfiles repo URL (leave empty to skip):',
169
- }]);
170
- if (dotfilesUrl) {
171
- const s = ora('Cloning dotfiles...').start();
172
- try {
173
- const { execSync } = require('child_process');
174
- const dotfilesDir = require('path').join(require('os').homedir(), '.adm', 'dotfiles');
175
- execSync(`git clone "${dotfilesUrl}" "${dotfilesDir}"`, { stdio: 'pipe' });
176
- s.succeed(`Dotfiles cloned to ${dotfilesDir}`);
177
- } catch (e) {
178
- s.fail(`Dotfiles clone failed: ${e.message}`);
246
+ let gitOffered = false;
247
+
248
+ if (runnable.length === 0 && manualResults.length === 0) {
249
+ log('Nothing selected — nothing to do.');
250
+ } else {
251
+ let results = manualResults;
252
+ let workedLine = null;
253
+
254
+ if (runnable.length > 0) {
255
+ // ── 3. install with the loader per tool — the line carries the
256
+ // full command in parentheses, and after 3s its [elapsed] (owner decision)
257
+ let spinner = null;
258
+ let prevRows = 0; // rows the previous tool's finished status line occupies
259
+ const eraseRows = n => {
260
+ if (n > 0 && stdout.isTTY) stdout.write((n > 1 ? `\x1b[${n - 1}A` : '') + '\r\x1b[J');
261
+ };
262
+ const installStart = Date.now();
263
+ cursorSave(); // collapse anchor: everything below is wiped on finish
264
+ const outcome = await executeInstall(runnable, {
265
+ execFunc,
266
+ home,
267
+ rcFile: env.rcFile,
268
+ onStart: ({ tool }) => {
269
+ if (spinner) spinner.stop();
270
+ // owner decision: ONE install line at a time — the previous
271
+ // tool's status line disappears as the next tool starts
272
+ eraseRows(prevRows);
273
+ prevRows = 0;
274
+ spinner = spinnerFactory(`${tool.force ? 'Reinstalling' : 'Installing'} ${tool.name} (${tool.command})`);
275
+ spinner.start();
276
+ },
277
+ onProgress: ({ tool, result }) => {
278
+ if (!spinner) return;
279
+ if (result.status === 'installed') spinner.succeed(`${tool.name} ${tool.force ? 'reinstalled' : 'installed'}`);
280
+ else if (result.status === 'skipped') spinner.info(`${tool.name} already installed`);
281
+ else spinner.fail(`${tool.name} failed (see report below)`);
282
+ spinner = null;
283
+ prevRows = 1; // status lines are short — always exactly one row
284
+ },
285
+ onInstalled: entry => appendInstalled(entry, { configDir }),
286
+ });
287
+
288
+ workedLine = `Worked for ${fmtDuration(Date.now() - installStart)}`;
289
+
290
+ // ── 4. report assembly: ✓ installed / ✓ reinstalled / i manual ──
291
+ const reinstalledIds = new Set(runnable.filter(e => e.force).map(e => e.id));
292
+ results = [...outcome.results.map(r => {
293
+ if (r.status === 'installed' && reinstalledIds.has(r.id)) {
294
+ return { ...r, status: 'reinstalled' };
295
+ }
296
+ return r;
297
+ }), ...manualResults];
298
+
299
+ // The git-identity question belongs to a git/gh FRESH install — a
300
+ // reinstall means the user already has it configured.
301
+ if (outcome.installed > 0 && !declinedBefore) {
302
+ const GIT_TOOLS = new Set(['git', 'gh']);
303
+ gitOffered = results.some(r => r.status === 'installed' && GIT_TOOLS.has(r.id));
304
+ }
305
+
306
+ // ── collapse (owner decision): restore the saved cursor position and
307
+ // clear — the whole install process disappears EXACTLY, whatever
308
+ // wrapped or printed in between; the report below stays. Non-TTY
309
+ // (tests) never drew the lines → the save/restore is skipped there.
310
+ cursorRestoreClear();
179
311
  }
312
+
313
+ const reportText = renderResults(results, { summary: false });
314
+
315
+ // persist FIRST so the report heading can carry the saved path
316
+ const header = [
317
+ `ADM install report — ${new Date().toISOString()}`,
318
+ `ADM ${getVersion()} · ${env.os}${env.distro ? `/${env.distro}` : ''} · manager: ${env.manager} · shell: ${env.shell}`,
319
+ workedLine,
320
+ ].filter(l => l !== null);
321
+ const body = [...header, '', reportText].join('\n') + '\n';
322
+ const saved = saveReport('install', body, { configDir });
323
+ const savedNote = saved.file
324
+ ? chalk.dim(` (saved: ${saved.file})`)
325
+ : chalk.dim(` (⚠ not saved: ${saved.error})`);
326
+
327
+ if (workedLine) log(chalk.bold(workedLine));
328
+ log('');
329
+ log(chalk.bold('Install report:') + savedNote);
330
+ log(reportText);
180
331
  }
181
332
 
182
- // Step 8: Assistant mode
183
- const { assistant } = await inquirer.prompt([{
184
- type: 'confirm', name: 'assistant', message: 'Enable assistant mode?', default: false,
185
- }]);
186
-
187
- // Save config
188
- const config = {
189
- installed: true,
190
- setupCompletedAt: new Date().toISOString(),
191
- shell,
192
- platform,
193
- nodeVersion,
194
- pnpm: pnpmStatus.installed || (await isPnpmInstalled()).installed,
195
- tools,
196
- git: { name: gitAnswers.name, email: gitAnswers.email, gpgsign: gitAnswers.gpgsign },
197
- assistant,
198
- dotfiles: dotfilesUrl || null,
199
- };
200
- await writeConfig(config);
201
-
202
- console.log(chalk.cyan('\n Setup complete! Config saved to ~/.adm/config.json\n'));
203
- return config;
204
- }
333
+ // ── 5. git step (story 10) — only after an actual install happened ──
334
+ // Reviewing the list without installing anything must not nag about git;
335
+ // a declined setup is remembered in config.json and never asked again.
336
+ if (gitOffered) {
337
+ const gitResult = await runGitStep({ execFunc, prompt, log, configDir });
338
+ if (gitResult.status === 'skipped') log('OK — you can set it later in any terminal: git config --global user.name "You"');
339
+ }
205
340
 
206
- function getFullPlan() {
207
- return [
208
- 'Detect and install system package manager (brew/apt)',
209
- 'Install Node.js via nvm',
210
- 'Install pnpm',
211
- 'Install optional tools (Vite, esbuild, TypeScript, Docker, gh)',
212
- 'Configure git (user.name, user.email, gpgsign)',
213
- 'Generate SSH key (ED25519)',
214
- 'Clone dotfiles repo',
215
- 'Enable assistant mode',
216
- 'Save config to ~/.adm/config.json',
217
- ];
341
+ return 0;
218
342
  }
219
343
 
220
- module.exports = { runSetup };
344
+ module.exports = { runWizard, collectStatuses, toolChoices };