@oaklandzoo/ostup 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -39
- package/bin/cli.mjs +40 -2
- package/package.json +2 -1
- package/scripts/install.ps1 +115 -0
- package/scripts/install.sh +144 -0
- package/src/bootstrap.mjs +347 -0
- package/src/credential-prompts.mjs +80 -15
- package/src/doctor.mjs +15 -13
- package/src/mvp-flow.mjs +6 -1
- package/src/preflight.mjs +3 -6
- package/src/tool-registry.mjs +164 -0
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
|
|
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
|
-
##
|
|
21
|
+
## Quick Start
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
Pick the path that matches your computer.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
### Mac (beginner — stock computer with nothing installed)
|
|
25
26
|
|
|
26
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
37
|
+
To pre-set flags (advanced):
|
|
48
38
|
|
|
49
|
-
|
|
50
|
-
-
|
|
39
|
+
```
|
|
40
|
+
/bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)" -- --yes --profile=default
|
|
41
|
+
```
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
To pre-set flags (advanced):
|
|
58
56
|
|
|
59
57
|
```
|
|
60
|
-
|
|
58
|
+
irm https://ostup-install.vercel.app/install.ps1 -OutFile install.ps1
|
|
59
|
+
./install.ps1 --yes --profile=default
|
|
61
60
|
```
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
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,18 @@ you do not have to repeat them every session.
|
|
|
168
188
|
|
|
169
189
|
| Problem | Fix |
|
|
170
190
|
|---|---|
|
|
171
|
-
| "
|
|
172
|
-
| "gh
|
|
173
|
-
| "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`. |
|
|
202
|
+
| "/opt/homebrew/Cellar is not writable" | You are on a shared Mac where Homebrew was installed by another user. 0.9.1+ handles this automatically by installing `gh` to `~/.local/bin/` and routing `vercel` through `~/.npm-global/`. If you saw this error, you are on 0.9.0; upgrade with `npx @oaklandzoo/ostup@latest`. |
|
|
181
203
|
|
|
182
204
|
## Advanced: API tokens instead of interactive login
|
|
183
205
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.9.1",
|
|
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,347 @@
|
|
|
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 { existsSync, constants as fsConstants } from 'node:fs';
|
|
10
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { detectAll, installCommandFor } from './tool-registry.mjs';
|
|
13
|
+
import { run as exec } from './exec.mjs';
|
|
14
|
+
|
|
15
|
+
const STAGE1_HINT = {
|
|
16
|
+
darwin: 'Mac installer: /bin/bash -c "$(curl -fsSL https://ostup-install.vercel.app/install.sh)"',
|
|
17
|
+
win32: 'Windows installer: irm https://ostup-install.vercel.app/install.ps1 | iex',
|
|
18
|
+
linux: 'On Linux, install Node 20+ from https://nodejs.org or your package manager, then re-run.',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function buildExplainer(missing) {
|
|
22
|
+
const items = missing.map((m, i) => ` ${i + 1}. ${m.tool.name.padEnd(14)} ${m.tool.blurb}`);
|
|
23
|
+
return [
|
|
24
|
+
'',
|
|
25
|
+
'ostup needs the following before it can build your site:',
|
|
26
|
+
'',
|
|
27
|
+
...items,
|
|
28
|
+
'',
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function describePlan(plan) {
|
|
33
|
+
if (plan.kind === 'manual') {
|
|
34
|
+
return `(manual) ${plan.url}${plan.note ? ` — ${plan.note}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
return `${plan.cmd} ${(plan.args || []).join(' ')}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultHasOnPath(bin) {
|
|
40
|
+
try {
|
|
41
|
+
if (process.platform === 'win32') {
|
|
42
|
+
execSync(`where.exe ${bin}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
43
|
+
} else {
|
|
44
|
+
execSync(`command -v ${bin}`, { stdio: ['ignore', 'pipe', 'ignore'], shell: '/bin/bash' });
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function patchPathFor(plan) {
|
|
53
|
+
if (!plan.postPath || plan.postPath.length === 0) return;
|
|
54
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
55
|
+
const existing = (process.env.PATH || '').split(sep);
|
|
56
|
+
const toAdd = plan.postPath.filter((dir) => !existing.includes(dir));
|
|
57
|
+
if (toAdd.length === 0) return;
|
|
58
|
+
process.env.PATH = toAdd.join(sep) + sep + (process.env.PATH || '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- darwin multi-user helpers ----------------------------------------------
|
|
62
|
+
// On a Mac where Homebrew was installed by a different user (shared / refurbished /
|
|
63
|
+
// family Mac), the current user can READ /opt/homebrew/ (so detection passes) but
|
|
64
|
+
// cannot WRITE to it (so `brew install gh` fails). Same applies to npm's global
|
|
65
|
+
// prefix which lives under brew. These helpers detect that state and switch to
|
|
66
|
+
// user-local install strategies that don't need sudo or admin handoff.
|
|
67
|
+
|
|
68
|
+
async function isPathWritable(path) {
|
|
69
|
+
try {
|
|
70
|
+
await access(path, fsConstants.W_OK);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function checkBrewWritable() {
|
|
78
|
+
for (const cellar of ['/opt/homebrew/Cellar', '/usr/local/Cellar']) {
|
|
79
|
+
if (existsSync(cellar)) {
|
|
80
|
+
return { exists: true, writable: await isPathWritable(cellar), cellar };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { exists: false, writable: false, cellar: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function checkNpmGlobalWritable() {
|
|
87
|
+
let prefix = null;
|
|
88
|
+
try {
|
|
89
|
+
prefix = execSync('npm prefix -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
90
|
+
} catch {
|
|
91
|
+
return { prefix: null, writable: false };
|
|
92
|
+
}
|
|
93
|
+
if (!prefix) return { prefix: null, writable: false };
|
|
94
|
+
return { prefix, writable: await isPathWritable(prefix) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function appendToZshrcIfMissing(line) {
|
|
98
|
+
const zshrc = join(homedir(), '.zshrc');
|
|
99
|
+
try {
|
|
100
|
+
const body = existsSync(zshrc) ? await readFile(zshrc, 'utf8') : '';
|
|
101
|
+
if (body.includes(line)) return false;
|
|
102
|
+
const sep = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
103
|
+
await writeFile(zshrc, body + sep + line + '\n');
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function prependToProcessPath(dir) {
|
|
111
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
112
|
+
const parts = (process.env.PATH || '').split(sep);
|
|
113
|
+
if (parts.includes(dir)) return;
|
|
114
|
+
process.env.PATH = dir + sep + (process.env.PATH || '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchLatestGhVersion() {
|
|
118
|
+
try {
|
|
119
|
+
const out = execSync(
|
|
120
|
+
'curl -fsSL https://api.github.com/repos/cli/cli/releases/latest',
|
|
121
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
122
|
+
).toString();
|
|
123
|
+
const m = out.match(/"tag_name"\s*:\s*"v([0-9]+\.[0-9]+\.[0-9]+)"/);
|
|
124
|
+
return m ? m[1] : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function installGhBinaryDirect() {
|
|
131
|
+
// Multi-user-Mac fallback: download gh from its GitHub release tarball into
|
|
132
|
+
// ~/.local/bin/gh. No brew, no sudo, no admin handoff. Patches PATH for the
|
|
133
|
+
// current process and appends an export line to ~/.zshrc for future shells.
|
|
134
|
+
const home = homedir();
|
|
135
|
+
const binDir = join(home, '.local', 'bin');
|
|
136
|
+
const arch = process.arch === 'arm64' ? 'macOS_arm64' : 'macOS_amd64';
|
|
137
|
+
const version = (await fetchLatestGhVersion()) || '2.82.1';
|
|
138
|
+
const url = `https://github.com/cli/cli/releases/download/v${version}/gh_${version}_${arch}.tar.gz`;
|
|
139
|
+
|
|
140
|
+
const installScript = [
|
|
141
|
+
'set -euo pipefail',
|
|
142
|
+
`mkdir -p "${binDir}"`,
|
|
143
|
+
'tmp=$(mktemp -d)',
|
|
144
|
+
'cd "$tmp"',
|
|
145
|
+
`curl -fL -o gh.tar.gz "${url}"`,
|
|
146
|
+
'tar -xzf gh.tar.gz',
|
|
147
|
+
`cp gh_${version}_${arch}/bin/gh "${binDir}/gh"`,
|
|
148
|
+
`chmod +x "${binDir}/gh"`,
|
|
149
|
+
'rm -rf "$tmp"',
|
|
150
|
+
].join('\n');
|
|
151
|
+
|
|
152
|
+
await exec('bootstrap-gh-direct', 'bash', ['-c', installScript], { stdio: 'inherit' });
|
|
153
|
+
prependToProcessPath(binDir);
|
|
154
|
+
await appendToZshrcIfMissing(`export PATH="${binDir}:$PATH"`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function setupUserNpmPrefix() {
|
|
158
|
+
// Multi-user-Mac fallback: route `npm install -g` into ~/.npm-global so the
|
|
159
|
+
// current user owns the directory and doesn't need write access to brew's
|
|
160
|
+
// npm prefix.
|
|
161
|
+
const home = homedir();
|
|
162
|
+
const prefix = join(home, '.npm-global');
|
|
163
|
+
const binDir = join(prefix, 'bin');
|
|
164
|
+
await exec('bootstrap-npm-prefix', 'npm', ['config', 'set', 'prefix', prefix], { stdio: 'inherit' });
|
|
165
|
+
prependToProcessPath(binDir);
|
|
166
|
+
await appendToZshrcIfMissing(`export PATH="${binDir}:$PATH"`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkInstallPrerequisites(missing, hasOnPath, platform) {
|
|
170
|
+
const reasons = [];
|
|
171
|
+
for (const m of missing) {
|
|
172
|
+
const plan = installCommandFor(m.tool, platform);
|
|
173
|
+
if (plan.kind === 'brew' && !hasOnPath('brew')) {
|
|
174
|
+
reasons.push({
|
|
175
|
+
tool: m.tool.name,
|
|
176
|
+
prereq: 'Homebrew',
|
|
177
|
+
hint: STAGE1_HINT.darwin,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (plan.kind === 'winget' && !hasOnPath('winget')) {
|
|
181
|
+
reasons.push({
|
|
182
|
+
tool: m.tool.name,
|
|
183
|
+
prereq: 'WinGet (Microsoft App Installer)',
|
|
184
|
+
hint: 'Update App Installer from the Microsoft Store: ms-windows-store://pdp/?productid=9NBLGGH4NNS1',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Dedupe by prereq
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
return reasons.filter((r) => {
|
|
191
|
+
if (seen.has(r.prereq)) return false;
|
|
192
|
+
seen.add(r.prereq);
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function printInstallPlan(missing, platform = process.platform) {
|
|
198
|
+
process.stdout.write('Install plan:\n');
|
|
199
|
+
for (const m of missing) {
|
|
200
|
+
const plan = installCommandFor(m.tool, platform);
|
|
201
|
+
process.stdout.write(` ${m.tool.name}: ${describePlan(plan)}\n`);
|
|
202
|
+
}
|
|
203
|
+
process.stdout.write('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function runBootstrap({
|
|
207
|
+
flags = {},
|
|
208
|
+
detect = detectAll,
|
|
209
|
+
hasOnPath = defaultHasOnPath,
|
|
210
|
+
platform = process.platform,
|
|
211
|
+
} = {}) {
|
|
212
|
+
const { found, missing } = detect();
|
|
213
|
+
if (missing.length === 0) {
|
|
214
|
+
if (flags.bootstrapOnly) {
|
|
215
|
+
process.stdout.write('[bootstrap] all required tools present. Nothing to install.\n');
|
|
216
|
+
}
|
|
217
|
+
return { found, missing: [], installed: [], skipped: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
process.stdout.write(buildExplainer(missing));
|
|
221
|
+
|
|
222
|
+
// --no-install: print plan, exit 0 without changing anything.
|
|
223
|
+
if (flags.noInstall) {
|
|
224
|
+
printInstallPlan(missing, platform);
|
|
225
|
+
process.stdout.write('No changes made. Re-run without --no-install to actually install.\n');
|
|
226
|
+
return { found, missing, installed: [], deferred: 'no-install' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Manual-only missing tools (e.g. Node version mismatch): cannot auto-install here.
|
|
230
|
+
// Point the user at the stage-1 installer for their OS and bail.
|
|
231
|
+
const manualOnly = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'manual');
|
|
232
|
+
if (manualOnly.length > 0) {
|
|
233
|
+
const lines = ['Some tools cannot be installed by ostup directly:'];
|
|
234
|
+
for (const m of manualOnly) {
|
|
235
|
+
const plan = installCommandFor(m.tool, platform);
|
|
236
|
+
lines.push(` - ${m.tool.name}: ${plan.url}`);
|
|
237
|
+
if (plan.note) lines.push(` ${plan.note}`);
|
|
238
|
+
}
|
|
239
|
+
lines.push('');
|
|
240
|
+
lines.push(STAGE1_HINT[platform] || STAGE1_HINT.linux);
|
|
241
|
+
lines.push('');
|
|
242
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
243
|
+
const err = new Error('Manual install required for: ' + manualOnly.map((m) => m.tool.name).join(', '));
|
|
244
|
+
err.code = 'UNSUPPORTED_OS_INSTALL';
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Prerequisite check (brew on Mac, winget on Windows).
|
|
249
|
+
const prereqProblems = checkInstallPrerequisites(missing, hasOnPath, platform);
|
|
250
|
+
if (prereqProblems.length > 0) {
|
|
251
|
+
const lines = ['Cannot install — a prerequisite is missing on your machine:'];
|
|
252
|
+
for (const r of prereqProblems) {
|
|
253
|
+
lines.push(` - ${r.prereq} (needed for ${r.tool})`);
|
|
254
|
+
lines.push(` ${r.hint}`);
|
|
255
|
+
}
|
|
256
|
+
lines.push('');
|
|
257
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
258
|
+
const err = new Error('Bootstrap cannot run: ' + prereqProblems.map((r) => r.prereq).join(', ') + ' missing.');
|
|
259
|
+
err.code = 'BOOTSTRAP_FAILED';
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Need a TTY for the confirm prompt unless --yes.
|
|
264
|
+
if (!flags.yes && !process.stdin.isTTY) {
|
|
265
|
+
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.');
|
|
266
|
+
err.code = 'NO_TTY_BOOTSTRAP';
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
printInstallPlan(missing, platform);
|
|
271
|
+
|
|
272
|
+
if (!flags.yes) {
|
|
273
|
+
const ok = await p.confirm({ message: 'Install these now?' });
|
|
274
|
+
if (p.isCancel(ok) || ok === false) {
|
|
275
|
+
const err = new Error('Bootstrap declined. Re-run when ready, or install the tools manually.');
|
|
276
|
+
err.code = 'BOOTSTRAP_DECLINED';
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// On darwin: detect multi-user-Mac scenario (brew owned by another user) and
|
|
282
|
+
// switch to user-local install strategies before running the loop. Skip when
|
|
283
|
+
// running under an injected hasOnPath (tests use platform-agnostic flows).
|
|
284
|
+
const useUserGhFallback = new Set();
|
|
285
|
+
let needsUserNpmPrefix = false;
|
|
286
|
+
if (platform === 'darwin' && hasOnPath === defaultHasOnPath) {
|
|
287
|
+
const brewMissing = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'brew');
|
|
288
|
+
const npmgMissing = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'npm-g');
|
|
289
|
+
if (brewMissing.length > 0) {
|
|
290
|
+
const brewState = await checkBrewWritable();
|
|
291
|
+
if (brewState.exists && !brewState.writable) {
|
|
292
|
+
process.stdout.write('\n[bootstrap] Homebrew is installed but owned by another user.\n');
|
|
293
|
+
process.stdout.write('[bootstrap] Switching to user-local install for brew-managed tools (no sudo, no admin handoff).\n\n');
|
|
294
|
+
for (const m of brewMissing) {
|
|
295
|
+
if (m.tool.id === 'gh') useUserGhFallback.add(m.tool.id);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (npmgMissing.length > 0) {
|
|
300
|
+
const npmState = await checkNpmGlobalWritable();
|
|
301
|
+
if (!npmState.writable) {
|
|
302
|
+
process.stdout.write('[bootstrap] npm global prefix is not writable by this user. Setting up ~/.npm-global as a per-user prefix.\n\n');
|
|
303
|
+
needsUserNpmPrefix = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (needsUserNpmPrefix) {
|
|
308
|
+
await setupUserNpmPrefix();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Run installs sequentially. stdio: 'inherit' so sudo/UAC prompts, progress bars, and
|
|
312
|
+
// any interactive winget/brew confirmations reach the user's terminal.
|
|
313
|
+
const installed = [];
|
|
314
|
+
for (const m of missing) {
|
|
315
|
+
if (useUserGhFallback.has(m.tool.id)) {
|
|
316
|
+
await installGhBinaryDirect();
|
|
317
|
+
installed.push(m.tool.id);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const plan = installCommandFor(m.tool, platform);
|
|
321
|
+
await exec(`bootstrap-${m.tool.id}`, plan.cmd, plan.args || [], {
|
|
322
|
+
stdio: 'inherit',
|
|
323
|
+
cwd: homedir(),
|
|
324
|
+
});
|
|
325
|
+
patchPathFor(plan);
|
|
326
|
+
installed.push(m.tool.id);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Re-detect to verify the installs worked.
|
|
330
|
+
const second = detect();
|
|
331
|
+
if (second.missing.length > 0) {
|
|
332
|
+
const stillMissing = second.missing.map((m) => m.tool.name).join(', ');
|
|
333
|
+
const err = new Error(
|
|
334
|
+
`Bootstrap finished but these tools are still not detected: ${stillMissing}. ` +
|
|
335
|
+
`Open a new terminal and re-run, or check the run log for details.`
|
|
336
|
+
);
|
|
337
|
+
err.code = 'BOOTSTRAP_VERIFY_FAILED';
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
process.stdout.write(`\n[bootstrap] installed ${installed.length} tool(s): ${installed.join(', ')}. Continuing.\n\n`);
|
|
342
|
+
return { found: second.found, missing: [], installed };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function runBootstrapStandalone({ flags = {} } = {}) {
|
|
346
|
+
return runBootstrap({ flags: { ...flags, bootstrapOnly: true } });
|
|
347
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
91
|
-
if (
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
106
|
-
if (
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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
|
-
// ===
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
report('ok',
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|