feat(install): marked per-directory bun and bunx shims for fresh Capy shells (A4, A-e)

gstack-capy-install (install mode, Capy machines only, GSTACK_BUN_SHIM=0 opts
out) writes ~/.local/bin/bun and bunx under ~/.gstack-runtime.lock. The shim
bakes the store, verdict directory, checkout and install default, walks up to
.git without spawning git, runs the Bun the (checkout, project) verdict
recorded, else a store Bun matching the .bun-version/package.json subset
(stderr names the --check to run when none matches), else the install
default, else the next unmarked bun on PATH. A foreign file or dangling link
is never touched. GSTACK_BUN_SHIM_EXPLAIN=1 prints project, record, choice and
rule; the doctor's runtime row shows the shim and its target.

Files: bin/gstack-capy-install, test/gstack-capy-install.test.ts, test/gstack-doctor.test.ts
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-15 00:20:53 +00:00
1 parent 9b96457dde
commit cafb39dd12
3 files changed
+191 -6

No files matched your search

+89 -6
View File
@@ -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
# <state root>/runtimes/bun/<version>/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" <<EOF_SHIM
#!/bin/sh
$GSTACK_BUN_SHIM_MARKER
# The Capy bun shim, written by $ROOT/bin/gstack-capy-install: runs the Bun
# recorded for the project around the working directory (docs/capy-install.md).
# GSTACK_BUN_SHIM_EXPLAIN=1 prints the project root, record, choice and rule.
store='$STORE'
verdicts='$(dirname "$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "")")'
checkout='$ROOT_REAL'
default='$INSTALL_VERSION'
marker='$GSTACK_BUN_SHIM_MARKER'
sha() { if command -v sha256sum >/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 <ref>; ...)` 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"
+92
View File
@@ -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<typeof fixture>, 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<typeof fixture>, cwd: string, cmd: string, extra: Record<string, string> = {}) =>
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<typeof fixture>, args: string[] = [], extra: Record<string, string> = {}, cwd?: string) {
return f.run(args, extra, cwd);
}
+10
View File
@@ -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');
});
});