@oaklandzoo/ostup 0.7.0 → 0.9.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.
@@ -0,0 +1,201 @@
1
+ // bootstrap.mjs: in-CLI stage 2. Detect missing tools via tool-registry, explain in plain English,
2
+ // confirm, install via exec.run (stdio: inherit so OS prompts reach the terminal), patch PATH,
3
+ // verify, and continue. Stage 1 (install.sh / install.ps1) handles Node and Homebrew/winget; this
4
+ // module never touches those — but it does check that brew/winget exist before attempting installs
5
+ // that require them, so beginners who skipped stage 1 get a clear pointer back to it.
6
+ import * as p from '@clack/prompts';
7
+ import { execSync } from 'node:child_process';
8
+ import { homedir } from 'node:os';
9
+ import { detectAll, installCommandFor } from './tool-registry.mjs';
10
+ import { run as exec } from './exec.mjs';
11
+
12
+ const STAGE1_HINT = {
13
+ darwin: 'Mac installer: /bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)"',
14
+ win32: 'Windows installer: irm https://ostup-install.vercel.app/install.ps1 | iex',
15
+ linux: 'On Linux, install Node 20+ from https://nodejs.org or your package manager, then re-run.',
16
+ };
17
+
18
+ function buildExplainer(missing) {
19
+ const items = missing.map((m, i) => ` ${i + 1}. ${m.tool.name.padEnd(14)} ${m.tool.blurb}`);
20
+ return [
21
+ '',
22
+ 'ostup needs the following before it can build your site:',
23
+ '',
24
+ ...items,
25
+ '',
26
+ ].join('\n');
27
+ }
28
+
29
+ function describePlan(plan) {
30
+ if (plan.kind === 'manual') {
31
+ return `(manual) ${plan.url}${plan.note ? ` — ${plan.note}` : ''}`;
32
+ }
33
+ return `${plan.cmd} ${(plan.args || []).join(' ')}`;
34
+ }
35
+
36
+ function defaultHasOnPath(bin) {
37
+ try {
38
+ if (process.platform === 'win32') {
39
+ execSync(`where.exe ${bin}`, { stdio: ['ignore', 'pipe', 'ignore'] });
40
+ } else {
41
+ execSync(`command -v ${bin}`, { stdio: ['ignore', 'pipe', 'ignore'], shell: '/bin/bash' });
42
+ }
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function patchPathFor(plan) {
50
+ if (!plan.postPath || plan.postPath.length === 0) return;
51
+ const sep = process.platform === 'win32' ? ';' : ':';
52
+ const existing = (process.env.PATH || '').split(sep);
53
+ const toAdd = plan.postPath.filter((dir) => !existing.includes(dir));
54
+ if (toAdd.length === 0) return;
55
+ process.env.PATH = toAdd.join(sep) + sep + (process.env.PATH || '');
56
+ }
57
+
58
+ function checkInstallPrerequisites(missing, hasOnPath, platform) {
59
+ const reasons = [];
60
+ for (const m of missing) {
61
+ const plan = installCommandFor(m.tool, platform);
62
+ if (plan.kind === 'brew' && !hasOnPath('brew')) {
63
+ reasons.push({
64
+ tool: m.tool.name,
65
+ prereq: 'Homebrew',
66
+ hint: STAGE1_HINT.darwin,
67
+ });
68
+ }
69
+ if (plan.kind === 'winget' && !hasOnPath('winget')) {
70
+ reasons.push({
71
+ tool: m.tool.name,
72
+ prereq: 'WinGet (Microsoft App Installer)',
73
+ hint: 'Update App Installer from the Microsoft Store: ms-windows-store://pdp/?productid=9NBLGGH4NNS1',
74
+ });
75
+ }
76
+ }
77
+ // Dedupe by prereq
78
+ const seen = new Set();
79
+ return reasons.filter((r) => {
80
+ if (seen.has(r.prereq)) return false;
81
+ seen.add(r.prereq);
82
+ return true;
83
+ });
84
+ }
85
+
86
+ function printInstallPlan(missing, platform = process.platform) {
87
+ process.stdout.write('Install plan:\n');
88
+ for (const m of missing) {
89
+ const plan = installCommandFor(m.tool, platform);
90
+ process.stdout.write(` ${m.tool.name}: ${describePlan(plan)}\n`);
91
+ }
92
+ process.stdout.write('\n');
93
+ }
94
+
95
+ export async function runBootstrap({
96
+ flags = {},
97
+ detect = detectAll,
98
+ hasOnPath = defaultHasOnPath,
99
+ platform = process.platform,
100
+ } = {}) {
101
+ const { found, missing } = detect();
102
+ if (missing.length === 0) {
103
+ if (flags.bootstrapOnly) {
104
+ process.stdout.write('[bootstrap] all required tools present. Nothing to install.\n');
105
+ }
106
+ return { found, missing: [], installed: [], skipped: true };
107
+ }
108
+
109
+ process.stdout.write(buildExplainer(missing));
110
+
111
+ // --no-install: print plan, exit 0 without changing anything.
112
+ if (flags.noInstall) {
113
+ printInstallPlan(missing, platform);
114
+ process.stdout.write('No changes made. Re-run without --no-install to actually install.\n');
115
+ return { found, missing, installed: [], deferred: 'no-install' };
116
+ }
117
+
118
+ // Manual-only missing tools (e.g. Node version mismatch): cannot auto-install here.
119
+ // Point the user at the stage-1 installer for their OS and bail.
120
+ const manualOnly = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'manual');
121
+ if (manualOnly.length > 0) {
122
+ const lines = ['Some tools cannot be installed by ostup directly:'];
123
+ for (const m of manualOnly) {
124
+ const plan = installCommandFor(m.tool, platform);
125
+ lines.push(` - ${m.tool.name}: ${plan.url}`);
126
+ if (plan.note) lines.push(` ${plan.note}`);
127
+ }
128
+ lines.push('');
129
+ lines.push(STAGE1_HINT[platform] || STAGE1_HINT.linux);
130
+ lines.push('');
131
+ process.stdout.write(lines.join('\n') + '\n');
132
+ const err = new Error('Manual install required for: ' + manualOnly.map((m) => m.tool.name).join(', '));
133
+ err.code = 'UNSUPPORTED_OS_INSTALL';
134
+ throw err;
135
+ }
136
+
137
+ // Prerequisite check (brew on Mac, winget on Windows).
138
+ const prereqProblems = checkInstallPrerequisites(missing, hasOnPath, platform);
139
+ if (prereqProblems.length > 0) {
140
+ const lines = ['Cannot install — a prerequisite is missing on your machine:'];
141
+ for (const r of prereqProblems) {
142
+ lines.push(` - ${r.prereq} (needed for ${r.tool})`);
143
+ lines.push(` ${r.hint}`);
144
+ }
145
+ lines.push('');
146
+ process.stdout.write(lines.join('\n') + '\n');
147
+ const err = new Error('Bootstrap cannot run: ' + prereqProblems.map((r) => r.prereq).join(', ') + ' missing.');
148
+ err.code = 'BOOTSTRAP_FAILED';
149
+ throw err;
150
+ }
151
+
152
+ // Need a TTY for the confirm prompt unless --yes.
153
+ if (!flags.yes && !process.stdin.isTTY) {
154
+ const err = new Error('Cannot prompt to install: no terminal detected. Pass --no-install to print the plan, or --yes to auto-accept, or run in a terminal.');
155
+ err.code = 'NO_TTY_BOOTSTRAP';
156
+ throw err;
157
+ }
158
+
159
+ printInstallPlan(missing, platform);
160
+
161
+ if (!flags.yes) {
162
+ const ok = await p.confirm({ message: 'Install these now?' });
163
+ if (p.isCancel(ok) || ok === false) {
164
+ const err = new Error('Bootstrap declined. Re-run when ready, or install the tools manually.');
165
+ err.code = 'BOOTSTRAP_DECLINED';
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ // Run installs sequentially. stdio: 'inherit' so sudo/UAC prompts, progress bars, and
171
+ // any interactive winget/brew confirmations reach the user's terminal.
172
+ const installed = [];
173
+ for (const m of missing) {
174
+ const plan = installCommandFor(m.tool, platform);
175
+ await exec(`bootstrap-${m.tool.id}`, plan.cmd, plan.args || [], {
176
+ stdio: 'inherit',
177
+ cwd: homedir(),
178
+ });
179
+ patchPathFor(plan);
180
+ installed.push(m.tool.id);
181
+ }
182
+
183
+ // Re-detect to verify the installs worked.
184
+ const second = detect();
185
+ if (second.missing.length > 0) {
186
+ const stillMissing = second.missing.map((m) => m.tool.name).join(', ');
187
+ const err = new Error(
188
+ `Bootstrap finished but these tools are still not detected: ${stillMissing}. ` +
189
+ `Open a new terminal and re-run, or check the run log for details.`
190
+ );
191
+ err.code = 'BOOTSTRAP_VERIFY_FAILED';
192
+ throw err;
193
+ }
194
+
195
+ process.stdout.write(`\n[bootstrap] installed ${installed.length} tool(s): ${installed.join(', ')}. Continuing.\n\n`);
196
+ return { found: second.found, missing: [], installed };
197
+ }
198
+
199
+ export async function runBootstrapStandalone({ flags = {} } = {}) {
200
+ return runBootstrap({ flags: { ...flags, bootstrapOnly: true } });
201
+ }
@@ -1,6 +1,7 @@
1
1
  // credential-prompts.mjs: detect GitHub and Vercel credentials, prompt and persist only if missing.
2
2
  import * as p from '@clack/prompts';
3
3
  import { execSync } from 'node:child_process';
4
+ import { run as exec } from './exec.mjs';
4
5
 
5
6
  export function checkGithubAuth({ env = process.env, runner = defaultCmdOk } = {}) {
6
7
  if (env.GH_TOKEN) return { ok: true, source: 'env-or-dotenv' };
@@ -80,21 +81,71 @@ export async function promptForVercelToken() {
80
81
  return typeof token === 'string' && token.trim() ? token.trim() : null;
81
82
  }
82
83
 
83
- export async function ensureCredentials({ stack = 'next' } = {}) {
84
+ function binaryOnPath(bin) {
85
+ try {
86
+ execSync(`${bin} --version`, { stdio: ['ignore', 'ignore', 'ignore'] });
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ async function chooseAuthMethod({ provider }) {
94
+ const choice = await p.select({
95
+ message: `${provider}: how would you like to sign in?`,
96
+ options: [
97
+ { value: 'browser', label: `Sign in to ${provider} in your browser (recommended)` },
98
+ { value: 'token', label: 'Paste a personal access token instead' },
99
+ ],
100
+ initialValue: 'browser',
101
+ });
102
+ if (p.isCancel(choice)) return 'token';
103
+ return choice;
104
+ }
105
+
106
+ async function attemptBrowserAuth({ provider }) {
107
+ try {
108
+ if (provider === 'GitHub') {
109
+ await exec('gh-auth', 'gh', ['auth', 'login', '--web', '-h', 'github.com'], { stdio: 'inherit' });
110
+ } else if (provider === 'Vercel') {
111
+ await exec('vercel-auth', 'vercel', ['login'], { stdio: 'inherit' });
112
+ }
113
+ return true;
114
+ } catch {
115
+ process.stdout.write(`${provider} browser sign-in did not complete. Falling back to token paste.\n`);
116
+ return false;
117
+ }
118
+ }
119
+
120
+ export async function ensureCredentials({ stack = 'next', flags = {} } = {}) {
84
121
  const collected = {};
85
122
 
86
123
  const gh = checkGithubAuth();
87
124
  if (gh.ok) {
88
125
  process.stdout.write(`GitHub: using existing auth (${gh.source}).\n`);
89
126
  } else {
90
- const token = await promptForGithubToken();
91
- if (!token) {
92
- const err = new Error('GitHub credentials are required.');
93
- err.code = 'NO_GH_CREDS';
94
- throw err;
127
+ let signedIn = false;
128
+ if (binaryOnPath('gh')) {
129
+ const method = flags.yes ? 'browser' : await chooseAuthMethod({ provider: 'GitHub' });
130
+ if (method === 'browser') {
131
+ signedIn = await attemptBrowserAuth({ provider: 'GitHub' });
132
+ if (signedIn && checkGithubAuth().ok) {
133
+ process.stdout.write('GitHub: signed in via browser.\n');
134
+ } else {
135
+ signedIn = false;
136
+ }
137
+ }
138
+ }
139
+ if (!signedIn) {
140
+ const token = await promptForGithubToken();
141
+ if (!token) {
142
+ const err = new Error('GitHub credentials are required.');
143
+ err.code = 'NO_GH_CREDS';
144
+ throw err;
145
+ }
146
+ process.env.GH_TOKEN = token;
147
+ collected.GH_TOKEN = token;
95
148
  }
96
- process.env.GH_TOKEN = token;
97
- collected.GH_TOKEN = token;
98
149
  }
99
150
 
100
151
  if (stack !== 'none') {
@@ -102,14 +153,28 @@ export async function ensureCredentials({ stack = 'next' } = {}) {
102
153
  if (v.ok) {
103
154
  process.stdout.write(`Vercel: using existing auth (${v.source}).\n`);
104
155
  } else {
105
- const token = await promptForVercelToken();
106
- if (!token) {
107
- const err = new Error('Vercel credentials are required for stack=' + stack + '.');
108
- err.code = 'NO_VERCEL_CREDS';
109
- throw err;
156
+ let signedIn = false;
157
+ if (binaryOnPath('vercel')) {
158
+ const method = flags.yes ? 'browser' : await chooseAuthMethod({ provider: 'Vercel' });
159
+ if (method === 'browser') {
160
+ signedIn = await attemptBrowserAuth({ provider: 'Vercel' });
161
+ if (signedIn && checkVercelAuth().ok) {
162
+ process.stdout.write('Vercel: signed in via browser.\n');
163
+ } else {
164
+ signedIn = false;
165
+ }
166
+ }
167
+ }
168
+ if (!signedIn) {
169
+ const token = await promptForVercelToken();
170
+ if (!token) {
171
+ const err = new Error('Vercel credentials are required for stack=' + stack + '.');
172
+ err.code = 'NO_VERCEL_CREDS';
173
+ throw err;
174
+ }
175
+ process.env.VERCEL_TOKEN = token;
176
+ collected.VERCEL_TOKEN = token;
110
177
  }
111
- process.env.VERCEL_TOKEN = token;
112
- collected.VERCEL_TOKEN = token;
113
178
  }
114
179
  }
115
180
 
package/src/doctor.mjs ADDED
@@ -0,0 +1,188 @@
1
+ // doctor.mjs: self-diagnosis subcommand. Runs preflight + auth checks + permissions + reports.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { execSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { detectAll, installCommandFor } from './tool-registry.mjs';
9
+ import { checkGithubAuth, checkVercelAuth } from './credential-prompts.mjs';
10
+
11
+ function runOk(cmd) {
12
+ try {
13
+ execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] });
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function runCapture(cmd) {
21
+ try {
22
+ return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function runDoctor() {
29
+ const findings = [];
30
+ let okCount = 0;
31
+ let failCount = 0;
32
+ let warnCount = 0;
33
+
34
+ function report(level, label, detail) {
35
+ findings.push({ level, label, detail });
36
+ if (level === 'ok') okCount++;
37
+ else if (level === 'fail') failCount++;
38
+ else warnCount++;
39
+ }
40
+
41
+ // === Tool detection (shared with bootstrap) ===
42
+ const detection = detectAll();
43
+ for (const f of detection.found) {
44
+ report('ok', f.tool.name.toLowerCase().replace(/\s+/g, '-'), f.version);
45
+ }
46
+ for (const m of detection.missing) {
47
+ const plan = installCommandFor(m.tool);
48
+ const planStr = plan.kind === 'manual'
49
+ ? plan.url
50
+ : `${plan.cmd} ${(plan.args || []).join(' ')}`;
51
+ const detail = m.reason === 'version-too-old'
52
+ ? `${m.detected} too old; need ${m.tool.detect.minMajor}+. Fix: ${planStr}`
53
+ : `not installed. Fix: ${planStr}`;
54
+ report('fail', m.tool.name.toLowerCase().replace(/\s+/g, '-'), detail);
55
+ }
56
+
57
+ // === Git config ===
58
+ const gitName = runCapture('git config --global user.name');
59
+ const gitEmail = runCapture('git config --global user.email');
60
+ if (gitName && gitEmail) {
61
+ report('ok', 'git config', `${gitName} <${gitEmail}>`);
62
+ } else {
63
+ report('fail', 'git config', 'user.name or user.email not set; commits will fail. Run: git config --global user.name "Your Name" && git config --global user.email you@example.com');
64
+ }
65
+
66
+ // === GitHub auth ===
67
+ const gh = checkGithubAuth();
68
+ if (gh.ok) {
69
+ const ghUser = runCapture('gh api user --jq .login') || '(unknown)';
70
+ report('ok', 'gh auth', `authenticated as ${ghUser} (source: ${gh.source})`);
71
+ // Check scopes
72
+ const scopes = runCapture('gh auth status 2>&1 | grep -oE "Token scopes:.*"');
73
+ if (scopes) {
74
+ report('ok', 'gh scopes', scopes.replace('Token scopes:', '').trim());
75
+ if (!scopes.includes('delete_repo')) {
76
+ report('warn', 'gh delete_repo scope', 'missing; synthetic test cleanup will require manual deletion. Run: gh auth refresh -h github.com -s delete_repo');
77
+ }
78
+ }
79
+ } else {
80
+ report('fail', 'gh auth', 'not authenticated; run: gh auth login');
81
+ }
82
+
83
+ // === Vercel auth ===
84
+ const v = checkVercelAuth();
85
+ if (v.ok) {
86
+ const vUser = runCapture('vercel whoami');
87
+ report('ok', 'vercel auth', `authenticated as ${vUser || '(unknown)'} (source: ${v.source})`);
88
+
89
+ // Detect personal-account-as-scope risk
90
+ const teams = runCapture('vercel teams ls 2>&1 | grep -E "^✔|^\\*" | head -5');
91
+ if (teams) {
92
+ report('ok', 'vercel teams', teams.replace(/\n/g, '; '));
93
+ }
94
+ } else {
95
+ report('fail', 'vercel auth', 'not authenticated; run: vercel login');
96
+ }
97
+
98
+ // === Vercel AI Gateway key (optional but needed for /generate-image) ===
99
+ if (process.env.VERCEL_AI_GATEWAY_KEY) {
100
+ report('ok', 'VERCEL_AI_GATEWAY_KEY', 'present (image generation enabled)');
101
+ } else {
102
+ report('warn', 'VERCEL_AI_GATEWAY_KEY', 'not set; /generate-image will fail until set. Get key at https://vercel.com/dashboard/ai-gateway');
103
+ }
104
+
105
+ // === Chrome for screenshot verification ===
106
+ const chromeMac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
107
+ if (existsSync(chromeMac)) {
108
+ report('ok', 'chrome', 'present (visual verification supported per CLAUDE.md Part 19)');
109
+ } else if (runOk('which chromium 2>/dev/null') || runOk('which google-chrome 2>/dev/null')) {
110
+ report('ok', 'chrome', 'chromium or google-chrome on PATH');
111
+ } else {
112
+ report('warn', 'chrome', 'no Chrome detected; visual verification per CLAUDE.md Part 19 will not work. Install from https://www.google.com/chrome/');
113
+ }
114
+
115
+ // === Disk space in home dir ===
116
+ const dfHome = runCapture(`df -k ${homedir()} | tail -1 | awk '{print $4}'`);
117
+ if (dfHome) {
118
+ const kbFree = parseInt(dfHome, 10);
119
+ if (Number.isFinite(kbFree) && kbFree < 1024 * 1024) {
120
+ // less than 1 GB free
121
+ report('warn', 'disk space', `${(kbFree / 1024).toFixed(0)} MB free in ${homedir()}; scaffolds may fail (npm install needs ~500 MB)`);
122
+ } else {
123
+ report('ok', 'disk space', `${(kbFree / 1024 / 1024).toFixed(1)} GB free in ${homedir()}`);
124
+ }
125
+ }
126
+
127
+ // === Memory dir creatable ===
128
+ const memTest = join(homedir(), '.claude', 'projects', '__ostup_doctor_test__', 'memory');
129
+ try {
130
+ const { mkdir, rm } = await import('node:fs/promises');
131
+ await mkdir(memTest, { recursive: true });
132
+ await rm(memTest, { recursive: true, force: true });
133
+ report('ok', 'memory dir', 'can create ~/.claude/projects/<project>/memory at scaffold time');
134
+ } catch (err) {
135
+ report('fail', 'memory dir', `cannot create ~/.claude/projects/__ostup_doctor_test__/memory: ${err.message}`);
136
+ }
137
+
138
+ // === Log dir creatable ===
139
+ try {
140
+ const { mkdir } = await import('node:fs/promises');
141
+ await mkdir(join(homedir(), '.ostup', 'logs'), { recursive: true });
142
+ report('ok', 'log dir', `~/.ostup/logs/ writable (run logs land here)`);
143
+ } catch (err) {
144
+ report('fail', 'log dir', `cannot create ~/.ostup/logs/: ${err.message}`);
145
+ }
146
+
147
+ // === npm version (we don't call `npm bin` anymore, but warn on very old npm) ===
148
+ const npmVer = runCapture('npm --version');
149
+ if (npmVer) {
150
+ const major = parseInt(npmVer.split('.')[0], 10);
151
+ if (major < 9) {
152
+ report('warn', 'npm version', `${npmVer} is old; recommend npm 9+. Run: npm install -g npm@latest`);
153
+ } else {
154
+ report('ok', 'npm', npmVer);
155
+ }
156
+ }
157
+
158
+ // === ostup tarball install location (sanity) ===
159
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
160
+ report('ok', 'ostup install', pkgRoot);
161
+
162
+ return { findings, okCount, warnCount, failCount };
163
+ }
164
+
165
+ export function printDoctorReport({ findings, okCount, warnCount, failCount }) {
166
+ const lines = [];
167
+ lines.push('');
168
+ lines.push('=====================================');
169
+ lines.push(' ostup doctor: self-diagnosis');
170
+ lines.push('=====================================');
171
+ lines.push('');
172
+ for (const f of findings) {
173
+ const sym = f.level === 'ok' ? ' OK ' : f.level === 'warn' ? ' WARN ' : ' FAIL ';
174
+ lines.push(`[${sym}] ${f.label.padEnd(28)} ${f.detail || ''}`);
175
+ }
176
+ lines.push('');
177
+ lines.push(`Summary: ${okCount} OK, ${warnCount} WARN, ${failCount} FAIL`);
178
+ lines.push('');
179
+ if (failCount > 0) {
180
+ lines.push('Fix the FAILs before running `ostup init`. WARNs are optional.');
181
+ } else if (warnCount > 0) {
182
+ lines.push('No blockers. WARNs are optional improvements.');
183
+ } else {
184
+ lines.push('Everything clean. Ready to scaffold.');
185
+ }
186
+ lines.push('');
187
+ return lines.join('\n');
188
+ }
package/src/exec.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // exec.mjs: uniform shell call wrapper with logging, dry-run capture, and step-aware error formatting.
2
2
  import { execa } from 'execa';
3
+ import { traceStep, getLogPath } from './tracer.mjs';
3
4
 
4
5
  const HINTS = {
5
6
  'gh repo create': 'Confirm the repo name is not already taken under this owner.',
@@ -56,19 +57,55 @@ export async function run(step, cmd, args = [], opts = {}) {
56
57
  return { stdout: '', stderr: '', exitCode: 0, dryRun: true };
57
58
  }
58
59
  if (!_quiet) process.stdout.write(`[${step}] start: ${display}\n`);
60
+ const start = Date.now();
59
61
  try {
60
- return await _runner(cmd, args, { ...opts });
62
+ const result = await _runner(cmd, args, { ...opts });
63
+ const duration = Date.now() - start;
64
+ await traceStep({
65
+ step, cmd, args,
66
+ stdout: result?.stdout || '',
67
+ stderr: result?.stderr || '',
68
+ exitCode: result?.exitCode || 0,
69
+ durationMs: duration,
70
+ });
71
+ return result;
61
72
  } catch (err) {
62
- const stderr = (err && (err.stderr || err.shortMessage || err.message) || '').toString().trim();
63
- process.stderr.write(
64
- [
65
- `[${step}] FAIL`,
66
- ` Command: ${display}`,
67
- ` Stderr: ${stderr || '(empty)'}`,
68
- ` Fix: ${hintFor(cmd, args)}`,
69
- 'Exit 1.',
70
- ].join('\n') + '\n'
71
- );
73
+ const duration = Date.now() - start;
74
+ const stdout = (err && err.stdout || '').toString();
75
+ const stderr = (err && err.stderr || '').toString();
76
+ const message = (err && (err.shortMessage || err.message) || '').toString().trim();
77
+
78
+ // Capture both stdout AND stderr (was stderr-only). Some tools (vercel, gh) output errors to stdout.
79
+ const stdoutTail = stdout.split('\n').slice(-15).join('\n').trim();
80
+ const stderrTail = stderr.split('\n').slice(-15).join('\n').trim();
81
+ const logPath = getLogPath();
82
+
83
+ const lines = [`[${step}] FAIL`, ` Command: ${display}`];
84
+ if (stderrTail) {
85
+ lines.push(` Stderr (last 15 lines):`);
86
+ for (const l of stderrTail.split('\n')) lines.push(` ${l}`);
87
+ }
88
+ if (stdoutTail) {
89
+ lines.push(` Stdout (last 15 lines):`);
90
+ for (const l of stdoutTail.split('\n')) lines.push(` ${l}`);
91
+ }
92
+ if (!stderrTail && !stdoutTail && message) {
93
+ lines.push(` Error: ${message}`);
94
+ }
95
+ lines.push(` Fix: ${hintFor(cmd, args)}`);
96
+ if (logPath) {
97
+ lines.push(` Log: ${logPath}`);
98
+ }
99
+ lines.push('Exit 1.');
100
+ process.stderr.write(lines.join('\n') + '\n');
101
+
102
+ await traceStep({
103
+ step, cmd, args,
104
+ stdout, stderr,
105
+ exitCode: err?.exitCode || 1,
106
+ durationMs: duration,
107
+ });
108
+
72
109
  const e = new Error(`step ${step} failed`);
73
110
  e.code = 'STEP_FAILED';
74
111
  e.step = step;
package/src/mvp-flow.mjs CHANGED
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { resolve, join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { preflightOrExit } from './preflight.mjs';
8
+ import { runBootstrap } from './bootstrap.mjs';
8
9
  import { runProjectPrompts, printSummary as printAnswersSummary, confirmProceed } from './project-prompts.mjs';
9
10
  import { ensureCredentials } from './credential-prompts.mjs';
10
11
  import { maybeScaffoldStack } from './steps/next-app.mjs';
@@ -34,6 +35,10 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
34
35
  throw err;
35
36
  }
36
37
 
38
+ if (!flags.skipBootstrap) {
39
+ await runBootstrap({ flags });
40
+ }
41
+
37
42
  preflightOrExit();
38
43
 
39
44
  let brief = null;
@@ -79,7 +84,7 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
79
84
  }
80
85
  }
81
86
 
82
- const creds = await ensureCredentials({ stack: answers.stack });
87
+ const creds = await ensureCredentials({ stack: answers.stack, flags });
83
88
 
84
89
  const targetDir = resolve(cwd, answers.projectName);
85
90
  await ensureFreshTarget(targetDir, flags.force);
package/src/preflight.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  // preflight.mjs: verify required binaries (node, git, gh, vercel) are installed.
2
+ // CHECKS now comes from tool-registry.mjs — single source of truth shared with bootstrap and doctor.
2
3
  import { execSync } from 'node:child_process';
4
+ import { CHECKS as REGISTRY_CHECKS } from './tool-registry.mjs';
3
5
 
4
- export const CHECKS = [
5
- { name: 'Node', cmd: ['node', '--version'], fix: 'Install Node 20+ from https://nodejs.org', minNodeMajor: 20 },
6
- { name: 'Git', cmd: ['git', '--version'], fix: 'Install git from https://git-scm.com' },
7
- { name: 'GitHub CLI', cmd: ['gh', '--version'], fix: 'Install: brew install gh' },
8
- { name: 'Vercel CLI', cmd: ['vercel', '--version'], fix: 'Install: npm i -g vercel' },
9
- ];
6
+ export const CHECKS = REGISTRY_CHECKS;
10
7
 
11
8
  function defaultRunner(cmd) {
12
9
  return execSync(cmd.join(' '), { stdio: ['ignore', 'pipe', 'ignore'] }).toString();