diff --git a/bin/gstack-doctor b/bin/gstack-doctor index 75be5e044..c54525bff 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -88,8 +88,9 @@ row() { esac } -# sq TEXT — TEXT single-quoted for bash. +# sq TEXT — TEXT single-quoted for bash; psq TEXT — single-quoted for PowerShell. sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +psq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"; } # clamp CHARSET MAX — stdin's first line reduced to CHARSET, at most MAX chars. clamp() { head -1 | tr -cd "$1" | cut -c1-"$2"; } diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index 8c5a49cf7..016acf266 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -2,8 +2,8 @@ # gstack-doctor-check.sh — `gstack-doctor --check`: the one-line install verdict. # # Sourced by bin/gstack-doctor after its prelude (ROOT, ROOT_REAL, BIN, SRC, -# DOCS, STATE_ROOT, EXE, clamp, the launch probe, the codex status helpers) -# and never executed on its own. Prints `required: `, one +# DOCS, STATE_ROOT, EXE, WINDOWS, clamp, sq, psq, the launch probe, the codex +# status helpers) and never executed on its own. Prints `required: `, one # `PASS | FAIL | SKIP ` row per component in bin/gstack-doctor-components.sh # (generated from lib/doctor-components.ts; the same ids feed --for, --require # and --json), then exactly one trailer: `gstack: ok for=` on @@ -16,7 +16,8 @@ # Inputs (set by bin/gstack-doctor): CHECK_FOR (skill), CHECK_REQUIRE (csv), # CHECK_JSON (0|1), CHECK_PROJECT (path or ""), CHECK_REVISION (ref or ""), # CHECK_MIN_REVISION (the supported minimum the Capy wrapper read from the -# remote's capy/support.env, `unknown` when its fetch failed, or ""), LIVE. +# remote's capy/support.env, `unknown` when its fetch failed, or ""), LIVE, +# WINDOWS (1 on Windows; GSTACK_DOCTOR_PLATFORM overrides the detection). # GSTACK_CAPY_WRAPPER_PATH (optional) names the wrapper a below-minimum fix runs. . "$BIN/gstack-doctor-components.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-components.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } @@ -378,6 +379,21 @@ _dc_version_below() { return 1 } +# _dc_upgrade_cmd — the command that moves this install to the latest release. +# On Windows bin/gstack-upgrade refuses every Git Bash install (it compares a +# pwd -P /c/... path with git's C:/... toplevel), so the held-install recovery +# forms from docs/troubleshooting.md#auto-update-held-install-recovery are +# named instead, one per shell, with the install path quoted for each. +_dc_upgrade_cmd() { + local win + if [ "$WINDOWS" -ne 1 ]; then printf '%s' "$SRC/bin/gstack-upgrade"; return; fi + win=$(cygpath -w "$SRC" 2>/dev/null) || win="$SRC" + printf '%s' "run the held-install recovery (bin/gstack-upgrade refuses on Git Bash installs): in Git Bash: cd $(sq "$SRC")" + printf '%s' ' && git fetch origin main && d=$(mktemp -d) && git show FETCH_HEAD:bin/gstack-ff-update > "$d/gstack-ff-update" && bash "$d/gstack-ff-update" . FETCH_HEAD; s=$?; rm -rf "$d"; [ "$s" = 0 ] && ./setup' + printf '%s' " — or in Windows PowerShell 5.1: Set-Location -LiteralPath $(psq "$win")" + printf '%s' " -ErrorAction Stop; \$gb = Join-Path (git --exec-path) '..\\..\\..\\bin\\bash.exe'; & \$gb -c 'set -f; IFS=; git fetch origin main && d=\$(mktemp -d) && git show FETCH_HEAD:bin/gstack-ff-update > \$d/gstack-ff-update && bash \$d/gstack-ff-update . FETCH_HEAD; s=\$?; rm -rf -- \$d; exit \$s'; if (\$LASTEXITCODE -eq 0) { .\\setup.ps1 }" +} + # _dc_check_min_revision INSTALLED HEAD — sets the PASS-row suffix _dc_min_note, # or prints the REVISION_BELOW_MINIMUM FAIL row for --min-revision and returns 1. # Returns 1 after printing a FAIL row. @@ -390,7 +406,9 @@ _dc_check_min_revision() { esac if _dc_version_below "$installed" "$CHECK_MIN_REVISION"; then if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then - fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (fast-forwards), or $SRC/bin/gstack-upgrade" + fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (fast-forwards), or $(_dc_upgrade_cmd)" + elif [ "$WINDOWS" -eq 1 ]; then + fix="$(_dc_upgrade_cmd)" else fix="$SRC/bin/gstack-upgrade (fast-forwards to the remote default branch, then reinstalls)" fi @@ -419,7 +437,9 @@ _dc_check_revision() { _dc_row PASS revision "$installed ($head) is --revision $want$_dc_min_note" return fi - _dc_row FAIL revision "installed ${installed:-unknown}${head:+ at $head} (requested $want)" "cd $SRC && git fetch origin && git checkout $want && ./setup, or run $SRC/bin/gstack-upgrade to move to the latest release (interactive: /gstack-upgrade)" REVISION_UNMET + local latest="run $SRC/bin/gstack-upgrade to move to the latest release (interactive: /gstack-upgrade)" + [ "$WINDOWS" -eq 1 ] && latest="to move to the latest release, $(_dc_upgrade_cmd)" + _dc_row FAIL revision "installed ${installed:-unknown}${head:+ at $head} (requested $want)" "cd $SRC && git fetch origin && git checkout $want && ./setup, or $latest" REVISION_UNMET } # _dc_wrapper_rev FILE — the GSTACK_CAPY_WRAPPER revision a Capy skill copy diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index ccc57c759..aa2c7c53a 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -760,7 +760,8 @@ describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pi expect(below.rows.revision.status).toBe('FAIL'); expect(below.rows.revision.rest).toContain(`installed ${VERSION}`); expect(below.rows.revision.rest).toContain('below the supported minimum 99.0.0.0'); - expect(below.rows.revision.rest).toContain(`${bashPath(f.root)}/bin/gstack-upgrade`); + // On Windows the fix names the held-install recovery instead (bin/gstack-upgrade refuses Git Bash installs). + expect(below.rows.revision.rest).toContain(process.platform === 'win32' ? 'in Git Bash: ' : `${bashPath(f.root)}/bin/gstack-upgrade`); expect(below.rows.revision.rest).toContain('(REVISION_BELOW_MINIMUM; '); expect(below.trailer).toBe('gstack: fail revision'); const wrapped = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' }); @@ -1033,4 +1034,43 @@ describe('gstack-doctor: rescue refs, CLAUDE.md skill lists, Windows bash, Windo expect(refused.status).toBe(1); expect(doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'posix' }).out).not.toContain('windows bash'); }); + + /** A held-install recovery form from docs/troubleshooting.md (the line after its form marker's fence). */ + const form = (name: string) => { + const doc = fs.readFileSync(path.join(REPO, 'docs/troubleshooting.md'), 'utf8'); + return doc.slice(doc.indexOf(``)).match(/```[a-z]+\n([^\n]+)\n```/)![1]; + }; + const check = (f: Fixture, args: string[], extra: Record = {}) => { + const r = doctor(f, ['--check', ...args], extra); + return { ...r, revision: r.out.split('\n').find(l => /^(PASS|FAIL) revision\b/.test(l)) ?? '' }; + }; + + test('--check on Windows: the upgrade fix names both held-install recovery forms, each labelled by shell with the install path quoted, never bin/gstack-upgrade; elsewhere it keeps bin/gstack-upgrade', () => { + const f = makeFixture(); + const root = bashPath(f.root); + const win = spawnSync('bash', ['-c', 'cygpath -w "$1" 2>/dev/null || printf %s "$1"', '_', root], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const gitBash = form('recovery-gitbash').replace('cd ~/.claude/skills/gstack', `cd '${root}'`); + const powershell = form('recovery-powershell').replace('"$HOME\\.claude\\skills\\gstack"', `'${win}'`); + expect(gitBash).toContain(`cd '${root}' && `); + expect(powershell).toContain(`Set-Location -LiteralPath '${win}' `); + const windows = { GSTACK_DOCTOR_PLATFORM: 'windows' }; + for (const [args, extra] of [ + [['--min-revision', '99.0.0.0'], windows], + [['--min-revision', '99.0.0.0'], { ...windows, GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' }], + [['--revision', 'v0.0.0.1'], windows], + ] as Array<[string[], Record]>) { + const r = check(f, args, extra); + expect(r.revision, r.out).toStartWith('FAIL revision'); + expect(r.revision).toContain(`in Git Bash: ${gitBash}`); + expect(r.revision).toContain(`in Windows PowerShell 5.1: ${powershell}`); + expect(r.revision).not.toContain(`${root}/bin/gstack-upgrade`); + } + expect(spawnSync('bash', ['-n', '-c', gitBash], { encoding: 'utf8', timeout: 10_000 }).status).toBe(0); + const json = JSON.parse(doctor(f, ['--check', '--json', '--min-revision', '99.0.0.0'], windows).out); + expect(json.rows.find((x: { id: string }) => x.id === 'revision').fix).toContain(`in Windows PowerShell 5.1: ${powershell}`); + const posix = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_DOCTOR_PLATFORM: 'posix' }); + expect(posix.revision).toContain(`fix: ${root}/bin/gstack-upgrade (fast-forwards to the remote default branch, then reinstalls)`); + expect(posix.revision).not.toContain('in Git Bash:'); + expect(check(f, ['--revision', 'v0.0.0.1'], { GSTACK_DOCTOR_PLATFORM: 'posix' }).revision).toContain(`or run ${root}/bin/gstack-upgrade to move to the latest release`); + }); });