@oaklandzoo/ostup 0.9.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 +1 -0
- package/package.json +1 -1
- package/src/bootstrap.mjs +146 -0
package/README.md
CHANGED
|
@@ -199,6 +199,7 @@ you do not have to repeat them every session.
|
|
|
199
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
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
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`. |
|
|
202
203
|
|
|
203
204
|
## Advanced: API tokens instead of interactive login
|
|
204
205
|
|
package/package.json
CHANGED
package/src/bootstrap.mjs
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import * as p from '@clack/prompts';
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
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';
|
|
9
12
|
import { detectAll, installCommandFor } from './tool-registry.mjs';
|
|
10
13
|
import { run as exec } from './exec.mjs';
|
|
11
14
|
|
|
@@ -55,6 +58,114 @@ function patchPathFor(plan) {
|
|
|
55
58
|
process.env.PATH = toAdd.join(sep) + sep + (process.env.PATH || '');
|
|
56
59
|
}
|
|
57
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
|
+
|
|
58
169
|
function checkInstallPrerequisites(missing, hasOnPath, platform) {
|
|
59
170
|
const reasons = [];
|
|
60
171
|
for (const m of missing) {
|
|
@@ -167,10 +278,45 @@ export async function runBootstrap({
|
|
|
167
278
|
}
|
|
168
279
|
}
|
|
169
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
|
+
|
|
170
311
|
// Run installs sequentially. stdio: 'inherit' so sudo/UAC prompts, progress bars, and
|
|
171
312
|
// any interactive winget/brew confirmations reach the user's terminal.
|
|
172
313
|
const installed = [];
|
|
173
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
|
+
}
|
|
174
320
|
const plan = installCommandFor(m.tool, platform);
|
|
175
321
|
await exec(`bootstrap-${m.tool.id}`, plan.cmd, plan.args || [], {
|
|
176
322
|
stdio: 'inherit',
|