mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
adding new project from template
This commit is contained in:
424
apps/app/src/components/new-project-modal.tsx
Normal file
424
apps/app/src/components/new-project-modal.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
FolderPlus,
|
||||
Rocket,
|
||||
ExternalLink,
|
||||
Check,
|
||||
Loader2,
|
||||
Link,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
workspaceDir?: boolean;
|
||||
templateSelection?: boolean;
|
||||
customUrl?: boolean;
|
||||
}
|
||||
|
||||
interface NewProjectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
|
||||
onCreateFromTemplate: (
|
||||
template: StarterTemplate,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
onCreateFromCustomUrl: (
|
||||
repoUrl: string,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export function NewProjectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreateBlankProject,
|
||||
onCreateFromTemplate,
|
||||
onCreateFromCustomUrl,
|
||||
isCreating,
|
||||
}: NewProjectModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [workspaceDir, setWorkspaceDir] = useState<string>("");
|
||||
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
|
||||
const [useCustomUrl, setUseCustomUrl] = useState(false);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
|
||||
// Fetch workspace directory when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsLoadingWorkspace(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.workspace.getConfig()
|
||||
.then((result) => {
|
||||
if (result.success && result.workspaceDir) {
|
||||
setWorkspaceDir(result.workspaceDir);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get workspace config:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingWorkspace(false);
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setProjectName("");
|
||||
setSelectedTemplate(null);
|
||||
setUseCustomUrl(false);
|
||||
setCustomUrl("");
|
||||
setActiveTab("blank");
|
||||
setErrors({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clear specific errors when user fixes them
|
||||
useEffect(() => {
|
||||
if (projectName && errors.projectName) {
|
||||
setErrors((prev) => ({ ...prev, projectName: false }));
|
||||
}
|
||||
}, [projectName, errors.projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
|
||||
setErrors((prev) => ({ ...prev, templateSelection: false }));
|
||||
}
|
||||
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customUrl && errors.customUrl) {
|
||||
setErrors((prev) => ({ ...prev, customUrl: false }));
|
||||
}
|
||||
}, [customUrl, errors.customUrl]);
|
||||
|
||||
const validateAndCreate = async () => {
|
||||
const newErrors: ValidationErrors = {};
|
||||
|
||||
// Check project name
|
||||
if (!projectName.trim()) {
|
||||
newErrors.projectName = true;
|
||||
}
|
||||
|
||||
// Check workspace dir
|
||||
if (!workspaceDir) {
|
||||
newErrors.workspaceDir = true;
|
||||
}
|
||||
|
||||
// Check template selection (only for template tab)
|
||||
if (activeTab === "template") {
|
||||
if (useCustomUrl) {
|
||||
if (!customUrl.trim()) {
|
||||
newErrors.customUrl = true;
|
||||
}
|
||||
} else if (!selectedTemplate) {
|
||||
newErrors.templateSelection = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are errors, show them and don't proceed
|
||||
if (Object.values(newErrors).some(Boolean)) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear errors and proceed
|
||||
setErrors({});
|
||||
|
||||
if (activeTab === "blank") {
|
||||
await onCreateBlankProject(projectName, workspaceDir);
|
||||
} else if (useCustomUrl && customUrl) {
|
||||
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
|
||||
} else if (selectedTemplate) {
|
||||
await onCreateFromTemplate(selectedTemplate, projectName, workspaceDir);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRepo = (url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
};
|
||||
|
||||
const handleSelectTemplate = (template: StarterTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setUseCustomUrl(false);
|
||||
setCustomUrl("");
|
||||
};
|
||||
|
||||
const handleToggleCustomUrl = () => {
|
||||
setUseCustomUrl(!useCustomUrl);
|
||||
if (!useCustomUrl) {
|
||||
setSelectedTemplate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="bg-card border-border max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||
data-testid="new-project-modal"
|
||||
>
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Start with a blank project or choose from a starter template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Project Name Input - Always visible at top */}
|
||||
<div className="space-y-3 pb-4 border-b border-border">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name" className={cn("text-foreground", errors.projectName && "text-red-500")}>
|
||||
Project Name {errors.projectName && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="my-awesome-project"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className={cn(
|
||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||
errors.projectName
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-border"
|
||||
)}
|
||||
data-testid="project-name-input"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.projectName && (
|
||||
<p className="text-xs text-red-500">Project name is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace Directory Display */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
|
||||
)}>
|
||||
<Folder className="w-4 h-4" />
|
||||
<span>
|
||||
{isLoadingWorkspace ? (
|
||||
"Loading workspace..."
|
||||
) : workspaceDir ? (
|
||||
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded">{projectPath || "..."}</code></>
|
||||
) : (
|
||||
<span className="text-red-500">No workspace configured - please configure WORKSPACE_DIR</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="blank" className="gap-2">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Blank Project
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="template" className="gap-2">
|
||||
<Rocket className="w-4 h-4" />
|
||||
Starter Kit
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<TabsContent value="blank" className="mt-0">
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create an empty project with the standard .automaker directory
|
||||
structure. Perfect for starting from scratch or importing an
|
||||
existing codebase.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="template" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
{/* Error message for template selection */}
|
||||
{errors.templateSelection && (
|
||||
<p className="text-sm text-red-500">Please select a template or enter a custom GitHub URL</p>
|
||||
)}
|
||||
|
||||
{/* Preset Templates */}
|
||||
<div className={cn(
|
||||
"space-y-3 rounded-lg p-1 -m-1",
|
||||
errors.templateSelection && "ring-2 ring-red-500/50"
|
||||
)}>
|
||||
{starterTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={cn(
|
||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||
selectedTemplate?.id === template.id && !useCustomUrl
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
data-testid={`template-${template.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-foreground">
|
||||
{template.name}
|
||||
</h4>
|
||||
{selectedTemplate?.id === template.id && !useCustomUrl && (
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{template.techStack.slice(0, 6).map((tech) => (
|
||||
<Badge
|
||||
key={tech}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{tech}
|
||||
</Badge>
|
||||
))}
|
||||
{template.techStack.length > 6 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{template.techStack.length - 6} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Features */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Features: </span>
|
||||
{template.features.slice(0, 3).join(" · ")}
|
||||
{template.features.length > 3 &&
|
||||
` · +${template.features.length - 3} more`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenRepo(template.repoUrl);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom URL Option */}
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||
useCustomUrl
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleToggleCustomUrl}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
|
||||
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Clone any public GitHub repository as a starting point.
|
||||
</p>
|
||||
|
||||
{useCustomUrl && (
|
||||
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
|
||||
<Input
|
||||
placeholder="https://github.com/username/repository"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
className={cn(
|
||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||
errors.customUrl
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-border"
|
||||
)}
|
||||
data-testid="custom-url-input"
|
||||
/>
|
||||
{errors.customUrl && (
|
||||
<p className="text-xs text-red-500">GitHub URL is required</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="border-t border-border pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={validateAndCreate}
|
||||
disabled={isCreating}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{activeTab === "template" ? "Cloning..." : "Creating..."}
|
||||
</>
|
||||
) : (
|
||||
<>Create Project</>
|
||||
)}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,13 +10,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI, type Project } from "@/lib/electron";
|
||||
import { initializeProject } from "@/lib/project-init";
|
||||
@@ -41,14 +31,14 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
||||
import { NewProjectModal } from "@/components/new-project-modal";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import type { StarterTemplate } from "@/lib/templates";
|
||||
|
||||
export function WelcomeView() {
|
||||
const { projects, addProject, setCurrentProject, setCurrentView } =
|
||||
useAppStore();
|
||||
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [newProjectPath, setNewProjectPath] = useState("");
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [showInitDialog, setShowInitDialog] = useState(false);
|
||||
@@ -231,31 +221,21 @@ export function WelcomeView() {
|
||||
);
|
||||
|
||||
const handleNewProject = () => {
|
||||
setNewProjectName("");
|
||||
setNewProjectPath("");
|
||||
setShowNewProjectDialog(true);
|
||||
setShowNewProjectModal(true);
|
||||
};
|
||||
|
||||
const handleInteractiveMode = () => {
|
||||
setCurrentView("interview");
|
||||
};
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
setNewProjectPath(result.filePaths[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName || !newProjectPath) return;
|
||||
|
||||
/**
|
||||
* Create a blank project with just .automaker directory structure
|
||||
*/
|
||||
const handleCreateBlankProject = async (projectName: string, parentDir: string) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${newProjectPath}/${newProjectName}`;
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Create project directory
|
||||
await api.mkdir(projectPath);
|
||||
@@ -274,7 +254,7 @@ export function WelcomeView() {
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${newProjectName}</project_name>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
Describe your project here. This file will be analyzed by an AI agent
|
||||
@@ -297,24 +277,24 @@ export function WelcomeView() {
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name: newProjectName,
|
||||
name: projectName,
|
||||
path: projectPath,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectDialog(false);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success("Project created", {
|
||||
description: `Created ${newProjectName} with .automaker directory`,
|
||||
description: `Created ${projectName} with .automaker directory`,
|
||||
});
|
||||
|
||||
// Set init status to show the dialog
|
||||
setInitStatus({
|
||||
isNewProject: true,
|
||||
createdFiles: initResult.createdFiles || [],
|
||||
projectName: newProjectName,
|
||||
projectName: projectName,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
setShowInitDialog(true);
|
||||
@@ -328,6 +308,206 @@ export function WelcomeView() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a project from a GitHub starter template
|
||||
*/
|
||||
const handleCreateFromTemplate = async (
|
||||
template: StarterTemplate,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Clone the template repository
|
||||
const cloneResult = await httpClient.templates.clone(
|
||||
template.repoUrl,
|
||||
projectName,
|
||||
parentDir
|
||||
);
|
||||
|
||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||
toast.error("Failed to clone template", {
|
||||
description: cloneResult.error || "Unknown error occurred",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = cloneResult.projectPath;
|
||||
|
||||
// Initialize .automaker directory with all necessary files
|
||||
const initResult = await initializeProject(projectPath);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error("Failed to initialize project", {
|
||||
description: initResult.error || "Unknown error occurred",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the app_spec.txt with template-specific info
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
This project was created from the "${template.name}" starter template.
|
||||
${template.description}
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join("\n ")}
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
${template.features.map((feature) => `<capability>${feature}</capability>`).join("\n ")}
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
<!-- The AI agent will populate this based on code analysis -->
|
||||
</implemented_features>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name: projectName,
|
||||
path: projectPath,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success("Project created from template", {
|
||||
description: `Created ${projectName} from ${template.name}`,
|
||||
});
|
||||
|
||||
// Set init status to show the dialog
|
||||
setInitStatus({
|
||||
isNewProject: true,
|
||||
createdFiles: initResult.createdFiles || [],
|
||||
projectName: projectName,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
setShowInitDialog(true);
|
||||
|
||||
// Kick off project analysis
|
||||
analyzeProject(projectPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project from template:", error);
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a project from a custom GitHub URL
|
||||
*/
|
||||
const handleCreateFromCustomUrl = async (
|
||||
repoUrl: string,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Clone the repository
|
||||
const cloneResult = await httpClient.templates.clone(
|
||||
repoUrl,
|
||||
projectName,
|
||||
parentDir
|
||||
);
|
||||
|
||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||
toast.error("Failed to clone repository", {
|
||||
description: cloneResult.error || "Unknown error occurred",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = cloneResult.projectPath;
|
||||
|
||||
// Initialize .automaker directory with all necessary files
|
||||
const initResult = await initializeProject(projectPath);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error("Failed to initialize project", {
|
||||
description: initResult.error || "Unknown error occurred",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the app_spec.txt with basic info
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
This project was cloned from ${repoUrl}.
|
||||
The AI agent will analyze the project structure.
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
<!-- The AI agent will fill this in after analyzing your project -->
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
<!-- List core features and capabilities -->
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
<!-- The AI agent will populate this based on code analysis -->
|
||||
</implemented_features>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name: projectName,
|
||||
path: projectPath,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success("Project created from repository", {
|
||||
description: `Created ${projectName} from ${repoUrl}`,
|
||||
});
|
||||
|
||||
// Set init status to show the dialog
|
||||
setInitStatus({
|
||||
isNewProject: true,
|
||||
createdFiles: initResult.createdFiles || [],
|
||||
projectName: projectName,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
setShowInitDialog(true);
|
||||
|
||||
// Kick off project analysis
|
||||
analyzeProject(projectPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project from custom URL:", error);
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recentProjects = [...projects]
|
||||
.sort((a, b) => {
|
||||
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
|
||||
@@ -508,82 +688,15 @@ export function WelcomeView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Project Dialog */}
|
||||
<Dialog
|
||||
open={showNewProjectDialog}
|
||||
onOpenChange={setShowNewProjectDialog}
|
||||
>
|
||||
<DialogContent
|
||||
className="bg-card border-border"
|
||||
data-testid="new-project-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">
|
||||
Create New Project
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Set up a new project directory with initial configuration files.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name" className="text-foreground">
|
||||
Project Name
|
||||
</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="my-awesome-project"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
className="bg-input border-border text-foreground placeholder:text-muted-foreground"
|
||||
data-testid="project-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-path" className="text-foreground">
|
||||
Parent Directory
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="project-path"
|
||||
placeholder="/path/to/projects"
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
className="flex-1 bg-input border-border text-foreground placeholder:text-muted-foreground"
|
||||
data-testid="project-path-input"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSelectDirectory}
|
||||
className="bg-secondary hover:bg-secondary/80 text-foreground border border-border"
|
||||
data-testid="browse-directory"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowNewProjectDialog(false)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectName || !newProjectPath || isCreating}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={showNewProjectDialog}
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Project"}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* New Project Modal */}
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
onOpenChange={setShowNewProjectModal}
|
||||
onCreateBlankProject={handleCreateBlankProject}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
{/* Project Initialization Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>
|
||||
|
||||
@@ -702,6 +702,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
},
|
||||
};
|
||||
|
||||
// Templates API
|
||||
templates = {
|
||||
clone: (repoUrl: string, projectName: string, parentDir: string): Promise<{
|
||||
success: boolean;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
sessions = {
|
||||
list: (includeArchived?: boolean): Promise<{
|
||||
|
||||
62
apps/app/src/lib/templates.ts
Normal file
62
apps/app/src/lib/templates.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Starter Kit Templates
|
||||
*
|
||||
* Define GitHub templates that users can clone when creating new projects.
|
||||
*/
|
||||
|
||||
export interface StarterTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
repoUrl: string;
|
||||
techStack: string[];
|
||||
features: string[];
|
||||
category: "fullstack" | "frontend" | "backend" | "ai" | "other";
|
||||
author: string;
|
||||
}
|
||||
|
||||
export const starterTemplates: StarterTemplate[] = [
|
||||
{
|
||||
id: "agentic-jumpstart",
|
||||
name: "Agentic Jumpstart",
|
||||
description: "A starter template for building agentic AI applications with a pre-configured development environment including database setup, Docker support, and TypeScript configuration.",
|
||||
repoUrl: "https://github.com/webdevcody/agentic-jumpstart-starter-kit",
|
||||
techStack: ["TypeScript", "Vite", "Drizzle ORM", "Docker", "PostCSS"],
|
||||
features: [
|
||||
"Pre-configured VS Code settings",
|
||||
"Docker Compose setup",
|
||||
"Database migrations with Drizzle",
|
||||
"Type-safe development",
|
||||
"Environment setup with .env.example"
|
||||
],
|
||||
category: "ai",
|
||||
author: "webdevcody"
|
||||
},
|
||||
{
|
||||
id: "full-stack-campus",
|
||||
name: "Full Stack Campus",
|
||||
description: "A feature-driven development template for building community platforms. Includes authentication, Stripe payments, file uploads, and real-time features using TanStack Start.",
|
||||
repoUrl: "https://github.com/webdevcody/full-stack-campus",
|
||||
techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"],
|
||||
features: [
|
||||
"Community posts with comments and reactions",
|
||||
"User profiles and portfolios",
|
||||
"Calendar event management",
|
||||
"Direct messaging",
|
||||
"Member discovery directory",
|
||||
"Real-time notifications",
|
||||
"Tiered subscriptions (free/basic/pro)",
|
||||
"File uploads with presigned URLs"
|
||||
],
|
||||
category: "fullstack",
|
||||
author: "webdevcody"
|
||||
}
|
||||
];
|
||||
|
||||
export function getTemplateById(id: string): StarterTemplate | undefined {
|
||||
return starterTemplates.find(t => t.id === id);
|
||||
}
|
||||
|
||||
export function getTemplatesByCategory(category: StarterTemplate["category"]): StarterTemplate[] {
|
||||
return starterTemplates.filter(t => t.category === category);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { createModelsRoutes } from "./routes/models.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
|
||||
@@ -112,6 +113,7 @@ app.use("/api/models", createModelsRoutes());
|
||||
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes());
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
171
apps/server/src/routes/templates.ts
Normal file
171
apps/server/src/routes/templates.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Templates routes
|
||||
* Provides API for cloning GitHub starter templates
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { addAllowedPath } from "../lib/security.js";
|
||||
|
||||
export function createTemplatesRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Clone a GitHub template to a new project directory
|
||||
* POST /api/templates/clone
|
||||
* Body: { repoUrl: string, projectName: string, parentDir: string }
|
||||
*/
|
||||
router.post("/clone", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { repoUrl, projectName, parentDir } = req.body as {
|
||||
repoUrl: string;
|
||||
projectName: string;
|
||||
parentDir: string;
|
||||
};
|
||||
|
||||
// Validate inputs
|
||||
if (!repoUrl || !projectName || !parentDir) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "repoUrl, projectName, and parentDir are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate repo URL is a valid GitHub URL
|
||||
const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
|
||||
if (!githubUrlPattern.test(repoUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid GitHub repository URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize project name (allow alphanumeric, dash, underscore)
|
||||
const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||
if (sanitizedName !== projectName) {
|
||||
console.log(
|
||||
`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`
|
||||
);
|
||||
}
|
||||
|
||||
// Build full project path
|
||||
const projectPath = path.join(parentDir, sanitizedName);
|
||||
|
||||
// Check if directory already exists
|
||||
try {
|
||||
await fs.access(projectPath);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Directory "${sanitizedName}" already exists in ${parentDir}`,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
try {
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("[Templates] Failed to create parent directory:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to create parent directory",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Templates] Cloning ${repoUrl} to ${projectPath}`);
|
||||
|
||||
// Clone the repository
|
||||
const cloneResult = await new Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>((resolve) => {
|
||||
const gitProcess = spawn("git", ["clone", repoUrl, projectPath], {
|
||||
cwd: parentDir,
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
|
||||
gitProcess.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
gitProcess.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: stderr || `Git clone failed with code ${code}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on("error", (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to spawn git: ${error.message}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!cloneResult.success) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: cloneResult.error || "Failed to clone repository",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove .git directory to start fresh
|
||||
try {
|
||||
const gitDir = path.join(projectPath, ".git");
|
||||
await fs.rm(gitDir, { recursive: true, force: true });
|
||||
console.log("[Templates] Removed .git directory");
|
||||
} catch (error) {
|
||||
console.warn("[Templates] Could not remove .git directory:", error);
|
||||
// Continue anyway - not critical
|
||||
}
|
||||
|
||||
// Initialize a fresh git repository
|
||||
await new Promise<void>((resolve) => {
|
||||
const gitInit = spawn("git", ["init"], {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
gitInit.on("close", () => {
|
||||
console.log("[Templates] Initialized fresh git repository");
|
||||
resolve();
|
||||
});
|
||||
|
||||
gitInit.on("error", () => {
|
||||
console.warn("[Templates] Could not initialize git");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Add to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
console.log(`[Templates] Successfully cloned template to ${projectPath}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
projectPath,
|
||||
projectName: sanitizedName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Templates] Clone error:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user