@oaklandzoo/ostup 0.8.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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # ostup
2
2
 
3
3
  Scaffold a new client project with one command. Get a live GitHub repo
4
- and live Vercel deploy URL in under five minutes.
4
+ and live Vercel deploy URL in under ten minutes, even from a stock
5
+ computer.
5
6
 
6
7
  ## What this does
7
8
 
@@ -17,60 +18,79 @@ When you run this tool, it will:
17
18
  prior materials (research, reference repos, screenshots, brand
18
19
  assets) you want the agent to have on hand
19
20
 
20
- ## What you need before running it
21
+ ## Quick Start
21
22
 
22
- ### One-time machine setup (you only do this once)
23
+ Pick the path that matches your computer.
23
24
 
24
- 1. Install Node.js 20 or higher. Download from https://nodejs.org
25
+ ### Mac (beginner stock computer with nothing installed)
25
26
 
26
- 2. Install GitHub CLI. In Terminal:
27
- ```
28
- brew install gh
29
- ```
30
- Then log in:
31
- ```
32
- gh auth login
33
- ```
34
- Choose: GitHub.com, then HTTPS, then "Login with a web browser."
35
- Follow the prompts.
27
+ Open Terminal and paste:
36
28
 
37
- 3. Install Vercel CLI. In Terminal:
38
- ```
39
- npm install -g vercel
40
- ```
41
- Then log in:
42
- ```
43
- vercel login
44
- ```
45
- Choose your email login method.
29
+ ```
30
+ /bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)"
31
+ ```
32
+
33
+ That script installs Homebrew and Node (if missing), then runs `ostup init`,
34
+ which installs Git, GitHub CLI, and Vercel CLI and walks you through signing
35
+ in. Total time: about 5-10 minutes the first time, mostly download waits.
46
36
 
47
- ### Accounts you need
37
+ To pre-set flags (advanced):
48
38
 
49
- - GitHub account: free at https://github.com/signup
50
- - Vercel account: free at https://vercel.com/signup (sign in with GitHub)
39
+ ```
40
+ /bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)" -- --yes --profile=default
41
+ ```
51
42
 
52
- If you do not have these accounts, the tool will pause and walk you
53
- through creating them when you reach that step.
43
+ ### Windows (beginner stock computer with nothing installed)
44
+
45
+ Open PowerShell and paste:
46
+
47
+ ```
48
+ irm https://ostup-install.vercel.app/install.ps1 | iex
49
+ ```
54
50
 
55
- ## Install
51
+ That script installs Node via WinGet (if missing), then runs `ostup init`,
52
+ which installs Git, GitHub CLI, and Vercel CLI and walks you through signing
53
+ in. Total time: about 5-10 minutes the first time.
56
54
 
57
- The fastest path:
55
+ To pre-set flags (advanced):
58
56
 
59
57
  ```
60
- npx @oaklandzoo/ostup init
58
+ irm https://ostup-install.vercel.app/install.ps1 -OutFile install.ps1
59
+ ./install.ps1 --yes --profile=default
61
60
  ```
62
61
 
63
- That is the whole install. `npx` downloads ostup on first run, nothing
64
- to install permanently.
62
+ ### Developer (you already have Node 20+ and npm)
63
+
64
+ ```
65
+ npx @oaklandzoo/ostup@latest init
66
+ ```
67
+
68
+ ostup will detect any missing tools (`git`, `gh`, `vercel`) and offer to
69
+ install them. Pass `--skip-bootstrap` to bypass detection or `--no-install`
70
+ to see what would be installed without making changes.
65
71
 
66
72
  If you'd rather install it globally so the command is just `ostup`:
67
73
 
68
74
  ```
69
75
  npm install -g @oaklandzoo/ostup
70
- ostup --version # should print 0.1.0
71
76
  ostup init
72
77
  ```
73
78
 
79
+ ## What ostup needs (handled for you by the beginner installer)
80
+
81
+ The beginner installer scripts above handle this list automatically.
82
+ This is what they're installing under the hood:
83
+
84
+ - Node.js 20+
85
+ - Git
86
+ - GitHub CLI (`gh`)
87
+ - Vercel CLI (`vercel`)
88
+ - A free GitHub account (https://github.com/signup) — sign in during the flow
89
+ - A free Vercel account (https://vercel.com/signup) — sign in during the flow
90
+
91
+ If you do not have GitHub or Vercel accounts yet, the tool pauses and
92
+ walks you through creating them when you reach those steps.
93
+
74
94
  ### Alternate: install from source
75
95
 
76
96
  If you cloned https://github.com/DubsFan/goodshin and want to run from
@@ -168,16 +188,17 @@ you do not have to repeat them every session.
168
188
 
169
189
  | Problem | Fix |
170
190
  |---|---|
171
- | "node: command not found" | Install Node 20+ from https://nodejs.org |
172
- | "gh: command not found" | Run `brew install gh` |
173
- | "vercel: command not found" | Run `npm install -g vercel` |
174
- | "gh auth required" | Run `gh auth login` |
175
- | "vercel auth required" | Run `vercel login` |
191
+ | "command not found" for node/gh/vercel | Run the beginner installer for your OS (top of this README). Or run `ostup bootstrap` if you already have Node. |
192
+ | "gh auth required" | Re-run `ostup init` and pick "Sign in to GitHub in your browser". Or run `gh auth login`. |
193
+ | "vercel auth required" | Re-run `ostup init` and pick "Sign in to Vercel in your browser". Or run `vercel login`. |
176
194
  | "Project name invalid" | Use only lowercase letters, numbers, and hyphens |
177
195
  | "Repo already exists" | Pick a different project name |
178
196
  | Vercel deploy hangs more than 5 minutes | Cancel with Ctrl-C, run again |
179
197
  | Deploy URL returns 401 | The tool tried to auto-disable Vercel deployment protection but could not. Open Vercel dashboard, find your project, Settings, Deployment Protection, Disable. |
180
198
  | "ingest path not found" | The path you gave does not exist. Re-run and provide a valid absolute or relative path. |
199
+ | Beginner installer failed | The bash and PowerShell installers log to `~/.ostup/logs/install-*.log`. The ostup CLI logs to `~/.ostup/logs/init-*.log`. Open the most recent log for the failing command's stderr. |
200
+ | "BOOTSTRAP_FAILED: Homebrew missing" | You ran `npx ostup init` on a Mac without Homebrew. Run the Mac beginner installer at the top of this README instead. |
201
+ | "BOOTSTRAP_FAILED: WinGet missing" | Update App Installer from the Microsoft Store: `ms-windows-store://pdp/?productid=9NBLGGH4NNS1`. |
181
202
 
182
203
  ## Advanced: API tokens instead of interactive login
183
204
 
package/bin/cli.mjs CHANGED
@@ -11,7 +11,7 @@ loadDotEnv();
11
11
 
12
12
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
13
 
14
- const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor']);
14
+ const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor', 'bootstrap']);
15
15
 
16
16
  async function readPkg() {
17
17
  const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
@@ -46,6 +46,8 @@ function parseArgs(argv) {
46
46
  else if (a.startsWith('--output=')) flags.output = a.slice('--output='.length);
47
47
  else if (a === '--white-label') flags.whiteLabel = true;
48
48
  else if (a === '--no-log') flags.noLog = true;
49
+ else if (a === '--skip-bootstrap') flags.skipBootstrap = true;
50
+ else if (a === '--no-install') flags.noInstall = true;
49
51
  else if (a.startsWith('-')) {
50
52
  process.stderr.write(`unknown flag: ${a}\n`);
51
53
  process.exit(1);
@@ -61,14 +63,21 @@ function printHelp() {
61
63
  [
62
64
  'ostup: scaffold a new repo with the Ostup Agent Kit plus GitHub and Vercel.',
63
65
  '',
66
+ 'First time on this computer? Use the beginner installer for your OS:',
67
+ ' Mac: /bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)"',
68
+ ' Windows: irm https://ostup-install.vercel.app/install.ps1 | iex',
69
+ '',
70
+ 'Already have Node 20+ and npm? Use this CLI directly.',
71
+ '',
64
72
  'Usage:',
65
73
  ' ostup <command> [flags]',
66
74
  '',
67
75
  'Commands:',
68
76
  ' init Scaffold a new project (interactive or with --yes).',
77
+ ' bootstrap Install missing tools (git, gh, vercel) on this machine.',
69
78
  ' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
70
79
  ' export-pro Bundle brief + brand + content + initial PRD into a ZIP for client handoff.',
71
- ' doctor Self-diagnosis: preflight + auth + permissions + disk + Chrome. Read-only.',
80
+ ' doctor Self-diagnosis: tool detection + auth + permissions + disk + Chrome. Read-only.',
72
81
  ' update Refresh bundled templates from the pinned source.',
73
82
  '',
74
83
  'Flags for `ostup init`:',
@@ -82,6 +91,12 @@ function printHelp() {
82
91
  ' --white-label Strip OSTUP / Goodshin attribution from generated docs (Studio tier).',
83
92
  ' --kit-only Drop the markdown kit into a target dir, no GitHub or Vercel.',
84
93
  ' --config <path> Read .ostup-config.yml from this path (kit-only mode).',
94
+ ' --skip-bootstrap Skip the in-CLI tool detection / install step (advanced).',
95
+ ' --no-install Detect missing tools, print the install plan, exit without installing.',
96
+ '',
97
+ 'Flags for `ostup bootstrap`:',
98
+ ' --yes, -y Auto-accept the install prompt and choose browser auth where possible.',
99
+ ' --no-install Print the install plan, exit without installing.',
85
100
  '',
86
101
  'Flags for `ostup brief`:',
87
102
  ' --brief <path> Load brief.json from <path> instead of running the intake.',
@@ -187,6 +202,24 @@ if (subcommand === 'doctor') {
187
202
  }
188
203
  }
189
204
 
205
+ if (subcommand === 'bootstrap') {
206
+ const { runBootstrapStandalone } = await import('../src/bootstrap.mjs');
207
+ try {
208
+ await runBootstrapStandalone({ flags });
209
+ process.exit(0);
210
+ } catch (err) {
211
+ process.stderr.write(`${err.message}\n`);
212
+ const userErrors = new Set([
213
+ 'NO_TTY_BOOTSTRAP',
214
+ 'BOOTSTRAP_DECLINED',
215
+ 'BOOTSTRAP_FAILED',
216
+ 'BOOTSTRAP_VERIFY_FAILED',
217
+ 'UNSUPPORTED_OS_INSTALL',
218
+ ]);
219
+ process.exit(userErrors.has(err.code) ? 1 : 2);
220
+ }
221
+ }
222
+
190
223
  // subcommand === 'init'
191
224
  if (flags.kitOnly) {
192
225
  const { scaffold } = await import('../src/scaffold.mjs');
@@ -224,6 +257,11 @@ try {
224
257
  'INGEST_PATH_NOT_FOUND',
225
258
  'BRIEF_NOT_FOUND',
226
259
  'BRIEF_INVALID',
260
+ 'NO_TTY_BOOTSTRAP',
261
+ 'BOOTSTRAP_DECLINED',
262
+ 'BOOTSTRAP_FAILED',
263
+ 'BOOTSTRAP_VERIFY_FAILED',
264
+ 'UNSUPPORTED_OS_INSTALL',
227
265
  ]);
228
266
  process.exit(userErrors.has(err.code) ? 1 : 2);
229
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Scaffolds a new repo with the Ostup Agent Kit pre-installed: slash commands, doc templates, and a clean working state.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "files": [
45
45
  "bin/",
46
46
  "src/",
47
+ "scripts/",
47
48
  "templates/",
48
49
  "LICENSE",
49
50
  "README.md"
@@ -0,0 +1,115 @@
1
+ # ostup beginner installer for Windows.
2
+ #
3
+ # Stage 1: get this PC to "can run Node 20+". That means installing Node via WinGet
4
+ # (if missing or below v20). Nothing else. Git, GitHub CLI, Vercel CLI, auth, scaffold,
5
+ # and deploy all happen in stage 2 — the ostup CLI's in-CLI bootstrap.
6
+ #
7
+ # Usage:
8
+ # irm https://ostup-install.vercel.app/install.ps1 | iex
9
+ #
10
+ # Forward flags to `ostup init` (download then run):
11
+ # irm https://ostup-install.vercel.app/install.ps1 -OutFile install.ps1
12
+ # ./install.ps1 --yes --profile=default
13
+
14
+ #Requires -Version 5.1
15
+ $ErrorActionPreference = 'Stop'
16
+
17
+ # --- logging -----------------------------------------------------------------
18
+ $LogDir = Join-Path $env:USERPROFILE '.ostup\logs'
19
+ New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
20
+ $ts = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
21
+ $LogFile = Join-Path $LogDir "install-$ts.log"
22
+ try { Start-Transcript -Path $LogFile -ErrorAction Stop | Out-Null } catch {}
23
+
24
+ # --- detect ------------------------------------------------------------------
25
+ $hasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
26
+ $hasNodeOk = $false
27
+ $nodeVerDetected = ''
28
+ if (Get-Command node -ErrorAction SilentlyContinue) {
29
+ try {
30
+ $nodeVerDetected = (& node --version).Trim()
31
+ if ($nodeVerDetected -match '^v(\d+)') {
32
+ $nodeMajor = [int]$Matches[1]
33
+ if ($nodeMajor -ge 20) { $hasNodeOk = $true }
34
+ }
35
+ } catch {}
36
+ }
37
+
38
+ # --- WinGet gate -------------------------------------------------------------
39
+ if (-not $hasWinget) {
40
+ Write-Host ''
41
+ Write-Host 'WinGet (Microsoft App Installer) is required to install Node automatically.'
42
+ Write-Host 'On Windows 11, WinGet is built in. On Windows 10, you may need to update App Installer:'
43
+ Write-Host ' ms-windows-store://pdp/?productid=9NBLGGH4NNS1'
44
+ Write-Host 'Open that URL in your browser, install or update App Installer, then re-run.'
45
+ try { Stop-Transcript | Out-Null } catch {}
46
+ exit 1
47
+ }
48
+
49
+ $needNode = -not $hasNodeOk
50
+
51
+ # --- explainer + consent (only when something is missing) -------------------
52
+ if ($needNode) {
53
+ Write-Host ''
54
+ Write-Host "You're about to set up your PC to run modern web tools."
55
+ Write-Host ''
56
+ if ($nodeVerDetected) {
57
+ Write-Host " 1. Node.js - The engine that runs the website setup tool (ostup)."
58
+ Write-Host " Your current Node $nodeVerDetected is too old; we need v20+."
59
+ } else {
60
+ Write-Host " 1. Node.js - The engine that runs the website setup tool (ostup)."
61
+ }
62
+ Write-Host ''
63
+ Write-Host 'Node will install via WinGet (Microsoft package manager). Windows may show a prompt'
64
+ Write-Host 'asking you to allow the install.'
65
+ Write-Host ''
66
+ Write-Host 'After this, ostup will continue and install Git, GitHub CLI, and Vercel CLI,'
67
+ Write-Host 'then walk you through signing in.'
68
+ Write-Host ''
69
+ Write-Host 'Total time: about 3-5 minutes for this step. Requires an internet connection.'
70
+ Write-Host ''
71
+
72
+ if ([Environment]::UserInteractive) {
73
+ $yn = Read-Host 'Continue? [Y/n]'
74
+ if ($yn -and $yn -notmatch '^(y|yes)$') {
75
+ Write-Host 'Cancelled. Re-run when ready.'
76
+ try { Stop-Transcript | Out-Null } catch {}
77
+ exit 1
78
+ }
79
+ } else {
80
+ Write-Host '(non-interactive session; proceeding)'
81
+ }
82
+
83
+ Write-Host ''
84
+ Write-Host 'Installing Node via WinGet...'
85
+ & winget install -e --id OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements
86
+ if ($LASTEXITCODE -ne 0) {
87
+ Write-Host ''
88
+ Write-Host "ERROR: WinGet returned exit code $LASTEXITCODE."
89
+ Write-Host "Log: $LogFile"
90
+ try { Stop-Transcript | Out-Null } catch {}
91
+ exit 1
92
+ }
93
+
94
+ # Patch PATH for this PowerShell session so npx works without restarting.
95
+ $env:Path = "C:\Program Files\nodejs;$env:Path"
96
+
97
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
98
+ Write-Host 'ERROR: Node install completed but node is not on PATH in this session.'
99
+ Write-Host 'Open a new PowerShell window and re-run, or check the log:'
100
+ Write-Host " $LogFile"
101
+ try { Stop-Transcript | Out-Null } catch {}
102
+ exit 1
103
+ }
104
+ $postVer = (& node --version).Trim()
105
+ Write-Host "Node $postVer installed."
106
+ }
107
+
108
+ # --- hand off to the ostup CLI ----------------------------------------------
109
+ Write-Host ''
110
+ Write-Host 'Stage 1 complete. Continuing with ostup...'
111
+ Write-Host ''
112
+
113
+ try { Stop-Transcript | Out-Null } catch {}
114
+ & npx '@oaklandzoo/ostup@latest' init @args
115
+ exit $LASTEXITCODE
@@ -0,0 +1,144 @@
1
+ #!/bin/bash
2
+ # ostup beginner installer for macOS.
3
+ #
4
+ # Stage 1: get this Mac to "can run Node 20+". That means installing Homebrew (if missing)
5
+ # and Node (if missing or below v20). Nothing else. Git, GitHub CLI, Vercel CLI, auth,
6
+ # scaffold, and deploy all happen in stage 2 — the ostup CLI's in-CLI bootstrap.
7
+ #
8
+ # Usage:
9
+ # /bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)"
10
+ #
11
+ # Forward flags to `ostup init`:
12
+ # /bin/bash -c "$(curl -fsSL ...install.sh)" -- --yes --profile=default
13
+
14
+ set -euo pipefail
15
+
16
+ # --- logging -----------------------------------------------------------------
17
+ LOG_DIR="${HOME}/.ostup/logs"
18
+ mkdir -p "$LOG_DIR"
19
+ LOG_FILE="${LOG_DIR}/install-$(date -u +%Y%m%dT%H%M%SZ).log"
20
+ exec > >(tee -a "$LOG_FILE") 2>&1
21
+
22
+ # --- platform check ----------------------------------------------------------
23
+ UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
24
+ if [ "$UNAME_S" != "Darwin" ]; then
25
+ cat <<EOF
26
+ This installer is for macOS only.
27
+
28
+ On Windows, open PowerShell and run:
29
+ irm https://ostup-install.vercel.app/install.ps1 | iex
30
+
31
+ On Linux, install Node 20+ from https://nodejs.org or your package manager, then run:
32
+ npx @oaklandzoo/ostup@latest init
33
+ EOF
34
+ exit 1
35
+ fi
36
+
37
+ # --- detect ------------------------------------------------------------------
38
+ HAS_BREW=0
39
+ HAS_NODE_OK=0
40
+ NODE_VER_DETECTED=""
41
+
42
+ if command -v brew >/dev/null 2>&1; then
43
+ HAS_BREW=1
44
+ fi
45
+
46
+ if command -v node >/dev/null 2>&1; then
47
+ NODE_VER_DETECTED="$(node --version 2>/dev/null || echo '')"
48
+ NODE_MAJOR="$(echo "$NODE_VER_DETECTED" | sed -E 's/^v([0-9]+).*/\1/')"
49
+ if [ -n "$NODE_MAJOR" ] && [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then
50
+ HAS_NODE_OK=1
51
+ fi
52
+ fi
53
+
54
+ # --- explainer + consent (only when something is missing) --------------------
55
+ NEED_BREW=$(( 1 - HAS_BREW ))
56
+ NEED_NODE=$(( 1 - HAS_NODE_OK ))
57
+
58
+ if [ "$NEED_BREW" -eq 1 ] || [ "$NEED_NODE" -eq 1 ]; then
59
+ echo ""
60
+ echo "You're about to set up your Mac to run modern web tools."
61
+ echo ""
62
+ i=1
63
+ if [ "$NEED_BREW" -eq 1 ]; then
64
+ echo " $i. Homebrew - Apple's package manager. Used to install Node."
65
+ i=$((i + 1))
66
+ fi
67
+ if [ "$NEED_NODE" -eq 1 ]; then
68
+ if [ -n "$NODE_VER_DETECTED" ]; then
69
+ echo " $i. Node.js - The engine that runs the website setup tool (ostup)."
70
+ echo " Your current Node $NODE_VER_DETECTED is too old; we need v20+."
71
+ else
72
+ echo " $i. Node.js - The engine that runs the website setup tool (ostup)."
73
+ fi
74
+ fi
75
+ echo ""
76
+ if [ "$NEED_BREW" -eq 1 ]; then
77
+ echo "You'll be asked for your Mac login password once (for Homebrew). It goes"
78
+ echo "straight to your Mac, not to ostup - we never see or store it."
79
+ echo ""
80
+ fi
81
+ echo "After this, ostup will continue and install GitHub CLI and Vercel CLI,"
82
+ echo "then walk you through signing in."
83
+ echo ""
84
+ echo "Total time: about 3-5 minutes for this step, depending on your internet."
85
+ echo "Requires an internet connection."
86
+ echo ""
87
+ if [ -t 0 ]; then
88
+ read -r -p "Continue? [Y/n] " yn
89
+ case "$yn" in
90
+ ""|y|Y|yes|YES) ;;
91
+ *)
92
+ echo "Cancelled. Re-run the command when ready."
93
+ exit 1
94
+ ;;
95
+ esac
96
+ else
97
+ echo "(no terminal detected; proceeding non-interactively)"
98
+ fi
99
+ fi
100
+
101
+ # --- install Homebrew if missing --------------------------------------------
102
+ if [ "$HAS_BREW" -eq 0 ]; then
103
+ echo ""
104
+ echo "[1/2] Installing Homebrew..."
105
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
106
+ # Add brew to current shell's PATH.
107
+ if [ -x /opt/homebrew/bin/brew ]; then
108
+ eval "$(/opt/homebrew/bin/brew shellenv)"
109
+ elif [ -x /usr/local/bin/brew ]; then
110
+ eval "$(/usr/local/bin/brew shellenv)"
111
+ else
112
+ echo "ERROR: Homebrew installation appears to have failed (brew not found at /opt/homebrew/bin or /usr/local/bin)."
113
+ echo "Log: $LOG_FILE"
114
+ exit 1
115
+ fi
116
+ echo "Homebrew installed."
117
+ fi
118
+
119
+ # --- install Node if missing or too old --------------------------------------
120
+ if [ "$HAS_NODE_OK" -eq 0 ]; then
121
+ echo ""
122
+ echo "[2/2] Installing Node via Homebrew..."
123
+ brew install node
124
+ # Confirm node is now on PATH and at ≥20.
125
+ if ! command -v node >/dev/null 2>&1; then
126
+ echo "ERROR: Node install completed but 'node' is not on PATH. Open a new Terminal window and re-run."
127
+ echo "Log: $LOG_FILE"
128
+ exit 1
129
+ fi
130
+ POST_VER="$(node --version)"
131
+ POST_MAJOR="$(echo "$POST_VER" | sed -E 's/^v([0-9]+).*/\1/')"
132
+ if [ "$POST_MAJOR" -lt 20 ] 2>/dev/null; then
133
+ echo "ERROR: Node $POST_VER installed but version is still below 20. Run: brew upgrade node"
134
+ echo "Log: $LOG_FILE"
135
+ exit 1
136
+ fi
137
+ echo "Node $POST_VER installed."
138
+ fi
139
+
140
+ # --- hand off to the ostup CLI ----------------------------------------------
141
+ echo ""
142
+ echo "Stage 1 complete. Continuing with ostup..."
143
+ echo ""
144
+ exec npx @oaklandzoo/ostup@latest init "$@"
@@ -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 CHANGED
@@ -5,7 +5,7 @@ import { execSync } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { join, resolve, dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { preflight } from './preflight.mjs';
8
+ import { detectAll, installCommandFor } from './tool-registry.mjs';
9
9
  import { checkGithubAuth, checkVercelAuth } from './credential-prompts.mjs';
10
10
 
11
11
  function runOk(cmd) {
@@ -38,18 +38,20 @@ export async function runDoctor() {
38
38
  else warnCount++;
39
39
  }
40
40
 
41
- // === Preflight ===
42
- const pf = preflight();
43
- if (pf.ok) {
44
- report('ok', 'preflight', 'all required binaries present (node 20+, git, gh, vercel)');
45
- } else {
46
- report('fail', `preflight: ${pf.failed?.name || 'unknown'}`, pf.reason || pf.failed?.fix || '(no detail)');
47
- }
48
-
49
- // === Node version detail ===
50
- const nodeVer = runCapture('node --version');
51
- if (nodeVer) {
52
- report('ok', 'node', nodeVer);
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);
53
55
  }
54
56
 
55
57
  // === Git config ===
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();
@@ -0,0 +1,164 @@
1
+ // tool-registry.mjs: single source of truth for the binaries ostup needs (node, git, gh, vercel).
2
+ // Detection logic lives here. Install commands per platform live here. Anything that wants to know
3
+ // "what does the user need installed" reads from REGISTRY. Avoids drift between preflight, bootstrap,
4
+ // and doctor.
5
+ import { execSync } from 'node:child_process';
6
+
7
+ export const REGISTRY = [
8
+ {
9
+ id: 'node',
10
+ name: 'Node',
11
+ blurb: 'The engine that runs the website setup tool (ostup).',
12
+ detect: { cmd: 'node', args: ['--version'], minMajor: 20 },
13
+ fix: 'Install Node 20+ from https://nodejs.org',
14
+ install: {
15
+ darwin: {
16
+ kind: 'manual',
17
+ url: 'https://nodejs.org',
18
+ note: 'The Mac installer (install.sh) handles Node via Homebrew. Re-run the bash installer to get it.',
19
+ },
20
+ linux: {
21
+ kind: 'manual',
22
+ url: 'https://nodejs.org',
23
+ note: 'Install Node 20+ from nodejs.org or your package manager.',
24
+ },
25
+ win32: {
26
+ kind: 'manual',
27
+ url: 'https://nodejs.org',
28
+ note: 'The Windows installer (install.ps1) handles Node via WinGet. Re-run the PowerShell installer to get it.',
29
+ },
30
+ },
31
+ },
32
+ {
33
+ id: 'git',
34
+ name: 'Git',
35
+ blurb: 'Saves versions of your project as you work.',
36
+ detect: { cmd: 'git', args: ['--version'] },
37
+ fix: 'Install git from https://git-scm.com',
38
+ install: {
39
+ darwin: {
40
+ kind: 'brew',
41
+ cmd: 'brew',
42
+ args: ['install', 'git'],
43
+ postPath: ['/opt/homebrew/bin', '/usr/local/bin'],
44
+ },
45
+ linux: {
46
+ kind: 'apt',
47
+ cmd: 'apt-get',
48
+ args: ['install', '-y', 'git'],
49
+ requiresRoot: true,
50
+ url: 'https://git-scm.com',
51
+ },
52
+ win32: {
53
+ kind: 'winget',
54
+ cmd: 'winget',
55
+ args: ['install', '-e', '--id', 'Git.Git', '--accept-package-agreements', '--accept-source-agreements'],
56
+ postPath: ['C:\\Program Files\\Git\\cmd'],
57
+ },
58
+ },
59
+ },
60
+ {
61
+ id: 'gh',
62
+ name: 'GitHub CLI',
63
+ blurb: 'Saves your project on GitHub so it is not just on your laptop.',
64
+ detect: { cmd: 'gh', args: ['--version'] },
65
+ fix: 'Install: brew install gh',
66
+ install: {
67
+ darwin: {
68
+ kind: 'brew',
69
+ cmd: 'brew',
70
+ args: ['install', 'gh'],
71
+ postPath: ['/opt/homebrew/bin', '/usr/local/bin'],
72
+ },
73
+ linux: {
74
+ kind: 'apt',
75
+ cmd: 'apt-get',
76
+ args: ['install', '-y', 'gh'],
77
+ requiresRoot: true,
78
+ url: 'https://cli.github.com',
79
+ },
80
+ win32: {
81
+ kind: 'winget',
82
+ cmd: 'winget',
83
+ args: ['install', '-e', '--id', 'GitHub.cli', '--accept-package-agreements', '--accept-source-agreements'],
84
+ postPath: ['C:\\Program Files\\GitHub CLI'],
85
+ },
86
+ },
87
+ },
88
+ {
89
+ id: 'vercel',
90
+ name: 'Vercel CLI',
91
+ blurb: 'Publishes your site to a public URL on the internet.',
92
+ detect: { cmd: 'vercel', args: ['--version'] },
93
+ fix: 'Install: npm i -g vercel',
94
+ install: {
95
+ darwin: {
96
+ kind: 'npm-g',
97
+ cmd: 'npm',
98
+ args: ['install', '-g', 'vercel'],
99
+ },
100
+ linux: {
101
+ kind: 'npm-g',
102
+ cmd: 'npm',
103
+ args: ['install', '-g', 'vercel'],
104
+ note: 'Stock apt-installed Node may need sudo for global installs.',
105
+ },
106
+ win32: {
107
+ kind: 'npm-g',
108
+ cmd: 'npm',
109
+ args: ['install', '-g', 'vercel'],
110
+ note: '%APPDATA%\\npm is on PATH by default with WinGet-installed Node.',
111
+ },
112
+ },
113
+ },
114
+ ];
115
+
116
+ // Back-compat shape for preflight.mjs (and test/preflight.test.mjs which imports CHECKS).
117
+ // Keep the array shape: { name, cmd: [bin, ...args], fix, minNodeMajor? }.
118
+ export const CHECKS = REGISTRY.map((tool) => ({
119
+ name: tool.name,
120
+ cmd: [tool.detect.cmd, ...(tool.detect.args || [])],
121
+ fix: tool.fix,
122
+ ...(tool.detect.minMajor ? { minNodeMajor: tool.detect.minMajor } : {}),
123
+ }));
124
+
125
+ function defaultRunner(cmd, args = []) {
126
+ return execSync([cmd, ...args].join(' '), { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
127
+ }
128
+
129
+ export function detectAll({ runner = defaultRunner } = {}) {
130
+ const found = [];
131
+ const missing = [];
132
+ for (const tool of REGISTRY) {
133
+ let out;
134
+ try {
135
+ out = runner(tool.detect.cmd, tool.detect.args || []);
136
+ } catch {
137
+ missing.push({ tool, reason: 'not-found' });
138
+ continue;
139
+ }
140
+ if (tool.detect.minMajor) {
141
+ const major = parseInt(String(out).replace(/^v/, '').split('.')[0], 10);
142
+ if (!Number.isFinite(major) || major < tool.detect.minMajor) {
143
+ missing.push({ tool, reason: 'version-too-old', detected: String(out).trim() });
144
+ continue;
145
+ }
146
+ }
147
+ found.push({ tool, version: String(out).trim() });
148
+ }
149
+ return { found, missing };
150
+ }
151
+
152
+ export function installCommandFor(toolOrId, platform = process.platform) {
153
+ const tool = typeof toolOrId === 'string'
154
+ ? REGISTRY.find((t) => t.id === toolOrId)
155
+ : toolOrId;
156
+ if (!tool) return null;
157
+ const plan = tool.install[platform];
158
+ if (plan) return plan;
159
+ return {
160
+ kind: 'manual',
161
+ url: tool.install.linux?.url || tool.install.darwin?.url || 'https://nodejs.org',
162
+ note: `No automatic install for platform ${platform}. Install ${tool.name} manually.`,
163
+ };
164
+ }