@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.9.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": {
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',