mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
2
apps/app/src/types/electron.d.ts
vendored
2
apps/app/src/types/electron.d.ts
vendored
@@ -719,6 +719,8 @@ export interface WorktreeAPI {
|
||||
prUrl?: string;
|
||||
prCreated: boolean;
|
||||
prError?: string;
|
||||
browserUrl?: string;
|
||||
ghCliAvailable?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
131
apps/server/src/routes/setup/routes/gh-status.ts
Normal file
131
apps/server/src/routes/setup/routes/gh-status.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user