From cd7485b0b4dcd9717d4d459f4305e544da6afc2d Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 30 Sep 2026 06:14:46 +0000 Subject: [PATCH] W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin. --- bin/gstack-analytics | 6 +- bin/gstack-artifacts-init | 4 +- bin/gstack-brain-context-load.ts | 3 +- bin/gstack-brain-enqueue | 4 +- bin/gstack-brain-restore | 4 +- bin/gstack-brain-sync | 4 +- bin/gstack-brain-uninstall | 4 +- bin/gstack-codex-probe | 22 +++--- bin/gstack-codex-session-import | 4 +- bin/gstack-design-detect.ts | 40 +++------- bin/gstack-developer-profile | 4 +- bin/gstack-distill-apply | 4 +- bin/gstack-distill-free-text | 4 +- bin/gstack-egress-lib.sh | 10 +-- bin/gstack-gbrain-read-capability.ts | 4 +- bin/gstack-gbrain-source-wireup | 4 +- bin/gstack-gbrain-sync.ts | 5 +- bin/gstack-learnings-log | 4 +- bin/gstack-learnings-search | 4 +- bin/gstack-memorable | 8 +- bin/gstack-memory-ingest.ts | 3 +- bin/gstack-question-log | 4 +- bin/gstack-question-preference | 4 +- bin/gstack-relink | 10 ++- bin/gstack-repo-mode | 4 +- bin/gstack-retro-metrics | 6 +- bin/gstack-review-log | 4 +- bin/gstack-review-read | 4 +- bin/gstack-session-update | 5 +- bin/gstack-skill-end | 4 +- bin/gstack-skill-start | 4 +- bin/gstack-slug | 6 +- bin/gstack-specialist-stats | 4 +- bin/gstack-state-root.sh | 26 +++++-- bin/gstack-telemetry-log | 8 +- bin/gstack-telemetry-sync | 6 +- bin/gstack-timeline-log | 4 +- bin/gstack-timeline-read | 4 +- bin/gstack-uninstall | 82 +++++++++++++++++++-- bin/gstack-update-check | 6 +- bin/gstack-verify-gate | 16 +++- browse/src/browser-skill-write.ts | 4 +- browse/src/browser-skills.ts | 6 +- browse/src/config.ts | 35 +++------ browse/src/domain-skills.ts | 4 +- browse/src/security-classifier.ts | 4 +- browse/src/telemetry.ts | 4 +- browse/src/tunnel-denial-log.ts | 4 +- browse/test/gstack-update-check.test.ts | 5 ++ careful/bin/hook-extract.sh | 46 ++++++------ design/src/daemon-state.ts | 6 +- freeze/bin/freeze-state.sh | 4 + gstack-upgrade/migrations/v0.16.2.0.sh | 3 +- gstack-upgrade/migrations/v1.0.0.0.sh | 3 +- gstack-upgrade/migrations/v1.17.0.0.sh | 4 +- gstack-upgrade/migrations/v1.27.0.0.sh | 3 +- gstack-upgrade/migrations/v1.37.0.0.sh | 3 +- gstack-upgrade/migrations/v1.38.1.0.sh | 3 +- gstack-upgrade/migrations/v1.40.0.0.sh | 3 +- gstack-upgrade/migrations/v1.58.0.0.sh | 3 +- gstack-upgrade/migrations/v1.65.0.0.sh | 3 +- gstack-upgrade/migrations/v1.78.0.0.sh | 3 +- ios-qa/daemon/src/allowlist.ts | 4 +- ios-qa/daemon/src/audit.ts | 8 +- ios-qa/daemon/src/single-instance.ts | 4 +- ios-qa/scripts/gen-accessors.ts | 4 +- lib/bin-context.ts | 4 +- lib/code-intelligence/selection.ts | 4 +- lib/cso/state.ts | 6 +- lib/egress-receipt.ts | 11 +-- lib/gbrain-local-status.ts | 6 +- lib/gstack-decision.ts | 4 +- lib/gstack-memory-helpers.ts | 5 +- lib/redact-audit-log.ts | 3 +- lib/state-root.ts | 12 ++- scripts/analytics.ts | 4 +- scripts/declared-annotation.ts | 8 +- scripts/gen-skill-docs.ts | 5 +- setup | 21 ++++-- test/freeze-owned-lifecycle.test.ts | 2 +- test/gen-skill-docs.test.ts | 1 + test/gstack-skill-start.test.ts | 1 + test/helpers/health-eval-fixture.ts | 1 + test/hook-scripts.test.ts | 16 ++-- test/ios-qa-regen.test.ts | 2 + test/plan-tune-cathedral.test.ts | 5 +- test/relink.test.ts | 2 + test/setup-claude-code-migration.test.ts | 2 +- test/skill-e2e-bws.test.ts | 2 +- test/skill-e2e-context-skills.test.ts | 2 +- test/skill-e2e-learnings.test.ts | 2 +- test/skill-e2e-plan-tune.test.ts | 1 + test/skill-e2e-session-intelligence.test.ts | 2 +- test/skill-e2e-skillify.test.ts | 2 +- test/state-root-one-variable.test.ts | 68 +++++++++++++++++ test/team-mode.test.ts | 2 +- test/uninstall.test.ts | 74 +++++++++++++++++-- test/user-render-out-dir-install.test.ts | 1 + 98 files changed, 546 insertions(+), 268 deletions(-) create mode 100644 test/state-root-one-variable.test.ts diff --git a/bin/gstack-analytics b/bin/gstack-analytics index ad06edd16..8e814bcc9 100755 --- a/bin/gstack-analytics +++ b/bin/gstack-analytics @@ -8,10 +8,12 @@ # gstack-analytics all # all time # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) set -uo pipefail -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +STATE_DIR="$_gstack_sr_root" JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" # ─── Parse time window ─────────────────────────────────────── diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index d2c1a8cf3..a82f9e7a4 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -46,7 +46,9 @@ BASH_COMPAT=50 set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" URL_BIN="$SCRIPT_DIR/gstack-artifacts-url" REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" diff --git a/bin/gstack-brain-context-load.ts b/bin/gstack-brain-context-load.ts index 647ceb9d4..4e41b4952 100644 --- a/bin/gstack-brain-context-load.ts +++ b/bin/gstack-brain-context-load.ts @@ -38,6 +38,7 @@ import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants import { join, dirname, basename, resolve, delimiter } from "path"; import { spawnSync } from "child_process"; import { homedir } from "os"; +import { resolveStateRoot } from "../lib/state-root"; import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; @@ -67,7 +68,7 @@ interface QueryResult { // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); -const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const GSTACK_HOME = resolveStateRoot(); // 500ms hard cap per Section 1C; overridable for slow/loaded environments // (test harnesses under CI load, cold CLI starts). const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500); diff --git a/bin/gstack-brain-enqueue b/bin/gstack-brain-enqueue index 815eff31b..e0e97fb8b 100755 --- a/bin/gstack-brain-enqueue +++ b/bin/gstack-brain-enqueue @@ -31,7 +31,9 @@ set -uo pipefail FILE="${1:-}" [ -z "$FILE" ] && exit 0 -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" SPOOL="$GSTACK_HOME/.brain-queue.d" SKIP_FILE="$GSTACK_HOME/.brain-skip.txt" diff --git a/bin/gstack-brain-restore b/bin/gstack-brain-restore index 781ba700a..f81a14d13 100755 --- a/bin/gstack-brain-restore +++ b/bin/gstack-brain-restore @@ -37,7 +37,9 @@ BASH_COMPAT=50 set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index fcdd48b94..c203937f6 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -29,7 +29,9 @@ BASH_COMPAT=50 set -uo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" # Maildir-style spool: one FILE per record, --.json. # Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file # + atomic rename; the drain deletes exactly the files it snapshotted. No diff --git a/bin/gstack-brain-uninstall b/bin/gstack-brain-uninstall index a240a855e..cb2a289c9 100755 --- a/bin/gstack-brain-uninstall +++ b/bin/gstack-brain-uninstall @@ -41,7 +41,9 @@ set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" # v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 2c372b02e..4c58ebe08 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -63,8 +63,11 @@ _gstack_codex_model_probe() { # Only call this AFTER _gstack_codex_auth_probe passes — probing without # auth just measures the auth failure again. local _codex_home="${CODEX_HOME:-$HOME/.codex}" - local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}" - local _cache="$_gstack_home/.codex-model-probe" + # State root from the shared twin (sourced in a subshell, so the caller's + # shell is untouched). A missing twin only disables the cache. + local _gstack_home _cache="" + _gstack_home="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home="" + [ -n "$_gstack_home" ] && _cache="$_gstack_home/.codex-model-probe" local _model="${GSTACK_CODEX_MODEL:-gpt-6-astra}" # Cache signature: config.toml + auth.json mtimes + gstack model selection. # Editing the model env/config or re-logging-in invalidates the cached result @@ -83,7 +86,7 @@ _gstack_codex_model_probe() { _sig="${_cfg_m}-${_auth_m}-${_model_sig}" local _now _now=$(date +%s 2>/dev/null || echo 0) - if [ -f "$_cache" ]; then + if [ -n "$_cache" ] && [ -f "$_cache" ]; then local _c_line _c_status _c_ts _c_sig _c_line=$(head -1 "$_cache" 2>/dev/null) _c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1) @@ -105,14 +108,12 @@ _gstack_codex_model_probe() { _out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only -c "model=\"$_model\"" "reply OK" &1) _code=$? if [ "$_code" -eq 0 ]; then - mkdir -p "$_gstack_home" 2>/dev/null || true - printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + [ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; } echo "MODEL_OK" return 0 fi if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then - mkdir -p "$_gstack_home" 2>/dev/null || true - printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + [ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; } echo "MODEL_UNUSABLE" printf '%s\n' "$_out" | grep -i "model" | head -3 echo "HINT: gstack requested model '$_model'." @@ -221,12 +222,15 @@ _gstack_codex_log_event() { local _event="$1" local _duration="${2:-0}" [ "${_TEL:-off}" = "off" ] && return 0 - mkdir -p "$HOME/.gstack/analytics" 2>/dev/null || return 0 + local _root + _root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0 + [ -n "$_root" ] || return 0 + mkdir -p "$_root/analytics" 2>/dev/null || return 0 local _ts _ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown) printf '{"skill":"codex","event":"%s","duration_s":"%s","ts":"%s"}\n' \ "$_event" "$_duration" "$_ts" \ - >> "$HOME/.gstack/analytics/skill-usage.jsonl" 2>/dev/null || true + >> "$_root/analytics/skill-usage.jsonl" 2>/dev/null || true } # --- Learnings log on hang -------------------------------------------------- diff --git a/bin/gstack-codex-session-import b/bin/gstack-codex-session-import index 7b1c5f07c..b4236202e 100755 --- a/bin/gstack-codex-session-import +++ b/bin/gstack-codex-session-import @@ -21,7 +21,9 @@ # derive all apply uniformly. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}" MODE="latest" diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index 079ac37a2..76a24224a 100755 --- a/bin/gstack-design-detect.ts +++ b/bin/gstack-design-detect.ts @@ -57,7 +57,7 @@ * * Scan hardening: every target, explicit or derived from `--changed`, must be an * existing regular file or directory whose realpath lies under the repo root (or - * cwd) or under ${GSTACK_HOME:-~/.gstack}/projects//designs/ (where design- + * cwd) or under /projects//designs/ (where design- * review keeps rendered-DOM dumps: `designs//dom/**` are page dumps and * scan with --no-inline-ignores, because an `impeccable-disable` comment there is * page-controlled; other designs/ files are gstack-authored artifacts and keep @@ -77,7 +77,7 @@ * whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED). * * Observability: one content-free JSON line per probe/scan appended to - * ${GSTACK_HOME:-~/.gstack}/analytics/design-detector.jsonl (local file, no egress). + * /analytics/design-detector.jsonl (local file, no egress). * * Non-sink: this spawns a third-party binary the user installed over local * paths; gstack does not audit that engine's network behavior (NOTICE.md). @@ -94,6 +94,7 @@ import { ENGINE_RELEASE_BASE, ENGINE_ASSETS, ENGINE_PINS, } from '../lib/design-detect-contract'; import { writeReceipt, writeOutcome } from '../lib/egress-receipt'; +import { readConfigKey, resolveStateRoot } from '../lib/state-root'; import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog'; import { isFrontendPath } from '../lib/frontend-scope'; @@ -104,14 +105,9 @@ const HOME = os.homedir(); const REAL_HOME = realpathOrNull(HOME) ?? HOME; const ENV = process.env; -/** Where config.yaml lives: the same precedence bin/gstack-config uses. */ -function gstackStateDir(): string { - return ENV.GSTACK_STATE_ROOT || ENV.GSTACK_HOME || ENV.GSTACK_STATE_DIR || path.join(HOME, '.gstack'); -} - -/** Existing local analytics location; design artifacts resolve separately below. */ +/** Config, analytics and design artifacts share the one state root (lib/state-root.ts). */ function gstackHome(): string { - return ENV.GSTACK_HOME || path.join(HOME, '.gstack'); + return resolveStateRoot(ENV); } function realpathOrNull(p: string): string | null { @@ -130,23 +126,10 @@ function gitTopLevel(cwd: string): string | null { return top ? realpathOrNull(top) : null; } -/** One flat key from config.yaml, read the way bin/gstack-config resolves it (same STATE_DIR precedence); '' when unset. */ +/** One flat key from config.yaml via readConfigKey (the bin/gstack-config reader); '' when unset. */ function configValue(key: string): string { - const file = path.join(gstackStateDir(), 'config.yaml'); - try { - const text = fs.readFileSync(file, 'utf-8'); - let value = ''; - const re = new RegExp(`^${key}:\\s*(.*?)\\s*$`); - for (const line of text.split('\n')) { - const m = line.match(re); - if (!m) continue; - // flat YAML: drop a trailing comment and surrounding quotes - value = m[1].replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1'); - } - return value; - } catch { - return ''; - } + // flat YAML: drop a trailing comment and surrounding quotes + return (readConfigKey(key, ENV) ?? '').replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1'); } /** design_detector: `Off` by hand must not silently re-enable a third-party binary. */ @@ -785,12 +768,7 @@ function refuse(target: string, why: string) { } function designsRoot(): string { - // Match the artifact producer's bin/gstack-paths precedence without changing - // config or analytics roots. Plugin data belongs to gstack only with its marker. - const stateRoot = ENV.GSTACK_HOME - || (ENV.CLAUDE_PLUGIN_DATA && /gstack/i.test(ENV.CLAUDE_PLUGIN_ROOT || '') ? ENV.CLAUDE_PLUGIN_DATA : '') - || (ENV.HOME ? path.join(ENV.HOME, '.gstack') : '.gstack'); - return path.join(stateRoot, 'projects'); + return path.join(resolveStateRoot(ENV), 'projects'); } type TargetClass = 'project' | 'artifact' | 'dom-dump'; diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index 57ba9adb9..e5f4197f8 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -29,7 +29,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" # Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which # Bun on Windows cannot open as a filesystem path (bites --derive). Normalize # once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below — diff --git a/bin/gstack-distill-apply b/bin/gstack-distill-apply index 5b97da0aa..d87128d40 100755 --- a/bin/gstack-distill-apply +++ b/bin/gstack-distill-apply @@ -24,7 +24,9 @@ # gstack-distill-apply --list # show pending proposals set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" diff --git a/bin/gstack-distill-free-text b/bin/gstack-distill-free-text index a7e997c0a..86cbd8918 100755 --- a/bin/gstack-distill-free-text +++ b/bin/gstack-distill-free-text @@ -32,7 +32,9 @@ set -euo pipefail BASH_COMPAT=50 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" diff --git a/bin/gstack-egress-lib.sh b/bin/gstack-egress-lib.sh index 37fe045a7..4cb50dbfb 100644 --- a/bin/gstack-egress-lib.sh +++ b/bin/gstack-egress-lib.sh @@ -43,13 +43,13 @@ case "${BASH_SOURCE[0]}" in *) _gstack_egress_lib_dir="$(pwd)" ;; esac +# State root from the shared twin (bin/gstack-state-root.sh, builtins only), +# sourced lazily on first use. _gstack_egress_home() { - if [ -n "${GSTACK_HOME:-}" ]; then - printf '%s' "$GSTACK_HOME" - elif [ -n "${GSTACK_STATE_DIR:-}" ]; then - printf '%s' "$GSTACK_STATE_DIR" + if command -v gstack_state_root >/dev/null 2>&1 || { [ -f "$_gstack_egress_lib_dir/gstack-state-root.sh" ] && . "$_gstack_egress_lib_dir/gstack-state-root.sh"; }; then + gstack_state_root else - printf '%s' "$HOME/.gstack" + printf '%s' "" fi } diff --git a/bin/gstack-gbrain-read-capability.ts b/bin/gstack-gbrain-read-capability.ts index 4850d0409..6cdee19cb 100644 --- a/bin/gstack-gbrain-read-capability.ts +++ b/bin/gstack-gbrain-read-capability.ts @@ -1,10 +1,10 @@ #!/usr/bin/env bun import { readFileSync, realpathSync, statSync } from 'node:fs'; -import { homedir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { gbrainInvocation, buildGbrainEnv } from '../lib/gbrain-exec'; import { parseSourcesList } from '../lib/gbrain-sources'; +import { resolveStateRoot } from '../lib/state-root'; type Verdict = { status: 'ready' | 'unknown' | 'skipped' | 'source'; reason: string; source_id?: string; page_count?: number }; @@ -20,7 +20,7 @@ function readCapability(): Verdict { try { root = realpathSync(repo.stdout.trim()); const pinPath = join(root, '.gbrain-source'); - const statePath = join(process.env.GSTACK_HOME || join(homedir(), '.gstack'), '.gbrain-sync-state.json'); + const statePath = join(resolveStateRoot(), '.gbrain-sync-state.json'); if (statSync(pinPath).size > 512 || statSync(statePath).size > 64 * 1024) return unknown('sync state or source pin exceeds the read limit'); pin = readFileSync(pinPath, 'utf8').trim(); diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 7947fd587..05e50b1df 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -43,7 +43,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" # v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 9b0b67ed8..29d644c6c 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -44,6 +44,7 @@ import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-s import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec"; import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; import { checkOwnedStagingDir } from "../lib/staging-guard"; +import { resolveStateRoot } from "../lib/state-root"; // ── Types ────────────────────────────────────────────────────────────────── @@ -98,7 +99,7 @@ interface StageResult { // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); -const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const GSTACK_HOME = resolveStateRoot(); const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json"); const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock"); const STALE_LOCK_MS = 5 * 60 * 1000; @@ -118,7 +119,7 @@ const DREAM_MARKER_STALE_MS = DEFAULT_DREAM_TIMEOUT_MS; * module-load-time const captures the real ~/.gstack before a test can redirect. */ export function dreamMarkerPath(): string { - return join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), ".dream-in-progress"); + return join(resolveStateRoot(), ".dream-in-progress"); } // Default 35-minute timeout for code-walk + memory-ingest stages. Override via diff --git a/bin/gstack-learnings-log b/bin/gstack-learnings-log index 3c47ebb80..fd0efaa56 100755 --- a/bin/gstack-learnings-log +++ b/bin/gstack-learnings-log @@ -14,7 +14,9 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; esac eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index d7038e821..4e3d8ba46 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -8,7 +8,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" TYPE="" QUERY="" diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 31b1a1dfd..993f163db 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -36,7 +36,9 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" GSTACK_CONFIG="$SCRIPT_DIR/gstack-config" -STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +STATE_DIR="$_gstack_sr_root" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" HOOK_SOURCE="gstack-memorable" @@ -380,9 +382,9 @@ status_bridge() { n="unknown (gstack-egress list failed; run it yourself)" fi echo "receipts: $n for sink $SINK (gstack-egress list --sink $SINK)" - # Same resolution as lib/egress-receipt.ts resolveEgressHome: GSTACK_HOME, GSTACK_STATE_DIR, ~/.gstack. + # Same root lib/egress-receipt.ts resolveEgressHome uses (the shared state-root chain). local ledger size - ledger="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}/security/egress.jsonl" + ledger="$STATE_DIR/security/egress.jsonl" if [ -f "$ledger" ]; then size="$(wc -c < "$ledger" | tr -d ' ')" if [ "${size:-0}" -gt 26214400 ]; then diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 412ee48f9..a94adf4fc 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -72,6 +72,7 @@ import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec"; import { writeReceipt } from "../lib/egress-receipt"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; +import { resolveStateRoot } from "../lib/state-root"; // ── Types ────────────────────────────────────────────────────────────────── @@ -186,7 +187,7 @@ interface BulkResult { // ── Constants ────────────────────────────────────────────────────────────── const HOME = homedir(); -const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); +const GSTACK_HOME = resolveStateRoot(); const STATE_PATH = join(GSTACK_HOME, ".transcript-ingest-state.json"); const DEFAULT_INCREMENTAL_BUDGET_MS = 50; diff --git a/bin/gstack-question-log b/bin/gstack-question-log index ea1afe1e3..d769bf371 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -35,7 +35,9 @@ case "$(uname -s)" in esac eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index f78d4b269..51de1347e 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -32,7 +32,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). -GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json" diff --git a/bin/gstack-relink b/bin/gstack-relink index ecc609cae..bafd24ef6 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -5,7 +5,7 @@ # gstack-relink # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) # GSTACK_INSTALL_DIR — override gstack install directory # GSTACK_SKILLS_DIR — override target skills directory set -euo pipefail @@ -40,7 +40,9 @@ PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false") # out-dir instead of the tracked install checkout. When a render exists for a # skill, relink serves it — otherwise a config change would silently flip # every skill back to the canonical (blockless) source. -RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" +. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" +RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" # ─── Ownership gate ─────────────────────────────────────────────────────────── # relink runs on every ./setup and used to `rm -rf` any same-name entry with a @@ -58,7 +60,7 @@ RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claud # refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs' # two-line banner on a real file) proves only that the SKILL.md came from us: # it authorizes touching that one file, never deleting the directory, and a -# differing file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills// +# differing file is moved to $GSTACK_STATE_ROOT/backups/skills// # before we link over it (a user who started their own skill from a gstack # SKILL.md looks exactly like a pre-marker legacy copy). # @@ -183,7 +185,7 @@ _report_foreign() { # Weakly-proven real files we would otherwise overwrite go here, one summary # line at the end. mv, not cp: the link that follows needs the path free. -BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)" +BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)" BACKED_UP=() _backup_skill_md() { # Non-zero when the file could NOT be moved: the caller leaves the entry alone. diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode index a5c5a6ba6..7b1dd901e 100755 --- a/bin/gstack-repo-mode +++ b/bin/gstack-repo-mode @@ -41,7 +41,9 @@ if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then fi # Check cache (7-day TTL) -CACHE_DIR="$HOME/.gstack/projects/$SLUG" +. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" +CACHE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG" CACHE_FILE="$CACHE_DIR/repo-mode.json" if [ -f "$CACHE_FILE" ]; then # GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics index 58c2c0518..e04f84a40 100755 --- a/bin/gstack-retro-metrics +++ b/bin/gstack-retro-metrics @@ -12,7 +12,7 @@ # # Conventions mirror bin/gstack-skill-start: # - Paths resolve $0-relative (works for every host + install layout). -# - State paths honor ${GSTACK_HOME:-$HOME/.gstack}. +# - State paths honor the shared state root (bin/gstack-paths). # - Error style: per-line `|| true`, never `set -e` — a mid-script failure # must not drop later METRIC lines. # - No heredocs (nothing to BASH_COMPAT-guard; see @@ -45,7 +45,9 @@ done # sibling-bin call must go through $_BIN, never bare PATH lookup). _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" -_GH="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +_GH="$_gstack_sr_root" echo "RETRO_METRICS_PROTO: 1" diff --git a/bin/gstack-review-log b/bin/gstack-review-log index c9f6149a0..c1bdab264 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -13,7 +13,9 @@ export GIT_OPTIONAL_LOCKS=0 export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="${1:-}" diff --git a/bin/gstack-review-read b/bin/gstack-review-read index 16139f1d8..3725c3e72 100755 --- a/bin/gstack-review-read +++ b/bin/gstack-review-read @@ -14,7 +14,9 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; esac eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown") if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun -e ' diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 15f8caa87..fdb2ce873 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -10,7 +10,10 @@ set +e GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +# Hook: source the state-root twin (never spawn gstack-paths); a broken +# install exits 0 silently, like every other error here. +. "$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" 2>/dev/null || exit 0 +STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}" # Egress receipt helpers (_receipted_git): fail-open — an update pull must # never block a session over a receipt hiccup. diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index b4b3b0174..b270b8f3a 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -30,7 +30,9 @@ done _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" -_GH="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +_GH="$_gstack_sr_root" _TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || echo off) _TEL_END=$(date +%s) diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 941e3b994..44eaac5b9 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -51,7 +51,9 @@ done _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _BIN="$_SCRIPT_DIR" -_GH="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +_GH="$_gstack_sr_root" # OV4: strip instruction markers from any passthrough text before echoing it # into the blessed tool result. Prior-session/repo content must not be able to diff --git a/bin/gstack-slug b/bin/gstack-slug index 12f0ed397..49cd56dc8 100755 --- a/bin/gstack-slug +++ b/bin/gstack-slug @@ -46,11 +46,13 @@ # injection when consumed via source or eval. set -euo pipefail -# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the +# State-root aware (bin/gstack-state-root.sh), matching lib/bin-context.ts (#2561): the # bash writer and the TS reader must key the SAME cache, and a test running # with GSTACK_HOME= must write its cache junk there, not into the real # home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack). -CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" +CACHE_DIR="$GSTACK_STATE_ROOT/slug-cache" PROJECT_DIR="$(pwd)" # Encode absolute path as cache key: /Users/j/foo → _Users_j_foo CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_') diff --git a/bin/gstack-specialist-stats b/bin/gstack-specialist-stats index 3349c2b71..1b1d1eafb 100755 --- a/bin/gstack-specialist-stats +++ b/bin/gstack-specialist-stats @@ -8,7 +8,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" if [ ! -d "$PROJECT_DIR" ]; then diff --git a/bin/gstack-state-root.sh b/bin/gstack-state-root.sh index 236f2e27b..1ce264fda 100644 --- a/bin/gstack-state-root.sh +++ b/bin/gstack-state-root.sh @@ -85,6 +85,21 @@ _gstack_config_rank() { esac } +# gstack_legacy_root_select — sets _gstack_legacy_root to the second candidate +# root merged privacy settings are also read from: $HOME/.gstack +# (GSTACK_TEST_LEGACY_ROOT in tests), or "" when there is none or it is the +# resolved root. Call after gstack_state_root_select. +gstack_legacy_root_select() { + _gstack_legacy_root="" + if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then + _gstack_legacy_root="$GSTACK_TEST_LEGACY_ROOT" + elif [ -n "$_gstack_home_val" ]; then + _gstack_legacy_root="$_gstack_home_val/.gstack" + fi + [ "$_gstack_legacy_root" = "$_gstack_sr_root" ] && _gstack_legacy_root="" + return 0 +} + # gstack_config_select KEY — sets _gstack_cfg_value and _gstack_cfg_root (the # root that supplied it; both "" when unset). Merged privacy keys (telemetry, # memorable_recall, codex_reviews, update_check) take the most restrictive @@ -97,14 +112,9 @@ gstack_config_select() { [ -n "$_gstack_cfg_value" ] && _gstack_cfg_root="$_gstack_sr_root" _gstack_config_rank "$1" "x" [ -n "$_gstack_cfg_rank" ] || return 0 - if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then - _gstack_cfg_legacy="$GSTACK_TEST_LEGACY_ROOT" - elif [ -n "$_gstack_home_val" ]; then - _gstack_cfg_legacy="$_gstack_home_val/.gstack" - else - return 0 - fi - [ "$_gstack_cfg_legacy" = "$_gstack_sr_root" ] && return 0 + gstack_legacy_root_select + [ -n "$_gstack_legacy_root" ] || return 0 + _gstack_cfg_legacy="$_gstack_legacy_root" _gstack_config_from_root "$_gstack_cfg_legacy" "$1" [ -n "$_gstack_cfg_one" ] || return 0 if [ -z "$_gstack_cfg_value" ]; then diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index 7b95e5ea7..f75fe7623 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -16,7 +16,7 @@ # epilogue may sweep. # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) # GSTACK_DIR — override auto-detected gstack root # # NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero @@ -29,7 +29,9 @@ SCRIPT_DIR="$GSTACK_DIR/bin" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; esac -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +STATE_DIR="$_gstack_sr_root" ANALYTICS_DIR="$STATE_DIR/analytics" JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here @@ -148,7 +150,7 @@ fi # can't be guessed or correlated by someone who knows your machine identity. INSTALL_ID="" if [ "$TIER" = "community" ]; then - ID_FILE="$HOME/.gstack/installation-id" + ID_FILE="$STATE_DIR/installation-id" if [ -f "$ID_FILE" ]; then INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)" fi diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync index 1a4ac02b8..9ad5d62f3 100755 --- a/bin/gstack-telemetry-sync +++ b/bin/gstack-telemetry-sync @@ -6,13 +6,15 @@ # Posts to the telemetry-ingest edge function (not PostgREST directly). # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) # GSTACK_DIR — override auto-detected gstack root # GSTACK_SUPABASE_URL — override Supabase project URL set -uo pipefail GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +STATE_DIR="$_gstack_sr_root" # Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed. . "$GSTACK_DIR/bin/gstack-egress-lib.sh" diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 6b7dc7e4e..0f24f8a23 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -12,7 +12,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" diff --git a/bin/gstack-timeline-read b/bin/gstack-timeline-read index 5c1b6bb6f..be2dc1d60 100755 --- a/bin/gstack-timeline-read +++ b/bin/gstack-timeline-read @@ -8,7 +8,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +GSTACK_HOME="$_gstack_sr_root" SINCE="" LIMIT=20 diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index eaa30a013..8adad5600 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -4,7 +4,15 @@ # Usage: # gstack-uninstall — interactive uninstall (prompts before removing) # gstack-uninstall --force — remove everything without prompting -# gstack-uninstall --keep-state — remove skills but keep ~/.gstack/ data +# gstack-uninstall --keep-state — remove skills but keep all gstack state +# +# State: only the default root ~/.gstack/ is ever deleted. A state root +# selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or +# plugin data; chain in bin/gstack-state-root.sh) is left in place and the +# exact removal command is printed. Uninstall refuses (exit 2) when ~/.gstack +# resolves to /, $HOME or an ancestor, the gstack checkout, the current git +# repository, or an ancestor of either. +# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md # # What gets REMOVED: # ~/.claude/skills/gstack — global Claude skill install (git clone or vendored) @@ -28,7 +36,6 @@ # # Env overrides (for testing): # GSTACK_DIR — override auto-detected gstack root -# GSTACK_STATE_DIR — override ~/.gstack state directory # # NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway. set -uo pipefail @@ -39,7 +46,14 @@ if [ -z "${HOME:-}" ]; then fi GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +# Only the default root is ever deleted; RESOLVED_ROOT (the full chain) is +# used read-only (daemon discovery) and reported when it is elsewhere. +STATE_DIR="$HOME/.gstack" +_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade ($_STATE_DOC)" >&2; exit 1; } +gstack_state_root_select +RESOLVED_ROOT="$_gstack_sr_root" +RESOLVED_VAR="$_gstack_sr_var" _GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" # ─── Parse flags ───────────────────────────────────────────── @@ -61,6 +75,53 @@ while [ $# -gt 0 ]; do esac done +# ─── State-root safety ─────────────────────────────────────── +_real() { (cd -P -- "$1" 2>/dev/null && pwd -P); } +# _is_at_or_above TARGET PATH — TARGET is PATH or an ancestor of it. +_is_at_or_above() { + [ -n "$2" ] || return 1 + [ "$1" = "$2" ] && return 0 + case "$2/" in "${1%/}/"*) return 0 ;; esac + return 1 +} +_refuse() { + echo "gstack-uninstall: refusing to delete $STATE_DIR: $1" >&2 + echo "fix: $2 (docs: $_STATE_DOC)" >&2 + exit 2 +} +if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then + _STATE_REAL="$(_real "$STATE_DIR")" + _HOME_REAL="$(_real "$HOME")" + _CHECKOUT_REAL="$(_real "$GSTACK_DIR")" + _GIT_REAL="" + [ -n "$_GIT_ROOT" ] && _GIT_REAL="$(_real "$_GIT_ROOT")" + if [ -z "$_STATE_REAL" ] || [ "$_STATE_REAL" = "/" ]; then + _refuse "it resolves to the filesystem root /" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" + elif _is_at_or_above "$_STATE_REAL" "$_HOME_REAL"; then + _refuse "it resolves to $_STATE_REAL, which is your home directory or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" + elif _is_at_or_above "$_STATE_REAL" "$_CHECKOUT_REAL"; then + _refuse "it resolves to $_STATE_REAL, which is the gstack checkout $_CHECKOUT_REAL or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state" + elif _is_at_or_above "$_STATE_REAL" "$_GIT_REAL"; then + _refuse "it resolves to $_STATE_REAL, which is the current git repository $_GIT_REAL or an ancestor of it" "run gstack-uninstall from outside $_GIT_REAL, or re-run with --keep-state" + fi +fi +# The resolved root is left in place when it is not the default root. +_LEAVE_ROOT="" +if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then + _RESOLVED_REAL="$(_real "$RESOLVED_ROOT")" + _DEFAULT_REAL="$(_real "$STATE_DIR")" + if [ "$RESOLVED_ROOT" != "$STATE_DIR" ] && { [ -z "$_RESOLVED_REAL" ] || [ "$_RESOLVED_REAL" != "$_DEFAULT_REAL" ]; }; then + _LEAVE_ROOT="$RESOLVED_ROOT" + fi +fi +_report_left_root() { + [ -n "$_LEAVE_ROOT" ] || return 0 + local _val + eval "_val=\${$RESOLVED_VAR:-}" + echo "left in place: $_LEAVE_ROOT (selected by $RESOLVED_VAR=$_val)" >&2 + echo "fix: after checking it, remove it with rm -rf -- '$_LEAVE_ROOT' (docs: $_STATE_DOC)" >&2 +} + # ─── Confirmation ──────────────────────────────────────────── if [ "$FORCE" -eq 0 ]; then echo "This will remove gstack from your system:" @@ -70,6 +131,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" [ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" + [ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place" if [ -n "$_GIT_ROOT" ]; then [ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)" @@ -125,12 +187,16 @@ if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json" fi -# Stop daemons tracked in global projects directory -if [ -d "$STATE_DIR/projects" ]; then +# Stop daemons tracked in global projects directories (the resolved root is +# only read here, never deleted). +_PROJ_ROOTS=("$STATE_DIR") +[ "$RESOLVED_ROOT" != "$STATE_DIR" ] && _PROJ_ROOTS+=("$RESOLVED_ROOT") +for _PROJ_ROOT in "${_PROJ_ROOTS[@]}"; do + [ -d "$_PROJ_ROOT/projects" ] || continue while IFS= read -r _BJ; do stop_browse_daemon "$_BJ" - done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) -fi + done < <(find "$_PROJ_ROOT/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) +done # ─── Remove gstack hooks from Claude Code settings ────────── # MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the @@ -400,6 +466,8 @@ for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-ske fi done +_report_left_root + # ─── Skipped-entry report ─────────────────────────────────── # Everything any provenance gate refused to delete (Claude shapes 2/3, # cursor real dirs) — listed once, at the end, so nothing is silent. diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 3573c7878..0337040be 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -10,7 +10,7 @@ # GSTACK_DIR — override auto-detected gstack root # GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback) # GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh) set -euo pipefail # A crash must not read as "up to date" (#1974). With set -e, any unguarded @@ -22,7 +22,9 @@ set -E trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +STATE_DIR="$_gstack_sr_root" # Egress receipt helpers (_receipted_curl / _receipted_git). Update checks # are fail-OPEN: a receipt hiccup warns but never blocks the version check. diff --git a/bin/gstack-verify-gate b/bin/gstack-verify-gate index 41f02cb5a..240eace6d 100755 --- a/bin/gstack-verify-gate +++ b/bin/gstack-verify-gate @@ -12,7 +12,7 @@ # NEVER runs until the user records it in the per-repo trust store: # gstack-verify-gate --trust (run from inside the repo) # The store maps realpath(repo root) -> sha256(command) at -# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "pathhash", +# $GSTACK_STATE_ROOT/verify-gate-trust (flat "pathhash", # 0600, atomic rewrite). Any edit to the declared command invalidates trust # until --trust is run again. Untrusted commands never block the turn. # @@ -23,7 +23,15 @@ set -uo pipefail TAB="$(printf '\t')" -STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust" +# Hook: source the state-root twin (never spawn gstack-paths). A broken +# install fails open, like every other absence. +_VG_TWIN="$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" +if [ ! -f "$_VG_TWIN" ] || ! . "$_VG_TWIN" 2>/dev/null; then + echo "verify-gate: $_VG_TWIN missing; gate skipped (reinstall with ./setup or /gstack-upgrade)" + exit 0 +fi +GSTACK_STATE_ROOT="$(gstack_state_root; printf x)"; GSTACK_STATE_ROOT="${GSTACK_STATE_ROOT%x}" +STORE="$GSTACK_STATE_ROOT/verify-gate-trust" _sha256() { if command -v shasum >/dev/null 2>&1; then @@ -100,7 +108,7 @@ _json_escape() { # Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim. _log_trust_grant() { local sec_dir log tty ts - sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security" + sec_dir="$GSTACK_STATE_ROOT/security" log="$sec_dir/verify-gate-trust-grants.jsonl" tty=false [ -t 0 ] && tty=true @@ -175,7 +183,7 @@ _session_key() { [ -n "$sid" ] || sid="ppid-$PPID" _sha256 "$sid|$(_trust_key)" } -ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts" +ATTEMPTS_DIR="$GSTACK_STATE_ROOT/verify-gate-attempts" COUNTER="$ATTEMPTS_DIR/$(_session_key)" # A first entry (stop_hook_active=false) starts a fresh blocking episode. diff --git a/browse/src/browser-skill-write.ts b/browse/src/browser-skill-write.ts index 81599b419..4d0262a46 100644 --- a/browse/src/browser-skill-write.ts +++ b/browse/src/browser-skill-write.ts @@ -18,11 +18,11 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { mkdirSecure } from './file-permissions'; import { isPathWithin } from './platform'; import type { TierPaths } from './browser-skills'; import { defaultTierPaths } from './browser-skills'; +import { resolveStateRoot } from '../../lib/state-root'; // ─── Naming validation ────────────────────────────────────────── @@ -71,7 +71,7 @@ export function stageSkill(opts: StageSkillOptions): string { } const spawnId = opts.spawnId ?? generateSpawnId(); - const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp'); + const tmpRoot = opts.tmpRoot ?? path.join(resolveStateRoot(), '.tmp'); const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`); const stagedDir = path.join(wrapperDir, opts.name); diff --git a/browse/src/browser-skills.ts b/browse/src/browser-skills.ts index 277f38096..009d3ea98 100644 --- a/browse/src/browser-skills.ts +++ b/browse/src/browser-skills.ts @@ -22,8 +22,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import * as cp from 'child_process'; +import { resolveStateRoot } from '../../lib/state-root'; // ─── Types ────────────────────────────────────────────────────── @@ -85,13 +85,13 @@ export interface TierPaths { * Project tier requires git or a project hint; returns null when neither resolves. */ export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths { - const home = opts.home ?? os.homedir(); + const stateRoot = opts.home !== undefined ? path.join(opts.home, '.gstack') : resolveStateRoot(); const projectRoot = opts.projectRoot ?? detectProjectRoot(); const bundledRoot = opts.bundledRoot ?? detectBundledRoot(); return { project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null, - global: path.join(home, '.gstack', 'browser-skills'), + global: path.join(stateRoot, 'browser-skills'), bundled: path.join(bundledRoot, 'browser-skills'), }; } diff --git a/browse/src/config.ts b/browse/src/config.ts index f7d7eeefa..2274dad7b 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -11,10 +11,10 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { mkdirSecure } from './file-permissions'; import { safeUnlinkQuiet } from './error-handling'; +import { readConfigKey, resolveStateRoot } from '../../lib/state-root'; export interface BrowseConfig { projectDir: string; @@ -193,39 +193,26 @@ export function readVersionHash(execPath: string = process.execPath): string | n } } -/** - * Resolve the gstack home directory. - * - * Honors the existing convention used by telemetry.ts and domain-skills.ts: - * 1. GSTACK_HOME env (explicit override) - * 2. $HOME/.gstack (default) - */ +/** The gstack state root: delegates to the shared chain in lib/state-root.ts. */ export function resolveGstackHome(): string { - return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + return resolveStateRoot(); } /** - * Read one key from the flat-YAML config store at /config.yaml - * (the shape bin/gstack-config writes: `key: value` lines). Tolerates - * optional single/double quotes around the value and a trailing `# comment`. - * Returns the unquoted value string, or null when the file is missing or - * unreadable or the key is absent. + * Read one key from the flat-YAML config store via readConfigKey (the same + * reader bin/gstack-config uses, including the most-restrictive merge for + * privacy keys such as telemetry). Tolerates optional single/double quotes + * around the value and a trailing `# comment`. Returns the unquoted value + * string, or null when the file is missing or unreadable or the key is absent. * * Single source of truth for flat-YAML key reads — isPairAgentEnabled * (pair_agent) and telemetry.ts (telemetry tier) both route through it so * the two consent gates can never drift on parsing semantics. */ export function readGstackConfigYamlKey(key: string): string | null { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - try { - const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8'); - // Last match wins: bin/gstack-config's `get` reads duplicates with - // `tail -1`, and both surfaces must agree on the same line. - const all = [...yaml.matchAll(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'gm'))]; - return all.length > 0 ? all[all.length - 1][1] : null; - } catch { - return null; - } + const raw = readConfigKey(key); + if (raw === null) return null; + return raw.replace(/\s*#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2'); } /** diff --git a/browse/src/domain-skills.ts b/browse/src/domain-skills.ts index 92258fee3..ae8112b8b 100644 --- a/browse/src/domain-skills.ts +++ b/browse/src/domain-skills.ts @@ -35,9 +35,9 @@ import { promises as fs } from 'fs'; import { open as fsOpen, constants as fsConstants } from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { createHash } from 'crypto'; import type { Page } from 'playwright'; +import { resolveStateRoot } from '../../lib/state-root'; export type SkillState = 'quarantined' | 'active' | 'global'; export type SkillScope = 'project' | 'global'; @@ -63,7 +63,7 @@ export interface DomainSkillRow { const PROMOTE_THRESHOLD = 3; function gstackHome(): string { - return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + return resolveStateRoot(); } function globalFile(): string { diff --git a/browse/src/security-classifier.ts b/browse/src/security-classifier.ts index a89ddf694..595dea73f 100644 --- a/browse/src/security-classifier.ts +++ b/browse/src/security-classifier.ts @@ -24,9 +24,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { mkdirSecure } from './file-permissions'; import { type LayerSignal } from './security'; +import { resolveStateRoot } from '../../lib/state-root'; // ─── Model location + packaging ────────────────────────────── @@ -45,7 +45,7 @@ import { type LayerSignal } from './security'; * vocab.txt * onnx/model.onnx (~112MB) */ -const MODELS_DIR = path.join(os.homedir(), '.gstack', 'models'); +const MODELS_DIR = path.join(resolveStateRoot(), 'models'); const TESTSAVANT_DIR = path.join(MODELS_DIR, 'testsavant-small'); const TESTSAVANT_HF_URL = 'https://huggingface.co/testsavantai/prompt-injection-defender-small-v0-onnx/resolve/main'; const TESTSAVANT_FILES = [ diff --git a/browse/src/telemetry.ts b/browse/src/telemetry.ts index c71ca8ae2..ad9092579 100644 --- a/browse/src/telemetry.ts +++ b/browse/src/telemetry.ts @@ -20,11 +20,11 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { readGstackConfigYamlKey } from './config'; +import { resolveStateRoot } from '../../lib/state-root'; function gstackHome(): string { - return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + return resolveStateRoot(); } function analyticsDir(): string { diff --git a/browse/src/tunnel-denial-log.ts b/browse/src/tunnel-denial-log.ts index 82b9c34a5..15bec5bd1 100644 --- a/browse/src/tunnel-denial-log.ts +++ b/browse/src/tunnel-denial-log.ts @@ -17,10 +17,10 @@ */ import { promises as fsp } from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { mkdirSecure } from './file-permissions'; +import { resolveStateRoot } from '../../lib/state-root'; -const LOG_DIR = path.join(os.homedir(), '.gstack', 'security'); +const LOG_DIR = path.join(resolveStateRoot(), 'security'); const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl'); const RATE_CAP = 60; // writes per minute const WINDOW_MS = 60_000; diff --git a/browse/test/gstack-update-check.test.ts b/browse/test/gstack-update-check.test.ts index 6b78263f7..a04886aa0 100644 --- a/browse/test/gstack-update-check.test.ts +++ b/browse/test/gstack-update-check.test.ts @@ -58,6 +58,11 @@ beforeEach(() => { join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'), join(binDir, 'gstack-egress-lib.sh'), ); + // Same for the state-root twin every migrated bin sources (docs/state-root.md). + symlinkSync( + join(import.meta.dir, '..', '..', 'bin', 'gstack-state-root.sh'), + join(binDir, 'gstack-state-root.sh'), + ); }); afterEach(() => { diff --git a/careful/bin/hook-extract.sh b/careful/bin/hook-extract.sh index 594b4d6c0..2934beb2e 100644 --- a/careful/bin/hook-extract.sh +++ b/careful/bin/hook-extract.sh @@ -64,40 +64,36 @@ gstack_hook_decision() { } # gstack_hook_state_root -# Print the gstack state root, resolved with EXACTLY the chain bin/gstack-paths -# uses (GSTACK_STATE_ROOT): GSTACK_HOME, then CLAUDE_PLUGIN_DATA only when -# CLAUDE_PLUGIN_ROOT names gstack (a CLAUDE_PLUGIN_DATA leaked from another -# plugin via CLAUDE_ENV_FILE must not redirect our state), then $HOME/.gstack, -# then a project-local .gstack. Hooks run on every Edit/Bash call, so this is -# pure bash — never spawn gstack-paths from a hook. The writers (/freeze, -# /guard, /unfreeze, /investigate) resolve through gstack-paths; a reader that -# used a different chain failed OPEN whenever GSTACK_HOME was set (#1459). -# test/hook-scripts.test.ts pins parity against gstack-paths. +# Print the gstack state root: a thin wrapper over gstack_state_root from +# bin/gstack-state-root.sh, the one bash implementation of the chain +# bin/gstack-paths uses (docs/state-root.md). Hooks run on every Edit/Bash +# call, so the twin is pure bash — never spawn gstack-paths from a hook. The +# writers (/freeze, /guard, /unfreeze, /investigate) resolve through +# gstack-paths; a reader that used a different chain failed OPEN whenever +# GSTACK_HOME was set (#1459). test/hook-scripts.test.ts pins parity. # Printed WITHOUT a trailing newline: callers capture with a sentinel # (`r="$(gstack_hook_state_root; printf x)"; r="${r%x}"`) so a root that -# itself ends in a newline round-trips exactly as gstack-paths' %q does — -# otherwise writer and reader would again disagree on the directory. +# itself ends in a newline round-trips exactly as gstack-paths' %q does. +# When the twin is missing (partial upgrade) the wrapper is removed, so +# callers take their own fallback: careful asks, freeze fails closed. gstack_hook_state_root() { - if [ -n "${GSTACK_HOME:-}" ]; then - printf '%s' "$GSTACK_HOME" - elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && printf '%s' "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then - printf '%s' "$CLAUDE_PLUGIN_DATA" - elif [ -n "${HOME:-}" ]; then - printf '%s' "$HOME/.gstack" - else - printf '%s' ".gstack" - fi + gstack_state_root } +_ghsr_twin="${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" +if ! { [ -f "$_ghsr_twin" ] && . "$_ghsr_twin" 2>/dev/null; } || ! command -v gstack_state_root >/dev/null 2>&1; then + unset -f gstack_hook_state_root +fi # gstack_hook_log_fire SKILL PATTERN # Append a hook_fire analytics record (pattern name only, never command -# content). Respects GSTACK_HOME so tests never pollute the operator's real -# analytics file. Deliberately NOT gstack_hook_state_root: every other +# content) under the resolved state root, the same root every other # analytics writer and reader (gstack-skill-start, gstack-retro-metrics, -# gstack-analytics) uses this two-step chain, and the usage log must stay one -# file. Best-effort: failures never affect the hook decision. +# gstack-analytics) uses, so the usage log stays one file and tests never +# pollute the operator's real analytics file. Best-effort: failures (or a +# missing twin) never affect the hook decision. gstack_hook_log_fire() { - _ghlf_dir="${GSTACK_HOME:-$HOME/.gstack}/analytics" + command -v gstack_state_root >/dev/null 2>&1 || return 0 + _ghlf_dir="$(gstack_state_root; printf x)"; _ghlf_dir="${_ghlf_dir%x}/analytics" mkdir -p "$_ghlf_dir" 2>/dev/null || true # Fields are JSON-encoded (a repo basename can carry quotes/backslashes) — # same rule this file states for decisions: never raw-interpolate into JSON. diff --git a/design/src/daemon-state.ts b/design/src/daemon-state.ts index dd20e660f..7f6dc951a 100644 --- a/design/src/daemon-state.ts +++ b/design/src/daemon-state.ts @@ -10,8 +10,8 @@ import { execFileSync } from "child_process"; import fs from "fs"; -import os from "os"; import path from "path"; +import { resolveStateRoot } from "../../lib/state-root"; export interface DaemonState { pid: number; @@ -48,11 +48,11 @@ export function resolveLockFilePath(stateFile: string = resolveStateFilePath()): } export function resolveDaemonLogPath(): string { - return path.join(os.homedir(), ".gstack", "design-daemon.log"); + return path.join(resolveStateRoot(), "design-daemon.log"); } export function resolveStartupLogPath(): string { - return path.join(os.homedir(), ".gstack", "design-daemon-startup.log"); + return path.join(resolveStateRoot(), "design-daemon-startup.log"); } /** diff --git a/freeze/bin/freeze-state.sh b/freeze/bin/freeze-state.sh index 6e64c045e..ad61b80cd 100755 --- a/freeze/bin/freeze-state.sh +++ b/freeze/bin/freeze-state.sh @@ -3,6 +3,10 @@ set -euo pipefail _here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$_here/../../careful/bin/hook-extract.sh" +if ! command -v gstack_hook_state_root >/dev/null 2>&1; then + echo "FREEZE_ERROR: cannot resolve the gstack state root: $_here/../../bin/gstack-state-root.sh is missing. No boundary changed. Fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)." >&2 + exit 1 +fi STATE_DIR="$(gstack_hook_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}" mkdir -p "$STATE_DIR" STATE_DIR="$(cd "$STATE_DIR" && pwd -P && printf x)"; STATE_DIR="${STATE_DIR%$'\nx'}" diff --git a/gstack-upgrade/migrations/v0.16.2.0.sh b/gstack-upgrade/migrations/v0.16.2.0.sh index afdc09d95..534db737b 100755 --- a/gstack-upgrade/migrations/v0.16.2.0.sh +++ b/gstack-upgrade/migrations/v0.16.2.0.sh @@ -11,7 +11,8 @@ # Affected: users who ran /office-hours before this version set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" PROFILE_FILE="$GSTACK_HOME/builder-profile.jsonl" # Find all per-project resource logs diff --git a/gstack-upgrade/migrations/v1.0.0.0.sh b/gstack-upgrade/migrations/v1.0.0.0.sh index 2e62fe06a..f43ea3f6b 100755 --- a/gstack-upgrade/migrations/v1.0.0.0.sh +++ b/gstack-upgrade/migrations/v1.0.0.0.sh @@ -13,7 +13,8 @@ # Affected: every user on v0.19.x and below who upgrades to v1.x set -euo pipefail -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" PROMPTED_FLAG="$GSTACK_HOME/.writing-style-prompted" PENDING_FLAG="$GSTACK_HOME/.writing-style-prompt-pending" diff --git a/gstack-upgrade/migrations/v1.17.0.0.sh b/gstack-upgrade/migrations/v1.17.0.0.sh index 5b8f1dd95..7048c7363 100755 --- a/gstack-upgrade/migrations/v1.17.0.0.sh +++ b/gstack-upgrade/migrations/v1.17.0.0.sh @@ -38,7 +38,9 @@ if [ "$SYNC_MODE" = "off" ] || [ -z "$SYNC_MODE" ]; then fi # Skip if no brain-sync git repo exists. -if [ ! -d "${HOME}/.gstack/.git" ]; then +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select +if [ ! -d "$_gstack_sr_root/.git" ]; then exit 0 fi diff --git a/gstack-upgrade/migrations/v1.27.0.0.sh b/gstack-upgrade/migrations/v1.27.0.0.sh index 65ac82890..e43466e38 100755 --- a/gstack-upgrade/migrations/v1.27.0.0.sh +++ b/gstack-upgrade/migrations/v1.27.0.0.sh @@ -44,7 +44,8 @@ fi # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" SKILLS_DIR="${HOME}/.claude/skills" BIN_DIR="${SKILLS_DIR}/gstack/bin" CONFIG_BIN="${BIN_DIR}/gstack-config" diff --git a/gstack-upgrade/migrations/v1.37.0.0.sh b/gstack-upgrade/migrations/v1.37.0.0.sh index b60b8530c..c975e6bd5 100755 --- a/gstack-upgrade/migrations/v1.37.0.0.sh +++ b/gstack-upgrade/migrations/v1.37.0.0.sh @@ -38,7 +38,8 @@ if [ -z "${HOME:-}" ]; then exit 0 fi -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" MIGRATIONS_DIR="$GSTACK_HOME/.migrations" DONE_TOUCH="$MIGRATIONS_DIR/v1.37.0.0.done" CONFIG_BIN="$HOME/.claude/skills/gstack/bin/gstack-config" diff --git a/gstack-upgrade/migrations/v1.38.1.0.sh b/gstack-upgrade/migrations/v1.38.1.0.sh index 2a56634d7..1b23e9edd 100755 --- a/gstack-upgrade/migrations/v1.38.1.0.sh +++ b/gstack-upgrade/migrations/v1.38.1.0.sh @@ -15,7 +15,8 @@ # still run. `set -u` is fine. set -u -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" ALLOWLIST="${GSTACK_HOME}/.brain-allowlist" PRIVACY="${GSTACK_HOME}/.brain-privacy-map.json" GITATTRS="${GSTACK_HOME}/.gitattributes" diff --git a/gstack-upgrade/migrations/v1.40.0.0.sh b/gstack-upgrade/migrations/v1.40.0.0.sh index e482d57a6..177705d7f 100755 --- a/gstack-upgrade/migrations/v1.40.0.0.sh +++ b/gstack-upgrade/migrations/v1.40.0.0.sh @@ -22,7 +22,8 @@ set -u -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" ALLOWLIST="${GSTACK_HOME}/.brain-allowlist" PRIVACY="${GSTACK_HOME}/.brain-privacy-map.json" GITATTRS="${GSTACK_HOME}/.gitattributes" diff --git a/gstack-upgrade/migrations/v1.58.0.0.sh b/gstack-upgrade/migrations/v1.58.0.0.sh index da2252286..bd5adc24f 100755 --- a/gstack-upgrade/migrations/v1.58.0.0.sh +++ b/gstack-upgrade/migrations/v1.58.0.0.sh @@ -20,7 +20,8 @@ set -u -GSTACK_HOME="${HOME}/.gstack" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" MIGRATION_DIR="${GSTACK_HOME}/.migrations" DONE="${MIGRATION_DIR}/v1.58.0.0.done" mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true diff --git a/gstack-upgrade/migrations/v1.65.0.0.sh b/gstack-upgrade/migrations/v1.65.0.0.sh index 56181d648..44e698a58 100755 --- a/gstack-upgrade/migrations/v1.65.0.0.sh +++ b/gstack-upgrade/migrations/v1.65.0.0.sh @@ -34,7 +34,8 @@ set -u -GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root" MIGRATION_DIR="${GSTACK_HOME}/.migrations" DONE="${MIGRATION_DIR}/v1.65.0.0.done" # Written when a removal happened but the verified end state (a Chromium diff --git a/gstack-upgrade/migrations/v1.78.0.0.sh b/gstack-upgrade/migrations/v1.78.0.0.sh index ee2ed3db3..28c230aaa 100755 --- a/gstack-upgrade/migrations/v1.78.0.0.sh +++ b/gstack-upgrade/migrations/v1.78.0.0.sh @@ -15,7 +15,8 @@ set -u INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}" -GH="${GSTACK_HOME:-$HOME/.gstack}" +. "${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GH="$_gstack_sr_root" mkdir -p "$GH" 2>/dev/null || exit 0 diff --git a/ios-qa/daemon/src/allowlist.ts b/ios-qa/daemon/src/allowlist.ts index 6496b041e..a440615a7 100644 --- a/ios-qa/daemon/src/allowlist.ts +++ b/ios-qa/daemon/src/allowlist.ts @@ -7,13 +7,13 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import type { Allowlist, AllowlistEntry, Capability } from './types'; import { capabilityCovers } from './types'; +import { resolveStateRoot } from '../../../lib/state-root'; export function defaultAllowlistPath(): string { return process.env.GSTACK_IOS_ALLOWLIST_PATH - ?? join(homedir(), '.gstack', 'ios-qa-allowlist.json'); + ?? join(resolveStateRoot(), 'ios-qa-allowlist.json'); } export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise { diff --git a/ios-qa/daemon/src/audit.ts b/ios-qa/daemon/src/audit.ts index 7b328ca39..c2743c3dc 100644 --- a/ios-qa/daemon/src/audit.ts +++ b/ios-qa/daemon/src/audit.ts @@ -3,28 +3,28 @@ import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import { createHash } from 'crypto'; import type { AuditRow, AttemptRow } from './types'; +import { resolveStateRoot } from '../../../lib/state-root'; const MAX_BYTES = 10 * 1024 * 1024; const MAX_GENS = 5; export function defaultAuditPath(): string { return process.env.GSTACK_IOS_AUDIT_PATH - ?? join(homedir(), '.gstack', 'security', 'ios-qa-audit.jsonl'); + ?? join(resolveStateRoot(), 'security', 'ios-qa-audit.jsonl'); } export function defaultAttemptsPath(): string { return process.env.GSTACK_IOS_ATTEMPTS_PATH - ?? join(homedir(), '.gstack', 'security', 'attempts.jsonl'); + ?? join(resolveStateRoot(), 'security', 'attempts.jsonl'); } let _saltCache: string | null = null; async function loadDeviceSalt(): Promise { if (_saltCache) return _saltCache; - const path = join(homedir(), '.gstack', 'security', 'device-salt'); + const path = join(resolveStateRoot(), 'security', 'device-salt'); try { _saltCache = (await readFile(path, 'utf-8')).trim(); } catch { diff --git a/ios-qa/daemon/src/single-instance.ts b/ios-qa/daemon/src/single-instance.ts index 0196b0032..432563e8b 100644 --- a/ios-qa/daemon/src/single-instance.ts +++ b/ios-qa/daemon/src/single-instance.ts @@ -8,8 +8,8 @@ import { readFile, mkdir, unlink } from 'fs/promises'; import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import { spawn } from 'child_process'; +import { resolveStateRoot } from '../../../lib/state-root'; export interface PidfileContents { pid: number; @@ -19,7 +19,7 @@ export interface PidfileContents { export function defaultPidfilePath(): string { return process.env.GSTACK_IOS_DAEMON_PIDFILE - ?? join(homedir(), '.gstack', 'ios-qa-daemon.pid'); + ?? join(resolveStateRoot(), 'ios-qa-daemon.pid'); } /** diff --git a/ios-qa/scripts/gen-accessors.ts b/ios-qa/scripts/gen-accessors.ts index 315f2bde6..d97b02db4 100644 --- a/ios-qa/scripts/gen-accessors.ts +++ b/ios-qa/scripts/gen-accessors.ts @@ -22,9 +22,9 @@ import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'fs'; import { join, resolve, dirname } from 'path'; -import { homedir } from 'os'; import { createHash } from 'crypto'; import { execSync } from 'child_process'; +import { resolveStateRoot } from '../../lib/state-root'; export interface AccessorField { name: string; @@ -771,7 +771,7 @@ function detectBuildId(): string { } export function defaultCacheRoot(): string { - return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors'); + return process.env.GSTACK_IOS_CACHE_ROOT ?? join(resolveStateRoot(), 'cache', 'gen-accessors'); } export function generate(inputs: GenInputs): GenResult { diff --git a/lib/bin-context.ts b/lib/bin-context.ts index d3be44009..ccc3ef7f3 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -7,8 +7,8 @@ import { spawnSync } from "child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { basename, dirname, join } from "path"; +import { resolveStateRoot } from "./state-root"; /** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */ function sanitizeSlug(s: string): string { @@ -148,7 +148,7 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri * 4. Project root's basename; else basename(cwd) for plain non-project folders. */ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string { - const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const home = gstackHome || resolveStateRoot(); const cacheDir = join(home, "slug-cache"); const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")); diff --git a/lib/code-intelligence/selection.ts b/lib/code-intelligence/selection.ts index b4fe58baf..c3534afb9 100644 --- a/lib/code-intelligence/selection.ts +++ b/lib/code-intelligence/selection.ts @@ -12,11 +12,11 @@ */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; -import { homedir } from "os"; import { dirname, join, resolve } from "path"; import { execFileSync } from "child_process"; import { hasRepoPolicyStore, repoPolicyTier } from "../gbrain-repo-policy-client"; import type { CodeProviderId, OpClass } from "./contract"; +import { resolveStateRoot } from "../state-root"; export interface Selection { provider: CodeProviderId | null; @@ -31,7 +31,7 @@ export interface Selection { const EMPTY: Selection = { provider: null, consents: {}, roots: {}, declined: false }; function storePath(env: NodeJS.ProcessEnv = process.env): string { - const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack"); + const home = resolveStateRoot(env); return join(home, "code-intelligence.json"); } diff --git a/lib/cso/state.ts b/lib/cso/state.ts index 39e889a22..680ca51ef 100644 --- a/lib/cso/state.ts +++ b/lib/cso/state.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import { atomicWriteSync } from '../fs-atomic'; import { CsoError, RunReportV3, canonical, completeness, fingerprint, renderReport, sha256 } from './contracts'; import { redact, sanitizeForJson, sanitizeHelperForJson } from './process'; +import { resolveStateRoot } from '../state-root'; const MAX_STATE_FILE=1024*1024; type ExactStats=Pick & Pick; @@ -179,9 +180,8 @@ export function discardAtomicNoReplaceTemp(path:string,publisherPid:number,optio } export function stateRoot(env: Record = process.env): string { - // Mirrors bin/gstack-paths. Security artifacts are intentionally outside every sync allowlist. - const userHome=env.HOME||(process.platform==='win32'?env.USERPROFILE:''); - return resolve(env.GSTACK_HOME || (env.CLAUDE_PLUGIN_ROOT?.toLowerCase().includes('gstack') ? env.CLAUDE_PLUGIN_DATA : '') || join(userHome || '.', '.gstack')); + // The shared chain (lib/state-root.ts), made absolute. Security artifacts are intentionally outside every sync allowlist. + return resolve(resolveStateRoot(env)); } function ensureDirectory(path:string,hardenExistingLeaf:boolean):string{ const p=resolve(path),root=parse(p).root; diff --git a/lib/egress-receipt.ts b/lib/egress-receipt.ts index 01882ebcf..fd501eb60 100644 --- a/lib/egress-receipt.ts +++ b/lib/egress-receipt.ts @@ -26,7 +26,7 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import os from 'node:os'; +import { resolveStateRoot } from './state-root'; import path from 'node:path'; export const EGRESS_RECEIPT_FAILED = 'EGRESS_RECEIPT_FAILED'; @@ -109,14 +109,9 @@ export interface VerifyResult { sizeWarning: string | null; } -/** - * Same resolution order as the rest of gstack (shell sinks, selection code): - * GSTACK_HOME, legacy GSTACK_STATE_DIR, then $HOME/.gstack. - */ +/** The shared state root (lib/state-root.ts), made absolute. */ export function resolveEgressHome(env: Env = process.env): string { - const configured = env.GSTACK_HOME || env.GSTACK_STATE_DIR; - if (configured) return path.resolve(configured); - return path.join(env.HOME || os.homedir(), '.gstack'); + return path.resolve(resolveStateRoot(env)); } export function egressLedgerPath(home: string): string { diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 033dc034f..ae6ebcd8c 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -49,6 +49,7 @@ import { atomicWriteSync } from "./fs-atomic"; import { homedir } from "os"; import { dirname, join } from "path"; import { buildGbrainEnv, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { resolveStateRoot } from "./state-root"; export type LocalEngineStatus = | "ok" @@ -116,10 +117,7 @@ function userHome(env?: NodeJS.ProcessEnv): string { /** Cache path computed fresh on each call so tests can mutate GSTACK_HOME per case. */ export function cacheFilePath(): string { - return join( - process.env.GSTACK_HOME || join(userHome(), ".gstack"), - ".gbrain-local-status-cache.json", - ); + return join(resolveStateRoot(), ".gbrain-local-status-cache.json"); } /** diff --git a/lib/gstack-decision.ts b/lib/gstack-decision.ts index 87a0f35cb..db1774a78 100644 --- a/lib/gstack-decision.ts +++ b/lib/gstack-decision.ts @@ -14,12 +14,12 @@ */ import { join } from "path"; -import { homedir } from "os"; import { randomUUID } from "crypto"; import { existsSync, readFileSync, appendFileSync, statSync, openSync, closeSync, unlinkSync } from "fs"; import { atomicWriteSync } from "./fs-atomic"; import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store"; import { scan } from "./redact-engine"; +import { resolveStateRoot } from "./state-root"; export type DecisionKind = "decide" | "supersede" | "redact"; export type DecisionScope = "repo" | "branch" | "issue"; @@ -57,7 +57,7 @@ export interface DecisionPaths { /** Resolve the per-project decision store paths. Bins pass slug + GSTACK_HOME. */ export function decisionPaths(slug: string, gstackHome?: string): DecisionPaths { - const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const home = gstackHome || resolveStateRoot(); const dir = join(home, "projects", slug || "unknown"); return { log: join(dir, "decisions.jsonl"), diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index f511abac8..2ea3a3334 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -24,7 +24,8 @@ import { appendJsonl } from "./jsonl-store"; import { gbrainConfigDir, isExecTimeout } from "./gbrain-exec"; import { dirname, join } from "path"; import { execFileSync } from "child_process"; -import { homedir, tmpdir } from "os"; +import { tmpdir } from "os"; +import { resolveStateRoot } from "./state-root"; // ── Types ────────────────────────────────────────────────────────────────── @@ -326,7 +327,7 @@ function redactMatch(s: string): string { const ENGINE_CACHE_TTL_MS = 60 * 1000; function gstackHome(): string { - return process.env.GSTACK_HOME || join(homedir(), ".gstack"); + return resolveStateRoot(); } function engineCachePath(): string { diff --git a/lib/redact-audit-log.ts b/lib/redact-audit-log.ts index 8ea03c5bf..510aba760 100644 --- a/lib/redact-audit-log.ts +++ b/lib/redact-audit-log.ts @@ -19,6 +19,7 @@ import * as os from "os"; import * as path from "path"; import { createHash } from "crypto"; import { appendJsonl } from "./jsonl-store"; +import { resolveStateRoot } from "./state-root"; export interface SemanticReviewEntry { ts: string; @@ -30,7 +31,7 @@ export interface SemanticReviewEntry { } function securityDir(): string { - const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack"); + const home = resolveStateRoot(); return path.join(home, "security"); } diff --git a/lib/state-root.ts b/lib/state-root.ts index 8ae563763..a0b178060 100644 --- a/lib/state-root.ts +++ b/lib/state-root.ts @@ -39,6 +39,13 @@ function legacyStateRoot(env: StateRootEnv, platform: NodeJS.Platform): string | return home ? `${home}/.gstack` : null; } +/** Roots merged privacy settings are read from: the resolved root, then $HOME/.gstack when different. */ +export function mergedStateRoots(env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string[] { + const resolved = resolveStateRoot(env, platform); + const legacy = legacyStateRoot(env, platform); + return legacy && legacy !== resolved ? [resolved, legacy] : [resolved]; +} + /** Privacy and egress opt-outs: most restrictive first. Read from every candidate root. */ export const MERGED_CONFIG_KEYS: Record = { telemetry: ['off', 'anonymous', 'community'], @@ -75,11 +82,8 @@ export interface ConfigKeyReading { * resolved root only. */ export function readConfigKeyWithRoot(key: string, env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): ConfigKeyReading { - const resolved = resolveStateRoot(env, platform); const order = MERGED_CONFIG_KEYS[key]; - const candidates = [resolved]; - const legacy = order ? legacyStateRoot(env, platform) : null; - if (legacy && legacy !== resolved) candidates.push(legacy); + const candidates = order ? mergedStateRoots(env, platform) : [resolveStateRoot(env, platform)]; let best: ConfigKeyReading = { value: null, root: null }; let bestRank = Infinity; for (const root of candidates) { diff --git a/scripts/analytics.ts b/scripts/analytics.ts index 6aa93cb37..1e4697c3a 100644 --- a/scripts/analytics.ts +++ b/scripts/analytics.ts @@ -13,7 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; +import { resolveStateRoot } from '../lib/state-root'; export interface AnalyticsEvent { skill: string; @@ -23,7 +23,7 @@ export interface AnalyticsEvent { pattern?: string; } -const ANALYTICS_FILE = path.join(os.homedir(), '.gstack', 'analytics', 'skill-usage.jsonl'); +const ANALYTICS_FILE = path.join(resolveStateRoot(), 'analytics', 'skill-usage.jsonl'); /** * Parse JSONL content into AnalyticsEvent[], skipping malformed lines. diff --git a/scripts/declared-annotation.ts b/scripts/declared-annotation.ts index fa45c585b..e856b080b 100644 --- a/scripts/declared-annotation.ts +++ b/scripts/declared-annotation.ts @@ -22,9 +22,9 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { SIGNAL_MAP, type Dimension, ALL_DIMENSIONS } from './psychographic-signals'; +import { resolveStateRoot } from '../lib/state-root'; const STRONG_HIGH = 0.7; const STRONG_LOW = 0.3; @@ -61,11 +61,7 @@ interface DeveloperProfile { } function stateRoot(): string { - return ( - process.env.GSTACK_STATE_ROOT || - process.env.GSTACK_HOME || - path.join(os.homedir(), '.gstack') - ); + return resolveStateRoot(); } function readProfile(): DeveloperProfile | null { diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index d19281ada..186b86062 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -27,6 +27,7 @@ import type { HostConfig } from './host-config'; const ROOT = path.resolve(import.meta.dir, '..'); import { ALL_MODEL_NAMES, resolveModel, type Model } from './models'; +import { resolveStateRoot } from '../lib/state-root'; type HostArg = Host | 'all'; @@ -75,7 +76,7 @@ export interface GenerationResult { /** Canonical generation never reads local detection state unless opted in. */ function loadGbrainOverride(respectDetection: boolean): boolean { if (!respectDetection) return false; - const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack'); + const stateDir = resolveStateRoot(); try { const json = JSON.parse(fs.readFileSync(path.join(stateDir, 'gbrain-detection.json'), 'utf-8')); // Slow, remote, and locked engines are still usable (#1964/#2051/#2456). @@ -1087,7 +1088,7 @@ export async function main(args = process.argv.slice(2)): Promise { } if (!settings.dryRun) { try { - const config = fs.readFileSync(path.join(process.env.HOME || '', '.gstack', 'config.yaml'), 'utf-8'); + const config = fs.readFileSync(path.join(resolveStateRoot(), 'config.yaml'), 'utf-8'); if (/^skill_prefix:\s*true/m.test(config)) { console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches (it patches both the install and any active gbrain render).'); } diff --git a/setup b/setup index 86ddc5242..53d2b1736 100755 --- a/setup +++ b/setup @@ -167,6 +167,9 @@ fi INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" +# One state root for everything setup writes (bin/gstack-state-root.sh, docs/state-root.md). +. "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")" BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse" CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills" @@ -308,7 +311,7 @@ _link_or_copy() { # marker) means we created the entry and may delete or refresh it whole; WEAK # (byte-identity with our source, or the two-line generated banner on a real # file) covers only that SKILL.md — never the directory — and a differing -# weakly-proven file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills// +# weakly-proven file is moved to $GSTACK_STATE_ROOT/backups/skills// # before we install over it. An entry is OURS # when: it is a symlink resolving into the gstack payload / render dir (or any # path with a `gstack` segment, the convention cleanup and gstack-uninstall @@ -332,7 +335,7 @@ _gstack_target_is_ours() { # $1 = absolute target path, $2 = gstack payload dir local t="$1" g="$2" g_real render render_real g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" - render="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")" case "$t" in "$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;; @@ -363,7 +366,7 @@ _claude_entry_is_ours() { # A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers # it), so an exact copy of that render is ours too. if [ -n "$src_md" ]; then - render_md="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md" + render_md="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md" [ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0 fi _gstack_generated_header "$entry/SKILL.md" && return 0 @@ -389,7 +392,7 @@ _claude_entry_owned_strongly() { } # Weakly-proven real files we would otherwise overwrite are moved here (mv, so # the path is free for the link); the final summary prints one line. -_SKILL_BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)" +_SKILL_BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)" _BACKED_UP_SKILL_MDS=() _backup_skill_md() { # Returns non-zero when the file could NOT be moved: the caller must then @@ -1620,7 +1623,7 @@ link_claude_skill_dirs() { # file's section-base paths point into the render dir, so section reads # resolve there too. _skill_md_src="$gstack_dir/$dir_name/SKILL.md" - _render_dir="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + _render_dir="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}" if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then _skill_md_src="$_render_dir/$dir_name/SKILL.md" fi @@ -2868,7 +2871,7 @@ fi # Users who install gbrain after running ./setup should re-run setup OR # call `gstack-config gbrain-refresh`. DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect" -GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" +GBRAIN_STATE_DIR="$GSTACK_STATE_ROOT" DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json" _GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}" # PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't @@ -3290,8 +3293,10 @@ fi # posture). An explicit answer is persisted and never re-asked; an explicit # "false" is a recorded decline (adversarial review finding 11). # `gstack-config get` defaults absent keys to "false", which is -# indistinguishable from a decline — test key presence in the config file. -_GSTACK_CFG_FILE="${GSTACK_HOME:-$HOME/.gstack}/config.yaml" +# indistinguishable from a decline — test key presence in the config file +# of the resolved state root (the twin beside gstack-config). +. "$(dirname "$GSTACK_CONFIG")/gstack-state-root.sh" 2>/dev/null && gstack_state_root_select +_GSTACK_CFG_FILE="${_gstack_sr_root:-$GSTACK_STATE_ROOT}/config.yaml" if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then _REDACT_PROMPT_TIMEOUT=10 diff --git a/test/freeze-owned-lifecycle.test.ts b/test/freeze-owned-lifecycle.test.ts index 10a722341..e3a2cf6b3 100644 --- a/test/freeze-owned-lifecycle.test.ts +++ b/test/freeze-owned-lifecycle.test.ts @@ -50,7 +50,7 @@ beforeEach(() => { env = { ...process.env, HOME: join(root, 'home'), GSTACK_HOME: join(root, 'state'), CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' }; mkdirSync(env.GSTACK_HOME!); state = join(env.GSTACK_HOME!, 'freeze-dir.txt'); - for (const file of ['freeze/bin/check-freeze.sh', 'freeze/bin/freeze-state.sh', 'careful/bin/hook-extract.sh', 'bin/gstack-paths']) { + for (const file of ['freeze/bin/check-freeze.sh', 'freeze/bin/freeze-state.sh', 'careful/bin/hook-extract.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh']) { if (!existsSync(join(ROOT, file))) continue; const dest = join(env.HOME!, '.claude/skills/gstack', file); mkdirSync(dirname(dest), { recursive: true }); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2c17418d4..1ed659b55 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1935,6 +1935,7 @@ describe('BENEFITS_FROM resolver', () => { fs.mkdirSync(cwd); fs.copyFileSync(path.join(ROOT, 'bin/gstack-slug'), helper); fs.chmodSync(helper, 0o755); + fs.copyFileSync(path.join(ROOT, 'bin/gstack-state-root.sh'), path.join(path.dirname(helper), 'gstack-state-root.sh')); const expected = path.join(home, '.gstack/projects/canonical-override/session-unknown-design-current.md'); const wrong = path.join(home, '.gstack/projects/project/session-unknown-design-wrong.md'); for (const file of [expected, wrong]) { diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index f2b133ab4..e6549c666 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -168,6 +168,7 @@ describe('gstack-skill-start behavior', () => { // Shadow the real bin dir by copying the script next to the poisoned tool. fs.copyFileSync(START, path.join(fakeBin, 'gstack-skill-start')); fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755); + fs.copyFileSync(path.join(path.dirname(START), 'gstack-state-root.sh'), path.join(fakeBin, 'gstack-state-root.sh')); const out = execFileSync(path.join(fakeBin, 'gstack-skill-start'), ['--skill', 't'], { timeout: 30_000, encoding: 'utf-8', diff --git a/test/helpers/health-eval-fixture.ts b/test/helpers/health-eval-fixture.ts index 5abafefec..bdadbf6a3 100644 --- a/test/helpers/health-eval-fixture.ts +++ b/test/helpers/health-eval-fixture.ts @@ -52,6 +52,7 @@ export function createHealthEvalFixture(dir: string, repoRoot: string): HealthEv fs.mkdirSync(path.join(dir, 'bin'), { recursive: true }); fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-slug'), path.join(dir, 'bin', 'gstack-slug')); fs.chmodSync(path.join(dir, 'bin', 'gstack-slug'), 0o755); + fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-state-root.sh'), path.join(dir, 'bin', 'gstack-state-root.sh')); // Only redirect installed paths. The workflow and score/history rules come // from the real generated skill, without its unrelated shared preamble. diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index 95cfcd213..df3b9575c 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -1082,11 +1082,11 @@ describe('check-freeze.sh state-root resolution (#1459 / #1509)', () => { // ============================================================ // gstack_hook_log_fire analytics sink follows the same state root (#1459) // ============================================================ -// The hook_fire record lands under ${GSTACK_HOME:-$HOME/.gstack}/analytics — -// the SAME two-step chain every other analytics writer and reader uses -// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) — deliberately -// NOT the plugin-aware state root the freeze FILE uses, so the usage log stays -// one file. Logging is best-effort: an unwritable sink never changes the decision. +// The hook_fire record lands under the resolved state root (bin/gstack-state-root.sh) +// — the SAME root the freeze FILE and every other analytics writer and reader +// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) use, so the usage +// log stays one file. Logging is best-effort: an unwritable sink never changes +// the decision. describe('gstack_hook_log_fire writes under the resolved state root', () => { const BOUNDARY = '/Users/dev/project/src/'; const OUTSIDE = '/Users/dev/other-project/index.ts'; @@ -1114,16 +1114,16 @@ describe('gstack_hook_log_fire writes under the resolved state root', () => { }); }); - test('plugin install: the freeze FILE is read from CLAUDE_PLUGIN_DATA but hook_fire still lands under $HOME/.gstack/analytics (one usage log)', () => { + test('plugin install: the freeze FILE and hook_fire both use CLAUDE_PLUGIN_DATA (one state root, one usage log)', () => { withFreezeDir(BOUNDARY, (pluginData) => { withEmptyDir((fakeHome) => { const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), freezeEnv(pluginData, { HOME: fakeHome, CLAUDE_PLUGIN_ROOT: '/Plugins/GSTACK' })); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); - const rec = lastRecord(path.join(fakeHome, '.gstack', 'analytics', 'skill-usage.jsonl')); + const rec = lastRecord(path.join(pluginData, 'analytics', 'skill-usage.jsonl')); expect(rec.event).toBe('hook_fire'); expect(rec.skill).toBe('freeze'); - expect(fs.existsSync(path.join(pluginData, 'analytics'))).toBe(false); + expect(fs.existsSync(path.join(fakeHome, '.gstack', 'analytics'))).toBe(false); }); }); }); diff --git a/test/ios-qa-regen.test.ts b/test/ios-qa-regen.test.ts index 2aa39884c..08bd4a822 100644 --- a/test/ios-qa-regen.test.ts +++ b/test/ios-qa-regen.test.ts @@ -48,6 +48,8 @@ function copyIntoFakeInstall(workDir: string): { root: string; launcher: string copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher); chmodSync(launcher, 0o755); copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts')); + mkdirSync(join(root, 'lib'), { recursive: true }); + copyFileSync(join(ROOT, 'lib', 'state-root.ts'), join(root, 'lib', 'state-root.ts')); for (const [template] of SAFE_TEMPLATE_MAP) { copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template)); } diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index 4ac4fd8fb..def4fcac4 100644 --- a/test/plan-tune-cathedral.test.ts +++ b/test/plan-tune-cathedral.test.ts @@ -43,6 +43,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; 'gstack-codex-session-import', 'gstack-distill-free-text', 'gstack-distill-apply', + 'gstack-state-root.sh', ]) { const src = path.join(ROOT, 'bin', script); if (fs.existsSync(src)) { @@ -65,10 +66,10 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; if (fs.existsSync(src)) fs.copyFileSync(src, path.join(scriptsDir, f)); } - // Both imports are resolved relative to the copied install, not the source checkout. + // These imports are resolved relative to the copied install, not the source checkout. const libDir = path.join(workDir, 'lib'); fs.mkdirSync(libDir, { recursive: true }); - for (const file of ['jsonl-store.ts', 'is-conductor.ts']) { + for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts']) { fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file)); } diff --git a/test/relink.test.ts b/test/relink.test.ts index 65424f9f1..45b9e6233 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -56,6 +56,8 @@ function setupMockInstall(skills: string[]): void { fs.mkdirSync(mockBin, { recursive: true }); fs.copyFileSync(path.join(BIN, 'gstack-config'), path.join(mockBin, 'gstack-config')); fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755); + // A real install always ships the state-root twin beside the bins that source it. + fs.copyFileSync(path.join(BIN, 'gstack-state-root.sh'), path.join(mockBin, 'gstack-state-root.sh')); if (fs.existsSync(path.join(BIN, 'gstack-relink'))) { fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink')); fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755); diff --git a/test/setup-claude-code-migration.test.ts b/test/setup-claude-code-migration.test.ts index 91b084e82..dcc14d6df 100644 --- a/test/setup-claude-code-migration.test.ts +++ b/test/setup-claude-code-migration.test.ts @@ -30,7 +30,7 @@ function fixture(copy: boolean) { put(path.join(root, 'VERSION'), '2.0.0.0\n'); put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n'); put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } })); - for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code']) { + for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh']) { const target = path.join(root, 'bin', bin); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'bin', bin), target); diff --git a/test/skill-e2e-bws.test.ts b/test/skill-e2e-bws.test.ts index f1fc5d782..bf9015845 100644 --- a/test/skill-e2e-bws.test.ts +++ b/test/skill-e2e-bws.test.ts @@ -219,7 +219,7 @@ Report the exact output — either "READY: " or "NEEDS_SETUP".`, // identically on main). const binDir = path.join(opDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); - for (const script of ['gstack-learnings-log', 'gstack-slug']) { + for (const script of ['gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) { fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script)); fs.chmodSync(path.join(binDir, script), 0o755); } diff --git a/test/skill-e2e-context-skills.test.ts b/test/skill-e2e-context-skills.test.ts index 32335ffe1..69a680393 100644 --- a/test/skill-e2e-context-skills.test.ts +++ b/test/skill-e2e-context-skills.test.ts @@ -59,7 +59,7 @@ function setupWorkdir(suffix: string): { workDir: string; gstackHome: string; sl const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); for (const script of [ - 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', + 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-learnings-log', 'gstack-learnings-search', 'gstack-update-check', 'gstack-config', 'gstack-repo-mode', ]) { diff --git a/test/skill-e2e-learnings.test.ts b/test/skill-e2e-learnings.test.ts index 8672ef3c2..78a933fd8 100644 --- a/test/skill-e2e-learnings.test.ts +++ b/test/skill-e2e-learnings.test.ts @@ -40,7 +40,7 @@ describeIfSelected('Learnings E2E', ['learnings-show'], () => { // Copy bin scripts needed by /learn const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); - for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug']) { + for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) { fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script)); fs.chmodSync(path.join(binDir, script), 0o755); } diff --git a/test/skill-e2e-plan-tune.test.ts b/test/skill-e2e-plan-tune.test.ts index 17a7a2a0f..7bb7530be 100644 --- a/test/skill-e2e-plan-tune.test.ts +++ b/test/skill-e2e-plan-tune.test.ts @@ -50,6 +50,7 @@ describeIfSelected('PlanTune E2E', ['plan-tune-inspect'], () => { for (const script of [ 'gstack-slug', 'gstack-config', + 'gstack-state-root.sh', 'gstack-question-log', 'gstack-question-preference', 'gstack-developer-profile', diff --git a/test/skill-e2e-session-intelligence.test.ts b/test/skill-e2e-session-intelligence.test.ts index 90fdbe149..de28e2d12 100644 --- a/test/skill-e2e-session-intelligence.test.ts +++ b/test/skill-e2e-session-intelligence.test.ts @@ -44,7 +44,7 @@ describeIfSelected('Session Intelligence E2E', [ const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); for (const script of [ - 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', + 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-learnings-log', 'gstack-learnings-search', ]) { const src = path.join(ROOT, 'bin', script); diff --git a/test/skill-e2e-skillify.test.ts b/test/skill-e2e-skillify.test.ts index 175bb03fa..1fd2bec51 100644 --- a/test/skill-e2e-skillify.test.ts +++ b/test/skill-e2e-skillify.test.ts @@ -96,7 +96,7 @@ function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape const binDir = path.join(workDir, 'bin'); fs.mkdirSync(binDir, { recursive: true }); for (const script of [ - 'gstack-timeline-log', 'gstack-slug', 'gstack-config', + 'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh', 'gstack-update-check', 'gstack-repo-mode', 'gstack-learnings-log', 'gstack-learnings-search', ]) { diff --git a/test/state-root-one-variable.test.ts b/test/state-root-one-variable.test.ts new file mode 100644 index 000000000..4de1b4815 --- /dev/null +++ b/test/state-root-one-variable.test.ts @@ -0,0 +1,68 @@ +/** + * One variable moves every store (W1). With only GSTACK_HOME, only + * GSTACK_STATE_ROOT or only GSTACK_STATE_DIR pointing at a temp dir, the + * config, egress ledger, trust-policy store, telemetry and analytics log, hook + * error log, and the root uninstall reports all resolve under that directory, + * and nothing lands in $HOME/.gstack. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveStateRoot } from '../lib/state-root'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin'); + +for (const variable of ['GSTACK_HOME', 'GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR'] as const) { + describe(`only ${variable} set`, () => { + test('every store resolves under it', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-one-var-')); + try { + const root = path.join(tmp, 'root'); + const home = path.join(tmp, 'home'); + const cwd = path.join(tmp, 'cwd'); + for (const d of [root, home, cwd]) fs.mkdirSync(d, { recursive: true }); + const env: Record = { + ...(process.env as Record), + HOME: home, GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', + GSTACK_TEST_LEGACY_ROOT: '', [variable]: root, + }; + expect(resolveStateRoot(env)).toBe(root); + const run = (cmd: string, args: string[], input?: string) => + spawnSync(cmd, args, { cwd, env, input, encoding: 'utf-8', timeout: 30_000 }); + + // config + expect(run('bash', [path.join(BIN, 'gstack-config'), 'set', 'telemetry', 'anonymous']).status).toBe(0); + expect(fs.readFileSync(path.join(root, 'config.yaml'), 'utf-8')).toContain('telemetry: anonymous'); + // telemetry + analytics log + expect(run('bash', [path.join(BIN, 'gstack-telemetry-log'), '--skill', 'qa', '--duration', '1', '--outcome', 'success', '--session-id', 'one-var-1', '--no-sweep']).status).toBe(0); + expect(fs.readFileSync(path.join(root, 'analytics', 'skill-usage.jsonl'), 'utf-8')).toContain('"skill":"qa"'); + // egress ledger + const receipt = run('bun', [path.join(BIN, 'gstack-egress-receipt'), 'write', '--sink', 'test', '--host', 'example.invalid', '--class', 'test', '--no-payload', '--consent', 'test']); + expect(receipt.status).toBe(0); + expect(fs.existsSync(path.join(root, 'security', 'egress.jsonl'))).toBe(true); + // trust-policy store + expect(run('bash', [path.join(BIN, 'gstack-gbrain-repo-policy'), 'set', 'https://github.com/a/b', 'deny']).status).toBe(0); + expect(fs.existsSync(path.join(root, 'gbrain-repo-policy.json'))).toBe(true); + // hook error log + const hook = run('bash', [path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-log-hook')], '{not json'); + expect(hook.status).toBe(0); + expect(fs.readFileSync(path.join(root, 'hook-errors.log'), 'utf-8')).toContain('question-log-hook'); + // uninstall target: a non-default root is reported, never deleted + const skillsGstack = path.join(home, '.claude', 'skills', 'gstack'); + fs.mkdirSync(path.dirname(skillsGstack), { recursive: true }); + const un = run('bash', [path.join(BIN, 'gstack-uninstall'), '--force']); + expect(un.status).toBe(0); + expect(un.stderr).toContain(`left in place: ${root} (selected by ${variable}=${root})`); + expect(un.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${root}'`); + expect(fs.existsSync(path.join(root, 'config.yaml'))).toBe(true); + + expect(fs.existsSync(path.join(home, '.gstack'))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + }); +} diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index 2419c60e0..982cd0b11 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -351,7 +351,7 @@ describe('setup --team / --no-team / -q', () => { fs.writeFileSync(file, content, { mode: 0o755 }); }; try { - for (const rel of ['setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts']) { + for (const rel of ['setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', 'bin/gstack-state-root.sh', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts']) { const dest = path.join(cwd, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(path.join(ROOT, rel), dest); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 82316d5eb..83d5e3926 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -258,7 +258,7 @@ describe('hook cleanup runs before the install root is deleted', () => { fs.mkdirSync(installBin, { recursive: true }); // The installed copies — the uninstaller under test IS the one inside // the root it deletes. - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const src = path.join(ROOT, 'bin', b); const dst = path.join(installBin, b); fs.copyFileSync(src, dst); @@ -317,7 +317,7 @@ describe('the Memorable bridge hook is removed by name and the kept config is le const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -360,7 +360,7 @@ describe('the Memorable arm stays quiet when nothing of its is registered', () = const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -392,7 +392,7 @@ describe('hook cleanup under lock contention is loud, never silent (review-army) const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -449,7 +449,7 @@ describe('the consent key never outlives the hook, even when the config lives ou const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); fs.mkdirSync(otherRoot, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) { const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -480,7 +480,7 @@ describe('the consent flip does not depend on the hook manager being present', ( const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); const installBin = path.join(installRoot, 'bin'); fs.mkdirSync(installBin, { recursive: true }); - for (const b of ['gstack-uninstall', 'gstack-config']) { // no settings hook, no session-update + for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) { // no settings hook, no session-update const dst = path.join(installBin, b); fs.copyFileSync(path.join(ROOT, 'bin', b), dst); fs.chmodSync(dst, 0o755); @@ -499,3 +499,65 @@ describe('the consent flip does not depend on the hook manager being present', ( } }); }); + +describe('state-root safety: only the default root is deleted (docs/state-root.md)', () => { + let tmp: string, mockHome: string, installBin: string, installRoot: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-root-')); + mockHome = path.join(tmp, 'home'); + installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); + installBin = path.join(installRoot, 'bin'); + fs.mkdirSync(installBin, { recursive: true }); + for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) { + fs.copyFileSync(path.join(ROOT, 'bin', b), path.join(installBin, b)); + fs.chmodSync(path.join(installBin, b), 0o755); + } + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + function uninstall(args: string[], env: Record, input?: string) { + return spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), ...args], { + cwd: tmp, input, encoding: 'utf-8', timeout: 30_000, + env: { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: path.join(mockHome, 'settings.json'), + GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', ...env } as Record, + }); + } + + for (const mode of ['--force', 'interactive'] as const) { + test(`a custom GSTACK_HOME root is left in place with the removal command (${mode}); $HOME/.gstack goes`, () => { + const custom = path.join(tmp, 'custom-state'); + fs.mkdirSync(path.join(custom, 'projects'), { recursive: true }); + fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true }); + const r = mode === '--force' ? uninstall(['--force'], { GSTACK_HOME: custom }) : uninstall([], { GSTACK_HOME: custom }, 'y\n'); + expect(r.status).toBe(0); + expect(r.stderr).toContain(`left in place: ${custom} (selected by GSTACK_HOME=${custom})`); + expect(r.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${custom}'`); + expect(r.stderr).toContain('docs/state-root.md'); + expect(fs.existsSync(path.join(custom, 'projects'))).toBe(true); + expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false); + if (mode === 'interactive') expect(r.stdout).toContain(`(kept) ${custom}`); + }); + } + + for (const [label, target] of [['$HOME', () => mockHome], ['/', () => '/'], ['the gstack checkout', () => installRoot]] as const) { + test(`$HOME/.gstack symlinked to ${label} is refused with exit 2 and a fix line`, () => { + fs.symlinkSync(target(), path.join(mockHome, '.gstack')); + const r = uninstall(['--force'], {}); + expect(r.status).toBe(2); + expect(r.stderr).toContain(`gstack-uninstall: refusing to delete ${path.join(mockHome, '.gstack')}: `); + expect(r.stderr).toMatch(/^fix: .*docs\/state-root\.md/m); + expect(fs.existsSync(path.join(installBin, 'gstack-uninstall'))).toBe(true); + expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true); + }); + } + + test('--keep-state deletes nothing and never refuses', () => { + const custom = path.join(tmp, 'custom-state'); + fs.mkdirSync(custom, { recursive: true }); + fs.symlinkSync(mockHome, path.join(mockHome, '.gstack')); + const r = uninstall(['--force', '--keep-state'], { GSTACK_HOME: custom }); + expect(r.status).toBe(0); + expect(r.stderr).not.toContain('left in place:'); + expect(fs.existsSync(custom)).toBe(true); + expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true); + }); +}); diff --git a/test/user-render-out-dir-install.test.ts b/test/user-render-out-dir-install.test.ts index e982f9cd6..07dd014e7 100644 --- a/test/user-render-out-dir-install.test.ts +++ b/test/user-render-out-dir-install.test.ts @@ -173,6 +173,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => { 'SKILL_PREFIX=0', '_WINDOWS_COPY_NOTE_PRINTED=1', `GSTACK_HOME="${home}"`, + `GSTACK_STATE_ROOT="${home}"`, extractFn(SETUP_SRC, '_link_or_copy'), extractFn(SETUP_SRC, '_print_windows_copy_note_once'), extractFn(SETUP_SRC, '_link_skill_runtime_assets'),