diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index aa6ec548..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: Build and Release Electron App - -on: - push: - tags: - - "v*.*.*" # Triggers on version tags like v1.0.0 - workflow_dispatch: # Allows manual triggering - inputs: - version: - description: "Version to release (e.g., v1.0.0)" - required: true - default: "v0.1.0" - -jobs: - build-and-release: - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - name: macOS - artifact-name: macos-builds - - os: windows-latest - name: Windows - artifact-name: windows-builds - - os: ubuntu-latest - name: Linux - artifact-name: linux-builds - - runs-on: ${{ matrix.os }} - - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - cache-dependency-path: package-lock.json - - - name: Configure Git for HTTPS - # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp) - # This is needed because SSH authentication isn't available in CI - run: git config --global url."https://github.com/".insteadOf "git@github.com:" - - - name: Install dependencies - # Use npm install instead of npm ci to correctly resolve platform-specific - # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) - run: npm install - - - name: Install Linux native bindings - # Workaround for npm optional dependencies bug (npm/cli#4828) - # Only needed on Linux - macOS and Windows get their bindings automatically - if: matrix.os == 'ubuntu-latest' - run: | - npm install --no-save --force \ - @rollup/rollup-linux-x64-gnu@4.53.3 \ - @tailwindcss/oxide-linux-x64-gnu@4.1.17 - - - name: Extract and set version - id: version - shell: bash - run: | - VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}" - # Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0) - VERSION="${VERSION_TAG#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Extracted version: $VERSION from tag: $VERSION_TAG" - # Update the app's package.json version - cd apps/app - npm version $VERSION --no-git-tag-version - cd ../.. - echo "Updated apps/app/package.json to version $VERSION" - - - name: Build Electron App (macOS) - if: matrix.os == 'macos-latest' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build:electron -- --mac --x64 --arm64 - - - name: Build Electron App (Windows) - if: matrix.os == 'windows-latest' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build:electron -- --win --x64 - - - name: Build Electron App (Linux) - if: matrix.os == 'ubuntu-latest' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build:electron -- --linux --x64 - - - name: Upload Release Assets - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ github.event.inputs.version || github.ref_name }} - files: | - apps/app/dist/*.exe - apps/app/dist/*.dmg - apps/app/dist/*.AppImage - apps/app/dist/*.zip - apps/app/dist/*.deb - apps/app/dist/*.rpm - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload macOS artifacts for R2 - if: matrix.os == 'macos-latest' - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact-name }} - path: apps/app/dist/*.dmg - retention-days: 1 - - - name: Upload Windows artifacts for R2 - if: matrix.os == 'windows-latest' - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact-name }} - path: apps/app/dist/*.exe - retention-days: 1 - - - name: Upload Linux artifacts for R2 - if: matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact-name }} - path: apps/app/dist/*.AppImage - retention-days: 1 - - upload-to-r2: - needs: build-and-release - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Install AWS SDK - run: npm install @aws-sdk/client-s3 - - - name: Extract version - id: version - shell: bash - run: | - VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}" - # Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0) - VERSION="${VERSION_TAG#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT - echo "Extracted version: $VERSION from tag: $VERSION_TAG" - - - name: Upload to R2 and update releases.json - env: - R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} - R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} - RELEASE_VERSION: ${{ steps.version.outputs.version }} - RELEASE_TAG: ${{ steps.version.outputs.version_tag }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: node .github/scripts/upload-to-r2.js diff --git a/README.md b/README.md index 8c863c53..39c31d4b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Automaker Logo + Automaker Logo

> **[!TIP]** @@ -88,6 +88,7 @@ The future of software development is **agentic coding**β€”where developers beco Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows. In the Discord, you can: + - πŸ’¬ Discuss agentic coding patterns and best practices - 🧠 Share ideas for AI-driven development workflows - πŸ› οΈ Get help setting up or extending Automaker @@ -252,19 +253,16 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE **Summary of Terms:** - **Allowed:** - - **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free). - **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction. - **Modify:** You can modify the code for internal use within your organization (commercial or non-profit). - **Restricted (The "No Monetization of the Tool" Rule):** - - **No Resale:** You cannot resell Automaker itself. - **No SaaS:** You cannot host Automaker as a service for others. - **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money. - **Liability:** - - **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk. - **Contributing:** diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index b165b025..afe42e7a 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -17,6 +17,9 @@ const logger = createLogger("Worktree"); const execAsync = promisify(exec); const featureLoader = new FeatureLoader(); +export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = + "chore: automaker initial commit"; + /** * Normalize path separators to forward slashes for cross-platform consistency. * This ensures paths from `path.join()` (backslashes on Windows) match paths @@ -77,3 +80,30 @@ export function logWorktreeError( // Re-export shared utilities export { getErrorMessageShared as getErrorMessage }; export const logError = createLogError(logger); + +/** + * Ensure the repository has at least one commit so git commands that rely on HEAD work. + * Returns true if an empty commit was created, false if the repo already had commits. + */ +export async function ensureInitialCommit(repoPath: string): Promise { + try { + await execAsync("git rev-parse --verify HEAD", { cwd: repoPath }); + return false; + } catch { + try { + await execAsync( + `git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, + { cwd: repoPath } + ); + logger.info( + `[Worktree] Created initial empty commit to enable worktrees in ${repoPath}` + ); + return true; + } catch (error) { + const reason = getErrorMessageShared(error); + throw new Error( + `Failed to create initial git commit. Please commit manually and retry. ${reason}` + ); + } + } +} diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index ab44374b..690afe48 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,7 +12,13 @@ import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; import { mkdir } from "fs/promises"; -import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js"; +import { + isGitRepo, + getErrorMessage, + logError, + normalizePath, + ensureInitialCommit, +} from "../common.js"; import { trackBranch } from "./branch-tracking.js"; const execAsync = promisify(exec); @@ -93,6 +99,9 @@ export function createCreateHandler() { return; } + // Ensure the repository has at least one commit so worktree commands referencing HEAD succeed + await ensureInitialCommit(projectPath); + // First, check if git already has a worktree for this branch (anywhere) const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); if (existingWorktree) { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 8edfd6dd..14fdf724 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -520,29 +520,28 @@ export class AutoModeService { } // Derive workDir from feature.branchName - // If no branchName, derive from feature ID: feature/{featureId} + // Worktrees should already be created when the feature is added/edited let worktreePath: string | null = null; - const branchName = feature.branchName || `feature/${featureId}`; + const branchName = feature.branchName; if (useWorktrees && branchName) { // Try to find existing worktree for this branch + // Worktree should already exist (created when feature was added/edited) worktreePath = await this.findExistingWorktreeForBranch( projectPath, branchName ); - if (!worktreePath) { - // Create worktree for this branch - worktreePath = await this.setupWorktree( - projectPath, - featureId, - branchName + if (worktreePath) { + console.log( + `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` + ); + } else { + // Worktree doesn't exist - log warning and continue with project path + console.warn( + `[AutoMode] Worktree for branch "${branchName}" not found, using project path` ); } - - console.log( - `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` - ); } // Ensure workDir is always an absolute path for cross-platform compatibility @@ -552,7 +551,7 @@ export class AutoModeService { // Update running feature with actual worktree info tempRunningFeature.worktreePath = worktreePath; - tempRunningFeature.branchName = branchName; + tempRunningFeature.branchName = branchName ?? null; // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); @@ -1479,60 +1478,6 @@ Format your response as a structured markdown document.`; } } - private async setupWorktree( - projectPath: string, - featureId: string, - branchName: string - ): Promise { - // First, check if git already has a worktree for this branch (anywhere) - const existingWorktree = await this.findExistingWorktreeForBranch( - projectPath, - branchName - ); - if (existingWorktree) { - // Path is already resolved to absolute in findExistingWorktreeForBranch - console.log( - `[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}` - ); - return existingWorktree; - } - - // Git worktrees stay in project directory - const worktreesDir = path.join(projectPath, ".worktrees"); - const worktreePath = path.join(worktreesDir, featureId); - - await fs.mkdir(worktreesDir, { recursive: true }); - - // Check if worktree directory already exists (might not be linked to branch) - try { - await fs.access(worktreePath); - // Return absolute path for cross-platform compatibility - return path.resolve(worktreePath); - } catch { - // Create new worktree - } - - // Create branch if it doesn't exist - try { - await execAsync(`git branch ${branchName}`, { cwd: projectPath }); - } catch { - // Branch may already exist - } - - // Create worktree - try { - await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { - cwd: projectPath, - }); - // Return absolute path for cross-platform compatibility - return path.resolve(worktreePath); - } catch (error) { - // Worktree creation failed, fall back to direct execution - console.error(`[AutoMode] Worktree creation failed:`, error); - return path.resolve(projectPath); - } - } - private async loadFeature( projectPath: string, featureId: string diff --git a/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/apps/server/tests/integration/routes/worktree/create.integration.test.ts new file mode 100644 index 00000000..03b85e7e --- /dev/null +++ b/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createCreateHandler } from "@/routes/worktree/routes/create.js"; +import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js"; +import { exec } from "child_process"; +import { promisify } from "util"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +const execAsync = promisify(exec); + +describe("worktree create route - repositories without commits", () => { + let repoPath: string | null = null; + + async function initRepoWithoutCommit() { + repoPath = await fs.mkdtemp( + path.join(os.tmpdir(), "automaker-no-commit-") + ); + await execAsync("git init", { cwd: repoPath }); + await execAsync('git config user.email "test@example.com"', { + cwd: repoPath, + }); + await execAsync('git config user.name "Test User"', { cwd: repoPath }); + // Intentionally skip creating an initial commit + } + + afterEach(async () => { + if (!repoPath) { + return; + } + await fs.rm(repoPath, { recursive: true, force: true }); + repoPath = null; + }); + + it("creates an initial commit before adding a worktree when HEAD is missing", async () => { + await initRepoWithoutCommit(); + const handler = createCreateHandler(); + + const json = vi.fn(); + const status = vi.fn().mockReturnThis(); + const req = { + body: { projectPath: repoPath, branchName: "feature/no-head" }, + } as any; + const res = { + json, + status, + } as any; + + await handler(req, res); + + expect(status).not.toHaveBeenCalled(); + expect(json).toHaveBeenCalled(); + const payload = json.mock.calls[0][0]; + expect(payload.success).toBe(true); + + const { stdout: commitCount } = await execAsync( + "git rev-list --count HEAD", + { cwd: repoPath! } + ); + expect(Number(commitCount.trim())).toBeGreaterThan(0); + + const { stdout: latestMessage } = await execAsync( + "git log -1 --pretty=%B", + { cwd: repoPath! } + ); + expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE); + }); +}); + diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index 45b4d6e4..ebf0857f 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -13,6 +13,10 @@ import { } from "../helpers/git-test-repo.js"; import * as fs from "fs/promises"; import * as path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); vi.mock("@/providers/provider-factory.js"); @@ -43,13 +47,24 @@ describe("auto-mode-service.ts (integration)", () => { }); describe("worktree operations", () => { - it("should create git worktree for feature", async () => { - // Create a test feature + it("should use existing git worktree for feature", async () => { + const branchName = "feature/test-feature-1"; + + // Create a test feature with branchName set await createTestFeature(testRepo.path, "test-feature-1", { id: "test-feature-1", category: "test", description: "Test feature", status: "pending", + branchName: branchName, + }); + + // Create worktree before executing (worktrees are now created when features are added/edited) + const worktreesDir = path.join(testRepo.path, ".worktrees"); + const worktreePath = path.join(worktreesDir, "test-feature-1"); + await fs.mkdir(worktreesDir, { recursive: true }); + await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, { + cwd: testRepo.path, }); // Mock provider to complete quickly @@ -82,9 +97,20 @@ describe("auto-mode-service.ts (integration)", () => { false // isAutoMode ); - // Verify branch was created + // Verify branch exists (was created when worktree was created) const branches = await listBranches(testRepo.path); - expect(branches).toContain("feature/test-feature-1"); + expect(branches).toContain(branchName); + + // Verify worktree exists and is being used + // The service should have found and used the worktree (check via logs) + // We can verify the worktree exists by checking git worktree list + const worktrees = await listWorktrees(testRepo.path); + expect(worktrees.length).toBeGreaterThan(0); + // Verify that at least one worktree path contains our feature ID + const worktreePathsMatch = worktrees.some(wt => + wt.includes("test-feature-1") || wt.includes(".worktrees") + ); + expect(worktreePathsMatch).toBe(true); // Note: Worktrees are not automatically cleaned up by the service // This is expected behavior - manual cleanup is required diff --git a/apps/ui/scripts/setup-e2e-fixtures.mjs b/apps/ui/scripts/setup-e2e-fixtures.mjs index 63ad5a02..a4fc585d 100644 --- a/apps/ui/scripts/setup-e2e-fixtures.mjs +++ b/apps/ui/scripts/setup-e2e-fixtures.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Resolve workspace root (apps/app/scripts -> workspace root) +// Resolve workspace root (apps/ui/scripts -> workspace root) const WORKSPACE_ROOT = path.resolve(__dirname, "../../.."); const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA"); const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt"); diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index 7f2a4585..c255c27f 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -1,11 +1,6 @@ import { useState, useEffect } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Input } from "@/components/ui/input"; @@ -115,8 +110,10 @@ export function SessionManager({ new Set() ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [sessionToDelete, setSessionToDelete] = useState(null); - const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); + const [sessionToDelete, setSessionToDelete] = + useState(null); + const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = + useState(false); // Check running state for all sessions const checkRunningSessions = async (sessionList: SessionListItem[]) => { @@ -233,11 +230,7 @@ export function SessionManager({ const api = getElectronAPI(); if (!editingName.trim() || !api?.sessions) return; - const result = await api.sessions.update( - sessionId, - editingName, - undefined - ); + const result = await api.sessions.update(sessionId, editingName, undefined); if (result.success) { setEditingSessionId(null); diff --git a/apps/ui/src/components/ui/branch-autocomplete.tsx b/apps/ui/src/components/ui/branch-autocomplete.tsx index 32b00ce1..9af78bd8 100644 --- a/apps/ui/src/components/ui/branch-autocomplete.tsx +++ b/apps/ui/src/components/ui/branch-autocomplete.tsx @@ -7,6 +7,7 @@ interface BranchAutocompleteProps { value: string; onChange: (value: string) => void; branches: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count placeholder?: string; className?: string; disabled?: boolean; @@ -18,6 +19,7 @@ export function BranchAutocomplete({ value, onChange, branches, + branchCardCounts, placeholder = "Select a branch...", className, disabled = false, @@ -27,12 +29,22 @@ export function BranchAutocomplete({ // Always include "main" at the top of suggestions const branchOptions: AutocompleteOption[] = React.useMemo(() => { const branchSet = new Set(["main", ...branches]); - return Array.from(branchSet).map((branch) => ({ - value: branch, - label: branch, - badge: branch === "main" ? "default" : undefined, - })); - }, [branches]); + return Array.from(branchSet).map((branch) => { + const cardCount = branchCardCounts?.[branch]; + // Show card count if available, otherwise show "default" for main branch only + const badge = branchCardCounts !== undefined + ? String(cardCount ?? 0) + : branch === "main" + ? "default" + : undefined; + + return { + value: branch, + label: branch, + badge, + }; + }); + }, [branches, branchCardCounts]); return ( { + return hookFeatures.reduce((counts, feature) => { + if (feature.status !== "completed") { + const branch = feature.branchName ?? "main"; + counts[branch] = (counts[branch] || 0) + 1; + } + return counts; + }, {} as Record); + }, [hookFeatures]); + // Custom collision detection that prioritizes columns over cards const collisionDetectionStrategy = useCallback((args: any) => { // First, check if pointer is within a column @@ -301,14 +312,14 @@ export function BoardView() { }); if (matchesRemovedWorktree) { - // Reset the feature's branch assignment - persistFeatureUpdate(feature.id, { - branchName: null as unknown as string | undefined, - }); + // Reset the feature's branch assignment - update both local state and persist + const updates = { branchName: null as unknown as string | undefined }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); } }); }, - [hookFeatures, persistFeatureUpdate] + [hookFeatures, updateFeature, persistFeatureUpdate] ); // Get in-progress features for keyboard shortcuts (needed before actions hook) @@ -417,6 +428,18 @@ export function BoardView() { hookFeaturesRef.current = hookFeatures; }, [hookFeatures]); + // Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef + const runningAutoTasksRef = useRef(runningAutoTasks); + useEffect(() => { + runningAutoTasksRef.current = runningAutoTasks; + }, [runningAutoTasks]); + + // Keep latest start handler without retriggering the auto mode effect + const handleStartImplementationRef = useRef(handleStartImplementation); + useEffect(() => { + handleStartImplementationRef.current = handleStartImplementation; + }, [handleStartImplementation]); + // Track features that are pending (started but not yet confirmed running) const pendingFeaturesRef = useRef>(new Set()); @@ -484,8 +507,9 @@ export function BoardView() { } // Count currently running tasks + pending features + // Use ref to get the latest running tasks without causing effect re-runs const currentRunning = - runningAutoTasks.length + pendingFeaturesRef.current.size; + runningAutoTasksRef.current.length + pendingFeaturesRef.current.size; const availableSlots = maxConcurrency - currentRunning; // No available slots, skip check @@ -540,6 +564,10 @@ export function BoardView() { // Start features up to available slots const featuresToStart = eligibleFeatures.slice(0, availableSlots); + const startImplementation = handleStartImplementationRef.current; + if (!startImplementation) { + return; + } for (const feature of featuresToStart) { // Check again before starting each feature @@ -565,7 +593,7 @@ export function BoardView() { } // Start the implementation - server will derive workDir from feature.branchName - const started = await handleStartImplementation(feature); + const started = await startImplementation(feature); // If successfully started, track it as pending until we receive the start event if (started) { @@ -579,7 +607,7 @@ export function BoardView() { // Check immediately, then every 3 seconds checkAndStartFeatures(); - const interval = setInterval(checkAndStartFeatures, 3000); + const interval = setInterval(checkAndStartFeatures, 1000); return () => { // Mark as inactive to prevent any pending async operations from continuing @@ -591,7 +619,8 @@ export function BoardView() { }, [ autoMode.isRunning, currentProject, - runningAutoTasks, + // runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs + // that would clear pendingFeaturesRef and cause concurrency issues maxConcurrency, // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs currentWorktreeBranch, @@ -600,7 +629,6 @@ export function BoardView() { isPrimaryWorktreeBranch, enableDependencyBlocking, persistFeatureUpdate, - handleStartImplementation, ]); // Use keyboard shortcuts hook (after actions hook) @@ -639,7 +667,9 @@ export function BoardView() { // Find feature for pending plan approval const pendingApprovalFeature = useMemo(() => { if (!pendingPlanApproval) return null; - return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; + return ( + hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null + ); }, [pendingPlanApproval, hookFeatures]); // Handle plan approval @@ -665,10 +695,10 @@ export function BoardView() { if (result.success) { // Immediately update local feature state to hide "Approve Plan" button // Get current feature to preserve version - const currentFeature = hookFeatures.find(f => f.id === featureId); + const currentFeature = hookFeatures.find((f) => f.id === featureId); updateFeature(featureId, { planSpec: { - status: 'approved', + status: "approved", content: editedPlan || pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, approvedAt: new Date().toISOString(), @@ -687,7 +717,14 @@ export function BoardView() { setPendingPlanApproval(null); } }, - [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + [ + pendingPlanApproval, + currentProject, + setPendingPlanApproval, + updateFeature, + loadFeatures, + hookFeatures, + ] ); // Handle plan rejection @@ -714,11 +751,11 @@ export function BoardView() { if (result.success) { // Immediately update local feature state // Get current feature to preserve version - const currentFeature = hookFeatures.find(f => f.id === featureId); + const currentFeature = hookFeatures.find((f) => f.id === featureId); updateFeature(featureId, { - status: 'backlog', + status: "backlog", planSpec: { - status: 'rejected', + status: "rejected", content: pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, reviewedByUser: true, @@ -736,7 +773,14 @@ export function BoardView() { setPendingPlanApproval(null); } }, - [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + [ + pendingPlanApproval, + currentProject, + setPendingPlanApproval, + updateFeature, + loadFeatures, + hookFeatures, + ] ); // Handle opening approval dialog from feature card button @@ -747,7 +791,7 @@ export function BoardView() { // Determine the planning mode for approval (skip should never have a plan requiring approval) const mode = feature.planningMode; const approvalMode: "lite" | "spec" | "full" = - mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec'; + mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec"; // Re-open the approval dialog with the feature's plan data setPendingPlanApproval({ @@ -832,6 +876,7 @@ export function BoardView() { }} onRemovedWorktrees={handleRemovedWorktrees} runningFeatureIds={runningAutoTasks} + branchCardCounts={branchCardCounts} features={hookFeatures.map((f) => ({ id: f.id, branchName: f.branchName, @@ -928,6 +973,7 @@ export function BoardView() { onAdd={handleAddFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} defaultSkipTests={defaultSkipTests} defaultBranch={selectedWorktreeBranch} currentBranch={currentWorktreeBranch || undefined} @@ -943,6 +989,7 @@ export function BoardView() { onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} @@ -1064,15 +1111,24 @@ export function BoardView() { onOpenChange={setShowDeleteWorktreeDialog} projectPath={currentProject.path} worktree={selectedWorktreeForAction} + affectedFeatureCount={ + selectedWorktreeForAction + ? hookFeatures.filter( + (f) => f.branchName === selectedWorktreeForAction.branch + ).length + : 0 + } onDeleted={(deletedWorktree, _deletedBranch) => { // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { // Match by branch name since worktreePath is no longer stored if (feature.branchName === deletedWorktree.branch) { - // Reset the feature's branch assignment - persistFeatureUpdate(feature.id, { + // Reset the feature's branch assignment - update both local state and persist + const updates = { branchName: null as unknown as string | undefined, - }); + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); } }); diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index f84eaded..4bd0b632 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -73,6 +73,7 @@ interface AddFeatureDialogProps { }) => void; categorySuggestions: string[]; branchSuggestions: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count defaultSkipTests: boolean; defaultBranch?: string; currentBranch?: string; @@ -87,6 +88,7 @@ export function AddFeatureDialog({ onAdd, categorySuggestions, branchSuggestions, + branchCardCounts, defaultSkipTests, defaultBranch = "main", currentBranch, @@ -116,11 +118,16 @@ export function AddFeatureDialog({ const [enhancementMode, setEnhancementMode] = useState< "improve" | "technical" | "simplify" | "acceptance" >("improve"); - const [planningMode, setPlanningMode] = useState('skip'); + const [planningMode, setPlanningMode] = useState("skip"); const [requirePlanApproval, setRequirePlanApproval] = useState(false); // Get enhancement model, planning mode defaults, and worktrees setting from store - const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore(); + const { + enhancementModel, + defaultPlanningMode, + defaultRequirePlanApproval, + useWorktrees, + } = useAppStore(); // Sync defaults when dialog opens useEffect(() => { @@ -134,7 +141,13 @@ export function AddFeatureDialog({ setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); } - }, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]); + }, [ + open, + defaultSkipTests, + defaultBranch, + defaultPlanningMode, + defaultRequirePlanApproval, + ]); const handleAdd = () => { if (!newFeature.description.trim()) { @@ -158,7 +171,7 @@ export function AddFeatureDialog({ // If currentBranch is provided (non-primary worktree), use it // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) const finalBranchName = useCurrentBranch - ? (currentBranch || "") + ? currentBranch || "" : newFeature.branchName || ""; onAdd({ @@ -399,6 +412,7 @@ export function AddFeatureDialog({ setNewFeature({ ...newFeature, branchName: value }) } branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} currentBranch={currentBranch} testIdPrefix="feature" /> @@ -481,7 +495,10 @@ export function AddFeatureDialog({ {/* Options Tab */} - + {/* Planning Mode Section */} Add Feature diff --git a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx index 60bf9178..6dee9277 100644 --- a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx @@ -11,7 +11,7 @@ import { import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; -import { Loader2, Trash2, AlertTriangle } from "lucide-react"; +import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react"; import { getElectronAPI } from "@/lib/electron"; import { toast } from "sonner"; @@ -29,6 +29,8 @@ interface DeleteWorktreeDialogProps { projectPath: string; worktree: WorktreeInfo | null; onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; + /** Number of features assigned to this worktree's branch */ + affectedFeatureCount?: number; } export function DeleteWorktreeDialog({ @@ -37,6 +39,7 @@ export function DeleteWorktreeDialog({ projectPath, worktree, onDeleted, + affectedFeatureCount = 0, }: DeleteWorktreeDialogProps) { const [deleteBranch, setDeleteBranch] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -99,6 +102,18 @@ export function DeleteWorktreeDialog({ ? + {affectedFeatureCount > 0 && ( +
+ + + {affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "} + {affectedFeatureCount !== 1 ? "are" : "is"} assigned to this + branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be + unassigned and moved to the main worktree. + +
+ )} + {worktree.hasChanges && (
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 5afe6b71..981a212f 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -76,6 +76,7 @@ interface EditFeatureDialogProps { ) => void; categorySuggestions: string[]; branchSuggestions: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; @@ -89,6 +90,7 @@ export function EditFeatureDialog({ onUpdate, categorySuggestions, branchSuggestions, + branchCardCounts, currentBranch, isMaximized, showProfilesOnly, @@ -388,6 +390,7 @@ export function EditFeatureDialog({ }) } branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} currentBranch={currentBranch} disabled={editingFeature.status !== "backlog"} testIdPrefix="edit-feature" diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 9deb8a40..8370d96f 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -82,8 +82,8 @@ export function useBoardActions({ } = useAppStore(); const autoMode = useAutoMode(); - // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side - // at execution time based on feature.branchName + // Worktrees are created when adding/editing features with a branch name + // This ensures the worktree exists before the feature starts execution const handleAddFeature = useCallback( async (featureData: { @@ -100,24 +100,58 @@ export function useBoardActions({ planningMode: PlanningMode; requirePlanApproval: boolean; }) => { - // Simplified: Only store branchName, no worktree creation on add - // Worktrees are created at execution time (when feature starts) // Empty string means "unassigned" (show only on primary worktree) - convert to undefined // Non-empty string is the actual branch name (for non-primary worktrees) const finalBranchName = featureData.branchName || undefined; + // If worktrees enabled and a branch is specified, create the worktree now + // This ensures the worktree exists before the feature starts + if (useWorktrees && finalBranchName && currentProject) { + try { + const api = getElectronAPI(); + if (api?.worktree?.create) { + const result = await api.worktree.create( + currentProject.path, + finalBranchName + ); + if (result.success) { + console.log( + `[Board] Worktree for branch "${finalBranchName}" ${ + result.worktree?.isNew ? "created" : "already exists" + }` + ); + // Refresh worktree list in UI + onWorktreeCreated?.(); + } else { + console.error( + `[Board] Failed to create worktree for branch "${finalBranchName}":`, + result.error + ); + toast.error("Failed to create worktree", { + description: result.error || "An error occurred", + }); + } + } + } catch (error) { + console.error("[Board] Error creating worktree:", error); + toast.error("Failed to create worktree", { + description: + error instanceof Error ? error.message : "An error occurred", + }); + } + } + const newFeatureData = { ...featureData, status: "backlog" as const, branchName: finalBranchName, - // No worktreePath - derived at runtime from branchName }; const createdFeature = addFeature(newFeatureData); // Must await to ensure feature exists on server before user can drag it await persistFeatureCreate(createdFeature); saveCategory(featureData.category); }, - [addFeature, persistFeatureCreate, saveCategory] + [addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated] ); const handleUpdateFeature = useCallback( @@ -139,6 +173,43 @@ export function useBoardActions({ ) => { const finalBranchName = updates.branchName || undefined; + // If worktrees enabled and a branch is specified, create the worktree now + // This ensures the worktree exists before the feature starts + if (useWorktrees && finalBranchName && currentProject) { + try { + const api = getElectronAPI(); + if (api?.worktree?.create) { + const result = await api.worktree.create( + currentProject.path, + finalBranchName + ); + if (result.success) { + console.log( + `[Board] Worktree for branch "${finalBranchName}" ${ + result.worktree?.isNew ? "created" : "already exists" + }` + ); + // Refresh worktree list in UI + onWorktreeCreated?.(); + } else { + console.error( + `[Board] Failed to create worktree for branch "${finalBranchName}":`, + result.error + ); + toast.error("Failed to create worktree", { + description: result.error || "An error occurred", + }); + } + } + } catch (error) { + console.error("[Board] Error creating worktree:", error); + toast.error("Failed to create worktree", { + description: + error instanceof Error ? error.message : "An error occurred", + }); + } + } + const finalUpdates = { ...updates, branchName: finalBranchName, @@ -151,7 +222,7 @@ export function useBoardActions({ } setEditingFeature(null); }, - [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] + [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated] ); const handleDeleteFeature = useCallback( diff --git a/apps/ui/src/components/views/board-view/shared/branch-selector.tsx b/apps/ui/src/components/views/board-view/shared/branch-selector.tsx index a395edf5..0ba0848b 100644 --- a/apps/ui/src/components/views/board-view/shared/branch-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/branch-selector.tsx @@ -10,6 +10,7 @@ interface BranchSelectorProps { branchName: string; onBranchNameChange: (branchName: string) => void; branchSuggestions: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count currentBranch?: string; disabled?: boolean; testIdPrefix?: string; @@ -21,6 +22,7 @@ export function BranchSelector({ branchName, onBranchNameChange, branchSuggestions, + branchCardCounts, currentBranch, disabled = false, testIdPrefix = "branch", @@ -69,6 +71,7 @@ export function BranchSelector({ value={branchName} onChange={onBranchNameChange} branches={branchSuggestions} + branchCardCounts={branchCardCounts} placeholder="Select or create branch..." data-testid={`${testIdPrefix}-input`} disabled={disabled} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 7776f983..fb11afcd 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -8,6 +8,7 @@ import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; interface WorktreeTabProps { worktree: WorktreeInfo; + cardCount?: number; // Number of unarchived cards for this branch isSelected: boolean; isRunning: boolean; isActivating: boolean; @@ -43,6 +44,7 @@ interface WorktreeTabProps { export function WorktreeTab({ worktree, + cardCount, isSelected, isRunning, isActivating, @@ -96,9 +98,9 @@ export function WorktreeTab({ )} {worktree.branch} - {worktree.hasChanges && ( + {cardCount !== undefined && cardCount > 0 && ( - {worktree.changedFilesCount} + {cardCount} )} @@ -139,9 +141,9 @@ export function WorktreeTab({ )} {worktree.branch} - {worktree.hasChanges && ( + {cardCount !== undefined && cardCount > 0 && ( - {worktree.changedFilesCount} + {cardCount} )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index e143ae73..c1beaf5f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -35,5 +35,6 @@ export interface WorktreePanelProps { onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; runningFeatureIds?: string[]; features?: FeatureInfo[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count refreshTrigger?: number; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 5bf77958..b3d90593 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -23,6 +23,7 @@ export function WorktreePanel({ onRemovedWorktrees, runningFeatureIds = [], features = [], + branchCardCounts, refreshTrigger = 0, }: WorktreePanelProps) { const { @@ -109,43 +110,47 @@ export function WorktreePanel({ Branch:
- {worktrees.map((worktree) => ( - - ))} + {worktrees.map((worktree) => { + const cardCount = branchCardCounts?.[worktree.branch]; + return ( + + ); + })}
- {section.title} + + {section.title} + {isOpen ? ( ) : ( @@ -86,19 +87,30 @@ function CodeBlock({ children, title }: { children: string; title?: string }) { ); } -function FeatureList({ items }: { items: { icon: React.ElementType; title: string; description: string }[] }) { +function FeatureList({ + items, +}: { + items: { icon: React.ElementType; title: string; description: string }[]; +}) { return (
{items.map((item, index) => { const ItemIcon = item.icon; return ( -
+
-
{item.title}
-
{item.description}
+
+ {item.title} +
+
+ {item.description} +
); @@ -108,7 +120,9 @@ function FeatureList({ items }: { items: { icon: React.ElementType; title: strin } export function WikiView() { - const [openSections, setOpenSections] = useState>(new Set(["overview"])); + const [openSections, setOpenSections] = useState>( + new Set(["overview"]) + ); const toggleSection = (id: string) => { setOpenSections((prev) => { @@ -138,14 +152,21 @@ export function WikiView() { content: (

- Automaker is an autonomous AI development studio that helps developers build software faster using AI agents. + Automaker is an + autonomous AI development studio that helps developers build + software faster using AI agents.

- At its core, Automaker provides a visual Kanban board to manage features. When you're ready, AI agents automatically implement those features in your codebase, complete with git worktree isolation for safe parallel development. + At its core, Automaker provides a visual Kanban board to manage + features. When you're ready, AI agents automatically implement those + features in your codebase, complete with git worktree isolation for + safe parallel development.

- Think of it as having a team of AI developers that can work on multiple features simultaneously while you focus on the bigger picture. + Think of it as having a team of AI developers that can work on + multiple features simultaneously while you focus on the bigger + picture.

@@ -160,17 +181,21 @@ export function WikiView() {

Automaker is built as a monorepo with two main applications:

  • - apps/app - Next.js + Electron frontend for the desktop application + apps/ui - Next.js + + Electron frontend for the desktop application
  • - apps/server - Express backend handling API requests and agent orchestration + apps/server - Express + backend handling API requests and agent orchestration

Key Technologies:

  • Electron wraps Next.js for cross-platform desktop support
  • -
  • Real-time communication via WebSocket for live agent updates
  • +
  • + Real-time communication via WebSocket for live agent updates +
  • State management with Zustand for reactive UI updates
  • Claude Agent SDK for AI capabilities
@@ -189,42 +214,50 @@ export function WikiView() { { icon: LayoutGrid, title: "Kanban Board", - description: "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.", + description: + "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.", }, { icon: Bot, title: "AI Agent Integration", - description: "Powered by Claude via the Agent SDK with full file, bash, and git access.", + description: + "Powered by Claude via the Agent SDK with full file, bash, and git access.", }, { icon: Cpu, title: "Multi-Model Support", - description: "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.", + description: + "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.", }, { icon: Brain, title: "Extended Thinking", - description: "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.", + description: + "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.", }, { icon: Zap, title: "Real-time Streaming", - description: "Watch AI agents work in real-time with live output streaming.", + description: + "Watch AI agents work in real-time with live output streaming.", }, { icon: GitBranch, title: "Git Worktree Isolation", - description: "Each feature runs in its own git worktree for safe parallel development.", + description: + "Each feature runs in its own git worktree for safe parallel development.", }, { icon: Users, title: "AI Profiles", - description: "Pre-configured model + thinking level combinations for different task types.", + description: + "Pre-configured model + thinking level combinations for different task types.", }, { icon: Terminal, title: "Integrated Terminal", - description: "Built-in terminal with tab support and split panes.", + description: + "Built-in terminal with tab support and split panes.", }, { icon: Keyboard, @@ -234,7 +267,8 @@ export function WikiView() { { icon: Palette, title: "14 Themes", - description: "From light to dark, retro to synthwave - pick your style.", + description: + "From light to dark, retro to synthwave - pick your style.", }, { icon: Image, @@ -244,7 +278,8 @@ export function WikiView() { { icon: TestTube, title: "Test Integration", - description: "Automatic test running and TDD support for quality assurance.", + description: + "Automatic test running and TDD support for quality assurance.", }, ]} /> @@ -257,39 +292,63 @@ export function WikiView() { icon: GitBranch, content: (
-

Here's what happens when you use Automaker to implement a feature:

+

+ Here's what happens when you use Automaker to implement a feature: +

  1. Create Feature -

    Add a new feature card to the Kanban board with description and steps

    +

    + Add a new feature card to the Kanban board with description and + steps +

  2. Feature Saved -

    Feature saved to .automaker/features/{id}/feature.json

    +

    + Feature saved to{" "} + + .automaker/features/{id}/feature.json + +

  3. Start Work -

    Drag to "In Progress" or enable auto mode to start implementation

    +

    + Drag to "In Progress" or enable auto mode to start + implementation +

  4. Git Worktree Created -

    Backend AutoModeService creates isolated git worktree (if enabled)

    +

    + Backend AutoModeService creates isolated git worktree (if + enabled) +

  5. Agent Executes -

    Claude Agent SDK runs with file/bash/git tool access

    +

    + Claude Agent SDK runs with file/bash/git tool access +

  6. Progress Streamed -

    Real-time updates via WebSocket as agent works

    +

    + Real-time updates via WebSocket as agent works +

  7. Completion -

    On success, feature moves to "waiting_approval" for your review

    +

    + On success, feature moves to "waiting_approval" for your review +

  8. Verify -

    Review changes and move to "verified" when satisfied

    +

    + Review changes and move to "verified" when satisfied +

@@ -301,9 +360,11 @@ export function WikiView() { icon: FolderTree, content: (
-

The Automaker codebase is organized as follows:

+

+ The Automaker codebase is organized as follows: +

-{`/automaker/ + {`/automaker/ β”œβ”€β”€ apps/ β”‚ β”œβ”€β”€ app/ # Frontend (Next.js + Electron) β”‚ β”‚ β”œβ”€β”€ electron/ # Electron main process @@ -332,18 +393,46 @@ export function WikiView() {

The main UI components that make up Automaker:

{[ - { file: "sidebar.tsx", desc: "Main navigation with project picker and view switching" }, - { file: "board-view.tsx", desc: "Kanban board with drag-and-drop cards" }, - { file: "agent-view.tsx", desc: "AI chat interface for conversational development" }, + { + file: "sidebar.tsx", + desc: "Main navigation with project picker and view switching", + }, + { + file: "board-view.tsx", + desc: "Kanban board with drag-and-drop cards", + }, + { + file: "agent-view.tsx", + desc: "AI chat interface for conversational development", + }, { file: "spec-view.tsx", desc: "Project specification editor" }, - { file: "context-view.tsx", desc: "Context file manager for AI context" }, - { file: "terminal-view.tsx", desc: "Integrated terminal with splits and tabs" }, - { file: "profiles-view.tsx", desc: "AI profile management (model + thinking presets)" }, - { file: "app-store.ts", desc: "Central Zustand state management" }, + { + file: "context-view.tsx", + desc: "Context file manager for AI context", + }, + { + file: "terminal-view.tsx", + desc: "Integrated terminal with splits and tabs", + }, + { + file: "profiles-view.tsx", + desc: "AI profile management (model + thinking presets)", + }, + { + file: "app-store.ts", + desc: "Central Zustand state management", + }, ].map((item) => ( -
- {item.file} - {item.desc} +
+ + {item.file} + + + {item.desc} +
))}
@@ -356,21 +445,45 @@ export function WikiView() { icon: Settings, content: (
-

Automaker stores project configuration in the .automaker/ directory:

+

+ Automaker stores project configuration in the{" "} + + .automaker/ + {" "} + directory: +

{[ - { file: "app_spec.txt", desc: "Project specification describing your app for AI context" }, - { file: "context/", desc: "Additional context files (docs, examples) for AI" }, - { file: "features/", desc: "Feature definitions with descriptions and steps" }, + { + file: "app_spec.txt", + desc: "Project specification describing your app for AI context", + }, + { + file: "context/", + desc: "Additional context files (docs, examples) for AI", + }, + { + file: "features/", + desc: "Feature definitions with descriptions and steps", + }, ].map((item) => ( -
- {item.file} - {item.desc} +
+ + {item.file} + + + {item.desc} +
))}
-

Tip: App Spec Best Practices

+

+ Tip: App Spec Best Practices +

  • Include your tech stack and key dependencies
  • Describe the project structure and conventions
  • @@ -391,39 +504,68 @@ export function WikiView() {
    1. Create or Open a Project -

      Use the sidebar to create a new project or open an existing folder

      +

      + Use the sidebar to create a new project or open an existing + folder +

    2. Write an App Spec -

      Go to Spec Editor and describe your project. This helps AI understand your codebase.

      +

      + Go to Spec Editor and describe your project. This helps AI + understand your codebase. +

    3. Add Context (Optional) -

      Add relevant documentation or examples to the Context view for better AI results

      +

      + Add relevant documentation or examples to the Context view for + better AI results +

    4. Create Features -

      Add feature cards to your Kanban board with clear descriptions and implementation steps

      +

      + Add feature cards to your Kanban board with clear descriptions + and implementation steps +

    5. Configure AI Profile -

      Choose an AI profile or customize model/thinking settings per feature

      +

      + Choose an AI profile or customize model/thinking settings per + feature +

    6. Start Implementation -

      Drag features to "In Progress" or enable auto mode to let AI work

      +

      + Drag features to "In Progress" or enable auto mode to let AI + work +

    7. Review and Verify -

      Check completed features, review changes, and mark as verified

      +

      + Check completed features, review changes, and mark as verified +

    Pro Tips:

      -
    • Use keyboard shortcuts for faster navigation (press ? to see all)
    • -
    • Enable git worktree isolation for parallel feature development
    • -
    • Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
    • +
    • + Use keyboard shortcuts for faster navigation (press{" "} + ?{" "} + to see all) +
    • +
    • + Enable git worktree isolation for parallel feature development +
    • +
    • + Start with "Quick Edit" profile for simple tasks, use "Heavy + Task" for complex work +
    • Keep your app spec up to date as your project evolves
    diff --git a/apps/ui/tests/worktree-integration.spec.ts b/apps/ui/tests/worktree-integration.spec.ts index b0faf2ab..9635c210 100644 --- a/apps/ui/tests/worktree-integration.spec.ts +++ b/apps/ui/tests/worktree-integration.spec.ts @@ -779,7 +779,7 @@ test.describe("Worktree Integration Tests", () => { expect(featureData.worktreePath).toBeUndefined(); }); - test("should store branch name when adding feature with new branch (worktree created at execution)", async ({ + test("should store branch name when adding feature with new branch (worktree created when adding feature)", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); @@ -788,7 +788,7 @@ test.describe("Worktree Integration Tests", () => { await waitForBoardView(page); // Use a branch name that doesn't exist yet - // Note: Worktrees are now created at execution time, not when adding to backlog + // Note: Worktrees are now created when features are added/edited, not at execution time const branchName = "feature/auto-create-worktree"; // Verify branch does NOT exist before we create the feature @@ -799,20 +799,28 @@ test.describe("Worktree Integration Tests", () => { await clickAddFeature(page); // Fill in the feature details with the new branch - await fillAddFeatureDialog(page, "Feature that should auto-create worktree", { - branch: branchName, - category: "Testing", - }); + await fillAddFeatureDialog( + page, + "Feature that should auto-create worktree", + { + branch: branchName, + category: "Testing", + } + ); // Confirm await confirmAddFeature(page); - // Wait for feature to be saved - await page.waitForTimeout(1000); + // Wait for feature to be saved and worktree to be created + await page.waitForTimeout(2000); - // Verify branch was NOT created when adding feature (created at execution time) + // Verify branch WAS created when adding feature (worktrees are created when features are added/edited) const branchesAfter = await listBranches(testRepo.path); - expect(branchesAfter).not.toContain(branchName); + expect(branchesAfter).toContain(branchName); + + // Verify worktree was created + const worktreePath = getWorktreePath(testRepo.path, branchName); + expect(fs.existsSync(worktreePath)).toBe(true); // Verify feature was created with correct branch name stored const featuresDir = path.join(testRepo.path, ".automaker", "features"); @@ -831,13 +839,9 @@ test.describe("Worktree Integration Tests", () => { const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); - + // Verify branch name is stored expect(featureData.branchName).toBe(branchName); - - // Verify worktreePath is NOT set (worktrees are created at execution time) - expect(featureData.worktreePath).toBeUndefined(); - // Verify feature is in backlog status expect(featureData.status).toBe("backlog"); }); @@ -896,7 +900,7 @@ test.describe("Worktree Integration Tests", () => { let featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); - + // Verify feature was created with the branch name stored expect(featureData.branchName).toBe(branchName); // Verify worktreePath is NOT set (worktrees are created at execution time, not when adding) @@ -1080,7 +1084,9 @@ test.describe("Worktree Integration Tests", () => { // When a worktree is selected, "Use current selected branch" should be selected // and the branch name should be shown in the label const currentBranchLabel = page.locator('label[for="feature-current"]'); - await expect(currentBranchLabel).toContainText(branchName, { timeout: 5000 }); + await expect(currentBranchLabel).toContainText(branchName, { + timeout: 5000, + }); // Close dialog await page.keyboard.press("Escape"); @@ -1271,11 +1277,7 @@ test.describe("Worktree Integration Tests", () => { expect(featureDir).toBeDefined(); // Read the feature data - const featureFilePath = path.join( - featuresDir, - featureDir!, - "feature.json" - ); + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); expect(featureData.status).toBe("backlog"); @@ -1292,9 +1294,7 @@ test.describe("Worktree Integration Tests", () => { // Wait for the feature to move to in_progress column await expect(async () => { - const updatedData = JSON.parse( - fs.readFileSync(featureFilePath, "utf-8") - ); + const updatedData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); expect(updatedData.status).toBe("in_progress"); }).toPass({ timeout: 10000 }); @@ -1907,7 +1907,10 @@ test.describe("Worktree Integration Tests", () => { await apiCreateWorktree(page, testRepo.path, branchName); // Add a file and commit in the worktree - fs.writeFileSync(path.join(worktreePath, "merge-file.txt"), "merge content"); + fs.writeFileSync( + path.join(worktreePath, "merge-file.txt"), + "merge content" + ); await execAsync("git add merge-file.txt", { cwd: worktreePath }); await execAsync('git commit -m "Add file for merge test"', { cwd: worktreePath, @@ -2061,9 +2064,9 @@ test.describe("Worktree Integration Tests", () => { // Verify the worktree has the file from develop const worktreePath = getWorktreePath(testRepo.path, "feature/from-develop"); - expect( - fs.existsSync(path.join(worktreePath, "develop-only.txt")) - ).toBe(true); + expect(fs.existsSync(path.join(worktreePath, "develop-only.txt"))).toBe( + true + ); const content = fs.readFileSync( path.join(worktreePath, "develop-only.txt"), "utf-8" @@ -2096,10 +2099,9 @@ test.describe("Worktree Integration Tests", () => { // Verify the worktree starts from the same commit as main const worktreePath = getWorktreePath(testRepo.path, "feature/from-head"); - const { stdout: worktreeHash } = await execAsync( - "git rev-parse HEAD~0", - { cwd: worktreePath } - ); + const { stdout: worktreeHash } = await execAsync("git rev-parse HEAD~0", { + cwd: worktreePath, + }); // The worktree's initial commit should be the same as main's HEAD // (Since it was just created, we check the parent commit) @@ -2391,15 +2393,15 @@ test.describe("Worktree Integration Tests", () => { let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); // Initially, the feature should be on main or have no branch set - expect( - !featureData.branchName || featureData.branchName === "main" - ).toBe(true); + expect(!featureData.branchName || featureData.branchName === "main").toBe( + true + ); // The new branch we want to assign const newBranchName = "feature/edited-branch"; const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName); - // Verify worktree does NOT exist before editing (worktrees are created at execution time) + // Verify worktree does NOT exist before editing expect(fs.existsSync(expectedWorktreePath)).toBe(false); // Find and click the edit button on the feature card @@ -2424,7 +2426,7 @@ test.describe("Worktree Integration Tests", () => { await page.waitForTimeout(300); // Type the new branch name - const commandInput = page.locator('[cmdk-input]'); + const commandInput = page.locator("[cmdk-input]"); await commandInput.fill(newBranchName); // Press Enter to select/create the branch @@ -2435,22 +2437,19 @@ test.describe("Worktree Integration Tests", () => { const saveButton = page.locator('[data-testid="confirm-edit-feature"]'); await saveButton.click(); - // Wait for the dialog to close + // Wait for the dialog to close and worktree to be created await page.waitForTimeout(2000); - // Verify worktree was NOT created during editing (worktrees are created at execution time) - expect(fs.existsSync(expectedWorktreePath)).toBe(false); + // Verify worktree WAS created during editing (worktrees are now created when features are added/edited) + expect(fs.existsSync(expectedWorktreePath)).toBe(true); - // Verify branch was NOT created (created at execution time) + // Verify branch WAS created (worktrees are created when features are added/edited) const branches = await listBranches(testRepo.path); - expect(branches).not.toContain(newBranchName); + expect(branches).toContain(newBranchName); - // Verify feature was updated with correct branchName only - // Note: worktreePath is no longer stored - worktrees are created server-side at execution time + // Verify feature was updated with correct branchName featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); expect(featureData.branchName).toBe(newBranchName); - // worktreePath should not exist in the feature data - expect(featureData.worktreePath).toBeUndefined(); }); test("should not create worktree when editing a feature and selecting main branch", async ({ @@ -2518,7 +2517,7 @@ test.describe("Worktree Integration Tests", () => { await page.waitForTimeout(300); // Type "main" to change to main branch - const commandInput = page.locator('[cmdk-input]'); + const commandInput = page.locator("[cmdk-input]"); await commandInput.fill("main"); await commandInput.press("Enter"); await page.waitForTimeout(200); @@ -2576,7 +2575,7 @@ test.describe("Worktree Integration Tests", () => { await branchInput.click(); await page.waitForTimeout(300); - const commandInput = page.locator('[cmdk-input]'); + const commandInput = page.locator("[cmdk-input]"); await commandInput.fill(existingBranch); await commandInput.press("Enter"); await page.waitForTimeout(200); diff --git a/docs/migration-plan-nextjs-to-vite.md b/docs/migration-plan-nextjs-to-vite.md index 5b3a0016..f282f3a5 100644 --- a/docs/migration-plan-nextjs-to-vite.md +++ b/docs/migration-plan-nextjs-to-vite.md @@ -29,27 +29,27 @@ Our current Next.js implementation uses **less than 5%** of the framework's capabilities. We're essentially running a static SPA with unnecessary overhead: -| Next.js Feature | Our Usage | -|-----------------|-----------| -| Server-Side Rendering | ❌ Not used | -| Static Site Generation | ❌ Not used | -| API Routes | ⚠️ Only 2 test endpoints | -| Image Optimization | ❌ Not used | -| Dynamic Routing | ❌ Not used | -| App Router | ⚠️ File structure only | -| Metadata API | ⚠️ Title/description only | -| Static Export | βœ… Used (`output: "export"`) | +| Next.js Feature | Our Usage | +| ---------------------- | ---------------------------- | +| Server-Side Rendering | ❌ Not used | +| Static Site Generation | ❌ Not used | +| API Routes | ⚠️ Only 2 test endpoints | +| Image Optimization | ❌ Not used | +| Dynamic Routing | ❌ Not used | +| App Router | ⚠️ File structure only | +| Metadata API | ⚠️ Title/description only | +| Static Export | βœ… Used (`output: "export"`) | ### Migration Benefits -| Metric | Current (Next.js) | Expected (Vite) | -|--------|-------------------|-----------------| -| Dev server startup | ~8-15s | ~1-3s | -| HMR speed | ~500ms-2s | ~50-100ms | -| Production build | ~45-90s | ~15-30s | -| Bundle overhead | Next.js runtime | None | -| Type safety (Electron) | 0% | 100% | -| Debug capabilities | Limited | Full debug console | +| Metric | Current (Next.js) | Expected (Vite) | +| ---------------------- | ----------------- | ------------------ | +| Dev server startup | ~8-15s | ~1-3s | +| HMR speed | ~500ms-2s | ~50-100ms | +| Production build | ~45-90s | ~15-30s | +| Bundle overhead | Next.js runtime | None | +| Type safety (Electron) | 0% | 100% | +| Debug capabilities | Limited | Full debug console | ### Target Stack @@ -92,26 +92,26 @@ Our current Next.js implementation uses **less than 5%** of the framework's capa ### Current Electron Layer Issues -| Issue | Impact | Solution | -|-------|--------|----------| -| Pure JavaScript | No compile-time safety | Migrate to TypeScript | -| Untyped IPC handlers | Runtime errors | IPC Schema with generics | -| String literal channels | Typos cause silent failures | Const enums | -| No debug tooling | Hard to diagnose issues | Debug console feature | -| Monolithic main.js | Hard to maintain | Modular IPC organization | +| Issue | Impact | Solution | +| ----------------------- | --------------------------- | ------------------------ | +| Pure JavaScript | No compile-time safety | Migrate to TypeScript | +| Untyped IPC handlers | Runtime errors | IPC Schema with generics | +| String literal channels | Typos cause silent failures | Const enums | +| No debug tooling | Hard to diagnose issues | Debug console feature | +| Monolithic main.js | Hard to maintain | Modular IPC organization | ### Current Component Structure Issues -| View File | Lines | Issue | -|-----------|-------|-------| -| spec-view.tsx | 1,230 | Exceeds 500-line threshold | -| analysis-view.tsx | 1,134 | Exceeds 500-line threshold | -| agent-view.tsx | 916 | Exceeds 500-line threshold | -| welcome-view.tsx | 815 | Exceeds 500-line threshold | -| context-view.tsx | 735 | Exceeds 500-line threshold | -| terminal-view.tsx | 697 | Exceeds 500-line threshold | -| interview-view.tsx | 637 | Exceeds 500-line threshold | -| board-view.tsx | 685 | βœ… Already has subfolder structure | +| View File | Lines | Issue | +| ------------------ | ----- | ---------------------------------- | +| spec-view.tsx | 1,230 | Exceeds 500-line threshold | +| analysis-view.tsx | 1,134 | Exceeds 500-line threshold | +| agent-view.tsx | 916 | Exceeds 500-line threshold | +| welcome-view.tsx | 815 | Exceeds 500-line threshold | +| context-view.tsx | 735 | Exceeds 500-line threshold | +| terminal-view.tsx | 697 | Exceeds 500-line threshold | +| interview-view.tsx | 637 | Exceeds 500-line threshold | +| board-view.tsx | 685 | βœ… Already has subfolder structure | --- @@ -322,22 +322,22 @@ libs/types/ ```typescript // libs/types/src/models.ts export interface ModelDefinition { - id: string - name: string - provider: ProviderType - contextWindow: number - maxOutputTokens: number - capabilities: ModelCapabilities + id: string; + name: string; + provider: ProviderType; + contextWindow: number; + maxOutputTokens: number; + capabilities: ModelCapabilities; } export interface ModelCapabilities { - vision: boolean - toolUse: boolean - streaming: boolean - computerUse: boolean + vision: boolean; + toolUse: boolean; + streaming: boolean; + computerUse: boolean; } -export type ProviderType = "claude" | "openai" | "gemini" | "ollama" +export type ProviderType = "claude" | "openai" | "gemini" | "ollama"; ``` ### @automaker/utils @@ -366,21 +366,21 @@ export type ErrorType = | "validation" | "not_found" | "server" - | "unknown" + | "unknown"; export interface ErrorInfo { - type: ErrorType - message: string - userMessage: string - retryable: boolean - statusCode?: number + type: ErrorType; + message: string; + userMessage: string; + retryable: boolean; + statusCode?: number; } -export function classifyError(error: unknown): ErrorInfo -export function getUserFriendlyErrorMessage(error: unknown): string -export function isAbortError(error: unknown): boolean -export function isAuthenticationError(error: unknown): boolean -export function isRateLimitError(error: unknown): boolean +export function classifyError(error: unknown): ErrorInfo; +export function getUserFriendlyErrorMessage(error: unknown): string; +export function isAbortError(error: unknown): boolean; +export function isAuthenticationError(error: unknown): boolean; +export function isRateLimitError(error: unknown): boolean; ``` ### @automaker/platform @@ -412,18 +412,18 @@ libs/platform/ ```typescript // libs/platform/src/paths/path-resolver.ts -import path from "path" +import path from "path"; /** * Platform-aware path separator */ -export const SEP = path.sep +export const SEP = path.sep; /** * Normalizes a path to use the correct separator for the current OS */ export function normalizePath(inputPath: string): string { - return inputPath.replace(/[/\\]/g, SEP) + return inputPath.replace(/[/\\]/g, SEP); } /** @@ -431,80 +431,83 @@ export function normalizePath(inputPath: string): string { * Useful for consistent storage/comparison */ export function toPosixPath(inputPath: string): string { - return inputPath.replace(/\\/g, "/") + return inputPath.replace(/\\/g, "/"); } /** * Converts a path to Windows format (backslashes) */ export function toWindowsPath(inputPath: string): string { - return inputPath.replace(/\//g, "\\") + return inputPath.replace(/\//g, "\\"); } /** * Resolves a path relative to a base, handling platform differences */ export function resolvePath(basePath: string, ...segments: string[]): string { - return path.resolve(basePath, ...segments) + return path.resolve(basePath, ...segments); } /** * Gets the relative path from one location to another */ export function getRelativePath(from: string, to: string): string { - return path.relative(from, to) + return path.relative(from, to); } /** * Joins path segments with proper platform separator */ export function joinPath(...segments: string[]): string { - return path.join(...segments) + return path.join(...segments); } /** * Extracts directory name from a path */ export function getDirname(filePath: string): string { - return path.dirname(filePath) + return path.dirname(filePath); } /** * Extracts filename from a path */ export function getBasename(filePath: string, ext?: string): string { - return path.basename(filePath, ext) + return path.basename(filePath, ext); } /** * Extracts file extension from a path */ export function getExtension(filePath: string): string { - return path.extname(filePath) + return path.extname(filePath); } /** * Checks if a path is absolute */ export function isAbsolutePath(inputPath: string): boolean { - return path.isAbsolute(inputPath) + return path.isAbsolute(inputPath); } /** * Ensures a path is absolute, resolving relative to cwd if needed */ -export function ensureAbsolutePath(inputPath: string, basePath?: string): string { +export function ensureAbsolutePath( + inputPath: string, + basePath?: string +): string { if (isAbsolutePath(inputPath)) { - return inputPath + return inputPath; } - return resolvePath(basePath || process.cwd(), inputPath) + return resolvePath(basePath || process.cwd(), inputPath); } ``` ```typescript // libs/platform/src/paths/path-constants.ts -import path from "path" -import os from "os" +import path from "path"; +import os from "os"; /** * Common system paths @@ -518,21 +521,27 @@ export const SYSTEM_PATHS = { /** Current working directory */ cwd: process.cwd(), -} as const +} as const; /** * Gets the appropriate app data directory for the current platform */ export function getAppDataPath(appName: string): string { - const platform = process.platform + const platform = process.platform; switch (platform) { case "win32": - return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), appName) + return path.join( + process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), + appName + ); case "darwin": - return path.join(os.homedir(), "Library", "Application Support", appName) + return path.join(os.homedir(), "Library", "Application Support", appName); default: // Linux and others - return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), appName) + return path.join( + process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), + appName + ); } } @@ -540,15 +549,22 @@ export function getAppDataPath(appName: string): string { * Gets the appropriate cache directory for the current platform */ export function getCachePath(appName: string): string { - const platform = process.platform + const platform = process.platform; switch (platform) { case "win32": - return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), appName, "Cache") + return path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), + appName, + "Cache" + ); case "darwin": - return path.join(os.homedir(), "Library", "Caches", appName) + return path.join(os.homedir(), "Library", "Caches", appName); default: - return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), appName) + return path.join( + process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), + appName + ); } } @@ -556,15 +572,24 @@ export function getCachePath(appName: string): string { * Gets the appropriate logs directory for the current platform */ export function getLogsPath(appName: string): string { - const platform = process.platform + const platform = process.platform; switch (platform) { case "win32": - return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), appName, "Logs") + return path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), + appName, + "Logs" + ); case "darwin": - return path.join(os.homedir(), "Library", "Logs", appName) + return path.join(os.homedir(), "Library", "Logs", appName); default: - return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), appName, "logs") + return path.join( + process.env.XDG_STATE_HOME || + path.join(os.homedir(), ".local", "state"), + appName, + "logs" + ); } } @@ -572,17 +597,19 @@ export function getLogsPath(appName: string): string { * Gets the user's Documents directory */ export function getDocumentsPath(): string { - const platform = process.platform + const platform = process.platform; switch (platform) { case "win32": return process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "Documents") - : path.join(os.homedir(), "Documents") + : path.join(os.homedir(), "Documents"); case "darwin": - return path.join(os.homedir(), "Documents") + return path.join(os.homedir(), "Documents"); default: - return process.env.XDG_DOCUMENTS_DIR || path.join(os.homedir(), "Documents") + return ( + process.env.XDG_DOCUMENTS_DIR || path.join(os.homedir(), "Documents") + ); } } @@ -590,87 +617,106 @@ export function getDocumentsPath(): string { * Gets the user's Desktop directory */ export function getDesktopPath(): string { - const platform = process.platform + const platform = process.platform; switch (platform) { case "win32": return process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "Desktop") - : path.join(os.homedir(), "Desktop") + : path.join(os.homedir(), "Desktop"); case "darwin": - return path.join(os.homedir(), "Desktop") + return path.join(os.homedir(), "Desktop"); default: - return process.env.XDG_DESKTOP_DIR || path.join(os.homedir(), "Desktop") + return process.env.XDG_DESKTOP_DIR || path.join(os.homedir(), "Desktop"); } } ``` ```typescript // libs/platform/src/paths/path-validator.ts -import path from "path" -import { isAbsolutePath } from "./path-resolver" +import path from "path"; +import { isAbsolutePath } from "./path-resolver"; /** * Characters that are invalid in file/directory names on Windows */ -const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g +const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g; /** * Reserved names on Windows (case-insensitive) */ const WINDOWS_RESERVED_NAMES = [ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" -] + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +]; export interface PathValidationResult { - valid: boolean - errors: string[] - sanitized?: string + valid: boolean; + errors: string[]; + sanitized?: string; } /** * Validates a filename for the current platform */ export function validateFilename(filename: string): PathValidationResult { - const errors: string[] = [] + const errors: string[] = []; if (!filename || filename.trim().length === 0) { - return { valid: false, errors: ["Filename cannot be empty"] } + return { valid: false, errors: ["Filename cannot be empty"] }; } // Check for path separators (filename shouldn't be a path) if (filename.includes("/") || filename.includes("\\")) { - errors.push("Filename cannot contain path separators") + errors.push("Filename cannot contain path separators"); } // Platform-specific checks if (process.platform === "win32") { if (WINDOWS_INVALID_CHARS.test(filename)) { - errors.push("Filename contains invalid characters for Windows") + errors.push("Filename contains invalid characters for Windows"); } - const nameWithoutExt = filename.split(".")[0].toUpperCase() + const nameWithoutExt = filename.split(".")[0].toUpperCase(); if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - errors.push(`"${nameWithoutExt}" is a reserved name on Windows`) + errors.push(`"${nameWithoutExt}" is a reserved name on Windows`); } if (filename.endsWith(" ") || filename.endsWith(".")) { - errors.push("Filename cannot end with a space or period on Windows") + errors.push("Filename cannot end with a space or period on Windows"); } } // Check length if (filename.length > 255) { - errors.push("Filename exceeds maximum length of 255 characters") + errors.push("Filename exceeds maximum length of 255 characters"); } return { valid: errors.length === 0, errors, - sanitized: errors.length > 0 ? sanitizeFilename(filename) : filename - } + sanitized: errors.length > 0 ? sanitizeFilename(filename) : filename, + }; } /** @@ -680,98 +726,102 @@ export function sanitizeFilename(filename: string): string { let sanitized = filename .replace(WINDOWS_INVALID_CHARS, "_") .replace(/[/\\]/g, "_") - .trim() + .trim(); // Handle Windows reserved names - const nameWithoutExt = sanitized.split(".")[0].toUpperCase() + const nameWithoutExt = sanitized.split(".")[0].toUpperCase(); if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - sanitized = "_" + sanitized + sanitized = "_" + sanitized; } // Remove trailing spaces and periods (Windows) - sanitized = sanitized.replace(/[\s.]+$/, "") + sanitized = sanitized.replace(/[\s.]+$/, ""); // Ensure not empty if (!sanitized) { - sanitized = "unnamed" + sanitized = "unnamed"; } // Truncate if too long if (sanitized.length > 255) { - const ext = path.extname(sanitized) - const name = path.basename(sanitized, ext) - sanitized = name.slice(0, 255 - ext.length) + ext + const ext = path.extname(sanitized); + const name = path.basename(sanitized, ext); + sanitized = name.slice(0, 255 - ext.length) + ext; } - return sanitized + return sanitized; } /** * Validates a full path for the current platform */ export function validatePath(inputPath: string): PathValidationResult { - const errors: string[] = [] + const errors: string[] = []; if (!inputPath || inputPath.trim().length === 0) { - return { valid: false, errors: ["Path cannot be empty"] } + return { valid: false, errors: ["Path cannot be empty"] }; } // Check total path length - const maxPathLength = process.platform === "win32" ? 260 : 4096 + const maxPathLength = process.platform === "win32" ? 260 : 4096; if (inputPath.length > maxPathLength) { - errors.push(`Path exceeds maximum length of ${maxPathLength} characters`) + errors.push(`Path exceeds maximum length of ${maxPathLength} characters`); } // Validate each segment - const segments = inputPath.split(/[/\\]/).filter(Boolean) + const segments = inputPath.split(/[/\\]/).filter(Boolean); for (const segment of segments) { // Skip drive letters on Windows if (process.platform === "win32" && /^[a-zA-Z]:$/.test(segment)) { - continue + continue; } - const segmentValidation = validateFilename(segment) + const segmentValidation = validateFilename(segment); if (!segmentValidation.valid) { - errors.push(...segmentValidation.errors.map(e => `Segment "${segment}": ${e}`)) + errors.push( + ...segmentValidation.errors.map((e) => `Segment "${segment}": ${e}`) + ); } } return { valid: errors.length === 0, - errors - } + errors, + }; } /** * Checks if a path is within a base directory (prevents directory traversal) */ export function isPathWithin(childPath: string, parentPath: string): boolean { - const resolvedChild = path.resolve(childPath) - const resolvedParent = path.resolve(parentPath) + const resolvedChild = path.resolve(childPath); + const resolvedParent = path.resolve(parentPath); - return resolvedChild.startsWith(resolvedParent + path.sep) || - resolvedChild === resolvedParent + return ( + resolvedChild.startsWith(resolvedParent + path.sep) || + resolvedChild === resolvedParent + ); } ``` ```typescript // libs/platform/src/os/platform-info.ts -import os from "os" +import os from "os"; -export type Platform = "windows" | "macos" | "linux" | "unknown" -export type Architecture = "x64" | "arm64" | "ia32" | "unknown" +export type Platform = "windows" | "macos" | "linux" | "unknown"; +export type Architecture = "x64" | "arm64" | "ia32" | "unknown"; export interface PlatformInfo { - platform: Platform - arch: Architecture - release: string - hostname: string - username: string - cpus: number - totalMemory: number - freeMemory: number - isWsl: boolean - isDocker: boolean + platform: Platform; + arch: Architecture; + release: string; + hostname: string; + username: string; + cpus: number; + totalMemory: number; + freeMemory: number; + isWsl: boolean; + isDocker: boolean; } /** @@ -780,13 +830,13 @@ export interface PlatformInfo { export function getPlatform(): Platform { switch (process.platform) { case "win32": - return "windows" + return "windows"; case "darwin": - return "macos" + return "macos"; case "linux": - return "linux" + return "linux"; default: - return "unknown" + return "unknown"; } } @@ -796,13 +846,13 @@ export function getPlatform(): Platform { export function getArchitecture(): Architecture { switch (process.arch) { case "x64": - return "x64" + return "x64"; case "arm64": - return "arm64" + return "arm64"; case "ia32": - return "ia32" + return "ia32"; default: - return "unknown" + return "unknown"; } } @@ -810,34 +860,34 @@ export function getArchitecture(): Architecture { * Checks if running on Windows */ export function isWindows(): boolean { - return process.platform === "win32" + return process.platform === "win32"; } /** * Checks if running on macOS */ export function isMacOS(): boolean { - return process.platform === "darwin" + return process.platform === "darwin"; } /** * Checks if running on Linux */ export function isLinux(): boolean { - return process.platform === "linux" + return process.platform === "linux"; } /** * Checks if running in WSL (Windows Subsystem for Linux) */ export function isWsl(): boolean { - if (process.platform !== "linux") return false + if (process.platform !== "linux") return false; try { - const release = os.release().toLowerCase() - return release.includes("microsoft") || release.includes("wsl") + const release = os.release().toLowerCase(); + return release.includes("microsoft") || release.includes("wsl"); } catch { - return false + return false; } } @@ -846,12 +896,14 @@ export function isWsl(): boolean { */ export function isDocker(): boolean { try { - const fs = require("fs") - return fs.existsSync("/.dockerenv") || - (fs.existsSync("/proc/1/cgroup") && - fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker")) + const fs = require("fs"); + return ( + fs.existsSync("/.dockerenv") || + (fs.existsSync("/proc/1/cgroup") && + fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker")) + ); } catch { - return false + return false; } } @@ -869,33 +921,33 @@ export function getPlatformInfo(): PlatformInfo { totalMemory: os.totalmem(), freeMemory: os.freemem(), isWsl: isWsl(), - isDocker: isDocker() - } + isDocker: isDocker(), + }; } /** * Gets the appropriate line ending for the current platform */ export function getLineEnding(): string { - return isWindows() ? "\r\n" : "\n" + return isWindows() ? "\r\n" : "\n"; } /** * Normalizes line endings to the current platform */ export function normalizeLineEndings(text: string): string { - const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - return isWindows() ? normalized.replace(/\n/g, "\r\n") : normalized + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + return isWindows() ? normalized.replace(/\n/g, "\r\n") : normalized; } ``` ```typescript // libs/platform/src/os/shell-commands.ts -import { isWindows, isMacOS } from "./platform-info" +import { isWindows, isMacOS } from "./platform-info"; export interface ShellCommand { - command: string - args: string[] + command: string; + args: string[]; } /** @@ -903,11 +955,11 @@ export interface ShellCommand { */ export function getOpenCommand(target: string): ShellCommand { if (isWindows()) { - return { command: "cmd", args: ["/c", "start", "", target] } + return { command: "cmd", args: ["/c", "start", "", target] }; } else if (isMacOS()) { - return { command: "open", args: [target] } + return { command: "open", args: [target] }; } else { - return { command: "xdg-open", args: [target] } + return { command: "xdg-open", args: [target] }; } } @@ -916,12 +968,12 @@ export function getOpenCommand(target: string): ShellCommand { */ export function getRevealCommand(filePath: string): ShellCommand { if (isWindows()) { - return { command: "explorer", args: ["/select,", filePath] } + return { command: "explorer", args: ["/select,", filePath] }; } else if (isMacOS()) { - return { command: "open", args: ["-R", filePath] } + return { command: "open", args: ["-R", filePath] }; } else { // Linux: try multiple file managers - return { command: "xdg-open", args: [require("path").dirname(filePath)] } + return { command: "xdg-open", args: [require("path").dirname(filePath)] }; } } @@ -930,9 +982,9 @@ export function getRevealCommand(filePath: string): ShellCommand { */ export function getDefaultShell(): string { if (isWindows()) { - return process.env.COMSPEC || "cmd.exe" + return process.env.COMSPEC || "cmd.exe"; } - return process.env.SHELL || "/bin/sh" + return process.env.SHELL || "/bin/sh"; } /** @@ -940,9 +992,9 @@ export function getDefaultShell(): string { */ export function getShellArgs(command: string): ShellCommand { if (isWindows()) { - return { command: "cmd.exe", args: ["/c", command] } + return { command: "cmd.exe", args: ["/c", command] }; } - return { command: "/bin/sh", args: ["-c", command] } + return { command: "/bin/sh", args: ["-c", command] }; } /** @@ -951,52 +1003,52 @@ export function getShellArgs(command: string): ShellCommand { export function escapeShellArg(arg: string): string { if (isWindows()) { // Windows cmd.exe escaping - return `"${arg.replace(/"/g, '""')}"` + return `"${arg.replace(/"/g, '""')}"`; } // POSIX shell escaping - return `'${arg.replace(/'/g, "'\\''")}'` + return `'${arg.replace(/'/g, "'\\''")}'`; } ``` ```typescript // libs/platform/src/os/env-utils.ts -import { isWindows } from "./platform-info" +import { isWindows } from "./platform-info"; /** * Gets an environment variable with a fallback */ export function getEnv(key: string, fallback?: string): string | undefined { - return process.env[key] ?? fallback + return process.env[key] ?? fallback; } /** * Gets an environment variable, throwing if not set */ export function requireEnv(key: string): string { - const value = process.env[key] + const value = process.env[key]; if (value === undefined) { - throw new Error(`Required environment variable "${key}" is not set`) + throw new Error(`Required environment variable "${key}" is not set`); } - return value + return value; } /** * Parses a boolean environment variable */ export function getBoolEnv(key: string, fallback = false): boolean { - const value = process.env[key] - if (value === undefined) return fallback - return ["true", "1", "yes", "on"].includes(value.toLowerCase()) + const value = process.env[key]; + if (value === undefined) return fallback; + return ["true", "1", "yes", "on"].includes(value.toLowerCase()); } /** * Parses a numeric environment variable */ export function getNumericEnv(key: string, fallback: number): number { - const value = process.env[key] - if (value === undefined) return fallback - const parsed = parseInt(value, 10) - return isNaN(parsed) ? fallback : parsed + const value = process.env[key]; + if (value === undefined) return fallback; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? fallback : parsed; } /** @@ -1004,60 +1056,68 @@ export function getNumericEnv(key: string, fallback: number): number { * Supports both $VAR and ${VAR} syntax, plus %VAR% on Windows */ export function expandEnvVars(input: string): string { - let result = input + let result = input; // Expand ${VAR} syntax - result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || "") + result = result.replace( + /\$\{([^}]+)\}/g, + (_, name) => process.env[name] || "" + ); // Expand $VAR syntax (not followed by another word char) - result = result.replace(/\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])/g, (_, name) => process.env[name] || "") + result = result.replace( + /\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])/g, + (_, name) => process.env[name] || "" + ); // Expand %VAR% syntax (Windows) if (isWindows()) { - result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] || "") + result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ""); } - return result + return result; } /** * Gets the PATH environment variable as an array */ export function getPathEntries(): string[] { - const pathVar = process.env.PATH || process.env.Path || "" - const separator = isWindows() ? ";" : ":" - return pathVar.split(separator).filter(Boolean) + const pathVar = process.env.PATH || process.env.Path || ""; + const separator = isWindows() ? ";" : ":"; + return pathVar.split(separator).filter(Boolean); } /** * Checks if a command is available in PATH */ export function isCommandInPath(command: string): boolean { - const pathEntries = getPathEntries() - const extensions = isWindows() ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") : [""] - const path = require("path") - const fs = require("fs") + const pathEntries = getPathEntries(); + const extensions = isWindows() + ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") + : [""]; + const path = require("path"); + const fs = require("fs"); for (const dir of pathEntries) { for (const ext of extensions) { - const fullPath = path.join(dir, command + ext) + const fullPath = path.join(dir, command + ext); try { - fs.accessSync(fullPath, fs.constants.X_OK) - return true + fs.accessSync(fullPath, fs.constants.X_OK); + return true; } catch { // Continue searching } } } - return false + return false; } ``` ```typescript // libs/platform/src/fs/safe-fs.ts -import fs from "fs" -import path from "path" +import fs from "fs"; +import path from "path"; /** * Safely reads a file, following symlinks but preventing escape from base directory @@ -1067,19 +1127,19 @@ export async function safeReadFile( basePath: string, encoding: BufferEncoding = "utf8" ): Promise { - const resolvedPath = path.resolve(filePath) - const resolvedBase = path.resolve(basePath) + const resolvedPath = path.resolve(filePath); + const resolvedBase = path.resolve(basePath); // Resolve symlinks - const realPath = await fs.promises.realpath(resolvedPath) - const realBase = await fs.promises.realpath(resolvedBase) + const realPath = await fs.promises.realpath(resolvedPath); + const realBase = await fs.promises.realpath(resolvedBase); // Ensure resolved path is within base if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) { - throw new Error(`Path "${filePath}" resolves outside of allowed directory`) + throw new Error(`Path "${filePath}" resolves outside of allowed directory`); } - return fs.promises.readFile(realPath, encoding) + return fs.promises.readFile(realPath, encoding); } /** @@ -1090,33 +1150,39 @@ export async function safeWriteFile( basePath: string, content: string ): Promise { - const resolvedPath = path.resolve(filePath) - const resolvedBase = path.resolve(basePath) + const resolvedPath = path.resolve(filePath); + const resolvedBase = path.resolve(basePath); // Ensure path is within base before any symlink resolution - if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { - throw new Error(`Path "${filePath}" is outside of allowed directory`) + if ( + !resolvedPath.startsWith(resolvedBase + path.sep) && + resolvedPath !== resolvedBase + ) { + throw new Error(`Path "${filePath}" is outside of allowed directory`); } // Check parent directory exists and is within base - const parentDir = path.dirname(resolvedPath) + const parentDir = path.dirname(resolvedPath); try { - const realParent = await fs.promises.realpath(parentDir) - const realBase = await fs.promises.realpath(resolvedBase) + const realParent = await fs.promises.realpath(parentDir); + const realBase = await fs.promises.realpath(resolvedBase); - if (!realParent.startsWith(realBase + path.sep) && realParent !== realBase) { - throw new Error(`Parent directory resolves outside of allowed directory`) + if ( + !realParent.startsWith(realBase + path.sep) && + realParent !== realBase + ) { + throw new Error(`Parent directory resolves outside of allowed directory`); } } catch (error) { // Parent doesn't exist, that's OK - we'll create it if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error + throw error; } } - await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true }) - await fs.promises.writeFile(resolvedPath, content, "utf8") + await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.promises.writeFile(resolvedPath, content, "utf8"); } /** @@ -1124,10 +1190,10 @@ export async function safeWriteFile( */ export async function pathExists(filePath: string): Promise { try { - await fs.promises.access(filePath) - return true + await fs.promises.access(filePath); + return true; } catch { - return false + return false; } } @@ -1136,9 +1202,9 @@ export async function pathExists(filePath: string): Promise { */ export async function safeStat(filePath: string): Promise { try { - return await fs.promises.stat(filePath) + return await fs.promises.stat(filePath); } catch { - return null + return null; } } @@ -1146,27 +1212,24 @@ export async function safeStat(filePath: string): Promise { * Recursively removes a directory */ export async function removeDirectory(dirPath: string): Promise { - await fs.promises.rm(dirPath, { recursive: true, force: true }) + await fs.promises.rm(dirPath, { recursive: true, force: true }); } /** * Copies a file or directory */ export async function copy(src: string, dest: string): Promise { - const stats = await fs.promises.stat(src) + const stats = await fs.promises.stat(src); if (stats.isDirectory()) { - await fs.promises.mkdir(dest, { recursive: true }) - const entries = await fs.promises.readdir(src, { withFileTypes: true }) + await fs.promises.mkdir(dest, { recursive: true }); + const entries = await fs.promises.readdir(src, { withFileTypes: true }); for (const entry of entries) { - await copy( - path.join(src, entry.name), - path.join(dest, entry.name) - ) + await copy(path.join(src, entry.name), path.join(dest, entry.name)); } } else { - await fs.promises.copyFile(src, dest) + await fs.promises.copyFile(src, dest); } } ``` @@ -1176,17 +1239,17 @@ export async function copy(src: string, dest: string): Promise { // Main barrel export // Path utilities -export * from "./paths/path-resolver" -export * from "./paths/path-constants" -export * from "./paths/path-validator" +export * from "./paths/path-resolver"; +export * from "./paths/path-constants"; +export * from "./paths/path-validator"; // OS utilities -export * from "./os/platform-info" -export * from "./os/shell-commands" -export * from "./os/env-utils" +export * from "./os/platform-info"; +export * from "./os/shell-commands"; +export * from "./os/env-utils"; // File system utilities -export * from "./fs/safe-fs" +export * from "./fs/safe-fs"; ``` ### @automaker/model-resolver @@ -1226,96 +1289,114 @@ libs/ipc-types/ ```typescript // electron/ipc/ipc-schema.ts -import type { OpenDialogOptions, SaveDialogOptions } from "electron" +import type { OpenDialogOptions, SaveDialogOptions } from "electron"; // Dialog result types export interface DialogResult { - canceled: boolean - filePaths?: string[] - filePath?: string - data?: T + canceled: boolean; + filePaths?: string[]; + filePath?: string; + data?: T; } // App path names (from Electron) export type AppPathName = - | "home" | "appData" | "userData" | "sessionData" - | "temp" | "exe" | "module" | "desktop" - | "documents" | "downloads" | "music" - | "pictures" | "videos" | "recent" | "logs" | "crashDumps" + | "home" + | "appData" + | "userData" + | "sessionData" + | "temp" + | "exe" + | "module" + | "desktop" + | "documents" + | "downloads" + | "music" + | "pictures" + | "videos" + | "recent" + | "logs" + | "crashDumps"; // Complete IPC Schema with request/response types export interface IPCSchema { // Dialog operations "dialog:openDirectory": { - request: Partial - response: DialogResult - } + request: Partial; + response: DialogResult; + }; "dialog:openFile": { - request: Partial - response: DialogResult - } + request: Partial; + response: DialogResult; + }; "dialog:saveFile": { - request: Partial - response: DialogResult - } + request: Partial; + response: DialogResult; + }; // Shell operations "shell:openExternal": { - request: { url: string } - response: { success: boolean; error?: string } - } + request: { url: string }; + response: { success: boolean; error?: string }; + }; "shell:openPath": { - request: { path: string } - response: { success: boolean; error?: string } - } + request: { path: string }; + response: { success: boolean; error?: string }; + }; // App info "app:getPath": { - request: { name: AppPathName } - response: string - } + request: { name: AppPathName }; + response: string; + }; "app:getVersion": { - request: void - response: string - } + request: void; + response: string; + }; "app:isPackaged": { - request: void - response: boolean - } + request: void; + response: boolean; + }; // Server management "server:getUrl": { - request: void - response: string - } + request: void; + response: string; + }; // Connection test - "ping": { - request: void - response: "pong" - } + ping: { + request: void; + response: "pong"; + }; // Debug console "debug:log": { request: { - level: DebugLogLevel - category: DebugCategory - message: string - args: unknown[] - } - response: void - } + level: DebugLogLevel; + category: DebugCategory; + message: string; + args: unknown[]; + }; + response: void; + }; } -export type DebugLogLevel = "info" | "warn" | "error" | "debug" | "success" +export type DebugLogLevel = "info" | "warn" | "error" | "debug" | "success"; export type DebugCategory = - | "general" | "ipc" | "route" | "network" - | "perf" | "state" | "lifecycle" | "updater" + | "general" + | "ipc" + | "route" + | "network" + | "perf" + | "state" + | "lifecycle" + | "updater"; // Type extractors -export type IPCChannel = keyof IPCSchema -export type IPCRequest = IPCSchema[T]["request"] -export type IPCResponse = IPCSchema[T]["response"] +export type IPCChannel = keyof IPCSchema; +export type IPCRequest = IPCSchema[T]["request"]; +export type IPCResponse = IPCSchema[T]["response"]; ``` ### Modular IPC Organization @@ -1326,12 +1407,12 @@ export const DIALOG_CHANNELS = { OPEN_DIRECTORY: "dialog:openDirectory", OPEN_FILE: "dialog:openFile", SAVE_FILE: "dialog:saveFile", -} as const +} as const; // electron/ipc/dialog/dialog-context.ts -import { contextBridge, ipcRenderer } from "electron" -import { DIALOG_CHANNELS } from "./dialog-channels" -import type { IPCRequest, IPCResponse } from "../ipc-schema" +import { contextBridge, ipcRenderer } from "electron"; +import { DIALOG_CHANNELS } from "./dialog-channels"; +import type { IPCRequest, IPCResponse } from "../ipc-schema"; export function exposeDialogContext(): void { contextBridge.exposeInMainWorld("dialogAPI", { @@ -1343,65 +1424,69 @@ export function exposeDialogContext(): void { saveFile: (options?: IPCRequest<"dialog:saveFile">) => ipcRenderer.invoke(DIALOG_CHANNELS.SAVE_FILE, options), - }) + }); } // electron/ipc/dialog/dialog-listeners.ts -import { ipcMain, dialog, BrowserWindow } from "electron" -import { DIALOG_CHANNELS } from "./dialog-channels" -import type { IPCRequest, IPCResponse } from "../ipc-schema" -import { debugLog } from "../../helpers/debug-mode" +import { ipcMain, dialog, BrowserWindow } from "electron"; +import { DIALOG_CHANNELS } from "./dialog-channels"; +import type { IPCRequest, IPCResponse } from "../ipc-schema"; +import { debugLog } from "../../helpers/debug-mode"; export function addDialogEventListeners(mainWindow: BrowserWindow): void { ipcMain.handle( DIALOG_CHANNELS.OPEN_DIRECTORY, async (_, options: IPCRequest<"dialog:openDirectory"> = {}) => { - debugLog.ipc(`OPEN_DIRECTORY called with options: ${JSON.stringify(options)}`) + debugLog.ipc( + `OPEN_DIRECTORY called with options: ${JSON.stringify(options)}` + ); const result = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory", "createDirectory"], ...options, - }) + }); - debugLog.ipc(`OPEN_DIRECTORY result: canceled=${result.canceled}, paths=${result.filePaths.length}`) + debugLog.ipc( + `OPEN_DIRECTORY result: canceled=${result.canceled}, paths=${result.filePaths.length}` + ); return { canceled: result.canceled, filePaths: result.filePaths, - } satisfies IPCResponse<"dialog:openDirectory"> + } satisfies IPCResponse<"dialog:openDirectory">; } - ) + ); ipcMain.handle( DIALOG_CHANNELS.OPEN_FILE, async (_, options: IPCRequest<"dialog:openFile"> = {}) => { - debugLog.ipc(`OPEN_FILE called`) + debugLog.ipc(`OPEN_FILE called`); const result = await dialog.showOpenDialog(mainWindow, { properties: ["openFile"], ...options, - }) + }); return { canceled: result.canceled, filePaths: result.filePaths, - } satisfies IPCResponse<"dialog:openFile"> + } satisfies IPCResponse<"dialog:openFile">; } - ) + ); ipcMain.handle( DIALOG_CHANNELS.SAVE_FILE, async (_, options: IPCRequest<"dialog:saveFile"> = {}) => { - debugLog.ipc(`SAVE_FILE called`) + debugLog.ipc(`SAVE_FILE called`); - const result = await dialog.showSaveDialog(mainWindow, options) + const result = await dialog.showSaveDialog(mainWindow, options); return { canceled: result.canceled, filePath: result.filePath, - } satisfies IPCResponse<"dialog:saveFile"> + } satisfies IPCResponse<"dialog:saveFile">; } - ) + ); } ``` @@ -1411,19 +1496,19 @@ export function addDialogEventListeners(mainWindow: BrowserWindow): void { ### Priority Matrix -| Priority | View | Lines | Action Required | -|----------|------|-------|-----------------| -| πŸ”΄ P0 | spec-view | 1,230 | Create subfolder with components/, dialogs/, hooks/ | -| πŸ”΄ P0 | analysis-view | 1,134 | Create subfolder with components/, dialogs/, hooks/ | -| πŸ”΄ P0 | agent-view | 916 | Create subfolder, extract message list, input, sidebar | -| 🟑 P1 | welcome-view | 815 | Create subfolder, extract sections | -| 🟑 P1 | context-view | 735 | Create subfolder, extract components | -| 🟑 P1 | terminal-view | 697 | Expand existing subfolder | -| 🟑 P1 | interview-view | 637 | Create subfolder | -| 🟒 P2 | settings-view | 178 | Move dialogs from components/ to dialogs/ | -| βœ… Done | board-view | 685 | Already properly structured | -| βœ… Done | setup-view | 144 | Already properly structured | -| βœ… Done | profiles-view | 300 | Already properly structured | +| Priority | View | Lines | Action Required | +| -------- | -------------- | ----- | ------------------------------------------------------ | +| πŸ”΄ P0 | spec-view | 1,230 | Create subfolder with components/, dialogs/, hooks/ | +| πŸ”΄ P0 | analysis-view | 1,134 | Create subfolder with components/, dialogs/, hooks/ | +| πŸ”΄ P0 | agent-view | 916 | Create subfolder, extract message list, input, sidebar | +| 🟑 P1 | welcome-view | 815 | Create subfolder, extract sections | +| 🟑 P1 | context-view | 735 | Create subfolder, extract components | +| 🟑 P1 | terminal-view | 697 | Expand existing subfolder | +| 🟑 P1 | interview-view | 637 | Create subfolder | +| 🟒 P2 | settings-view | 178 | Move dialogs from components/ to dialogs/ | +| βœ… Done | board-view | 685 | Already properly structured | +| βœ… Done | setup-view | 144 | Already properly structured | +| βœ… Done | profiles-view | 300 | Already properly structured | ### Immediate Dialog Reorganization @@ -1448,8 +1533,8 @@ mv components/delete-all-archived-sessions-dialog.tsx β†’ agent-view/dialogs/ ```typescript // src/lib/platform.ts -export const isElectron = typeof window !== "undefined" && - "electronAPI" in window +export const isElectron = + typeof window !== "undefined" && "electronAPI" in window; export const platform = { isElectron, @@ -1457,63 +1542,69 @@ export const platform = { isMac: isElectron ? window.electronAPI.platform === "darwin" : false, isWindows: isElectron ? window.electronAPI.platform === "win32" : false, isLinux: isElectron ? window.electronAPI.platform === "linux" : false, -} +}; ``` ### API Abstraction Layer ```typescript // src/lib/api/file-picker.ts -import { platform } from "../platform" +import { platform } from "../platform"; export interface FilePickerResult { - canceled: boolean - paths: string[] + canceled: boolean; + paths: string[]; } export async function pickDirectory(): Promise { if (platform.isElectron) { - const result = await window.dialogAPI.openDirectory() - return { canceled: result.canceled, paths: result.filePaths || [] } + const result = await window.dialogAPI.openDirectory(); + return { canceled: result.canceled, paths: result.filePaths || [] }; } // Web fallback using File System Access API try { - const handle = await window.showDirectoryPicker() - return { canceled: false, paths: [handle.name] } + const handle = await window.showDirectoryPicker(); + return { canceled: false, paths: [handle.name] }; } catch (error) { if ((error as Error).name === "AbortError") { - return { canceled: true, paths: [] } + return { canceled: true, paths: [] }; } - throw error + throw error; } } export async function pickFile(options?: { - accept?: Record + accept?: Record; }): Promise { if (platform.isElectron) { const result = await window.dialogAPI.openFile({ filters: options?.accept - ? Object.entries(options.accept).map(([name, extensions]) => ({ name, extensions })) - : undefined - }) - return { canceled: result.canceled, paths: result.filePaths || [] } + ? Object.entries(options.accept).map(([name, extensions]) => ({ + name, + extensions, + })) + : undefined, + }); + return { canceled: result.canceled, paths: result.filePaths || [] }; } // Web fallback try { const [handle] = await window.showOpenFilePicker({ types: options?.accept - ? Object.entries(options.accept).map(([description, accept]) => ({ description, accept: { "application/*": accept } })) - : undefined - }) - return { canceled: false, paths: [handle.name] } + ? Object.entries(options.accept).map(([description, accept]) => ({ + description, + accept: { "application/*": accept }, + })) + : undefined, + }); + return { canceled: false, paths: [handle.name] }; } catch (error) { if ((error as Error).name === "AbortError") { - return { canceled: true, paths: [] } + return { canceled: true, paths: [] }; } - throw error + throw error; } } ``` @@ -1536,6 +1627,7 @@ export async function pickFile(options?: { - [x] Create `index.html` for Vite entry **Deliverables**: + - [x] Working Vite dev server - [x] TypeScript Electron main process - [ ] Debug console functional (deferred) @@ -1555,6 +1647,7 @@ export async function pickFile(options?: { - [ ] Test Web build (needs verification) **Additional completed tasks**: + - [x] Remove all "use client" directives (not needed in Vite) - [x] Replace all `setCurrentView()` calls with TanStack Router `navigate()` - [x] Rename `apps/app` to `apps/ui` @@ -1564,6 +1657,7 @@ export async function pickFile(options?: { - [x] Remove PostCSS config (using `@tailwindcss/vite` plugin) **Deliverables**: + - [x] All views accessible via TanStack Router - [x] Electron build functional - [ ] Web build functional (needs testing) @@ -1583,6 +1677,7 @@ export async function pickFile(options?: { - [ ] Reorganize `settings-view` dialogs **Deliverables**: + - All views under 500 lines - Consistent folder structure across all views - Barrel exports for all component folders @@ -1599,6 +1694,7 @@ export async function pickFile(options?: { - [ ] Update imports across apps **Deliverables**: + - 5 new shared packages - No code duplication between apps - Clean dependency graph @@ -1615,6 +1711,7 @@ export async function pickFile(options?: { - [ ] Remove Next.js dependencies **Deliverables**: + - Comprehensive test coverage - Performance metrics documentation - Updated CI/CD configuration @@ -1626,30 +1723,30 @@ export async function pickFile(options?: { ### Developer Experience -| Aspect | Before | After | -|--------|--------|-------| -| Dev server startup | 8-15 seconds | 1-3 seconds | -| Hot Module Replacement | 500ms-2s | 50-100ms | -| TypeScript in Electron | Not supported | Full support | -| Debug tooling | Limited | Full debug console | -| Build times | 45-90 seconds | 15-30 seconds | +| Aspect | Before | After | +| ---------------------- | ------------- | ------------------ | +| Dev server startup | 8-15 seconds | 1-3 seconds | +| Hot Module Replacement | 500ms-2s | 50-100ms | +| TypeScript in Electron | Not supported | Full support | +| Debug tooling | Limited | Full debug console | +| Build times | 45-90 seconds | 15-30 seconds | ### Code Quality -| Aspect | Before | After | -|--------|--------|-------| -| Electron type safety | 0% | 100% | -| Component organization | Inconsistent | Standardized | -| Code sharing | None | 5 shared packages | -| Path handling | Ad-hoc | Centralized utilities | +| Aspect | Before | After | +| ---------------------- | ------------ | --------------------- | +| Electron type safety | 0% | 100% | +| Component organization | Inconsistent | Standardized | +| Code sharing | None | 5 shared packages | +| Path handling | Ad-hoc | Centralized utilities | ### Bundle Size -| Aspect | Before | After | -|--------|--------|-------| -| Next.js runtime | ~200KB | 0KB | -| Framework overhead | High | Minimal | -| Tree shaking | Limited | Full | +| Aspect | Before | After | +| ------------------ | ------- | ------- | +| Next.js runtime | ~200KB | 0KB | +| Framework overhead | High | Minimal | +| Tree shaking | Limited | Full | --- @@ -1664,12 +1761,12 @@ export async function pickFile(options?: { ### Known Challenges -| Challenge | Mitigation | -|-----------|------------| -| Route migration | TanStack Router has similar file-based routing | +| Challenge | Mitigation | +| --------------------- | ------------------------------------------------ | +| Route migration | TanStack Router has similar file-based routing | | Environment variables | Simple search/replace (`NEXT_PUBLIC_` β†’ `VITE_`) | -| Build configuration | Reference electron-starter-template | -| SSR considerations | N/A - we don't use SSR | +| Build configuration | Reference electron-starter-template | +| SSR considerations | N/A - we don't use SSR | ### Testing Strategy @@ -1684,13 +1781,13 @@ export async function pickFile(options?: { ```typescript // vite.config.mts -import { defineConfig } from "vite" -import react from "@vitejs/plugin-react" -import electron from "vite-plugin-electron" -import renderer from "vite-plugin-electron-renderer" -import { TanStackRouterVite } from "@tanstack/router-plugin/vite" -import tailwindcss from "@tailwindcss/vite" -import path from "path" +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import electron from "vite-plugin-electron"; +import renderer from "vite-plugin-electron-renderer"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; export default defineConfig({ plugins: [ @@ -1741,21 +1838,22 @@ export default defineConfig({ build: { outDir: "dist", }, -}) +}); ``` --- ## Document History -| Version | Date | Author | Changes | -|---------|------|--------|---------| -| 1.0 | Dec 2025 | Team | Initial migration plan | -| 1.1 | Dec 2025 | Team | Phase 1 & 2 complete. Updated checkboxes, added completed tasks, noted deferred items | +| Version | Date | Author | Changes | +| ------- | -------- | ------ | ------------------------------------------------------------------------------------- | +| 1.0 | Dec 2025 | Team | Initial migration plan | +| 1.1 | Dec 2025 | Team | Phase 1 & 2 complete. Updated checkboxes, added completed tasks, noted deferred items | --- **Next Steps**: + 1. ~~Review and approve this plan~~ βœ… 2. ~~Wait for `feature/worktrees` branch merge~~ βœ… 3. ~~Create migration branch~~ βœ… (refactor/frontend) diff --git a/docs/terminal.md b/docs/terminal.md index 38dece63..8a30678f 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -7,18 +7,23 @@ The integrated terminal provides a full-featured terminal emulator within Automa Configure the terminal via environment variables in `apps/server/.env`: ### Disable Terminal Completely + ``` TERMINAL_ENABLED=false ``` + Set to `false` to completely disable the terminal feature. ### Password Protection + ``` TERMINAL_PASSWORD=yourpassword ``` + By default, the terminal is **not password protected**. Add this variable to require a password. When password protection is enabled: + - Enter the password in **Settings > Terminal** to unlock - The terminal remains unlocked for the session - You can toggle password requirement on/off in settings after unlocking @@ -27,11 +32,11 @@ When password protection is enabled: When the terminal is focused, the following shortcuts are available: -| Shortcut | Action | -|----------|--------| -| `Alt+D` | Split terminal right (horizontal split) | -| `Alt+S` | Split terminal down (vertical split) | -| `Alt+W` | Close current terminal | +| Shortcut | Action | +| -------- | --------------------------------------- | +| `Alt+D` | Split terminal right (horizontal split) | +| `Alt+S` | Split terminal down (vertical split) | +| `Alt+W` | Close current terminal | Global shortcut (works anywhere in the app): | Shortcut | Action | @@ -41,22 +46,27 @@ Global shortcut (works anywhere in the app): ## Features ### Multiple Terminals + - Create multiple terminal tabs using the `+` button - Split terminals horizontally or vertically within a tab - Drag terminals to rearrange them ### Theming + The terminal automatically matches your app theme. Supported themes include: + - Light / Dark / System - Retro, Dracula, Nord, Monokai - Tokyo Night, Solarized, Gruvbox - Catppuccin, One Dark, Synthwave, Red ### Font Size + - Use the zoom controls (`+`/`-` buttons) in each terminal panel - Or use `Cmd/Ctrl + Scroll` to zoom ### Scrollback + - The terminal maintains a scrollback buffer of recent output - Scroll up to view previous output - Output is preserved when reconnecting @@ -65,7 +75,7 @@ The terminal automatically matches your app theme. Supported themes include: The terminal uses a client-server architecture: -1. **Frontend** (`apps/app`): xterm.js terminal emulator with WebGL rendering +1. **Frontend** (`apps/ui`): xterm.js terminal emulator with WebGL rendering 2. **Backend** (`apps/server`): node-pty for PTY (pseudo-terminal) sessions Communication happens over WebSocket for real-time bidirectional data flow. @@ -73,6 +83,7 @@ Communication happens over WebSocket for real-time bidirectional data flow. ### Shell Detection The server automatically detects the best shell: + - **WSL**: User's shell or `/bin/bash` - **macOS**: User's shell, zsh, or bash - **Linux**: User's shell, bash, or sh @@ -81,13 +92,16 @@ The server automatically detects the best shell: ## Troubleshooting ### Terminal not connecting + 1. Ensure the server is running (`npm run dev:server`) 2. Check that port 3008 is available 3. Verify the terminal is unlocked ### Slow performance with heavy output + The terminal throttles output at ~60fps to prevent UI lockup. Very fast output (like `cat` on large files) will be batched. ### Shortcuts not working + - Ensure the terminal is focused (click inside it) - Some system shortcuts may conflict (especially Alt+Shift combinations on Windows) diff --git a/package-lock.json b/package-lock.json index 9f0d3a4a..54afdd99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1003,7 +1003,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT",