From 8c24381759b1d0eca4eaffc016f44127c3a8f727 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Tue, 16 Dec 2025 13:56:53 -0500 Subject: [PATCH] feat: add GitHub setup step and enhance setup flow - Introduced a new GitHubSetupStep component for GitHub CLI configuration during the setup process. - Updated SetupView to include the GitHub step in the setup flow, allowing users to skip or proceed based on their GitHub CLI status. - Enhanced state management to track GitHub CLI installation and authentication status. - Added logging for transitions between setup steps to improve user feedback. - Updated related files to ensure cross-platform path normalization and compatibility. --- .../components/worktree-selector.tsx | 23 +- .../board-view/dialogs/create-pr-dialog.tsx | 65 ++- .../hooks/use-board-column-features.ts | 5 +- apps/app/src/components/views/setup-view.tsx | 24 +- .../setup-view/steps/github-setup-step.tsx | 333 ++++++++++++ .../views/setup-view/steps/index.ts | 1 + apps/app/src/lib/electron.ts | 30 ++ apps/app/src/lib/http-api-client.ts | 10 + apps/app/src/lib/utils.ts | 17 + apps/app/src/store/setup-store.ts | 22 + apps/app/src/types/electron.d.ts | 2 + apps/server/src/lib/automaker-paths.ts | 282 +---------- .../app-spec/generate-features-from-spec.ts | 4 +- .../src/routes/app-spec/generate-spec.ts | 4 +- .../app-spec/parse-and-create-features.ts | 2 +- .../fs/routes/delete-board-background.ts | 4 +- .../routes/fs/routes/save-board-background.ts | 4 +- .../server/src/routes/fs/routes/save-image.ts | 6 +- apps/server/src/routes/setup/index.ts | 2 + .../src/routes/setup/routes/gh-status.ts | 131 +++++ .../routes/worktree/routes/branch-tracking.ts | 6 +- .../src/routes/worktree/routes/create-pr.ts | 138 +++-- .../src/routes/worktree/routes/migrate.ts | 69 +-- apps/server/src/services/auto-mode-service.ts | 42 +- apps/server/src/services/feature-loader.ts | 68 +-- docs/clean-code.md | 474 ++++++++++++++++++ 26 files changed, 1302 insertions(+), 466 deletions(-) create mode 100644 apps/app/src/components/views/setup-view/steps/github-setup-step.tsx create mode 100644 apps/server/src/routes/setup/routes/gh-status.ts create mode 100644 docs/clean-code.md diff --git a/apps/app/src/components/views/board-view/components/worktree-selector.tsx b/apps/app/src/components/views/board-view/components/worktree-selector.tsx index dd72d2b3..db8732b8 100644 --- a/apps/app/src/components/views/board-view/components/worktree-selector.tsx +++ b/apps/app/src/components/views/board-view/components/worktree-selector.tsx @@ -34,7 +34,7 @@ import { } from "lucide-react"; import { useAppStore } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; -import { cn } from "@/lib/utils"; +import { cn, pathsEqual, normalizePath } from "@/lib/utils"; import { toast } from "sonner"; interface WorktreeInfo { @@ -225,10 +225,10 @@ export function WorktreeSelector({ const result = await api.worktree.startDevServer(projectPath, targetPath); if (result.success && result.result) { - // Update running servers map + // Update running servers map (normalize path for cross-platform compatibility) setRunningDevServers((prev) => { const next = new Map(prev); - next.set(targetPath, { + next.set(normalizePath(targetPath), { worktreePath: result.result!.worktreePath, port: result.result!.port, url: result.result!.url, @@ -260,10 +260,10 @@ export function WorktreeSelector({ const result = await api.worktree.stopDevServer(targetPath); if (result.success) { - // Update running servers map + // Update running servers map (normalize path for cross-platform compatibility) setRunningDevServers((prev) => { const next = new Map(prev); - next.delete(targetPath); + next.delete(normalizePath(targetPath)); return next; }); toast.success(result.result?.message || "Dev server stopped"); @@ -285,8 +285,10 @@ export function WorktreeSelector({ }; // Helper to get the path key for a worktree (for looking up in runningDevServers) + // Normalizes path for cross-platform compatibility const getWorktreeKey = (worktree: WorktreeInfo) => { - return worktree.isMain ? projectPath : worktree.path; + const path = worktree.isMain ? projectPath : worktree.path; + return path ? normalizePath(path) : path; }; // Helper to check if a worktree has running features @@ -301,12 +303,13 @@ export function WorktreeSelector({ if (!feature) return false; // For main worktree, check features with no worktreePath or matching projectPath + // Use pathsEqual for cross-platform compatibility (Windows uses backslashes) if (worktree.isMain) { - return !feature.worktreePath || feature.worktreePath === projectPath; + return !feature.worktreePath || pathsEqual(feature.worktreePath, projectPath); } // For other worktrees, check if worktreePath matches - return feature.worktreePath === worktreeKey; + return pathsEqual(feature.worktreePath, worktreeKey); }); }; @@ -459,7 +462,7 @@ export function WorktreeSelector({ // currentWorktree.path is null for main, or the worktree path for others const currentWorktreePath = currentWorktree?.path ?? null; const selectedWorktree = currentWorktreePath - ? worktrees.find((w) => w.path === currentWorktreePath) + ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) : worktrees.find((w) => w.isMain); @@ -469,7 +472,7 @@ export function WorktreeSelector({ // Default to main selected if currentWorktree is null/undefined or path is null const isSelected = worktree.isMain ? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null - : worktree.path === currentWorktreePath; + : pathsEqual(worktree.path, currentWorktreePath); const isRunning = hasRunningFeatures(worktree); diff --git a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx index b3e01be8..4d1ee520 100644 --- a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -47,6 +47,8 @@ export function CreatePRDialog({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [prUrl, setPrUrl] = useState(null); + const [browserUrl, setBrowserUrl] = useState(null); + const [showBrowserFallback, setShowBrowserFallback] = useState(false); // Reset state when dialog opens or worktree changes useEffect(() => { @@ -58,6 +60,8 @@ export function CreatePRDialog({ setIsDraft(false); setError(null); setPrUrl(null); + setBrowserUrl(null); + setShowBrowserFallback(false); } }, [open, worktree?.path]); @@ -93,14 +97,26 @@ export function CreatePRDialog({ }); onCreated(); } else { + // Branch was pushed successfully toast.success("Branch pushed", { description: result.result.committed ? `Commit ${result.result.commitHash} pushed to ${result.result.branch}` : `Branch ${result.result.branch} pushed`, }); - if (!result.result.prCreated) { - // Show the specific error if available + + // Check if we should show browser fallback + if (!result.result.prCreated && result.result.browserUrl) { const prError = result.result.prError; + + // If gh CLI is not available, show browser fallback UI + if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) { + setBrowserUrl(result.result.browserUrl); + setShowBrowserFallback(true); + onCreated(); + return; // Don't close dialog, show browser fallback UI + } + + // gh CLI is available but failed - show error with browser option if (prError) { // Parse common gh CLI errors for better messages let errorMessage = prError; @@ -111,16 +127,25 @@ export function CreatePRDialog({ } else if (prError.includes("not logged in") || prError.includes("auth")) { errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal."; } + + // Show error but also provide browser option + setBrowserUrl(result.result.browserUrl); + setShowBrowserFallback(true); toast.error("PR creation failed", { description: errorMessage, duration: 8000, }); - } else { - toast.info("PR not created", { - description: "GitHub CLI (gh) may not be installed or authenticated", - }); + onCreated(); + return; } } + + // No browser URL available, just close + if (!result.result.prCreated) { + toast.info("PR not created", { + description: "GitHub CLI (gh) may not be installed or authenticated", + }); + } onCreated(); onOpenChange(false); } @@ -145,6 +170,8 @@ export function CreatePRDialog({ setIsDraft(false); setError(null); setPrUrl(null); + setBrowserUrl(null); + setShowBrowserFallback(false); }, 200); }; @@ -185,6 +212,32 @@ export function CreatePRDialog({ View Pull Request + ) : showBrowserFallback && browserUrl ? ( +
+
+ +
+
+

Branch Pushed!

+

+ Your changes have been pushed to GitHub. +
+ Click below to create a pull request in your browser. +

+
+
+ +

+ Tip: Install the GitHub CLI (gh) to create PRs directly from the app +

+
+
) : ( <>
diff --git a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts index 89015a29..c14ba074 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts @@ -1,5 +1,6 @@ import { useMemo, useCallback } from "react"; import { Feature } from "@/store/app-store"; +import { pathsEqual } from "@/lib/utils"; type ColumnId = Feature["status"]; @@ -65,8 +66,8 @@ export function useBoardColumnFeatures({ // No worktree or branch assigned - show only on main matchesWorktree = !currentWorktreePath; } else if (f.worktreePath) { - // Has worktreePath - match by path - matchesWorktree = f.worktreePath === effectiveWorktreePath; + // Has worktreePath - match by path (use pathsEqual for cross-platform compatibility) + matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath); } else if (effectiveBranch === null) { // We're selecting a non-main worktree but can't determine its branch yet // (worktrees haven't loaded). Don't show branch-only features until we know. diff --git a/apps/app/src/components/views/setup-view.tsx b/apps/app/src/components/views/setup-view.tsx index cf5cb7d3..dcbca43e 100644 --- a/apps/app/src/components/views/setup-view.tsx +++ b/apps/app/src/components/views/setup-view.tsx @@ -7,6 +7,7 @@ import { WelcomeStep, CompleteStep, ClaudeSetupStep, + GitHubSetupStep, } from "./setup-view/steps"; // Main Setup View @@ -19,12 +20,13 @@ export function SetupView() { } = useSetupStore(); const { setCurrentView } = useAppStore(); - const steps = ["welcome", "claude", "complete"] as const; + const steps = ["welcome", "claude", "github", "complete"] as const; type StepName = (typeof steps)[number]; const getStepName = (): StepName => { if (currentStep === "claude_detect" || currentStep === "claude_auth") return "claude"; if (currentStep === "welcome") return "welcome"; + if (currentStep === "github") return "github"; return "complete"; }; const currentIndex = steps.indexOf(getStepName()); @@ -42,6 +44,10 @@ export function SetupView() { setCurrentStep("claude_detect"); break; case "claude": + console.log("[Setup Flow] Moving to github step"); + setCurrentStep("github"); + break; + case "github": console.log("[Setup Flow] Moving to complete step"); setCurrentStep("complete"); break; @@ -54,12 +60,20 @@ export function SetupView() { case "claude": setCurrentStep("welcome"); break; + case "github": + setCurrentStep("claude_detect"); + break; } }; const handleSkipClaude = () => { console.log("[Setup Flow] Skipping Claude setup"); setSkipClaudeSetup(true); + setCurrentStep("github"); + }; + + const handleSkipGithub = () => { + console.log("[Setup Flow] Skipping GitHub setup"); setCurrentStep("complete"); }; @@ -110,6 +124,14 @@ export function SetupView() { /> )} + {currentStep === "github" && ( + handleNext("github")} + onBack={() => handleBack("github")} + onSkip={handleSkipGithub} + /> + )} + {currentStep === "complete" && ( )} diff --git a/apps/app/src/components/views/setup-view/steps/github-setup-step.tsx b/apps/app/src/components/views/setup-view/steps/github-setup-step.tsx new file mode 100644 index 00000000..7f5ae1be --- /dev/null +++ b/apps/app/src/components/views/setup-view/steps/github-setup-step.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useSetupStore } from "@/store/setup-store"; +import { getElectronAPI } from "@/lib/electron"; +import { + CheckCircle2, + Loader2, + ArrowRight, + ArrowLeft, + ExternalLink, + Copy, + RefreshCw, + AlertTriangle, + Github, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { StatusBadge } from "../components"; + +interface GitHubSetupStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +export function GitHubSetupStep({ + onNext, + onBack, + onSkip, +}: GitHubSetupStepProps) { + const { ghCliStatus, setGhCliStatus } = useSetupStore(); + const [isChecking, setIsChecking] = useState(false); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getGhStatus) { + return; + } + const result = await api.setup.getGhStatus(); + if (result.success) { + setGhCliStatus({ + installed: result.installed, + authenticated: result.authenticated, + version: result.version, + path: result.path, + user: result.user, + }); + } + } catch (error) { + console.error("Failed to check gh status:", error); + } finally { + setIsChecking(false); + } + }, [setGhCliStatus]); + + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success("Command copied to clipboard"); + }; + + const isReady = ghCliStatus?.installed && ghCliStatus?.authenticated; + + const getStatusBadge = () => { + if (isChecking) { + return ; + } + if (ghCliStatus?.authenticated) { + return ; + } + if (ghCliStatus?.installed) { + return ; + } + return ; + }; + + return ( +
+
+
+ +
+

+ GitHub CLI Setup +

+

+ Optional - Used for creating pull requests +

+
+ + {/* Info Banner */} + + +
+ +
+

+ This step is optional +

+

+ The GitHub CLI allows you to create pull requests directly from + the app. Without it, you can still create PRs manually in your + browser. +

+
+
+
+
+ + {/* Status Card */} + + +
+ + + GitHub CLI Status + +
+ {getStatusBadge()} + +
+
+ + {ghCliStatus?.installed + ? ghCliStatus.authenticated + ? `Logged in${ghCliStatus.user ? ` as ${ghCliStatus.user}` : ""}` + : "Installed but not logged in" + : "Not installed on your system"} + +
+ + {/* Success State */} + {isReady && ( +
+ +
+

+ GitHub CLI is ready! +

+

+ You can create pull requests directly from the app. + {ghCliStatus?.version && ( + Version: {ghCliStatus.version} + )} +

+
+
+ )} + + {/* Not Installed */} + {!ghCliStatus?.installed && !isChecking && ( +
+
+ +
+

+ GitHub CLI not found +

+

+ Install the GitHub CLI to enable PR creation from the app. +

+
+
+ +
+

+ Installation Commands: +

+ +
+

macOS (Homebrew)

+
+ + brew install gh + + +
+
+ +
+

Windows (winget)

+
+ + winget install GitHub.cli + + +
+
+ +
+

Linux (apt)

+
+ + sudo apt install gh + + +
+
+ + + View all installation options + + +
+
+ )} + + {/* Installed but not authenticated */} + {ghCliStatus?.installed && !ghCliStatus?.authenticated && !isChecking && ( +
+
+ +
+

+ GitHub CLI not logged in +

+

+ Run the login command to authenticate with GitHub. +

+
+
+ +
+

+ Run this command in your terminal: +

+
+ + gh auth login + + +
+
+
+ )} + + {/* Loading State */} + {isChecking && ( +
+ +
+

+ Checking GitHub CLI status... +

+
+
+ )} +
+
+ + {/* Navigation */} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/app/src/components/views/setup-view/steps/index.ts b/apps/app/src/components/views/setup-view/steps/index.ts index 4ad4a782..7299a070 100644 --- a/apps/app/src/components/views/setup-view/steps/index.ts +++ b/apps/app/src/components/views/setup-view/steps/index.ts @@ -2,3 +2,4 @@ export { WelcomeStep } from "./welcome-step"; export { CompleteStep } from "./complete-step"; export { ClaudeSetupStep } from "./claude-setup-step"; +export { GitHubSetupStep } from "./github-setup-step"; diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts index 4f6f6e24..13606ee3 100644 --- a/apps/app/src/lib/electron.ts +++ b/apps/app/src/lib/electron.ts @@ -387,6 +387,15 @@ export interface ElectronAPI { authenticated: boolean; error?: string; }>; + getGhStatus?: () => Promise<{ + success: boolean; + installed: boolean; + authenticated: boolean; + version: string | null; + path: string | null; + user: string | null; + error?: string; + }>; onInstallProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void; }; @@ -910,6 +919,15 @@ interface SetupAPI { authenticated: boolean; error?: string; }>; + getGhStatus?: () => Promise<{ + success: boolean; + installed: boolean; + authenticated: boolean; + version: string | null; + path: string | null; + user: string | null; + error?: string; + }>; onInstallProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void; } @@ -996,6 +1014,18 @@ function createMockSetupAPI(): SetupAPI { }; }, + getGhStatus: async () => { + console.log("[Mock] Getting GitHub CLI status"); + return { + success: true, + installed: false, + authenticated: false, + version: null, + path: null, + user: null, + }; + }, + onInstallProgress: (callback) => { // Mock progress events return () => {}; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 1149ef3b..8dbc5512 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -474,6 +474,16 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.post("/api/setup/verify-claude-auth", { authMethod }), + getGhStatus: (): Promise<{ + success: boolean; + installed: boolean; + authenticated: boolean; + version: string | null; + path: string | null; + user: string | null; + error?: string; + }> => this.get("/api/setup/gh-status"), + onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent("agent:stream", callback); }, diff --git a/apps/app/src/lib/utils.ts b/apps/app/src/lib/utils.ts index d5580da5..88bc24a7 100644 --- a/apps/app/src/lib/utils.ts +++ b/apps/app/src/lib/utils.ts @@ -35,3 +35,20 @@ export function truncateDescription(description: string, maxLength = 50): string } return `${description.slice(0, maxLength)}...`; } + +/** + * Normalize a file path to use forward slashes consistently. + * This is important for cross-platform compatibility (Windows uses backslashes). + */ +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/"); +} + +/** + * Compare two paths for equality, handling cross-platform differences. + * Normalizes both paths to forward slashes before comparison. + */ +export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean { + if (!p1 || !p2) return p1 === p2; + return normalizePath(p1) === normalizePath(p2); +} diff --git a/apps/app/src/store/setup-store.ts b/apps/app/src/store/setup-store.ts index c7df63a7..b8e8a694 100644 --- a/apps/app/src/store/setup-store.ts +++ b/apps/app/src/store/setup-store.ts @@ -10,6 +10,16 @@ export interface CliStatus { error?: string; } +// GitHub CLI Status +export interface GhCliStatus { + installed: boolean; + authenticated: boolean; + version: string | null; + path: string | null; + user: string | null; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | "oauth_token_env" @@ -45,6 +55,7 @@ export type SetupStep = | "welcome" | "claude_detect" | "claude_auth" + | "github" | "complete"; export interface SetupState { @@ -58,6 +69,9 @@ export interface SetupState { claudeAuthStatus: ClaudeAuthStatus | null; claudeInstallProgress: InstallProgress; + // GitHub CLI state + ghCliStatus: GhCliStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -76,6 +90,9 @@ export interface SetupActions { setClaudeInstallProgress: (progress: Partial) => void; resetClaudeInstallProgress: () => void; + // GitHub CLI + setGhCliStatus: (status: GhCliStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -99,6 +116,8 @@ const initialState: SetupState = { claudeAuthStatus: null, claudeInstallProgress: { ...initialInstallProgress }, + ghCliStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -145,6 +164,9 @@ export const useSetupStore = create()( claudeInstallProgress: { ...initialInstallProgress }, }), + // GitHub CLI + setGhCliStatus: (status) => set({ ghCliStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), }), diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index b90f1c9c..3d45d4c4 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -719,6 +719,8 @@ export interface WorktreeAPI { prUrl?: string; prCreated: boolean; prError?: string; + browserUrl?: string; + ghCliAvailable?: boolean; }; error?: string; }>; diff --git a/apps/server/src/lib/automaker-paths.ts b/apps/server/src/lib/automaker-paths.ts index bd4b328d..cfd87305 100644 --- a/apps/server/src/lib/automaker-paths.ts +++ b/apps/server/src/lib/automaker-paths.ts @@ -1,322 +1,84 @@ /** * Automaker Paths - Utilities for managing automaker data storage * - * Stores project data in an external location (~/.automaker/projects/{project-id}/) - * to avoid conflicts with git worktrees and symlink issues. - * - * The project-id is derived from the git remote URL (if available) or project path, - * ensuring each project has a unique storage location that persists across worktrees. + * Stores project data inside the project directory at {projectPath}/.automaker/ */ import fs from "fs/promises"; import path from "path"; -import { createHash } from "crypto"; -import { exec } from "child_process"; -import { promisify } from "util"; -import os from "os"; - -const execAsync = promisify(exec); - -/** - * Get the base automaker directory in user's home - */ -export function getAutomakerBaseDir(): string { - return path.join(os.homedir(), ".automaker"); -} - -/** - * Get the projects directory - */ -export function getProjectsDir(): string { - return path.join(getAutomakerBaseDir(), "projects"); -} - -/** - * Generate a project ID from a unique identifier (git remote or path) - */ -function generateProjectId(identifier: string): string { - const hash = createHash("sha256").update(identifier).digest("hex"); - return hash.substring(0, 16); -} - -/** - * Get the main git repository root path (resolves worktree paths to main repo) - */ -async function getMainRepoPath(projectPath: string): Promise { - try { - // Get the main worktree path (handles worktrees) - const { stdout } = await execAsync( - "git worktree list --porcelain | head -1 | sed 's/worktree //'", - { cwd: projectPath } - ); - const mainPath = stdout.trim(); - return mainPath || projectPath; - } catch { - return projectPath; - } -} - -/** - * Get a unique identifier for a git project - * Prefers git remote URL, falls back to main repo path - */ -async function getProjectIdentifier(projectPath: string): Promise { - const mainPath = await getMainRepoPath(projectPath); - - try { - // Try to get the git remote URL first (most stable identifier) - const { stdout } = await execAsync("git remote get-url origin", { - cwd: mainPath, - }); - const remoteUrl = stdout.trim(); - if (remoteUrl) { - return remoteUrl; - } - } catch { - // No remote configured, fall through - } - - // Fall back to the absolute main repo path - return path.resolve(mainPath); -} /** * Get the automaker data directory for a project - * This is the external location where all .automaker data is stored + * This is stored inside the project at .automaker/ */ -export async function getAutomakerDir(projectPath: string): Promise { - const identifier = await getProjectIdentifier(projectPath); - const projectId = generateProjectId(identifier); - return path.join(getProjectsDir(), projectId); +export function getAutomakerDir(projectPath: string): string { + return path.join(projectPath, ".automaker"); } /** * Get the features directory for a project */ -export async function getFeaturesDir(projectPath: string): Promise { - const automakerDir = await getAutomakerDir(projectPath); - return path.join(automakerDir, "features"); +export function getFeaturesDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), "features"); } /** * Get the directory for a specific feature */ -export async function getFeatureDir( - projectPath: string, - featureId: string -): Promise { - const featuresDir = await getFeaturesDir(projectPath); - return path.join(featuresDir, featureId); +export function getFeatureDir(projectPath: string, featureId: string): string { + return path.join(getFeaturesDir(projectPath), featureId); } /** * Get the images directory for a feature */ -export async function getFeatureImagesDir( +export function getFeatureImagesDir( projectPath: string, featureId: string -): Promise { - const featureDir = await getFeatureDir(projectPath, featureId); - return path.join(featureDir, "images"); +): string { + return path.join(getFeatureDir(projectPath, featureId), "images"); } /** * Get the board directory for a project (board backgrounds, etc.) */ -export async function getBoardDir(projectPath: string): Promise { - const automakerDir = await getAutomakerDir(projectPath); - return path.join(automakerDir, "board"); +export function getBoardDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), "board"); } /** * Get the images directory for a project (general images) */ -export async function getImagesDir(projectPath: string): Promise { - const automakerDir = await getAutomakerDir(projectPath); - return path.join(automakerDir, "images"); +export function getImagesDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), "images"); } /** * Get the worktrees metadata directory for a project */ -export async function getWorktreesDir(projectPath: string): Promise { - const automakerDir = await getAutomakerDir(projectPath); - return path.join(automakerDir, "worktrees"); +export function getWorktreesDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), "worktrees"); } /** * Get the app spec file path for a project */ -export async function getAppSpecPath(projectPath: string): Promise { - const automakerDir = await getAutomakerDir(projectPath); - return path.join(automakerDir, "app_spec.txt"); +export function getAppSpecPath(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), "app_spec.txt"); } /** * Get the branch tracking file path for a project */ -export async function getBranchTrackingPath( - projectPath: string -): Promise { - const automakerDir = await getAutomakerDir(projectPath); - return path.join(automakerDir, "active-branches.json"); +export function getBranchTrackingPath(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), "active-branches.json"); } /** * Ensure the automaker directory structure exists for a project */ export async function ensureAutomakerDir(projectPath: string): Promise { - const automakerDir = await getAutomakerDir(projectPath); + const automakerDir = getAutomakerDir(projectPath); await fs.mkdir(automakerDir, { recursive: true }); return automakerDir; } - -/** - * Check if there's existing .automaker data in the project directory that needs migration - */ -export async function hasLegacyAutomakerDir( - projectPath: string -): Promise { - const mainPath = await getMainRepoPath(projectPath); - const legacyPath = path.join(mainPath, ".automaker"); - - try { - const stats = await fs.lstat(legacyPath); - // Only count it as legacy if it's a directory (not a symlink) - return stats.isDirectory() && !stats.isSymbolicLink(); - } catch { - return false; - } -} - -/** - * Get the legacy .automaker path in the project directory - */ -export async function getLegacyAutomakerDir( - projectPath: string -): Promise { - const mainPath = await getMainRepoPath(projectPath); - return path.join(mainPath, ".automaker"); -} - -/** - * Migrate data from legacy in-repo .automaker to external location - * Returns true if migration was performed, false if not needed - */ -export async function migrateLegacyData(projectPath: string): Promise { - if (!(await hasLegacyAutomakerDir(projectPath))) { - return false; - } - - const legacyDir = await getLegacyAutomakerDir(projectPath); - const newDir = await ensureAutomakerDir(projectPath); - - console.log(`[automaker-paths] Migrating data from ${legacyDir} to ${newDir}`); - - try { - // Copy all contents from legacy to new location - const entries = await fs.readdir(legacyDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(legacyDir, entry.name); - const destPath = path.join(newDir, entry.name); - - // Skip if destination already exists - try { - await fs.access(destPath); - console.log( - `[automaker-paths] Skipping ${entry.name} (already exists in destination)` - ); - continue; - } catch { - // Destination doesn't exist, proceed with copy - } - - if (entry.isDirectory()) { - await fs.cp(srcPath, destPath, { recursive: true }); - } else if (entry.isFile()) { - await fs.copyFile(srcPath, destPath); - } - // Skip symlinks - } - - console.log(`[automaker-paths] Migration complete`); - - // Optionally rename the old directory to mark it as migrated - const backupPath = path.join( - path.dirname(legacyDir), - ".automaker-migrated" - ); - try { - await fs.rename(legacyDir, backupPath); - console.log( - `[automaker-paths] Renamed legacy directory to .automaker-migrated` - ); - } catch (error) { - console.warn( - `[automaker-paths] Could not rename legacy directory:`, - error - ); - } - - return true; - } catch (error) { - console.error(`[automaker-paths] Migration failed:`, error); - throw error; - } -} - -/** - * Convert a legacy relative path (e.g., ".automaker/features/...") - * to the new external absolute path - */ -export async function convertLegacyPath( - projectPath: string, - legacyRelativePath: string -): Promise { - // If it doesn't start with .automaker, return as-is - if (!legacyRelativePath.startsWith(".automaker")) { - return legacyRelativePath; - } - - const automakerDir = await getAutomakerDir(projectPath); - // Remove ".automaker/" prefix and join with new base - const relativePart = legacyRelativePath.replace(/^\.automaker\/?/, ""); - return path.join(automakerDir, relativePart); -} - -/** - * Get a relative path for display/storage (relative to external automaker dir) - * The path is prefixed with "automaker:" to indicate it's an external path - */ -export async function getDisplayPath( - projectPath: string, - absolutePath: string -): Promise { - const automakerDir = await getAutomakerDir(projectPath); - if (absolutePath.startsWith(automakerDir)) { - const relativePart = absolutePath.substring(automakerDir.length + 1); - return `automaker:${relativePart}`; - } - return absolutePath; -} - -/** - * Resolve a display path back to absolute path - */ -export async function resolveDisplayPath( - projectPath: string, - displayPath: string -): Promise { - if (displayPath.startsWith("automaker:")) { - const automakerDir = await getAutomakerDir(projectPath); - const relativePart = displayPath.substring("automaker:".length); - return path.join(automakerDir, relativePart); - } - // Legacy ".automaker" paths - if (displayPath.startsWith(".automaker")) { - return convertLegacyPath(projectPath, displayPath); - } - // Already absolute or project-relative path - return displayPath; -} diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index b436b687..2bf1eab5 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec( logger.debug("projectPath:", projectPath); logger.debug("maxFeatures:", featureCount); - // Read existing spec from external automaker directory - const specPath = await getAppSpecPath(projectPath); + // Read existing spec from .automaker directory + const specPath = getAppSpecPath(projectPath); let spec: string; logger.debug("Reading spec from:", specPath); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 0013e28b..90552d19 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -210,9 +210,9 @@ ${getAppSpecFormatInstruction()}`; logger.error("❌ WARNING: responseText is empty! Nothing to save."); } - // Save spec to external automaker directory + // Save spec to .automaker directory const specDir = await ensureAutomakerDir(projectPath); - const specPath = await getAppSpecPath(projectPath); + const specPath = getAppSpecPath(projectPath); logger.info("Saving spec to:", specPath); logger.info(`Content to save (${responseText.length} chars)`); diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index b553f8f9..3dd9248a 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -42,7 +42,7 @@ export async function parseAndCreateFeatures( logger.info(`Parsed ${parsed.features?.length || 0} features`); logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2)); - const featuresDir = await getFeaturesDir(projectPath); + const featuresDir = getFeaturesDir(projectPath); await fs.mkdir(featuresDir, { recursive: true }); const createdFeatures: Array<{ id: string; title: string }> = []; diff --git a/apps/server/src/routes/fs/routes/delete-board-background.ts b/apps/server/src/routes/fs/routes/delete-board-background.ts index 684225e7..8b502021 100644 --- a/apps/server/src/routes/fs/routes/delete-board-background.ts +++ b/apps/server/src/routes/fs/routes/delete-board-background.ts @@ -21,8 +21,8 @@ export function createDeleteBoardBackgroundHandler() { return; } - // Get external board directory - const boardDir = await getBoardDir(projectPath); + // Get board directory + const boardDir = getBoardDir(projectPath); try { // Try to remove all background files in the board directory diff --git a/apps/server/src/routes/fs/routes/save-board-background.ts b/apps/server/src/routes/fs/routes/save-board-background.ts index 4b4eb90d..9a496c7c 100644 --- a/apps/server/src/routes/fs/routes/save-board-background.ts +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -27,8 +27,8 @@ export function createSaveBoardBackgroundHandler() { return; } - // Get external board directory - const boardDir = await getBoardDir(projectPath); + // Get board directory + const boardDir = getBoardDir(projectPath); await fs.mkdir(boardDir, { recursive: true }); // Decode base64 data (remove data URL prefix if present) diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts index eac863ed..b56b5a12 100644 --- a/apps/server/src/routes/fs/routes/save-image.ts +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -1,5 +1,5 @@ /** - * POST /save-image endpoint - Save image to external automaker images directory + * POST /save-image endpoint - Save image to .automaker images directory */ import type { Request, Response } from "express"; @@ -27,8 +27,8 @@ export function createSaveImageHandler() { return; } - // Get external images directory - const imagesDir = await getImagesDir(projectPath); + // Get images directory + const imagesDir = getImagesDir(projectPath); await fs.mkdir(imagesDir, { recursive: true }); // Decode base64 data (remove data URL prefix if present) diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index de3ce300..2b5db942 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -11,6 +11,7 @@ import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js"; import { createApiKeysHandler } from "./routes/api-keys.js"; import { createPlatformHandler } from "./routes/platform.js"; import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js"; +import { createGhStatusHandler } from "./routes/gh-status.js"; export function createSetupRoutes(): Router { const router = Router(); @@ -23,6 +24,7 @@ export function createSetupRoutes(): Router { router.get("/api-keys", createApiKeysHandler()); router.get("/platform", createPlatformHandler()); router.post("/verify-claude-auth", createVerifyClaudeAuthHandler()); + router.get("/gh-status", createGhStatusHandler()); return router; } diff --git a/apps/server/src/routes/setup/routes/gh-status.ts b/apps/server/src/routes/setup/routes/gh-status.ts new file mode 100644 index 00000000..7dcf5d82 --- /dev/null +++ b/apps/server/src/routes/setup/routes/gh-status.ts @@ -0,0 +1,131 @@ +/** + * GET /gh-status endpoint - Get GitHub CLI status + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import os from "os"; +import path from "path"; +import fs from "fs/promises"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +// Extended PATH to include common tool installation locations +const extendedPath = [ + process.env.PATH, + "/opt/homebrew/bin", + "/usr/local/bin", + "/home/linuxbrew/.linuxbrew/bin", + `${process.env.HOME}/.local/bin`, +].filter(Boolean).join(":"); + +const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +export interface GhStatus { + installed: boolean; + authenticated: boolean; + version: string | null; + path: string | null; + user: string | null; + error?: string; +} + +async function getGhStatus(): Promise { + const status: GhStatus = { + installed: false, + authenticated: false, + version: null, + path: null, + user: null, + }; + + const isWindows = process.platform === "win32"; + + // Check if gh CLI is installed + try { + const findCommand = isWindows ? "where gh" : "command -v gh"; + const { stdout } = await execAsync(findCommand, { env: execEnv }); + status.path = stdout.trim().split(/\r?\n/)[0]; + status.installed = true; + } catch { + // gh not in PATH, try common locations + const commonPaths = isWindows + ? [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "gh", "bin", "gh.exe"), + path.join(process.env.ProgramFiles || "", "GitHub CLI", "gh.exe"), + ] + : [ + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + path.join(os.homedir(), ".local", "bin", "gh"), + "/home/linuxbrew/.linuxbrew/bin/gh", + ]; + + for (const p of commonPaths) { + try { + await fs.access(p); + status.path = p; + status.installed = true; + break; + } catch { + // Not found at this path + } + } + } + + if (!status.installed) { + return status; + } + + // Get version + try { + const { stdout } = await execAsync("gh --version", { env: execEnv }); + // Extract version from output like "gh version 2.40.1 (2024-01-09)" + const versionMatch = stdout.match(/gh version ([\d.]+)/); + status.version = versionMatch ? versionMatch[1] : stdout.trim().split("\n")[0]; + } catch { + // Version command failed + } + + // Check authentication status + try { + const { stdout } = await execAsync("gh auth status", { env: execEnv }); + // If this succeeds without error, we're authenticated + status.authenticated = true; + + // Try to extract username from output + const userMatch = stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) || + stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i); + if (userMatch) { + status.user = userMatch[1]; + } + } catch (error: unknown) { + // Auth status returns non-zero if not authenticated + const err = error as { stderr?: string }; + if (err.stderr?.includes("not logged in")) { + status.authenticated = false; + } + } + + return status; +} + +export function createGhStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const status = await getGhStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, "Get GitHub CLI status failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/branch-tracking.ts b/apps/server/src/routes/worktree/routes/branch-tracking.ts index 0e6e68b3..8d45e2fd 100644 --- a/apps/server/src/routes/worktree/routes/branch-tracking.ts +++ b/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -1,10 +1,8 @@ /** * Branch tracking utilities * - * Tracks active branches in external automaker storage so users + * Tracks active branches in .automaker so users * can switch between branches even after worktrees are removed. - * - * Data is stored outside the git repo to avoid worktree/symlink conflicts. */ import { readFile, writeFile } from "fs/promises"; @@ -31,7 +29,7 @@ export async function getTrackedBranches( projectPath: string ): Promise { try { - const filePath = await getBranchTrackingPath(projectPath); + const filePath = getBranchTrackingPath(projectPath); const content = await readFile(filePath, "utf-8"); const data: BranchTrackingData = JSON.parse(content); return data.branches || []; diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index aa1f762e..8a7acd75 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -111,7 +111,7 @@ export function createCreatePRHandler() { return; } - // Create PR using gh CLI + // Create PR using gh CLI or provide browser fallback const base = baseBranch || "main"; const title = prTitle || branchName; const body = prBody || `Changes from branch ${branchName}`; @@ -119,65 +119,97 @@ export function createCreatePRHandler() { let prUrl: string | null = null; let prError: string | null = null; + let browserUrl: string | null = null; + let ghCliAvailable = false; + + // Check if gh CLI is available try { - // Check if gh CLI is available (use extended PATH for Homebrew/etc) await execAsync("command -v gh", { env: execEnv }); + ghCliAvailable = true; + } catch { + ghCliAvailable = false; + } - // Check if this is a fork by looking for upstream remote - let upstreamRepo: string | null = null; - let originOwner: string | null = null; - try { - const { stdout: remotes } = await execAsync("git remote -v", { - cwd: worktreePath, - env: execEnv, - }); - - // Parse remotes to detect fork workflow - const lines = remotes.split("\n"); - for (const line of lines) { - const match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/); - if (match) { - const [, remoteName, owner] = match; - if (remoteName === "upstream") { - upstreamRepo = line.match(/[:/]([^/]+\/[^/\s]+?)(?:\.git)?\s+\(fetch\)/)?.[1] || null; - } else if (remoteName === "origin") { - originOwner = owner; - } - } - } - } catch { - // Couldn't parse remotes, continue without fork detection - } - - // Build gh pr create command - let prCmd = `gh pr create --base "${base}"`; - - // If this is a fork (has upstream remote), specify the repo and head - if (upstreamRepo && originOwner) { - // For forks: --repo specifies where to create PR, --head specifies source - prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`; - } else { - // Not a fork, just specify the head branch - prCmd += ` --head "${branchName}"`; - } - - prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`; - prCmd = prCmd.trim(); - - console.log("[CreatePR] Running:", prCmd); - const { stdout: prOutput } = await execAsync(prCmd, { + // Get repository URL for browser fallback + let repoUrl: string | null = null; + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + try { + const { stdout: remotes } = await execAsync("git remote -v", { cwd: worktreePath, env: execEnv, }); - prUrl = prOutput.trim(); - } catch (ghError: unknown) { - // gh CLI not available or PR creation failed - const err = ghError as { stderr?: string; message?: string }; - prError = err.stderr || err.message || "PR creation failed"; - console.warn("[CreatePR] gh CLI error:", prError); + + // Parse remotes to detect fork workflow and get repo URL + const lines = remotes.split("\n"); + for (const line of lines) { + const match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/); + if (match) { + const [, remoteName, owner, repo] = match; + if (remoteName === "upstream") { + upstreamRepo = `${owner}/${repo}`; + repoUrl = `https://github.com/${owner}/${repo}`; + } else if (remoteName === "origin") { + originOwner = owner; + if (!repoUrl) { + repoUrl = `https://github.com/${owner}/${repo}`; + } + } + } + } + } catch { + // Couldn't parse remotes } - // Return result with any error info + // Construct browser URL for PR creation + if (repoUrl) { + const encodedTitle = encodeURIComponent(title); + const encodedBody = encodeURIComponent(body); + + if (upstreamRepo && originOwner) { + // Fork workflow: PR to upstream from origin + browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`; + } else { + // Regular repo + browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`; + } + } + + if (ghCliAvailable) { + try { + // Build gh pr create command + let prCmd = `gh pr create --base "${base}"`; + + // If this is a fork (has upstream remote), specify the repo and head + if (upstreamRepo && originOwner) { + // For forks: --repo specifies where to create PR, --head specifies source + prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`; + } else { + // Not a fork, just specify the head branch + prCmd += ` --head "${branchName}"`; + } + + prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`; + prCmd = prCmd.trim(); + + console.log("[CreatePR] Running:", prCmd); + const { stdout: prOutput } = await execAsync(prCmd, { + cwd: worktreePath, + env: execEnv, + }); + prUrl = prOutput.trim(); + } catch (ghError: unknown) { + // gh CLI failed + const err = ghError as { stderr?: string; message?: string }; + prError = err.stderr || err.message || "PR creation failed"; + console.warn("[CreatePR] gh CLI error:", prError); + } + } else { + prError = "gh_cli_not_available"; + console.log("[CreatePR] gh CLI not available, returning browser URL"); + } + + // Return result with browser fallback URL res.json({ success: true, result: { @@ -188,6 +220,8 @@ export function createCreatePRHandler() { prUrl, prCreated: !!prUrl, prError: prError || undefined, + browserUrl: browserUrl || undefined, + ghCliAvailable, }, }); } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/migrate.ts b/apps/server/src/routes/worktree/routes/migrate.ts index e69e1cc9..6aecc0df 100644 --- a/apps/server/src/routes/worktree/routes/migrate.ts +++ b/apps/server/src/routes/worktree/routes/migrate.ts @@ -1,63 +1,32 @@ /** - * POST /migrate endpoint - Migrate legacy .automaker data to external storage + * POST /migrate endpoint - Migration endpoint (no longer needed) * - * This endpoint checks if there's legacy .automaker data in the project directory - * and migrates it to the external ~/.automaker/projects/{project-id}/ location. + * This endpoint is kept for backwards compatibility but no longer performs + * any migration since .automaker is now stored in the project directory. */ import type { Request, Response } from "express"; -import { getErrorMessage, logError } from "../common.js"; -import { - hasLegacyAutomakerDir, - migrateLegacyData, - getAutomakerDir, - getLegacyAutomakerDir, -} from "../../../lib/automaker-paths.js"; +import { getAutomakerDir } from "../../../lib/automaker-paths.js"; export function createMigrateHandler() { return async (req: Request, res: Response): Promise => { - try { - const { projectPath } = req.body as { projectPath: string }; + const { projectPath } = req.body as { projectPath: string }; - if (!projectPath) { - res.status(400).json({ - success: false, - error: "projectPath is required", - }); - return; - } - - // Check if migration is needed - const hasLegacy = await hasLegacyAutomakerDir(projectPath); - - if (!hasLegacy) { - const automakerDir = await getAutomakerDir(projectPath); - res.json({ - success: true, - migrated: false, - message: "No legacy .automaker directory found - nothing to migrate", - externalPath: automakerDir, - }); - return; - } - - // Perform migration - console.log(`[migrate] Starting migration for project: ${projectPath}`); - const legacyPath = await getLegacyAutomakerDir(projectPath); - const externalPath = await getAutomakerDir(projectPath); - - await migrateLegacyData(projectPath); - - res.json({ - success: true, - migrated: true, - message: "Successfully migrated .automaker data to external storage", - legacyPath, - externalPath, + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath is required", }); - } catch (error) { - logError(error, "Migration failed"); - res.status(500).json({ success: false, error: getErrorMessage(error) }); + return; } + + // Migration is no longer needed - .automaker is stored in project directory + const automakerDir = getAutomakerDir(projectPath); + res.json({ + success: true, + migrated: false, + message: "No migration needed - .automaker is stored in project directory", + path: automakerDir, + }); }; } diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 621f192f..4c58d201 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -337,8 +337,8 @@ export class AutoModeService { featureId: string, useWorktrees = true ): Promise { - // Check if context exists in external automaker directory - const featureDir = await getFeatureDir(projectPath, featureId); + // Check if context exists in .automaker directory + const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); let hasContext = false; @@ -399,8 +399,8 @@ export class AutoModeService { // Load feature info for context const feature = await this.loadFeature(projectPath, featureId); - // Load previous agent output if it exists (from external automaker) - const featureDir = await getFeatureDir(projectPath, featureId); + // Load previous agent output if it exists + const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); let previousContext = ""; try { @@ -461,10 +461,10 @@ Address the follow-up instructions above. Review the previous work and make the // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); - // Copy follow-up images to feature folder (external automaker) + // Copy follow-up images to feature folder const copiedImagePaths: string[] = []; if (imagePaths && imagePaths.length > 0) { - const featureDirForImages = await getFeatureDir(projectPath, featureId); + const featureDirForImages = getFeatureDir(projectPath, featureId); const featureImagesDir = path.join(featureDirForImages, "images"); await fs.mkdir(featureImagesDir, { recursive: true }); @@ -512,9 +512,9 @@ Address the follow-up instructions above. Review the previous work and make the allImagePaths.push(...allPaths); } - // Save updated feature.json with new images (external automaker) + // Save updated feature.json with new images if (copiedImagePaths.length > 0 && feature) { - const featureDirForSave = await getFeatureDir(projectPath, featureId); + const featureDirForSave = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDirForSave, "feature.json"); try { @@ -707,8 +707,8 @@ Address the follow-up instructions above. Review the previous work and make the projectPath: string, featureId: string ): Promise { - // Context is stored in external automaker directory - const featureDir = await getFeatureDir(projectPath, featureId); + // Context is stored in .automaker directory + const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); try { @@ -782,8 +782,8 @@ Format your response as a structured markdown document.`; } } - // Save analysis to external automaker directory - const automakerDir = await getAutomakerDir(projectPath); + // Save analysis to .automaker directory + const automakerDir = getAutomakerDir(projectPath); const analysisPath = path.join(automakerDir, "project-analysis.md"); await fs.mkdir(automakerDir, { recursive: true }); await fs.writeFile(analysisPath, analysisResult); @@ -844,7 +844,7 @@ Format your response as a structured markdown document.`; featureId: string, branchName: string ): Promise { - // Git worktrees stay in project directory (not external automaker) + // Git worktrees stay in project directory const worktreesDir = path.join(projectPath, ".worktrees"); const worktreePath = path.join(worktreesDir, featureId); @@ -883,8 +883,8 @@ Format your response as a structured markdown document.`; projectPath: string, featureId: string ): Promise { - // Features are stored in external automaker directory - const featureDir = await getFeatureDir(projectPath, featureId); + // Features are stored in .automaker directory + const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, "feature.json"); try { @@ -900,8 +900,8 @@ Format your response as a structured markdown document.`; featureId: string, status: string ): Promise { - // Features are stored in external automaker directory - const featureDir = await getFeatureDir(projectPath, featureId); + // Features are stored in .automaker directory + const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, "feature.json"); try { @@ -924,8 +924,8 @@ Format your response as a structured markdown document.`; } private async loadPendingFeatures(projectPath: string): Promise { - // Features are stored in external automaker directory - const featuresDir = await getFeaturesDir(projectPath); + // Features are stored in .automaker directory + const featuresDir = getFeaturesDir(projectPath); try { const entries = await fs.readdir(featuresDir, { withFileTypes: true }); @@ -1114,11 +1114,11 @@ When done, summarize what you implemented and any notes for the developer.`; // Execute via provider const stream = provider.executeQuery(options); let responseText = ""; - // Agent output goes to external automaker directory + // Agent output goes to .automaker directory // Note: We use the original projectPath here (from config), not workDir // because workDir might be a worktree path const configProjectPath = this.config?.projectPath || workDir; - const featureDirForOutput = await getFeatureDir(configProjectPath, featureId); + const featureDirForOutput = getFeatureDir(configProjectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); for await (const msg of stream) { diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 578253b4..fdba7b1e 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -1,8 +1,6 @@ /** * Feature Loader - Handles loading and managing features from individual feature folders - * Each feature is stored in external automaker storage: ~/.automaker/projects/{project-id}/features/{featureId}/feature.json - * - * Features are stored outside the git repo to avoid worktree conflicts. + * Each feature is stored in .automaker/features/{featureId}/feature.json */ import path from "path"; @@ -29,17 +27,14 @@ export class FeatureLoader { /** * Get the features directory path */ - async getFeaturesDir(projectPath: string): Promise { + getFeaturesDir(projectPath: string): string { return getFeaturesDir(projectPath); } /** * Get the images directory path for a feature */ - async getFeatureImagesDir( - projectPath: string, - featureId: string - ): Promise { + getFeatureImagesDir(projectPath: string, featureId: string): string { return getFeatureImagesDir(projectPath, featureId); } @@ -95,10 +90,7 @@ export class FeatureLoader { return imagePaths; } - const featureImagesDir = await this.getFeatureImagesDir( - projectPath, - featureId - ); + const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId); await fs.mkdir(featureImagesDir, { recursive: true }); const updatedPaths: Array = @@ -166,30 +158,22 @@ export class FeatureLoader { /** * Get the path to a specific feature folder */ - async getFeatureDir(projectPath: string, featureId: string): Promise { + getFeatureDir(projectPath: string, featureId: string): string { return getFeatureDir(projectPath, featureId); } /** * Get the path to a feature's feature.json file */ - async getFeatureJsonPath( - projectPath: string, - featureId: string - ): Promise { - const featureDir = await this.getFeatureDir(projectPath, featureId); - return path.join(featureDir, "feature.json"); + getFeatureJsonPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), "feature.json"); } /** * Get the path to a feature's agent-output.md file */ - async getAgentOutputPath( - projectPath: string, - featureId: string - ): Promise { - const featureDir = await this.getFeatureDir(projectPath, featureId); - return path.join(featureDir, "agent-output.md"); + getAgentOutputPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md"); } /** @@ -204,7 +188,7 @@ export class FeatureLoader { */ async getAll(projectPath: string): Promise { try { - const featuresDir = await this.getFeaturesDir(projectPath); + const featuresDir = this.getFeaturesDir(projectPath); // Check if features directory exists try { @@ -221,10 +205,7 @@ export class FeatureLoader { const features: Feature[] = []; for (const dir of featureDirs) { const featureId = dir.name; - const featureJsonPath = await this.getFeatureJsonPath( - projectPath, - featureId - ); + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); try { const content = await fs.readFile(featureJsonPath, "utf-8"); @@ -273,10 +254,7 @@ export class FeatureLoader { */ async get(projectPath: string, featureId: string): Promise { try { - const featureJsonPath = await this.getFeatureJsonPath( - projectPath, - featureId - ); + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const content = await fs.readFile(featureJsonPath, "utf-8"); return JSON.parse(content); } catch (error) { @@ -299,8 +277,8 @@ export class FeatureLoader { featureData: Partial ): Promise { const featureId = featureData.id || this.generateFeatureId(); - const featureDir = await this.getFeatureDir(projectPath, featureId); - const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId); + const featureDir = this.getFeatureDir(projectPath, featureId); + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); // Ensure automaker directory exists await ensureAutomakerDir(projectPath); @@ -376,7 +354,7 @@ export class FeatureLoader { }; // Write back to file - const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId); + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); await fs.writeFile( featureJsonPath, JSON.stringify(updatedFeature, null, 2), @@ -392,7 +370,7 @@ export class FeatureLoader { */ async delete(projectPath: string, featureId: string): Promise { try { - const featureDir = await this.getFeatureDir(projectPath, featureId); + const featureDir = this.getFeatureDir(projectPath, featureId); await fs.rm(featureDir, { recursive: true, force: true }); console.log(`[FeatureLoader] Deleted feature ${featureId}`); return true; @@ -413,10 +391,7 @@ export class FeatureLoader { featureId: string ): Promise { try { - const agentOutputPath = await this.getAgentOutputPath( - projectPath, - featureId - ); + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); const content = await fs.readFile(agentOutputPath, "utf-8"); return content; } catch (error) { @@ -439,10 +414,10 @@ export class FeatureLoader { featureId: string, content: string ): Promise { - const featureDir = await this.getFeatureDir(projectPath, featureId); + const featureDir = this.getFeatureDir(projectPath, featureId); await fs.mkdir(featureDir, { recursive: true }); - const agentOutputPath = await this.getAgentOutputPath(projectPath, featureId); + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); await fs.writeFile(agentOutputPath, content, "utf-8"); } @@ -454,10 +429,7 @@ export class FeatureLoader { featureId: string ): Promise { try { - const agentOutputPath = await this.getAgentOutputPath( - projectPath, - featureId - ); + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); await fs.unlink(agentOutputPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { diff --git a/docs/clean-code.md b/docs/clean-code.md new file mode 100644 index 00000000..894ccad1 --- /dev/null +++ b/docs/clean-code.md @@ -0,0 +1,474 @@ +# Clean Code Guidelines + +## Overview + +This document serves as a comprehensive guide for writing clean, maintainable, and extensible code. It outlines principles and practices that ensure code quality, reusability, and long-term maintainability. When writing or reviewing code, follow these guidelines to create software that is easy to understand, modify, and extend. This file is used by LLMs to understand and enforce coding standards throughout the codebase. + +--- + +## Core Principles + +### 1. DRY (Don't Repeat Yourself) + +**Principle**: Every piece of knowledge should have a single, unambiguous representation within a system. + +**Practices**: + +- Extract repeated logic into reusable functions, classes, or modules +- Use constants for repeated values +- Create shared utilities for common operations +- Avoid copy-pasting code blocks +- When you find yourself writing similar code more than twice, refactor it + +**Example - Bad**: + +```typescript +// Repeated validation logic +if (email.includes("@") && email.length > 5) { + // ... +} +if (email.includes("@") && email.length > 5) { + // ... +} +``` + +**Example - Good**: + +```typescript +function isValidEmail(email: string): boolean { + return email.includes("@") && email.length > 5; +} + +if (isValidEmail(email)) { + // ... +} +``` + +--- + +### 2. Code Reusability + +**Principle**: Write code that can be used in multiple contexts without modification or with minimal adaptation. + +**Practices**: + +- Create generic, parameterized functions instead of specific ones +- Use composition over inheritance where appropriate +- Design functions to be pure (no side effects) when possible +- Create utility libraries for common operations +- Use dependency injection to make components reusable +- Design APIs that are flexible and configurable + +**Example - Bad**: + +```typescript +function calculateUserTotal(userId: string) { + const user = getUser(userId); + return user.items.reduce((sum, item) => sum + item.price, 0); +} +``` + +**Example - Good**: + +```typescript +function calculateTotal(items: T[]): number { + return items.reduce((sum, item) => sum + item.price, 0); +} + +function calculateUserTotal(userId: string) { + const user = getUser(userId); + return calculateTotal(user.items); +} +``` + +--- + +### 3. Abstract Functions and Abstractions + +**Principle**: Create abstractions that hide implementation details and provide clear, simple interfaces. + +**Practices**: + +- Use interfaces and abstract classes to define contracts +- Create abstraction layers between different concerns +- Hide complex implementation behind simple function signatures +- Use dependency inversion - depend on abstractions, not concretions +- Create factory functions/classes for object creation +- Use strategy pattern for interchangeable algorithms + +**Example - Bad**: + +```typescript +function processPayment(amount: number, cardNumber: string, cvv: string) { + // Direct implementation tied to specific payment processor + fetch("https://stripe.com/api/charge", { + method: "POST", + body: JSON.stringify({ amount, cardNumber, cvv }), + }); +} +``` + +**Example - Good**: + +```typescript +interface PaymentProcessor { + processPayment( + amount: number, + details: PaymentDetails + ): Promise; +} + +class StripeProcessor implements PaymentProcessor { + async processPayment( + amount: number, + details: PaymentDetails + ): Promise { + // Implementation + } +} + +function processPayment( + processor: PaymentProcessor, + amount: number, + details: PaymentDetails +) { + return processor.processPayment(amount, details); +} +``` + +--- + +### 4. Extensibility + +**Principle**: Design code that can be easily extended with new features without modifying existing code. + +**Practices**: + +- Follow the Open/Closed Principle: open for extension, closed for modification +- Use plugin architectures and hooks for extensibility +- Design with future requirements in mind (but don't over-engineer) +- Use configuration over hardcoding +- Create extension points through interfaces and callbacks +- Use composition and dependency injection +- Design APIs that can accommodate new parameters/options + +**Example - Bad**: + +```typescript +function sendNotification(user: User, type: string) { + if (type === "email") { + sendEmail(user.email); + } else if (type === "sms") { + sendSMS(user.phone); + } + // Adding new notification types requires modifying this function +} +``` + +**Example - Good**: + +```typescript +interface NotificationChannel { + send(user: User): Promise; +} + +class EmailChannel implements NotificationChannel { + async send(user: User): Promise { + // Implementation + } +} + +class SMSChannel implements NotificationChannel { + async send(user: User): Promise { + // Implementation + } +} + +class NotificationService { + constructor(private channels: NotificationChannel[]) {} + + async send(user: User): Promise { + await Promise.all(this.channels.map((channel) => channel.send(user))); + } +} +// New notification types can be added without modifying existing code +``` + +--- + +### 5. Avoid Magic Numbers and Strings + +**Principle**: Use named constants instead of hardcoded values to improve readability and maintainability. + +**Practices**: + +- Extract all magic numbers into named constants +- Use enums for related constants +- Create configuration objects for settings +- Use constants for API endpoints, timeouts, limits, etc. +- Document why specific values are used + +**Example - Bad**: + +```typescript +if (user.age >= 18) { + // What does 18 mean? +} + +setTimeout(() => { + // What does 3000 mean? +}, 3000); + +if (status === "active") { + // What are the valid statuses? +} +``` + +**Example - Good**: + +```typescript +const MINIMUM_AGE_FOR_ADULTS = 18; +const SESSION_TIMEOUT_MS = 3000; + +enum UserStatus { + ACTIVE = "active", + INACTIVE = "inactive", + SUSPENDED = "suspended", +} + +if (user.age >= MINIMUM_AGE_FOR_ADULTS) { + // Clear intent +} + +setTimeout(() => { + // Clear intent +}, SESSION_TIMEOUT_MS); + +if (status === UserStatus.ACTIVE) { + // Type-safe and clear +} +``` + +--- + +## Additional Best Practices + +### 6. Single Responsibility Principle + +Each function, class, or module should have one reason to change. + +**Example**: + +```typescript +// Bad: Multiple responsibilities +class User { + save() { + /* database logic */ + } + sendEmail() { + /* email logic */ + } + validate() { + /* validation logic */ + } +} + +// Good: Single responsibility +class User { + validate() { + /* validation only */ + } +} + +class UserRepository { + save(user: User) { + /* database logic */ + } +} + +class EmailService { + sendToUser(user: User) { + /* email logic */ + } +} +``` + +### 7. Meaningful Names + +- Use descriptive names that reveal intent +- Avoid abbreviations unless they're widely understood +- Use verbs for functions, nouns for classes +- Be consistent with naming conventions + +**Example**: + +```typescript +// Bad +const d = new Date(); +const u = getUser(); +function calc(x, y) {} + +// Good +const currentDate = new Date(); +const currentUser = getUser(); +function calculateTotal(price: number, quantity: number): number {} +``` + +### 8. Small Functions + +- Functions should do one thing and do it well +- Keep functions short (ideally under 20 lines) +- Extract complex logic into separate functions +- Use descriptive function names instead of comments + +### 9. Error Handling + +- Handle errors explicitly +- Use appropriate error types +- Provide meaningful error messages +- Don't swallow errors silently +- Use try-catch appropriately + +**Example**: + +```typescript +// Bad +function divide(a: number, b: number) { + return a / b; // Can throw division by zero +} + +// Good +function divide(a: number, b: number): number { + if (b === 0) { + throw new Error("Division by zero is not allowed"); + } + return a / b; +} +``` + +### 10. Comments and Documentation + +- Write self-documenting code (code should explain itself) +- Use comments to explain "why", not "what" +- Document complex algorithms or business logic +- Keep comments up-to-date with code changes +- Use JSDoc/TSDoc for public APIs + +### 11. Type Safety + +- Use TypeScript types/interfaces effectively +- Avoid `any` type unless absolutely necessary +- Use union types and discriminated unions +- Leverage type inference where appropriate +- Create custom types for domain concepts + +**Example**: + +```typescript +// Bad +function processUser(data: any) { + return data.name; +} + +// Good +interface User { + id: string; + name: string; + email: string; +} + +function processUser(user: User): string { + return user.name; +} +``` + +### 12. Testing Considerations + +- Write testable code (pure functions, dependency injection) +- Keep functions small and focused +- Avoid hidden dependencies +- Use mocks and stubs appropriately +- Design for testability from the start + +### 13. Performance vs. Readability + +- Prefer readability over premature optimization +- Profile before optimizing +- Use clear algorithms first, optimize if needed +- Document performance-critical sections +- Balance between clean code and performance requirements + +### 14. Code Organization + +- Group related functionality together +- Use modules/packages to organize code +- Follow consistent file and folder structures +- Separate concerns (UI, business logic, data access) +- Use barrel exports (index files) appropriately + +### 15. Configuration Management + +- Externalize configuration values +- Use environment variables for environment-specific settings +- Create configuration objects/interfaces +- Validate configuration at startup +- Provide sensible defaults + +**Example**: + +```typescript +// Bad +const apiUrl = "https://api.example.com"; +const timeout = 5000; + +// Good +interface Config { + apiUrl: string; + timeout: number; + maxRetries: number; +} + +const config: Config = { + apiUrl: process.env.API_URL || "https://api.example.com", + timeout: parseInt(process.env.TIMEOUT || "5000"), + maxRetries: parseInt(process.env.MAX_RETRIES || "3"), +}; +``` + +--- + +## Code Review Checklist + +When reviewing code, check for: + +- [ ] No code duplication (DRY principle) +- [ ] Meaningful variable and function names +- [ ] No magic numbers or strings +- [ ] Functions are small and focused +- [ ] Proper error handling +- [ ] Type safety maintained +- [ ] Code is testable +- [ ] Documentation where needed +- [ ] Consistent code style +- [ ] Proper abstraction levels +- [ ] Extensibility considered +- [ ] Single responsibility principle followed + +--- + +## Summary + +Clean code is: + +- **Readable**: Easy to understand at a glance +- **Maintainable**: Easy to modify and update +- **Testable**: Easy to write tests for +- **Extensible**: Easy to add new features +- **Reusable**: Can be used in multiple contexts +- **Well-documented**: Clear intent and purpose +- **Type-safe**: Leverages type system effectively +- **DRY**: No unnecessary repetition +- **Abstracted**: Proper separation of concerns +- **Configurable**: Uses constants and configuration over hardcoding + +Remember: Code is read far more often than it is written. Write code for your future self and your teammates.