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.
This commit is contained in:
Cody Seibert
2025-12-16 13:56:53 -05:00
parent 8482cdab87
commit 8c24381759
26 changed files with 1302 additions and 466 deletions

View File

@@ -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);

View File

@@ -47,6 +47,8 @@ export function CreatePRDialog({
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(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
</Button>
</div>
) : showBrowserFallback && browserUrl ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
<GitPullRequest className="w-8 h-8 text-blue-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your changes have been pushed to GitHub.
<br />
Click below to create a pull request in your browser.
</p>
</div>
<div className="space-y-2">
<Button
onClick={() => window.open(browserUrl, "_blank")}
className="gap-2 w-full"
>
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
</p>
</div>
</div>
) : (
<>
<div className="grid gap-4 py-4">

View File

@@ -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.

View File

@@ -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" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}

View File

@@ -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 <StatusBadge status="checking" label="Checking..." />;
}
if (ghCliStatus?.authenticated) {
return <StatusBadge status="authenticated" label="Ready" />;
}
if (ghCliStatus?.installed) {
return <StatusBadge status="unverified" label="Not Logged In" />;
}
return <StatusBadge status="not_installed" label="Not Installed" />;
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<Github className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
GitHub CLI Setup
</h2>
<p className="text-muted-foreground">
Optional - Used for creating pull requests
</p>
</div>
{/* Info Banner */}
<Card className="bg-amber-500/10 border-amber-500/20">
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-foreground">
This step is optional
</p>
<p className="text-sm text-muted-foreground mt-1">
The GitHub CLI allows you to create pull requests directly from
the app. Without it, you can still create PRs manually in your
browser.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Github className="w-5 h-5" />
GitHub CLI Status
</CardTitle>
<div className="flex items-center gap-2">
{getStatusBadge()}
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
<CardDescription>
{ghCliStatus?.installed
? ghCliStatus.authenticated
? `Logged in${ghCliStatus.user ? ` as ${ghCliStatus.user}` : ""}`
: "Installed but not logged in"
: "Not installed on your system"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Success State */}
{isReady && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
GitHub CLI is ready!
</p>
<p className="text-sm text-muted-foreground">
You can create pull requests directly from the app.
{ghCliStatus?.version && (
<span className="ml-1">Version: {ghCliStatus.version}</span>
)}
</p>
</div>
</div>
)}
{/* Not Installed */}
{!ghCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not found
</p>
<p className="text-sm text-muted-foreground mt-1">
Install the GitHub CLI to enable PR creation from the app.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">
Installation Commands:
</p>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">macOS (Homebrew)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
brew install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("brew install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Windows (winget)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
winget install GitHub.cli
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("winget install GitHub.cli")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Linux (apt)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
sudo apt install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("sudo apt install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<a
href="https://cli.github.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
>
View all installation options
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
)}
{/* Installed but not authenticated */}
{ghCliStatus?.installed && !ghCliStatus?.authenticated && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not logged in
</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate with GitHub.
</p>
</div>
</div>
<div className="space-y-2 p-4 rounded-lg bg-muted/30 border border-border">
<p className="text-sm text-muted-foreground">
Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
gh auth login
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("gh auth login")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Checking GitHub CLI status...
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
{isReady ? "Skip" : "Skip for now"}
</Button>
<Button
onClick={onNext}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="github-next-button"
>
{isReady ? "Continue" : "Continue without GitHub CLI"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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";

View File

@@ -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 () => {};

View File

@@ -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);
},

View File

@@ -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);
}

View File

@@ -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<InstallProgress>) => 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<SetupState & SetupActions>()(
claudeInstallProgress: { ...initialInstallProgress },
}),
// GitHub CLI
setGhCliStatus: (status) => set({ ghCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}),

View File

@@ -719,6 +719,8 @@ export interface WorktreeAPI {
prUrl?: string;
prCreated: boolean;
prError?: string;
browserUrl?: string;
ghCliAvailable?: boolean;
};
error?: string;
}>;

View File

@@ -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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<boolean> {
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<string> {
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<boolean> {
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<string> {
// 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<string> {
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<string> {
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;
}

View File

@@ -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);

View File

@@ -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)`);

View File

@@ -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 }> = [];

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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<GhStatus> {
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<void> => {
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) });
}
};
}

View File

@@ -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<TrackedBranch[]> {
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 || [];

View File

@@ -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) {

View File

@@ -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<void> => {
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,
});
};
}

View File

@@ -337,8 +337,8 @@ export class AutoModeService {
featureId: string,
useWorktrees = true
): Promise<void> {
// 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<boolean> {
// 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<string> {
// 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<Feature | null> {
// 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<void> {
// 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<Feature[]> {
// 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) {

View File

@@ -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<string> {
getFeaturesDir(projectPath: string): string {
return getFeaturesDir(projectPath);
}
/**
* Get the images directory path for a feature
*/
async getFeatureImagesDir(
projectPath: string,
featureId: string
): Promise<string> {
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<string | { path: string; [key: string]: unknown }> =
@@ -166,30 +158,22 @@ export class FeatureLoader {
/**
* Get the path to a specific feature folder
*/
async getFeatureDir(projectPath: string, featureId: string): Promise<string> {
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<string> {
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<string> {
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<Feature[]> {
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<Feature | null> {
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<Feature>
): Promise<Feature> {
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<boolean> {
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<string | null> {
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<void> {
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<void> {
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") {

474
docs/clean-code.md Normal file
View File

@@ -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<T extends { price: number }>(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<PaymentResult>;
}
class StripeProcessor implements PaymentProcessor {
async processPayment(
amount: number,
details: PaymentDetails
): Promise<PaymentResult> {
// 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<void>;
}
class EmailChannel implements NotificationChannel {
async send(user: User): Promise<void> {
// Implementation
}
}
class SMSChannel implements NotificationChannel {
async send(user: User): Promise<void> {
// Implementation
}
}
class NotificationService {
constructor(private channels: NotificationChannel[]) {}
async send(user: User): Promise<void> {
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.