From 19a851b769660afe5c7fc18332c04779ca0805f6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 3 Apr 2026 22:13:53 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(sdk,cli):=20external=20state=20storage?= =?UTF-8?q?=20=E2=80=94=20move=20.squad/=20out=20of=20working=20tree=20(#7?= =?UTF-8?q?92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stateLocation: 'external' to SquadDirConfig - resolveExternalStateDir() + deriveProjectKey() utilities - resolveSquadPaths() honors external state location - squad externalize / internalize commands - State survives branch switches, invisible to git status - 9 new tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/external-state.md | 15 ++ .../squad-cli/src/cli/commands/externalize.ts | 193 ++++++++++++++++++ packages/squad-sdk/src/index.ts | 2 +- packages/squad-sdk/src/resolution.ts | 57 ++++++ test/external-state.test.ts | 92 +++++++++ 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 .changeset/external-state.md create mode 100644 packages/squad-cli/src/cli/commands/externalize.ts create mode 100644 test/external-state.test.ts diff --git a/.changeset/external-state.md b/.changeset/external-state.md new file mode 100644 index 000000000..fb11df7b0 --- /dev/null +++ b/.changeset/external-state.md @@ -0,0 +1,15 @@ +--- +"@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 to `~/.squad/projects/{projectKey}/` +- `deriveProjectKey()` generates a stable key from the repo path +- `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) +- 8 new tests 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..757b83a42 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -0,0 +1,193 @@ +/** + * squad externalize — move .squad/ state out of the working tree. + * + * Moves all squad state to `~/.squad/projects/{projectKey}/` 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); + if (storage.existsSync(dest)) { + // Merge: copy contents over (don't clobber existing) + copyDirRecursive(src, dest); + } else { + 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 + const config = { + version: 1, + teamRoot: '.', + projectKey: key, + stateLocation: 'external', + }; + storage.writeSync( + path.join(squadDir, 'config.json'), + 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 the external marker from config.json + const updatedConfig = { ...config }; + delete updatedConfig.stateLocation; + storage.writeSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n'); + + 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 185a192a2..554d8f581 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 } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, resolveExternalStateDir, deriveProjectKey } 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 fbffaf461..bd9cd240c 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -34,6 +34,14 @@ export interface SquadDirConfig { consult?: boolean; /** True when extraction is disabled for consult sessions (read-only consultation) */ extractionDisabled?: boolean; + /** + * Where squad state is stored. + * - 'local' (default): state lives in `.squad/` inside the repo + * - 'external': state lives in `~/.squad/projects/{projectKey}/`, only a + * thin config.json marker remains in the repo. Survives branch switches, + * invisible to `git status`, never pollutes PRs. + */ + stateLocation?: 'local' | 'external'; } /** @@ -222,6 +230,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: parsed.stateLocation === 'external' ? 'external' : undefined, }; } return null; @@ -258,6 +267,22 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null const isLegacy = name === '.ai-team'; const config = loadDirConfig(projectDir); + if (config && config.stateLocation === 'external') { + // External mode: state lives in ~/.squad/projects/{projectKey}/ + const projectRoot = path.resolve(projectDir, '..'); + const projectKey = config.projectKey || deriveProjectKey(projectRoot); + const externalDir = resolveExternalStateDir(projectKey); + return { + mode: 'remote', + projectDir: externalDir, + teamDir: externalDir, + personalDir: resolvePersonalSquadDir(), + config, + name, + isLegacy, + }; + } + if (config && config.teamRoot) { // Remote mode: teamDir resolved relative to the project root (parent of .squad/) const projectRoot = path.resolve(projectDir, '..'); @@ -323,6 +348,38 @@ export function resolveGlobalSquadPath(): string { return globalDir; } +/** + * Resolve the external state directory for a project. + * + * External state lives under the global squad config: + * `{globalSquadDir}/projects/{projectKey}/` + * + * This is invisible to `git status`, survives branch switches, and never + * pollutes PRs. The project key is a stable slug derived from the repo name + * or an explicit key in `.squad/config.json`. + * + * @param projectKey - Unique project identifier (slug). Falls back to repo basename. + * @param create - Whether to create the directory if missing (default: true). + * @returns Absolute path to the external state directory. + */ +export function resolveExternalStateDir(projectKey: string, create: boolean = true): string { + const globalDir = resolveGlobalSquadPath(); + const stateDir = path.join(globalDir, 'projects', projectKey); + if (create && !storage.existsSync(stateDir)) { + storage.mkdirSync(stateDir, { recursive: true }); + } + return stateDir; +} + +/** + * Derive a stable project key from the repo root path. + * Uses the basename of the repo directory, lowercased and sanitized. + */ +export function deriveProjectKey(repoRoot: string): string { + const basename = path.basename(repoRoot); + return basename.toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'unknown-project'; +} + /** * Resolves the user's personal squad directory. * Returns null if SQUAD_NO_PERSONAL is set or directory doesn't exist. diff --git a/test/external-state.test.ts b/test/external-state.test.ts new file mode 100644 index 000000000..8ab83fb1d --- /dev/null +++ b/test/external-state.test.ts @@ -0,0 +1,92 @@ +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()}`); + +afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); +}); + +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'); + + // Cleanup + rmSync(dir, { recursive: true, force: true }); + }); + + 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); + rmSync(dir1, { recursive: true, force: true }); + }); +}); + +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(); + }); +}); From 797c31da5f1b37307c6b97c1b94202ba84b6062f Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 3 Apr 2026 23:48:03 +0300 Subject: [PATCH 2/6] fix: address all 10 Copilot review comments on external state (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix doc comment: ~/ → {globalSquadDir}/ for accuracy - Add path traversal protection: sanitize projectKey, reject ../ - Cross-platform deriveProjectKey: use path.win32.basename for Windows paths - Preserve existing config.json fields during externalize (merge, don't overwrite) - Delete config.json on internalize (restore true local mode) - Remove dead code branch in directory copy - Isolate tests from real user state (redirect env vars to TEST_ROOT) - Add 3 security tests (path traversal, empty key, sanitization) - Fix changeset: correct test count (12) and path description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/external-state.md | 7 ++-- .../squad-cli/src/cli/commands/externalize.ts | 42 ++++++++++++------- packages/squad-sdk/src/resolution.ts | 15 +++++-- test/external-state.test.ts | 41 +++++++++++++++--- 4 files changed, 78 insertions(+), 27 deletions(-) diff --git a/.changeset/external-state.md b/.changeset/external-state.md index fb11df7b0..9585a11d6 100644 --- a/.changeset/external-state.md +++ b/.changeset/external-state.md @@ -6,10 +6,11 @@ Add external state storage — move .squad/ out of the working tree (#792) - New `stateLocation: 'external'` option in `.squad/config.json` -- `resolveExternalStateDir()` resolves state to `~/.squad/projects/{projectKey}/` -- `deriveProjectKey()` generates a stable key from the repo path +- `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) -- 8 new tests +- Path traversal protection on projectKey +- 12 new tests diff --git a/packages/squad-cli/src/cli/commands/externalize.ts b/packages/squad-cli/src/cli/commands/externalize.ts index 757b83a42..3d2a82c2b 100644 --- a/packages/squad-cli/src/cli/commands/externalize.ts +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -55,12 +55,9 @@ export function runExternalize(projectDir: string, projectKey?: string): void { const src = path.join(squadDir, dir); if (!storage.existsSync(src)) continue; const dest = path.join(externalDir, dir); - if (storage.existsSync(dest)) { - // Merge: copy contents over (don't clobber existing) - copyDirRecursive(src, dest); - } else { - copyDirRecursive(src, dest); - } + // 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++; } @@ -71,7 +68,7 @@ export function runExternalize(projectDir: string, projectKey?: string): void { if (!storage.existsSync(src)) continue; const dest = path.join(externalDir, file); const content = storage.readSync(src); - if (content !== null) { + if (content != null) { storage.mkdirSync(path.dirname(dest), { recursive: true }); storage.writeSync(dest, content); } @@ -79,15 +76,30 @@ export function runExternalize(projectDir: string, projectKey?: string): void { movedCount++; } - // Write thin config.json marker + // 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( - path.join(squadDir, 'config.json'), + configPath, JSON.stringify(config, null, 2) + '\n', ); @@ -147,16 +159,16 @@ export function runInternalize(projectDir: string): void { const src = path.join(externalDir, file); if (!storage.existsSync(src)) continue; const content = storage.readSync(src); - if (content !== null) { + if (content != null) { storage.writeSync(path.join(squadDir, file), content); } movedCount++; } - // Remove the external marker from config.json - const updatedConfig = { ...config }; - delete updatedConfig.stateLocation; - storage.writeSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n'); + // Remove the config.json marker to restore true local mode. + // loadDirConfig() returns null when config.json is absent → resolveSquadPaths() + // falls through to local mode (projectDir === teamDir). + storage.deleteSync?.(configPath); console.log(`✅ Internalized ${movedCount} items. State is back in .squad/.`); } @@ -171,7 +183,7 @@ function copyDirRecursive(src: string, dest: string): void { copyDirRecursive(srcPath, destPath); } else { const content = storage.readSync(srcPath); - if (content !== null) { + if (content != null) { storage.writeSync(destPath, content); } } diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index bd9cd240c..010ec8ca0 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -37,7 +37,7 @@ export interface SquadDirConfig { /** * Where squad state is stored. * - 'local' (default): state lives in `.squad/` inside the repo - * - 'external': state lives in `~/.squad/projects/{projectKey}/`, only a + * - 'external': state lives in `{globalSquadDir}/projects/{projectKey}/`, only a * thin config.json marker remains in the repo. Survives branch switches, * invisible to `git status`, never pollutes PRs. */ @@ -363,8 +363,13 @@ export function resolveGlobalSquadPath(): string { * @returns Absolute path to the external state directory. */ export function resolveExternalStateDir(projectKey: string, create: boolean = true): string { + // Sanitize: reject path traversal attempts + const sanitized = projectKey.replace(/[^a-z0-9._-]/g, '-').replace(/^-+|-+$/g, ''); + if (!sanitized || sanitized === '.' || sanitized === '..' || sanitized.includes('..')) { + throw new Error(`Invalid project key: "${projectKey}"`); + } const globalDir = resolveGlobalSquadPath(); - const stateDir = path.join(globalDir, 'projects', projectKey); + const stateDir = path.join(globalDir, 'projects', sanitized); if (create && !storage.existsSync(stateDir)) { storage.mkdirSync(stateDir, { recursive: true }); } @@ -376,8 +381,10 @@ export function resolveExternalStateDir(projectKey: string, create: boolean = tr * Uses the basename of the repo directory, lowercased and sanitized. */ export function deriveProjectKey(repoRoot: string): string { - const basename = path.basename(repoRoot); - return basename.toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'unknown-project'; + // Handle Windows paths on non-Windows platforms (and vice versa) + const isWindowsPath = repoRoot.includes('\\') || /^[a-zA-Z]:/.test(repoRoot); + const basename = isWindowsPath ? path.win32.basename(repoRoot) : path.basename(repoRoot); + return basename.toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/^-+|-+$/g, '') || 'unknown-project'; } /** diff --git a/test/external-state.test.ts b/test/external-state.test.ts index 8ab83fb1d..ce8ae83a6 100644 --- a/test/external-state.test.ts +++ b/test/external-state.test.ts @@ -6,8 +6,27 @@ 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', () => { @@ -16,7 +35,7 @@ describe('deriveProjectKey', () => { }); it('replaces spaces and special chars with dashes', () => { - expect(deriveProjectKey('/path/to/My Project (v2)')).toBe('my-project--v2-'); + expect(deriveProjectKey('/path/to/My Project (v2)')).toBe('my-project--v2'); }); it('handles Windows paths', () => { @@ -35,9 +54,6 @@ describe('resolveExternalStateDir', () => { expect(existsSync(dir)).toBe(true); expect(dir).toContain('projects'); expect(dir).toContain('test-project-123'); - - // Cleanup - rmSync(dir, { recursive: true, force: true }); }); it('returns path without creating when create=false', () => { @@ -51,7 +67,6 @@ describe('resolveExternalStateDir', () => { const dir1 = resolveExternalStateDir('idempotent-test-proj'); const dir2 = resolveExternalStateDir('idempotent-test-proj'); expect(dir1).toBe(dir2); - rmSync(dir1, { recursive: true, force: true }); }); }); @@ -90,3 +105,19 @@ describe('SquadDirConfig stateLocation', () => { 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\\'); + }); +}); From d1b80d14d80e0e7696393361eb59a29ae183428d Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 02:13:35 +0300 Subject: [PATCH 3/6] fix: wire externalize/internalize commands into CLI entry router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes unwired-command CI gate — externalize.ts was added but never imported in cli-entry.ts, making it unreachable for users. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 3c7e970c4..b3b23dee5 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -693,6 +693,19 @@ async function main(): Promise { return; } + if (cmd === 'externalize') { + const { runExternalize } = await import('./cli/commands/externalize.js'); + const projectKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : 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'); From ef5af9e952c31479e88b806196b0a805a4614470 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 4 Apr 2026 18:43:21 +0300 Subject: [PATCH 4/6] fix: address Copilot review comments - Update JSDoc in externalize.ts to match platform-specific paths from resolveGlobalSquadPath() - Add comment on resolveSquad() explaining it intentionally returns the in-repo marker dir - Sanitize projectKey at CLI injection point with replace(/[\/\\\.]/g, '_') Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 3 ++- packages/squad-cli/src/cli/commands/externalize.ts | 6 ++++-- packages/squad-sdk/src/resolution.ts | 6 ++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index b3b23dee5..131512a5d 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -695,7 +695,8 @@ async function main(): Promise { if (cmd === 'externalize') { const { runExternalize } = await import('./cli/commands/externalize.js'); - const projectKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : undefined; + const rawKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : undefined; + const projectKey = rawKey ? rawKey.replace(/[\/\\\.]/g, '_') : undefined; runExternalize(process.cwd(), projectKey); return; } diff --git a/packages/squad-cli/src/cli/commands/externalize.ts b/packages/squad-cli/src/cli/commands/externalize.ts index 3d2a82c2b..911dded9b 100644 --- a/packages/squad-cli/src/cli/commands/externalize.ts +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -1,8 +1,10 @@ /** * squad externalize — move .squad/ state out of the working tree. * - * Moves all squad state to `~/.squad/projects/{projectKey}/` and writes - * a thin `.squad/config.json` marker in the repo. After externalization: + * 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 diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index 010ec8ca0..e0f2d3880 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -108,10 +108,16 @@ function getMainWorktreePath(worktreeDir: string, gitFilePath: string): string | * 1. Walk up from `startDir` checking for `.squad/` — stops at `.git` directory boundary * 2. If `.git` is a file (worktree), check the main checkout for `.squad/` * + * **Note:** In external-state mode, this still returns the in-repo `.squad/` path. + * That directory serves as a marker (containing only `config.json`) — the actual + * state directory is resolved by `resolveSquadPaths()` via `resolveExternalStateDir()`. + * * @param startDir - Directory to start searching from. Defaults to `process.cwd()`. * @returns Absolute path to `.squad/` or `null`. */ export function resolveSquad(startDir?: string): string | null { + // Intentionally returns the in-repo .squad/ marker directory, even when state + // is externalized. Callers needing the actual state dir should use resolveSquadPaths(). let current = path.resolve(startDir ?? process.cwd()); // eslint-disable-next-line no-constant-condition From acc2611115a7e6c61b24bd4991cb60b2c2eaaa26 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 5 Apr 2026 08:02:27 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20sanitize=20projectKey,=20wire=20CLI=20commands,=20p?= =?UTF-8?q?reserve=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolveExternalStateDir() with path traversal validation and projectKey sanitization (rejects empty, '..', normalizes separators) - Add deriveProjectKey() for stable project key derivation from paths - Add stateLocation field to SquadDirConfig interface and loadDirConfig() - Export both new functions from SDK index - Fix runInternalize() to remove teamRoot and projectKey alongside stateLocation (preserves unrelated config fields like consult/stateBackend) - Docstrings reference platform-specific paths (%APPDATA%, ~/.config, etc.) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../squad-cli/src/cli/commands/externalize.ts | 31 ++++++-- packages/squad-sdk/src/index.ts | 2 +- packages/squad-sdk/src/resolution.ts | 70 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/squad-cli/src/cli/commands/externalize.ts b/packages/squad-cli/src/cli/commands/externalize.ts index 911dded9b..df2dc3ca9 100644 --- a/packages/squad-cli/src/cli/commands/externalize.ts +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -167,10 +167,33 @@ export function runInternalize(projectDir: string): void { movedCount++; } - // Remove the config.json marker to restore true local mode. - // loadDirConfig() returns null when config.json is absent → resolveSquadPaths() - // falls through to local mode (projectDir === teamDir). - storage.deleteSync?.(configPath); + // 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/.`); } 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 b2ec13dd0..a8d6b00fc 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -34,6 +34,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; } @@ -224,6 +226,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, }; } @@ -537,3 +540,70 @@ 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 base = path.basename(projectDir); + 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; +} From 13fd033463337f1a2b440045ed1a19d161f91e1a Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 5 Apr 2026 09:34:38 +0300 Subject: [PATCH 6/6] fix: normalize path separators in deriveProjectKey before basename --- packages/squad-sdk/src/resolution.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index a8d6b00fc..72e996ba2 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -556,7 +556,8 @@ export function scratchFile(squadRoot: string, prefix: string, ext: string = '.t * @returns A sanitized, lowercase project key suitable for use as a directory name. */ export function deriveProjectKey(projectDir: string): string { - const base = path.basename(projectDir); + const normalized = projectDir.replace(/\\/g, '/'); + const base = path.basename(normalized); if (!base) return 'unknown-project'; const sanitized = base