From b37f7bd4992d5564d344d0ec75aa796a632f0c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Can=20G=C3=BCm=C3=BC=C5=9F?= Date: Fri, 3 Jul 2026 23:18:57 +0300 Subject: [PATCH] fix(cli): resolve externalized state in squad export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `squad externalize` moves team state out of the working tree, `squad export` still read directly from the local .squad/ directory via detectSquadDir(). Depending on what remained locally, it either died with the misleading "No squad found — run init first" (the marker-only .squad/ has no team.md) or silently exported stale/re-scaffolded local files instead of the real team knowledge in the external state dir — a corrupt backup that reports success. Route export through effectiveSquadDir(), the same external-state-aware resolution already used by build, loop, plugin, watch, and doctor. All state reads (team.md, decisions.md, routing.md, casting/, agents/, and .squad-local skills) now come from the effective state directory; the existence check and manifest build both use it. Working-tree skill sources (.copilot/skills, .ai-team/skills) are unchanged, as is legacy .ai-team behavior (no external marker there, resolution falls through to the local path). Added two regression tests that fail without the fix: one exporting from a marker-only externalized project (previously fatal), one proving external state wins over stale local files when the stateLocation marker is set (previously exported the stale copy). Fixes #1396 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-1396-export-external-state.md | 5 ++ packages/squad-cli/src/cli/commands/export.ts | 11 +-- test/cli/export-import.test.ts | 75 +++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-1396-export-external-state.md diff --git a/.changeset/fix-1396-export-external-state.md b/.changeset/fix-1396-export-external-state.md new file mode 100644 index 000000000..1b8a8e420 --- /dev/null +++ b/.changeset/fix-1396-export-external-state.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix #1396: `squad export` now resolves externalized state. After `squad externalize`, export read the local `.squad/` directory directly, so it either failed with a misleading "No squad found — run init first" or silently exported stale/scaffolded local files instead of the real team state in the external directory. Export now routes through the same `effectiveSquadDir()` resolution used by `build`, `loop`, `plugin`, `watch`, and `doctor`, reading `team.md`, `decisions.md`, `routing.md`, `casting/`, `agents/`, and `.squad`-local skills from the effective state directory. diff --git a/packages/squad-cli/src/cli/commands/export.ts b/packages/squad-cli/src/cli/commands/export.ts index 3d02265f1..59f853b67 100644 --- a/packages/squad-cli/src/cli/commands/export.ts +++ b/packages/squad-cli/src/cli/commands/export.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { FSStorageProvider, exportToRepo, parseRepoString } from '@bradygaster/squad-sdk'; import type { RepoSpec } from '@bradygaster/squad-sdk'; -import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { effectiveSquadDir } from '../core/effective-squad-dir.js'; import { success, warn, info } from '../core/output.js'; import { fatal } from '../core/errors.js'; import { getPackageVersion } from '../core/version.js'; @@ -32,7 +32,8 @@ export interface ExportRepoOptions { } /** - * Build the export manifest from a local squad directory. + * Build the export manifest from the effective squad state directory + * (the external state dir when state is externalized, otherwise local .squad/). */ function buildManifest(dest: string, storage: FSStorageProvider, squadInfo: { path: string }): ExportManifest { const manifest: ExportManifest = { @@ -129,14 +130,14 @@ function buildManifest(dest: string, storage: FSStorageProvider, squadInfo: { pa */ export async function runExport(dest: string, outPath?: string, repoOptions?: ExportRepoOptions): Promise { const storage = new FSStorageProvider(); - const squadInfo = detectSquadDir(dest); - const teamMd = path.join(squadInfo.path, 'team.md'); + const { stateDir } = effectiveSquadDir(dest); + const teamMd = path.join(stateDir, 'team.md'); if (!storage.existsSync(teamMd)) { fatal('No squad found — run init first'); } - const manifest = buildManifest(dest, storage, squadInfo); + const manifest = buildManifest(dest, storage, { path: stateDir }); const bundleJson = JSON.stringify(manifest, null, 2) + '\n'; // Export to GitHub repo if --repo is specified diff --git a/test/cli/export-import.test.ts b/test/cli/export-import.test.ts index c6de80971..35c3ea6ed 100644 --- a/test/cli/export-import.test.ts +++ b/test/cli/export-import.test.ts @@ -13,6 +13,10 @@ import { runInit } from '@bradygaster/squad-cli/core/init'; import { runExport } from '@bradygaster/squad-cli/commands/export'; import { runImport } from '@bradygaster/squad-cli/commands/import'; +const EXT_ROOT = join(tmpdir(), `.test-cli-export-ext-${randomBytes(4).toString('hex')}`); +const EXT_GLOBAL = join(tmpdir(), `.test-cli-export-ext-global-${randomBytes(4).toString('hex')}`); +const EXT_PROJECT_KEY = `test-export-ext-${randomBytes(4).toString('hex')}`; + const TEST_ROOT = join(tmpdir(), `.test-cli-export-import-${randomBytes(4).toString('hex')}`); const IMPORT_ROOT = join(tmpdir(), `.test-cli-import-target-${randomBytes(4).toString('hex')}`); @@ -347,3 +351,74 @@ describe('CLI: export/import commands', () => { expect(existsSync(join(IMPORT_ROOT, '.squad', 'routing.md'))).toBe(false); }); }); + +describe('CLI: export with externalized state (#1396)', () => { + const origAppData = process.env['APPDATA']; + const origXdgConfig = process.env['XDG_CONFIG_HOME']; + const externalStateDir = join(EXT_GLOBAL, 'squad', 'projects', EXT_PROJECT_KEY); + + beforeEach(async () => { + if (existsSync(EXT_ROOT)) { + await rm(EXT_ROOT, { recursive: true, force: true }); + } + if (existsSync(EXT_GLOBAL)) { + await rm(EXT_GLOBAL, { recursive: true, force: true }); + } + + // Point resolveGlobalSquadPath() inside EXT_GLOBAL (not the real user dir) + if (process.platform === 'win32') { + process.env['APPDATA'] = EXT_GLOBAL; + } else { + process.env['XDG_CONFIG_HOME'] = EXT_GLOBAL; + } + + // Local repo: thin .squad/ holding only the marker `squad externalize` leaves behind + await mkdir(join(EXT_ROOT, '.squad'), { recursive: true }); + await writeFile( + join(EXT_ROOT, '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '.', projectKey: EXT_PROJECT_KEY, stateLocation: 'external' }, null, 2) + ); + + // External state dir: the real team state + await mkdir(join(externalStateDir, 'agents', 'alice'), { recursive: true }); + await writeFile(join(externalStateDir, 'team.md'), '# External Team\n'); + await writeFile(join(externalStateDir, 'decisions.md'), '# External Decisions\n'); + await writeFile(join(externalStateDir, 'agents', 'alice', 'charter.md'), '# Alice Charter\n'); + }); + + afterEach(async () => { + if (origAppData === undefined) delete process.env['APPDATA']; + else process.env['APPDATA'] = origAppData; + if (origXdgConfig === undefined) delete process.env['XDG_CONFIG_HOME']; + else process.env['XDG_CONFIG_HOME'] = origXdgConfig; + + if (existsSync(EXT_ROOT)) { + await rm(EXT_ROOT, { recursive: true, force: true }); + } + if (existsSync(EXT_GLOBAL)) { + await rm(EXT_GLOBAL, { recursive: true, force: true }); + } + }); + + it('exports team state from the external state dir', async () => { + await runExport(EXT_ROOT); + + const exportPath = join(EXT_ROOT, 'squad-export.json'); + expect(existsSync(exportPath)).toBe(true); + + const manifest = JSON.parse(await readFile(exportPath, 'utf-8')); + expect(manifest.team_md).toBe('# External Team\n'); + expect(manifest.decisions_md).toBe('# External Decisions\n'); + expect(manifest.agents['alice']?.charter).toBe('# Alice Charter\n'); + }); + + it('prefers external state over stale local files when the marker is set', async () => { + // Simulate local scaffolding re-created after externalize (e.g. a re-run init) + await writeFile(join(EXT_ROOT, '.squad', 'team.md'), '# Stale Local Scaffold\n'); + + await runExport(EXT_ROOT); + + const manifest = JSON.parse(await readFile(join(EXT_ROOT, 'squad-export.json'), 'utf-8')); + expect(manifest.team_md).toBe('# External Team\n'); + }); +});