diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index 140cae2ea..8c5a49cf7 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -432,7 +432,8 @@ _dc_wrapper_rev() { # _dc_check_wrapper — the Capy skill copy. The active wrapper's provenance # (GSTACK_CAPY_WRAPPER_REV/PATH, exported by the bootstrapper) decides: current -# passes, and a stale Drive copy elsewhere is a WARN for the owner. Without +# passes; an active copy below the minimum, or a stale Drive copy elsewhere, is +# a WARN for the owner (an older copy still works, capy/support.env). Without # provenance (a direct doctor run, or a wrapper too old to export it) the Drive # copies decide: GSTACK_CAPY_WRAPPER_PATH when it names one, else every # ~/.capy/drive/*/skills/gstack/scripts/install.sh, failing only when each is @@ -455,7 +456,7 @@ _dc_check_wrapper() { fi if [ -n "${GSTACK_CAPY_WRAPPER_REV:-}" ]; then if [ "$GSTACK_CAPY_WRAPPER_REV" -lt "$min" ] 2>/dev/null; then - _dc_row FAIL wrapper "the active Capy skill copy ${active:-} is wrapper $GSTACK_CAPY_WRAPPER_REV, minimum $min" "$workaround; owner: $owner" CAPY_WRAPPER_STALE + _dc_row WARN wrapper "the active Capy skill copy ${active:-} is wrapper $GSTACK_CAPY_WRAPPER_REV, minimum $min (it still works)" "$owner" CAPY_WRAPPER_STALE elif [ -n "$stale" ]; then _dc_row WARN wrapper "active wrapper $GSTACK_CAPY_WRAPPER_REV (${active:-unknown path}) is current; stale Drive copies (minimum $min): $stale" "$owner" CAPY_WRAPPER_STALE else diff --git a/bin/gstack-upgrade b/bin/gstack-upgrade index 7505c8a55..d54425039 100755 --- a/bin/gstack-upgrade +++ b/bin/gstack-upgrade @@ -77,6 +77,23 @@ REMOTE_VER=$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) PENDING="$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-upgrade.pending" CAPY=0 [ -f "$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-capy-install.state" ] && [ -x "$ROOT/bin/gstack-capy-install" ] && CAPY=1 +# Run outside a Capy session, the installer this script starts is the +# checkout's own; say so, so the doctor's wrapper row judges that copy. +if [ "$CAPY" -eq 1 ] && [ -z "${GSTACK_CAPY_WRAPPER_REV:-}" ] && [ -f "$ROOT/capy/skill/gstack/scripts/install.sh" ]; then + GSTACK_CAPY_WRAPPER_REV=$(sed -n 's/^GSTACK_CAPY_WRAPPER=\([0-9][0-9]*\).*/\1/p' "$ROOT/capy/skill/gstack/scripts/install.sh" | head -1) + [ -n "$GSTACK_CAPY_WRAPPER_REV" ] && export GSTACK_CAPY_WRAPPER_REV GSTACK_CAPY_WRAPPER_PATH="$ROOT/capy/skill/gstack/scripts/install.sh" +fi +# stop_trailer ERRFILE — an installer stop line (exit 3, before ./setup) as a +# trailer: a RUNTIME_PIN_CONFLICT is `gstack: fail pins`, any other stop +# `gstack: fail runtime`. +stop_trailer() { + _stop=$(grep -E '^gstack-capy-install: .*\([A-Z][A-Z_]*;' "$1" | tail -1) + case "$_stop" in + '') ;; + *'(RUNTIME_PIN_CONFLICT;'*) echo "gstack: fail pins — ${_stop#gstack-capy-install: }" ;; + *) echo "gstack: fail runtime — ${_stop#gstack-capy-install: }" ;; + esac +} # certify — the caller's project (see the header); exits with its outcome. certify() { [ -n "$CALLER_PROJECT" ] && [ "$REINSTALL" -eq 1 ] || exit 0 @@ -84,10 +101,10 @@ certify() { _err=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") (cd "$CALLER" && "$ROOT/bin/gstack-capy-install" --project "$CALLER_PROJECT" "$_err") _rc=$? - _conflict=$(grep '(RUNTIME_PIN_CONFLICT;' "$_err" | tail -1) - cat "$_err" >&2; rm -f "$_err" - [ "$_rc" -eq 0 ] && exit 0 - [ -n "$_conflict" ] && echo "gstack: fail pins — ${_conflict#gstack-capy-install: }" + cat "$_err" >&2 + [ "$_rc" -eq 0 ] && { rm -f "$_err"; exit 0; } + [ "$_rc" -eq 3 ] && stop_trailer "$_err" + rm -f "$_err" exit 1 fi (cd "$CALLER" && "$ROOT/bin/gstack-doctor" --check --require runtime,pins --project "$CALLER_PROJECT" "$PENDING" - _held=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") + _held=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") _ierr=$(mktemp "${TMPDIR:-/tmp}/gstack-upgrade.XXXXXX") if [ "$CAPY" -eq 1 ]; then - (cd "$ROOT" && GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" "$_held"; continue; } ;; esac + (cd "$ROOT" && GSTACK_CAPY_UPGRADED_FROM="$1" "$ROOT/bin/gstack-capy-install" "$_ierr") | while IFS= read -r _line; do + case "$_line" in "gstack: "*) printf '%s\n' "$_line" > "$_held"; continue ;; esac printf '%s\n' "$_line" done _rc=${PIPESTATUS[0]} + cat "$_ierr" >&2 else (cd "$ROOT" && ./setup --refresh-registered &2 exit 1 } diff --git a/capy/support.env b/capy/support.env index 37d453d0b..01fd89653 100644 --- a/capy/support.env +++ b/capy/support.env @@ -16,4 +16,4 @@ GSTACK_MIN_REVISION=1.91.96.0 # GSTACK_CAPY_MIN_WRAPPER: the oldest bootstrapper revision (GSTACK_CAPY_WRAPPER in # capy/skill/gstack/scripts/install.sh) the owner should run. An older copy still # works and prints an owner action (CAPY_WRAPPER_STALE). -GSTACK_CAPY_MIN_WRAPPER=2 +GSTACK_CAPY_MIN_WRAPPER=1 diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 60dd79fe6..1081cebee 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -829,7 +829,7 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { expect(parse(f, [], { GSTACK_CAPY_WRAPPER_PATH: stale }).trailer).toBe('gstack: fail wrapper'); }); - test('the printed workaround certifies: a current active wrapper passes with the stale Drive copy unchanged (WARN, owner action); a stale active wrapper fails', () => { + test('the printed workaround certifies: a current active wrapper passes with the stale Drive copy unchanged (WARN, owner action); a stale active wrapper is a WARN too', () => { const f = makeFixture(); withSupport(f); const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); @@ -844,8 +844,11 @@ describe('gstack-doctor --check: wrapper and runtime drift', () => { expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); withSupport(f, 2); const old = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: stale }); - expect(old.trailer).toBe('gstack: fail wrapper'); - expect(old.line('wrapper')).toContain(`FAIL wrapper — the active Capy skill copy ${stale} is wrapper 1, minimum 2`); + // Integration item 2: an active copy below the minimum still works, so it + // is an owner-action WARN and the trailer stays ok. + expect(old.status, old.out).toBe(0); + expect(old.trailer).toMatch(/^gstack: ok /); + expect(old.line('wrapper')).toBe(`WARN wrapper — the active Capy skill copy ${stale} is wrapper 1, minimum 2 (it still works); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); }); test('a drifted Bun resolution is a runtime FAIL carrying the installer\'s detail, fix and BUN_RESOLUTION_DRIFT', () => { diff --git a/test/gstack-upgrade-bin.test.ts b/test/gstack-upgrade-bin.test.ts index c1e92da26..98f4e3420 100644 --- a/test/gstack-upgrade-bin.test.ts +++ b/test/gstack-upgrade-bin.test.ts @@ -238,8 +238,12 @@ describe('gstack-upgrade', () => { 'echo "capy-install $* cwd=$(pwd -P)" >> "$UPGRADE_LOG"', 'case " $* " in *" --project "*)', ' [ -n "${STUB_PIN_CONFLICT:-}" ] && { echo "gstack-capy-install: Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), $2/package.json pins exactly 1.3.14; fix: raise the project\'s pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)" >&2; exit 3; }', - ' [ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — bun 1.4.2 outside CI 1.4.3; fix: x (PROJECT_PIN_MISMATCH; d)"; echo "gstack: fail pins"; exit 1; } ;;', + ' [ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — bun 1.4.2 outside CI 1.4.3; fix: x (PROJECT_PIN_MISMATCH; d)"; echo "gstack: fail pins"; exit 1; }', + ' [ -n "${STUB_PROJECT_STOP:-}" ] && { echo "gstack-capy-install: preflight: jq not found; fix: install jq (INSTALL_PREFLIGHT_FAILED; d)" >&2; exit 3; } ;;', 'esac', + 'echo "wrapper-env rev=${GSTACK_CAPY_WRAPPER_REV:-} path=${GSTACK_CAPY_WRAPPER_PATH:-}" >> "$UPGRADE_LOG.env"', + '[ -n "${STUB_STOP:-}" ] && { echo "gstack-capy-install: preflight: curl not found; fix: install curl (INSTALL_PREFLIGHT_FAILED; d)" >&2; exit 3; }', + '[ -n "${STUB_WRAPPER_FAIL:-}" ] && { echo "FAIL wrapper — every Capy skill copy is stale; fix: x (CAPY_WRAPPER_STALE; d)"; echo "gstack: fail wrapper"; exit 1; }', '[ -f "${STUB_FAIL_ONCE:-/nonexistent}" ] && { rm -f "$STUB_FAIL_ONCE"; echo "gstack: fail setup — ./setup failed"; exit 1; }', 'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"', ].join('\n') + '\n'; @@ -305,6 +309,35 @@ describe('gstack-upgrade', () => { expect(f.calls().at(-1)).toBe(`capy-install --project ${f.project} cwd=${f.project}`); }); + // Integration items 2 and 9: the wrapper row is never a reinstall failure, + // and every installer stop line becomes a trailer. + test('a reinstall whose only failing row is wrapper leaves no pending marker; the installer runs with the checkout copy as the active wrapper', () => { + const f = capyFixture(); + write(path.join(f.root, 'capy/skill/gstack/scripts/install.sh'), 'GSTACK_CAPY_WRAPPER=7\n'); + const r = f.from(fs.realpathSync(f.host.base), [], { STUB_WRAPPER_FAIL: '1' }); + expect(r.status).toBe(1); + expect(r.stdout).toContain('gstack: fail wrapper'); + expect(r.stdout).toContain('only the wrapper row failed (an owner action on the Drive copy, CAPY_WRAPPER_STALE), so no reinstall is pending'); + expect(r.stderr).not.toContain('completes the pending reinstall'); + expect(fs.existsSync(f.pending)).toBe(false); + const again = f.from(fs.realpathSync(f.host.base)); + expect(again.stdout).not.toContain('reinstall pending'); + expect(fs.readFileSync(path.join(f.host.base, 'calls.log.env'), 'utf8')).toContain(`rev=7 path=${f.root}/capy/skill/gstack/scripts/install.sh`); + }); + + test('an installer stop line maps to `gstack: fail runtime — `: in the caller-project check (no pending) and in the reinstall (pending kept)', () => { + const f = capyFixture(); + const cert = f.from(f.project, [], { STUB_PROJECT_STOP: '1' }); + expect(cert.status).toBe(1); + expect(cert.stdout.trim().split('\n').at(-1)).toBe('gstack: fail runtime — preflight: jq not found; fix: install jq (INSTALL_PREFLIGHT_FAILED; d)'); + expect(fs.existsSync(f.pending)).toBe(false); + const g = capyFixture(); + const re = g.from(g.project, [], { STUB_STOP: '1' }); + expect(re.status).toBe(1); + expect(re.stdout.trim().split('\n').at(-1)).toBe('gstack: fail runtime — preflight: curl not found; fix: install curl (INSTALL_PREFLIGHT_FAILED; d)'); + expect(fs.existsSync(g.pending)).toBe(true); + }); + test('off Capy the caller\'s project is certified by the doctor\'s runtime and pins rows', () => { const f = fixture(); write(path.join(f.root, 'bin/gstack-doctor'), '#!/usr/bin/env bash\necho "doctor $*" >> "$UPGRADE_LOG"\n[ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — x"; echo "gstack: fail pins"; exit 1; }\necho "gstack: ok 1.0.1 for=require=runtime,pins"\n', 0o755);