feat: add GSD_HOME env var to override global ~/.gsd directory (#1566)

Centralise all ~/.gsd path construction through app-paths.ts (compiled
code) or a module-level `gsdHome` const (runtime extensions that cannot
import app-paths). When GSD_HOME is set, every path that previously
resolved under ~/.gsd now resolves under the override.

Existing overrides (GSD_STATE_DIR, GSD_CODING_AGENT_DIR) continue to
take precedence when set.
This commit is contained in:
Glen 2026-03-21 00:29:01 +10:00 committed by GitHub
parent 21a9ab2bcf
commit 869e037202
17 changed files with 52 additions and 21 deletions

View file

@ -151,6 +151,14 @@ Recommended verification order:
- If a server is team-shared and safe to commit, `.mcp.json` is usually the better home.
- If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.gsd/mcp.json`.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `GSD_HOME` | `~/.gsd` | Global GSD directory. All paths derive from this unless individually overridden. |
| `GSD_STATE_DIR` | `$GSD_HOME` | Per-project state root. Controls where `projects/<repo-hash>/` directories are created. Takes precedence over `GSD_HOME` for project state. |
| `GSD_CODING_AGENT_DIR` | `$GSD_HOME/agent` | Agent directory containing managed resources, extensions, and auth. Takes precedence over `GSD_HOME` for agent paths. |
## All Settings
### `models`

View file

@ -1,7 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
export const appRoot = join(homedir(), '.gsd')
export const appRoot = process.env.GSD_HOME || join(homedir(), '.gsd')
export const agentDir = join(appRoot, 'agent')
export const sessionsDir = join(appRoot, 'sessions')
export const authFilePath = join(agentDir, 'auth.json')

View file

@ -7,7 +7,7 @@
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { appRoot } from "./app-paths.js";
import { dirname, join } from "node:path";
// ─── Types ──────────────────────────────────────────────────────────────────
@ -66,7 +66,7 @@ function isManifest(data: unknown): data is ExtensionManifest {
// ─── Registry Path ──────────────────────────────────────────────────────────
export function getRegistryPath(): string {
return join(homedir(), ".gsd", "extensions", "registry.json");
return join(appRoot, "extensions", "registry.json");
}
// ─── Registry I/O ───────────────────────────────────────────────────────────

View file

@ -10,13 +10,13 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { appRoot } from "./app-paths.js";
// Inlined from preferences.ts to avoid crossing the compiled/uncompiled
// boundary — this file is compiled by tsc, but preferences.ts is loaded
// via jiti at runtime. Importing it as .js fails because no .js exists
// in dist/. See #592, #1110.
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
const GLOBAL_PREFERENCES_PATH = join(appRoot, "preferences.md");
export function saveRemoteQuestionsConfig(channel: "slack" | "discord" | "telegram", channelId: string): void {
const prefsPath = GLOBAL_PREFERENCES_PATH;

View file

@ -22,6 +22,8 @@ import { join, sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Project Root → Worktree Sync ─────────────────────────────────────────
/**
@ -111,7 +113,7 @@ export function syncStateToProjectRoot(
*/
export function readResourceVersion(): string | null {
const agentDir =
process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
const manifestPath = join(agentDir, "managed-resources.json");
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));

View file

@ -11,6 +11,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFile
import { dirname, join } from "node:path";
import { homedir } from "node:os";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Types (mirrored from extension-registry.ts) ────────────────────────────
interface ExtensionManifest {
@ -48,11 +50,11 @@ interface ExtensionRegistry {
// ─── Registry I/O ───────────────────────────────────────────────────────────
function getRegistryPath(): string {
return join(homedir(), ".gsd", "extensions", "registry.json");
return join(gsdHome, "extensions", "registry.json");
}
function getAgentExtensionsDir(): string {
return join(homedir(), ".gsd", "agent", "extensions");
return join(gsdHome, "agent", "extensions");
}
function loadRegistry(): ExtensionRegistry {

View file

@ -10,6 +10,8 @@ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
import { enableDebug } from "./debug-logger.js";
import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
@ -482,7 +484,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
if (parts.length === 3 && ["enable", "disable", "info"].includes(parts[1])) {
const idPrefix = parts[2] ?? "";
try {
const extDir = join(homedir(), ".gsd", "agent", "extensions");
const extDir = join(gsdHome, "agent", "extensions");
const ids: { id: string; name: string }[] = [];
for (const entry of readdirSync(extDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;

View file

@ -11,6 +11,8 @@ import { join } from "node:path";
import { homedir } from "node:os";
import { gsdRoot } from "./paths.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Types ──────────────────────────────────────────────────────────────────────
export interface ProjectDetection {
@ -400,7 +402,6 @@ function detectVerificationCommands(
* Check if global GSD setup exists (has ~/.gsd/ with preferences).
*/
export function hasGlobalSetup(): boolean {
const gsdHome = join(homedir(), ".gsd");
return (
existsSync(join(gsdHome, "preferences.md")) ||
existsSync(join(gsdHome, "PREFERENCES.md"))
@ -412,7 +413,6 @@ export function hasGlobalSetup(): boolean {
* Returns true if ~/.gsd/ doesn't exist or has no preferences or auth.
*/
export function isFirstEverLaunch(): boolean {
const gsdHome = join(homedir(), ".gsd");
if (!existsSync(gsdHome)) return true;
// If we have preferences, not first launch

View file

@ -60,6 +60,8 @@ import { join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { shortcutDesc } from "../shared/mod.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
import { Text } from "@gsd/pi-tui";
import { pauseAutoForProviderError, classifyProviderError } from "./provider-error-pause.js";
import { toPosixPath } from "../shared/mod.js";
@ -73,7 +75,7 @@ import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../cmux/index.js"
function warnDeprecatedAgentInstructions(): void {
const paths = [
join(homedir(), ".gsd", "agent-instructions.md"),
join(gsdHome, "agent-instructions.md"),
join(process.cwd(), ".gsd", "agent-instructions.md"),
];
for (const p of paths) {

View file

@ -13,6 +13,8 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
import { gsdRoot } from "./paths.js";
import { parse as parseYaml } from "yaml";
import type { PostUnitHookConfig, PreDispatchHookConfig, TokenProfile } from "./types.js";
@ -82,14 +84,14 @@ export {
// ─── Path Constants & Getters ───────────────────────────────────────────────
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
const GLOBAL_PREFERENCES_PATH = join(gsdHome, "preferences.md");
const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
function projectPreferencesPath(): string {
return join(gsdRoot(process.cwd()), "preferences.md");
}
// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
// Check uppercase as a fallback so those files aren't silently ignored.
const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.md");
const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(gsdHome, "PREFERENCES.md");
function projectPreferencesPathUppercase(): string {
return join(gsdRoot(process.cwd()), "PREFERENCES.md");
}

View file

@ -12,6 +12,8 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, s
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Repo Identity ──────────────────────────────────────────────────────────
/**
@ -113,7 +115,7 @@ export function repoIdentity(basePath: string): string {
* otherwise `~/.gsd/projects/<hash>`.
*/
export function externalGsdRoot(basePath: string): string {
const base = process.env.GSD_STATE_DIR || join(homedir(), ".gsd");
const base = process.env.GSD_STATE_DIR || gsdHome;
return join(base, "projects", repoIdentity(basePath));
}

View file

@ -11,6 +11,8 @@ import { join } from "node:path";
import { homedir } from "node:os";
import { resolveProjectRoot } from "./worktree.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
@ -23,7 +25,7 @@ function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
}
export function readResourceVersion(): string | null {
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
const manifestPath = join(agentDir, "managed-resources.json");
const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
return manifest?.gsdVersion ?? null;

View file

@ -7,6 +7,8 @@ import { join } from "node:path";
import { homedir } from "node:os";
import { readPromptRecord } from "./store.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
export interface LatestPromptSummary {
id: string;
status: string;
@ -14,7 +16,7 @@ export interface LatestPromptSummary {
}
export function getLatestPromptSummary(): LatestPromptSummary | null {
const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions");
const runtimeDir = join(gsdHome, "runtime", "remote-questions");
if (!existsSync(runtimeDir)) return null;
const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json"));
if (files.length === 0) return null;

View file

@ -7,8 +7,10 @@ import { join } from "node:path";
import { homedir } from "node:os";
import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
function runtimeDir(): string {
return join(homedir(), ".gsd", "runtime", "remote-questions");
return join(gsdHome, "runtime", "remote-questions");
}
function recordPath(id: string): string {

View file

@ -17,7 +17,8 @@ import { resolveSearchProviderFromPreferences } from '../gsd/preferences.js'
// Compute authFilePath locally instead of importing from app-paths.ts,
// because extensions are copied to ~/.gsd/agent/extensions/ at runtime
// where the relative import '../../../app-paths.ts' doesn't resolve.
const authFilePath = join(homedir(), '.gsd', 'agent', 'auth.json')
const gsdHome = process.env.GSD_HOME || join(homedir(), '.gsd')
const authFilePath = join(gsdHome, 'agent', 'auth.json')
export type SearchProvider = 'tavily' | 'brave' | 'ollama'
export type SearchProviderPreference = SearchProvider | 'auto'

View file

@ -57,8 +57,10 @@ function encodeCwd(cwd: string): string {
return cwd.replace(/\//g, "--");
}
const gsdHome = process.env.GSD_HOME || path.join(os.homedir(), ".gsd");
function getIsolationBaseDir(cwd: string, taskId: string): string {
return path.join(os.homedir(), ".gsd", "wt", encodeCwd(cwd), taskId);
return path.join(gsdHome, "wt", encodeCwd(cwd), taskId);
}
// Track active isolation dirs for cleanup on exit

View file

@ -8,6 +8,8 @@
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
import type { Rule } from "./ttsr-manager.js";
import { splitFrontmatter, parseFrontmatterMap } from "../shared/frontmatter.js";
@ -59,7 +61,7 @@ function scanDir(dir: string): Rule[] {
* Project rules override global rules with the same name.
*/
export function loadRules(cwd: string): Rule[] {
const globalDir = join(homedir(), ".gsd", "agent", "rules");
const globalDir = join(gsdHome, "agent", "rules");
const projectDir = join(cwd, ".gsd", "rules");
const globalRules = scanDir(globalDir);