diff --git a/.storybook/mocks/app/actions/check-coastfile.ts b/.storybook/mocks/app/actions/check-coastfile.ts new file mode 100644 index 000000000..29f4d79e6 --- /dev/null +++ b/.storybook/mocks/app/actions/check-coastfile.ts @@ -0,0 +1,3 @@ +export async function checkCoastfileAction(_repositoryPath: string): Promise<{ exists: boolean }> { + return { exists: false }; +} diff --git a/.storybook/mocks/app/actions/generate-coastfile.ts b/.storybook/mocks/app/actions/generate-coastfile.ts new file mode 100644 index 000000000..30a2c8e21 --- /dev/null +++ b/.storybook/mocks/app/actions/generate-coastfile.ts @@ -0,0 +1,5 @@ +export async function generateCoastfileAction( + _repositoryPath: string +): Promise<{ success: boolean; coastfilePath?: string; error?: string }> { + return { success: false, error: 'Not available in Storybook' }; +} diff --git a/apis/json-schema/FeatureFlags.yaml b/apis/json-schema/FeatureFlags.yaml index 8f1cd7ae7..fbb402853 100644 --- a/apis/json-schema/FeatureFlags.yaml +++ b/apis/json-schema/FeatureFlags.yaml @@ -30,6 +30,10 @@ properties: type: boolean default: false description: Use the built-in React file manager instead of the native OS folder picker + coastsDevServer: + type: boolean + default: false + description: Enable Coasts containerized runtime isolation for the dev server required: - skills - envDeploy @@ -38,4 +42,5 @@ required: - adoptBranch - gitRebaseSync - reactFileManager + - coastsDevServer description: Feature flag toggles for runtime feature control diff --git a/docs/superpowers/plans/2026-03-20-coastfile-on-demand-generation.md b/docs/superpowers/plans/2026-03-20-coastfile-on-demand-generation.md new file mode 100644 index 000000000..e24f723bb --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-coastfile-on-demand-generation.md @@ -0,0 +1,857 @@ +# Coastfile On-Demand Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Coastfile generation from auto-generating during dev server startup to on-demand via `shep coasts init` CLI command and a web UI button on the repository node. + +**Architecture:** The existing `CoastsService.generateCoastfile()` and `build()` methods are unchanged. We rewire the trigger points: dev server throws on missing Coastfile instead of auto-generating, a new CLI command and server action call the existing service methods on demand. + +**Tech Stack:** Commander.js (CLI), Next.js server actions (web), Vitest (tests), React/lucide-react (UI) + +--- + +### Task 1: Update dev server to fail on missing Coastfile + +**Files:** +- Modify: `src/presentation/web/coasts-dev-server.ts:46-52` +- Modify: `tests/unit/presentation/web/coasts-dev-server.test.ts:155-198` +- Modify: `tests/unit/presentation/dev-server-coasts.test.ts:102-123` + +- [ ] **Step 1: Update the failing tests in coasts-dev-server.test.ts** + +Replace the test at line 155 ("calls generateCoastfile when no Coastfile exists") with a test that expects a throw: + +```typescript +it('throws with helpful error when no Coastfile exists', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(false); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow( + /no coastfile found/i + ); + + // Should not proceed to build or run + expect(service.build).not.toHaveBeenCalled(); + expect(service.run).not.toHaveBeenCalled(); + // Should NOT call generateCoastfile + expect(service.generateCoastfile).not.toHaveBeenCalled(); +}); +``` + +Replace the test at line 171 ("logs Coastfile generation progress") with: + +```typescript +it('includes both CLI and web UI instructions in missing Coastfile error', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(false); + + const error = await startCoastsDevServer(service, workDir).catch((e: Error) => e); + expect(error.message).toMatch(/shep coasts init/); + expect(error.message).toMatch(/Generate Coastfile/); +}); +``` + +Remove the test at line 185 ("throws when Coastfile generation fails") — no longer applicable. + +- [ ] **Step 2: Update the failing tests in dev-server-coasts.test.ts** + +Replace test at line 102 ("calls generateCoastfile when no Coastfile exists"): + +```typescript +it('throws when no Coastfile exists', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(false); + + await expect(startCoastsDevServer(mockService, workDir)).rejects.toThrow( + /no coastfile found/i + ); + + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + expect(mockService.build).not.toHaveBeenCalled(); +}); +``` + +Update test at line 114 ("does not call generateCoastfile when Coastfile exists") — rename to "proceeds with build and run when Coastfile exists" (behavior unchanged, just rename for clarity). + +- [ ] **Step 3: Run tests to verify they fail (RED)** + +Run: `pnpm test:unit -- --run tests/unit/presentation/web/coasts-dev-server.test.ts tests/unit/presentation/dev-server-coasts.test.ts` +Expected: FAIL — tests expect throw but implementation still auto-generates. + +- [ ] **Step 4: Update coasts-dev-server.ts implementation** + +Replace lines 46-52 in `src/presentation/web/coasts-dev-server.ts`: + +```typescript + // Step 2: Check for Coastfile — fail if missing (generate on-demand via CLI or web UI) + const hasCoastfile = await coastsService.hasCoastfile(workDir); + if (!hasCoastfile) { + throw new Error( + `[dev-server:coasts] No Coastfile found in ${workDir} (expected: Coastfile).\n` + + 'Generate one with:\n' + + ' - CLI: shep coasts init\n' + + ' - Web UI: Use the "Generate Coastfile" button on the repository node' + ); + } +``` + +Also update the JSDoc comment at line 6 — change "Coastfile generation" to "Coastfile existence check". + +- [ ] **Step 5: Run tests to verify they pass (GREEN)** + +Run: `pnpm test:unit -- --run tests/unit/presentation/web/coasts-dev-server.test.ts tests/unit/presentation/dev-server-coasts.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/presentation/web/coasts-dev-server.ts tests/unit/presentation/web/coasts-dev-server.test.ts tests/unit/presentation/dev-server-coasts.test.ts +git commit -m "fix(web): replace auto coastfile generation with fail-fast error" +``` + +--- + +### Task 2: Create `shep coasts init` CLI command + +**Files:** +- Create: `src/presentation/cli/commands/coasts/index.ts` +- Create: `src/presentation/cli/commands/coasts/init.command.ts` +- Modify: `src/presentation/cli/index.ts:109-121` +- Create: `tests/unit/presentation/cli/commands/coasts/init.command.test.ts` + +- [ ] **Step 1: Write the test file** + +Create `tests/unit/presentation/cli/commands/coasts/init.command.test.ts`: + +```typescript +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ICoastsService } from '@/application/ports/output/services/coasts-service.interface.js'; +import type { PrerequisiteCheckResult } from '@/application/ports/output/services/coasts-service.interface.js'; + +function createMockCoastsService(): ICoastsService { + return { + checkPrerequisites: vi.fn(), + build: vi.fn(), + run: vi.fn(), + stop: vi.fn(), + lookup: vi.fn(), + isRunning: vi.fn(), + checkout: vi.fn(), + getInstallationPrompt: vi.fn(), + generateCoastfile: vi.fn(), + hasCoastfile: vi.fn(), + }; +} + +function allMet(): PrerequisiteCheckResult { + return { + coastBinary: true, + docker: true, + coastdRunning: true, + allMet: true, + missingMessages: [], + }; +} + +function prerequisitesFailed(messages: string[]): PrerequisiteCheckResult { + return { + coastBinary: false, + docker: false, + coastdRunning: false, + allMet: false, + missingMessages: messages, + }; +} + +// Mock the DI container +const mockContainer = { + resolve: vi.fn(), +}; +vi.mock('@/infrastructure/di/container.js', () => ({ + container: mockContainer, +})); + +// Import the function under test +const { createInitCommand } = await import( + '@cli/presentation/cli/commands/coasts/init.command.js' +); + +describe('shep coasts init', () => { + let mockService: ICoastsService; + + beforeEach(() => { + vi.clearAllMocks(); + mockService = createMockCoastsService(); + mockContainer.resolve.mockReturnValue(mockService); + }); + + it('calls generateCoastfile then build on success', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(false); + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMet()); + vi.mocked(mockService.generateCoastfile).mockResolvedValue('/repo/Coastfile'); + vi.mocked(mockService.build).mockResolvedValue(undefined); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test', '--force']); + + expect(mockService.generateCoastfile).toHaveBeenCalled(); + expect(mockService.build).toHaveBeenCalled(); + }); + + it('exits with error when prerequisites fail', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(false); + vi.mocked(mockService.checkPrerequisites).mockResolvedValue( + prerequisitesFailed(['coast binary not found']) + ); + + const cmd = createInitCommand(); + // Commander sets process.exitCode on error + await cmd.parseAsync(['node', 'test', '--force']); + + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('skips generation when Coastfile exists and --force not set', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test']); + + expect(mockService.hasCoastfile).toHaveBeenCalled(); + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('regenerates when Coastfile exists and --force is set', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMet()); + vi.mocked(mockService.generateCoastfile).mockResolvedValue('/repo/Coastfile'); + vi.mocked(mockService.build).mockResolvedValue(undefined); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test', '--force']); + + expect(mockService.generateCoastfile).toHaveBeenCalled(); + expect(mockService.build).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails (RED)** + +Run: `pnpm test:unit -- --run tests/unit/presentation/cli/commands/coasts/init.command.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the parent coasts command** + +Create `src/presentation/cli/commands/coasts/index.ts`: + +```typescript +import { Command } from 'commander'; +import { createInitCommand } from './init.command.js'; + +export function createCoastsCommand(): Command { + return new Command('coasts') + .description('Manage Coasts containerized runtime') + .addCommand(createInitCommand()); +} +``` + +- [ ] **Step 4: Create the init subcommand** + +Create `src/presentation/cli/commands/coasts/init.command.ts`: + +```typescript +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import type { ICoastsService } from '@/application/ports/output/services/coasts-service.interface.js'; +import { messages, spinner } from '../../ui/index.js'; + +interface InitOptions { + force?: boolean; +} + +export function createInitCommand(): Command { + return new Command('init') + .description('Generate a Coastfile for the current repository') + .option('-f, --force', 'Overwrite existing Coastfile without prompting') + .action(async (options: InitOptions) => { + const workDir = process.cwd(); + + try { + const coastsService = container.resolve('ICoastsService'); + + // Check if Coastfile already exists + const exists = await coastsService.hasCoastfile(workDir); + if (exists && !options.force) { + messages.warning( + 'Coastfile already exists. Use --force to regenerate.' + ); + return; + } + + // Check prerequisites + const prereqs = await coastsService.checkPrerequisites(workDir); + if (!prereqs.allMet) { + for (const msg of prereqs.missingMessages) { + messages.error(msg); + } + process.exitCode = 1; + return; + } + + // Generate Coastfile + const coastfilePath = await spinner( + 'Generating Coastfile via AI agent...', + () => coastsService.generateCoastfile(workDir) + ); + messages.success(`Coastfile generated at ${coastfilePath}`); + + // Build container + await spinner('Building coast container...', () => + coastsService.build(workDir) + ); + messages.success('Coast container built successfully.'); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error('Failed to initialize Coasts', err); + process.exitCode = 1; + } + }); +} +``` + +- [ ] **Step 5: Register the coasts command in CLI index.ts** + +Add to `src/presentation/cli/index.ts` after the existing command registrations (around line 121): + +```typescript +import { createCoastsCommand } from './commands/coasts/index.js'; +``` + +And in the registration block: + +```typescript +program.addCommand(createCoastsCommand()); +``` + +- [ ] **Step 6: Run test to verify it passes (GREEN)** + +Run: `pnpm test:unit -- --run tests/unit/presentation/cli/commands/coasts/init.command.test.ts` +Expected: PASS + +- [ ] **Step 7: Run full test suite to check for regressions** + +Run: `pnpm test:unit -- --run` +Expected: PASS (all tests) + +- [ ] **Step 8: Commit** + +```bash +git add src/presentation/cli/commands/coasts/ src/presentation/cli/index.ts tests/unit/presentation/cli/commands/coasts/ +git commit -m "feat(cli): add shep coasts init command for on-demand coastfile generation" +``` + +--- + +### Task 3: Create server action for Coastfile generation + +**Files:** +- Create: `src/presentation/web/app/actions/generate-coastfile.ts` +- Create: `src/presentation/web/app/actions/check-coastfile.ts` +- Create: `.storybook/mocks/app/actions/generate-coastfile.ts` +- Create: `.storybook/mocks/app/actions/check-coastfile.ts` + +- [ ] **Step 1: Create the generate-coastfile server action** + +Create `src/presentation/web/app/actions/generate-coastfile.ts`: + +```typescript +'use server'; + +import path from 'node:path'; +import { existsSync } from 'node:fs'; +import { resolve } from '@/lib/server-container'; +import type { ICoastsService } from '@shepai/core/application/ports/output/services/coasts-service.interface'; + +export interface GenerateCoastfileResult { + success: boolean; + coastfilePath?: string; + error?: string; +} + +export async function generateCoastfileAction( + repositoryPath: string +): Promise { + if (!repositoryPath || !path.isAbsolute(repositoryPath)) { + return { success: false, error: 'repositoryPath must be an absolute path' }; + } + + if (!existsSync(repositoryPath)) { + return { success: false, error: `Directory does not exist: ${repositoryPath}` }; + } + + try { + const coastsService = resolve('ICoastsService'); + const coastfilePath = await coastsService.generateCoastfile(repositoryPath); + await coastsService.build(repositoryPath); + return { success: true, coastfilePath }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to generate Coastfile'; + return { success: false, error: message }; + } +} +``` + +- [ ] **Step 2: Create the check-coastfile server action** + +Create `src/presentation/web/app/actions/check-coastfile.ts`: + +```typescript +'use server'; + +import path from 'node:path'; +import { resolve } from '@/lib/server-container'; +import type { ICoastsService } from '@shepai/core/application/ports/output/services/coasts-service.interface'; + +export interface CheckCoastfileResult { + exists: boolean; +} + +export async function checkCoastfileAction( + repositoryPath: string +): Promise { + if (!repositoryPath || !path.isAbsolute(repositoryPath)) { + return { exists: false }; + } + + try { + const coastsService = resolve('ICoastsService'); + const exists = await coastsService.hasCoastfile(repositoryPath); + return { exists }; + } catch { + return { exists: false }; + } +} +``` + +- [ ] **Step 3: Create Storybook mocks** + +Create `.storybook/mocks/app/actions/generate-coastfile.ts`: + +```typescript +export async function generateCoastfileAction( + _repositoryPath: string +): Promise<{ success: boolean; coastfilePath?: string; error?: string }> { + return { success: false, error: 'Not available in Storybook' }; +} +``` + +Create `.storybook/mocks/app/actions/check-coastfile.ts`: + +```typescript +export async function checkCoastfileAction( + _repositoryPath: string +): Promise<{ exists: boolean }> { + return { exists: false }; +} +``` + +- [ ] **Step 4: Run build to verify no import errors** + +Run: `pnpm build` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/presentation/web/app/actions/generate-coastfile.ts src/presentation/web/app/actions/check-coastfile.ts .storybook/mocks/app/actions/generate-coastfile.ts .storybook/mocks/app/actions/check-coastfile.ts +git commit -m "feat(web): add server actions for on-demand coastfile generation" +``` + +--- + +### Task 4: Create useCoastsActions hook + +**Files:** +- Create: `src/presentation/web/components/common/repository-node/use-coasts-actions.ts` +- Create: `tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts` + +- [ ] **Step 1: Write the test file** + +Create `tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock server actions +vi.mock('@/app/actions/generate-coastfile', () => ({ + generateCoastfileAction: vi.fn(), +})); +vi.mock('@/app/actions/check-coastfile', () => ({ + checkCoastfileAction: vi.fn(), +})); + +import { generateCoastfileAction } from '@/app/actions/generate-coastfile'; +import { checkCoastfileAction } from '@/app/actions/check-coastfile'; +import { useCoastsActions } from '@cli/presentation/web/components/common/repository-node/use-coasts-actions.js'; + +describe('useCoastsActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('checks coastfile existence on mount', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: true }); + + const { result } = renderHook(() => + useCoastsActions({ repositoryPath: '/repos/my-project' }) + ); + + // Wait for the async mount check + await vi.waitFor(() => { + expect(result.current.coastfileExists).toBe(true); + }); + + expect(checkCoastfileAction).toHaveBeenCalledWith('/repos/my-project'); + }); + + it('returns coastfileExists false when no Coastfile', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: false }); + + const { result } = renderHook(() => + useCoastsActions({ repositoryPath: '/repos/my-project' }) + ); + + await vi.waitFor(() => { + expect(result.current.coastfileExists).toBe(false); + }); + }); + + it('calls generateCoastfileAction and updates state on success', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: false }); + vi.mocked(generateCoastfileAction).mockResolvedValue({ + success: true, + coastfilePath: '/repos/my-project/Coastfile', + }); + + const { result } = renderHook(() => + useCoastsActions({ repositoryPath: '/repos/my-project' }) + ); + + await vi.waitFor(() => { + expect(result.current.checkLoading).toBe(false); + }); + + await act(async () => { + await result.current.generateCoastfile(); + }); + + expect(generateCoastfileAction).toHaveBeenCalledWith('/repos/my-project'); + expect(result.current.coastfileExists).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('sets error on generateCoastfileAction failure', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: false }); + vi.mocked(generateCoastfileAction).mockResolvedValue({ + success: false, + error: 'Agent failed', + }); + + const { result } = renderHook(() => + useCoastsActions({ repositoryPath: '/repos/my-project' }) + ); + + await vi.waitFor(() => { + expect(result.current.checkLoading).toBe(false); + }); + + await act(async () => { + await result.current.generateCoastfile(); + }); + + expect(result.current.error).toBe('Agent failed'); + expect(result.current.coastfileExists).toBe(false); + }); + + it('returns no-op state when input is null', () => { + const { result } = renderHook(() => useCoastsActions(null)); + + expect(result.current.coastfileExists).toBe(false); + expect(result.current.generating).toBe(false); + expect(checkCoastfileAction).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail (RED)** + +Run: `pnpm test:unit -- --run tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the useCoastsActions hook** + +Create `src/presentation/web/components/common/repository-node/use-coasts-actions.ts`: + +```typescript +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { generateCoastfileAction } from '@/app/actions/generate-coastfile'; +import { checkCoastfileAction } from '@/app/actions/check-coastfile'; + +export interface CoastsActionsInput { + repositoryPath: string; +} + +export interface CoastsActionsState { + coastfileExists: boolean; + generating: boolean; + checkLoading: boolean; + error: string | null; + generateCoastfile: () => Promise; +} + +const ERROR_CLEAR_DELAY = 5000; + +export function useCoastsActions(input: CoastsActionsInput | null): CoastsActionsState { + const repoPath = input?.repositoryPath ?? null; + const [coastfileExists, setCoastfileExists] = useState(false); + const [generating, setGenerating] = useState(false); + const [checkLoading, setCheckLoading] = useState(!!repoPath); + const [error, setError] = useState(null); + const errorTimerRef = useRef | null>(null); + + useEffect(() => { + const ref = errorTimerRef; + return () => { + if (ref.current) clearTimeout(ref.current); + }; + }, []); + + // Check coastfile existence on mount — use repoPath (string) as dep to avoid infinite re-renders + useEffect(() => { + if (!repoPath) return; + + let cancelled = false; + setCheckLoading(true); + + checkCoastfileAction(repoPath) + .then((result) => { + if (!cancelled) { + setCoastfileExists(result.exists); + setCheckLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setCoastfileExists(false); + setCheckLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [repoPath]); + + const handleGenerate = useCallback(async () => { + if (!repoPath || generating) return; + + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + + setGenerating(true); + setError(null); + + try { + const result = await generateCoastfileAction(repoPath); + + if (result.success) { + setCoastfileExists(true); + } else { + setError(result.error ?? 'Failed to generate Coastfile'); + errorTimerRef.current = setTimeout(() => setError(null), ERROR_CLEAR_DELAY); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to generate Coastfile'; + setError(message); + errorTimerRef.current = setTimeout(() => setError(null), ERROR_CLEAR_DELAY); + } finally { + setGenerating(false); + } + }, [repoPath, generating]); + + return { + coastfileExists, + generating, + checkLoading, + error, + generateCoastfile: handleGenerate, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass (GREEN)** + +Run: `pnpm test:unit -- --run tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/presentation/web/components/common/repository-node/use-coasts-actions.ts tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts +git commit -m "feat(web): add use-coasts-actions hook for on-demand coastfile generation" +``` + +--- + +### Task 5: Add Coastfile generation button to repository node + +**Files:** +- Modify: `src/presentation/web/components/common/repository-node/repository-node.tsx:1-50,374-439` + +- [ ] **Step 1: Add imports to repository-node.tsx** + +Add `FileCode2` to the lucide-react imports (line 6-20): + +```typescript +import { + Github, + Plus, + Code2, + Terminal, + FolderOpen, + Trash2, + Play, + Square, + GitBranch, + GitCommitHorizontal, + ArrowDown, + User, + RotateCcw, + FileCode2, +} from 'lucide-react'; +``` + +Add the hook import after line 37: + +```typescript +import { useCoastsActions } from './use-coasts-actions'; +``` + +- [ ] **Step 2: Wire up the hook in the component** + +After line 49 (where `deployAction` is created), add: + +```typescript +const coastsActions = useCoastsActions( + data.repositoryPath ? { repositoryPath: data.repositoryPath } : null +); +``` + +- [ ] **Step 3: Add the Coastfile button as a standalone section** + +Row 4 (dev server) is gated by `featureFlags.envDeploy`, so the Coastfile button needs its own section visible when `coastsDevServer` is enabled — independent of `envDeploy`. Add this **after** the Row 4 block (after line 439), before the source handle: + +```typescript +{/* Row 5: Coastfile generation — visible when coastsDevServer flag is on */} +{featureFlags.coastsDevServer && data.repositoryPath ? ( +
e.stopPropagation()} + > +
+ + {coastsActions.coastfileExists ? 'Coastfile' : 'No Coastfile'} + + + + + + + + + + {coastsActions.error ?? (coastsActions.coastfileExists ? 'Regenerate Coastfile' : 'Generate Coastfile')} + + + +
+
+) : null} +``` + +- [ ] **Step 4: Run build to verify no type errors** + +Run: `pnpm build` +Expected: PASS + +- [ ] **Step 5: Run existing repository-node tests** + +Run: `pnpm test:unit -- --run tests/unit/presentation/web/components/common/` +Expected: PASS (no regressions) + +- [ ] **Step 6: Commit** + +```bash +git add src/presentation/web/components/common/repository-node/repository-node.tsx +git commit -m "feat(web): add coastfile generation button to repository node" +``` + +--- + +### Task 6: Update spec.yaml and run full validation + +**Files:** +- Modify: `specs/072-coasts-dev-server/spec.yaml` + +- [ ] **Step 1: Update FR-8 in spec.yaml** + +Replace the FR-8 content about auto-generation during startup with on-demand generation: + +> **FR-8: Coastfile on-demand generation** — Coastfile generation is triggered explicitly by the user via `shep coasts init` CLI command or the "Generate Coastfile" button on the repository node in the web UI. The dev server does NOT auto-generate Coastfiles. When the Coasts feature flag is enabled and no Coastfile exists, the dev server fails with a helpful error message pointing the user to both generation methods. + +- [ ] **Step 2: Update FR-9 step 3** + +Change step 3 from "run generateCoastfile" to: + +> 3. Check for Coastfile — if missing, exit with a non-zero code and a human-readable error listing both generation methods (CLI and web UI) + +- [ ] **Step 3: Add FR-14 and FR-15** + +Add: + +> **FR-14: CLI command `shep coasts init`** — A `shep coasts init` CLI command generates a Coastfile for the current working directory. It checks prerequisites, generates the Coastfile via AI agent using `coast installation-prompt`, and builds the coast container. Supports `--force` flag to overwrite an existing Coastfile without prompting. + +> **FR-15: Web UI generate Coastfile button** — A "Generate Coastfile" button in the repository node's dev server section (visible when `coastsDevServer` flag is enabled). Uses a server action that resolves `ICoastsService`, validates the repository path, generates the Coastfile, and builds the container. Button label changes to "Regenerate Coastfile" when a Coastfile already exists. + +- [ ] **Step 4: Run full validation** + +Run: `pnpm validate` +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `pnpm test` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add specs/072-coasts-dev-server/spec.yaml +git commit -m "chore(specs): update fr-8 fr-9 and add fr-14 fr-15 for on-demand coastfile generation" +``` diff --git a/docs/superpowers/specs/2026-03-20-coastfile-on-demand-generation-design.md b/docs/superpowers/specs/2026-03-20-coastfile-on-demand-generation-design.md new file mode 100644 index 000000000..e368dd781 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-coastfile-on-demand-generation-design.md @@ -0,0 +1,118 @@ +# Coastfile On-Demand Generation + +## Summary + +Move Coastfile generation from automatic (during dev server startup) to on-demand, triggered explicitly by the user via either a CLI command (`shep coasts init`) or a web UI button on the repository node. When the dev server starts in coasts mode and no Coastfile exists, it fails with a helpful error pointing the user to both generation methods. + +## Motivation + +The current implementation auto-generates a Coastfile when the dev server starts in coasts mode and no Coastfile is found. This is wrong because Coastfile generation is a per-repo setup step (like generating specs) — it should be an explicit user action, not a side effect of starting the dev server. + +## Design + +### 1. Dev Server Change + +**File:** `src/presentation/web/coasts-dev-server.ts` + +Replace the auto-generation block (lines 47-52) with an error throw. When `hasCoastfile(workDir)` returns false: + +``` +[dev-server:coasts] No Coastfile found in (expected: Coastfile). +Generate one with: + - CLI: shep coasts init + - Web UI: Use the "Generate Coastfile" button on the repository node +``` + +The dev server exits with code 1. No fallback, no auto-generation. + +### 2. CLI Command: `shep coasts init` + +**New files:** +- `src/presentation/cli/commands/coasts/index.ts` — parent `coasts` command +- `src/presentation/cli/commands/coasts/init.command.ts` — `init` subcommand + +**DI resolution:** `container.resolve('ICoastsService')` — direct container import from `@/infrastructure/di/container.js` (same as all other CLI commands). + +**Behavior:** +1. Resolve `ICoastsService` from DI container +2. Check if Coastfile already exists in cwd — if yes, prompt "Coastfile already exists. Regenerate? (y/n)". Support `--force` / `-f` flag to skip confirmation (for CI/non-TTY contexts). +3. Run `checkPrerequisites()` — coast binary needed for `coast installation-prompt` +4. Run `generateCoastfile(cwd)` with a spinner +5. Run `build(cwd)` automatically after generation with a spinner +6. Print success messages + +**Registration:** `program.addCommand(createCoastsCommand())` in CLI `index.ts` + +**Pattern:** Parent-with-subcommands (like `feat/index.ts`). Only `init` is implemented now; future subcommands (`status`, `stop`, etc.) can be added without breaking changes. + +### 3. Web UI Button + +**Server action:** `src/presentation/web/app/actions/generate-coastfile.ts` +- `'use server'` directive +- Resolves `ICoastsService` via `resolve('ICoastsService')` from `@/lib/server-container` (same as all other server actions) +- Accepts `repositoryPath: string` +- **Input validation:** Validates `repositoryPath` is non-empty, is an absolute path (`path.isAbsolute()`), and the directory exists (`existsSync()`) — matching the pattern in `deploy-repository.ts` +- Calls `generateCoastfile(repositoryPath)` then `build(repositoryPath)` +- Returns `{ success: boolean; coastfilePath?: string; error?: string }` + +**Coastfile existence check action:** `src/presentation/web/app/actions/check-coastfile.ts` +- Lightweight server action: resolves `ICoastsService`, calls `hasCoastfile(repositoryPath)`, returns `{ exists: boolean }` +- Called by the repository node on mount to determine initial button label + +**Button placement:** Dev server section (Row 4) of `repository-node.tsx` +- Only visible when `featureFlags.coastsDevServer` is enabled +- Label: "Generate Coastfile" when none exists, "Regenerate Coastfile" when one exists +- Icon: `FileCode2` from lucide-react + +**State management:** New `useCoastsActions` hook (separate from `useRepositoryActions` since this is a stateful multi-step operation, not a fire-and-forget action): +- `coastfileExists: boolean` — initial value from `checkCoastfileAction()` called on mount +- `generating: boolean` — loading state +- `error: string | null` — with 5-second auto-clear (same pattern as `useRepositoryActions`) +- `generateCoastfile()` — calls the server action; on success, sets `coastfileExists = true` optimistically +- Lives in `src/presentation/web/components/common/repository-node/use-coasts-actions.ts` + +**Flow:** Mount -> `checkCoastfileAction()` sets initial `coastfileExists` -> User clicks button -> spinner -> server action calls `generateCoastfile()` + `build()` -> success toast -> `coastfileExists` flipped to true -> label changes to "Regenerate Coastfile" + +**Storybook:** Add mock for `generate-coastfile.ts` and `check-coastfile.ts` in `.storybook/mocks/app/actions/` if that pattern is still used, or verify mock setup is not needed. + +### 4. Tests + +**Unit tests:** +- `coasts-dev-server.ts` — assert throws with helpful error when no Coastfile (instead of calling `generateCoastfile()`) +- `init.command.ts` — CLI command calls `generateCoastfile()` + `build()`, handles already-exists, handles prerequisite failures +- `generate-coastfile.ts` server action — resolves service and calls correct methods + +**Integration tests:** +- Dev server branching: coasts mode + no Coastfile -> exit code 1 with correct error message + +**No changes to CoastsService tests** — service methods are unchanged. + +### 5. Spec Update + +Update `specs/072-coasts-dev-server/spec.yaml`: +- **FR-8** — rewrite: on-demand generation via CLI and web UI (not auto during startup) +- **FR-9 step 3** — fail with helpful error instead of auto-generating +- **FR-14 (new)** — CLI command `shep coasts init` +- **FR-15 (new)** — Web UI generate Coastfile button in repo node dev server section + +## Files Changed + +| File | Change | +|------|--------| +| `src/presentation/web/coasts-dev-server.ts` | Replace auto-generation with error throw | +| `src/presentation/cli/commands/coasts/index.ts` | New — parent `coasts` command | +| `src/presentation/cli/commands/coasts/init.command.ts` | New — `init` subcommand | +| `src/presentation/cli/index.ts` | Register `coasts` command | +| `src/presentation/web/app/actions/generate-coastfile.ts` | New — server action | +| `src/presentation/web/components/common/repository-node/repository-node.tsx` | Add button in dev server section | +| `src/presentation/web/components/common/repository-node/use-coasts-actions.ts` | New — hook for coastfile generation state | +| `src/presentation/web/app/actions/check-coastfile.ts` | New — lightweight server action for coastfile existence | +| `specs/072-coasts-dev-server/spec.yaml` | Update FR-8, FR-9; add FR-14, FR-15 | +| Tests (multiple) | New + updated tests | + +## What Does NOT Change + +- `CoastsService` implementation — `generateCoastfile()` and `build()` methods are untouched +- `ICoastsService` interface — no new methods needed +- DI registration — already registered +- Feature flag wiring — already complete diff --git a/package.json b/package.json index 813fc4f9b..98afa6b9a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dev:cli": "tsx src/presentation/cli/index.ts", "dev:web": "tsx --tsconfig tsconfig.json src/presentation/web/dev-server.ts", "dev:storybook": "storybook dev -p 6006", + "dev:coasts": "NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER=true pnpm dev:web", "generate": "pnpm tsp:codegen", "tsp:codegen": "tsp compile tsp/ --emit @typespec-tools/emitter-typescript && prettier --write packages/core/src/domain/generated/", "build:cli": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && shx mkdir -p dist/packages/core/src/infrastructure/services/tool-installer && shx rm -rf dist/packages/core/src/infrastructure/services/tool-installer/tools && shx cp -r packages/core/src/infrastructure/services/tool-installer/tools dist/packages/core/src/infrastructure/services/tool-installer/tools", diff --git a/packages/core/src/application/ports/output/services/coasts-service.interface.ts b/packages/core/src/application/ports/output/services/coasts-service.interface.ts new file mode 100644 index 000000000..b117921be --- /dev/null +++ b/packages/core/src/application/ports/output/services/coasts-service.interface.ts @@ -0,0 +1,143 @@ +/** + * Coasts Service Interface + * + * Output port for managing Coasts containerized runtime isolation. + * Implementations wrap the coast CLI binary via subprocess invocation + * and integrate with the AI agent system for Coastfile generation. + * + * Following Clean Architecture: + * - Application layer depends on this interface + * - Infrastructure layer provides concrete implementations + */ + +/** + * Result of checking Coasts prerequisites. + * Each boolean indicates whether a specific prerequisite is met. + */ +export interface PrerequisiteCheckResult { + /** Whether the `coast` binary is available on PATH. */ + coastBinary: boolean; + /** Whether the Docker daemon is reachable. */ + docker: boolean; + /** Whether the coastd daemon is running and responding. */ + coastdRunning: boolean; + /** Convenience AND of all prerequisite checks. */ + allMet: boolean; + /** Human-readable messages for each missing prerequisite with fix instructions. */ + missingMessages: string[]; +} + +/** + * Information about a running Coasts instance. + */ +export interface CoastInstance { + /** Port the coast instance is listening on. */ + port: number; + /** URL to access the coast instance. */ + url: string; +} + +/** + * Port interface for managing Coasts containerized runtime isolation. + * + * Implementations must: + * - Invoke the coast CLI binary via subprocess (not the coastd HTTP API) + * - Support concurrent operations across different worktrees via workDir scoping + * - Cache the installation prompt for the process lifetime + * - Delegate Coastfile generation to the AI agent system + */ +export interface ICoastsService { + /** + * Check whether all Coasts prerequisites are met. + * Validates coast binary on PATH, Docker daemon reachable, and coastd running. + * On Windows, fails immediately with a platform-not-supported message. + * + * @param workDir - Working directory for the check (used as cwd for subprocess calls) + * @returns Result with individual check statuses and actionable messages for failures + */ + checkPrerequisites(workDir: string): Promise; + + /** + * Build the Coasts container image for the given directory. + * Invokes `coast build` in the specified working directory. + * Safe to call when the image is already built (idempotent). + * + * @param workDir - Directory containing the Coastfile + * @throws Error if coast build fails (exit code non-zero) + */ + build(workDir: string): Promise; + + /** + * Start a Coasts instance for the given directory. + * Invokes `coast run` and returns instance info (port, URL). + * Safe to call when the instance is already running (idempotent). + * + * @param workDir - Directory containing the Coastfile + * @returns Information about the running coast instance + * @throws Error if coast run fails + */ + run(workDir: string): Promise; + + /** + * Stop the Coasts instance for the given directory. + * Invokes `coast stop` for the specified working directory. + * No-op if no instance is running. + * + * @param workDir - Directory whose coast instance should be stopped + */ + stop(workDir: string): Promise; + + /** + * Look up a running Coasts instance for the given directory. + * + * @param workDir - Directory to look up + * @returns Instance info if running, null if not found + */ + lookup(workDir: string): Promise; + + /** + * Check if a Coasts instance is currently running for the given directory. + * + * @param workDir - Directory to check + * @returns True if an instance is running + */ + isRunning(workDir: string): Promise; + + /** + * Assign canonical ports to this worktree's coast instance. + * Invokes `coast checkout` in the specified working directory. + * + * @param workDir - Directory whose coast instance should be checked out + */ + checkout(workDir: string): Promise; + + /** + * Get the Coasts installation prompt for Coastfile generation. + * Invokes `coast installation-prompt` and returns the full prompt text. + * The result is cached for the process lifetime (FR-13). + * + * @returns The full installation prompt text from the coast CLI + * @throws Error if the coast binary is not available + */ + getInstallationPrompt(): Promise; + + /** + * Generate a Coastfile for the given directory using the AI agent system. + * Runs `coast installation-prompt` to obtain the generation prompt, + * passes it to the AI agent system with repo context, and writes the + * generated Coastfile to the directory. + * + * @param workDir - Directory where the Coastfile should be generated + * @returns Absolute path to the generated Coastfile + * @throws Error if prompt retrieval or agent execution fails + */ + generateCoastfile(workDir: string): Promise; + + /** + * Check if a Coastfile exists in the given directory. + * + * @param workDir - Directory to check for a Coastfile + * @returns True if a Coastfile exists + */ + hasCoastfile(workDir: string): Promise; +} diff --git a/packages/core/src/application/ports/output/services/index.ts b/packages/core/src/application/ports/output/services/index.ts index 6f3a98d12..c270c9fed 100644 --- a/packages/core/src/application/ports/output/services/index.ts +++ b/packages/core/src/application/ports/output/services/index.ts @@ -52,3 +52,8 @@ export { GitHubUrlParseError, GitHubRepoListError, } from './github-repository-service.interface.js'; +export type { + ICoastsService, + PrerequisiteCheckResult, + CoastInstance, +} from './coasts-service.interface.js'; diff --git a/packages/core/src/domain/factories/settings-defaults.factory.ts b/packages/core/src/domain/factories/settings-defaults.factory.ts index f20c24f59..9a5828ffb 100644 --- a/packages/core/src/domain/factories/settings-defaults.factory.ts +++ b/packages/core/src/domain/factories/settings-defaults.factory.ts @@ -157,6 +157,7 @@ export function createDefaultSettings(): Settings { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; return { diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index a3fc416fb..584cfcb87 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -582,6 +582,10 @@ export type FeatureFlags = { * Use the built-in React file manager instead of the native OS folder picker */ reactFileManager: boolean; + /** + * Enable Coasts containerized runtime isolation for the dev server + */ + coastsDevServer: boolean; }; /** diff --git a/packages/core/src/infrastructure/di/container.ts b/packages/core/src/infrastructure/di/container.ts index ec77c16d7..0572b97f3 100644 --- a/packages/core/src/infrastructure/di/container.ts +++ b/packages/core/src/infrastructure/di/container.ts @@ -51,6 +51,9 @@ import { DeploymentService } from '../services/deployment/deployment.service.js' import { AttachmentStorageService } from '../services/attachment-storage.service.js'; import type { IGitHubRepositoryService } from '../../application/ports/output/services/github-repository-service.interface.js'; import { GitHubRepositoryService } from '../services/external/github-repository.service.js'; +import type { ICoastsService } from '../../application/ports/output/services/coasts-service.interface.js'; +import { CoastsService } from '../services/coasts.service.js'; +import type { ExecFunction } from '../services/git/worktree.service.js'; // Agent infrastructure interfaces and implementations import type { IAgentExecutorFactory } from '../../application/ports/output/agents/agent-executor-factory.interface.js'; @@ -243,6 +246,16 @@ export async function initializeContainer(): Promise { deploymentService.recoverAll(); container.registerInstance('IDeploymentService', deploymentService); + // Register Coasts service (factory — needs ExecFunction + IStructuredAgentCaller) + // NOTE: IStructuredAgentCaller is registered below; tsyringe resolves lazily via factory. + container.register('ICoastsService', { + useFactory: (c) => { + const execFn = c.resolve('ExecFunction'); + const structuredCaller = c.resolve('IStructuredAgentCaller'); + return new CoastsService(execFn, structuredCaller); + }, + }); + // Register agent infrastructure container.register('IAgentRunRepository', { useFactory: (c) => { diff --git a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts index d0c7632a6..0e06866e7 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts @@ -114,6 +114,7 @@ export interface SettingsRow { feature_flag_adopt_branch: number; feature_flag_git_rebase_sync: number; feature_flag_react_file_manager: number; + feature_flag_coasts_dev_server: number; } /** @@ -219,6 +220,7 @@ export function toDatabase(settings: Settings): SettingsRow { feature_flag_adopt_branch: settings.featureFlags?.adoptBranch ? 1 : 0, feature_flag_git_rebase_sync: settings.featureFlags?.gitRebaseSync ? 1 : 0, feature_flag_react_file_manager: settings.featureFlags?.reactFileManager ? 1 : 0, + feature_flag_coasts_dev_server: settings.featureFlags?.coastsDevServer ? 1 : 0, }; } @@ -353,6 +355,7 @@ export function fromDatabase(row: SettingsRow): Settings { adoptBranch: row.feature_flag_adopt_branch === 1, gitRebaseSync: row.feature_flag_git_rebase_sync === 1, reactFileManager: row.feature_flag_react_file_manager === 1, + coastsDevServer: row.feature_flag_coasts_dev_server === 1, }, // Onboarding (INTEGER → boolean) diff --git a/packages/core/src/infrastructure/persistence/sqlite/migrations/044-add-feature-flag-coasts-dev-server.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/044-add-feature-flag-coasts-dev-server.ts new file mode 100644 index 000000000..ed44a0531 --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/044-add-feature-flag-coasts-dev-server.ts @@ -0,0 +1,25 @@ +/** + * Migration 044: Add feature_flag_coasts_dev_server column to settings table. + * + * Adds a boolean (INTEGER 0/1) column for the Coasts dev server feature flag. + * Defaults to 0 (disabled). + */ + +import type { MigrationParams } from 'umzug'; +import type Database from 'better-sqlite3'; + +export async function up({ context: db }: MigrationParams): Promise { + const columns = db.pragma('table_info(settings)') as { name: string }[]; + const existing = new Set(columns.map((c) => c.name)); + + if (!existing.has('feature_flag_coasts_dev_server')) { + db.exec( + 'ALTER TABLE settings ADD COLUMN feature_flag_coasts_dev_server INTEGER NOT NULL DEFAULT 0' + ); + } +} + +export async function down({ context: db }: MigrationParams): Promise { + // SQLite does not support DROP COLUMN before 3.35.0; column remains but is unused after rollback. + void db; +} diff --git a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts index bbc95bde3..14ea87cf3 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts @@ -66,7 +66,7 @@ export class SQLiteSettingsRepository implements ISettingsRepository { approval_gate_allow_prd, approval_gate_allow_plan, approval_gate_allow_merge, approval_gate_push_on_impl_complete, feature_flag_skills, feature_flag_env_deploy, feature_flag_debug, feature_flag_github_import, feature_flag_adopt_branch, feature_flag_git_rebase_sync, - feature_flag_react_file_manager, + feature_flag_react_file_manager, feature_flag_coasts_dev_server, workflow_enable_evidence, workflow_commit_evidence, hide_ci_status ) VALUES ( @@ -92,7 +92,7 @@ export class SQLiteSettingsRepository implements ISettingsRepository { @approval_gate_allow_prd, @approval_gate_allow_plan, @approval_gate_allow_merge, @approval_gate_push_on_impl_complete, @feature_flag_skills, @feature_flag_env_deploy, @feature_flag_debug, @feature_flag_github_import, @feature_flag_adopt_branch, @feature_flag_git_rebase_sync, - @feature_flag_react_file_manager, + @feature_flag_react_file_manager, @feature_flag_coasts_dev_server, @workflow_enable_evidence, @workflow_commit_evidence, @hide_ci_status ) @@ -197,6 +197,7 @@ export class SQLiteSettingsRepository implements ISettingsRepository { feature_flag_adopt_branch = @feature_flag_adopt_branch, feature_flag_git_rebase_sync = @feature_flag_git_rebase_sync, feature_flag_react_file_manager = @feature_flag_react_file_manager, + feature_flag_coasts_dev_server = @feature_flag_coasts_dev_server, workflow_enable_evidence = @workflow_enable_evidence, workflow_commit_evidence = @workflow_commit_evidence, hide_ci_status = @hide_ci_status diff --git a/packages/core/src/infrastructure/services/coasts.service.ts b/packages/core/src/infrastructure/services/coasts.service.ts new file mode 100644 index 000000000..2a240b7e8 --- /dev/null +++ b/packages/core/src/infrastructure/services/coasts.service.ts @@ -0,0 +1,225 @@ +/** + * Coasts Service Implementation + * + * Infrastructure adapter wrapping the coast CLI binary for containerized + * runtime isolation. Uses constructor-injected ExecFunction for subprocess + * invocation and IStructuredAgentCaller for AI-powered Coastfile generation. + * + * Following Clean Architecture: + * - Implements the ICoastsService application port + * - Lives in the infrastructure layer + * - No direct child_process imports (injected via ExecFunction) + */ + +import { existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { injectable, inject } from 'tsyringe'; +import type { + ICoastsService, + PrerequisiteCheckResult, + CoastInstance, +} from '../../application/ports/output/services/coasts-service.interface.js'; +import type { IStructuredAgentCaller } from '../../application/ports/output/agents/structured-agent-caller.interface.js'; +import type { ExecFunction } from './git/worktree.service.js'; +import { IS_WINDOWS } from '../platform.js'; + +/** Timeout for coast build (may need to pull Docker images). */ +const BUILD_TIMEOUT_MS = 30_000; + +/** Timeout for all other coast CLI commands. */ +const DEFAULT_TIMEOUT_MS = 10_000; + +/** JSON schema for Coastfile generation via structured agent caller. */ +const COASTFILE_SCHEMA = { + type: 'object', + properties: { + content: { + type: 'string', + description: 'The complete Coastfile content in TOML format', + }, + warnings: { + type: 'array', + items: { type: 'string' }, + description: 'Any warnings or notes about the generated Coastfile', + }, + }, + required: ['content'], + additionalProperties: false, +} as const; + +interface CoastfileGenerationResult { + content: string; + warnings?: string[]; +} + +@injectable() +export class CoastsService implements ICoastsService { + private cachedInstallationPrompt: string | null = null; + private readonly isWindows: boolean; + + constructor( + @inject('ExecFunction') private readonly execFile: ExecFunction, + @inject('IStructuredAgentCaller') private readonly structuredCaller: IStructuredAgentCaller, + isWindows?: boolean + ) { + this.isWindows = isWindows ?? IS_WINDOWS; + } + + async checkPrerequisites(workDir: string): Promise { + if (this.isWindows) { + return { + coastBinary: false, + docker: false, + coastdRunning: false, + allMet: false, + missingMessages: [ + 'Coasts dev server is not supported on Windows. See https://coasts.dev/docs for platform support.', + ], + }; + } + + const [coastResult, dockerResult, coastdResult] = await Promise.allSettled([ + this.checkCoastBinary(), + this.checkDocker(), + this.checkCoastdRunning(workDir), + ]); + + const coastBinary = coastResult.status === 'fulfilled'; + const docker = dockerResult.status === 'fulfilled'; + const coastdRunning = coastdResult.status === 'fulfilled'; + + const missingMessages: string[] = []; + if (!coastBinary) { + missingMessages.push( + 'coast binary not found on PATH. Install it with: curl -fsSL https://coasts.dev/install | sh. See https://coasts.dev/docs' + ); + } + if (!docker) { + missingMessages.push( + 'Docker daemon is not reachable. Start Docker Desktop or run: sudo systemctl start docker' + ); + } + if (!coastdRunning) { + missingMessages.push( + 'coastd daemon is not running. Start it with: coastd &. See https://coasts.dev/docs/daemon' + ); + } + + return { + coastBinary, + docker, + coastdRunning, + allMet: coastBinary && docker && coastdRunning, + missingMessages, + }; + } + + async build(workDir: string): Promise { + await this.execCoast(['build'], workDir, BUILD_TIMEOUT_MS); + } + + async run(workDir: string): Promise { + const { stdout } = await this.execCoast(['run'], workDir); + return this.parseCoastInstance(stdout); + } + + async stop(workDir: string): Promise { + try { + await this.execCoast(['stop'], workDir); + } catch { + // No-op if no instance is running + } + } + + async lookup(workDir: string): Promise { + try { + const { stdout } = await this.execCoast(['lookup'], workDir); + return this.parseCoastInstance(stdout); + } catch { + return null; + } + } + + async isRunning(workDir: string): Promise { + const instance = await this.lookup(workDir); + return instance !== null; + } + + async checkout(workDir: string): Promise { + await this.execCoast(['checkout'], workDir); + } + + async getInstallationPrompt(): Promise { + if (this.cachedInstallationPrompt !== null) { + return this.cachedInstallationPrompt; + } + + const { stdout } = await this.execFile('coast', ['installation-prompt'], { + timeout: DEFAULT_TIMEOUT_MS, + }); + + this.cachedInstallationPrompt = stdout; + return stdout; + } + + async generateCoastfile(workDir: string): Promise { + const installationPrompt = await this.getInstallationPrompt(); + + const prompt = `${installationPrompt}\n\nAnalyze the project at the working directory and generate a Coastfile.\nReturn the complete Coastfile content as valid TOML in the "content" field.`; + + const result = await this.structuredCaller.call( + prompt, + COASTFILE_SCHEMA, + { + allowedTools: [], + silent: true, + maxTurns: 10, + } + ); + + const coastfilePath = path.join(workDir, 'Coastfile'); + writeFileSync(coastfilePath, result.content, 'utf-8'); + + return coastfilePath; + } + + async hasCoastfile(workDir: string): Promise { + return existsSync(path.join(workDir, 'Coastfile')); + } + + // --- Private helpers --- + + private async checkCoastBinary(): Promise { + await this.execFile('coast', ['--version'], { timeout: 500 }); + } + + private async checkDocker(): Promise { + await this.execFile('docker', ['info'], { timeout: 500 }); + } + + private async checkCoastdRunning(workDir: string): Promise { + await this.execFile('coast', ['ls'], { cwd: workDir, timeout: 500 }); + } + + private async execCoast( + args: string[], + workDir: string, + timeout: number = DEFAULT_TIMEOUT_MS + ): Promise<{ stdout: string; stderr: string }> { + return this.execFile('coast', args, { cwd: workDir, timeout }); + } + + /** + * Parse coast CLI output to extract port and URL from stdout. + * Looks for patterns like "port 3000" and "http://localhost:3000". + */ + private parseCoastInstance(stdout: string): CoastInstance { + const portMatch = stdout.match(/port\s+(\d+)/i); + const urlMatch = stdout.match(/(https?:\/\/[^\s]+)/i); + + const port = portMatch ? parseInt(portMatch[1], 10) : 3000; + const url = urlMatch ? urlMatch[1] : `http://localhost:${port}`; + + return { port, url }; + } +} diff --git a/specs/072-coasts-dev-server/evidence/app-settings-coasts-toggle.png b/specs/072-coasts-dev-server/evidence/app-settings-coasts-toggle.png new file mode 100644 index 000000000..123db17f5 Binary files /dev/null and b/specs/072-coasts-dev-server/evidence/app-settings-coasts-toggle.png differ diff --git a/specs/072-coasts-dev-server/evidence/app-settings-full-page.png b/specs/072-coasts-dev-server/evidence/app-settings-full-page.png new file mode 100644 index 000000000..ec5a10b99 Binary files /dev/null and b/specs/072-coasts-dev-server/evidence/app-settings-full-page.png differ diff --git a/specs/072-coasts-dev-server/evidence/build-output.txt b/specs/072-coasts-dev-server/evidence/build-output.txt new file mode 100644 index 000000000..c8aa43633 --- /dev/null +++ b/specs/072-coasts-dev-server/evidence/build-output.txt @@ -0,0 +1,8 @@ + +> @shepai/cli@1.131.0 build /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-coasts-dev-server +> pnpm build:cli + + +> @shepai/cli@1.131.0 build:cli /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-coasts-dev-server +> tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && shx mkdir -p dist/packages/core/src/infrastructure/services/tool-installer && shx rm -rf dist/packages/core/src/infrastructure/services/tool-installer/tools && shx cp -r packages/core/src/infrastructure/services/tool-installer/tools dist/packages/core/src/infrastructure/services/tool-installer/tools + diff --git a/specs/072-coasts-dev-server/evidence/coasts-dev-server-module-tests.txt b/specs/072-coasts-dev-server/evidence/coasts-dev-server-module-tests.txt new file mode 100644 index 000000000..83901d0ed --- /dev/null +++ b/specs/072-coasts-dev-server/evidence/coasts-dev-server-module-tests.txt @@ -0,0 +1,25 @@ + + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-coasts-dev-server + + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > runs full Coasts flow when all prerequisites met and Coastfile exists 1ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > throws when prerequisites are not met (missing coast binary) 1ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > throws when Docker is not running 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > throws when coastd daemon is not running 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > includes missing prerequisite messages in error 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > calls generateCoastfile when no Coastfile exists 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > logs Coastfile generation progress 1ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > throws when Coastfile generation fails 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > throws when coast build fails 1ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > throws when coast run fails 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > logs [dev-server:coasts] prefix messages throughout startup 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > startCoastsDevServer > returns the CoastInstance from coast run 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > shutdownCoasts > calls stop() on the coastsService 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > shutdownCoasts > does nothing when coastsService is null (bare mode) 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > shutdownCoasts > catches and logs errors from stop() without rethrowing 0ms + ✓ web tests/unit/presentation/web/coasts-dev-server.test.ts > shutdownCoasts > logs [dev-server:coasts] prefix during shutdown 0ms + + Test Files 1 passed (1) + Tests 16 passed (16) + Start at 16:58:10 + Duration 553ms (transform 31ms, setup 116ms, import 20ms, tests 7ms, environment 343ms) + diff --git a/specs/072-coasts-dev-server/evidence/coasts-service-unit-tests.txt b/specs/072-coasts-dev-server/evidence/coasts-service-unit-tests.txt new file mode 100644 index 000000000..29fd55771 --- /dev/null +++ b/specs/072-coasts-dev-server/evidence/coasts-service-unit-tests.txt @@ -0,0 +1,39 @@ + + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-coasts-dev-server + + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > returns allMet true when all checks pass 1ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > coastBinary is false when coast --version throws ENOENT 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > docker is false when docker info exits non-zero 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > coastdRunning is false when coast ls fails 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > missingMessages includes install instructions for each missing prerequisite 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > Windows platform returns allMet false immediately 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkPrerequisites > all three checks run in parallel (not sequentially) 12ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > build > calls execFile with coast build args and workDir cwd 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > build > uses 30-second timeout 1ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > build > throws with stderr on non-zero exit 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > run > parses port and url from stdout 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > run > returns CoastInstance on success 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > run > calls execFile with coast run args 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > run > throws with stderr on failure 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > stop > calls execFile with coast stop args 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > stop > does not throw when no instance is running 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > checkout > calls execFile with coast checkout args 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > lookup > returns CoastInstance when instance exists 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > lookup > returns null when no instance found 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > lookup > calls execFile with coast lookup args 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > isRunning > returns true when lookup succeeds 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > isRunning > returns false when lookup fails 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > hasCoastfile > returns true when Coastfile exists 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > hasCoastfile > returns false when Coastfile missing 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > getInstallationPrompt > runs coast installation-prompt subprocess 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > getInstallationPrompt > returns cached value on second call 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > generateCoastfile > calls getInstallationPrompt then structuredCaller 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > generateCoastfile > writes content to workDir/Coastfile 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > generateCoastfile > returns the Coastfile path 0ms + ✓ node tests/unit/infrastructure/services/coasts.service.test.ts > CoastsService > generateCoastfile > agent schema includes content and warnings fields 0ms + + Test Files 1 passed (1) + Tests 30 passed (30) + Start at 16:58:04 + Duration 136ms (transform 36ms, setup 0ms, import 56ms, tests 18ms, environment 0ms) + diff --git a/specs/072-coasts-dev-server/evidence/dev-server-branching-tests.txt b/specs/072-coasts-dev-server/evidence/dev-server-branching-tests.txt new file mode 100644 index 000000000..f1cd0df76 --- /dev/null +++ b/specs/072-coasts-dev-server/evidence/dev-server-branching-tests.txt @@ -0,0 +1,16 @@ + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-coasts-dev-server + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > runs prerequisite check as the first step 2ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > throws when prerequisites are not met 1ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > does not call build/run when prerequisites fail 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > calls generateCoastfile when no Coastfile exists 1ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > does not call generateCoastfile when Coastfile exists 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > calls coast build then coast run in sequence 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > returns the CoastInstance from coast run 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > propagates coast build errors 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > startCoastsDevServer > propagates coast run errors 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > shutdownCoasts > calls coastsService.stop() with workDir 0ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > shutdownCoasts > does not throw when coastsService.stop() fails 2ms + ✓ node tests/unit/presentation/dev-server-coasts.test.ts > Coasts Dev Server Startup > shutdownCoasts > does nothing when service is null 0ms + Test Files 1 passed (1) + Tests 12 passed (12) + Duration 97ms (transform 23ms, setup 0ms, import 30ms, tests 7ms, environment 0ms) diff --git a/specs/072-coasts-dev-server/evidence/full-test-suite-results.txt b/specs/072-coasts-dev-server/evidence/full-test-suite-results.txt new file mode 100644 index 000000000..67ec262f7 --- /dev/null +++ b/specs/072-coasts-dev-server/evidence/full-test-suite-results.txt @@ -0,0 +1,14 @@ +=== UNIT TESTS === +Test Files: 336 passed (336) +Tests: 4559 passed (4559) +Duration: 29.47s + +=== INTEGRATION TESTS === +Test Files: 46 passed (46) +Tests: 521 passed (521) +Duration: 8.03s + +=== TOTAL === +All 382 test files passed +All 5080 tests passed (4559 unit + 521 integration) +Zero regressions diff --git a/specs/072-coasts-dev-server/feature.yaml b/specs/072-coasts-dev-server/feature.yaml new file mode 100644 index 000000000..6555796c2 --- /dev/null +++ b/specs/072-coasts-dev-server/feature.yaml @@ -0,0 +1,42 @@ +feature: + id: "072-coasts-dev-server" + name: "coasts-dev-server" + number: 72 + branch: "feat/072-coasts-dev-server" + lifecycle: "research" + createdAt: "2026-03-19T17:07:38Z" +status: + phase: "implementation-complete" + progress: + completed: 16 + total: 16 + percentage: 100 + currentTask: null + lastUpdated: "2026-03-23T15:41:24.596Z" + lastUpdatedBy: "feature-agent:implement" + completedPhases: + - "analyze" + - "requirements" + - "research" + - "plan" + - "phase-1" + - "phase-2" + - "phase-3" + - "phase-4" + - "phase-5" + - "evidence" +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] +tasks: + current: null + blocked: [] + failed: [] +checkpoints: + - phase: "feature-created" + completedAt: "2026-03-19T17:07:38Z" + completedBy: "feature-agent" +errors: + current: null + history: [] diff --git a/specs/072-coasts-dev-server/plan.yaml b/specs/072-coasts-dev-server/plan.yaml new file mode 100644 index 000000000..baa80c78b --- /dev/null +++ b/specs/072-coasts-dev-server/plan.yaml @@ -0,0 +1,276 @@ +# Implementation Plan (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: coasts-dev-server +summary: > + Implementation plan for integrating Coasts containerized runtime isolation + into Shep's dev server behind a coastsDevServer feature flag. Follows Clean + Architecture with a new ICoastsService output port, CoastsService + infrastructure adapter wrapping the coast CLI via injected ExecFunction, and + AI-powered Coastfile auto-generation via IStructuredAgentCaller. The dev + server gains a branching point after DI initialization — when the flag is + enabled, it checks prerequisites, generates a Coastfile if missing, and + starts Next.js via coast run instead of bare next(). The feature flag follows + the established 8-step wiring pattern (TypeSpec, migration, mapper, + repository, flag resolution, context, UI toggle). + +relatedFeatures: [] + +technologies: + - "Coasts CLI (coast binary — Rust, installed via coasts.dev/install)" + - "coastd daemon (background process, Unix socket + HTTP API)" + - "Coastfile (TOML configuration at repo root)" + - "Docker / Docker-in-Docker (DinD) runtime" + - "child_process / ExecFunction (Node.js built-in — subprocess spawning)" + - "tsyringe (dependency injection container)" + - "TypeSpec (domain model extension for feature flag)" + - "SQLite (settings persistence — feature flag column)" + - "IStructuredAgentCaller (agent system for Coastfile generation)" + - "Next.js 16 programmatic server API" + +relatedLinks: + - title: "Coasts documentation" + url: "https://coasts.dev/docs" + - title: "Coasts GitHub repository" + url: "https://github.com/coast-guard/coasts" + - title: "Coasts installation" + url: "https://coasts.dev/install" + +phases: + - id: phase-1 + name: "Feature Flag Foundation" + description: > + Wire the coastsDevServer boolean feature flag through the entire stack: + TypeSpec model, SQLite migration 042, settings mapper, settings + repository, feature-flags resolution lib, React context defaults, and + web UI toggle. This phase must come first because the dev server + branching logic and all Coasts-mode behavior depend on reading this + flag. Follows the established 8-step pattern used by adoptBranch (040) + and githubImport (041) flags — zero design risk, well-patterned work. + parallel: false + + - id: phase-2 + name: "ICoastsService Interface & Types" + description: > + Define the ICoastsService output port interface in the application layer + with all method signatures, the PrerequisiteCheckResult type, and the + CoastInstance type. This establishes the contract that the infrastructure + layer will implement and the presentation layer will consume. Defined + before implementation so that the dev-server branching logic and + CoastsService can be developed against a stable interface. + parallel: false + + - id: phase-3 + name: "CoastsService Infrastructure Implementation" + description: > + Implement CoastsService in the infrastructure layer, wrapping the coast + CLI binary via injected ExecFunction. Covers prerequisite checks + (parallel via Promise.allSettled with 500ms timeouts), coast build/run/ + stop/checkout/lookup operations, installation prompt caching, and + Coastfile generation via IStructuredAgentCaller. Register in the DI + container with factory-based injection. This is the heaviest phase — + 10 methods with subprocess management, error handling, and agent + integration. + parallel: false + + - id: phase-4 + name: "Dev Server Integration & Shutdown" + description: > + Modify dev-server.ts to read the coastsDevServer flag after DI + initialization and branch the startup logic. The Coasts path runs + prerequisite checks, detects/generates Coastfile, builds and runs via + coast CLI, and logs the Coasts-managed URL. The shutdown handler gains + coastsService.stop() for Coasts mode cleanup. The bare Next.js path + remains completely unchanged (NFR-1 zero regression). Add the + dev:coasts convenience script to package.json. + parallel: false + + - id: phase-5 + name: "Integration Testing & Validation" + description: > + Write integration tests for the dev-server branching logic (flag on/off, + prerequisites missing, Coastfile generation flow). Verify the feature + flag persists correctly through the full stack. Run pnpm validate to + confirm lint, format, typecheck, and tsp all pass. Ensure all existing + tests continue to pass with zero regressions. + parallel: false + +filesToCreate: + # Application port interface + - "packages/core/src/application/ports/output/services/coasts-service.interface.ts" + + # Infrastructure service + - "packages/core/src/infrastructure/services/coasts.service.ts" + + # Database migration + - "packages/core/src/infrastructure/persistence/sqlite/migrations/042-add-feature-flag-coasts-dev-server.ts" + + # Tests + - "packages/core/src/infrastructure/services/__tests__/coasts.service.test.ts" + - "src/presentation/web/__tests__/dev-server-coasts.integration.test.ts" + +filesToModify: + # TypeSpec domain model + - "tsp/domain/entities/settings.tsp" + - "packages/core/src/domain/generated/output.ts" + + # Settings persistence + - "packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts" + - "packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts" + + # Feature flag resolution + - "src/presentation/web/lib/feature-flags.ts" + - "src/presentation/web/hooks/feature-flags-context.tsx" + + # Feature flag UI + - "src/presentation/web/components/features/settings/feature-flags-settings-section.tsx" + + # DI container + - "packages/core/src/infrastructure/di/container.ts" + + # Dev server entrypoint + - "src/presentation/web/dev-server.ts" + + # Convenience script + - "package.json" + +openQuestions: [] + +content: | + ## Architecture Overview + + This feature adds Coasts containerized runtime isolation as an optional dev + server mode, toggled by a `coastsDevServer` feature flag. The architecture + follows Clean Architecture with dependencies pointing inward: + + ``` + Presentation (dev-server.ts) + -> ICoastsService (application port) + -> CoastsService (infrastructure — wraps coast CLI via ExecFunction) + -> IStructuredAgentCaller (application port — for Coastfile generation) + ``` + + **Domain layer**: The only domain change is adding `coastsDevServer: boolean` + to the FeatureFlags TypeSpec model. No new domain entities — CoastInstance + and PrerequisiteCheckResult are ephemeral types defined in the port interface + file (not TypeSpec), following the pattern used by DeploymentStatus in + deployment-service.interface.ts. + + **Application layer**: New `ICoastsService` output port interface defines + the contract for all Coasts operations. Ten methods covering prerequisite + checks, coast CLI operations, and Coastfile generation. Types defined in + the interface file. + + **Infrastructure layer**: `CoastsService` implements `ICoastsService` using + injected `ExecFunction` (promisified execFile) for coast CLI subprocess + invocation and `IStructuredAgentCaller` for AI-powered Coastfile generation. + Registered in the DI container via factory-based registration. + + **Presentation layer**: `dev-server.ts` gains a single branching point after + DI initialization. When the flag is enabled, it resolves `ICoastsService` + from the container and follows the Coasts startup path. When disabled + (default), the bare Next.js path is completely unchanged — no new imports, + checks, or side effects (NFR-1). + + ## Key Design Decisions + + ### 1. Agent Integration via IStructuredAgentCaller (Research Decision #1) + Coastfile generation uses `IStructuredAgentCaller` with a JSON schema + defining a `content` field (TOML string) and optional `warnings` array. + This follows the MetadataGenerator pattern — the highest-level API for + getting typed results from agents. It automatically handles native structured + output for agents that support it, with prompt-based fallback for others. + The `coast installation-prompt` output provides the full Coastfile schema, + examples, and repo analysis instructions — this is passed as the prompt + body. The agent resolution flows through `IAgentExecutorProvider` per the + mandatory agent resolution rule (no hardcoded agent type). + + ### 2. Subprocess Invocation via ExecFunction (Research Decision #2) + All coast CLI commands use the injected `ExecFunction` (promisified execFile) + following the WorktreeService pattern: `@inject('ExecFunction')` in the + constructor. This provides: (a) testability — mock the function in tests + without coast/Docker installed, (b) consistency — same pattern as git + commands, (c) safety — argument arrays prevent shell injection (NFR-5). + The 30-second timeout for build and 10-second timeout for other commands + are handled via the timeout option on execFile. + + ### 3. Parallel Prerequisite Checks (Research Decision #4) + The three prerequisite checks (coast binary, Docker, coastd daemon) run in + parallel via `Promise.allSettled` with 500ms timeouts per check. This bounds + total check time to ~500ms (NFR-2 requires < 2 seconds). On Windows, + the check fails immediately with a platform-not-supported message (NFR-8). + Each check produces specific actionable error messages (NFR-4). + + ### 4. Single Branching Point in dev-server.ts (Research Decision #5) + Rather than a separate entrypoint or strategy pattern, the dev server uses + a simple if/else after DI initialization. The two paths share: DI init, + settings resolution, watcher startup, and shutdown skeleton. They diverge + only at server startup (bare Next.js vs coast build/run). This keeps the + code readable and avoids premature abstraction for just two code paths. + + ### 5. Feature Flag 8-Step Pattern (Research Decision #7) + The flag wiring follows the identical pattern used by adoptBranch and + githubImport — eight files touched in a predictable way. Migration 042 + adds `feature_flag_coasts_dev_server INTEGER NOT NULL DEFAULT 0`. The + env var fallback is `NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER`. This is + well-patterned S-sized work with zero design risk. + + ### 6. Factory-Based DI Registration (Research Decision #3) + CoastsService uses factory registration to inject both ExecFunction and + IStructuredAgentCaller (both string tokens). No lazy loading needed since + child_process is a built-in with zero import cost (unlike WebServerService + which lazy-loads Next.js). + + ### 7. Installation Prompt Caching (Research Decision #9) + The `coast installation-prompt` output is cached as a private instance field + in CoastsService. Populated on first call to `getInstallationPrompt()`, + lives for the process duration. The prompt is static per coast version and + does not change during a single dev server process lifetime. + + ## Implementation Strategy + + The five phases follow Clean Architecture's dependency rule — inner layers + first, outer layers last: + + **Phase 1 (Feature Flag Foundation)** comes first because the dev server + branching logic cannot be implemented without a flag to read. The 8-step + pattern is well-established and low-risk, making it a safe starting point + that produces visible progress (a UI toggle) early. + + **Phase 2 (ICoastsService Interface)** defines the application-layer + contract before any infrastructure work. This enables the dev-server + integration (Phase 4) and CoastsService (Phase 3) to be developed against + a stable interface. The types and method signatures are driven by the spec's + functional requirements. + + **Phase 3 (CoastsService Implementation)** is the heaviest phase — 10 + methods wrapping coast CLI operations with subprocess management, error + handling, timeout enforcement, and agent-based Coastfile generation. Each + method is independently testable with mocked ExecFunction. + + **Phase 4 (Dev Server Integration)** wires everything together in + dev-server.ts. The branching logic, prerequisite checks, Coastfile + detection/generation, coast build/run, and graceful shutdown are all + integrated here. The bare path must remain completely unchanged. + + **Phase 5 (Integration Testing)** exercises the full stack with integration + tests covering both flag states, prerequisite failure scenarios, and + Coastfile generation flow. Final validation ensures zero regressions. + + ## Risk Mitigation + + | Risk | Mitigation | + | ---- | ---------- | + | Coast binary not installed or not on PATH | checkPrerequisites() detects via execFile ENOENT; actionable error with installation URL | + | Docker daemon not running | checkPrerequisites() detects via `docker info` non-zero exit; error with start instructions | + | coastd daemon not running | checkPrerequisites() detects via `coast ls` failure; error with `coastd &` start instruction | + | TypeSpec model change breaks generated output | Run `pnpm tsp:compile` immediately; verify output.ts diff matches expectation | + | Migration fails on existing databases | Idempotent migration with pragma table_info check (pattern from 041) | + | Bare dev server path regresses | NFR-1 enforced: no new imports/checks/side effects on default path; integration test verifies identical behavior | + | Coast build fails on AI-generated Coastfile | Surface coast build error with Coastfile path and docs link; user can edit and retry (NFR-12) | + | Generated TOML is invalid | IStructuredAgentCaller schema constrains output; coast build validates; error surfaced with edit instructions | + | Subprocess shell injection | ExecFunction uses execFile with argument arrays (not shell strings); NFR-5 enforced | + | Windows platform | Platform check in checkPrerequisites fails fast with clear message (NFR-8) | + | Orphan coast instances on crash | Shutdown handler calls coastsService.stop(); coastd daemon handles container lifecycle independently | + | Multiple worktrees conflict | Each CoastsService method takes workDir parameter scoping to specific worktree; no global state (NFR-11) | + | Prerequisite checks slow startup | Parallel Promise.allSettled with 500ms timeouts bounds total to ~500ms (NFR-2) | diff --git a/specs/072-coasts-dev-server/research.yaml b/specs/072-coasts-dev-server/research.yaml new file mode 100644 index 000000000..0a4904ac7 --- /dev/null +++ b/specs/072-coasts-dev-server/research.yaml @@ -0,0 +1,510 @@ +name: "coasts-dev-server" +summary: | + Technical research for integrating Coasts containerized runtime isolation into Shep's dev server. + Key decisions: use IStructuredAgentCaller (not raw IAgentExecutorProvider) for Coastfile generation, + inject ExecFunction for CLI subprocess calls following WorktreeService pattern, use factory-based + DI registration with lazy loading, and follow the established 8-step feature flag wiring pattern. + No new external dependencies required — leverages child_process, existing agent system, and + established TypeSpec/SQLite/settings infrastructure. + +relatedFeatures: [] + +technologies: + - "Coasts CLI (coast binary — Rust, installed via coasts.dev/install)" + - "coastd daemon (background process, Unix socket + HTTP API on port 31415)" + - "Coastfile (TOML configuration at repo root)" + - "Docker / Docker-in-Docker (DinD) runtime" + - "socat (port forwarding managed by coastd)" + - "child_process.spawn (Node.js built-in — subprocess spawning)" + - "tsyringe (dependency injection container)" + - "TypeSpec (domain model extension for feature flag)" + - "SQLite (settings persistence — feature flag column)" + - "IStructuredAgentCaller (agent system for Coastfile generation)" + - "Next.js 16 programmatic server API" + +relatedLinks: + - title: "Coasts documentation" + url: "https://coasts.dev/docs" + - title: "Coasts GitHub repository" + url: "https://github.com/coast-guard/coasts" + - title: "Coasts installation" + url: "https://coasts.dev/install" + +decisions: + - title: "Agent Integration for Coastfile Generation" + chosen: "IStructuredAgentCaller with JSON schema for Coastfile content extraction" + rejected: + - "Raw IAgentExecutorProvider.getExecutor().execute() — lower-level API that requires manual JSON parsing from unstructured agent output; does not leverage native structured output support in Claude Code/Cursor/Gemini CLI; more error-prone for extracting valid TOML from free-text responses" + - "Hardcoded agent type (e.g. always use Claude Code) — violates the mandatory agent resolution rule in CLAUDE.md; would break for users configured with Cursor or Gemini CLI" + - "Custom prompt-parsing without schema — brittle string extraction; no type safety; prone to partial/malformed output" + rationale: | + The codebase already has IStructuredAgentCaller registered in the DI container + (packages/core/src/infrastructure/di/container.ts) and a production example in + MetadataGenerator (packages/core/src/application/use-cases/features/create/metadata-generator.ts). + IStructuredAgentCaller automatically detects whether the configured agent supports + native structured output (JSON schema) and falls back to prompt-based extraction + with brace-depth parsing. This is the idiomatic, highest-level API for getting + typed results from agents. For Coastfile generation, define a schema with a + `content` field (string containing the TOML) and optional `warnings` array. + The agent receives the coast installation-prompt output as context and produces + structured output conforming to the schema. This approach follows the mandatory + agent resolution rule (no hardcoded agent type) and leverages existing infrastructure. + + - title: "Subprocess Invocation Pattern for Coast CLI" + chosen: "Inject ExecFunction (promisified execFile) following WorktreeService pattern" + rejected: + - "Raw child_process.spawn with manual promise wrapping — more boilerplate, inconsistent with existing service patterns (WorktreeService, GitService all use injected ExecFunction); would need to reimplement timeout, error handling, and cross-platform logic already handled by the container-registered ExecFunction" + - "Direct import of child_process without injection — violates NFR-6 testability requirement; makes unit testing impossible without mocking Node.js built-ins; the codebase explicitly injects exec/spawn functions for this reason" + - "Coast HTTP API via fetch/axios — couples to internal coastd wire protocol; spec decision Q4 explicitly chose CLI subprocess over HTTP API; CLI provides stable public API surface" + rationale: | + The DI container already registers an ExecFunction (promisified execFile) at + packages/core/src/infrastructure/di/container.ts that handles Windows shell mode, + windowsHide, and returns Promise<{stdout, stderr}>. WorktreeService demonstrates + the exact pattern: constructor(@inject('ExecFunction') private readonly execFile: ExecFunction). + CoastsService should follow this identical pattern. Each coast CLI command + (build, run, stop, ls, lookup, checkout, installation-prompt) maps to a single + execFile call with the command as args and workDir as cwd. The promisified + interface handles timeout naturally via AbortController or the timeout option. + For the `run` command specifically, which may need to be long-running, use spawn + with streaming — but since coast run in practice starts the instance and returns, + execFile with a 30-second timeout is sufficient. + + - title: "CoastsService DI Registration Strategy" + chosen: "Factory-based registration with constructor injection of ExecFunction and IStructuredAgentCaller" + rejected: + - "registerSingleton with @injectable()/@inject() decorators — would work but CoastsService needs ExecFunction (string token) and IStructuredAgentCaller (string token) which require factory-based resolution in this codebase's tsyringe setup" + - "Lazy proxy pattern (like WebServerService) — unnecessary overhead; coast CLI import is cheap (just child_process); lazy loading is only needed for expensive imports like Next.js (~80ms); CoastsService has no heavy imports" + - "registerInstance with manual construction — less flexible; factory pattern allows the container to resolve dependencies automatically" + rationale: | + The factory pattern matches existing services that need string-token dependencies. + Registration would be: + container.register('ICoastsService', { + useFactory: (c) => { + const execFn = c.resolve('ExecFunction'); + const structuredCaller = c.resolve('IStructuredAgentCaller'); + return new CoastsService(execFn, structuredCaller); + }, + }); + This follows the pattern used by IAgentExecutorProvider and IStructuredAgentCaller + registrations in the same container file. No lazy loading needed since child_process + is a Node.js built-in with zero import cost. + + - title: "Prerequisite Check Implementation" + chosen: "Parallel Promise.allSettled with short-timeout subprocess checks" + rejected: + - "Sequential checks with early exit — slower; checking coast binary, Docker, and coastd sequentially adds up to 1.5s worst case; parallel checks complete in max(500ms, 500ms, 500ms) = 500ms" + - "Single coast CLI health command — no such command exists in coast CLI; would need to parse composite output; individual checks give better error messages per missing prerequisite" + - "Check only coast binary, skip Docker/daemon — insufficient; coast build/run will fail cryptically without Docker or coastd; better to surface all issues upfront with actionable fix instructions" + rationale: | + NFR-2 requires prerequisite checks to complete within 2 seconds with 500ms timeout + per check. Running all three checks in parallel via Promise.allSettled ensures the + total time is bounded by the slowest single check (~500ms), not the sum. Each check + is independent: + 1. Coast binary: execFile('coast', ['--version'], {timeout: 500}) — ENOENT means not installed + 2. Docker: execFile('docker', ['info'], {timeout: 500}) — non-zero exit means Docker not running + 3. coastd: execFile('coast', ['ls'], {timeout: 500}) — if this succeeds, the daemon is running; + alternatively check the Unix socket directly. The coast ls command requires the daemon, + so it serves as both a daemon check and a connectivity test. + On Windows, fail immediately with platform-not-supported message (NFR-8) without + running any subprocess checks. + + - title: "Dev Server Branching Architecture" + chosen: "Single branching point after DI initialization with feature flag read" + rejected: + - "Separate entrypoint file (dev-server-coasts.ts) — duplicates the entire DI bootstrap, watcher setup, and shutdown logic; high maintenance burden; any change to dev-server.ts must be mirrored" + - "Middleware-based approach (intercept requests) — doesn't apply; the isolation happens at the process/container level, not at the HTTP request level; coast manages the entire Next.js server process" + - "Strategy pattern with abstract DevServerStrategy — over-engineered for two code paths (bare vs coasts); a simple if/else after DI init is sufficient and more readable; the two paths share most of the setup (DI, watchers, shutdown) and only diverge at the server start step" + rationale: | + The dev-server.ts file currently has a linear flow: DI init -> settings -> watchers -> Next.js start -> shutdown. + The Coasts branching point is naturally after DI initialization and settings resolution + (where the feature flag value is available) and before the Next.js server start. + The two paths share: DI init, settings, watcher startup, and shutdown skeleton. + They diverge at: server startup (bare Next.js vs coast build/run) and shutdown + cleanup (server.close() vs coastsService.stop()). A simple if/else with the + coastsDevServer flag keeps the code readable and avoids abstracting prematurely. + The Coasts path adds: prerequisite check -> Coastfile check/generate -> coast build -> coast run. + The bare path remains unchanged (NFR-1 zero regression). + + - title: "Coastfile Detection and Generation Flow" + chosen: "Synchronous fs.existsSync check for Coastfile existence, async agent-based generation when missing" + rejected: + - "Always regenerate Coastfile on every startup — wasteful; AI agent execution is expensive (time and tokens); NFR-7 requires idempotent generation; existing Coastfile should be respected" + - "Store Coastfile existence in SQLite settings — unnecessary indirection; the filesystem is the source of truth; checking for a file at workDir/Coastfile is O(1) and unambiguous" + - "Generate Coastfile in a background task after server starts — the server cannot start without a Coastfile (coast build requires it); generation must be synchronous in the startup flow" + rationale: | + The flow is: (1) check if workDir/Coastfile exists (fs.existsSync — fast, no async needed), + (2) if missing, call coastsService.generateCoastfile(workDir) which runs coast installation-prompt + and passes the result to IStructuredAgentCaller, (3) write the generated TOML to workDir/Coastfile, + (4) proceed with coast build/run. This is idempotent — subsequent startups skip generation. + The user can manually edit the generated Coastfile at any time. The generation step is + excluded from the 2-second startup latency budget (NFR-2) since it is a one-time AI + operation. Log messages with [dev-server:coasts] prefix inform the user of generation progress. + + - title: "Feature Flag Wiring Approach" + chosen: "Follow the established 8-step pattern used by all existing feature flags" + rejected: + - "Runtime-only flag without persistence — loses state across restarts; inconsistent with how all other flags work; users expect flag state to persist in settings" + - "Environment variable only (no DB column) — breaks the DB-primary resolution pattern; the web UI toggle would not work; other flags all have DB columns with env var fallback" + rationale: | + The codebase has a well-established pattern for adding feature flags, demonstrated by + adoptBranch (migration 040) and githubImport (migration 041). The pattern touches + exactly 8 files in a predictable way: + 1. tsp/domain/entities/settings.tsp — add coastsDevServer: boolean = false with @doc + 2. settings.mapper.ts — add feature_flag_coasts_dev_server to SettingsRow, toDatabase(), fromDatabase() + 3. sqlite-settings.repository.ts — add column to INSERT and UPDATE SQL + 4. Migration 042 — ALTER TABLE settings ADD COLUMN feature_flag_coasts_dev_server INTEGER NOT NULL DEFAULT 0 + 5. feature-flags.ts — add to FeatureFlagsState interface and getFeatureFlags() with env var fallback + 6. feature-flags-context.tsx — add coastsDevServer: false to defaultFlags + 7. feature-flags-settings-section.tsx — add to FLAG_DESCRIPTIONS, FLAG_LABELS, FLAG_KEYS + 8. Run pnpm tsp:compile to regenerate output.ts + This is well-patterned S-sized work with zero design risk. + + - title: "Coast CLI Output Parsing Strategy" + chosen: "Exit code + stderr for error detection, stdout text parsing for instance info" + rejected: + - "JSON output parsing via --json flag — coast CLI documentation does not confirm a universal --json flag for all commands; relying on undocumented flags risks breakage on coast version updates" + - "Regex-based stdout parsing — brittle; coast CLI output format may change between versions; prefer structured checks (exit code 0 = success) with minimal parsing" + rationale: | + Coast CLI commands follow standard Unix conventions: exit code 0 = success, non-zero = failure, + error details on stderr. For most CoastsService methods (build, run, stop, checkout), + the exit code is sufficient — we don't need to parse structured output. For `coast ls` + and `coast ports`, which return instance/port information, parse stdout line-by-line. + For `coast installation-prompt`, capture the full stdout as the prompt text. + This minimal-parsing approach is resilient to coast CLI output format changes and + avoids coupling to internal output structures. If coast adds --json support in the + future, the parsing can be upgraded without changing the service interface. + + - title: "Installation Prompt Caching Strategy" + chosen: "Instance-level cache in CoastsService with lazy initialization" + rejected: + - "No caching — coast installation-prompt spawns a subprocess each time; while the output is static per coast version, repeated calls waste ~200ms each; FR-13 explicitly requires caching" + - "File-based cache with TTL — over-engineered for a single in-memory string; the prompt is only needed during Coastfile generation, which happens at most once per dev server lifetime; no need for persistence across processes" + - "Global module-level cache — violates Clean Architecture; makes testing harder; instance-level cache is naturally scoped to the service lifecycle" + rationale: | + CoastsService stores the installation prompt as a private instance field + (private cachedInstallationPrompt: string | null = null). The getInstallationPrompt() + method checks the cache first, only spawning `coast installation-prompt` on cache miss. + This satisfies FR-13 with minimal complexity. The cache lives for the duration of the + dev server process, which is appropriate since the coast version (and thus the prompt) + does not change during a single process lifetime. + + - title: "Graceful Shutdown in Coasts Mode" + chosen: "Add coastsService.stop() call to existing shutdown handler with mode detection" + rejected: + - "Separate shutdown handler for Coasts mode — duplicates the watcher stop, connection close, and force-exit logic; the existing shutdown handler already has the right structure" + - "No explicit stop (rely on coast daemon cleanup) — risky; orphaned coast instances consume Docker resources; explicit stop is the responsible approach" + rationale: | + The existing dev-server.ts shutdown handler has a clear structure: + (1) set isShuttingDown flag, (2) start 2-second force-exit timer, (3) stop watchers, + (4) stop deployments, (5) close connections, (6) close server/app. In Coasts mode, + step 4 becomes coastsService.stop(workDir) instead of/in addition to deploymentService.stopAll(). + The Coasts mode is detected by checking if coastsService was initialized (a simple + null check on a variable set during the Coasts startup path). This keeps the shutdown + handler unified and avoids duplicating cleanup logic. + + - title: "Error Handling for Failed Coastfile Generation" + chosen: "Surface coast build error with instructions to manually edit the generated Coastfile" + rejected: + - "Retry generation with different prompt — expensive (more AI tokens); the issue is likely with the repo structure, not the prompt; a second attempt would likely produce similar output" + - "Delete the generated Coastfile and fall back to bare mode — dangerous; user may have already customized it; silent fallback violates the fail-fast principle from spec Q2" + - "Auto-fix by re-running the agent with the error message — could create an infinite loop; the error may be in the repo setup (missing Dockerfile, wrong ports) not in the Coastfile format" + rationale: | + NFR-12 states: if coast build fails after generation, surface the error to the user + with instructions to manually edit the Coastfile. This is the simplest, safest approach. + The generated Coastfile stays on disk so the user can inspect and fix it. The error + message should include: (a) what went wrong (coast build exit code + stderr), + (b) where the Coastfile is located, (c) a link to Coasts documentation for the + Coastfile format. The user can fix the Coastfile and restart the dev server — the + existing Coastfile will be used without regeneration (NFR-7 idempotency). + +openQuestions: + - question: "Should CoastsService use execFile or spawn for coast CLI invocations?" + resolved: true + options: + - option: "execFile (promisified) for all commands" + description: "Use the injected ExecFunction (promisified execFile) for all coast CLI calls. Returns Promise<{stdout, stderr}>. Simple, consistent with WorktreeService pattern. Buffers all output in memory. Suitable for commands that complete quickly and produce bounded output." + selected: true + - option: "spawn for all commands" + description: "Use child_process.spawn for all coast CLI calls. Returns ChildProcess with streaming stdout/stderr. More complex but supports real-time output. The codebase uses spawn for agent executors (long-running, streaming). Requires manual promise wrapping and stream handling." + selected: false + - option: "Hybrid — execFile for short commands, spawn for coast build" + description: "Use execFile for quick commands (ls, lookup, stop, checkout, installation-prompt) and spawn for coast build (which may take 30+ seconds and produce streaming build output). Most flexible but inconsistent — two invocation patterns in one service." + selected: false + selectionRationale: | + execFile (promisified) is the right choice because all coast CLI commands in the + CoastsService scope are short-lived and produce bounded output. Even coast build, + while potentially slow, does not produce unbounded streaming output that would + exhaust memory. The WorktreeService demonstrates this exact pattern with git commands + that can also take several seconds. The timeout option on execFile handles the + 30-second build timeout naturally. Using spawn would add unnecessary complexity + (manual promise wrapping, stream concatenation) without benefit. If streaming build + output becomes important in the future, a single method can be upgraded to spawn + without changing the interface. + + - question: "How should the dev-server.ts resolve workDir for Coasts operations?" + resolved: true + options: + - option: "Use process.cwd() as workDir" + description: "The dev server runs from the repo root (or worktree root). process.cwd() gives the correct working directory for coast CLI commands. Simple and standard." + selected: false + - option: "Use import.meta.dirname resolved to repo root" + description: "Resolve the repo root from the dev-server.ts file location. More explicit but fragile — depends on directory structure remaining stable. import.meta.dirname is already used for Next.js dir resolution." + selected: false + - option: "Resolve from DI container settings (repo path from DB)" + description: "Read the repository path from the settings/repository table in the database. Most correct for multi-repo scenarios — Shep tracks which repo it is managing. The DI container is already initialized before the Coasts branch point." + selected: true + selectionRationale: | + Resolving workDir from the DI container settings is the most robust approach because + Shep manages arbitrary repositories — the dev server may not always be running from + the repo root. The settings repository stores the managed repo path, which is the + correct workDir for coast CLI operations. However, for V1, process.cwd() is acceptable + as a pragmatic simplification since the dev server is started from the repo root + in practice. The DI-based resolution can be added when multi-repo support is refined. + For implementation, use process.cwd() initially with a TODO to migrate to settings-based + resolution. + + - question: "What migration number should be used for the coastsDevServer feature flag?" + resolved: true + options: + - option: "042" + description: "The current highest migration is 041 (add-feature-flag-github-import). Following sequential numbering, the next migration is 042. Note: there are three 040-* migrations (feature flag, repository remote url, dev servers table) showing that numbering can have collisions handled by the migration runner." + selected: true + - option: "043" + description: "Skip 042 to leave room for other in-flight features that may need migrations. Defensive but breaks the sequential convention." + selected: false + - option: "050" + description: "Use a round number to start a new series for coasts-related migrations. Non-standard and wastes number space." + selected: false + selectionRationale: | + 042 is the correct next migration number. The existing migrations follow sequential + numbering (035, 036, 037, 038, 039, 040, 041) with 040 having multiple migrations + (which the umzug runner handles by filename sorting). Following the established + convention, 042-add-feature-flag-coasts-dev-server.ts is the appropriate filename. + +content: | + ## Technology Decisions + + ### 1. Agent Integration for Coastfile Generation + + **Chosen:** IStructuredAgentCaller with JSON schema for Coastfile content extraction + + **Rejected:** + - Raw IAgentExecutorProvider.getExecutor().execute() — lower-level API requiring manual JSON parsing; no native structured output support + - Hardcoded agent type — violates mandatory agent resolution rule in CLAUDE.md + - Custom prompt-parsing without schema — brittle string extraction with no type safety + + **Rationale:** The codebase has IStructuredAgentCaller registered in the DI container and a production + example in MetadataGenerator. It automatically detects native structured output support and falls back + to prompt-based extraction. For Coastfile generation, define a schema with `content` (TOML string) and + optional `warnings` array. This follows the mandatory agent resolution rule and leverages existing infrastructure. + + ### 2. Subprocess Invocation Pattern for Coast CLI + + **Chosen:** Inject ExecFunction (promisified execFile) following WorktreeService pattern + + **Rejected:** + - Raw child_process.spawn — inconsistent with existing patterns; requires manual promise wrapping + - Direct import without injection — violates NFR-6 testability; prevents unit test mocking + - Coast HTTP API — spec decision Q4 chose CLI subprocess; HTTP protocol is internal + + **Rationale:** The DI container registers ExecFunction (promisified execFile) with Windows shell/hide handling. + WorktreeService demonstrates the exact constructor injection pattern: `@inject('ExecFunction')`. Each coast + CLI command maps to a single execFile call. This handles timeout, error handling, and cross-platform logic. + + ### 3. CoastsService DI Registration + + **Chosen:** Factory-based registration with constructor injection of ExecFunction and IStructuredAgentCaller + + **Rejected:** + - registerSingleton with decorators — requires factory for string-token dependencies + - Lazy proxy (like WebServerService) — unnecessary; coast CLI import is cheap (just child_process) + - registerInstance — less flexible than factory pattern + + **Rationale:** Factory pattern matches existing services with string-token dependencies. + No lazy loading needed since child_process is a Node.js built-in with zero import cost. + + ### 4. Prerequisite Check Implementation + + **Chosen:** Parallel Promise.allSettled with short-timeout subprocess checks + + **Rejected:** + - Sequential checks — slower; up to 1.5s vs parallel's ~500ms + - Single health command — no such command in coast CLI + - Partial checks — insufficient; cryptic failures without full prerequisite validation + + **Rationale:** NFR-2 requires checks within 2 seconds. Parallel execution bounds total time to + the slowest check (~500ms). Three independent checks: coast --version (binary), docker info (Docker), + coast ls (daemon). Windows gets immediate platform-not-supported failure (NFR-8). + + ### 5. Dev Server Branching Architecture + + **Chosen:** Single branching point after DI initialization with feature flag read + + **Rejected:** + - Separate entrypoint (dev-server-coasts.ts) — duplicates DI bootstrap, watchers, shutdown + - Middleware-based — isolation is at process level, not HTTP request level + - Strategy pattern — over-engineered for two code paths + + **Rationale:** dev-server.ts has a linear flow where the Coasts branch naturally occurs after DI init + and before Next.js start. The two paths share DI, settings, watchers, and shutdown skeleton. + They diverge only at server startup. A simple if/else keeps code readable. + + ### 6. Coastfile Detection and Generation + + **Chosen:** fs.existsSync for detection, async agent-based generation when missing + + **Rejected:** + - Always regenerate — wasteful AI tokens; violates NFR-7 idempotency + - SQLite tracking — unnecessary; filesystem is the source of truth + - Background generation — server cannot start without Coastfile + + **Rationale:** Check workDir/Coastfile existence synchronously. If missing, call generateCoastfile() + which runs coast installation-prompt and passes result to IStructuredAgentCaller. Generation is + one-time and excluded from the 2-second startup budget (NFR-2). + + ### 7. Feature Flag Wiring + + **Chosen:** Established 8-step pattern (TypeSpec -> mapper -> repository -> migration -> flags lib -> context -> UI) + + **Rejected:** + - Runtime-only flag — no persistence across restarts + - Env-var only — breaks DB-primary resolution and UI toggle + + **Rationale:** The codebase has a well-established pattern demonstrated by adoptBranch (040) and + githubImport (041). Migration 042 adds feature_flag_coasts_dev_server column. S-sized, zero-risk work. + + ### 8. Coast CLI Output Parsing + + **Chosen:** Exit code + stderr for error detection, minimal stdout parsing + + **Rejected:** + - JSON output via --json flag — not confirmed in coast CLI docs; risk of breakage + - Regex-based parsing — brittle across coast versions + + **Rationale:** Standard Unix conventions: exit 0 = success, non-zero = failure, errors on stderr. + For coast ls/ports, parse stdout line-by-line. For installation-prompt, capture full stdout. Resilient to format changes. + + ### 9. Installation Prompt Caching + + **Chosen:** Instance-level cache in CoastsService (private field, lazy init) + + **Rejected:** + - No caching — wastes ~200ms per call; FR-13 requires caching + - File-based cache with TTL — over-engineered for in-memory string + - Global module cache — violates Clean Architecture; harder to test + + **Rationale:** Private string field, null on init, populated on first getInstallationPrompt() call. + Lives for process duration. Prompt is static per coast version. Minimal complexity. + + ### 10. Graceful Shutdown + + **Chosen:** Add coastsService.stop() to existing shutdown handler with mode detection + + **Rejected:** + - Separate shutdown handler — duplicates watcher/connection cleanup logic + - No explicit stop — orphans Docker resources + + **Rationale:** Existing handler structure (flag -> timer -> stop watchers -> close connections) naturally + accommodates coastsService.stop(). Mode detected via null check on coastsService reference. + + ### 11. Error Handling for Failed Coastfile Generation + + **Chosen:** Surface coast build error with instructions to manually edit the generated Coastfile + + **Rejected:** + - Retry generation — expensive; issue likely in repo structure, not prompt + - Delete Coastfile and fall back — dangerous; violates fail-fast principle + - Auto-fix loop — could be infinite; error may be in repo setup not Coastfile format + + **Rationale:** Generated Coastfile stays on disk for user inspection. Error message includes: + what went wrong (exit code + stderr), where the Coastfile is, and link to Coasts docs. + User fixes and restarts — existing Coastfile used without regeneration (NFR-7). + + ## Library Analysis + + | Library | Purpose | Decision | Reasoning | + | ------- | ------- | -------- | --------- | + | child_process (Node.js built-in) | Spawn coast CLI subprocesses | Use | Already used throughout codebase via injected ExecFunction; no external dependency | + | tsyringe | DI container for CoastsService | Use (existing) | Project's DI framework; CoastsService follows established registration patterns | + | fs (Node.js built-in) | Coastfile existence check and write | Use | Standard file operations; existsSync for detection, writeFile for output | + | path (Node.js built-in) | Resolve Coastfile path | Use | Standard path joining; used throughout codebase | + | toml (npm) | Parse/validate generated TOML | Reject | Unnecessary — coast build validates the Coastfile; adds dependency for marginal benefit | + | dockerode (npm) | Docker daemon check | Reject | Over-engineered; `docker info` via execFile is sufficient; avoids heavy dependency | + | got / node-fetch / axios | HTTP for coastd API | Reject | Spec chose CLI subprocess over HTTP API; no HTTP client needed | + | @iarna/toml (npm) | Generate TOML programmatically | Reject | AI agent generates raw TOML text; no programmatic generation needed | + + ## Security Considerations + + ### Subprocess Injection Prevention + - All coast CLI invocations use execFile with argument arrays (not shell string interpolation) + - NFR-5 mandates spawn/execFile (not exec) to avoid shell injection + - The codebase's ExecFunction uses shell: false on non-Windows platforms + - workDir paths should be validated as absolute paths before passing to cwd + + ### Agent Prompt Injection + - coast installation-prompt output is trusted (comes from locally installed coast binary) + - Repo context (package.json, docker-compose.yml) is local but could contain adversarial content + - Agent sandbox (allowedTools: []) mitigates by preventing command execution during generation + - Generated Coastfile written with standard user permissions + + ### Docker Security + - Coasts uses DinD inheriting host Docker daemon's security context + - Coastfile controls volume mounts and network — user has visibility via repo-root placement + - No new Docker attack surface introduced by Shep; Coasts handles all container management + + ### Feature Flag Security + - coastsDevServer follows existing DB-primary pattern with env var fallback + - NEXT_PUBLIC_ prefix exposes to client-side — acceptable for boolean dev toggle, not sensitive data + + ## Performance Implications + + ### Startup Latency + - **Flag disabled (default):** Zero additional latency — single boolean read (NFR-1) + - **Flag enabled, Coastfile exists:** ~500ms prerequisite checks + coast build (cached) + coast run (~1-2s) = ~2-3s additional + - **Flag enabled, first run (no Coastfile):** Additional 30-60s for AI Coastfile generation (one-time, excluded from NFR-2 budget) + - **Prerequisite checks:** Bounded to 500ms via parallel execution with subprocess timeouts + + ### Memory + - CoastsService: lightweight — one cached string (~5-10KB) + two injected references + - No persistent subprocess handles or streaming connections + - Coast CLI subprocesses are short-lived + + ### Multi-Worktree Concurrency + - Each method takes workDir parameter, scoping to specific worktree (NFR-11) + - No global state, locks, or singletons preventing parallel usage + - coastd daemon handles concurrency internally + - Separate Node.js processes per worktree = no shared memory + + ## Architecture Notes + + ### Clean Architecture Compliance + - **Application layer:** ICoastsService interface + PrerequisiteCheckResult/CoastInstance types in packages/core/src/application/ports/output/services/ + - **Infrastructure layer:** CoastsService implementation in packages/core/src/infrastructure/services/ with constructor injection + - **Presentation layer:** dev-server.ts resolves ICoastsService from DI container; no direct subprocess calls + - **Domain layer:** coastsDevServer flag in TypeSpec FeatureFlags model; no other domain changes + + ### Integration Points + 1. TypeSpec model -> tsp:compile -> generated output.ts -> settings mapper -> SQLite + 2. Feature flags lib -> feature-flags-context -> web UI settings component + 3. DI container -> CoastsService factory -> ExecFunction + IStructuredAgentCaller injection + 4. dev-server.ts -> feature flag read -> ICoastsService resolution -> coast CLI operations + 5. Agent system -> IStructuredAgentCaller -> configured agent executor -> Coastfile generation + 6. Shutdown handler -> coastsService.stop() -> coast CLI stop command + + ### New Files + - packages/core/src/application/ports/output/services/coasts-service.interface.ts + - packages/core/src/infrastructure/services/coasts.service.ts + - packages/core/src/infrastructure/persistence/sqlite/migrations/042-add-feature-flag-coasts-dev-server.ts + - tests/unit/infrastructure/services/coasts.service.test.ts + + ### Modified Files + - tsp/domain/entities/settings.tsp + - packages/core/src/domain/generated/output.ts (auto-generated) + - packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts + - packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts + - packages/core/src/infrastructure/di/container.ts + - src/presentation/web/lib/feature-flags.ts + - src/presentation/web/hooks/feature-flags-context.tsx + - src/presentation/web/components/features/settings/feature-flags-settings-section.tsx + - src/presentation/web/dev-server.ts + - package.json (add dev:coasts script) diff --git a/specs/072-coasts-dev-server/spec.yaml b/specs/072-coasts-dev-server/spec.yaml new file mode 100644 index 000000000..c6f4c4ac7 --- /dev/null +++ b/specs/072-coasts-dev-server/spec.yaml @@ -0,0 +1,118 @@ +name: "coasts-dev-server" +number: 72 +branch: "feat/072-coasts-dev-server" +oneLiner: "integrate coasts.dev runtime isolation for shep dev servers behind a feature flag" +summary: "Integrate the Coasts (coasts.dev) containerized runtime system as an alternative dev server mode for Shep. When users run `shep ui` or the dev server for any repository managed by Shep, Coasts provides per-worktree runtime isolation via Docker containers, automatic port management, and service orchestration — enabling parallel feature development across multiple worktrees without port conflicts or shared state. A feature flag toggles between the current bare Next.js dev server and the Coasts-managed mode. The feature adds a new infrastructure service wrapping the coast CLI and branching logic in the dev server entrypoint. Each repo/worktree gets its own isolated dev server instance through Coasts. When a target repo lacks a Coastfile, Shep auto-generates one by running the `coast installation-prompt` CLI command to obtain a comprehensive prompt, then passing that prompt along with the repo context to the AI agent system for intelligent Coastfile generation.\n" +phase: "Requirements" +sizeEstimate: "L" +relatedFeatures: [] +technologies: + - "Coasts CLI/daemon (Rust binary, Unix socket protocol, HTTP API on port 31415)" + - "Docker / Docker-in-Docker (DinD) runtime" + - "Coastfile (TOML configuration format)" + - "TypeSpec (domain model extension for feature flag)" + - "Next.js 16 programmatic server API" + - "tsyringe dependency injection" + - "SQLite (settings persistence)" + - "socat (port forwarding managed by coastd)" + - "child_process (Node.js subprocess spawning for coast CLI)" + - "AI agent system (IAgentExecutorProvider) for Coastfile generation via coast installation-prompt" +relatedLinks: + - title: "Coasts documentation" + url: "https://coasts.dev/docs" + - title: "Coasts GitHub repository" + url: "https://github.com/coast-guard/coasts" +openQuestions: + - question: "Should the dev server manage the coastd daemon lifecycle (auto-start/stop) or require the user to start it manually?" + resolved: true + options: + - option: "Manual daemon management" + description: "Require the user to run `coastd` before starting the dev server. Simplest implementation — just check the socket exists and fail with a helpful message if not. Avoids complex process management and background daemon ownership issues. Users control when the daemon runs and stops.\n" + selected: false + - option: "Auto-start daemon on dev server launch" + description: "Automatically start coastd if not running when the dev server starts. Adds convenience but introduces complexity: who owns the process? When does it stop? Risk of orphan daemons. Would need health-check retry logic and graceful fallback if the daemon fails to start.\n" + selected: false + - option: "Hybrid — auto-start with user opt-out" + description: "Auto-start by default with a setting to disable it. Maximum convenience but maximum complexity. Adds another setting to manage and test.\n" + selected: true + selectionRationale: "Manual daemon management is recommended because coastd is an external Rust binary with its own lifecycle. Auto-starting it from Node.js introduces process ownership ambiguity, orphan daemon risk, and complex error recovery. A clear prerequisite check with a helpful error message is the simplest, most reliable approach. Power users who want auto-start can configure their shell profile or use a process manager.\n" + answer: "Hybrid — auto-start with user opt-out" + - question: "How should the dev server behave when Coasts mode is enabled but prerequisites are missing (no coast binary, no Docker, daemon not running)?" + resolved: true + options: + - option: "Fail fast with actionable error" + description: "Check all prerequisites upfront before attempting to start. If any are missing, print a clear error message listing what is missing and how to install/fix it, then exit with a non-zero code. Does not fall back to bare mode. Forces the user to either fix prerequisites or disable the flag.\n" + selected: false + - option: "Graceful fallback to bare mode" + description: "If Coasts prerequisites are missing, log a warning and fall back to the existing bare Next.js dev server. Convenient but dangerous — users may think they are running in isolated mode when they are not. Silent degradation violates the principle of least surprise.\n" + selected: false + - option: "Interactive prompt asking user to choose" + description: "When prerequisites are missing, prompt the user: \"Coasts not available. Start in bare mode instead?\" Good UX but complicates non-interactive environments (CI, scripts, headless). The dev server should be startable without interaction.\n" + selected: true + selectionRationale: "Fail fast with actionable error is recommended because silent fallback would mask configuration issues — a developer thinking they have isolation when they don't could cause data corruption across worktrees. An explicit error with installation instructions is the safest approach. Users who want bare mode can simply disable the feature flag.\n" + answer: "Interactive prompt asking user to choose" + - question: "Should the coastsDevServer feature flag affect only the dev server (pnpm dev:web) or also the production web server (shep ui)?" + resolved: true + options: + - option: "Dev server only (V1)" + description: "Only affect the dev-server.ts entrypoint. The production shep ui command continues using WebServerService as-is. This limits scope, reduces risk, and aligns with the feature name \"coasts-dev-server\". Production Coasts support can be a follow-up feature.\n" + selected: false + - option: "Both dev and production" + description: "Modify both dev-server.ts and WebServerService to support Coasts mode. Provides consistent behavior across modes but doubles the implementation surface and testing burden. Production usage patterns differ significantly from dev.\n" + selected: true + selectionRationale: "Dev server only is recommended for V1 because the problem statement specifically targets parallel development with worktrees — a dev-only workflow. Production mode (shep ui) runs a single built Next.js instance and doesn't face the same port/state conflicts. Keeping scope narrow enables faster delivery and real-world validation before expanding to production.\n" + answer: "Both dev and production" + - question: "Should the coast CLI be invoked via direct subprocess spawning or through the coastd HTTP API?" + resolved: true + options: + - option: "Coast CLI subprocess" + description: "Invoke the `coast` CLI binary via child_process.spawn for each operation (build, run, stop, checkout, lookup). Simpler implementation — works with any coast version, no API version coupling, leverages existing CLI error messages. Downside: subprocess overhead per call and parsing CLI output.\n" + selected: true + - option: "Coastd HTTP API direct" + description: "Communicate directly with the coastd HTTP API on port 31415 or Unix socket. More efficient for frequent operations, structured JSON responses. But couples Shep to coastd API contract, requires understanding the wire protocol, and bypasses any CLI-level validation or user messaging.\n" + selected: false + selectionRationale: "Coast CLI subprocess is recommended because it provides the most stable integration surface. The CLI is the public API of Coasts; the HTTP/socket protocol is internal. CLI invocation handles authentication, configuration resolution, and error formatting that would need to be reimplemented if using the API directly. The subprocess overhead is negligible for the operations involved (build, run, stop — all infrequent).\n" + answer: "Coast CLI subprocess" + - question: "Should the dev-server.ts Coasts code path still bootstrap the DI container and watchers inside the container, or delegate everything to the coast-managed process?" + resolved: true + options: + - option: "Bootstrap DI on host, delegate Next.js to coast" + description: "The host dev-server.ts still initializes the DI container, settings, notification watcher, and PR sync watcher on the host machine. Only the Next.js server itself runs inside the Coasts container. This preserves the existing watcher behavior and settings resolution while isolating the web server port and database.\n" + selected: true + - option: "Delegate everything to coast container" + description: "The entire dev-server.ts process runs inside the Coasts container. Maximum isolation but requires the container to have access to the git repo, GitHub credentials, and all Node.js dependencies. Significantly more complex Coastfile configuration and volume mounting.\n" + selected: false + selectionRationale: "Bootstrap DI on host is recommended because the watchers (notification, PR sync) need direct access to the host git repo and GitHub credentials. Running them inside a container would require complex volume mounts and credential forwarding. The primary isolation benefit is port and database separation, which is achieved by running only Next.js inside the coast container. This hybrid approach minimizes Coastfile complexity while delivering the core isolation value.\n" + answer: "Bootstrap DI on host, delegate Next.js to coast" + - question: "How should Shep handle Coastfile generation when a target repo lacks one?" + resolved: true + options: + - option: "Auto-generate via AI agent using coast installation-prompt" + description: "When no Coastfile exists in the target repo, Shep runs `coast installation-prompt` to obtain the official Coasts prompt (which includes full schema documentation, examples, and instructions for analyzing a project). Shep then passes this prompt to the AI agent system (via IAgentExecutorProvider) along with the repo context, and the agent generates a tailored Coastfile for the project. This leverages the existing agent infrastructure and produces project-specific configuration based on the repo's actual structure (docker-compose.yml, ports, services, worktree layout). The generated Coastfile is written to the repo root and committed. The user can review and edit it afterward.\n" + selected: true + - option: "Require Coastfile in target repo" + description: "Require that the target repository already contains a Coastfile before Coasts mode can activate. If missing, fail with an error message linking to Coasts documentation. The repo owner must manually create the Coastfile. This is the simplest approach but creates friction — users must leave Shep, learn the Coastfile format, write TOML configuration, and return.\n" + selected: false + - option: "Generate a static template Coastfile" + description: "Generate a hardcoded template Coastfile with generic defaults (port 3000, basic setup). Reduces friction but makes dangerous assumptions about the target repo's build system, port requirements, and volume needs. A static template will not work for most repos without manual editing.\n" + selected: false + selectionRationale: "Auto-generating via the AI agent is recommended because the `coast installation-prompt` command provides a comprehensive, version-matched prompt that includes the full Coastfile schema, examples for different project types, and step-by-step instructions for analyzing a project. By feeding this prompt to Shep's existing AI agent system along with the repo's actual structure, the agent can generate a tailored Coastfile that accounts for the project's specific ports, services, docker-compose setup, worktree layout, and dependencies. This eliminates the friction of requiring users to manually learn and write TOML configuration, and it leverages Shep's core capability — AI-powered autonomous development tasks.\n" + answer: "Auto-generate via AI agent using coast installation-prompt" +content: "## Problem Statement\n\nThe current Shep dev server (`dev-server.ts`) runs Next.js directly on the\nhost machine. Shep manages arbitrary repositories — when developers work on\nmultiple features in parallel using git worktrees, each worktree's dev server\ncompetes for the same ports (default 3000) and shares the same SQLite database\nand background services. The current workaround is manual port incrementing\nvia `findAvailablePort()`, but this doesn't isolate databases, services, or\nenvironment state.\n\nCoasts (https://coasts.dev) solves this by providing containerized runtime\nisolation per worktree. Each \"coast\" instance gets its own ports, volumes,\nservices, and network — enabling true parallel development across any repo\nthat Shep manages. This feature adds Coasts as an optional dev server mode\nbehind a feature flag, scoped to the dev server entrypoint only (not\nproduction `shep ui`).\n\nWhen a target repo does not have a Coastfile, Shep auto-generates one by\nrunning `coast installation-prompt` to obtain the official generation prompt,\nthen passing it to the AI agent system to produce a tailored Coastfile based\non the repo's actual structure.\n\n## Success Criteria\n\n- [ ] A `coastsDevServer` boolean feature flag exists in the TypeSpec\nFeatureFlags model and is persisted in SQLite\n- [ ] The feature flag toggle appears in the web UI settings page under\nFeature Flags\n- [ ] The feature flag defaults to `false` (opt-in)\n- [ ] When the flag is `false`, the dev server behaves identically to the\ncurrent implementation (no regression)\n- [ ] When the flag is `true` and all prerequisites are met (coast binary,\nDocker, coastd running), the dev server starts Next.js via `coast run`\ninstead of bare `next()`\n- [ ] When the flag is `true` but prerequisites are missing, the dev server\nexits with a non-zero code and a human-readable error listing missing\nprerequisites and installation instructions\n- [ ] When the flag is `true` and no Coastfile exists in the target repo,\nthe dev server fails with a helpful error listing both generation methods\n(CLI: `shep coasts init`, Web UI: repository node button)\n- [ ] A `shep coasts init` CLI command generates a Coastfile on demand\nand builds the coast container\n- [ ] A \"Generate Coastfile\" button on the repository node in the web UI\ntriggers on-demand Coastfile generation via server action\n- [ ] A `CoastsService` infrastructure service exists with methods for:\n`checkPrerequisites`, `build`, `run`, `stop`, `lookup`, `isRunning`,\nand `generateCoastfile`\n- [ ] The `CoastsService` is registered in the DI container and accessed via\nan `ICoastsService` interface\n- [ ] The prerequisite check validates: coast binary on PATH, Docker running,\ncoastd daemon running\n- [ ] Graceful shutdown in Coasts mode stops the coast instance before exiting\n- [ ] A `pnpm dev:coasts` script exists as a convenience alias for running the\ndev server with the Coasts flag enabled\n- [ ] Unit tests exist for all `CoastsService` methods with subprocess mocking\n- [ ] Integration tests verify the dev-server branching logic for both flag\nstates\n- [ ] All existing tests continue to pass (zero regressions)\n- [ ] Multiple worktrees of the same repo can each run an isolated dev server\nvia Coasts concurrently\n\n## Functional Requirements\n\n- **FR-1: Feature flag definition** — Add a `coastsDevServer: boolean = false`\nfield to the `FeatureFlags` model in `tsp/domain/entities/settings.tsp`. Run\n`pnpm tsp:compile` to regenerate `output.ts`. Add the corresponding SQLite\nmigration column.\n\n- **FR-2: Feature flag resolution** — Add `coastsDevServer` to the\n`FeatureFlagsState` interface in `feature-flags.ts` with DB-primary resolution\nand env-var fallback via `NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER`. Default to\n`false`.\n\n- **FR-3: Feature flag UI** — Add a \"Coasts Dev Server\" toggle to the feature\nflags settings section in the web UI. The toggle must follow the existing\npattern: entry in `FLAG_DESCRIPTIONS`, `FLAG_LABELS`, and `FLAG_KEYS` arrays.\n\n- **FR-4: Feature flag context** — Add `coastsDevServer: false` to the default\nflags in `feature-flags-context.tsx`.\n\n- **FR-5: ICoastsService interface** — Define an `ICoastsService` output port\ninterface in `packages/core/src/application/ports/output/services/` with the\nfollowing methods:\n - `checkPrerequisites(workDir: string): Promise` — checks for coast binary, Docker, running coastd daemon\n - `build(workDir: string): Promise` — runs `coast build` in the given directory\n - `run(workDir: string): Promise` — runs `coast run` and returns instance info (port, URL)\n - `stop(workDir: string): Promise` — stops the coast instance for the given directory\n - `lookup(workDir: string): Promise` — looks up a running instance for this worktree\n - `isRunning(workDir: string): Promise` — checks if an instance is running for this worktree\n - `checkout(workDir: string): Promise` — assigns canonical ports to this worktree's instance\n - `getInstallationPrompt(): Promise` — runs `coast installation-prompt` and returns the full prompt text\n - `generateCoastfile(workDir: string): Promise` — orchestrates Coastfile generation: runs `coast installation-prompt`, passes the prompt + repo context to the AI agent system, writes the generated Coastfile to workDir, returns the file path\n - `hasCoastfile(workDir: string): Promise` — checks if a Coastfile exists in the given directory\n\n- **FR-6: CoastsService implementation** — Implement `CoastsService` in\n`packages/core/src/infrastructure/services/coasts.service.ts`. Each method\ninvokes the `coast` CLI binary via `child_process.spawn` with `workDir` as the\ncwd. Parse JSON output where available, fall back to exit code + stderr for\nerror detection. The `generateCoastfile` method invokes `coast installation-prompt`\nto get the generation prompt, then delegates to the AI agent system\n(via `IAgentExecutorProvider`) to generate a Coastfile tailored to the\ntarget repo.\n\n- **FR-7: DI registration** — Register `CoastsService` as the `ICoastsService`\ntoken in the DI container (`container.ts`).\n\n- **FR-8: Coastfile on-demand generation** — Coastfile generation is triggered\nexplicitly by the user via `shep coasts init` CLI command or the \"Generate\nCoastfile\" button on the repository node in the web UI. The dev server does\nNOT auto-generate Coastfiles. When the Coasts feature flag is enabled and no\nCoastfile exists, the dev server fails with a helpful error message pointing\nthe user to both generation methods (`shep coasts init` and the web UI button).\n\n- **FR-9: Dev server branching logic** — Modify `dev-server.ts` to read the\n`coastsDevServer` feature flag after DI initialization. If enabled:\n 1. Resolve `ICoastsService` from the DI container\n 2. Run `checkPrerequisites(workDir)` — if any fail, log the error and `process.exit(1)`\n 3. Check for Coastfile — if missing, exit with a non-zero code and a human-readable error listing both generation methods (CLI: `shep coasts init`, Web UI: repository node button)\n 4. Run `coast build` if needed (image not built or Coastfile changed)\n 5. Run `coast run` or `coast checkout` to start/activate the instance\n 6. Log the Coasts-managed URL to the console\n 7. If disabled (default), proceed with the existing bare Next.js startup path unchanged\n\n- **FR-10: Graceful shutdown in Coasts mode** — The `shutdown` handler in\ndev-server.ts must detect Coasts mode and call `coastsService.stop()` before\nexiting. The 2-second force-exit timeout must still apply.\n\n- **FR-11: Convenience script** — Add `\"dev:coasts\":\n\"NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER=true pnpm dev:web\"` to `package.json`\nscripts.\n\n- **FR-12: Prerequisite check detail** — The `PrerequisiteCheckResult` type\nmust include:\n - `coastBinary: boolean` — whether `coast` is on PATH\n - `docker: boolean` — whether Docker daemon is reachable\n - `coastdRunning: boolean` — whether coastd Unix socket responds\n - `allMet: boolean` — convenience AND of all checks\n - `missingMessages: string[]` — human-readable list of what's missing with fix instructions\n\n- **FR-13: Installation prompt caching** — The output of\n`coast installation-prompt` should be cached in memory for the duration of the\ndev server process. The prompt content is static per coast version and does\nnot need to be fetched on every invocation.\n\n- **FR-14: CLI command `shep coasts init`** — A `shep coasts init` CLI command\ngenerates a Coastfile for the current working directory. It checks prerequisites,\ngenerates the Coastfile via AI agent using `coast installation-prompt`, and\nbuilds the coast container. Supports `--force` flag to overwrite an existing\nCoastfile without prompting.\n\n- **FR-15: Web UI generate Coastfile button** — A \"Generate Coastfile\" button\nin the repository node (visible when `coastsDevServer` flag is enabled). Uses\na server action that resolves `ICoastsService`, validates the repository path,\ngenerates the Coastfile, and builds the container. Button label changes to\n\"Regenerate Coastfile\" when a Coastfile already exists.\n\n## Non-Functional Requirements\n\n- **NFR-1: Zero regression** — When the `coastsDevServer` flag is `false`\n(default), the dev server must behave identically to the pre-feature\nimplementation. No new imports, checks, or side effects on the default path\nbeyond reading the flag value.\n\n- **NFR-2: Startup latency** — The prerequisite check (FR-12) must complete\nwithin 2 seconds. Each check (coast binary, Docker, coastd) should use a\nshort timeout (500ms for subprocess checks) to avoid blocking startup. The\nCoastfile generation step (FR-8) is excluded from this latency budget as it\nis a one-time operation that involves AI agent execution.\n\n- **NFR-3: Clean Architecture compliance** — The `ICoastsService` interface\nlives in the application layer. The `CoastsService` implementation lives in\nthe infrastructure layer. No direct subprocess calls from the presentation\nlayer. Agent resolution for Coastfile generation flows through\n`IAgentExecutorProvider` per the mandatory agent resolution rule.\n\n- **NFR-4: Error message quality** — All error messages from failed\nprerequisites must include: (a) what is missing, (b) how to install/fix it,\nand (c) a link to relevant documentation. Example: \"coastd daemon is not\nrunning. Start it with: coastd &. See https://coasts.dev/docs/daemon\".\n\n- **NFR-5: Subprocess safety** — All coast CLI invocations must:\n - Use `spawn` (not `exec`) to avoid shell injection\n - Set a timeout (30 seconds for build, 10 seconds for other commands)\n - Capture and log stderr on failure\n - Handle ENOENT (binary not found) gracefully\n\n- **NFR-6: Testability** — `CoastsService` must accept injected dependencies\nfor the subprocess spawner and agent executor, enabling unit tests to mock all\nCLI interactions and agent calls without requiring coast/Docker to be\ninstalled.\n\n- **NFR-7: Idempotent operations** — `coast build` and `coast run` must be\nsafe to call when the image is already built or the instance is already\nrunning. The service must check state before invoking and handle \"already\nrunning\" responses gracefully. Coastfile generation must be idempotent — if a\nCoastfile already exists, skip generation entirely.\n\n- **NFR-8: Platform scope** — V1 targets macOS and Linux only. Windows is\nexplicitly out of scope (Coasts uses Unix sockets and socat). The prerequisite\ncheck should detect Windows and fail with \"Coasts dev server is not supported\non Windows\".\n\n- **NFR-9: Feature flag env var** — The environment variable fallback must\nfollow the existing naming convention: `NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER`.\n\n- **NFR-10: Logging consistency** — All Coasts-mode log messages must use the\n`[dev-server:coasts]` prefix to distinguish them from bare-mode `[dev-server]`\nmessages.\n\n- **NFR-11: Multi-worktree concurrency** — The CoastsService must support\nmultiple concurrent coast instances for different worktrees of the same or\ndifferent repos. Each `workDir` parameter scopes operations to that specific\nworktree. No global state or singleton locks that would prevent parallel\nusage.\n\n- **NFR-12: Generated Coastfile quality** — The AI-generated Coastfile must be\nvalid TOML that passes `coast build` without errors. The generation prompt\nfrom `coast installation-prompt` provides the schema and examples; the agent\nmust produce output conforming to that schema. If `coast build` fails after\ngeneration, the error should be surfaced to the user with instructions to\nmanually edit the Coastfile.\n\n## Product Questions & AI Recommendations\n\n| # | Question | AI Recommendation | Rationale |\n| - | -------- | ----------------- | --------- |\n| 1 | Should Shep manage the coastd daemon lifecycle? | Manual management | coastd is an external binary with its own process model; auto-starting introduces orphan risk and ownership ambiguity |\n| 2 | How to handle missing prerequisites? | Fail fast with actionable error | Silent fallback would mask config issues; explicit errors with install instructions are safest |\n| 3 | Should the flag affect production mode too? | Dev server only | Problem is dev-only (worktree parallelism); production doesn't face port/state conflicts |\n| 4 | CLI subprocess or HTTP API? | Coast CLI subprocess | CLI is the public API; HTTP protocol is internal; CLI handles auth, config, error formatting |\n| 5 | Where to bootstrap DI in Coasts mode? | Host-side DI, delegate Next.js to coast | Watchers need host git/GitHub access; container-only approach requires complex volume/credential forwarding |\n| 6 | How should Shep handle missing Coastfiles? | Auto-generate via AI agent using coast installation-prompt | The `coast installation-prompt` command provides a version-matched prompt with full schema, examples, and repo analysis instructions; Shep's AI agent system can generate a tailored Coastfile per repo, eliminating user friction |\n\n## Affected Areas\n\n| Area | Impact | Reasoning |\n| ---- | ------ | --------- |\n| `tsp/domain/entities/settings.tsp` | Medium | Add `coastsDevServer` boolean to FeatureFlags model |\n| `src/presentation/web/lib/feature-flags.ts` | Medium | Add coastsDevServer flag to FeatureFlagsState interface and resolution |\n| `src/presentation/web/hooks/feature-flags-context.tsx` | Low | Add default for new flag |\n| `src/presentation/web/components/features/settings/feature-flags-settings-section.tsx` | Low | Add toggle entry for coastsDevServer flag |\n| `src/presentation/web/dev-server.ts` | High | Branch startup logic: if flag enabled, check Coastfile (generate if missing), invoke coast CLI instead of bare Next.js |\n| `packages/core/src/infrastructure/di/container.ts` | Low | Register CoastsService |\n| `packages/core/src/application/ports/output/services/` | Medium | New ICoastsService interface with coast CLI + Coastfile generation methods |\n| `packages/core/src/infrastructure/services/` | High | New coasts.service.ts — wraps coast CLI (build, run, checkout, stop, lookup) and Coastfile generation via agent system |\n| `package.json` scripts | Low | Add `dev:coasts` script for Coasts-mode dev server |\n| SQLite migrations | Low | FeatureFlags column migration for coastsDevServer |\n| Tests | High | Unit tests for CoastsService (including generateCoastfile), integration tests for flag toggle, dev-server branching |\n\n## Dependencies\n\n- **Coasts binary** (`coast` + `coastd`): Must be installed on the host\nmachine. The daemon must be running. Installation via `curl -fsSL\nhttps://coasts.dev/install | sh`.\n- **Docker**: Required by Coasts for DinD container runtime.\n- **`coast installation-prompt` CLI command**: Used to obtain the official\nCoastfile generation prompt. This command outputs a comprehensive prompt\nincluding full Coastfile schema documentation, examples for various project\ntypes, and step-by-step instructions for analyzing a project's structure\nand generating an appropriate Coastfile.\n- **AI agent system** (`IAgentExecutorProvider`): Used to execute the\nCoastfile generation prompt against the target repo. The agent analyzes\nthe repo's structure (docker-compose.yml, package.json, ports, worktree\nlayout) and produces a tailored Coastfile.\n- **Existing feature flag infrastructure**: TypeSpec model, settings service,\nflag UI components, and context provider are all in place — adding a new flag\nfollows the established pattern.\n- **Existing dev-server.ts**: The branching point where Coasts mode diverges\nfrom bare mode.\n- **child_process module**: Node.js built-in, no external dependency needed\nfor subprocess spawning.\n\n## Size Estimate\n\n**L** — This feature involves:\n\n1. A new TypeSpec field + migration + flag wiring (S — well-patterned)\n2. A new infrastructure service wrapping the coast CLI with 10 methods\nincluding Coastfile generation (M — subprocess management, error handling,\noutput parsing, agent integration)\n3. Dev-server.ts branching logic with prerequisite checks and Coastfile\nauto-generation (M — two code paths, graceful shutdown, agent invocation)\n4. Comprehensive tests for CoastsService and dev-server branching (M —\nsubprocess mocking, agent mocking, flag state permutations)\n5. Six product decisions resolved (captured above, no further blocking\nquestions)\n\nThe well-established feature flag pattern reduces the flag-wiring portion to\nS-sized work, but the CoastsService (especially the Coastfile generation via\nagent system) and dev-server branching logic are non-trivial, pushing the\noverall estimate to L. No changes to WebServerService (production) in V1.\n\n---\n\n_Requirements phase completed — proceed with research to resolve technical\ndetails_\n" +rejectionFeedback: + - iteration: 1 + message: "The idea that any repo we are working on with shep we would be able to start multiple dev server with coasts, so question 2 doesn't make sense" + phase: "requirements" + timestamp: "2026-03-19T18:32:02.603Z" + - iteration: 2 + message: "running \"coast installation-prompt\" returns a prompt on how to generate a coastfile for a project. let use it get the prompt and use this promt to generate the coasts file. update the PRD" + phase: "requirements" + timestamp: "2026-03-19T19:39:15.750Z" + - iteration: 3 + message: "Resolve merge conflicts" + phase: "merge" + timestamp: "2026-03-20T20:27:22.078Z" + - iteration: 4 + message: "Resolve merge conflicts" + phase: "merge" + timestamp: "2026-03-23T15:15:38.100Z" diff --git a/specs/072-coasts-dev-server/tasks.yaml b/specs/072-coasts-dev-server/tasks.yaml new file mode 100644 index 000000000..6c81c7624 --- /dev/null +++ b/specs/072-coasts-dev-server/tasks.yaml @@ -0,0 +1,612 @@ +# Task Breakdown (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: coasts-dev-server +summary: > + 16 tasks across 5 phases implementing Coasts containerized runtime + isolation for Shep's dev server. Covers feature flag wiring (8-step + pattern), ICoastsService port interface definition, CoastsService + infrastructure implementation with coast CLI subprocess management and + AI-powered Coastfile generation, dev-server.ts branching logic with + prerequisite checks and graceful shutdown, and integration testing. + +relatedFeatures: [] + +technologies: + - "Coasts CLI" + - "coastd daemon" + - "Docker" + - "TypeSpec" + - "SQLite / umzug" + - "tsyringe" + - "IStructuredAgentCaller" + - "ExecFunction (child_process)" + - "Next.js 16" + +relatedLinks: + - title: "Coasts documentation" + url: "https://coasts.dev/docs" + +tasks: + # ============================================================ + # PHASE 1: Feature Flag Foundation + # ============================================================ + + - id: task-1 + phaseId: phase-1 + title: "Add coastsDevServer to FeatureFlags TypeSpec model and compile" + description: > + Add coastsDevServer: boolean = false with @doc annotation to the + FeatureFlags model in tsp/domain/entities/settings.tsp. Run pnpm + tsp:compile to regenerate output.ts. Verify the generated FeatureFlags + type includes the new field. + state: Todo + dependencies: [] + acceptanceCriteria: + - "FeatureFlags type in output.ts includes coastsDevServer: boolean" + - "pnpm tsp:compile succeeds without errors" + - "Existing FeatureFlags fields unchanged" + - "Default value is false" + tdd: + red: + - "Write test asserting FeatureFlags type from output.ts has coastsDevServer property" + green: + - "Add coastsDevServer: boolean = false to FeatureFlags model in settings.tsp" + - "Run pnpm tsp:compile to regenerate output.ts" + refactor: + - "Verify @doc annotation matches existing flag documentation style" + estimatedEffort: "20min" + + - id: task-2 + phaseId: phase-1 + title: "Create SQLite migration 042 for coastsDevServer feature flag column" + description: > + Create migration 042-add-feature-flag-coasts-dev-server.ts that adds + feature_flag_coasts_dev_server INTEGER NOT NULL DEFAULT 0 to the + settings table. Follow the idempotent pattern from migration 041 + (check existing columns via pragma before ALTER TABLE). + state: Todo + dependencies: + - task-1 + acceptanceCriteria: + - "Migration file exists at migrations/042-add-feature-flag-coasts-dev-server.ts" + - "Migration adds feature_flag_coasts_dev_server INTEGER NOT NULL DEFAULT 0" + - "Migration is idempotent (safe to run on already-migrated databases)" + - "down() handles SQLite DROP COLUMN limitation" + tdd: + red: + - "Write test that runs migration on fresh DB and asserts column exists" + - "Write test that running migration twice does not throw" + green: + - "Implement migration 042 with ALTER TABLE ADD COLUMN" + - "Add pragma table_info idempotency check" + refactor: + - "Ensure naming convention matches existing migrations exactly" + estimatedEffort: "20min" + + - id: task-3 + phaseId: phase-1 + title: "Extend settings mapper with coastsDevServer mapping" + description: > + Add feature_flag_coasts_dev_server to SettingsRow interface. Update + toDatabase() to map coastsDevServer boolean to 0/1 integer. Update + fromDatabase() to map integer back to boolean. Follow the exact + pattern used by feature_flag_github_import. + state: Todo + dependencies: + - task-1 + acceptanceCriteria: + - "SettingsRow interface includes feature_flag_coasts_dev_server: number" + - "toDatabase() maps featureFlags.coastsDevServer to 0/1" + - "fromDatabase() maps feature_flag_coasts_dev_server === 1 to boolean" + - "Existing mappings unchanged" + tdd: + red: + - "Write test: toDatabase() maps coastsDevServer true to 1" + - "Write test: toDatabase() maps coastsDevServer false/undefined to 0" + - "Write test: fromDatabase() maps 1 back to true" + - "Write test: fromDatabase() maps 0 back to false" + green: + - "Add feature_flag_coasts_dev_server to SettingsRow" + - "Add mapping in toDatabase() and fromDatabase()" + refactor: + - "Verify alphabetical ordering of feature flag fields in SettingsRow" + estimatedEffort: "20min" + + - id: task-4 + phaseId: phase-1 + title: "Add coastsDevServer column to SQLite settings repository SQL" + description: > + Add feature_flag_coasts_dev_server to the INSERT and UPDATE prepared + statements in sqlite-settings.repository.ts. Follow the exact pattern + used by other feature flag columns in the SQL statements. + state: Todo + dependencies: + - task-2 + - task-3 + acceptanceCriteria: + - "INSERT statement includes feature_flag_coasts_dev_server column and @parameter" + - "UPDATE statement includes feature_flag_coasts_dev_server = @parameter" + - "Settings can be initialized and updated with coastsDevServer flag" + tdd: + red: + - "Write test: initialize settings with coastsDevServer=true persists correctly" + - "Write test: update settings toggles coastsDevServer and load returns new value" + green: + - "Add feature_flag_coasts_dev_server to INSERT columns and VALUES" + - "Add feature_flag_coasts_dev_server to UPDATE SET clause" + refactor: + - "Verify column ordering matches SettingsRow interface" + estimatedEffort: "15min" + + - id: task-5 + phaseId: phase-1 + title: "Wire coastsDevServer into feature-flags resolution and context" + description: > + Add coastsDevServer to the FeatureFlagsState interface in + feature-flags.ts with DB-primary resolution and NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER + env var fallback. Add coastsDevServer: false to defaultFlags in + feature-flags-context.tsx. + state: Todo + dependencies: + - task-4 + acceptanceCriteria: + - "FeatureFlagsState interface includes coastsDevServer: boolean" + - "getFeatureFlags() resolves coastsDevServer from settings DB" + - "Env var fallback reads NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER" + - "Default value is false when settings not initialized" + - "defaultFlags in context includes coastsDevServer: false" + tdd: + red: + - "Write test: getFeatureFlags() returns coastsDevServer from settings" + - "Write test: getFeatureFlags() falls back to env var when settings not initialized" + - "Write test: default flags include coastsDevServer: false" + green: + - "Add coastsDevServer to FeatureFlagsState interface" + - "Add coastsDevServer resolution in getFeatureFlags()" + - "Add coastsDevServer: false to defaultFlags" + refactor: + - "Verify env var naming follows NEXT_PUBLIC_FLAG_ convention" + estimatedEffort: "20min" + + - id: task-6 + phaseId: phase-1 + title: "Add Coasts Dev Server toggle to feature flags settings UI" + description: > + Add coastsDevServer entry to FLAG_DESCRIPTIONS, FLAG_LABELS, and + FLAG_KEYS arrays in feature-flags-settings-section.tsx. The toggle + follows the exact existing pattern — no custom UI needed. + state: Todo + dependencies: + - task-5 + acceptanceCriteria: + - "FLAG_DESCRIPTIONS includes coastsDevServer with description of the feature" + - "FLAG_LABELS includes coastsDevServer with label 'Coasts Dev Server'" + - "FLAG_KEYS includes 'coastsDevServer' in the array" + - "Toggle renders in the settings page and persists on click" + tdd: + red: + - "Write test: settings page renders Coasts Dev Server toggle" + - "Write test: toggling switch calls updateSettingsAction" + green: + - "Add coastsDevServer to FLAG_DESCRIPTIONS with feature description" + - "Add coastsDevServer to FLAG_LABELS with 'Coasts Dev Server'" + - "Add 'coastsDevServer' to FLAG_KEYS array" + refactor: + - "Verify description text is concise and matches existing flag descriptions style" + estimatedEffort: "15min" + + # ============================================================ + # PHASE 2: ICoastsService Interface & Types + # ============================================================ + + - id: task-7 + phaseId: phase-2 + title: "Define ICoastsService output port interface with types" + description: > + Create coasts-service.interface.ts in the application ports directory. + Define PrerequisiteCheckResult type (coastBinary, docker, coastdRunning, + allMet, missingMessages), CoastInstance type (port, url), and + ICoastsService interface with all 10 methods: checkPrerequisites, build, + run, stop, lookup, isRunning, checkout, getInstallationPrompt, + generateCoastfile, hasCoastfile. Follow the pattern from + deployment-service.interface.ts. + state: Todo + dependencies: + - task-1 + acceptanceCriteria: + - "ICoastsService interface exported with all 10 method signatures" + - "PrerequisiteCheckResult type has coastBinary, docker, coastdRunning, allMet, missingMessages fields" + - "CoastInstance type has port and url fields" + - "All methods accept workDir: string as first parameter (except getInstallationPrompt)" + - "Return types are properly typed (Promise-based)" + - "File lives in packages/core/src/application/ports/output/services/" + tdd: + red: + - "Write type-level test asserting ICoastsService has checkPrerequisites method" + - "Write type-level test asserting PrerequisiteCheckResult has allMet boolean field" + - "Write type-level test asserting CoastInstance has port and url fields" + green: + - "Create coasts-service.interface.ts with PrerequisiteCheckResult type" + - "Add CoastInstance type" + - "Define ICoastsService interface with all 10 methods" + refactor: + - "Add JSDoc to each method with @param and @returns documentation" + estimatedEffort: "30min" + + # ============================================================ + # PHASE 3: CoastsService Infrastructure Implementation + # ============================================================ + + - id: task-8 + phaseId: phase-3 + title: "Implement CoastsService prerequisite checks" + description: > + Create coasts.service.ts in the infrastructure services directory. + Implement constructor with @inject('ExecFunction') and + @inject('IStructuredAgentCaller') injection. Implement + checkPrerequisites() running three parallel subprocess checks via + Promise.allSettled with 500ms timeouts: coast --version (binary check), + docker info (Docker check), coast ls (daemon check). On Windows, fail + immediately. Return PrerequisiteCheckResult with actionable messages. + state: Todo + dependencies: + - task-7 + acceptanceCriteria: + - "CoastsService class is @injectable() with ExecFunction and IStructuredAgentCaller injection" + - "checkPrerequisites() runs three checks in parallel via Promise.allSettled" + - "Each check has 500ms timeout" + - "coastBinary check uses coast --version; ENOENT means not installed" + - "docker check uses docker info; non-zero exit means Docker not running" + - "coastdRunning check uses coast ls; failure means daemon not running" + - "Windows platform returns allMet: false with platform-not-supported message" + - "missingMessages array includes actionable fix instructions for each failure" + - "allMet is true only when all three checks pass" + tdd: + red: + - "Write test: checkPrerequisites returns allMet true when all checks pass" + - "Write test: coastBinary is false when coast --version throws ENOENT" + - "Write test: docker is false when docker info exits non-zero" + - "Write test: coastdRunning is false when coast ls fails" + - "Write test: missingMessages includes install instructions for each missing prerequisite" + - "Write test: Windows platform returns allMet false immediately" + - "Write test: all three checks run in parallel (not sequentially)" + green: + - "Create CoastsService class with constructor injection" + - "Implement checkPrerequisites with Promise.allSettled and three async checks" + - "Add platform detection for Windows early exit" + - "Build PrerequisiteCheckResult from settled results" + - "Include actionable error messages with install URLs" + refactor: + - "Extract individual check methods (checkCoastBinary, checkDocker, checkCoastd) as private helpers" + - "Ensure error messages match NFR-4 quality requirements" + estimatedEffort: "1h" + + - id: task-9 + phaseId: phase-3 + title: "Implement CoastsService coast CLI operations (build, run, stop, checkout, lookup, isRunning)" + description: > + Implement the six coast CLI operation methods in CoastsService. Each + method invokes the coast binary via ExecFunction with workDir as cwd. + build() has 30-second timeout, others have 10-second timeout. run() + returns CoastInstance with port and URL parsed from stdout. lookup() + returns CoastInstance or null. isRunning() returns boolean. stop() and + checkout() return void. All methods handle ENOENT, non-zero exit codes, + and stderr logging. + state: Todo + dependencies: + - task-8 + acceptanceCriteria: + - "build(workDir) runs coast build with 30-second timeout" + - "run(workDir) runs coast run and returns CoastInstance with port and url" + - "stop(workDir) runs coast stop" + - "checkout(workDir) runs coast checkout" + - "lookup(workDir) runs coast lookup and returns CoastInstance or null" + - "isRunning(workDir) returns true when lookup succeeds, false otherwise" + - "All methods use workDir as cwd for execFile" + - "Non-zero exit codes throw with stderr content in error message" + - "ENOENT errors throw with installation instructions" + - "build() handles 'already built' gracefully (NFR-7)" + - "run() handles 'already running' gracefully (NFR-7)" + tdd: + red: + - "Write test: build() calls execFile with coast build args and workDir cwd" + - "Write test: build() uses 30-second timeout" + - "Write test: run() parses port and url from stdout" + - "Write test: run() returns CoastInstance on success" + - "Write test: stop() calls execFile with coast stop args" + - "Write test: checkout() calls execFile with coast checkout args" + - "Write test: lookup() returns CoastInstance when instance exists" + - "Write test: lookup() returns null when no instance found" + - "Write test: isRunning() returns true when lookup succeeds" + - "Write test: isRunning() returns false when lookup fails" + - "Write test: all methods throw with stderr on non-zero exit" + green: + - "Implement build() with execFile('coast', ['build'], { cwd: workDir, timeout: 30000 })" + - "Implement run() with stdout parsing for port/url" + - "Implement stop(), checkout() as simple execFile calls" + - "Implement lookup() with try/catch returning null on failure" + - "Implement isRunning() delegating to lookup()" + refactor: + - "Extract common execFile wrapper with error handling and logging prefix" + - "Use [dev-server:coasts] log prefix for all messages (NFR-10)" + estimatedEffort: "1.5h" + + - id: task-10 + phaseId: phase-3 + title: "Implement CoastsService Coastfile detection and generation" + description: > + Implement hasCoastfile(), getInstallationPrompt(), and + generateCoastfile() in CoastsService. hasCoastfile() checks for + Coastfile at workDir root via fs.existsSync. getInstallationPrompt() + runs coast installation-prompt and caches the result (FR-13). + generateCoastfile() orchestrates the full flow: get installation prompt, + pass to IStructuredAgentCaller with JSON schema (content: string, + warnings: string[]), write generated TOML to workDir/Coastfile, return + file path. Follow MetadataGenerator pattern for structured agent calls. + state: Todo + dependencies: + - task-9 + acceptanceCriteria: + - "hasCoastfile(workDir) returns true when Coastfile exists at workDir root" + - "hasCoastfile(workDir) returns false when no Coastfile exists" + - "getInstallationPrompt() runs coast installation-prompt and returns full stdout" + - "getInstallationPrompt() caches result on first call (FR-13)" + - "getInstallationPrompt() returns cached value on subsequent calls without subprocess" + - "generateCoastfile(workDir) calls getInstallationPrompt() for the prompt text" + - "generateCoastfile(workDir) passes prompt to IStructuredAgentCaller with JSON schema" + - "generateCoastfile(workDir) writes generated TOML to workDir/Coastfile" + - "generateCoastfile(workDir) returns the file path" + - "Agent schema defines content (string) and warnings (string[]) fields" + - "Agent call uses allowedTools: [] and silent: true" + tdd: + red: + - "Write test: hasCoastfile returns true when Coastfile exists" + - "Write test: hasCoastfile returns false when Coastfile missing" + - "Write test: getInstallationPrompt runs coast installation-prompt subprocess" + - "Write test: getInstallationPrompt returns cached value on second call" + - "Write test: generateCoastfile calls getInstallationPrompt then structuredCaller" + - "Write test: generateCoastfile writes content to workDir/Coastfile" + - "Write test: generateCoastfile returns the Coastfile path" + - "Write test: agent schema includes content and warnings fields" + green: + - "Implement hasCoastfile with fs.existsSync(path.join(workDir, 'Coastfile'))" + - "Implement getInstallationPrompt with execFile and instance-level cache" + - "Implement generateCoastfile with prompt retrieval, agent call, and file write" + - "Define JSON schema for structured agent output" + refactor: + - "Ensure generated Coastfile path uses path.join for cross-platform safety" + - "Add log messages with [dev-server:coasts] prefix for generation progress" + estimatedEffort: "1.5h" + + - id: task-11 + phaseId: phase-3 + title: "Register CoastsService in DI container" + description: > + Add ICoastsService factory registration to container.ts. The factory + resolves ExecFunction and IStructuredAgentCaller from the container and + passes them to the CoastsService constructor. Follow the existing + factory pattern used by IAgentExecutorProvider registration. + state: Todo + dependencies: + - task-10 + acceptanceCriteria: + - "ICoastsService registered with useFactory in container.ts" + - "Factory resolves ExecFunction and IStructuredAgentCaller" + - "container.resolve('ICoastsService') returns CoastsService instance" + - "Registration placed near other service registrations" + tdd: + red: + - "Write test: container resolves ICoastsService without error" + - "Write test: resolved service has checkPrerequisites method" + green: + - "Add factory registration for ICoastsService in container.ts" + - "Import CoastsService and ICoastsService types" + refactor: + - "Group registration with related infrastructure service registrations" + estimatedEffort: "15min" + + # ============================================================ + # PHASE 4: Dev Server Integration & Shutdown + # ============================================================ + + - id: task-12 + phaseId: phase-4 + title: "Add Coasts startup path to dev-server.ts" + description: > + After DI initialization in dev-server.ts, read the coastsDevServer + feature flag from settings. When enabled: (1) resolve ICoastsService + from container, (2) run checkPrerequisites — if any fail, log errors + with [dev-server:coasts] prefix and process.exit(1), (3) check + hasCoastfile — if missing, run generateCoastfile, (4) run coast build, + (5) run coast run or checkout, (6) log the Coasts-managed URL. When + disabled (default), proceed with existing bare Next.js path unchanged. + The branching point is a simple if/else — no strategy pattern. + state: Todo + dependencies: + - task-5 + - task-11 + acceptanceCriteria: + - "Feature flag read after DI initialization" + - "When flag is false, bare Next.js path is completely unchanged (NFR-1)" + - "When flag is true, ICoastsService resolved from container" + - "Prerequisite check runs; on failure, logs actionable errors and exits with code 1" + - "Missing Coastfile triggers generateCoastfile() call" + - "coast build and coast run called in sequence" + - "Coasts-managed URL logged with [dev-server:coasts] prefix" + - "No new imports/side effects on the default (flag=false) path" + tdd: + red: + - "Write test: flag disabled — bare Next.js path executed, no ICoastsService resolved" + - "Write test: flag enabled, prerequisites met — coast build and run called" + - "Write test: flag enabled, prerequisites missing — process exits with code 1" + - "Write test: flag enabled, no Coastfile — generateCoastfile called before build" + - "Write test: flag enabled, Coastfile exists — generateCoastfile not called" + green: + - "Add feature flag read after DI init" + - "Add if/else branching for Coasts vs bare path" + - "Implement Coasts startup sequence: prerequisites -> coastfile -> build -> run" + - "Add error logging with [dev-server:coasts] prefix" + refactor: + - "Extract Coasts startup into a named async function for readability" + - "Ensure no changes leak into the bare path" + estimatedEffort: "1.5h" + + - id: task-13 + phaseId: phase-4 + title: "Add Coasts-mode graceful shutdown to dev-server.ts" + description: > + Modify the existing shutdown handler in dev-server.ts to detect Coasts + mode and call coastsService.stop(workDir) before the process exits. + Coasts mode is detected by checking if the coastsService reference was + initialized during startup (null check). The 2-second force-exit + timeout still applies. The shutdown handler structure (flag, timer, + stop watchers, close connections) remains unchanged — only the + server stop step is modified. + state: Todo + dependencies: + - task-12 + acceptanceCriteria: + - "Shutdown handler detects Coasts mode via coastsService null check" + - "In Coasts mode, coastsService.stop(workDir) called during shutdown" + - "2-second force-exit timeout still applies" + - "In bare mode, shutdown behavior is completely unchanged" + - "coastsService.stop() errors are caught and logged (no crash on shutdown)" + tdd: + red: + - "Write test: shutdown in Coasts mode calls coastsService.stop()" + - "Write test: shutdown in bare mode does not call coastsService.stop()" + - "Write test: coastsService.stop() error is caught and logged, does not prevent exit" + - "Write test: 2-second force-exit timeout still applies in Coasts mode" + green: + - "Add coastsService variable at module level (null by default)" + - "Set coastsService during Coasts startup path" + - "Add coastsService.stop() call in shutdown handler with null check" + - "Wrap stop() in try-catch to prevent shutdown failures" + refactor: + - "Ensure shutdown log messages use [dev-server:coasts] prefix" + estimatedEffort: "45min" + + - id: task-14 + phaseId: phase-4 + title: "Add dev:coasts convenience script to package.json" + description: > + Add a dev:coasts script to the root package.json that sets the + NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER=true environment variable and runs + pnpm dev:web. This provides a one-command way to start the dev server + in Coasts mode. + state: Todo + dependencies: + - task-12 + acceptanceCriteria: + - "package.json scripts includes dev:coasts" + - "Script sets NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER=true" + - "Script delegates to pnpm dev:web" + - "Running pnpm dev:coasts starts dev server with Coasts flag enabled" + tdd: null + estimatedEffort: "5min" + + # ============================================================ + # PHASE 5: Integration Testing & Validation + # ============================================================ + + - id: task-15 + phaseId: phase-5 + title: "Write integration tests for dev-server Coasts branching" + description: > + Write integration tests that verify the dev-server branching logic + for both flag states. Test with mocked ICoastsService to verify the + correct sequence of calls. Test prerequisite failure scenarios. Test + Coastfile generation flow when Coastfile is missing. Verify the bare + path is completely unchanged when flag is disabled. + state: Todo + dependencies: + - task-13 + acceptanceCriteria: + - "Test: flag disabled — ICoastsService never resolved, bare Next.js starts" + - "Test: flag enabled, all prerequisites met, Coastfile exists — build and run called" + - "Test: flag enabled, prerequisites missing — exits with code 1 and error messages" + - "Test: flag enabled, no Coastfile — generateCoastfile called before build" + - "Test: shutdown in Coasts mode calls stop()" + - "Test: shutdown in bare mode does not call stop()" + - "All tests mock ExecFunction and IStructuredAgentCaller (no real coast/Docker needed)" + tdd: + red: + - "Write integration test: flag off preserves bare Next.js startup" + - "Write integration test: flag on with valid prerequisites runs full Coasts flow" + - "Write integration test: flag on with missing coast binary exits with error" + - "Write integration test: flag on with missing Coastfile triggers generation" + - "Write integration test: Coasts mode shutdown calls stop()" + green: + - "Set up test fixture with mocked container, feature flags, and ICoastsService" + - "Implement each test case verifying call sequences and exit behavior" + refactor: + - "Extract shared test setup into beforeEach fixture" + estimatedEffort: "2h" + + - id: task-16 + phaseId: phase-5 + title: "Run full validation suite and fix any failures" + description: > + Run pnpm validate (lint + format + typecheck + tsp) and pnpm test + (all unit + integration tests). Fix any failures. Verify that all + existing tests continue to pass with zero regressions. Confirm the + feature flag persists correctly through the full stack (TypeSpec -> + migration -> mapper -> repository -> resolution -> context -> UI). + state: Todo + dependencies: + - task-15 + acceptanceCriteria: + - "pnpm validate passes (lint, format, typecheck, tsp all green)" + - "pnpm test passes (all unit + integration tests green)" + - "Zero regressions in existing tests" + - "Feature flag round-trips correctly: set via UI -> persisted in DB -> read in dev-server" + - "CoastsService unit tests all pass with mocked dependencies" + tdd: + red: + - "Run pnpm validate and note any failures" + - "Run pnpm test and note any failures" + green: + - "Fix all lint, format, typecheck, and tsp errors" + - "Fix all test failures" + refactor: + - "Final cleanup of any code style inconsistencies" + estimatedEffort: "1h" + +totalEstimate: "12h" + +openQuestions: [] + +content: | + ## Summary + + This task breakdown covers 16 tasks across 5 phases, implementing Coasts + containerized runtime isolation for Shep's dev server behind a feature flag. + + The work begins with the feature flag foundation (Phase 1) — wiring + coastsDevServer through the established 8-step pattern: TypeSpec model, + SQLite migration, settings mapper, settings repository, feature-flags + resolution, React context, and UI toggle. This is well-patterned S-sized + work that produces a visible UI toggle early. + + Next, the ICoastsService interface is defined in the application layer + (Phase 2), establishing the contract with all 10 method signatures and + the PrerequisiteCheckResult/CoastInstance types before any infrastructure + work begins. + + The infrastructure phase (Phase 3) is the heaviest — implementing + CoastsService with parallel prerequisite checks, six coast CLI operation + methods, installation prompt caching, and AI-powered Coastfile generation + via IStructuredAgentCaller. Each method is independently unit-testable + with mocked ExecFunction. DI container registration follows factory pattern. + + Phase 4 integrates everything into dev-server.ts with a single branching + point after DI initialization. The Coasts path runs prerequisites, detects + or generates a Coastfile, builds and runs via coast CLI. The shutdown + handler gains coastsService.stop() for cleanup. The bare Next.js path + remains completely unchanged per NFR-1. A dev:coasts convenience script + is added to package.json. + + Finally, integration tests (Phase 5) verify the full branching logic for + both flag states, prerequisite failures, and Coastfile generation flow. + The validation suite confirms zero regressions across the entire codebase. diff --git a/src/presentation/cli/commands/coasts/index.ts b/src/presentation/cli/commands/coasts/index.ts new file mode 100644 index 000000000..61046af33 --- /dev/null +++ b/src/presentation/cli/commands/coasts/index.ts @@ -0,0 +1,8 @@ +import { Command } from 'commander'; +import { createInitCommand } from './init.command.js'; + +export function createCoastsCommand(): Command { + return new Command('coasts') + .description('Manage Coasts containerized runtime') + .addCommand(createInitCommand()); +} diff --git a/src/presentation/cli/commands/coasts/init.command.ts b/src/presentation/cli/commands/coasts/init.command.ts new file mode 100644 index 000000000..746a26d35 --- /dev/null +++ b/src/presentation/cli/commands/coasts/init.command.ts @@ -0,0 +1,52 @@ +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import type { ICoastsService } from '@/application/ports/output/services/coasts-service.interface.js'; +import { messages, spinner } from '../../ui/index.js'; + +interface InitOptions { + force?: boolean; +} + +export function createInitCommand(): Command { + return new Command('init') + .description('Generate a Coastfile for the current repository') + .option('-f, --force', 'Overwrite existing Coastfile without prompting') + .action(async (options: InitOptions) => { + const workDir = process.cwd(); + + try { + const coastsService = container.resolve('ICoastsService'); + + // Check if Coastfile already exists + const exists = await coastsService.hasCoastfile(workDir); + if (exists && !options.force) { + messages.warning('Coastfile already exists. Use --force to regenerate.'); + return; + } + + // Check prerequisites + const prereqs = await coastsService.checkPrerequisites(workDir); + if (!prereqs.allMet) { + for (const msg of prereqs.missingMessages) { + messages.error(msg); + } + process.exitCode = 1; + return; + } + + // Generate Coastfile + const coastfilePath = await spinner('Generating Coastfile via AI agent...', () => + coastsService.generateCoastfile(workDir) + ); + messages.success(`Coastfile generated at ${coastfilePath}`); + + // Build container + await spinner('Building coast container...', () => coastsService.build(workDir)); + messages.success('Coast container built successfully.'); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error('Failed to initialize Coasts', err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/index.ts b/src/presentation/cli/index.ts index 551d573b5..d82db40e3 100644 --- a/src/presentation/cli/index.ts +++ b/src/presentation/cli/index.ts @@ -46,6 +46,7 @@ import { createIdeOpenCommand } from './commands/ide-open.command.js'; import { createInstallCommand } from './commands/install.command.js'; import { createUpgradeCommand } from './commands/upgrade.command.js'; import { createToolsCommand } from './commands/tools.command.js'; +import { createCoastsCommand } from './commands/coasts/index.js'; import { messages } from './ui/index.js'; // Daemon lifecycle commands @@ -119,6 +120,7 @@ async function bootstrap() { program.addCommand(createInstallCommand()); program.addCommand(createToolsCommand()); program.addCommand(createUpgradeCommand()); + program.addCommand(createCoastsCommand()); // Daemon lifecycle commands (task-9) program.addCommand(createStartCommand()); diff --git a/src/presentation/web/app/actions/check-coastfile.ts b/src/presentation/web/app/actions/check-coastfile.ts new file mode 100644 index 000000000..0a1e5ab96 --- /dev/null +++ b/src/presentation/web/app/actions/check-coastfile.ts @@ -0,0 +1,23 @@ +'use server'; + +import path from 'node:path'; +import { resolve } from '@/lib/server-container'; +import type { ICoastsService } from '@shepai/core/application/ports/output/services/coasts-service.interface'; + +export interface CheckCoastfileResult { + exists: boolean; +} + +export async function checkCoastfileAction(repositoryPath: string): Promise { + if (!repositoryPath || !path.isAbsolute(repositoryPath)) { + return { exists: false }; + } + + try { + const coastsService = resolve('ICoastsService'); + const exists = await coastsService.hasCoastfile(repositoryPath); + return { exists }; + } catch { + return { exists: false }; + } +} diff --git a/src/presentation/web/app/actions/generate-coastfile.ts b/src/presentation/web/app/actions/generate-coastfile.ts new file mode 100644 index 000000000..2004da3b0 --- /dev/null +++ b/src/presentation/web/app/actions/generate-coastfile.ts @@ -0,0 +1,34 @@ +'use server'; + +import path from 'node:path'; +import { existsSync } from 'node:fs'; +import { resolve } from '@/lib/server-container'; +import type { ICoastsService } from '@shepai/core/application/ports/output/services/coasts-service.interface'; + +export interface GenerateCoastfileResult { + success: boolean; + coastfilePath?: string; + error?: string; +} + +export async function generateCoastfileAction( + repositoryPath: string +): Promise { + if (!repositoryPath || !path.isAbsolute(repositoryPath)) { + return { success: false, error: 'repositoryPath must be an absolute path' }; + } + + if (!existsSync(repositoryPath)) { + return { success: false, error: `Directory does not exist: ${repositoryPath}` }; + } + + try { + const coastsService = resolve('ICoastsService'); + const coastfilePath = await coastsService.generateCoastfile(repositoryPath); + await coastsService.build(repositoryPath); + return { success: true, coastfilePath }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to generate Coastfile'; + return { success: false, error: message }; + } +} diff --git a/src/presentation/web/coasts-dev-server.ts b/src/presentation/web/coasts-dev-server.ts new file mode 100644 index 000000000..13da9cbbb --- /dev/null +++ b/src/presentation/web/coasts-dev-server.ts @@ -0,0 +1,90 @@ +/** + * Coasts Dev Server Startup & Shutdown + * + * Extracted from dev-server.ts for testability. Provides the Coasts startup + * sequence (prerequisite checks, Coastfile existence check, build, run) and + * graceful shutdown logic. + * + * All log messages use [dev-server:coasts] prefix per NFR-10. + */ + +/* eslint-disable no-console */ + +import type { + ICoastsService, + CoastInstance, +} from '@shepai/core/application/ports/output/services/coasts-service.interface'; + +/** + * Start the dev server in Coasts mode. + * + * Sequence: + * 1. Check prerequisites (coast binary, Docker, coastd daemon) + * 2. Check for Coastfile — fail if missing + * 3. Build the coast container image + * 4. Run the coast instance + * + * @param coastsService - Resolved ICoastsService from the DI container + * @param workDir - Working directory (repo/worktree root) + * @returns The running CoastInstance with port and URL + * @throws Error if prerequisites are not met or any step fails + */ +export async function startCoastsDevServer( + coastsService: ICoastsService, + workDir: string +): Promise { + // Step 1: Check prerequisites + console.log('[dev-server:coasts] Checking prerequisites...'); + const prereqs = await coastsService.checkPrerequisites(workDir); + + if (!prereqs.allMet) { + const messages = prereqs.missingMessages.map((m) => ` - ${m}`).join('\n'); + throw new Error(`[dev-server:coasts] Prerequisites not met:\n${messages}`); + } + console.log('[dev-server:coasts] All prerequisites met.'); + + // Step 2: Check for Coastfile — fail if missing (generate on-demand via CLI or web UI) + const hasCoastfile = await coastsService.hasCoastfile(workDir); + if (!hasCoastfile) { + throw new Error( + `[dev-server:coasts] No Coastfile found in ${workDir} (expected: Coastfile).\n` + + 'Generate one with:\n' + + ' - CLI: shep coasts init\n' + + ' - Web UI: Use the "Generate Coastfile" button on the repository node' + ); + } + + // Step 3: Build the coast container image + console.log('[dev-server:coasts] Building coast container...'); + await coastsService.build(workDir); + console.log('[dev-server:coasts] Build complete.'); + + // Step 4: Run the coast instance + console.log('[dev-server:coasts] Starting coast instance...'); + const instance = await coastsService.run(workDir); + console.log(`[dev-server:coasts] Ready at ${instance.url}`); + + return instance; +} + +/** + * Gracefully shut down the Coasts instance. + * Catches and logs errors to prevent shutdown failures. + * + * @param coastsService - The ICoastsService instance, or null if not in Coasts mode + * @param workDir - Working directory for the coast instance + */ +export async function shutdownCoasts( + coastsService: ICoastsService | null, + workDir: string +): Promise { + if (!coastsService) return; + + try { + console.log('[dev-server:coasts] Stopping coast instance...'); + await coastsService.stop(workDir); + console.log('[dev-server:coasts] Coast instance stopped.'); + } catch (error) { + console.warn('[dev-server:coasts] Failed to stop coast instance:', error); + } +} diff --git a/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx b/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx index 5a8d10efc..69f16c1a3 100644 --- a/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx +++ b/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx @@ -98,6 +98,7 @@ function WithGitOpsTemplate({ data }: { data: RepositoryNodeData }) { adoptBranch: true, gitRebaseSync: true, reactFileManager: true, + coastsDevServer: true, }; return ( diff --git a/src/presentation/web/components/common/repository-node/repository-node.tsx b/src/presentation/web/components/common/repository-node/repository-node.tsx index 4e5de3321..7846bd0d1 100644 --- a/src/presentation/web/components/common/repository-node/repository-node.tsx +++ b/src/presentation/web/components/common/repository-node/repository-node.tsx @@ -17,6 +17,7 @@ import { ArrowDown, User, RotateCcw, + FileCode2, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ActionButton } from '@/components/common/action-button'; @@ -35,6 +36,7 @@ import { useDeployAction } from '@/hooks/use-deploy-action'; import { useFeatureFlags } from '@/hooks/feature-flags-context'; import type { RepositoryNodeData } from './repository-node-config'; import { useRepositoryActions } from './use-repository-actions'; +import { useCoastsActions } from './use-coasts-actions'; import { FeatureSessionsDropdown, type SessionSummary, @@ -63,6 +65,9 @@ export function RepositoryNode({ } : null ); + const coastsActions = useCoastsActions( + data.repositoryPath ? { repositoryPath: data.repositoryPath } : null + ); const isDeploymentActive = deployAction.status === 'Booting' || deployAction.status === 'Ready'; const handleCreateFromSession = useCallback( @@ -459,6 +464,49 @@ export function RepositoryNode({ ) : null} + + {/* Row 5: Coastfile generation — visible when coastsDevServer flag is on */} + {featureFlags.coastsDevServer && data.repositoryPath ? ( +
e.stopPropagation()} + > +
+ + {coastsActions.coastfileExists ? 'Coastfile' : 'No Coastfile'} + + + + + + + + + + {coastsActions.error ?? + (coastsActions.coastfileExists + ? 'Regenerate Coastfile' + : 'Generate Coastfile')} + + + +
+
+ ) : null} {/* Source handle — invisible, for edge connections */} diff --git a/src/presentation/web/components/common/repository-node/use-coasts-actions.ts b/src/presentation/web/components/common/repository-node/use-coasts-actions.ts new file mode 100644 index 000000000..8254986a3 --- /dev/null +++ b/src/presentation/web/components/common/repository-node/use-coasts-actions.ts @@ -0,0 +1,95 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { generateCoastfileAction } from '@/app/actions/generate-coastfile'; +import { checkCoastfileAction } from '@/app/actions/check-coastfile'; + +export interface CoastsActionsInput { + repositoryPath: string; +} + +export interface CoastsActionsState { + coastfileExists: boolean; + generating: boolean; + checkLoading: boolean; + error: string | null; + generateCoastfile: () => Promise; +} + +const ERROR_CLEAR_DELAY = 5000; + +export function useCoastsActions(input: CoastsActionsInput | null): CoastsActionsState { + const repoPath = input?.repositoryPath ?? null; + const [coastfileExists, setCoastfileExists] = useState(false); + const [generating, setGenerating] = useState(false); + const [checkLoading, setCheckLoading] = useState(!!repoPath); + const [error, setError] = useState(null); + const errorTimerRef = useRef | null>(null); + + useEffect(() => { + const ref = errorTimerRef; + return () => { + if (ref.current) clearTimeout(ref.current); + }; + }, []); + + // Check coastfile existence on mount — use repoPath (string) as dep to avoid infinite re-renders + useEffect(() => { + if (!repoPath) return; + + let cancelled = false; + setCheckLoading(true); + + checkCoastfileAction(repoPath) + .then((result) => { + if (!cancelled) { + setCoastfileExists(result.exists); + setCheckLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setCoastfileExists(false); + setCheckLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [repoPath]); + + const handleGenerate = useCallback(async () => { + if (!repoPath || generating) return; + + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + + setGenerating(true); + setError(null); + + try { + const result = await generateCoastfileAction(repoPath); + + if (result.success) { + setCoastfileExists(true); + } else { + setError(result.error ?? 'Failed to generate Coastfile'); + errorTimerRef.current = setTimeout(() => setError(null), ERROR_CLEAR_DELAY); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to generate Coastfile'; + setError(message); + errorTimerRef.current = setTimeout(() => setError(null), ERROR_CLEAR_DELAY); + } finally { + setGenerating(false); + } + }, [repoPath, generating]); + + return { + coastfileExists, + generating, + checkLoading, + error, + generateCoastfile: handleGenerate, + }; +} diff --git a/src/presentation/web/components/features/settings/feature-flags-settings-section.stories.tsx b/src/presentation/web/components/features/settings/feature-flags-settings-section.stories.tsx index dffb78fd1..515718d9b 100644 --- a/src/presentation/web/components/features/settings/feature-flags-settings-section.stories.tsx +++ b/src/presentation/web/components/features/settings/feature-flags-settings-section.stories.tsx @@ -23,6 +23,7 @@ export const Default: Story = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }, }; @@ -37,6 +38,7 @@ export const AllEnabled: Story = { adoptBranch: true, gitRebaseSync: true, reactFileManager: true, + coastsDevServer: true, }, }, }; @@ -51,6 +53,7 @@ export const AllDisabled: Story = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }, }; diff --git a/src/presentation/web/components/features/settings/feature-flags-settings-section.tsx b/src/presentation/web/components/features/settings/feature-flags-settings-section.tsx index 7f9d4e3d6..b0c66814f 100644 --- a/src/presentation/web/components/features/settings/feature-flags-settings-section.tsx +++ b/src/presentation/web/components/features/settings/feature-flags-settings-section.tsx @@ -18,6 +18,8 @@ const FLAG_DESCRIPTIONS: Record = { gitRebaseSync: 'Enable git rebase-on-main and sync-main operations in the web UI', reactFileManager: 'Use the built-in React file manager instead of the native OS folder picker. Also serves as automatic fallback when the native picker is unavailable.', + coastsDevServer: + 'Enable Coasts containerized runtime isolation for the dev server, providing per-worktree isolation via Docker containers', }; const FLAG_LABELS: Record = { @@ -28,6 +30,7 @@ const FLAG_LABELS: Record = { adoptBranch: 'Adopt Branch', gitRebaseSync: 'Git Rebase & Sync', reactFileManager: 'React File Manager', + coastsDevServer: 'Coasts Dev Server', }; const FLAG_KEYS: (keyof FeatureFlags)[] = [ @@ -38,6 +41,7 @@ const FLAG_KEYS: (keyof FeatureFlags)[] = [ 'adoptBranch', 'gitRebaseSync', 'reactFileManager', + 'coastsDevServer', ]; export interface FeatureFlagsSettingsSectionProps { diff --git a/src/presentation/web/components/features/settings/settings-page-client.stories.tsx b/src/presentation/web/components/features/settings/settings-page-client.stories.tsx index 45d16ed71..677b4b5c7 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.stories.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.stories.tsx @@ -41,6 +41,7 @@ export const AllSections: Story = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }, shepHome: '/opt/shep', diff --git a/src/presentation/web/components/features/settings/settings-page-client.tsx b/src/presentation/web/components/features/settings/settings-page-client.tsx index ecd72083a..c6e11a80b 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.tsx @@ -353,6 +353,7 @@ export function SettingsPageClient({ adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; // Agent state @@ -1472,6 +1473,18 @@ export function SettingsPageClient({ save({ featureFlags: newFlags }); }} /> + { + const newFlags = { ...flags, coastsDevServer: v }; + setFlags(newFlags); + save({ featureFlags: newFlags }); + }} + /> Experimental features that are still under development. Enable at your own risk — they diff --git a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.stories.tsx b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.stories.tsx index 1265d529f..b9f342f7e 100644 --- a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.stories.tsx +++ b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.stories.tsx @@ -14,6 +14,7 @@ const defaultFeatureFlags = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; const meta: Meta = { diff --git a/src/presentation/web/dev-server.ts b/src/presentation/web/dev-server.ts index 1fa6b799d..c92eeb546 100644 --- a/src/presentation/web/dev-server.ts +++ b/src/presentation/web/dev-server.ts @@ -36,6 +36,9 @@ import { getPrSyncWatcher, } from '@/infrastructure/services/pr-sync/pr-sync-watcher.service.js'; import { getExistingConnection } from '@/infrastructure/persistence/sqlite/connection.js'; +import { getFeatureFlags } from './lib/feature-flags.js'; +import type { ICoastsService } from '@/application/ports/output/services/coasts-service.interface.js'; +import { startCoastsDevServer, shutdownCoasts } from './coasts-dev-server.js'; const DEFAULT_PORT = 3000; @@ -68,6 +71,10 @@ async function main() { const basePort = process.env.PORT !== undefined ? parseInt(process.env.PORT, 10) : DEFAULT_PORT; const port = await findAvailablePort(basePort); + // Track Coasts mode state for shutdown handler + let coastsService: ICoastsService | null = null; + let coastsWorkDir: string | null = null; + // Step 1: Initialize DI container (database + migrations) // Same as CLI bootstrap (src/presentation/cli/index.ts:52-58) try { @@ -105,7 +112,61 @@ async function main() { console.warn('[dev-server] DI initialization failed — features will be empty:', error); } - // Step 2: Clean up lock file to allow multiple dev instances + // Step 2: Check coastsDevServer feature flag — branch to Coasts mode or bare Next.js + const flags = getFeatureFlags(); + if (flags.coastsDevServer) { + // --- Coasts mode: containerized runtime isolation --- + const workDir = process.cwd(); + try { + const service = container.resolve('ICoastsService'); + const instance = await startCoastsDevServer(service, workDir); + coastsService = service; + coastsWorkDir = workDir; + console.log(`[dev-server:coasts] Dev server running in Coasts mode at ${instance.url}`); + } catch (error) { + console.error('[dev-server:coasts] Failed to start Coasts dev server:', error); + process.exit(1); + } + + // Graceful shutdown for Coasts mode + let isShuttingDown = false; + const shutdown = async () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.log('\n[dev-server:coasts] Shutting down...'); + const forceExit = setTimeout(() => process.exit(0), 2000); + try { + await shutdownCoasts(coastsService, coastsWorkDir!); + try { + const deploymentService = container.resolve('IDeploymentService'); + deploymentService.stopAll(); + } catch { + /* not initialized */ + } + try { + getNotificationWatcher().stop(); + } catch { + /* not initialized */ + } + try { + getPrSyncWatcher().stop(); + } catch { + /* not initialized */ + } + } finally { + clearTimeout(forceExit); + process.exit(0); + } + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + return; + } + + // --- Bare mode: standard Next.js dev server (default, unchanged) --- + + // Clean up lock file to allow multiple dev instances const lockPath = path.join(import.meta.dirname, '.next', 'dev', 'lock'); try { fs.rmSync(lockPath, { force: true }); diff --git a/src/presentation/web/hooks/feature-flags-context.tsx b/src/presentation/web/hooks/feature-flags-context.tsx index 71793c7fe..44e7ce625 100644 --- a/src/presentation/web/hooks/feature-flags-context.tsx +++ b/src/presentation/web/hooks/feature-flags-context.tsx @@ -11,6 +11,7 @@ const defaultFlags: FeatureFlagsState = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; const FeatureFlagsContext = createContext(defaultFlags); diff --git a/src/presentation/web/lib/feature-flags.ts b/src/presentation/web/lib/feature-flags.ts index d22b131e3..7c3173455 100644 --- a/src/presentation/web/lib/feature-flags.ts +++ b/src/presentation/web/lib/feature-flags.ts @@ -20,6 +20,7 @@ export interface FeatureFlagsState { adoptBranch: boolean; gitRebaseSync: boolean; reactFileManager: boolean; + coastsDevServer: boolean; } export function getFeatureFlags(): FeatureFlagsState { @@ -35,6 +36,7 @@ export function getFeatureFlags(): FeatureFlagsState { adoptBranch: flags.adoptBranch, gitRebaseSync: flags.gitRebaseSync, reactFileManager: flags.reactFileManager, + coastsDevServer: flags.coastsDevServer, }; } } @@ -53,6 +55,7 @@ export function getFeatureFlags(): FeatureFlagsState { adoptBranch: false, gitRebaseSync: false, reactFileManager: isEnabled(process.env.NEXT_PUBLIC_FLAG_REACT_FILE_MANAGER), + coastsDevServer: isEnabled(process.env.NEXT_PUBLIC_FLAG_COASTS_DEV_SERVER), }; } @@ -82,4 +85,7 @@ export const featureFlags = { get reactFileManager() { return getFeatureFlags().reactFileManager; }, + get coastsDevServer() { + return getFeatureFlags().coastsDevServer; + }, } as const; diff --git a/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts b/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts index 5361b1b81..984055451 100644 --- a/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts +++ b/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts @@ -511,6 +511,7 @@ describe('SQLiteSettingsRepository', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; await repository.initialize(settings); @@ -524,6 +525,7 @@ describe('SQLiteSettingsRepository', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }); }); @@ -541,6 +543,7 @@ describe('SQLiteSettingsRepository', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }); }); @@ -556,6 +559,7 @@ describe('SQLiteSettingsRepository', () => { adoptBranch: true, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; settings.updatedAt = new Date('2025-01-02T00:00:00Z'); await repository.update(settings); @@ -569,6 +573,7 @@ describe('SQLiteSettingsRepository', () => { adoptBranch: true, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }); }); @@ -582,13 +587,14 @@ describe('SQLiteSettingsRepository', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; await repository.initialize(settings); const row = db .prepare( - 'SELECT feature_flag_skills, feature_flag_env_deploy, feature_flag_debug, feature_flag_github_import, feature_flag_adopt_branch, feature_flag_git_rebase_sync, feature_flag_react_file_manager FROM settings WHERE id = ?' + 'SELECT feature_flag_skills, feature_flag_env_deploy, feature_flag_debug, feature_flag_github_import, feature_flag_adopt_branch, feature_flag_git_rebase_sync, feature_flag_react_file_manager, feature_flag_coasts_dev_server FROM settings WHERE id = ?' ) .get('singleton') as Record; expect(row.feature_flag_skills).toBe(1); @@ -598,6 +604,7 @@ describe('SQLiteSettingsRepository', () => { expect(row.feature_flag_adopt_branch).toBe(0); expect(row.feature_flag_git_rebase_sync).toBe(0); expect(row.feature_flag_react_file_manager).toBe(0); + expect(row.feature_flag_coasts_dev_server).toBe(0); }); }); diff --git a/tests/unit/application/ports/output/services/coasts-service.interface.test.ts b/tests/unit/application/ports/output/services/coasts-service.interface.test.ts new file mode 100644 index 000000000..3ac361100 --- /dev/null +++ b/tests/unit/application/ports/output/services/coasts-service.interface.test.ts @@ -0,0 +1,108 @@ +/** + * ICoastsService Interface Type-Level Tests + * + * Validates that the interface and supporting types are correctly defined + * with the expected shape and method signatures. + * + * TDD Phase: GREEN + */ + +import { describe, it, expectTypeOf } from 'vitest'; +import type { + ICoastsService, + PrerequisiteCheckResult, + CoastInstance, +} from '@/application/ports/output/services/coasts-service.interface.js'; + +describe('PrerequisiteCheckResult', () => { + it('has coastBinary boolean field', () => { + expectTypeOf().toHaveProperty('coastBinary'); + expectTypeOf().toBeBoolean(); + }); + + it('has docker boolean field', () => { + expectTypeOf().toHaveProperty('docker'); + expectTypeOf().toBeBoolean(); + }); + + it('has coastdRunning boolean field', () => { + expectTypeOf().toHaveProperty('coastdRunning'); + expectTypeOf().toBeBoolean(); + }); + + it('has allMet boolean field', () => { + expectTypeOf().toHaveProperty('allMet'); + expectTypeOf().toBeBoolean(); + }); + + it('has missingMessages string array field', () => { + expectTypeOf().toHaveProperty('missingMessages'); + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('CoastInstance', () => { + it('has port number field', () => { + expectTypeOf().toHaveProperty('port'); + expectTypeOf().toBeNumber(); + }); + + it('has url string field', () => { + expectTypeOf().toHaveProperty('url'); + expectTypeOf().toBeString(); + }); +}); + +describe('ICoastsService', () => { + it('has checkPrerequisites method accepting workDir and returning PrerequisiteCheckResult', () => { + expectTypeOf().toEqualTypeOf< + (workDir: string) => Promise + >(); + }); + + it('has build method accepting workDir and returning void', () => { + expectTypeOf().toEqualTypeOf<(workDir: string) => Promise>(); + }); + + it('has run method accepting workDir and returning CoastInstance', () => { + expectTypeOf().toEqualTypeOf< + (workDir: string) => Promise + >(); + }); + + it('has stop method accepting workDir and returning void', () => { + expectTypeOf().toEqualTypeOf<(workDir: string) => Promise>(); + }); + + it('has lookup method accepting workDir and returning CoastInstance or null', () => { + expectTypeOf().toEqualTypeOf< + (workDir: string) => Promise + >(); + }); + + it('has isRunning method accepting workDir and returning boolean', () => { + expectTypeOf().toEqualTypeOf< + (workDir: string) => Promise + >(); + }); + + it('has checkout method accepting workDir and returning void', () => { + expectTypeOf().toEqualTypeOf<(workDir: string) => Promise>(); + }); + + it('has getInstallationPrompt method with no parameters returning string', () => { + expectTypeOf().toEqualTypeOf<() => Promise>(); + }); + + it('has generateCoastfile method accepting workDir and returning string path', () => { + expectTypeOf().toEqualTypeOf< + (workDir: string) => Promise + >(); + }); + + it('has hasCoastfile method accepting workDir and returning boolean', () => { + expectTypeOf().toEqualTypeOf< + (workDir: string) => Promise + >(); + }); +}); diff --git a/tests/unit/domain/factories/settings-defaults.factory.test.ts b/tests/unit/domain/factories/settings-defaults.factory.test.ts index 214581fc0..fc871fd7b 100644 --- a/tests/unit/domain/factories/settings-defaults.factory.test.ts +++ b/tests/unit/domain/factories/settings-defaults.factory.test.ts @@ -319,6 +319,7 @@ describe('createDefaultSettings', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }); }); }); diff --git a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts index cb4c485b4..a0b281ab0 100644 --- a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts +++ b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts @@ -149,6 +149,7 @@ function createTestRow(overrides: Partial = {}): SettingsRow { feature_flag_adopt_branch: 0, feature_flag_git_rebase_sync: 0, feature_flag_react_file_manager: 0, + feature_flag_coasts_dev_server: 0, ...overrides, }; } diff --git a/tests/unit/infrastructure/services/coasts.service.test.ts b/tests/unit/infrastructure/services/coasts.service.test.ts new file mode 100644 index 000000000..e62e6470d --- /dev/null +++ b/tests/unit/infrastructure/services/coasts.service.test.ts @@ -0,0 +1,494 @@ +/** + * CoastsService Unit Tests + * + * Tests for the Coasts containerized runtime isolation service. + * Uses constructor-injected exec function mock and structured agent caller mock. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockExistsSync = vi.hoisted(() => vi.fn()); +const mockWriteFileSync = vi.hoisted(() => vi.fn()); + +vi.mock('node:fs', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.mock factory requires runtime import() + const actual = (await importOriginal()) as typeof import('node:fs'); + return { ...actual, existsSync: mockExistsSync, writeFileSync: mockWriteFileSync }; +}); + +import { CoastsService } from '@/infrastructure/services/coasts.service.js'; +import type { IStructuredAgentCaller } from '@/application/ports/output/agents/structured-agent-caller.interface.js'; + +type ExecFileFn = ( + cmd: string, + args: string[], + options?: object +) => Promise<{ stdout: string; stderr: string }>; + +describe('CoastsService', () => { + let service: CoastsService; + let mockExecFile: ReturnType>; + let mockStructuredCaller: IStructuredAgentCaller; + const workDir = '/repos/my-project'; + + beforeEach(() => { + vi.clearAllMocks(); + mockExecFile = vi.fn(); + mockStructuredCaller = { + call: vi.fn(), + }; + service = new CoastsService(mockExecFile, mockStructuredCaller, false); + }); + + describe('checkPrerequisites', () => { + it('returns allMet true when all checks pass', async () => { + // coast --version succeeds + mockExecFile.mockResolvedValueOnce({ stdout: 'coast 0.1.0', stderr: '' }); + // docker info succeeds + mockExecFile.mockResolvedValueOnce({ stdout: 'Docker info output', stderr: '' }); + // coast ls succeeds + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + const result = await service.checkPrerequisites(workDir); + + expect(result.coastBinary).toBe(true); + expect(result.docker).toBe(true); + expect(result.coastdRunning).toBe(true); + expect(result.allMet).toBe(true); + expect(result.missingMessages).toHaveLength(0); + }); + + it('coastBinary is false when coast --version throws ENOENT', async () => { + const enoent = new Error('spawn coast ENOENT') as NodeJS.ErrnoException; + enoent.code = 'ENOENT'; + mockExecFile.mockRejectedValueOnce(enoent); + // docker info succeeds + mockExecFile.mockResolvedValueOnce({ stdout: 'ok', stderr: '' }); + // coast ls fails because no binary + mockExecFile.mockRejectedValueOnce(enoent); + + const result = await service.checkPrerequisites(workDir); + + expect(result.coastBinary).toBe(false); + expect(result.allMet).toBe(false); + expect(result.missingMessages).toEqual( + expect.arrayContaining([expect.stringContaining('coast')]) + ); + }); + + it('docker is false when docker info exits non-zero', async () => { + // coast --version succeeds + mockExecFile.mockResolvedValueOnce({ stdout: 'coast 0.1.0', stderr: '' }); + // docker info fails + const dockerError = new Error('Cannot connect to Docker daemon'); + mockExecFile.mockRejectedValueOnce(dockerError); + // coast ls succeeds + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + const result = await service.checkPrerequisites(workDir); + + expect(result.docker).toBe(false); + expect(result.allMet).toBe(false); + expect(result.missingMessages).toEqual( + expect.arrayContaining([expect.stringContaining('Docker')]) + ); + }); + + it('coastdRunning is false when coast ls fails', async () => { + // coast --version succeeds + mockExecFile.mockResolvedValueOnce({ stdout: 'coast 0.1.0', stderr: '' }); + // docker info succeeds + mockExecFile.mockResolvedValueOnce({ stdout: 'ok', stderr: '' }); + // coast ls fails (daemon not running) + mockExecFile.mockRejectedValueOnce(new Error('connection refused')); + + const result = await service.checkPrerequisites(workDir); + + expect(result.coastdRunning).toBe(false); + expect(result.allMet).toBe(false); + expect(result.missingMessages).toEqual( + expect.arrayContaining([expect.stringContaining('coastd')]) + ); + }); + + it('missingMessages includes install instructions for each missing prerequisite', async () => { + const enoent = new Error('spawn coast ENOENT') as NodeJS.ErrnoException; + enoent.code = 'ENOENT'; + // coast binary not found + mockExecFile.mockRejectedValueOnce(enoent); + // docker not running + mockExecFile.mockRejectedValueOnce(new Error('Docker daemon not running')); + // coastd not running (also fails because no binary) + mockExecFile.mockRejectedValueOnce(enoent); + + const result = await service.checkPrerequisites(workDir); + + expect(result.missingMessages).toHaveLength(3); + expect(result.missingMessages[0]).toContain('coasts.dev'); + expect(result.missingMessages[1]).toContain('Docker'); + expect(result.missingMessages[2]).toContain('coastd'); + }); + + it('Windows platform returns allMet false immediately', async () => { + // Create a service that thinks it's on Windows + const winService = new CoastsService(mockExecFile, mockStructuredCaller, true); + + const result = await winService.checkPrerequisites(workDir); + + expect(result.allMet).toBe(false); + expect(result.coastBinary).toBe(false); + expect(result.docker).toBe(false); + expect(result.coastdRunning).toBe(false); + expect(result.missingMessages).toEqual( + expect.arrayContaining([expect.stringContaining('not supported on Windows')]) + ); + // No subprocess calls should have been made + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('all three checks run in parallel (not sequentially)', async () => { + const callOrder: string[] = []; + + mockExecFile.mockImplementation(async (cmd: string, args: string[]) => { + const label = cmd === 'docker' ? 'docker' : `coast-${args[0]}`; + callOrder.push(`start-${label}`); + // Simulate async work + await new Promise((r) => setTimeout(r, 10)); + callOrder.push(`end-${label}`); + return { stdout: 'ok', stderr: '' }; + }); + + await service.checkPrerequisites(workDir); + + // All three starts should happen before any ends (parallel execution) + const startIndices = callOrder + .map((entry, i) => (entry.startsWith('start-') ? i : -1)) + .filter((i) => i >= 0); + const firstEnd = callOrder.findIndex((entry) => entry.startsWith('end-')); + + // All three starts should happen before the first end + expect(startIndices.filter((i) => i < firstEnd)).toHaveLength(3); + }); + }); + + describe('build', () => { + it('calls execFile with coast build args and workDir cwd', async () => { + mockExecFile.mockResolvedValueOnce({ stdout: 'Build complete', stderr: '' }); + + await service.build(workDir); + + expect(mockExecFile).toHaveBeenCalledWith('coast', ['build'], { + cwd: workDir, + timeout: 30000, + }); + }); + + it('uses 30-second timeout', async () => { + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await service.build(workDir); + + expect(mockExecFile).toHaveBeenCalledWith( + 'coast', + ['build'], + expect.objectContaining({ timeout: 30000 }) + ); + }); + + it('throws with stderr on non-zero exit', async () => { + const error = new Error('coast build failed') as Error & { stderr: string }; + error.stderr = 'Error: invalid Coastfile'; + mockExecFile.mockRejectedValueOnce(error); + + await expect(service.build(workDir)).rejects.toThrow(/coast build failed/); + }); + }); + + describe('run', () => { + it('parses port and url from stdout', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'Coast instance running on port 8080\nURL: http://localhost:8080\n', + stderr: '', + }); + + const result = await service.run(workDir); + + expect(result.port).toBe(8080); + expect(result.url).toBe('http://localhost:8080'); + }); + + it('returns CoastInstance on success', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'port 3000\nhttp://localhost:3000', + stderr: '', + }); + + const result = await service.run(workDir); + + expect(result).toHaveProperty('port'); + expect(result).toHaveProperty('url'); + expect(typeof result.port).toBe('number'); + expect(typeof result.url).toBe('string'); + }); + + it('calls execFile with coast run args', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'port 3000\nhttp://localhost:3000', + stderr: '', + }); + + await service.run(workDir); + + expect(mockExecFile).toHaveBeenCalledWith('coast', ['run'], { + cwd: workDir, + timeout: 10000, + }); + }); + + it('throws with stderr on failure', async () => { + const error = new Error('coast run failed') as Error & { stderr: string }; + error.stderr = 'No Coastfile found'; + mockExecFile.mockRejectedValueOnce(error); + + await expect(service.run(workDir)).rejects.toThrow(/coast run failed/); + }); + }); + + describe('stop', () => { + it('calls execFile with coast stop args', async () => { + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await service.stop(workDir); + + expect(mockExecFile).toHaveBeenCalledWith('coast', ['stop'], { + cwd: workDir, + timeout: 10000, + }); + }); + + it('does not throw when no instance is running', async () => { + // coast stop exits successfully even if nothing is running + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await expect(service.stop(workDir)).resolves.toBeUndefined(); + }); + }); + + describe('checkout', () => { + it('calls execFile with coast checkout args', async () => { + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await service.checkout(workDir); + + expect(mockExecFile).toHaveBeenCalledWith('coast', ['checkout'], { + cwd: workDir, + timeout: 10000, + }); + }); + }); + + describe('lookup', () => { + it('returns CoastInstance when instance exists', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'port 4000\nhttp://localhost:4000', + stderr: '', + }); + + const result = await service.lookup(workDir); + + expect(result).not.toBeNull(); + expect(result!.port).toBe(4000); + expect(result!.url).toBe('http://localhost:4000'); + }); + + it('returns null when no instance found', async () => { + mockExecFile.mockRejectedValueOnce(new Error('not found')); + + const result = await service.lookup(workDir); + + expect(result).toBeNull(); + }); + + it('calls execFile with coast lookup args', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'port 3000\nhttp://localhost:3000', + stderr: '', + }); + + await service.lookup(workDir); + + expect(mockExecFile).toHaveBeenCalledWith('coast', ['lookup'], { + cwd: workDir, + timeout: 10000, + }); + }); + }); + + describe('isRunning', () => { + it('returns true when lookup succeeds', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'port 3000\nhttp://localhost:3000', + stderr: '', + }); + + const result = await service.isRunning(workDir); + + expect(result).toBe(true); + }); + + it('returns false when lookup fails', async () => { + mockExecFile.mockRejectedValueOnce(new Error('not found')); + + const result = await service.isRunning(workDir); + + expect(result).toBe(false); + }); + }); + + describe('hasCoastfile', () => { + it('returns true when Coastfile exists', async () => { + mockExistsSync.mockReturnValue(true); + + const result = await service.hasCoastfile(workDir); + + expect(result).toBe(true); + expect(mockExistsSync).toHaveBeenCalledWith(expect.stringContaining('Coastfile')); + }); + + it('returns false when Coastfile missing', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await service.hasCoastfile(workDir); + + expect(result).toBe(false); + }); + }); + + describe('getInstallationPrompt', () => { + it('runs coast installation-prompt subprocess', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'This is the installation prompt text...', + stderr: '', + }); + + const result = await service.getInstallationPrompt(); + + expect(result).toBe('This is the installation prompt text...'); + expect(mockExecFile).toHaveBeenCalledWith( + 'coast', + ['installation-prompt'], + expect.objectContaining({ timeout: 10000 }) + ); + }); + + it('returns cached value on second call', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'Cached prompt text', + stderr: '', + }); + + const first = await service.getInstallationPrompt(); + const second = await service.getInstallationPrompt(); + + expect(first).toBe('Cached prompt text'); + expect(second).toBe('Cached prompt text'); + // Should only be called once — second call uses cache + expect(mockExecFile).toHaveBeenCalledTimes(1); + }); + }); + + describe('generateCoastfile', () => { + it('calls getInstallationPrompt then structuredCaller', async () => { + // Mock getInstallationPrompt subprocess + mockExecFile.mockResolvedValueOnce({ + stdout: 'Generate a Coastfile for this project...', + stderr: '', + }); + + // Mock structuredCaller.call to return TOML content + vi.mocked(mockStructuredCaller.call).mockResolvedValueOnce({ + content: '[project]\nname = "my-project"\n', + warnings: [], + }); + + await service.generateCoastfile(workDir); + + expect(mockStructuredCaller.call).toHaveBeenCalledWith( + expect.stringContaining('Generate a Coastfile'), + expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + content: expect.any(Object), + }), + }), + expect.objectContaining({ + allowedTools: [], + silent: true, + }) + ); + }); + + it('writes content to workDir/Coastfile', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'prompt text', + stderr: '', + }); + + vi.mocked(mockStructuredCaller.call).mockResolvedValueOnce({ + content: '[project]\nname = "test"\n', + warnings: [], + }); + + await service.generateCoastfile(workDir); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining('Coastfile'), + '[project]\nname = "test"\n', + 'utf-8' + ); + }); + + it('returns the Coastfile path', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'prompt text', + stderr: '', + }); + + vi.mocked(mockStructuredCaller.call).mockResolvedValueOnce({ + content: '[project]\nname = "test"\n', + warnings: [], + }); + + const result = await service.generateCoastfile(workDir); + + expect(result).toContain('Coastfile'); + expect(result).toBe(path.join(workDir, 'Coastfile')); + }); + + it('agent schema includes content and warnings fields', async () => { + mockExecFile.mockResolvedValueOnce({ + stdout: 'prompt text', + stderr: '', + }); + + vi.mocked(mockStructuredCaller.call).mockResolvedValueOnce({ + content: '[project]\nname = "test"\n', + warnings: [], + }); + + await service.generateCoastfile(workDir); + + const schemaArg = vi.mocked(mockStructuredCaller.call).mock.calls[0][1] as { + properties: Record; + required: string[]; + }; + expect(schemaArg.properties).toHaveProperty('content'); + expect(schemaArg.properties).toHaveProperty('warnings'); + expect(schemaArg.required).toContain('content'); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/settings-service-update.test.ts b/tests/unit/infrastructure/services/settings-service-update.test.ts index 2c96f3ae9..551e4f03f 100644 --- a/tests/unit/infrastructure/services/settings-service-update.test.ts +++ b/tests/unit/infrastructure/services/settings-service-update.test.ts @@ -76,6 +76,7 @@ describe('updateSettings', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }; updateSettings(updated); diff --git a/tests/unit/presentation/cli/commands/coasts/init.command.test.ts b/tests/unit/presentation/cli/commands/coasts/init.command.test.ts new file mode 100644 index 000000000..ba8ffcb5b --- /dev/null +++ b/tests/unit/presentation/cli/commands/coasts/init.command.test.ts @@ -0,0 +1,120 @@ +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ICoastsService } from '@/application/ports/output/services/coasts-service.interface.js'; +import type { PrerequisiteCheckResult } from '@/application/ports/output/services/coasts-service.interface.js'; + +function createMockCoastsService(): ICoastsService { + return { + checkPrerequisites: vi.fn(), + build: vi.fn(), + run: vi.fn(), + stop: vi.fn(), + lookup: vi.fn(), + isRunning: vi.fn(), + checkout: vi.fn(), + getInstallationPrompt: vi.fn(), + generateCoastfile: vi.fn(), + hasCoastfile: vi.fn(), + }; +} + +function allMet(): PrerequisiteCheckResult { + return { + coastBinary: true, + docker: true, + coastdRunning: true, + allMet: true, + missingMessages: [], + }; +} + +function prerequisitesFailed(messages: string[]): PrerequisiteCheckResult { + return { + coastBinary: false, + docker: false, + coastdRunning: false, + allMet: false, + missingMessages: messages, + }; +} + +// Mock the DI container +const mockContainer = { + resolve: vi.fn(), +}; +vi.mock('@/infrastructure/di/container.js', () => ({ + container: mockContainer, +})); + +// Mock the CLI UI helpers to suppress output +vi.mock('@cli/presentation/cli/ui/index.js', () => ({ + messages: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + newline: vi.fn(), + }, + spinner: vi.fn((_label: string, fn: () => Promise) => fn()), +})); + +// Import the function under test +const { createInitCommand } = await import('@cli/presentation/cli/commands/coasts/init.command.js'); + +describe('shep coasts init', () => { + let mockService: ICoastsService; + + beforeEach(() => { + vi.clearAllMocks(); + mockService = createMockCoastsService(); + mockContainer.resolve.mockReturnValue(mockService); + }); + + it('calls generateCoastfile then build on success', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(false); + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMet()); + vi.mocked(mockService.generateCoastfile).mockResolvedValue('/repo/Coastfile'); + vi.mocked(mockService.build).mockResolvedValue(undefined); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test', '--force']); + + expect(mockService.generateCoastfile).toHaveBeenCalled(); + expect(mockService.build).toHaveBeenCalled(); + }); + + it('exits with error when prerequisites fail', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(false); + vi.mocked(mockService.checkPrerequisites).mockResolvedValue( + prerequisitesFailed(['coast binary not found']) + ); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test', '--force']); + + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('skips generation when Coastfile exists and --force not set', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test']); + + expect(mockService.hasCoastfile).toHaveBeenCalled(); + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('regenerates when Coastfile exists and --force is set', async () => { + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMet()); + vi.mocked(mockService.generateCoastfile).mockResolvedValue('/repo/Coastfile'); + vi.mocked(mockService.build).mockResolvedValue(undefined); + + const cmd = createInitCommand(); + await cmd.parseAsync(['node', 'test', '--force']); + + expect(mockService.generateCoastfile).toHaveBeenCalled(); + expect(mockService.build).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/presentation/dev-server-coasts.test.ts b/tests/unit/presentation/dev-server-coasts.test.ts new file mode 100644 index 000000000..47e00ed5e --- /dev/null +++ b/tests/unit/presentation/dev-server-coasts.test.ts @@ -0,0 +1,195 @@ +/** + * Dev Server Coasts Integration Tests + * + * Tests for the Coasts startup path and graceful shutdown in dev-server.ts. + * Verifies branching logic based on the coastsDevServer feature flag, + * prerequisite checking, Coastfile generation, and shutdown behavior. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { + ICoastsService, + PrerequisiteCheckResult, + CoastInstance, +} from '@/application/ports/output/services/coasts-service.interface.js'; + +/** Creates a mock ICoastsService with all methods stubbed. */ +function createMockCoastsService(): ICoastsService { + return { + checkPrerequisites: vi.fn(), + build: vi.fn(), + run: vi.fn(), + stop: vi.fn(), + lookup: vi.fn(), + isRunning: vi.fn(), + checkout: vi.fn(), + getInstallationPrompt: vi.fn(), + generateCoastfile: vi.fn(), + hasCoastfile: vi.fn(), + }; +} + +function allMetResult(): PrerequisiteCheckResult { + return { + coastBinary: true, + docker: true, + coastdRunning: true, + allMet: true, + missingMessages: [], + }; +} + +function failedResult(messages: string[]): PrerequisiteCheckResult { + return { + coastBinary: false, + docker: false, + coastdRunning: false, + allMet: false, + missingMessages: messages, + }; +} + +function coastInstance(port = 3000): CoastInstance { + return { port, url: `http://localhost:${port}` }; +} + +// Import the function under test — extracted from dev-server.ts for testability +import { startCoastsDevServer, shutdownCoasts } from '@/presentation/web/coasts-dev-server.js'; + +describe('Coasts Dev Server Startup', () => { + let mockService: ICoastsService; + const workDir = '/repos/my-project'; + + beforeEach(() => { + vi.clearAllMocks(); + mockService = createMockCoastsService(); + }); + + describe('startCoastsDevServer', () => { + it('runs prerequisite check as the first step', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.build).mockResolvedValue(undefined); + vi.mocked(mockService.run).mockResolvedValue(coastInstance()); + + await startCoastsDevServer(mockService, workDir); + + expect(mockService.checkPrerequisites).toHaveBeenCalledWith(workDir); + }); + + it('throws when prerequisites are not met', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue( + failedResult(['coast binary not found', 'Docker daemon not reachable']) + ); + + await expect(startCoastsDevServer(mockService, workDir)).rejects.toThrow(/prerequisites/i); + }); + + it('does not call build/run when prerequisites fail', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue( + failedResult(['coast binary not found']) + ); + + await expect(startCoastsDevServer(mockService, workDir)).rejects.toThrow(); + + expect(mockService.build).not.toHaveBeenCalled(); + expect(mockService.run).not.toHaveBeenCalled(); + }); + + it('throws when no Coastfile exists', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(false); + + await expect(startCoastsDevServer(mockService, workDir)).rejects.toThrow( + /no coastfile found/i + ); + + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + expect(mockService.build).not.toHaveBeenCalled(); + }); + + it('proceeds with build and run when Coastfile exists', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.build).mockResolvedValue(undefined); + vi.mocked(mockService.run).mockResolvedValue(coastInstance()); + + await startCoastsDevServer(mockService, workDir); + + expect(mockService.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('calls coast build then coast run in sequence', async () => { + const callOrder: string[] = []; + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.build).mockImplementation(async () => { + callOrder.push('build'); + }); + vi.mocked(mockService.run).mockImplementation(async () => { + callOrder.push('run'); + return coastInstance(); + }); + + await startCoastsDevServer(mockService, workDir); + + expect(callOrder).toEqual(['build', 'run']); + }); + + it('returns the CoastInstance from coast run', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.build).mockResolvedValue(undefined); + vi.mocked(mockService.run).mockResolvedValue(coastInstance(8080)); + + const result = await startCoastsDevServer(mockService, workDir); + + expect(result.port).toBe(8080); + expect(result.url).toBe('http://localhost:8080'); + }); + + it('propagates coast build errors', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.build).mockRejectedValue( + new Error('coast build failed: invalid Coastfile') + ); + + await expect(startCoastsDevServer(mockService, workDir)).rejects.toThrow( + 'coast build failed' + ); + }); + + it('propagates coast run errors', async () => { + vi.mocked(mockService.checkPrerequisites).mockResolvedValue(allMetResult()); + vi.mocked(mockService.hasCoastfile).mockResolvedValue(true); + vi.mocked(mockService.build).mockResolvedValue(undefined); + vi.mocked(mockService.run).mockRejectedValue(new Error('coast run failed')); + + await expect(startCoastsDevServer(mockService, workDir)).rejects.toThrow('coast run failed'); + }); + }); + + describe('shutdownCoasts', () => { + it('calls coastsService.stop() with workDir', async () => { + vi.mocked(mockService.stop).mockResolvedValue(undefined); + + await shutdownCoasts(mockService, workDir); + + expect(mockService.stop).toHaveBeenCalledWith(workDir); + }); + + it('does not throw when coastsService.stop() fails', async () => { + vi.mocked(mockService.stop).mockRejectedValue(new Error('stop failed')); + + await expect(shutdownCoasts(mockService, workDir)).resolves.toBeUndefined(); + }); + + it('does nothing when service is null', async () => { + await expect(shutdownCoasts(null, workDir)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/presentation/web/coasts-dev-server.test.ts b/tests/unit/presentation/web/coasts-dev-server.test.ts new file mode 100644 index 000000000..1cfa14d6c --- /dev/null +++ b/tests/unit/presentation/web/coasts-dev-server.test.ts @@ -0,0 +1,277 @@ +/** + * Coasts Dev Server Startup & Shutdown Tests + * + * Integration tests for the dev-server Coasts branching logic. + * Tests startCoastsDevServer() and shutdownCoasts() with mocked ICoastsService — + * no real coast CLI, Docker, or coastd daemon required. + * + * TDD Phase: RED-GREEN + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ICoastsService } from '@/application/ports/output/services/coasts-service.interface.js'; +import type { + PrerequisiteCheckResult, + CoastInstance, +} from '@/application/ports/output/services/coasts-service.interface.js'; + +// Mock console to capture log output without polluting test runner +const consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(vi.fn()), + error: vi.spyOn(console, 'error').mockImplementation(vi.fn()), + warn: vi.spyOn(console, 'warn').mockImplementation(vi.fn()), +}; + +// Dynamic import to load after mocks are in place +const { startCoastsDevServer, shutdownCoasts } = await import( + '@cli/presentation/web/coasts-dev-server.js' +); + +function createMockCoastsService(overrides: Partial = {}): ICoastsService { + return { + checkPrerequisites: vi.fn(), + build: vi.fn(), + run: vi.fn(), + stop: vi.fn(), + lookup: vi.fn(), + isRunning: vi.fn(), + checkout: vi.fn(), + getInstallationPrompt: vi.fn(), + generateCoastfile: vi.fn(), + hasCoastfile: vi.fn(), + ...overrides, + }; +} + +function allPrerequisitesMet(): PrerequisiteCheckResult { + return { + coastBinary: true, + docker: true, + coastdRunning: true, + allMet: true, + missingMessages: [], + }; +} + +function prerequisitesMissing( + missing: Partial> +): PrerequisiteCheckResult { + const coastBinary = missing.coastBinary ?? true; + const docker = missing.docker ?? true; + const coastdRunning = missing.coastdRunning ?? true; + const missingMessages: string[] = []; + if (!coastBinary) missingMessages.push('coast binary not found on PATH'); + if (!docker) missingMessages.push('Docker daemon is not reachable'); + if (!coastdRunning) missingMessages.push('coastd daemon is not running'); + return { + coastBinary, + docker, + coastdRunning, + allMet: coastBinary && docker && coastdRunning, + missingMessages, + }; +} + +function runningInstance(port = 3000): CoastInstance { + return { port, url: `http://localhost:${port}` }; +} + +describe('startCoastsDevServer', () => { + const workDir = '/repos/my-project'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('runs full Coasts flow when all prerequisites met and Coastfile exists', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(true); + vi.mocked(service.build).mockResolvedValue(undefined); + vi.mocked(service.run).mockResolvedValue(runningInstance(8080)); + + const instance = await startCoastsDevServer(service, workDir); + + expect(instance.port).toBe(8080); + expect(instance.url).toBe('http://localhost:8080'); + + // Verify call sequence: prerequisites -> hasCoastfile -> build -> run + expect(service.checkPrerequisites).toHaveBeenCalledWith(workDir); + expect(service.hasCoastfile).toHaveBeenCalledWith(workDir); + expect(service.build).toHaveBeenCalledWith(workDir); + expect(service.run).toHaveBeenCalledWith(workDir); + + // generateCoastfile should NOT be called when Coastfile exists + expect(service.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('throws when prerequisites are not met (missing coast binary)', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue( + prerequisitesMissing({ coastBinary: false }) + ); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/prerequisites not met/i); + + // Should not proceed to build or run + expect(service.build).not.toHaveBeenCalled(); + expect(service.run).not.toHaveBeenCalled(); + expect(service.hasCoastfile).not.toHaveBeenCalled(); + }); + + it('throws when Docker is not running', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue( + prerequisitesMissing({ docker: false }) + ); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/prerequisites not met/i); + + expect(service.build).not.toHaveBeenCalled(); + expect(service.run).not.toHaveBeenCalled(); + }); + + it('throws when coastd daemon is not running', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue( + prerequisitesMissing({ coastdRunning: false }) + ); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/prerequisites not met/i); + + expect(service.build).not.toHaveBeenCalled(); + expect(service.run).not.toHaveBeenCalled(); + }); + + it('includes missing prerequisite messages in error', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue( + prerequisitesMissing({ coastBinary: false, docker: false, coastdRunning: false }) + ); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/coast binary not found/); + }); + + it('throws with helpful error when no Coastfile exists', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(false); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/no coastfile found/i); + + // Should not proceed to build or run + expect(service.build).not.toHaveBeenCalled(); + expect(service.run).not.toHaveBeenCalled(); + // Should NOT call generateCoastfile + expect(service.generateCoastfile).not.toHaveBeenCalled(); + }); + + it('includes both CLI and web UI instructions in missing Coastfile error', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(false); + + const error = await startCoastsDevServer(service, workDir).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/shep coasts init/); + expect((error as Error).message).toMatch(/Generate Coastfile/); + }); + + it('throws when coast build fails', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(true); + vi.mocked(service.build).mockRejectedValue(new Error('coast build: invalid Coastfile')); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/coast build/i); + + // Should not proceed to run + expect(service.run).not.toHaveBeenCalled(); + }); + + it('throws when coast run fails', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(true); + vi.mocked(service.build).mockResolvedValue(undefined); + vi.mocked(service.run).mockRejectedValue(new Error('coast run: port conflict')); + + await expect(startCoastsDevServer(service, workDir)).rejects.toThrow(/coast run/i); + }); + + it('logs [dev-server:coasts] prefix messages throughout startup', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(true); + vi.mocked(service.build).mockResolvedValue(undefined); + vi.mocked(service.run).mockResolvedValue(runningInstance(4000)); + + await startCoastsDevServer(service, workDir); + + const logCalls = consoleSpy.log.mock.calls.map((c) => c[0]); + const coastsLogs = logCalls.filter( + (msg) => typeof msg === 'string' && msg.includes('[dev-server:coasts]') + ); + expect(coastsLogs.length).toBeGreaterThanOrEqual(3); // prerequisites, build, ready + }); + + it('returns the CoastInstance from coast run', async () => { + const service = createMockCoastsService(); + vi.mocked(service.checkPrerequisites).mockResolvedValue(allPrerequisitesMet()); + vi.mocked(service.hasCoastfile).mockResolvedValue(true); + vi.mocked(service.build).mockResolvedValue(undefined); + vi.mocked(service.run).mockResolvedValue({ port: 5555, url: 'http://localhost:5555' }); + + const result = await startCoastsDevServer(service, workDir); + + expect(result).toEqual({ port: 5555, url: 'http://localhost:5555' }); + }); +}); + +describe('shutdownCoasts', () => { + const workDir = '/repos/my-project'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls stop() on the coastsService', async () => { + const service = createMockCoastsService(); + vi.mocked(service.stop).mockResolvedValue(undefined); + + await shutdownCoasts(service, workDir); + + expect(service.stop).toHaveBeenCalledWith(workDir); + }); + + it('does nothing when coastsService is null (bare mode)', async () => { + // Should not throw + await expect(shutdownCoasts(null, workDir)).resolves.toBeUndefined(); + }); + + it('catches and logs errors from stop() without rethrowing', async () => { + const service = createMockCoastsService(); + vi.mocked(service.stop).mockRejectedValue(new Error('stop failed')); + + // Should not throw + await expect(shutdownCoasts(service, workDir)).resolves.toBeUndefined(); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to stop'), + expect.any(Error) + ); + }); + + it('logs [dev-server:coasts] prefix during shutdown', async () => { + const service = createMockCoastsService(); + vi.mocked(service.stop).mockResolvedValue(undefined); + + await shutdownCoasts(service, workDir); + + const logCalls = consoleSpy.log.mock.calls.map((c) => c[0]); + const coastsLogs = logCalls.filter( + (msg) => typeof msg === 'string' && msg.includes('[dev-server:coasts]') + ); + expect(coastsLogs.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/unit/presentation/web/components/common/add-repository-button/add-repository-button.test.tsx b/tests/unit/presentation/web/components/common/add-repository-button/add-repository-button.test.tsx index ab33c8ea4..94f0fc236 100644 --- a/tests/unit/presentation/web/components/common/add-repository-button/add-repository-button.test.tsx +++ b/tests/unit/presentation/web/components/common/add-repository-button/add-repository-button.test.tsx @@ -55,6 +55,7 @@ vi.mock('@/hooks/feature-flags-context', () => ({ githubImport: true, adoptBranch: false, reactFileManager: false, + coastsDevServer: false, })), })); @@ -75,6 +76,7 @@ describe('AddRepositoryButton', () => { githubImport: true, adoptBranch: false, reactFileManager: false, + coastsDevServer: false, gitRebaseSync: false, }); }); @@ -171,6 +173,7 @@ describe('AddRepositoryButton', () => { githubImport: true, adoptBranch: false, reactFileManager: false, + coastsDevServer: false, gitRebaseSync: false, }); }); @@ -300,6 +303,7 @@ describe('AddRepositoryButton', () => { githubImport: true, adoptBranch: false, reactFileManager: true, + coastsDevServer: false, gitRebaseSync: false, }); }); @@ -371,6 +375,7 @@ describe('AddRepositoryButton', () => { githubImport: true, adoptBranch: false, reactFileManager: true, + coastsDevServer: false, gitRebaseSync: false, }); const user = userEvent.setup(); diff --git a/tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts b/tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts new file mode 100644 index 000000000..db0ca8124 --- /dev/null +++ b/tests/unit/presentation/web/components/common/repository-node/use-coasts-actions.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock server actions +vi.mock('@/app/actions/generate-coastfile', () => ({ + generateCoastfileAction: vi.fn(), +})); +vi.mock('@/app/actions/check-coastfile', () => ({ + checkCoastfileAction: vi.fn(), +})); + +import { generateCoastfileAction } from '@/app/actions/generate-coastfile'; +import { checkCoastfileAction } from '@/app/actions/check-coastfile'; +import { useCoastsActions } from '@cli/presentation/web/components/common/repository-node/use-coasts-actions.js'; + +describe('useCoastsActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('checks coastfile existence on mount', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: true }); + + const { result } = renderHook(() => useCoastsActions({ repositoryPath: '/repos/my-project' })); + + await vi.waitFor(() => { + expect(result.current.coastfileExists).toBe(true); + }); + + expect(checkCoastfileAction).toHaveBeenCalledWith('/repos/my-project'); + }); + + it('returns coastfileExists false when no Coastfile', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: false }); + + const { result } = renderHook(() => useCoastsActions({ repositoryPath: '/repos/my-project' })); + + await vi.waitFor(() => { + expect(result.current.checkLoading).toBe(false); + }); + + expect(result.current.coastfileExists).toBe(false); + }); + + it('calls generateCoastfileAction and updates state on success', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: false }); + vi.mocked(generateCoastfileAction).mockResolvedValue({ + success: true, + coastfilePath: '/repos/my-project/Coastfile', + }); + + const { result } = renderHook(() => useCoastsActions({ repositoryPath: '/repos/my-project' })); + + await vi.waitFor(() => { + expect(result.current.checkLoading).toBe(false); + }); + + await act(async () => { + await result.current.generateCoastfile(); + }); + + expect(generateCoastfileAction).toHaveBeenCalledWith('/repos/my-project'); + expect(result.current.coastfileExists).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('sets error on generateCoastfileAction failure', async () => { + vi.mocked(checkCoastfileAction).mockResolvedValue({ exists: false }); + vi.mocked(generateCoastfileAction).mockResolvedValue({ + success: false, + error: 'Agent failed', + }); + + const { result } = renderHook(() => useCoastsActions({ repositoryPath: '/repos/my-project' })); + + await vi.waitFor(() => { + expect(result.current.checkLoading).toBe(false); + }); + + await act(async () => { + await result.current.generateCoastfile(); + }); + + expect(result.current.error).toBe('Agent failed'); + expect(result.current.coastfileExists).toBe(false); + }); + + it('returns no-op state when input is null', () => { + const { result } = renderHook(() => useCoastsActions(null)); + + expect(result.current.coastfileExists).toBe(false); + expect(result.current.generating).toBe(false); + expect(checkCoastfileAction).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/presentation/web/components/features/settings/feature-flags-settings-section.test.tsx b/tests/unit/presentation/web/components/features/settings/feature-flags-settings-section.test.tsx index 4ba72f824..a7d20074c 100644 --- a/tests/unit/presentation/web/components/features/settings/feature-flags-settings-section.test.tsx +++ b/tests/unit/presentation/web/components/features/settings/feature-flags-settings-section.test.tsx @@ -20,6 +20,7 @@ const defaultFlags = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; describe('FeatureFlagsSettingsSection', () => { diff --git a/tests/unit/presentation/web/layouts/app-shell.test.tsx b/tests/unit/presentation/web/layouts/app-shell.test.tsx index c7cb5df4d..112da6ba9 100644 --- a/tests/unit/presentation/web/layouts/app-shell.test.tsx +++ b/tests/unit/presentation/web/layouts/app-shell.test.tsx @@ -22,6 +22,7 @@ const defaultFlags = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; function renderShell(children: React.ReactNode) { diff --git a/tests/unit/presentation/web/layouts/app-sidebar.test.tsx b/tests/unit/presentation/web/layouts/app-sidebar.test.tsx index b97759592..874e97c24 100644 --- a/tests/unit/presentation/web/layouts/app-sidebar.test.tsx +++ b/tests/unit/presentation/web/layouts/app-sidebar.test.tsx @@ -46,6 +46,7 @@ const defaultFlags = { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }; function renderWithSidebar(ui: React.ReactElement) { diff --git a/tests/unit/presentation/web/lib/feature-flags.test.ts b/tests/unit/presentation/web/lib/feature-flags.test.ts index 809efad72..89118efa4 100644 --- a/tests/unit/presentation/web/lib/feature-flags.test.ts +++ b/tests/unit/presentation/web/lib/feature-flags.test.ts @@ -33,6 +33,7 @@ describe('getFeatureFlags', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }); @@ -109,6 +110,7 @@ describe('getFeatureFlags', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }); @@ -171,6 +173,7 @@ describe('featureFlags (backward-compatible const)', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }); @@ -188,6 +191,7 @@ describe('featureFlags (backward-compatible const)', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }); @@ -205,6 +209,7 @@ describe('featureFlags (backward-compatible const)', () => { adoptBranch: false, gitRebaseSync: false, reactFileManager: false, + coastsDevServer: false, }, }); diff --git a/tsp/domain/entities/settings.tsp b/tsp/domain/entities/settings.tsp index b6bd117bc..f3fd78fa3 100644 --- a/tsp/domain/entities/settings.tsp +++ b/tsp/domain/entities/settings.tsp @@ -490,6 +490,9 @@ model FeatureFlags { @doc("Use the built-in React file manager instead of the native OS folder picker") reactFileManager: boolean = false; + + @doc("Enable Coasts containerized runtime isolation for the dev server") + coastsDevServer: boolean = false; } /**