From 7beb854793408caebd497a436bf339cc8d6c74d1 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 09:54:40 -0700 Subject: [PATCH 1/2] feat: add --repo flag for import/export to GitHub repos Add the ability to export Squad configuration directly to a GitHub repository and import it from one, using the GitHub Contents API via the gh CLI. New flags: - squad export --repo owner/repo [--branch branch] - squad import --repo owner/repo [--branch branch] [--force] Implementation: - New SDK module: sharing/repo-sync.ts with injectable operations interface for testability - Updated CLI export/import commands to handle --repo flag - Updated cli-entry.ts to parse --repo and --branch arguments - Full test coverage with mock operations Closes #1128 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/repo-import-export.md | 6 + packages/squad-cli/src/cli-entry.ts | 26 +- packages/squad-cli/src/cli/commands/export.ts | 77 ++++-- packages/squad-cli/src/cli/commands/import.ts | 149 ++++++++---- packages/squad-sdk/src/sharing/index.ts | 1 + packages/squad-sdk/src/sharing/repo-sync.ts | 223 ++++++++++++++++++ test/repo-sync.test.ts | 189 +++++++++++++++ 7 files changed, 608 insertions(+), 63 deletions(-) create mode 100644 .changeset/repo-import-export.md create mode 100644 packages/squad-sdk/src/sharing/repo-sync.ts create mode 100644 test/repo-sync.test.ts diff --git a/.changeset/repo-import-export.md b/.changeset/repo-import-export.md new file mode 100644 index 000000000..f6127f02d --- /dev/null +++ b/.changeset/repo-import-export.md @@ -0,0 +1,6 @@ +--- +'@bradygaster/squad-cli': minor +'@bradygaster/squad-sdk': minor +--- + +Add `--repo` and `--branch` flags to `squad export` and `squad import` commands, enabling users to push/pull Squad configuration directly to/from a GitHub repository via the GitHub Contents API. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 44c285bff..4ce442e45 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -704,18 +704,32 @@ async function main(): Promise { const { runExport } = await import('./cli/commands/export.js'); const outIdx = args.indexOf('--out'); const outPath = (outIdx !== -1 && args[outIdx + 1]) ? args[outIdx + 1] : undefined; - await runExport(getSquadStartDir(), outPath); + const repoIdx = args.indexOf('--repo'); + const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined; + const branchIdx = args.indexOf('--branch'); + const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined; + const repoOptions = repoArg ? { repo: repoArg, branch: branchArg } : undefined; + await runExport(getSquadStartDir(), outPath, repoOptions); return; } if (cmd === 'import') { const { runImport } = await import('./cli/commands/import.js'); - const importFile = args[1]; - if (!importFile) { - fatal('Usage: squad import [--force]'); - } + const repoIdx = args.indexOf('--repo'); + const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined; + const branchIdx = args.indexOf('--branch'); + const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined; const hasForce = args.includes('--force'); - await runImport(getSquadStartDir(), importFile, hasForce); + if (repoArg) { + const repoOptions = { repo: repoArg, branch: branchArg }; + await runImport(getSquadStartDir(), '', hasForce, repoOptions); + } else { + const importFile = args[1]; + if (!importFile) { + fatal('Usage: squad import [--force] or squad import --repo owner/repo [--branch branch] [--force]'); + } + await runImport(getSquadStartDir(), importFile, hasForce); + } return; } diff --git a/packages/squad-cli/src/cli/commands/export.ts b/packages/squad-cli/src/cli/commands/export.ts index 41216bb66..9e320926e 100644 --- a/packages/squad-cli/src/cli/commands/export.ts +++ b/packages/squad-cli/src/cli/commands/export.ts @@ -1,13 +1,15 @@ /** * Export command — port from beta CLI - * Exports squad to squad-export.json + * Exports squad to squad-export.json (local or GitHub repo) */ import path from 'node:path'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import { FSStorageProvider, exportToRepo, parseRepoString } from '@bradygaster/squad-sdk'; +import type { RepoSpec } from '@bradygaster/squad-sdk'; import { detectSquadDir } from '../core/detect-squad-dir.js'; -import { success, warn } from '../core/output.js'; +import { success, warn, info } from '../core/output.js'; import { fatal } from '../core/errors.js'; +import { ghAvailable, ghAuthenticated } from '../core/gh-cli.js'; interface ExportManifest { version: string; @@ -18,18 +20,15 @@ interface ExportManifest { skills: string[]; } +export interface ExportRepoOptions { + repo: string; + branch?: string; +} + /** - * Export squad to JSON + * Build the export manifest from a local squad directory. */ -export async function runExport(dest: string, outPath?: string): Promise { - const storage = new FSStorageProvider(); - const squadInfo = detectSquadDir(dest); - const teamMd = path.join(squadInfo.path, 'team.md'); - - if (!storage.existsSync(teamMd)) { - fatal('No squad found — run init first'); - } - +function buildManifest(dest: string, storage: FSStorageProvider, squadInfo: { path: string }): ExportManifest { const manifest: ExportManifest = { version: '1.0', exported_at: new Date().toISOString(), @@ -59,7 +58,6 @@ export async function runExport(dest: string, outPath?: string): Promise { if (storage.existsSync(agentsDir)) { for (const entry of storage.listSync(agentsDir)) { const agentDir = path.join(agentsDir, entry); - // Check if entry is a directory using StorageProvider if (!storage.isDirectorySync(agentDir)) continue; const agent: { charter?: string; history?: string } = {}; const charterPath = path.join(agentDir, 'charter.md'); @@ -98,11 +96,58 @@ export async function runExport(dest: string, outPath?: string): Promise { console.error(`Warning: could not read skills: ${(err as Error).message}`); } - // Determine output path + return manifest; +} + +/** + * Export squad to JSON (local file or GitHub repo) + */ +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'); + + if (!storage.existsSync(teamMd)) { + fatal('No squad found — run init first'); + } + + const manifest = buildManifest(dest, storage, squadInfo); + const bundleJson = JSON.stringify(manifest, null, 2) + '\n'; + + // Export to GitHub repo if --repo is specified + if (repoOptions?.repo) { + if (!(await ghAvailable())) { + fatal('GitHub CLI (gh) is required for repo export. Install from https://cli.github.com'); + } + if (!(await ghAuthenticated())) { + fatal('GitHub CLI is not authenticated. Run: gh auth login'); + } + + const parsed = parseRepoString(repoOptions.repo); + const repoSpec: RepoSpec = { + owner: parsed.owner, + repo: parsed.repo, + branch: repoOptions.branch, + }; + + try { + const result = await exportToRepo(bundleJson, repoSpec, { + branch: repoOptions.branch, + }); + success(result.message); + } catch (err) { + fatal(`Failed to export to repo: ${(err as Error).message}`); + } + + warn('Review agent histories before sharing — they may contain project-specific information'); + return; + } + + // Local file export (existing behavior) const finalOutPath = outPath || path.join(dest, 'squad-export.json'); try { - storage.writeSync(finalOutPath, JSON.stringify(manifest, null, 2) + '\n'); + storage.writeSync(finalOutPath, bundleJson); } catch (err) { fatal(`Failed to write export file: ${(err as Error).message}`); } diff --git a/packages/squad-cli/src/cli/commands/import.ts b/packages/squad-cli/src/cli/commands/import.ts index da61ad928..00d111a06 100644 --- a/packages/squad-cli/src/cli/commands/import.ts +++ b/packages/squad-cli/src/cli/commands/import.ts @@ -1,14 +1,16 @@ /** * Import command — port from beta CLI - * Imports squad from squad-export.json + * Imports squad from squad-export.json (local file or GitHub repo) */ import path from 'node:path'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import { FSStorageProvider, importFromRepo, parseRepoString } from '@bradygaster/squad-sdk'; +import type { RepoSpec } from '@bradygaster/squad-sdk'; import { detectSquadDir } from '../core/detect-squad-dir.js'; import { success, warn, info } from '../core/output.js'; import { fatal } from '../core/errors.js'; import { splitHistory } from '../core/history-split.js'; +import { ghAvailable, ghAuthenticated } from '../core/gh-cli.js'; interface ImportManifest { version: string; @@ -19,42 +21,21 @@ interface ImportManifest { skills: string[]; } +export interface ImportRepoOptions { + repo: string; + branch?: string; +} + /** - * Import squad from JSON + * Apply an import manifest to a target directory. */ -export async function runImport(dest: string, importPath: string, force: boolean): Promise { - const storage = new FSStorageProvider(); - const resolvedPath = path.resolve(importPath); - - if (!storage.existsSync(resolvedPath)) { - fatal(`Import file not found: ${importPath}`); - } - - let manifest: ImportManifest; - try { - const raw = storage.readSync(resolvedPath); - if (raw === undefined) { - fatal(`Import file not found: ${importPath}`); - } - manifest = JSON.parse(raw); - } catch (err) { - fatal(`Invalid JSON in import file: ${(err as Error).message}`); - } - - // Validate manifest - if (manifest.version !== '1.0') { - fatal(`Unsupported export version: ${manifest.version || 'missing'} (expected 1.0)`); - } - if (!manifest.agents || typeof manifest.agents !== 'object') { - fatal('Invalid export file: missing or invalid "agents" field'); - } - if (!manifest.casting || typeof manifest.casting !== 'object') { - fatal('Invalid export file: missing or invalid "casting" field'); - } - if (!Array.isArray(manifest.skills)) { - fatal('Invalid export file: missing or invalid "skills" field'); - } - +function applyManifest( + manifest: ImportManifest, + dest: string, + sourceLabel: string, + force: boolean, + storage: FSStorageProvider, +): void { const squadInfo = detectSquadDir(dest); const squadDir = squadInfo.path; @@ -89,8 +70,7 @@ export async function runImport(dest: string, importPath: string, force: boolean ); } - // Determine source project name from filename - const sourceProject = path.basename(resolvedPath, '.json'); + // Determine source project name const importDate = new Date().toISOString(); // Write agents @@ -106,9 +86,9 @@ export async function runImport(dest: string, importPath: string, force: boolean // History split: separate portable knowledge from project learnings let historyContent = ''; if (agent.history) { - historyContent = splitHistory(agent.history, sourceProject); + historyContent = splitHistory(agent.history, sourceLabel); } - historyContent = `📌 Imported from ${sourceProject} on ${importDate}. Portable knowledge carried over; project learnings from previous project preserved below.\n\n` + historyContent; + historyContent = `📌 Imported from ${sourceLabel} on ${importDate}. Portable knowledge carried over; project learnings from previous project preserved below.\n\n` + historyContent; storage.writeSync(path.join(agentDir, 'history.md'), historyContent); } @@ -132,7 +112,7 @@ export async function runImport(dest: string, importPath: string, force: boolean } // Output - success(`Imported squad from ${path.basename(resolvedPath)}`); + success(`Imported squad from ${sourceLabel}`); info(` ${agentNames.length} agents: ${agentNames.join(', ')}`); info(` ${manifest.skills.length} skills imported`); info(` Casting: ${universe} universe preserved`); @@ -144,3 +124,90 @@ export async function runImport(dest: string, importPath: string, force: boolean info(' 2. Tell the team about this project — they\'ll adapt'); console.log(); } + +/** + * Import squad from JSON (local file or GitHub repo) + */ +export async function runImport(dest: string, importPath: string, force: boolean, repoOptions?: ImportRepoOptions): Promise { + const storage = new FSStorageProvider(); + + // Import from GitHub repo if --repo is specified + if (repoOptions?.repo) { + if (!(await ghAvailable())) { + fatal('GitHub CLI (gh) is required for repo import. Install from https://cli.github.com'); + } + if (!(await ghAuthenticated())) { + fatal('GitHub CLI is not authenticated. Run: gh auth login'); + } + + const parsed = parseRepoString(repoOptions.repo); + const repoSpec: RepoSpec = { + owner: parsed.owner, + repo: parsed.repo, + branch: repoOptions.branch, + }; + + let manifest: ImportManifest; + try { + const result = await importFromRepo(repoSpec, { + branch: repoOptions.branch, + }); + manifest = JSON.parse(result.content); + } catch (err) { + fatal(`Failed to import from repo: ${(err as Error).message}`); + } + + // Validate manifest + if (manifest.version !== '1.0') { + fatal(`Unsupported export version: ${manifest.version || 'missing'} (expected 1.0)`); + } + if (!manifest.agents || typeof manifest.agents !== 'object') { + fatal('Invalid export file: missing or invalid "agents" field'); + } + if (!manifest.casting || typeof manifest.casting !== 'object') { + fatal('Invalid export file: missing or invalid "casting" field'); + } + if (!Array.isArray(manifest.skills)) { + fatal('Invalid export file: missing or invalid "skills" field'); + } + + const sourceLabel = `${repoSpec.owner}/${repoSpec.repo}`; + applyManifest(manifest, dest, sourceLabel, force, storage); + return; + } + + // Local file import (existing behavior) + const resolvedPath = path.resolve(importPath); + + if (!storage.existsSync(resolvedPath)) { + fatal(`Import file not found: ${importPath}`); + } + + let manifest: ImportManifest; + try { + const raw = storage.readSync(resolvedPath); + if (raw === undefined) { + fatal(`Import file not found: ${importPath}`); + } + manifest = JSON.parse(raw); + } catch (err) { + fatal(`Invalid JSON in import file: ${(err as Error).message}`); + } + + // Validate manifest + if (manifest.version !== '1.0') { + fatal(`Unsupported export version: ${manifest.version || 'missing'} (expected 1.0)`); + } + if (!manifest.agents || typeof manifest.agents !== 'object') { + fatal('Invalid export file: missing or invalid "agents" field'); + } + if (!manifest.casting || typeof manifest.casting !== 'object') { + fatal('Invalid export file: missing or invalid "casting" field'); + } + if (!Array.isArray(manifest.skills)) { + fatal('Invalid export file: missing or invalid "skills" field'); + } + + const sourceLabel = path.basename(resolvedPath, '.json'); + applyManifest(manifest, dest, sourceLabel, force, storage); +} diff --git a/packages/squad-sdk/src/sharing/index.ts b/packages/squad-sdk/src/sharing/index.ts index 50c62728d..2de0520e7 100644 --- a/packages/squad-sdk/src/sharing/index.ts +++ b/packages/squad-sdk/src/sharing/index.ts @@ -10,3 +10,4 @@ export * from './agent-repo.js'; export * from './cache.js'; export * from './conflicts.js'; export * from './consult.js'; +export * from './repo-sync.js'; diff --git a/packages/squad-sdk/src/sharing/repo-sync.ts b/packages/squad-sdk/src/sharing/repo-sync.ts new file mode 100644 index 000000000..bc28cfb6c --- /dev/null +++ b/packages/squad-sdk/src/sharing/repo-sync.ts @@ -0,0 +1,223 @@ +/** + * Repo Sync — export/import Squad configuration to/from a GitHub repository. + * + * Uses the GitHub Contents API via `gh api` for authentication and transport. + * The bundle is stored at a configurable path (default: `.squad/squad-export.json`) + * in the target repository. + */ + +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// ── Types ────────────────────────────────────────────────────────── + +export interface RepoSpec { + owner: string; + repo: string; + branch?: string; + path?: string; +} + +export interface RepoSyncResult { + success: boolean; + message: string; + sha?: string; +} + +/** + * Abstracted GitHub operations for repo-sync — enables test injection. + */ +export interface RepoSyncOperations { + /** Get file content and metadata from a repo. Returns null if not found. */ + getFile(owner: string, repo: string, path: string, branch?: string): Promise<{ content: string; sha: string } | null>; + /** Create or update a file in a repo. Requires sha for updates. */ + putFile(owner: string, repo: string, path: string, content: string, message: string, sha?: string, branch?: string): Promise<{ sha: string }>; + /** Get the default branch of a repo. */ + getDefaultBranch(owner: string, repo: string): Promise; +} + +// ── Validation ───────────────────────────────────────────────────── + +/** + * Parse an "owner/repo" string into components. + */ +export function parseRepoString(repoStr: string): { owner: string; repo: string } { + // Strip optional github.com URL prefix + const cleaned = repoStr + .replace(/^https?:\/\/github\.com\//, '') + .replace(/\.git$/, '') + .replace(/\/$/, ''); + + const parts = cleaned.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid repo format "${repoStr}": expected "owner/repo"`); + } + return { owner: parts[0], repo: parts[1] }; +} + +/** + * Validate a repo file path is safe (relative, no traversal). + */ +export function validateRepoPath(filePath: string): void { + if (!filePath || filePath.startsWith('/') || filePath.includes('..')) { + throw new Error(`Invalid repo path "${filePath}": must be relative with no ".." segments`); + } +} + +// ── Default Operations (gh CLI) ──────────────────────────────────── + +const DEFAULT_BUNDLE_PATH = '.squad/squad-export.json'; + +/** + * Create default operations using `gh api` CLI. + */ +export function createGhOperations(): RepoSyncOperations { + return { + async getFile(owner, repo, path, branch) { + const endpoint = `/repos/${owner}/${repo}/contents/${path}${branch ? `?ref=${branch}` : ''}`; + try { + const { stdout } = await execFileAsync('gh', [ + 'api', endpoint, '--jq', '{ content: .content, sha: .sha }', + ]); + const data = JSON.parse(stdout); + // GitHub returns base64 content, possibly line-wrapped + const decoded = Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8'); + return { content: decoded, sha: data.sha }; + } catch (err) { + const msg = (err as Error).message || ''; + if (msg.includes('404') || msg.includes('Not Found')) { + return null; + } + throw err; + } + }, + + async putFile(owner, repo, path, content, message, sha, branch) { + const endpoint = `/repos/${owner}/${repo}/contents/${path}`; + const encoded = Buffer.from(content).toString('base64'); + const body: Record = { + message, + content: encoded, + }; + if (sha) body.sha = sha; + if (branch) body.branch = branch; + + const stdout = await new Promise((resolve, reject) => { + const proc = spawn('gh', [ + 'api', endpoint, + '-X', 'PUT', + '--input', '-', + ], { stdio: ['pipe', 'pipe', 'pipe'] }); + + let out = ''; + let err = ''; + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString(); }); + proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString(); }); + proc.on('close', (code) => { + if (code === 0) resolve(out); + else reject(new Error(err || `gh api exited with code ${code}`)); + }); + proc.on('error', reject); + proc.stdin.write(JSON.stringify(body)); + proc.stdin.end(); + }); + + const data = JSON.parse(stdout); + return { sha: data.content?.sha || '' }; + }, + + async getDefaultBranch(owner, repo) { + const { stdout } = await execFileAsync('gh', [ + 'api', `/repos/${owner}/${repo}`, '--jq', '.default_branch', + ]); + return stdout.trim(); + }, + }; +} + +// ── Export to Repo ───────────────────────────────────────────────── + +export interface ExportToRepoOptions { + /** Commit message for the push. */ + message?: string; + /** Branch to push to. If omitted, uses repo default branch. */ + branch?: string; + /** Path in the repo for the bundle file. */ + path?: string; + /** Operations interface (injectable for testing). */ + ops?: RepoSyncOperations; +} + +/** + * Export a local Squad bundle JSON string to a GitHub repository. + */ +export async function exportToRepo( + bundleJson: string, + repoSpec: RepoSpec, + options?: ExportToRepoOptions, +): Promise { + const ops = options?.ops ?? createGhOperations(); + const filePath = repoSpec.path ?? options?.path ?? DEFAULT_BUNDLE_PATH; + validateRepoPath(filePath); + + const branch = repoSpec.branch ?? options?.branch; + const message = options?.message ?? 'chore: update Squad configuration export'; + + // Check if file already exists (need SHA for update) + const existing = await ops.getFile(repoSpec.owner, repoSpec.repo, filePath, branch); + const sha = existing?.sha; + + // Push the file + const result = await ops.putFile( + repoSpec.owner, + repoSpec.repo, + filePath, + bundleJson, + message, + sha, + branch, + ); + + return { + success: true, + message: `Exported to ${repoSpec.owner}/${repoSpec.repo}/${filePath}${branch ? ` (branch: ${branch})` : ''}`, + sha: result.sha, + }; +} + +// ── Import from Repo ─────────────────────────────────────────────── + +export interface ImportFromRepoOptions { + /** Branch to import from. If omitted, uses repo default branch. */ + branch?: string; + /** Path in the repo for the bundle file. */ + path?: string; + /** Operations interface (injectable for testing). */ + ops?: RepoSyncOperations; +} + +/** + * Import a Squad bundle JSON string from a GitHub repository. + * Returns the raw JSON content for the caller to apply. + */ +export async function importFromRepo( + repoSpec: RepoSpec, + options?: ImportFromRepoOptions, +): Promise<{ content: string; sha: string }> { + const ops = options?.ops ?? createGhOperations(); + const filePath = repoSpec.path ?? options?.path ?? DEFAULT_BUNDLE_PATH; + validateRepoPath(filePath); + + const branch = repoSpec.branch ?? options?.branch; + + const result = await ops.getFile(repoSpec.owner, repoSpec.repo, filePath, branch); + if (!result) { + throw new Error( + `No Squad export found at ${repoSpec.owner}/${repoSpec.repo}/${filePath}${branch ? ` (branch: ${branch})` : ''}`, + ); + } + + return result; +} diff --git a/test/repo-sync.test.ts b/test/repo-sync.test.ts new file mode 100644 index 000000000..6cdad72a5 --- /dev/null +++ b/test/repo-sync.test.ts @@ -0,0 +1,189 @@ +/** + * Repo Sync tests — export/import Squad configuration to/from GitHub repos + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + exportToRepo, + importFromRepo, + parseRepoString, + validateRepoPath, +} from '@bradygaster/squad-sdk/sharing'; +import type { RepoSyncOperations } from '@bradygaster/squad-sdk/sharing'; + +// ── Mock operations ──────────────────────────────────────────────── + +function createMockOps(files: Record = {}): RepoSyncOperations { + return { + async getFile(owner, repo, path, _branch) { + const key = `${owner}/${repo}/${path}`; + return files[key] ?? null; + }, + async putFile(owner, repo, path, content, _message, _sha, _branch) { + const key = `${owner}/${repo}/${path}`; + const newSha = 'abc123newsha'; + files[key] = { content, sha: newSha }; + return { sha: newSha }; + }, + async getDefaultBranch(_owner, _repo) { + return 'main'; + }, + }; +} + +// ── parseRepoString ──────────────────────────────────────────────── + +describe('parseRepoString', () => { + it('parses owner/repo format', () => { + expect(parseRepoString('octocat/hello-world')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + }); + }); + + it('strips github.com URL prefix', () => { + expect(parseRepoString('https://github.com/octocat/hello-world')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + }); + }); + + it('strips .git suffix', () => { + expect(parseRepoString('octocat/hello-world.git')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + }); + }); + + it('throws on invalid format', () => { + expect(() => parseRepoString('just-a-name')).toThrow('Invalid repo format'); + expect(() => parseRepoString('a/b/c')).toThrow('Invalid repo format'); + expect(() => parseRepoString('')).toThrow('Invalid repo format'); + }); +}); + +// ── validateRepoPath ─────────────────────────────────────────────── + +describe('validateRepoPath', () => { + it('accepts relative paths', () => { + expect(() => validateRepoPath('.squad/squad-export.json')).not.toThrow(); + expect(() => validateRepoPath('configs/squad.json')).not.toThrow(); + }); + + it('rejects absolute paths', () => { + expect(() => validateRepoPath('/etc/passwd')).toThrow('Invalid repo path'); + }); + + it('rejects path traversal', () => { + expect(() => validateRepoPath('../escape.json')).toThrow('Invalid repo path'); + expect(() => validateRepoPath('a/../b')).toThrow('Invalid repo path'); + }); + + it('rejects empty path', () => { + expect(() => validateRepoPath('')).toThrow('Invalid repo path'); + }); +}); + +// ── exportToRepo ─────────────────────────────────────────────────── + +describe('exportToRepo', () => { + it('creates a new file when none exists', async () => { + const files: Record = {}; + const ops = createMockOps(files); + const bundle = JSON.stringify({ version: '1.0', agents: {} }); + + const result = await exportToRepo(bundle, { owner: 'myorg', repo: 'config' }, { ops }); + + expect(result.success).toBe(true); + expect(result.message).toContain('myorg/config'); + expect(files['myorg/config/.squad/squad-export.json']).toBeDefined(); + expect(files['myorg/config/.squad/squad-export.json']!.content).toBe(bundle); + }); + + it('updates existing file (uses SHA)', async () => { + const files: Record = { + 'myorg/config/.squad/squad-export.json': { content: '{"old": true}', sha: 'oldsha123' }, + }; + const ops = createMockOps(files); + const putSpy = vi.spyOn(ops, 'putFile'); + const bundle = JSON.stringify({ version: '1.0', agents: {} }); + + await exportToRepo(bundle, { owner: 'myorg', repo: 'config' }, { ops }); + + expect(putSpy).toHaveBeenCalledWith( + 'myorg', 'config', '.squad/squad-export.json', + bundle, expect.any(String), 'oldsha123', undefined, + ); + }); + + it('respects branch option', async () => { + const ops = createMockOps(); + const putSpy = vi.spyOn(ops, 'putFile'); + const bundle = '{}'; + + await exportToRepo(bundle, { owner: 'a', repo: 'b', branch: 'dev' }, { ops }); + + expect(putSpy).toHaveBeenCalledWith( + 'a', 'b', '.squad/squad-export.json', + bundle, expect.any(String), undefined, 'dev', + ); + }); + + it('respects custom path in repoSpec', async () => { + const files: Record = {}; + const ops = createMockOps(files); + const bundle = '{}'; + + await exportToRepo(bundle, { owner: 'a', repo: 'b', path: 'custom/path.json' }, { ops }); + + expect(files['a/b/custom/path.json']).toBeDefined(); + }); +}); + +// ── importFromRepo ───────────────────────────────────────────────── + +describe('importFromRepo', () => { + it('fetches bundle from default path', async () => { + const bundleData = JSON.stringify({ version: '1.0', agents: {}, casting: {}, skills: [] }); + const files = { + 'myorg/config/.squad/squad-export.json': { content: bundleData, sha: 'sha456' }, + }; + const ops = createMockOps(files); + + const result = await importFromRepo({ owner: 'myorg', repo: 'config' }, { ops }); + + expect(result.content).toBe(bundleData); + expect(result.sha).toBe('sha456'); + }); + + it('throws when file not found', async () => { + const ops = createMockOps({}); + + await expect( + importFromRepo({ owner: 'myorg', repo: 'config' }, { ops }) + ).rejects.toThrow('No Squad export found'); + }); + + it('respects branch option', async () => { + const ops = createMockOps(); + const getSpy = vi.spyOn(ops, 'getFile'); + + try { + await importFromRepo({ owner: 'a', repo: 'b', branch: 'feat' }, { ops }); + } catch { /* expected not found */ } + + expect(getSpy).toHaveBeenCalledWith('a', 'b', '.squad/squad-export.json', 'feat'); + }); + + it('respects custom path', async () => { + const bundleData = '{"version":"1.0"}'; + const files = { + 'a/b/my/export.json': { content: bundleData, sha: 'sha789' }, + }; + const ops = createMockOps(files); + + const result = await importFromRepo({ owner: 'a', repo: 'b', path: 'my/export.json' }, { ops }); + + expect(result.content).toBe(bundleData); + }); +}); From c2c77e48bdae614ca82dd55dba628fb261814b3a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 10:17:54 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?export=20top-level=20files=20and=20add=20path=20traversal=20pro?= =?UTF-8?q?tection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Export now includes team.md, decisions.md, and routing.md in the manifest to avoid blank-file issues (#1122). 2. Import manifest schema updated with team_md, decisions_md, routing_md fields. Older bundles without these fields get empty defaults. 3. Path traversal protection: agent names and skill-derived names are validated as safe slugs, and resolved paths are checked to stay under their expected parent directories before any writes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli/commands/export.ts | 16 ++++ packages/squad-cli/src/cli/commands/import.ts | 40 +++++++++- test/cli/export-import.test.ts | 74 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/packages/squad-cli/src/cli/commands/export.ts b/packages/squad-cli/src/cli/commands/export.ts index 9e320926e..6e8e17f6e 100644 --- a/packages/squad-cli/src/cli/commands/export.ts +++ b/packages/squad-cli/src/cli/commands/export.ts @@ -15,6 +15,9 @@ interface ExportManifest { version: string; exported_at: string; squad_version: string; + team_md?: string; + decisions_md?: string; + routing_md?: string; casting: Record; agents: Record; skills: string[]; @@ -38,6 +41,19 @@ function buildManifest(dest: string, storage: FSStorageProvider, squadInfo: { pa skills: [] }; + // Read top-level squad files (team.md, decisions.md, routing.md) + const teamMdPath = path.join(squadInfo.path, 'team.md'); + const teamMdContent = storage.readSync(teamMdPath); + if (teamMdContent !== undefined) manifest.team_md = teamMdContent; + + const decisionsMdPath = path.join(squadInfo.path, 'decisions.md'); + const decisionsMdContent = storage.readSync(decisionsMdPath); + if (decisionsMdContent !== undefined) manifest.decisions_md = decisionsMdContent; + + const routingMdPath = path.join(squadInfo.path, 'routing.md'); + const routingMdContent = storage.readSync(routingMdPath); + if (routingMdContent !== undefined) manifest.routing_md = routingMdContent; + // Read casting state const castingDir = path.join(squadInfo.path, 'casting'); for (const file of ['registry.json', 'policy.json', 'history.json']) { diff --git a/packages/squad-cli/src/cli/commands/import.ts b/packages/squad-cli/src/cli/commands/import.ts index 00d111a06..183d6ee62 100644 --- a/packages/squad-cli/src/cli/commands/import.ts +++ b/packages/squad-cli/src/cli/commands/import.ts @@ -16,6 +16,9 @@ interface ImportManifest { version: string; exported_at?: string; squad_version?: string; + team_md?: string; + decisions_md?: string; + routing_md?: string; casting: Record; agents: Record; skills: string[]; @@ -26,6 +29,24 @@ export interface ImportRepoOptions { branch?: string; } +/** Validate that a name is a safe slug (no path traversal or special characters). */ +function isSafeSlug(name: string): boolean { + if (!name) return false; + if (name.includes('..') || name.includes('/') || name.includes('\\')) return false; + if (name.startsWith('.') || name.startsWith('-')) return false; + // Only allow alphanumeric, hyphens, underscores, and dots (not leading) + return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); +} + +/** Validate that a resolved path is under the expected parent directory. */ +function assertPathUnder(resolvedPath: string, parentDir: string): void { + const normalizedChild = path.resolve(resolvedPath); + const normalizedParent = path.resolve(parentDir); + if (!normalizedChild.startsWith(normalizedParent + path.sep) && normalizedChild !== normalizedParent) { + throw new Error(`Path traversal detected: "${resolvedPath}" escapes "${parentDir}"`); + } +} + /** * Apply an import manifest to a target directory. */ @@ -59,8 +80,11 @@ function applyManifest( storage.mkdirSync(path.join(dest, '.copilot', 'skills'), { recursive: true }); // Write empty project-specific files - storage.writeSync(path.join(squadDir, 'decisions.md'), ''); - storage.writeSync(path.join(squadDir, 'team.md'), ''); + storage.writeSync(path.join(squadDir, 'decisions.md'), manifest.decisions_md ?? ''); + storage.writeSync(path.join(squadDir, 'team.md'), manifest.team_md ?? ''); + if (manifest.routing_md !== undefined) { + storage.writeSync(path.join(squadDir, 'routing.md'), manifest.routing_md); + } // Write casting state for (const [key, value] of Object.entries(manifest.casting)) { @@ -76,8 +100,12 @@ function applyManifest( // Write agents const agentNames = Object.keys(manifest.agents); for (const name of agentNames) { + if (!isSafeSlug(name)) { + fatal(`Invalid agent name "${name}": must be a safe slug (alphanumeric, hyphens, underscores)`); + } const agent = manifest.agents[name]!; const agentDir = path.join(squadDir, 'agents', name); + assertPathUnder(agentDir, path.join(squadDir, 'agents')); if (agent.charter) { storage.writeSync(path.join(agentDir, 'charter.md'), agent.charter); @@ -95,10 +123,16 @@ function applyManifest( // Write skills for (const skillContent of manifest.skills) { const nameMatch = skillContent.match(/^name:\s*["']?(.+?)["']?\s*$/m); - const skillName = nameMatch + let skillName = nameMatch ? nameMatch[1]!.trim().toLowerCase().replace(/\s+/g, '-') : `skill-${manifest.skills.indexOf(skillContent)}`; + // Sanitize skill name to prevent path traversal + skillName = skillName.replace(/[^a-z0-9._-]/g, '-').replace(/^[.-]+/, ''); + if (!skillName || !isSafeSlug(skillName)) { + skillName = `skill-${manifest.skills.indexOf(skillContent)}`; + } const skillDir = path.join(dest, '.copilot', 'skills', skillName); + assertPathUnder(skillDir, path.join(dest, '.copilot', 'skills')); storage.writeSync(path.join(skillDir, 'SKILL.md'), skillContent); } diff --git a/test/cli/export-import.test.ts b/test/cli/export-import.test.ts index 8c6ba84c7..f1a5ca504 100644 --- a/test/cli/export-import.test.ts +++ b/test/cli/export-import.test.ts @@ -252,4 +252,78 @@ describe('CLI: export/import commands', () => { expect(history).toContain('📌 Imported from'); expect(history).toContain('my-project-export'); }); + + it('should export and import team.md, decisions.md, and routing.md', async () => { + const teamPath = join(TEST_ROOT, '.squad', 'team.md'); + await writeFile(teamPath, '# My Team\nLead: Alice\n'); + await writeFile(join(TEST_ROOT, '.squad', 'decisions.md'), '# Decisions\n- Use TypeScript\n'); + await writeFile(join(TEST_ROOT, '.squad', 'routing.md'), '# Routing\n- `*.ts` → fenster\n'); + + const exportPath = join(TEST_ROOT, 'squad-export.json'); + await runExport(TEST_ROOT, exportPath); + + // Verify manifest contains these fields + const content = await readFile(exportPath, 'utf-8'); + const manifest = JSON.parse(content); + expect(manifest.team_md).toBe('# My Team\nLead: Alice\n'); + expect(manifest.decisions_md).toBe('# Decisions\n- Use TypeScript\n'); + expect(manifest.routing_md).toBe('# Routing\n- `*.ts` → fenster\n'); + + // Import and verify round-trip + await runImport(IMPORT_ROOT, exportPath, false); + const importedTeam = await readFile(join(IMPORT_ROOT, '.squad', 'team.md'), 'utf-8'); + const importedDecisions = await readFile(join(IMPORT_ROOT, '.squad', 'decisions.md'), 'utf-8'); + const importedRouting = await readFile(join(IMPORT_ROOT, '.squad', 'routing.md'), 'utf-8'); + expect(importedTeam).toBe('# My Team\nLead: Alice\n'); + expect(importedDecisions).toBe('# Decisions\n- Use TypeScript\n'); + expect(importedRouting).toBe('# Routing\n- `*.ts` → fenster\n'); + }); + + it('should reject agent names with path traversal', async () => { + const teamPath = join(TEST_ROOT, '.squad', 'team.md'); + await writeFile(teamPath, '# Team\n'); + + // Create a malicious export file with path traversal in agent name + const maliciousManifest = { + version: '1.0', + exported_at: new Date().toISOString(), + squad_version: '0.6.0', + casting: {}, + agents: { '../../../etc/evil': { charter: 'malicious content' } }, + skills: [], + }; + const exportPath = join(TEST_ROOT, 'malicious-export.json'); + await writeFile(exportPath, JSON.stringify(maliciousManifest)); + + await expect( + runImport(IMPORT_ROOT, exportPath, false) + ).rejects.toThrow(/Invalid agent name|Path traversal/); + }); + + it('should handle older bundles without team_md/decisions_md gracefully', async () => { + const teamPath = join(TEST_ROOT, '.squad', 'team.md'); + await writeFile(teamPath, '# Team\n'); + + // Old format bundle without team_md, decisions_md, routing_md + const oldManifest = { + version: '1.0', + exported_at: new Date().toISOString(), + squad_version: '0.6.0', + casting: {}, + agents: {}, + skills: [], + }; + const exportPath = join(TEST_ROOT, 'old-export.json'); + await writeFile(exportPath, JSON.stringify(oldManifest)); + + await runImport(IMPORT_ROOT, exportPath, false); + + // Should write empty defaults + const importedTeam = await readFile(join(IMPORT_ROOT, '.squad', 'team.md'), 'utf-8'); + const importedDecisions = await readFile(join(IMPORT_ROOT, '.squad', 'decisions.md'), 'utf-8'); + expect(importedTeam).toBe(''); + expect(importedDecisions).toBe(''); + // routing.md should not be created if not in bundle + expect(existsSync(join(IMPORT_ROOT, '.squad', 'routing.md'))).toBe(false); + }); });