diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 83e21e8c2..7da110086 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -10,14 +10,21 @@ # Steps: clone or reuse the checkout under a lock (never reset, never pulled # here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and # hands its held lock over with GSTACK_CAPY_LOCK_HELD=1); preflight git, curl, -# node, jq and Bun (a supported Bun already in $BUN_INSTALL/bin is reused; the -# exact release archive, SHA-256 verified, is installed there only when -# gstack's supported minimum and the project's pins agree on it); ./setup +# node, jq and Bun through gstack_bun_resolve (an installed Bun that satisfies +# gstack's minimum and the project's pins is reused; otherwise the exact +# release archive, SHA-256 verified, is published in the runtime store +# /runtimes/bun//bun, GSTACK_BUN_INSTALL_VERSION +# overriding the default); ./setup # --host claude --no-prefix; ./setup --host codex --model gpt-6-astra (both # skipped when this HEAD was already set up and the check passes); # gstack-doctor --check with the launch PATH; the install verdict -# (bin/gstack-install-verdict.sh). --check certifies only: no clone, no Bun -# install, no setup. Chromium is lazy by default (bin/gstack-browser-ensure +# (bin/gstack-install-verdict.sh), keyed by checkout and project and holding +# the resolved Bun. --check certifies only: no clone, no Bun install, no +# setup. On a Capy machine (CAPY_MACHINE_CONFIG or ~/.capy/system) install also writes the marked +# per-directory shims ~/.local/bin/bun and ~/.local/bin/bunx, which run the Bun +# recorded for the project around the working directory in any fresh shell; +# GSTACK_BUN_SHIM=0 opts out, a foreign ~/.local/bin/bun is never touched, and +# deleting the files removes them. Chromium is lazy by default (bin/gstack-browser-ensure # installs it on first use); --with-browser installs it now. The last lines # are the GSTACK_LAUNCH line the parent supplies to every gstack command it # launches and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`; @@ -31,7 +38,7 @@ ROOT="${GSTACK_CAPY_ROOT:-$SELF}" REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}" DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md" FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}" -usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; } need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; } while [ $# -gt 0 ]; do case "$1" in @@ -309,6 +316,81 @@ if [ "$CHECK_ONLY" -eq 0 ] && [ "$SKIPPED" -eq 0 ]; then fi [ "$CHECK_ONLY" -eq 1 ] && certify "$@" +# write_shim — the per-directory bun and bunx shims (Capy only, install mode). +# The store, verdict directory, checkout and install default are baked in, so +# a fresh shell needs no environment; rewritten when they change. Only a +# missing file or one carrying the marker is replaced. +write_shim() { + local dir="$HOME/.local/bin" name tmp + [ "${GSTACK_BUN_SHIM:-1}" != 0 ] || return 0 + [ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "$HOME/.capy/system" ] || return 0 + mkdir -p "$dir" || { say "could not create $dir; no bun shim written"; return 0; } + exec 8>"$HOME/.gstack-runtime.lock" + command -v flock >/dev/null 2>&1 && flock -w 60 8 + for name in bun bunx; do + if { [ -e "$dir/$name" ] || [ -L "$dir/$name" ]; } && ! { [ -f "$dir/$name" ] && [ ! -L "$dir/$name" ] && _gstack_bun_is_shim "$dir/$name"; }; then + say "$dir/$name is not gstack's shim (no marker); left untouched, so keep the GSTACK_LAUNCH prefix" + continue + fi + tmp="$dir/.$name.gstack.$$" + if [ "$name" = bunx ]; then + printf '#!/bin/sh\n%s\n# The Capy bunx shim: bun x through the bun shim beside it (docs/capy-install.md).\nexec %s x "$@"\n' "$GSTACK_BUN_SHIM_MARKER" "'$dir/bun'" > "$tmp" + else + cat > "$tmp" </dev/null 2>&1; then printf '%s' "\$1" | sha256sum | cut -d' ' -f1; else printf '%s' "\$1" | shasum -a 256 | cut -d' ' -f1; fi; } +root="" record="" chose="" rule="" +dir=\$(pwd -P 2>/dev/null) || dir="" +while [ -n "\$dir" ]; do + [ -e "\$dir/.git" ] && { root=\$dir; break; } + [ "\$dir" = / ] && break + dir=\${dir%/*}; [ -n "\$dir" ] || dir=/ +done +if [ -n "\$root" ]; then + key=\$root; [ "\$root" = "\$checkout" ] && key=none + record="\$verdicts/\$(sha "\$key").json" + if [ -f "\$record" ]; then + b=\$(sed -n 's/^ "bun_path": "\(.*\)",\$/\1/p' "\$record" | head -1) + [ -n "\$b" ] && [ -x "\$b" ] && chose=\$b rule=record + else + record="" pin="" + [ -f "\$root/.bun-version" ] && pin=\$(head -1 "\$root/.bun-version" | tr -d ' v\r') + [ -n "\$pin" ] || pin=\$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"bun@\([0-9][0-9.]*\)".*/\1/p' "\$root/package.json" 2>/dev/null | head -1) + [ -n "\$pin" ] || pin=\$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' "\$root/package.json" 2>/dev/null | head -1) + if [ -n "\$pin" ] && [ -x "\$store/\$pin/bun" ]; then chose="\$store/\$pin/bun" rule=pin + else echo "gstack bun shim: \$root has no recorded Bun; run ${GSTACK_CAPY_WRAPPER_PATH:+bash }${GSTACK_CAPY_WRAPPER_PATH:-$ROOT/bin/gstack-capy-install} --check here" >&2 + fi + fi +fi +[ -n "\$chose" ] || { [ -x "\$store/\$default/bun" ] && chose="\$store/\$default/bun" rule=default; } +if [ -z "\$chose" ]; then + set -f; ifs=\$IFS; IFS=: + for d in \$PATH; do + [ -n "\$d" ] && [ -f "\$d/bun" ] && [ -x "\$d/bun" ] || continue + head -c 512 "\$d/bun" 2>/dev/null | grep -aqF "\$marker" && continue + chose="\$d/bun" rule=PATH; break + done + IFS=\$ifs; set +f +fi +[ "\${GSTACK_BUN_SHIM_EXPLAIN:-}" = 1 ] && echo "gstack bun shim: project=\${root:-none} record=\${record:-none} chose=\${chose:-none} rule=\${rule:-none}" >&2 +[ -n "\$chose" ] || { echo "gstack bun shim: no bun found (store \$store, then PATH)" >&2; exit 127; } +exec "\$chose" "\$@" +EOF_SHIM + fi + chmod 0755 "$tmp" && if cmp -s "$tmp" "$dir/$name"; then rm -f "$tmp"; else mv -f "$tmp" "$dir/$name"; fi + done + exec 8>&- +} + # 4. Certify. The trailer is the contract; `(requested ; ...)` when the # existing checkout is not at --revision (it is never moved here). INSTALLED=$(head -1 "$ROOT/VERSION") @@ -319,6 +401,7 @@ if [ -n "$REVISION" ] && [ "$REVISION" != "$INSTALLED" ] && [ "$REVISION" != "v$ fi TRAILER="$(printf '%s\n' "$OUT" | grep '^gstack: ' | tail -1)$REV_NOTE" record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)" +[ "$CHECK_ONLY" -eq 1 ] || write_shim printf '%s\n' "$OUT" | grep -v '^gstack: ' say "total: $(( $(now) - T0 ))s" echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT" diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index f0386b656..d2d59522e 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -499,6 +499,98 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti }); }); +// Plan A4/A-e: on Capy the installer writes marked ~/.local/bin/bun and bunx +// shims that run the Bun recorded for the project around the working +// directory, in a fresh shell with Capy's PATH and no gstack environment. +describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-directory bun shim', () => { + const realBun = (file: string, version: string) => script(file, [`[ "$1" = --version ] && { echo ${version}; exit 0; }`, `exec "${process.execPath}" "$@"`]); + const repo = (f: ReturnType, name: string, pin?: string) => { + const dir = path.join(f.base, name); + fs.mkdirSync(dir, { recursive: true }); + if (pin) write(path.join(dir, '.bun-version'), `${pin}\n`); + sh('git init -q', dir); + return dir; + }; + /** A fresh non-login shell: Capy's PATH (~/.local/bin first), HOME, nothing from gstack. */ + const fresh = (f: ReturnType, cwd: string, cmd: string, extra: Record = {}) => + spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', timeout: 30_000, env: { HOME: f.home, PATH: `${f.home}/.local/bin:${f.env.PATH}`, ...extra } }); + + test('fresh shells alternate between two exact pins, an unpinned project and a directory outside any project; custom state root, no env', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const state = path.join(f.base, 'custom-state'); + const storeDir = path.join(state, 'runtimes/bun'); + realBun(path.join(storeDir, '1.4.2/bun'), '1.4.2'); + realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3'); + const a = repo(f, 'a', '1.4.2'), b = repo(f, 'b', '1.4.3'), c = repo(f, 'c'); + for (const dir of [a, b, c]) { + const r = run(f, ['--for', 'review'], { GSTACK_STATE_ROOT: state }, dir); + expect(r.status, r.stdout + r.stderr).toBe(0); + } + const shim = fs.readFileSync(path.join(f.home, '.local/bin/bun'), 'utf8'); + expect(shim.split('\n')[1]).toBe('# gstack-bun-shim: gstack-owned; delete this file to remove it'); + expect(shim).toContain(`store='${storeDir}'`); + const outside = path.join(f.base, 'outside'); + fs.mkdirSync(outside); + for (const [dir, want] of [[a, '1.4.2'], [b, '1.4.3'], [a, '1.4.2'], [c, '1.4.3'], [outside, '1.4.3'], [b, '1.4.3']] as const) { + const r = fresh(f, dir, 'bun --version'); + expect(r.stdout.trim(), `${dir}\n${r.stderr}`).toBe(want); + expect(r.stderr).toBe(''); + } + const explained = fresh(f, path.join(a), 'mkdir -p sub && cd sub && bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(explained.stderr).toMatch(new RegExp(`^gstack bun shim: project=${a} record=${state}/install-verdicts/[0-9a-f]{64}/[0-9a-f]{64}\\.json chose=${storeDir}/1\\.4\\.2/bun rule=record\\n$`)); + expect(fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toBe(`gstack bun shim: project=none record=none chose=${storeDir}/1.4.3/bun rule=default\n`); + const bunx = fresh(f, a, 'bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(bunx.stderr).toContain(`chose=${storeDir}/1.4.2/bun rule=record`); + const unrecordedPinned = repo(f, 'd', '1.4.2'); + const d = fresh(f, unrecordedPinned, 'bun --version'); + expect([d.stdout.trim(), d.stderr]).toEqual(['1.4.2', '']); + const unrecorded = repo(f, 'e'); + const e = fresh(f, unrecorded, 'bun --version'); + expect(e.stdout.trim()).toBe('1.4.3'); + expect(e.stderr).toBe(`gstack bun shim: ${unrecorded} has no recorded Bun; run ${f.root}/bin/gstack-capy-install --check here\n`); + }); + + test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => { + const f = fixture(); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + write(path.join(f.home, '.local/bin/bun'), '#!/bin/sh\necho mine\n', 0o755); + fs.symlinkSync(path.join(f.base, 'missing'), path.join(f.home, '.local/bin/bunx')); + const r = run(f, ['--for', 'autoplan']); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.readFileSync(path.join(f.home, '.local/bin/bun'), 'utf8')).toBe('#!/bin/sh\necho mine\n'); + expect(fs.readlinkSync(path.join(f.home, '.local/bin/bunx'))).toBe(path.join(f.base, 'missing')); + expect(r.stdout).toContain(`${f.home}/.local/bin/bun is not gstack's shim (no marker); left untouched, so keep the GSTACK_LAUNCH prefix`); + const g = fixture(); + fs.mkdirSync(path.join(g.home, '.capy/system'), { recursive: true }); + expect(run(g, [], { GSTACK_BUN_SHIM: '0' }).status).toBe(0); + expect(fs.existsSync(path.join(g.home, '.local/bin/bun'))).toBe(false); + expect(run(g).status).toBe(0); + const first = fs.readFileSync(path.join(g.home, '.local/bin/bun'), 'utf8'); + expect(run(g, [], { GSTACK_STATE_ROOT: path.join(g.base, 'moved') }).status).toBe(0); + const second = fs.readFileSync(path.join(g.home, '.local/bin/bun'), 'utf8'); + expect(second).not.toBe(first); + expect(second).toContain(`store='${path.join(g.base, 'moved')}/runtimes/bun'`); + const plain = fixture(); + expect(run(plain).status).toBe(0); + expect(fs.existsSync(path.join(plain.home, '.local/bin/bun'))).toBe(false); + }); + + test('the shim adds little over the Bun it runs (fixture timing bound)', () => { + const f = fixture(); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const storeBun = path.join(f.home, '.gstack-state/runtimes/bun/1.4.3/bun'); + realBun(storeBun, '1.4.3'); + expect(run(f).status).toBe(0); + const time = (cmd: string) => { + const samples = Array.from({ length: 7 }, () => { const t = performance.now(); fresh(f, f.base, cmd); return performance.now() - t; }).sort((x, y) => x - y); + return samples[3]; + }; + const overhead = time('bun --version') - time(`"${storeBun}" --version`); + expect(overhead).toBeLessThan(250); + }); +}); + function run(f: ReturnType, args: string[] = [], extra: Record = {}, cwd?: string) { return f.run(args, extra, cwd); } diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 2fbd834cb..09b184985 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -853,4 +853,14 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { expect(r.trailer).toBe('gstack: fail runtime'); expect(r.line('runtime')).toBe('FAIL runtime — bun resolution drifted: recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone; fix: bash /d/install.sh --for review (BUN_RESOLUTION_DRIFT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-resolution-drift)'); }); + + test('the runtime row shows gstack\'s bun shim and where it resolves from here, or names a foreign ~/.local/bin/bun it left alone', () => { + const f = makeFixture(); + const shim = path.join(f.home, '.local/bin/bun'); + write(shim, '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\n[ "$GSTACK_BUN_SHIM_EXPLAIN" = 1 ] && echo "gstack bun shim: project=none record=none chose=/store/1.4.3/bun rule=default" >&2\necho 1.4.3\n', 0o755); + expect(parse(f, []).line('runtime')).toContain(`; shim ${shim} -> /store/1.4.3/bun`); + write(shim, '#!/bin/sh\necho mine\n', 0o755); + expect(parse(f, []).line('runtime')).toContain(`; ${shim} is not gstack's shim (no marker), left untouched: launch with the GSTACK_LAUNCH line`); + expect(parse(f, [], { GSTACK_BUN_INSTALL_VERSION: '1.4.2' }).line('runtime')).toContain('; install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION'); + }); });