mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
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.
This commit is contained in:
1 parent
ab3b6f02e8
commit
cd7485b0b4
98 files changed
+546
-268
No files matched your search
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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, <epoch>-<pid>-<uniq>.json.
|
||||
# Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file
|
||||
# + atomic rename; the drain deletes exactly the files it snapshotted. No
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-9
@@ -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" </dev/null 2>&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 --------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/<slug>/designs/ (where design-
|
||||
* cwd) or under <state root>/projects/<slug>/designs/ (where design-
|
||||
* review keeps rendered-DOM dumps: `designs/<audit>/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).
|
||||
* <state root>/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';
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' "<gstack state root: $_gstack_egress_lib_dir/gstack-state-root.sh is missing; reinstall with ./setup or /gstack-upgrade>"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+6
-4
@@ -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/<ts>/
|
||||
# differing file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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=<temp> 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 '/' '_')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+75
-7
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+12
-4
@@ -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 "path<TAB>hash",
|
||||
# $GSTACK_STATE_ROOT/verify-gate-trust (flat "path<TAB>hash",
|
||||
# 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
+11
-24
@@ -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 <gstack home>/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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+21
-25
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<Allowlist> {
|
||||
|
||||
@@ -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<string> {
|
||||
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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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, "_"));
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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<fs.BigIntStats,'dev'|'ino'|'mtimeNs'|'ctimeNs'|'isFile'|'isSymbolicLink'|'isDirectory'> & Pick<fs.Stats,'nlink'|'size'|'mode'|'uid'>;
|
||||
@@ -179,9 +180,8 @@ export function discardAtomicNoReplaceTemp(path:string,publisherPid:number,optio
|
||||
}
|
||||
|
||||
export function stateRoot(env: Record<string,string|undefined> = 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -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<string, readonly string[]> = {
|
||||
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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number> {
|
||||
}
|
||||
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).');
|
||||
}
|
||||
|
||||
@@ -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/<ts>/
|
||||
# weakly-proven file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
|
||||
# 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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -219,7 +219,7 @@ Report the exact output — either "READY: <path>" 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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]) {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+68
-6
@@ -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<string, string>, 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<string, string>,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user