diff --git a/.changeset/external-state.md b/.changeset/external-state.md new file mode 100644 index 000000000..9585a11d6 --- /dev/null +++ b/.changeset/external-state.md @@ -0,0 +1,16 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Add external state storage — move .squad/ out of the working tree (#792) + +- New `stateLocation: 'external'` option in `.squad/config.json` +- `resolveExternalStateDir()` resolves state under the platform-specific global Squad directory (`resolveGlobalSquadPath()`) +- `deriveProjectKey()` generates a stable key from the repo path (cross-platform) +- `resolveSquadPaths()` honors external state location +- `squad externalize` moves state out, `squad internalize` moves it back +- State survives branch switches, invisible to `git status`, never pollutes PRs +- Thin `.squad/config.json` marker stays in repo (gitignored) +- Path traversal protection on projectKey +- 12 new tests diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c875d8c42..7f21415d5 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -774,6 +774,20 @@ async function main(): Promise { return; } + if (cmd === 'externalize') { + const { runExternalize } = await import('./cli/commands/externalize.js'); + const rawKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : undefined; + const projectKey = rawKey ? rawKey.replace(/[\/\\\.]/g, '_') : undefined; + runExternalize(process.cwd(), projectKey); + return; + } + + if (cmd === 'internalize') { + const { runInternalize } = await import('./cli/commands/externalize.js'); + runInternalize(process.cwd()); + return; + } + if (cmd === 'rc' || cmd === 'remote-control') { const { runRC } = await import('./cli/commands/rc.js'); const hasTunnel = args.includes('--tunnel'); diff --git a/packages/squad-cli/src/cli/commands/externalize.ts b/packages/squad-cli/src/cli/commands/externalize.ts new file mode 100644 index 000000000..df2dc3ca9 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -0,0 +1,230 @@ +/** + * squad externalize — move .squad/ state out of the working tree. + * + * Moves all squad state to `{resolveGlobalSquadPath()}/projects/{projectKey}/` + * (platform-specific: `%APPDATA%/squad/` on Windows, `~/Library/Application Support/squad/` + * on macOS, `$XDG_CONFIG_HOME/squad/` on Linux) and writes a thin `.squad/config.json` + * marker in the repo. After externalization: + * - State survives branch switches (not tied to the working tree) + * - State is invisible to `git status` and never pollutes PRs + * - A `.squad/config.json` marker lets the walk-up resolver find state + * + * To restore local state, run `squad internalize`. + * + * @module cli/commands/externalize + */ + +import path from 'node:path'; +import { FSStorageProvider, resolveExternalStateDir, deriveProjectKey } from '@bradygaster/squad-sdk'; +import { fatal } from '../core/errors.js'; + +const storage = new FSStorageProvider(); + +/** Directories under .squad/ that hold the actual state */ +const STATE_DIRS = [ + 'agents', 'casting', 'decisions', 'identity', 'orchestration-log', + 'log', 'plugins', 'templates', 'skills', 'sessions', '.scratch', +]; + +/** Files under .squad/ that hold state (not config.json — that stays) */ +const STATE_FILES = [ + 'team.md', 'routing.md', 'ceremonies.md', 'decisions.md', + 'decisions-archive.md', '.first-run', +]; + +/** + * Move .squad/ state to the external directory. + * + * @param projectDir - Absolute path to the project root (where .squad/ lives). + * @param projectKey - Optional explicit project key. Defaults to repo basename slug. + */ +export function runExternalize(projectDir: string, projectKey?: string): void { + const squadDir = path.join(projectDir, '.squad'); + if (!storage.existsSync(squadDir)) { + fatal('.squad/ directory not found. Run `squad init` first.'); + } + + // Derive project key + const key = projectKey || deriveProjectKey(projectDir); + const externalDir = resolveExternalStateDir(key, true); + + console.log(`📦 Externalizing .squad/ state to: ${externalDir}`); + + let movedCount = 0; + + // Move directories + for (const dir of STATE_DIRS) { + const src = path.join(squadDir, dir); + if (!storage.existsSync(src)) continue; + const dest = path.join(externalDir, dir); + // Copy contents into the external state directory. If destination files + // already exist, they may be overwritten by copyDirRecursive(). + copyDirRecursive(src, dest); + storage.deleteDirSync?.(src); + movedCount++; + } + + // Move files + for (const file of STATE_FILES) { + const src = path.join(squadDir, file); + if (!storage.existsSync(src)) continue; + const dest = path.join(externalDir, file); + const content = storage.readSync(src); + if (content != null) { + storage.mkdirSync(path.dirname(dest), { recursive: true }); + storage.writeSync(dest, content); + } + storage.deleteSync?.(src); + movedCount++; + } + + // Write thin config.json marker, preserving any existing config fields + const configPath = path.join(squadDir, 'config.json'); + let existingConfig: Record = {}; + if (storage.existsSync(configPath)) { + try { + const raw = storage.readSync(configPath); + if (raw != null) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + existingConfig = parsed as Record; + } + } + } catch { /* start fresh if config is malformed */ } + } + + const config = { + ...existingConfig, + version: 1, + teamRoot: '.', + projectKey: key, + stateLocation: 'external', + }; + storage.writeSync( + configPath, + JSON.stringify(config, null, 2) + '\n', + ); + + // Ensure config.json is gitignored (it's machine-local) + ensureGitignored(projectDir, '.squad/config.json'); + + console.log(`✅ Externalized ${movedCount} items. State now at: ${externalDir}`); + console.log(` .squad/config.json marker left in repo (gitignored).`); + console.log(` Run \`squad internalize\` to move state back.`); +} + +/** + * Move externalized state back into the working tree. + */ +export function runInternalize(projectDir: string): void { + const squadDir = path.join(projectDir, '.squad'); + const configPath = path.join(squadDir, 'config.json'); + + if (!storage.existsSync(configPath)) { + fatal('.squad/config.json not found. State is already local or not initialized.'); + } + + let config: { stateLocation?: string; projectKey?: string }; + try { + config = JSON.parse(storage.readSync(configPath) ?? '{}'); + } catch { + fatal('.squad/config.json is malformed.'); + return; + } + + if (config.stateLocation !== 'external') { + fatal('State is already local (stateLocation is not "external").'); + } + + const key = config.projectKey || deriveProjectKey(projectDir); + const externalDir = resolveExternalStateDir(key, false); + + if (!storage.existsSync(externalDir)) { + fatal(`External state directory not found: ${externalDir}`); + } + + console.log(`📥 Internalizing state from: ${externalDir}`); + + let movedCount = 0; + + // Copy directories back + for (const dir of STATE_DIRS) { + const src = path.join(externalDir, dir); + if (!storage.existsSync(src)) continue; + const dest = path.join(squadDir, dir); + copyDirRecursive(src, dest); + movedCount++; + } + + // Copy files back + for (const file of STATE_FILES) { + const src = path.join(externalDir, file); + if (!storage.existsSync(src)) continue; + const content = storage.readSync(src); + if (content != null) { + storage.writeSync(path.join(squadDir, file), content); + } + movedCount++; + } + + // Remove external-state fields from config.json (stateLocation, teamRoot, + // projectKey) to restore true local mode. Preserve any other settings + // (e.g. consult, stateBackend). If nothing meaningful remains, delete the file + // so loadDirConfig() returns null and resolveSquadPaths() falls through to + // local mode. + let fullConfig: Record = {}; + try { + const raw = storage.readSync(configPath); + if (raw != null) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + fullConfig = parsed as Record; + } + } + } catch { /* treat malformed as empty */ } + + delete fullConfig.stateLocation; + delete fullConfig.teamRoot; + delete fullConfig.projectKey; + + // Check if any meaningful fields remain beyond version + const { version: _v, ...rest } = fullConfig; + if (Object.keys(rest).length > 0) { + storage.writeSync(configPath, JSON.stringify(fullConfig, null, 2) + '\n'); + } else { + storage.deleteSync?.(configPath); + } + + console.log(`✅ Internalized ${movedCount} items. State is back in .squad/.`); +} + +function copyDirRecursive(src: string, dest: string): void { + storage.mkdirSync(dest, { recursive: true }); + const entries = storage.listSync?.(src) ?? []; + for (const entry of entries) { + const srcPath = path.join(src, entry); + const destPath = path.join(dest, entry); + if (storage.isDirectorySync(srcPath)) { + copyDirRecursive(srcPath, destPath); + } else { + const content = storage.readSync(srcPath); + if (content != null) { + storage.writeSync(destPath, content); + } + } + } +} + +function ensureGitignored(projectDir: string, entry: string): void { + const gitignorePath = path.join(projectDir, '.gitignore'); + let existing = ''; + if (storage.existsSync(gitignorePath)) { + existing = storage.readSync(gitignorePath) ?? ''; + } + if (!existing.includes(entry)) { + const block = (existing && !existing.endsWith('\n') ? '\n' : '') + + '# Squad: local config (machine-specific, never commit)\n' + + entry + '\n'; + storage.appendSync(gitignorePath, block); + } +} diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 12f7e2c12..f8410aecb 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,7 +10,7 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir } from './resolution.js'; export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index 217a4f1ae..fa4bf8324 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -35,6 +35,8 @@ export interface SquadDirConfig { consult?: boolean; /** True when extraction is disabled for consult sessions (read-only consultation) */ extractionDisabled?: boolean; + /** Where state is stored: 'external' when moved out of the working tree */ + stateLocation?: string; /** State storage backend: worktree | external | git-notes | orphan */ stateBackend?: string; } @@ -225,6 +227,7 @@ export function loadDirConfig(squadDir: string): SquadDirConfig | null { projectKey: typeof parsed.projectKey === 'string' ? parsed.projectKey : null, consult: parsed.consult === true ? true : undefined, extractionDisabled: parsed.extractionDisabled === true ? true : undefined, + stateLocation: typeof parsed.stateLocation === 'string' ? parsed.stateLocation : undefined, stateBackend: typeof parsed.stateBackend === 'string' ? parsed.stateBackend : undefined, }; } @@ -528,3 +531,71 @@ export function scratchFile(squadRoot: string, prefix: string, ext: string = '.t } return filePath; } + +// ============================================================================ +// External state storage (Issue #792) +// ============================================================================ + +/** + * Derive a stable project key from a project directory path. + * + * Takes the basename of the path, lowercases it, and replaces unsafe characters + * with dashes. Returns `'unknown-project'` if the basename is empty (e.g., + * filesystem root). + * + * @param projectDir - Absolute path to the project root. + * @returns A sanitized, lowercase project key suitable for use as a directory name. + */ +export function deriveProjectKey(projectDir: string): string { + const normalized = projectDir.replace(/\\/g, '/'); + const base = path.basename(normalized); + if (!base) return 'unknown-project'; + + const sanitized = base + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .replace(/^-+|-+$/g, ''); + + return sanitized || 'unknown-project'; +} + +/** + * Resolve the external state directory for a project. + * + * Returns `{globalDir}/projects/{sanitizedKey}/` where `globalDir` is the + * platform-specific global config directory (e.g., `%APPDATA%/squad` on Windows, + * `~/Library/Application Support/squad` on macOS, `$XDG_CONFIG_HOME/squad` or + * `~/.config/squad` on Linux). + * + * Validates the project key to prevent path traversal. Throws if the key + * is empty or contains `..` sequences. + * + * @param projectKey - The project key (from deriveProjectKey or user-supplied). + * @param create - Whether to create the directory if it doesn't exist (default: true). + * @returns Absolute path to the project's external state directory. + * @throws If projectKey is empty or contains path traversal sequences. + */ +export function resolveExternalStateDir(projectKey: string, create: boolean = true): string { + if (!projectKey || projectKey.includes('..')) { + throw new Error('Invalid project key'); + } + + // Sanitize: replace path separators and unsafe chars with dashes + const sanitized = projectKey + .replace(/[/\\]/g, '-') + .replace(/[^a-zA-Z0-9._-]/g, '-') + .replace(/^-+|-+$/g, ''); + + if (!sanitized) { + throw new Error('Invalid project key'); + } + + const globalDir = resolveGlobalSquadPath(); + const projectsDir = path.join(globalDir, 'projects', sanitized); + + if (create && !storage.existsSync(projectsDir)) { + storage.mkdirSync(projectsDir, { recursive: true }); + } + + return projectsDir; +} diff --git a/test/external-state.test.ts b/test/external-state.test.ts new file mode 100644 index 000000000..ce8ae83a6 --- /dev/null +++ b/test/external-state.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolveExternalStateDir, deriveProjectKey } from '@bradygaster/squad-sdk'; +import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const TEST_ROOT = path.join(os.tmpdir(), `squad-external-test-${Date.now()}`); + +// Isolate tests from real user state by overriding config dirs +const origAppData = process.env['APPDATA']; +const origXdgConfig = process.env['XDG_CONFIG_HOME']; + +beforeEach(() => { + mkdirSync(TEST_ROOT, { recursive: true }); + // Redirect global squad dir into test root so we never touch real user state + if (process.platform === 'win32') { + process.env['APPDATA'] = TEST_ROOT; + } else { + process.env['XDG_CONFIG_HOME'] = TEST_ROOT; + } +}); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + // Restore env + if (origAppData !== undefined) process.env['APPDATA'] = origAppData; + else delete process.env['APPDATA']; + if (origXdgConfig !== undefined) process.env['XDG_CONFIG_HOME'] = origXdgConfig; + else delete process.env['XDG_CONFIG_HOME']; +}); + +describe('deriveProjectKey', () => { + it('lowercases and sanitizes the basename', () => { + expect(deriveProjectKey('/home/user/My-Cool-Project')).toBe('my-cool-project'); + }); + + it('replaces spaces and special chars with dashes', () => { + expect(deriveProjectKey('/path/to/My Project (v2)')).toBe('my-project--v2'); + }); + + it('handles Windows paths', () => { + expect(deriveProjectKey('C:\\Users\\tamir\\squad')).toBe('squad'); + }); + + it('returns "unknown-project" for empty basename', () => { + // path.basename of root returns '' + expect(deriveProjectKey('/')).toBe('unknown-project'); + }); +}); + +describe('resolveExternalStateDir', () => { + it('creates the projects directory', () => { + const dir = resolveExternalStateDir('test-project-123'); + expect(existsSync(dir)).toBe(true); + expect(dir).toContain('projects'); + expect(dir).toContain('test-project-123'); + }); + + it('returns path without creating when create=false', () => { + const dir = resolveExternalStateDir('nonexistent-project-xyz', false); + expect(dir).toContain('nonexistent-project-xyz'); + // May or may not exist depending on prior runs — just check the path is sane + expect(dir).toContain('projects'); + }); + + it('is idempotent', () => { + const dir1 = resolveExternalStateDir('idempotent-test-proj'); + const dir2 = resolveExternalStateDir('idempotent-test-proj'); + expect(dir1).toBe(dir2); + }); +}); + +describe('SquadDirConfig stateLocation', () => { + it('loadDirConfig parses stateLocation: external', async () => { + const { loadDirConfig } = await import('@bradygaster/squad-sdk'); + + const configDir = path.join(TEST_ROOT, '.squad'); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ + version: 1, + teamRoot: '.', + projectKey: 'my-project', + stateLocation: 'external', + })); + + const config = loadDirConfig(configDir); + expect(config).not.toBeNull(); + expect(config!.stateLocation).toBe('external'); + expect(config!.projectKey).toBe('my-project'); + }); + + it('loadDirConfig defaults stateLocation to undefined (local)', async () => { + const { loadDirConfig } = await import('@bradygaster/squad-sdk'); + + const configDir = path.join(TEST_ROOT, '.squad2'); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ + version: 1, + teamRoot: '.', + projectKey: null, + })); + + const config = loadDirConfig(configDir); + expect(config).not.toBeNull(); + expect(config!.stateLocation).toBeUndefined(); + }); +}); + +describe('resolveExternalStateDir security', () => { + it('rejects path traversal in projectKey', () => { + expect(() => resolveExternalStateDir('../../etc/passwd')).toThrow('Invalid project key'); + }); + + it('rejects empty projectKey', () => { + expect(() => resolveExternalStateDir('')).toThrow('Invalid project key'); + }); + + it('sanitizes special characters in projectKey', () => { + const dir = resolveExternalStateDir('my/project\\name'); + expect(dir).toContain('my-project-name'); + expect(dir).not.toContain('/project\\'); + }); +});