Merge branch 'main' into random-fixes

This commit is contained in:
Cody Seibert
2025-12-14 14:19:06 -05:00
20 changed files with 1071 additions and 858 deletions

View File

@@ -33,6 +33,7 @@ interface BrowseResult {
directories: DirectoryEntry[];
drives?: string[];
error?: string;
warning?: string;
}
interface FileBrowserDialogProps {
@@ -41,6 +42,7 @@ interface FileBrowserDialogProps {
onSelect: (path: string) => void;
title?: string;
description?: string;
initialPath?: string;
}
export function FileBrowserDialog({
@@ -49,6 +51,7 @@ export function FileBrowserDialog({
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder or paste a path directly",
initialPath,
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [pathInput, setPathInput] = useState<string>("");
@@ -57,11 +60,13 @@ export function FileBrowserDialog({
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
try {
// Get server URL from environment or default
@@ -82,6 +87,7 @@ export function FileBrowserDialog({
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
setWarning(result.warning || "");
} else {
setError(result.error || "Failed to browse directory");
}
@@ -94,13 +100,25 @@ export function FileBrowserDialog({
}
};
// Load home directory on mount
// Reset current path when dialog closes
useEffect(() => {
if (open && !currentPath) {
browseDirectory();
if (!open) {
setCurrentPath("");
setPathInput("");
setParentPath(null);
setDirectories([]);
setError("");
setWarning("");
}
}, [open]);
// Load initial path or home directory when dialog opens
useEffect(() => {
if (open && !currentPath) {
browseDirectory(initialPath);
}
}, [open, initialPath]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
};
@@ -246,7 +264,13 @@ export function FileBrowserDialog({
</div>
)}
{!loading && !error && directories.length === 0 && (
{warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
<div className="text-sm text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
No subdirectories found

View File

@@ -34,6 +34,10 @@ import {
Sparkles,
Loader2,
Terminal,
Rocket,
Zap,
CheckCircle2,
ArrowRight,
} from "lucide-react";
import {
DropdownMenu,
@@ -78,6 +82,7 @@ import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { NewProjectModal } from "@/components/new-project-modal";
import {
DndContext,
DragEndEvent,
@@ -92,6 +97,8 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { getHttpApiClient } from "@/lib/http-api-client";
import type { StarterTemplate } from "@/lib/templates";
interface NavSection {
label?: string;
@@ -205,6 +212,8 @@ export function Sidebar() {
setPreviewTheme,
theme: globalTheme,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
@@ -234,17 +243,26 @@ export function Sidebar() {
// State for running agents count
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
// State for new project modal
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
// State for new project onboarding dialog
const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
// State for new project setup dialog
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [isCreatingSpec, setIsCreatingSpec] = useState(false);
const [creatingSpecProjectPath, setCreatingSpecProjectPath] = useState<
string | null
>(null);
const [generateFeatures, setGenerateFeatures] = useState(true);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Ref for project search input
const projectSearchInputRef = useRef<HTMLInputElement>(null);
@@ -334,22 +352,39 @@ export function Sidebar() {
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log("[Sidebar] Spec regeneration event:", event.type);
console.log(
"[Sidebar] Spec regeneration event:",
event.type,
"for project:",
event.projectPath
);
// Only handle events for the project we're currently setting up
if (
event.projectPath !== creatingSpecProjectPath &&
event.projectPath !== setupProjectPath
) {
console.log(
"[Sidebar] Ignoring event - not for project being set up"
);
return;
}
if (event.type === "spec_regeneration_complete") {
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
// Clear onboarding state if we came from onboarding
setNewProjectName("");
setNewProjectPath("");
toast.success("App specification created", {
description: "Your project is now set up and ready to go!",
});
// Navigate to spec view to show the new spec
setCurrentView("spec");
} else if (event.type === "spec_regeneration_error") {
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: event.error,
});
@@ -360,7 +395,12 @@ export function Sidebar() {
return () => {
unsubscribe();
};
}, [setCurrentView]);
}, [
setCurrentView,
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
]);
// Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => {
@@ -409,8 +449,8 @@ export function Sidebar() {
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
setIsCreatingSpec(true);
setCreatingSpecProjectPath(setupProjectPath);
// Set store state immediately so the loader shows up right away
setSpecCreatingForProject(setupProjectPath);
setShowSpecIndicator(true);
setShowSetupDialog(false);
@@ -418,8 +458,7 @@ export function Sidebar() {
const api = getElectronAPI();
if (!api.specRegeneration) {
toast.error("Spec regeneration not available");
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
return;
}
const result = await api.specRegeneration.create(
@@ -430,8 +469,7 @@ export function Sidebar() {
if (!result.success) {
console.error("[Sidebar] Failed to start spec creation:", result.error);
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: result.error,
});
@@ -439,24 +477,345 @@ export function Sidebar() {
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error("[Sidebar] Failed to create spec:", error);
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}, [setupProjectPath, projectOverview]);
}, [setupProjectPath, projectOverview, setSpecCreatingForProject]);
// Handle skipping setup
const handleSkipSetup = useCallback(() => {
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
// Clear onboarding state if we came from onboarding
if (newProjectPath) {
setNewProjectName("");
setNewProjectPath("");
}
toast.info("Setup skipped", {
description: "You can set up your app_spec.txt later from the Spec view.",
});
}, [newProjectPath]);
// Handle onboarding dialog - generate spec
const handleOnboardingGenerateSpec = useCallback(() => {
setShowOnboardingDialog(false);
// Navigate to the setup dialog flow
setSetupProjectPath(newProjectPath);
setProjectOverview("");
setShowSetupDialog(true);
}, [newProjectPath]);
// Handle onboarding dialog - skip
const handleOnboardingSkip = useCallback(() => {
setShowOnboardingDialog(false);
setNewProjectName("");
setNewProjectPath("");
toast.info(
"You can generate your app_spec.txt anytime from the Spec view",
{
description: "Your project is ready to use!",
}
);
}, []);
/**
* Create a blank project with just .automaker directory structure
*/
const handleCreateBlankProject = useCallback(
async (projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
return;
}
// 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 the project name
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
to understand your project structure and tech stack.
</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 trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created", {
description: `Created ${projectName} with .automaker directory`,
});
} catch (error) {
console.error("[Sidebar] Failed to create project:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a project from a GitHub starter template
*/
const handleCreateFromTemplate = useCallback(
async (
template: StarterTemplate,
projectName: string,
parentDir: string
) => {
setIsCreatingProject(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
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
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 trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created from template", {
description: `Created ${projectName} from ${template.name}`,
});
} catch (error) {
console.error(
"[Sidebar] Failed to create project from template:",
error
);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a project from a custom GitHub URL
*/
const handleCreateFromCustomUrl = useCallback(
async (repoUrl: string, projectName: string, parentDir: string) => {
setIsCreatingProject(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
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
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 trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created from repository", {
description: `Created ${projectName} from ${repoUrl}`,
});
} catch (error) {
console.error("[Sidebar] Failed to create project from URL:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
@@ -871,7 +1230,7 @@ export function Sidebar() {
<img
src="/logo.png"
alt="A"
className="h-[1.3em] w-auto inline-block align-middle group-hover:rotate-12 transition-transform"
className="h-[1.8em] w-auto inline-block align-middle group-hover:rotate-12 transition-transform"
/>
<span className="-ml-0.5">
uto<span className="text-brand-500">maker</span>
@@ -899,7 +1258,7 @@ export function Sidebar() {
{sidebarOpen && (
<div className="flex items-center gap-2 titlebar-no-drag px-2 mt-3">
<button
onClick={() => setCurrentView("welcome")}
onClick={() => setShowNewProjectModal(true)}
className="group flex items-center justify-center flex-1 px-3 py-2.5 rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border"
title="New Project"
data-testid="new-project-button"
@@ -1621,27 +1980,112 @@ export function Sidebar() {
</DialogContent>
</Dialog>
{/* Spec Creation Indicator - Bottom Right Toast */}
{isCreatingSpec &&
showSpecIndicator &&
currentProject?.path === creatingSpecProjectPath && (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 bg-card border border-border rounded-lg shadow-lg p-4 max-w-sm">
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Creating App Specification</p>
<p className="text-xs text-muted-foreground truncate">
Working on your project...
{/* New Project Onboarding Dialog */}
<Dialog
open={showOnboardingDialog}
onOpenChange={(open) => {
if (!open) {
handleOnboardingSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
<Rocket className="w-6 h-6 text-brand-500" />
</div>
<div>
<DialogTitle className="text-2xl">
Welcome to {newProjectName}!
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1">
Your new project is ready. Let&apos;s get you started.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-6">
{/* Main explanation */}
<div className="space-y-3">
<p className="text-sm text-foreground leading-relaxed">
Would you like to auto-generate your{" "}
<strong>app_spec.txt</strong>? This file helps describe your
project and is used to pre-populate your backlog with features
to work on.
</p>
</div>
{/* Benefits list */}
<div className="space-y-3 rounded-lg bg-muted/50 border border-border p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">
Pre-populate your backlog
</p>
<p className="text-xs text-muted-foreground mt-1">
Automatically generate features based on your project
specification
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">
Better AI assistance
</p>
<p className="text-xs text-muted-foreground mt-1">
Help AI agents understand your project structure and tech
stack
</p>
</div>
</div>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">
Project documentation
</p>
<p className="text-xs text-muted-foreground mt-1">
Keep a clear record of your project&apos;s capabilities and
features
</p>
</div>
</div>
</div>
{/* Info box */}
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs text-blue-400 leading-relaxed">
<strong className="text-blue-300">Tip:</strong> You can always
generate or edit your app_spec.txt later from the Spec Editor in
the sidebar.
</p>
</div>
<button
onClick={() => setShowSpecIndicator(false)}
className="p-1 hover:bg-muted rounded-md transition-colors flex-shrink-0"
aria-label="Dismiss notification"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="ghost"
onClick={handleOnboardingSkip}
className="text-muted-foreground hover:text-foreground"
>
Skip for now
</Button>
<Button
onClick={handleOnboardingGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
>
<Sparkles className="w-4 h-4 mr-2" />
Generate App Spec
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
@@ -1650,6 +2094,16 @@ export function Sidebar() {
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
);
}

View File

@@ -197,6 +197,7 @@ export function NewProjectModal({
title: "Select Base Project Directory",
description:
"Choose the parent directory where your project will be created",
initialPath: workspaceDir || undefined,
});
if (selectedPath) {
setWorkspaceDir(selectedPath);
@@ -281,7 +282,7 @@ export function NewProjectModal({
<>
Will be created at:{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || "..."}
{projectPath || workspaceDir}
</code>
</>
) : (

View File

@@ -345,6 +345,7 @@ export function AnalysisView() {
const techStack = detectTechStack();
// Generate the spec content
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
const specContent = `<project_specification>
<project_name>${projectName}</project_name>

View File

@@ -28,6 +28,7 @@ import {
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, modelSupportsThinking } from "@/lib/utils";
import type { SpecRegenerationEvent } from "@/types/electron";
import {
Card,
CardDescription,
@@ -179,6 +180,8 @@ export function BoardView() {
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardBackgroundByProject,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -233,6 +236,9 @@ export function BoardView() {
const [searchQuery, setSearchQuery] = useState("");
// Validation state for add feature form
const [descriptionError, setDescriptionError] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
const isCreatingSpec = specCreatingForProject === currentProject?.path;
const creatingSpecProjectPath = specCreatingForProject;
// Make current project available globally for modal
useEffect(() => {
@@ -264,6 +270,37 @@ export function BoardView() {
};
}, []);
// Subscribe to spec regeneration events to clear state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
event.type,
"for project:",
event.projectPath
);
// Only handle completion/error events for the project being created
// The creating state is set by sidebar when user initiates the action
if (event.projectPath !== specCreatingForProject) {
return;
}
if (event.type === "spec_regeneration_complete") {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
setSpecCreatingForProject(null);
}
});
return () => {
unsubscribe();
};
}, [specCreatingForProject, setSpecCreatingForProject]);
// Track previous project to detect switches
const prevProjectPathRef = useRef<string | null>(null);
const isSwitchingProjectRef = useRef<boolean>(false);
@@ -1791,34 +1828,50 @@ export function BoardView() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
<div className="relative max-w-md flex-1 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProject?.path === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
{/* Board Background & Detail Level Controls */}

View File

@@ -248,6 +248,7 @@ export function InterviewView() {
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
return `<project_specification>
<project_name>${projectName || "my-project"}</project_name>

View File

@@ -279,7 +279,18 @@ export function SpecView() {
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log("[SpecView] Regeneration event:", event.type);
console.log(
"[SpecView] Regeneration event:",
event.type,
"for project:",
event.projectPath
);
// Only handle events for the current project
if (event.projectPath !== currentProject?.path) {
console.log("[SpecView] Ignoring event - not for current project");
return;
}
if (event.type === "spec_regeneration_progress") {
// Extract phase from content if present

View File

@@ -255,6 +255,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with the project name
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -352,6 +353,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with template-specific info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -456,6 +458,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with basic info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>

View File

@@ -1,11 +1,18 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserOptions {
title?: string;
description?: string;
initialPath?: string;
}
interface FileBrowserContextValue {
@@ -16,36 +23,47 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
const [resolver, setResolver] = useState<
((value: string | null) => void) | null
>(null);
const [dialogOptions, setDialogOptions] = useState<FileBrowserOptions>({});
const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
}, []);
const openFileBrowser = useCallback(
(options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
},
[]
);
const handleSelect = useCallback((path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}, [resolver]);
const handleOpenChange = useCallback((open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
if (!open) {
const handleSelect = useCallback(
(path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}
}, [resolver]);
},
[resolver]
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
if (!open) {
setDialogOptions({});
}
},
[resolver]
);
return (
<FileBrowserContext.Provider value={{ openFileBrowser }}>
@@ -56,6 +74,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
onSelect={handleSelect}
title={dialogOptions.title}
description={dialogOptions.description}
initialPath={dialogOptions.initialPath}
/>
</FileBrowserContext.Provider>
);
@@ -70,9 +89,13 @@ export function useFileBrowser() {
}
// Global reference for non-React code (like HttpApiClient)
let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise<string | null>) | null = null;
let globalFileBrowserFn:
| ((options?: FileBrowserOptions) => Promise<string | null>)
| null = null;
export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise<string | null>) {
export function setGlobalFileBrowser(
fn: (options?: FileBrowserOptions) => Promise<string | null>
) {
globalFileBrowserFn = fn;
}

View File

@@ -133,10 +133,15 @@ export interface SuggestionsAPI {
// Spec Regeneration types
export type SpecRegenerationEvent =
| { type: "spec_regeneration_progress"; content: string }
| { type: "spec_regeneration_tool"; tool: string; input: unknown }
| { type: "spec_regeneration_complete"; message: string }
| { type: "spec_regeneration_error"; error: string };
| { type: "spec_regeneration_progress"; content: string; projectPath: string }
| {
type: "spec_regeneration_tool";
tool: string;
input: unknown;
projectPath: string;
}
| { type: "spec_regeneration_complete"; message: string; projectPath: string }
| { type: "spec_regeneration_error"; error: string; projectPath: string };
export interface SpecRegenerationAPI {
create: (
@@ -1923,6 +1928,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: initialization] Starting project analysis...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -1935,6 +1941,7 @@ async function simulateSpecCreation(
type: "spec_regeneration_tool",
tool: "Glob",
input: { pattern: "**/*.{json,ts,tsx}" },
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -1946,6 +1953,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: analysis] Detecting tech stack...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -1989,6 +1997,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;
@@ -2004,6 +2013,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: initialization] Starting spec regeneration...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2015,6 +2025,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: analysis] Analyzing codebase...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2049,6 +2060,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;
@@ -2062,6 +2074,7 @@ async function simulateFeatureGeneration(projectPath: string) {
type: "spec_regeneration_progress",
content:
"[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2072,6 +2085,7 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: feature_generation] Reading implementation roadmap...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2083,6 +2097,7 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: feature_generation] Creating features from roadmap...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2094,11 +2109,13 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: complete] All tasks completed!\n",
projectPath: projectPath,
});
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;

View File

@@ -428,6 +428,10 @@ export interface AppState {
// Terminal state
terminalState: TerminalState;
// Spec Creation State (per-project, keyed by project path)
// Tracks which project is currently having its spec generated
specCreatingForProject: string | null;
}
// Default background settings for board backgrounds
@@ -630,6 +634,10 @@ export interface AppActions {
direction?: "horizontal" | "vertical"
) => void;
// Spec Creation actions
setSpecCreatingForProject: (projectPath: string | null) => void;
isSpecCreatingForProject: (projectPath: string) => boolean;
// Reset
reset: () => void;
}
@@ -713,6 +721,7 @@ const initialState: AppState = {
activeSessionId: null,
defaultFontSize: 14,
},
specCreatingForProject: null,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -2080,6 +2089,15 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
// Spec Creation actions
setSpecCreatingForProject: (projectPath) => {
set({ specCreatingForProject: projectPath });
},
isSpecCreatingForProject: (projectPath) => {
return get().specCreatingForProject === projectPath;
},
// Reset
reset: () => set(initialState),
}),

View File

@@ -243,19 +243,23 @@ export type SpecRegenerationEvent =
| {
type: "spec_regeneration_progress";
content: string;
projectPath: string;
}
| {
type: "spec_regeneration_tool";
tool: string;
input: unknown;
projectPath: string;
}
| {
type: "spec_regeneration_complete";
message: string;
projectPath: string;
}
| {
type: "spec_regeneration_error";
error: string;
projectPath: string;
};
export interface SpecRegenerationAPI {

View File

@@ -51,3 +51,5 @@ TERMINAL_ENABLED=true
# Password to protect terminal access (leave empty for no password)
# If set, users must enter this password before accessing terminal
TERMINAL_PASSWORD=
ENABLE_REQUEST_LOGGING=false

View File

@@ -22,12 +22,14 @@
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"morgan": "^1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/cors": "^2.8.18",
"@types/express": "^5.0.1",
"@types/morgan": "^1.9.10",
"@types/node": "^20",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.15",

View File

@@ -8,6 +8,7 @@
import express from "express";
import cors from "cors";
import morgan from "morgan";
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import dotenv from "dotenv";
@@ -46,6 +47,7 @@ dotenv.config();
const PORT = parseInt(process.env.PORT || "3008", 10);
const DATA_DIR = process.env.DATA_DIR || "./data";
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== "false"; // Default to true
// Check for required environment variables
// Claude Agent SDK supports EITHER OAuth token (subscription) OR API key (pay-per-use)
@@ -83,6 +85,22 @@ initAllowedPaths();
const app = express();
// Middleware
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
if (ENABLE_REQUEST_LOGGING) {
morgan.token("status-colored", (req, res) => {
const status = res.statusCode;
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
return `\x1b[32m${status}\x1b[0m`; // Green for success
});
app.use(
morgan(":method :url :status-colored", {
skip: (req) => req.url === "/api/health", // Skip health check logs
})
);
}
app.use(
cors({
origin: process.env.CORS_ORIGIN || "*",

View File

@@ -1,14 +1,16 @@
/**
* Security utilities for path validation
* Note: All permission checks have been disabled to allow unrestricted access
*/
import path from "path";
// Allowed project directories - loaded from environment
// Allowed project directories - kept for API compatibility
const allowedPaths = new Set<string>();
/**
* Initialize allowed paths from environment variable
* Note: All paths are now allowed regardless of this setting
*/
export function initAllowedPaths(): void {
const dirs = process.env.ALLOWED_PROJECT_DIRS;
@@ -21,13 +23,11 @@ export function initAllowedPaths(): void {
}
}
// Always allow the data directory
const dataDir = process.env.DATA_DIR;
if (dataDir) {
allowedPaths.add(path.resolve(dataDir));
}
// Always allow the workspace directory (where projects are created)
const workspaceDir = process.env.WORKSPACE_DIR;
if (workspaceDir) {
allowedPaths.add(path.resolve(workspaceDir));
@@ -35,41 +35,24 @@ export function initAllowedPaths(): void {
}
/**
* Add a path to the allowed list
* Add a path to the allowed list (no-op, all paths allowed)
*/
export function addAllowedPath(filePath: string): void {
allowedPaths.add(path.resolve(filePath));
}
/**
* Check if a path is allowed
* Check if a path is allowed - always returns true
*/
export function isPathAllowed(filePath: string): boolean {
const resolved = path.resolve(filePath);
// Check if the path is under any allowed directory
for (const allowed of allowedPaths) {
if (resolved.startsWith(allowed + path.sep) || resolved === allowed) {
return true;
}
}
return false;
export function isPathAllowed(_filePath: string): boolean {
return true;
}
/**
* Validate a path and throw if not allowed
* Validate a path - just resolves the path without checking permissions
*/
export function validatePath(filePath: string): string {
const resolved = path.resolve(filePath);
if (!isPathAllowed(resolved)) {
throw new Error(
`Access denied: ${filePath} is not in an allowed directory`
);
}
return resolved;
return path.resolve(filePath);
}
/**

View File

@@ -75,37 +75,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
const resolvedPath = path.resolve(dirPath);
// Security check: allow paths in allowed directories OR within home directory
const isAllowed = (() => {
// Check if path or parent is in allowed paths
if (isPathAllowed(resolvedPath)) return true;
const parentPath = path.dirname(resolvedPath);
if (isPathAllowed(parentPath)) return true;
// Also allow within home directory (like the /browse endpoint)
const homeDir = os.homedir();
const normalizedHome = path.normalize(homeDir);
if (
resolvedPath === normalizedHome ||
resolvedPath.startsWith(normalizedHome + path.sep)
) {
return true;
}
return false;
})();
if (!isAllowed) {
res.status(403).json({
success: false,
error: `Access denied: ${dirPath} is not in an allowed directory`,
});
return;
}
await fs.mkdir(resolvedPath, { recursive: true });
// Add the new directory to allowed paths so subsequent operations work
// Add the new directory to allowed paths for tracking
addAllowedPath(resolvedPath);
res.json({ success: true });
@@ -449,6 +421,13 @@ export function createFsRoutes(_events: EventEmitter): Router {
return drives;
};
// Get parent directory
const parentPath = path.dirname(targetPath);
const hasParent = parentPath !== targetPath;
// Get available drives
const drives = await detectDrives();
try {
const stats = await fs.stat(targetPath);
@@ -471,13 +450,6 @@ export function createFsRoutes(_events: EventEmitter): Router {
}))
.sort((a, b) => a.name.localeCompare(b.name));
// Get parent directory
const parentPath = path.dirname(targetPath);
const hasParent = parentPath !== targetPath;
// Get available drives
const drives = await detectDrives();
res.json({
success: true,
currentPath: targetPath,
@@ -486,11 +458,29 @@ export function createFsRoutes(_events: EventEmitter): Router {
drives,
});
} catch (error) {
res.status(400).json({
success: false,
error:
error instanceof Error ? error.message : "Failed to read directory",
});
// Handle permission errors gracefully - still return path info so user can navigate away
const errorMessage =
error instanceof Error ? error.message : "Failed to read directory";
const isPermissionError =
errorMessage.includes("EPERM") || errorMessage.includes("EACCES");
if (isPermissionError) {
// Return success with empty directories so user can still navigate to parent
res.json({
success: true,
currentPath: targetPath,
parentPath: hasParent ? parentPath : null,
directories: [],
drives,
warning:
"Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security",
});
} else {
res.status(400).json({
success: false,
error: errorMessage,
});
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -637,130 +627,5 @@ export function createFsRoutes(_events: EventEmitter): Router {
}
);
// Browse directories for file picker
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
router.post("/browse", async (req: Request, res: Response) => {
try {
const { dirPath } = req.body as { dirPath?: string };
const homeDir = os.homedir();
// Detect available drives on Windows
const detectDrives = async (): Promise<string[]> => {
if (os.platform() !== "win32") {
return [];
}
const drives: string[] = [];
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (const letter of letters) {
const drivePath = `${letter}:\\`;
try {
await fs.access(drivePath);
drives.push(drivePath);
} catch {
// Drive doesn't exist, skip it
}
}
return drives;
};
// Check if a path is safe to browse
const isSafePath = (targetPath: string): boolean => {
const resolved = path.resolve(targetPath);
const normalizedHome = path.resolve(homeDir);
// Allow browsing within home directory
if (
resolved === normalizedHome ||
resolved.startsWith(normalizedHome + path.sep)
) {
return true;
}
// Allow browsing already-allowed paths
if (isPathAllowed(resolved)) {
return true;
}
// On Windows, allow drive roots for initial navigation
if (os.platform() === "win32") {
const driveRootMatch = /^[A-Z]:\\$/i.test(resolved);
if (driveRootMatch) {
return true;
}
}
// On Unix, allow root for initial navigation (but only list, not read files)
if (os.platform() !== "win32" && resolved === "/") {
return true;
}
return false;
};
// Default to home directory if no path provided
const targetPath = dirPath ? path.resolve(dirPath) : homeDir;
// Security check: validate the path is safe to browse
if (!isSafePath(targetPath)) {
res.status(403).json({
success: false,
error:
"Access denied: browsing is restricted to your home directory and allowed project paths",
});
return;
}
try {
const stats = await fs.stat(targetPath);
if (!stats.isDirectory()) {
res
.status(400)
.json({ success: false, error: "Path is not a directory" });
return;
}
// Read directory contents
const entries = await fs.readdir(targetPath, { withFileTypes: true });
// Filter for directories only and exclude hidden directories
const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => ({
name: entry.name,
path: path.join(targetPath, entry.name),
}))
.sort((a, b) => a.name.localeCompare(b.name));
// Get parent directory (only if parent is also safe to browse)
const parentPath = path.dirname(targetPath);
const hasParent = parentPath !== targetPath && isSafePath(parentPath);
// Get available drives on Windows
const drives = await detectDrives();
res.json({
success: true,
currentPath: targetPath,
parentPath: hasParent ? parentPath : null,
directories,
drives,
});
} catch (error) {
res.status(400).json({
success: false,
error:
error instanceof Error ? error.message : "Failed to read directory",
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -7,6 +7,7 @@ import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
import { getAppSpecFormatInstruction } from "../lib/app-spec-format.js";
let isRunning = false;
let currentAbortController: AbortController | null = null;
@@ -111,8 +112,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "spec_error",
type: "spec_regeneration_error",
error: error.message || String(error),
projectPath: projectPath,
});
})
.finally(() => {
@@ -199,8 +201,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "spec_error",
type: "spec_regeneration_error",
error: error.message || String(error),
projectPath: projectPath,
});
})
.finally(() => {
@@ -345,30 +348,13 @@ async function generateSpec(
Project Overview:
${projectOverview}
Based on this overview, analyze the project and create a comprehensive specification that includes:
Based on this overview, analyze the project directory (if it exists) and create a comprehensive specification. Use the Read, Glob, and Grep tools to explore the codebase and understand:
- Existing technologies and frameworks
- Project structure and architecture
- Current features and capabilities
- Code patterns and conventions
1. **Project Summary** - Brief description of what the project does
2. **Core Features** - Main functionality the project needs
3. **Technical Stack** - Recommended technologies and frameworks
4. **Architecture** - High-level system design
5. **Data Models** - Key entities and their relationships
6. **API Design** - Main endpoints/interfaces needed
7. **User Experience** - Key user flows and interactions
${
generateFeatures
? `
Also generate a list of features to implement. For each feature provide:
- ID (lowercase-hyphenated)
- Title
- Description
- Priority (1=high, 2=medium, 3=low)
- Estimated complexity (simple, moderate, complex)
`
: ""
}
Format your response as markdown. Be specific and actionable.`;
${getAppSpecFormatInstruction()}`;
console.log(`[SpecRegeneration] Prompt length: ${prompt.length} chars`);
@@ -430,8 +416,9 @@ Format your response as markdown. Be specific and actionable.`;
`[SpecRegeneration] Text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "spec_progress",
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
} else if (block.type === "tool_use") {
console.log(`[SpecRegeneration] Tool use: ${block.name}`);
@@ -479,16 +466,47 @@ Format your response as markdown. Be specific and actionable.`;
console.log("[SpecRegeneration] Spec saved successfully");
events.emit("spec-regeneration:event", {
type: "spec_complete",
specPath,
content: responseText,
});
// If generate features was requested, parse and create them
// Emit spec completion event
if (generateFeatures) {
console.log("[SpecRegeneration] Starting feature generation...");
await parseAndCreateFeatures(projectPath, responseText, events);
// If features will be generated, emit intermediate completion
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: "[Phase: spec_complete] Spec created! Generating features...\n",
projectPath: projectPath,
});
} else {
// If no features, emit final completion
events.emit("spec-regeneration:event", {
type: "spec_regeneration_complete",
message: "Spec regeneration complete!",
projectPath: projectPath,
});
}
// If generate features was requested, generate them from the spec
if (generateFeatures) {
console.log("[SpecRegeneration] Starting feature generation from spec...");
// Create a new abort controller for feature generation
const featureAbortController = new AbortController();
try {
await generateFeaturesFromSpec(
projectPath,
events,
featureAbortController
);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (featureError) {
console.error(
"[SpecRegeneration] Feature generation failed:",
featureError
);
// Don't throw - spec generation succeeded, feature generation is optional
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: (featureError as Error).message || "Feature generation failed",
projectPath: projectPath,
});
}
}
console.log(
@@ -520,8 +538,9 @@ async function generateFeaturesFromSpec(
} catch (readError) {
console.error("[SpecRegeneration] ❌ Failed to read spec file:", readError);
events.emit("spec-regeneration:event", {
type: "features_error",
type: "spec_regeneration_error",
error: "No project spec found. Generate spec first.",
projectPath: projectPath,
});
return;
}
@@ -558,8 +577,9 @@ Generate 5-15 features that build on each other logically.`;
console.log(`[SpecRegeneration] Prompt length: ${prompt.length} chars`);
events.emit("spec-regeneration:event", {
type: "features_progress",
type: "spec_regeneration_progress",
content: "Analyzing spec and generating features...\n",
projectPath: projectPath,
});
const options: Options = {
@@ -616,8 +636,9 @@ Generate 5-15 features that build on each other logically.`;
`[SpecRegeneration] Feature text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "features_progress",
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
}
}
@@ -723,16 +744,17 @@ async function parseAndCreateFeatures(
);
events.emit("spec-regeneration:event", {
type: "features_complete",
features: createdFeatures,
count: createdFeatures.length,
type: "spec_regeneration_complete",
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath,
});
} catch (error) {
console.error("[SpecRegeneration] ❌ parseAndCreateFeatures() failed:");
console.error("[SpecRegeneration] Error:", error);
events.emit("spec-regeneration:event", {
type: "features_error",
type: "spec_regeneration_error",
error: (error as Error).message,
projectPath: projectPath,
});
}

View File

@@ -113,7 +113,7 @@ describe("security.ts", () => {
});
describe("isPathAllowed", () => {
it("should allow paths under allowed directories", async () => {
it("should allow all paths (permissions disabled)", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
@@ -122,96 +122,17 @@ describe("security.ts", () => {
);
initAllowedPaths();
// All paths are now allowed regardless of configuration
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/project/subdir/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/project/deep/nested/file.txt")).toBe(true);
});
it("should allow the exact allowed directory", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/allowed/project")).toBe(true);
});
it("should reject paths outside allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/not/allowed/file.txt")).toBe(false);
expect(isPathAllowed("/tmp/file.txt")).toBe(false);
expect(isPathAllowed("/etc/passwd")).toBe(false);
});
it("should block path traversal attempts", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
// These should resolve outside the allowed directory
expect(isPathAllowed("/allowed/project/../../../etc/passwd")).toBe(false);
expect(isPathAllowed("/allowed/project/../../other/file.txt")).toBe(false);
});
it("should resolve relative paths correctly", async () => {
const cwd = process.cwd();
process.env.ALLOWED_PROJECT_DIRS = cwd;
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("./file.txt")).toBe(true);
expect(isPathAllowed("./subdir/file.txt")).toBe(true);
});
it("should reject paths that are parents of allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project/subdir";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/allowed/project")).toBe(false);
expect(isPathAllowed("/allowed")).toBe(false);
});
it("should handle multiple allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
process.env.DATA_DIR = "";
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(isPathAllowed("/path1/file.txt")).toBe(true);
expect(isPathAllowed("/path2/file.txt")).toBe(true);
expect(isPathAllowed("/path3/file.txt")).toBe(true);
expect(isPathAllowed("/path4/file.txt")).toBe(false);
expect(isPathAllowed("/not/allowed/file.txt")).toBe(true);
expect(isPathAllowed("/tmp/file.txt")).toBe(true);
expect(isPathAllowed("/etc/passwd")).toBe(true);
expect(isPathAllowed("/any/path")).toBe(true);
});
});
describe("validatePath", () => {
it("should return resolved path for allowed paths", async () => {
it("should return resolved path for any path (permissions disabled)", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = "";
@@ -224,7 +145,7 @@ describe("security.ts", () => {
expect(result).toBe(path.resolve("/allowed/file.txt"));
});
it("should throw error for disallowed paths", async () => {
it("should not throw error for any path (permissions disabled)", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = "";
@@ -233,25 +154,14 @@ describe("security.ts", () => {
);
initAllowedPaths();
expect(() => validatePath("/disallowed/file.txt")).toThrow("Access denied");
expect(() => validatePath("/disallowed/file.txt")).toThrow(
"not in an allowed directory"
// All paths are now allowed, no errors thrown
expect(() => validatePath("/disallowed/file.txt")).not.toThrow();
expect(validatePath("/disallowed/file.txt")).toBe(
path.resolve("/disallowed/file.txt")
);
});
it("should include the file path in error message", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = "";
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
initAllowedPaths();
expect(() => validatePath("/bad/path.txt")).toThrow("/bad/path.txt");
});
it("should resolve paths before validation", async () => {
it("should resolve relative paths", async () => {
const cwd = process.cwd();
process.env.ALLOWED_PROJECT_DIRS = cwd;
process.env.DATA_DIR = "";