diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index ee3cf86..8f5e1a1 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -162,6 +162,72 @@ When multiple repositories (or multiple client paths within the same repo) conta This differs from [plugin skill deduplication](/docs/guides/plugins/#duplicate-skill-handling), which keeps all copies and renames them for uniqueness. Repository skills are an index of pointers, so only the best source is kept. +## Lifecycle Hooks + +The optional `lifecycleHooks` section lets a workspace run scripts at defined +phases of the sync/update flow. This is an escape hatch for runtime prerequisites +that AllAgents cannot sync as agent artifacts (e.g., installing helper binaries +like `agent-tui` or `bd`). + +```yaml +lifecycleHooks: + preSync: + # String shorthand (required, name derived from script) + - scripts/check-deps.sh + + # Object form with explicit name + - name: install-agent-tui + script: | + if ! command -v agent-tui >/dev/null 2>&1; then + cargo install agent-tui + fi + + # Optional script — failure is logged as a warning, sync continues + - name: nice-to-have + script: ./optional-setup.sh + optional: true +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `script` | Yes | — | Shell command to execute (run via `sh -c`) | +| `name` | No | `script` value | Display name for CLI output | +| `optional` | No | `false` | If `true`, failure is a warning instead of an abort | + +### Behavior + +- **Ordering**: Scripts run in the order listed, before any plugin validation or filesystem mutation. +- **Required scripts**: A failing required script (the default) aborts the sync before any changes are made to the workspace. +- **Optional scripts**: A failing optional script is logged as a warning; sync continues. +- **Dry-run**: `--dry-run` shows which scripts would run without executing them. +- **Environment**: Each script runs in the workspace root with these variables set: + +| Variable | Description | +|---|---| +| `ALLAGENTS_WORKSPACE` | Absolute path to the workspace root | +| `ALLAGENTS_CONFIG_DIR` | Absolute path to `.allagents/` | + +### Idempotent script pattern + +Scripts can store a marker (pinned version, commit hash) under `.allagents/` to +skip redundant work on re-run: + +```bash +MARKER="$ALLAGENTS_CONFIG_DIR/.my-tool-version" +CURRENT=$(cat "$MARKER" 2>/dev/null || echo "none") +if [ "$CURRENT" = "1.2.3" ] && command -v my-tool >/dev/null 2>&1; then + exit 0 +fi +# ... install my-tool ... +echo "1.2.3" > "$MARKER" +``` + +See `examples/workspaces/lifecycle-hooks/` for a complete working example. + +:::caution[Security] +Lifecycle scripts are arbitrary local commands. Review any script from a plugin or template before enabling it in your workspace. Only the workspace owner can opt in — scripts are never silently executed. +::: + ## MCP Servers The optional top-level `mcpServers` field defines MCP servers managed directly by the workspace. Servers defined here are synced to all configured project-scoped MCP clients (`claude`, `codex`, `vscode`, `copilot`). diff --git a/examples/workspaces/lifecycle-hooks/.allagents/workspace.yaml b/examples/workspaces/lifecycle-hooks/.allagents/workspace.yaml new file mode 100644 index 0000000..9a11876 --- /dev/null +++ b/examples/workspaces/lifecycle-hooks/.allagents/workspace.yaml @@ -0,0 +1,64 @@ +version: 2 + +# Example: Workspace with lifecycle hooks for runtime prerequisites +# +# This workspace uses lifecycleHooks.preSync scripts to ensure required +# binaries are available before plugin artifacts are synced. +# +# SECURITY: These are arbitrary local commands. Review scripts from any +# plugin or template before enabling them in your workspace. +# +# Scripts run in the workspace root directory and receive: +# ALLAGENTS_WORKSPACE - absolute path to the workspace root +# ALLAGENTS_CONFIG_DIR - absolute path to the .allagents/ directory +# +# Idempotent pattern: scripts can store a marker (e.g., a pinned version +# or commit hash) under .allagents/ to skip redundant work on re-run. + +repositories: [] + +plugins: + # Beads project-management skills and Codex hooks. + - https://github.com/gastownhall/beads/tree/main/plugins/beads + + # Agent TUI skills. + - https://github.com/pproenca/agent-tui/tree/master/skills + +clients: + - codex + +syncMode: copy + +lifecycleHooks: + preSync: + # Install agent-tui if not already present. + # Uses .allagents/.agent-tui-version as an idempotency marker. + - name: install-agent-tui + script: | + AGENT_TUI_VERSION="0.5.0" + MARKER="$ALLAGENTS_CONFIG_DIR/.agent-tui-version" + CURRENT=$(cat "$MARKER" 2>/dev/null || echo "none") + if [ "$CURRENT" = "$AGENT_TUI_VERSION" ] && command -v agent-tui >/dev/null 2>&1; then + echo "agent-tui $AGENT_TUI_VERSION already installed" + exit 0 + fi + echo "Installing agent-tui $AGENT_TUI_VERSION..." + # Replace with actual install command for your platform: + # cargo install agent-tui --version "$AGENT_TUI_VERSION" + # npm install -g agent-tui@"$AGENT_TUI_VERSION" + echo "$AGENT_TUI_VERSION" > "$MARKER" + + # Install bd (Beads CLI) if not already present. + - name: install-bd + script: | + BD_VERSION="0.3.0" + MARKER="$ALLAGENTS_CONFIG_DIR/.bd-version" + CURRENT=$(cat "$MARKER" 2>/dev/null || echo "none") + if [ "$CURRENT" = "$BD_VERSION" ] && command -v bd >/dev/null 2>&1; then + echo "bd $BD_VERSION already installed" + exit 0 + fi + echo "Installing bd $BD_VERSION..." + # Replace with actual install command: + # cargo install bd --version "$BD_VERSION" + echo "$BD_VERSION" > "$MARKER" diff --git a/examples/workspaces/lifecycle-hooks/README.md b/examples/workspaces/lifecycle-hooks/README.md new file mode 100644 index 0000000..44925b6 --- /dev/null +++ b/examples/workspaces/lifecycle-hooks/README.md @@ -0,0 +1,56 @@ +# Lifecycle Hooks Example + +This workspace demonstrates the `lifecycleHooks.preSync` escape hatch for +installing runtime prerequisites that AllAgents cannot sync as agent artifacts. + +## What it does + +Before syncing plugin artifacts, AllAgents runs each `preSync` script in order: + +1. `install-agent-tui` - installs the `agent-tui` binary (if not already present) +2. `install-bd` - installs the `bd` (Beads) CLI (if not already present) + +Both scripts use an idempotency marker stored under `.allagents/` to avoid +re-downloading on every sync. + +## Security + +Lifecycle scripts are arbitrary local commands. **Review any script from a +plugin or template before enabling it in your workspace.** Only the workspace +owner can opt into running these scripts -- they are never silently executed. + +## Environment + +Scripts run in the workspace root and receive: + +| Variable | Value | +|---|---| +| `ALLAGENTS_WORKSPACE` | Absolute path to the workspace root | +| `ALLAGENTS_CONFIG_DIR` | Absolute path to `.allagents/` | + +## Usage + +```bash +# Copy this example to a new workspace +allagents workspace init my-workspace --from examples/workspaces/lifecycle-hooks + +# Run sync (lifecycle hooks execute before plugin copy) +cd my-workspace && allagents update + +# Dry-run (shows what scripts would run, does not execute) +allagents update --dry-run +``` + +## Optional scripts + +Mark a script as `optional: true` to continue sync even if it fails: + +```yaml +lifecycleHooks: + preSync: + - name: nice-to-have + script: ./optional-setup.sh + optional: true +``` + +A required (default) script failure aborts the sync before any filesystem mutation. diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index fe2bb45..107ee01 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -9,6 +9,7 @@ import { syncWorkspace, } from '../../core/sync.js'; import type { SyncResult } from '../../core/sync.js'; +import { formatLifecycleResults } from '../../core/lifecycle-scripts.js'; import { ensureUserWorkspace, getUserWorkspaceConfig, @@ -329,6 +330,16 @@ const syncCmd = command({ } console.log(''); + // Print lifecycle hook results + if (result.lifecycleResults) { + for (const [phase, phaseResult] of Object.entries(result.lifecycleResults)) { + for (const line of formatLifecycleResults(phase, phaseResult)) { + console.log(line); + } + console.log(''); + } + } + // Print plugin results for (const pluginResult of result.pluginResults) { console.log(formatPluginHeader(pluginResult)); diff --git a/src/cli/format-sync.ts b/src/cli/format-sync.ts index 2dacc68..73e04a6 100644 --- a/src/cli/format-sync.ts +++ b/src/cli/format-sync.ts @@ -419,6 +419,26 @@ export function buildSyncData(result: SyncResult) { ...(r.error && { error: r.error }), })), }), + ...(result.lifecycleResults && { + lifecycleHooks: Object.fromEntries( + Object.entries(result.lifecycleResults).map(([phase, r]) => [ + phase, + { + success: r.success, + error: r.error, + scripts: r.results.map((s) => ({ + name: s.name, + script: s.script, + success: s.success, + exitCode: s.exitCode, + durationMs: s.durationMs, + ...(s.skipped && { skipped: true }), + ...(s.stderr && { stderr: s.stderr }), + })), + }, + ]), + ), + }), }; } diff --git a/src/core/lifecycle-scripts.ts b/src/core/lifecycle-scripts.ts new file mode 100644 index 0000000..32e6c49 --- /dev/null +++ b/src/core/lifecycle-scripts.ts @@ -0,0 +1,162 @@ +import { execFile } from 'node:child_process'; +import { join } from 'node:path'; +import { CONFIG_DIR } from '../constants.js'; +import { + normalizeLifecycleScript, + type LifecycleScript, +} from '../models/workspace-config.js'; + +export interface LifecycleScriptResult { + name: string; + script: string; + success: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + durationMs: number; + skipped?: boolean; +} + +export interface RunLifecycleScriptsResult { + results: LifecycleScriptResult[]; + success: boolean; + error?: string; +} + +/** + * Run a single lifecycle script in the workspace root. + * The script is executed via sh -c so shell features (pipes, redirects) work. + */ +function runScript( + script: string, + workspacePath: string, + timeoutMs: number, +): Promise<{ exitCode: number | null; stdout: string; stderr: string; durationMs: number }> { + return new Promise((resolve) => { + const start = Date.now(); + const child = execFile( + 'sh', + ['-c', script], + { + cwd: workspacePath, + env: { + ...process.env, + ALLAGENTS_WORKSPACE: workspacePath, + ALLAGENTS_CONFIG_DIR: join(workspacePath, CONFIG_DIR), + }, + timeout: timeoutMs, + }, + (error, stdout, stderr) => { + resolve({ + exitCode: error ? (error as { code?: number }).code ?? 1 : 0, + stdout: stdout ?? '', + stderr: stderr ?? '', + durationMs: Date.now() - start, + }); + }, + ); + // Ensure the promise resolves even if the process is killed + child.on('error', () => {}); + }); +} + +/** + * Run lifecycle scripts for a given phase (e.g., preSync). + * + * @param scripts - Raw script entries from workspace config + * @param workspacePath - Workspace root directory + * @param options - dryRun mode and timeout + * @returns Results for each script and overall success + */ +export async function runLifecycleScripts( + scripts: LifecycleScript[], + workspacePath: string, + options: { dryRun?: boolean; timeoutMs?: number } = {}, +): Promise { + const { dryRun = false, timeoutMs = 120_000 } = options; + const results: LifecycleScriptResult[] = []; + + for (const entry of scripts) { + const normalized = normalizeLifecycleScript(entry); + + if (dryRun) { + results.push({ + name: normalized.name, + script: normalized.script, + success: true, + exitCode: 0, + stdout: '', + stderr: '', + durationMs: 0, + skipped: true, + }); + continue; + } + + const { exitCode, stdout, stderr, durationMs } = await runScript( + normalized.script, + workspacePath, + timeoutMs, + ); + const success = exitCode === 0; + + results.push({ + name: normalized.name, + script: normalized.script, + success, + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + durationMs, + }); + + // A failing required script stops execution + if (!success && !normalized.optional) { + return { + results, + success: false, + error: `Lifecycle script '${normalized.name}' failed (exit code ${exitCode})`, + }; + } + } + + return { + results, + success: true, + }; +} + +/** + * Format lifecycle script results for CLI output. + */ +export function formatLifecycleResults( + phase: string, + result: RunLifecycleScriptsResult, +): string[] { + if (result.results.length === 0) return []; + + const lines: string[] = []; + lines.push(`Lifecycle hooks (${phase}):`); + + for (const r of result.results) { + if (r.skipped) { + lines.push(` [dry-run] would run: ${r.name} (${r.script})`); + } else if (r.success) { + lines.push(` ✓ ${r.name} (${formatDuration(r.durationMs)})`); + } else { + lines.push(` ✗ ${r.name} (exit code ${r.exitCode}, ${formatDuration(r.durationMs)})`); + if (r.stderr) { + for (const line of r.stderr.split('\n').slice(0, 5)) { + lines.push(` ${line}`); + } + } + } + } + + return lines; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} diff --git a/src/core/sync.ts b/src/core/sync.ts index 0860d85..344203a 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -111,6 +111,10 @@ import { } from './native/index.js'; import { Stopwatch } from '../utils/stopwatch.js'; import { processManagedRepos } from './managed-repos.js'; +import { + runLifecycleScripts, + type RunLifecycleScriptsResult, +} from './lifecycle-scripts.js'; /** * Result of deduplicating clients by skillsPath @@ -205,6 +209,8 @@ export interface SyncResult { }; /** Results of managed repository clone/pull operations */ managedRepoResults?: import('./managed-repos.js').ManagedRepoResult[]; + /** Results of lifecycle hook scripts (keyed by phase name) */ + lifecycleResults?: Record; } /** @@ -222,6 +228,10 @@ export function mergeSyncResults(a: SyncResult, b: SyncResult): SyncResult { a.mcpResults || b.mcpResults ? { ...a.mcpResults, ...b.mcpResults } : undefined; + const lifecycleResults = + a.lifecycleResults || b.lifecycleResults + ? { ...a.lifecycleResults, ...b.lifecycleResults } + : undefined; // Merge nativeResults when both scopes produce them const nativeResult = a.nativeResult && b.nativeResult @@ -253,6 +263,7 @@ export function mergeSyncResults(a: SyncResult, b: SyncResult): SyncResult { ...(purgedPaths.length > 0 && { purgedPaths }), ...(deletedArtifacts.length > 0 && { deletedArtifacts }), ...(mcpResults && { mcpResults }), + ...(lifecycleResults && { lifecycleResults }), ...(nativeResult && { nativeResult }), ...(() => { const managedRepoResults = [ @@ -2023,7 +2034,23 @@ export async function syncWorkspace( ); } - // Step 0a: Process managed repositories (clone/pull) before anything else + // Step 0a: Run lifecycle pre-sync hooks (before any plugin validation or filesystem mutation) + const lifecycleResults: Record = {}; + const preSyncScripts = config.lifecycleHooks?.preSync; + if (preSyncScripts && preSyncScripts.length > 0) { + const preSyncResult = await sw.measure('lifecycle-presync', () => + runLifecycleScripts(preSyncScripts, workspacePath, { dryRun }), + ); + lifecycleResults.preSync = preSyncResult; + + if (!preSyncResult.success) { + return failedSyncResult(preSyncResult.error ?? 'Lifecycle pre-sync hooks failed', { + lifecycleResults, + }); + } + } + + // Step 0b: Process managed repositories (clone/pull) before anything else const managedRepoResults = await sw.measure('managed-repos', () => processManagedRepos(config.repositories ?? [], workspacePath, { offline, @@ -2062,6 +2089,7 @@ export async function syncWorkspace( warnings: [ "No clients configured in workspace.yaml — no artifacts were synced. Add clients to workspace.yaml or run 'allagents workspace init' to configure.", ], + ...(Object.keys(lifecycleResults).length > 0 && { lifecycleResults }), }; } @@ -2442,6 +2470,7 @@ export async function syncWorkspace( ...(Object.keys(mcpResults).length > 0 && { mcpResults }), ...(nativeResult && { nativeResult }), ...(managedRepoResults.length > 0 && { managedRepoResults }), + ...(Object.keys(lifecycleResults).length > 0 && { lifecycleResults }), timing: sw.toJSON(), }; } diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 3413469..065a535 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -275,6 +275,31 @@ export function getClientInstallMode( return 'file'; } +/** + * Normalized lifecycle script entry with all fields resolved. + */ +export interface NormalizedLifecycleScript { + script: string; + name: string; + optional: boolean; +} + +/** + * Normalize a lifecycle script entry to its full form. + */ +export function normalizeLifecycleScript( + entry: LifecycleScript, +): NormalizedLifecycleScript { + if (typeof entry === 'string') { + return { script: entry, name: entry, optional: false }; + } + return { + script: entry.script, + name: entry.name ?? entry.script, + optional: entry.optional ?? false, + }; +} + /** * Resolve effective install mode for a (plugin, client) pair. * Priority: plugin-level > client-level > 'file' default. @@ -357,6 +382,35 @@ export const McpServerConfigSchema = z.union([ export type McpServerConfig = z.infer; +/** + * A single lifecycle script entry. + * + * String shorthand: "scripts/setup.sh" (required, name derived from script) + * Object form: { script, name?, optional? } + */ +export const LifecycleScriptSchema = z.union([ + z.string(), + z.object({ + script: z.string(), + name: z.string().optional(), + optional: z.boolean().optional(), + }), +]); + +export type LifecycleScript = z.infer; + +/** + * Lifecycle hooks that run at defined phases of the sync/update flow. + * + * preSync: scripts that run before plugin artifacts are synced. + * A failing required (non-optional) script aborts the sync before filesystem mutation. + */ +export const LifecycleHooksSchema = z.object({ + preSync: z.array(LifecycleScriptSchema).optional(), +}); + +export type LifecycleHooks = z.infer; + /** * Complete workspace configuration (workspace.yaml) */ @@ -375,6 +429,12 @@ export const WorkspaceConfigSchema = z.object({ * plugin-defined servers on name conflicts. */ mcpServers: z.record(McpServerConfigSchema).optional(), + /** + * Lifecycle hooks that run at defined phases of the sync/update flow. + * Scripts run in the workspace root and receive ALLAGENTS_WORKSPACE and + * ALLAGENTS_CONFIG_DIR environment variables. + */ + lifecycleHooks: LifecycleHooksSchema.optional(), /** @deprecated Use inline skills field on plugin entry instead. Will be removed in v3. */ disabledSkills: z.array(z.string()).optional(), /** @deprecated Use inline skills field on plugin entry instead. Will be removed in v3. */ diff --git a/tests/unit/core/lifecycle-scripts.test.ts b/tests/unit/core/lifecycle-scripts.test.ts new file mode 100644 index 0000000..78d3446 --- /dev/null +++ b/tests/unit/core/lifecycle-scripts.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + runLifecycleScripts, + formatLifecycleResults, +} from '../../../src/core/lifecycle-scripts.js'; +import { + LifecycleScriptSchema, + normalizeLifecycleScript, +} from '../../../src/models/workspace-config.js'; + +describe('lifecycle-scripts', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-lifecycle-test-')); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + // --------------------------------------------------------------- + // Schema and normalization + // --------------------------------------------------------------- + describe('schema parsing', () => { + it('should parse string shorthand', () => { + const result = LifecycleScriptSchema.safeParse('scripts/setup.sh'); + expect(result.success).toBe(true); + expect(result.data).toBe('scripts/setup.sh'); + }); + + it('should parse object form with all fields', () => { + const result = LifecycleScriptSchema.safeParse({ + script: 'scripts/setup.sh', + name: 'Setup', + optional: true, + }); + expect(result.success).toBe(true); + expect(result.data).toEqual({ + script: 'scripts/setup.sh', + name: 'Setup', + optional: true, + }); + }); + + it('should parse object form with only script', () => { + const result = LifecycleScriptSchema.safeParse({ + script: 'scripts/setup.sh', + }); + expect(result.success).toBe(true); + expect(result.data).toEqual({ script: 'scripts/setup.sh' }); + }); + + it('should reject non-string non-object values', () => { + const result = LifecycleScriptSchema.safeParse(42); + expect(result.success).toBe(false); + }); + }); + + describe('normalizeLifecycleScript', () => { + it('should normalize string shorthand', () => { + const result = normalizeLifecycleScript('scripts/setup.sh'); + expect(result).toEqual({ + script: 'scripts/setup.sh', + name: 'scripts/setup.sh', + optional: false, + }); + }); + + it('should normalize object form with defaults', () => { + const result = normalizeLifecycleScript({ script: 'scripts/setup.sh' }); + expect(result).toEqual({ + script: 'scripts/setup.sh', + name: 'scripts/setup.sh', + optional: false, + }); + }); + + it('should normalize object form with explicit fields', () => { + const result = normalizeLifecycleScript({ + script: 'scripts/setup.sh', + name: 'Install agent-tui', + optional: true, + }); + expect(result).toEqual({ + script: 'scripts/setup.sh', + name: 'Install agent-tui', + optional: true, + }); + }); + }); + + // --------------------------------------------------------------- + // Script execution + // --------------------------------------------------------------- + describe('runLifecycleScripts', () => { + it('should run a single script successfully', async () => { + await writeFile(join(testDir, 'marker.txt'), 'not yet'); + const result = await runLifecycleScripts( + ['echo "hello" > marker.txt'], + testDir, + ); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(1); + expect(result.results[0].success).toBe(true); + expect(result.results[0].exitCode).toBe(0); + expect(result.results[0].name).toBe('echo "hello" > marker.txt'); + }); + + it('should run multiple scripts in order', async () => { + const result = await runLifecycleScripts( + [ + 'echo "first" > order.txt', + 'echo "second" >> order.txt', + ], + testDir, + ); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.results[0].success).toBe(true); + expect(result.results[1].success).toBe(true); + + const content = await Bun.file(join(testDir, 'order.txt')).text(); + expect(content).toBe('first\nsecond\n'); + }); + + it('should stop on required script failure', async () => { + const result = await runLifecycleScripts( + [ + { script: 'exit 1', name: 'failing-script' }, + { script: 'echo "should not run" > skipped.txt', name: 'second' }, + ], + testDir, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('failing-script'); + expect(result.results).toHaveLength(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].exitCode).toBe(1); + expect(existsSync(join(testDir, 'skipped.txt'))).toBe(false); + }); + + it('should continue past optional script failure', async () => { + await writeFile(join(testDir, 'marker.txt'), 'not yet'); + const result = await runLifecycleScripts( + [ + { script: 'exit 1', name: 'optional-step', optional: true }, + { script: 'echo "ran" > marker.txt', name: 'required-step' }, + ], + testDir, + ); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.results[0].success).toBe(false); + expect(result.results[0].name).toBe('optional-step'); + expect(result.results[1].success).toBe(true); + expect(result.results[1].name).toBe('required-step'); + }); + + it('should provide ALLAGENTS_WORKSPACE and ALLAGENTS_CONFIG_DIR env vars', async () => { + const result = await runLifecycleScripts( + ['echo "$ALLAGENTS_WORKSPACE" > ws.txt && echo "$ALLAGENTS_CONFIG_DIR" > cfg.txt'], + testDir, + ); + + expect(result.success).toBe(true); + const ws = (await Bun.file(join(testDir, 'ws.txt')).text()).trim(); + const cfg = (await Bun.file(join(testDir, 'cfg.txt')).text()).trim(); + expect(ws).toBe(testDir); + expect(cfg).toBe(join(testDir, '.allagents')); + }); + + it('should capture stderr on failure', async () => { + const result = await runLifecycleScripts( + ['echo "error output" >&2 && exit 1'], + testDir, + ); + + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('error output'); + }); + + it('should handle empty script list', async () => { + const result = await runLifecycleScripts([], testDir); + expect(result.success).toBe(true); + expect(result.results).toHaveLength(0); + }); + + it('should timeout long-running scripts', async () => { + const result = await runLifecycleScripts( + ['sleep 60'], + testDir, + { timeoutMs: 500 }, + ); + + expect(result.success).toBe(false); + expect(result.results[0].exitCode).not.toBe(0); + }); + }); + + // --------------------------------------------------------------- + // Dry-run + // --------------------------------------------------------------- + describe('dry-run', () => { + it('should not execute scripts in dry-run mode', async () => { + const result = await runLifecycleScripts( + ['echo "should not execute" > dryrun-marker.txt'], + testDir, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(1); + expect(result.results[0].skipped).toBe(true); + expect(existsSync(join(testDir, 'dryrun-marker.txt'))).toBe(false); + }); + + it('should show all scripts in dry-run even if later ones would fail', async () => { + const result = await runLifecycleScripts( + [ + { script: 'echo ok', name: 'step-1' }, + { script: 'exit 1', name: 'step-2' }, + { script: 'echo ok', name: 'step-3' }, + ], + testDir, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(3); + for (const r of result.results) { + expect(r.skipped).toBe(true); + } + }); + }); + + // --------------------------------------------------------------- + // Format output + // --------------------------------------------------------------- + describe('formatLifecycleResults', () => { + it('should return empty for no results', () => { + const lines = formatLifecycleResults('preSync', { + results: [], + success: true, + }); + expect(lines).toHaveLength(0); + }); + + it('should format successful scripts', () => { + const lines = formatLifecycleResults('preSync', { + results: [ + { + name: 'install-deps', + script: 'npm install', + success: true, + exitCode: 0, + stdout: '', + stderr: '', + durationMs: 150, + }, + ], + success: true, + }); + expect(lines[0]).toContain('preSync'); + expect(lines[1]).toContain('install-deps'); + expect(lines[1]).toContain('150ms'); + }); + + it('should format failed scripts with stderr', () => { + const lines = formatLifecycleResults('preSync', { + results: [ + { + name: 'bad-script', + script: 'exit 1', + success: false, + exitCode: 1, + stdout: '', + stderr: 'something went wrong', + durationMs: 50, + }, + ], + success: false, + error: 'bad-script failed', + }); + expect(lines[1]).toContain('bad-script'); + expect(lines[1]).toContain('exit code 1'); + expect(lines[2]).toContain('something went wrong'); + }); + + it('should format dry-run skipped scripts', () => { + const lines = formatLifecycleResults('preSync', { + results: [ + { + name: 'setup', + script: './setup.sh', + success: true, + exitCode: 0, + stdout: '', + stderr: '', + durationMs: 0, + skipped: true, + }, + ], + success: true, + }); + expect(lines[1]).toContain('dry-run'); + expect(lines[1]).toContain('setup'); + }); + }); +}); diff --git a/tests/unit/core/sync-lifecycle.test.ts b/tests/unit/core/sync-lifecycle.test.ts new file mode 100644 index 0000000..0ac4625 --- /dev/null +++ b/tests/unit/core/sync-lifecycle.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { dump } from 'js-yaml'; +import { syncWorkspace } from '../../../src/core/sync.js'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; + +describe('sync with lifecycle hooks', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-sync-lifecycle-')); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + async function writeWorkspaceConfig(config: Record) { + const yaml = dump(config, { lineWidth: -1 }); + await writeFile(join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), yaml, 'utf-8'); + } + + it('should run preSync hooks before plugin sync', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + 'echo "presync-ran" > .allagents/presync-marker.txt', + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: false }); + + expect(result.success).toBe(true); + expect(result.lifecycleResults).toBeDefined(); + expect(result.lifecycleResults!.preSync).toBeDefined(); + expect(result.lifecycleResults!.preSync.results).toHaveLength(1); + expect(result.lifecycleResults!.preSync.results[0].success).toBe(true); + + // Verify the script actually ran + const marker = await readFile(join(testDir, '.allagents', 'presync-marker.txt'), 'utf-8'); + expect(marker.trim()).toBe('presync-ran'); + }); + + it('should run multiple preSync hooks in order', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + 'echo "first" > .allagents/order.txt', + 'echo "second" >> .allagents/order.txt', + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: false }); + + expect(result.success).toBe(true); + expect(result.lifecycleResults!.preSync.results).toHaveLength(2); + + const content = await readFile(join(testDir, '.allagents', 'order.txt'), 'utf-8'); + expect(content).toBe('first\nsecond\n'); + }); + + it('should abort sync on required preSync script failure', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + { script: 'exit 42', name: 'failing-hook' }, + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: false }); + + expect(result.success).toBe(false); + expect(result.error).toContain('failing-hook'); + expect(result.lifecycleResults!.preSync.results[0].exitCode).toBe(42); + }); + + it('should continue sync on optional preSync script failure', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + { script: 'exit 1', name: 'optional-hook', optional: true }, + { script: 'echo "ok"', name: 'required-hook' }, + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: false }); + + expect(result.success).toBe(true); + expect(result.lifecycleResults!.preSync.results).toHaveLength(2); + expect(result.lifecycleResults!.preSync.results[0].success).toBe(false); + expect(result.lifecycleResults!.preSync.results[1].success).toBe(true); + }); + + it('should not execute hooks in dry-run mode', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + 'echo "should-not-run" > .allagents/dryrun-marker.txt', + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: true }); + + expect(result.success).toBe(true); + expect(result.lifecycleResults!.preSync.results[0].skipped).toBe(true); + expect(existsSync(join(testDir, '.allagents', 'dryrun-marker.txt'))).toBe(false); + }); + + it('should not include lifecycleResults when no hooks configured', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + }); + + const result = await syncWorkspace(testDir, { dryRun: false }); + + expect(result.success).toBe(true); + expect(result.lifecycleResults).toBeUndefined(); + }); + + it('should include lifecycleResults in failed sync result', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + 'echo "ran-before-fail" > .allagents/partial.txt', + { script: 'exit 1', name: 'abort-hook' }, + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: false }); + + expect(result.success).toBe(false); + expect(result.lifecycleResults!.preSync.results).toHaveLength(2); + expect(result.lifecycleResults!.preSync.results[0].success).toBe(true); + expect(result.lifecycleResults!.preSync.results[1].success).toBe(false); + }); + + it('should parse lifecycleHooks from workspace config correctly', async () => { + await writeWorkspaceConfig({ + repositories: [], + plugins: [], + clients: ['claude'], + lifecycleHooks: { + preSync: [ + 'scripts/one.sh', + { script: 'scripts/two.sh', name: 'Two' }, + { script: 'scripts/three.sh', name: 'Three', optional: true }, + ], + }, + }); + + const result = await syncWorkspace(testDir, { dryRun: true }); + + expect(result.success).toBe(true); + const scripts = result.lifecycleResults!.preSync.results; + expect(scripts).toHaveLength(3); + expect(scripts[0].name).toBe('scripts/one.sh'); + expect(scripts[0].skipped).toBe(true); + expect(scripts[1].name).toBe('Two'); + expect(scripts[2].name).toBe('Three'); + }); +});