refactor: streamline ALLOWED_ROOT_DIRECTORY handling and remove legacy support

This commit refactors the handling of ALLOWED_ROOT_DIRECTORY by removing legacy support for ALLOWED_PROJECT_DIRS and simplifying the security logic. Key changes include:

- Removed deprecated ALLOWED_PROJECT_DIRS references from .env.example and security.ts.
- Updated initAllowedPaths() to focus solely on ALLOWED_ROOT_DIRECTORY and DATA_DIR.
- Enhanced logging for ALLOWED_ROOT_DIRECTORY configuration status.
- Adjusted route handlers to utilize the new workspace directory logic.
- Introduced a centralized storage module for localStorage operations to improve consistency and error handling.

These changes aim to enhance security and maintainability by consolidating directory management into a single variable.

Tests: All unit tests passing.
This commit is contained in:
Test User
2025-12-20 20:49:28 -05:00
parent f3c9e828e2
commit 86d92e610b
17 changed files with 485 additions and 244 deletions

View File

@@ -22,11 +22,6 @@ AUTOMAKER_API_KEY=
# Example: ALLOWED_ROOT_DIRECTORY=/projects
ALLOWED_ROOT_DIRECTORY=
# (Legacy) Restrict file operations to these directories (comma-separated)
# DEPRECATED: Use ALLOWED_ROOT_DIRECTORY instead for simpler configuration
# This is kept for backward compatibility
# ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
# CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production
CORS_ORIGIN=*

View File

@@ -26,6 +26,14 @@ RUN npm run build --workspace=apps/server
# Production stage
FROM node:20-alpine
# Install git, curl, and GitHub CLI
RUN apk add --no-cache git curl && \
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d '"' -f 4 | sed 's/v//') && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv gh_*_linux_amd64/bin/gh /usr/local/bin/gh && \
rm -rf gh.tar.gz gh_*_linux_amd64
WORKDIR /app
# Create non-root user

View File

@@ -23,21 +23,27 @@ let allowedRootDirectory: string | null = null;
// Data directory - always allowed for settings/credentials
let dataDirectory: string | null = null;
// Allowed project directories - kept for backward compatibility and API compatibility
// Allowed paths set - stores ALLOWED_ROOT_DIRECTORY and DATA_DIR
const allowedPaths = new Set<string>();
/**
* Initialize security settings from environment variables
* - ALLOWED_ROOT_DIRECTORY: main security boundary
* - DATA_DIR: appData exception, always allowed
* - ALLOWED_PROJECT_DIRS: legacy variable, stored for compatibility
*/
export function initAllowedPaths(): void {
// Load ALLOWED_ROOT_DIRECTORY (new single variable)
// Load ALLOWED_ROOT_DIRECTORY
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
if (rootDir) {
allowedRootDirectory = path.resolve(rootDir);
allowedPaths.add(allowedRootDirectory);
console.log(
`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`
);
} else {
console.log(
"[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths"
);
}
// Load DATA_DIR (appData exception - always allowed)
@@ -45,17 +51,7 @@ export function initAllowedPaths(): void {
if (dataDir) {
dataDirectory = path.resolve(dataDir);
allowedPaths.add(dataDirectory);
}
// Load legacy ALLOWED_PROJECT_DIRS for backward compatibility during transition
const dirs = process.env.ALLOWED_PROJECT_DIRS;
if (dirs) {
for (const dir of dirs.split(",")) {
const trimmed = dir.trim();
if (trimmed) {
allowedPaths.add(path.resolve(trimmed));
}
}
console.log(`[Security] ✓ DATA_DIR configured: ${dataDirectory}`);
}
}
@@ -68,19 +64,13 @@ export function addAllowedPath(filePath: string): void {
}
/**
* Check if a path is allowed based on ALLOWED_ROOT_DIRECTORY and legacy ALLOWED_PROJECT_DIRS
* Check if a path is allowed based on ALLOWED_ROOT_DIRECTORY
* Returns true if:
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
* - Path is within any legacy allowed path (ALLOWED_PROJECT_DIRS), OR
* - Path is within DATA_DIR (appData exception), OR
* - No restrictions are configured (backward compatibility)
*/
export function isPathAllowed(filePath: string): boolean {
// If no restrictions are configured, allow all paths (backward compatibility)
if (!allowedRootDirectory && allowedPaths.size === 0) {
return true;
}
const resolvedPath = path.resolve(filePath);
// Always allow appData directory (settings, credentials)
@@ -88,19 +78,21 @@ export function isPathAllowed(filePath: string): boolean {
return true;
}
// Allow if within ALLOWED_ROOT_DIRECTORY
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
// If no ALLOWED_ROOT_DIRECTORY restriction is configured, allow all paths
// Note: DATA_DIR is checked above as an exception, but doesn't restrict other paths
if (!allowedRootDirectory) {
return true;
}
// Check legacy allowed paths (ALLOWED_PROJECT_DIRS)
for (const allowedPath of allowedPaths) {
if (isPathWithinDirectory(resolvedPath, allowedPath)) {
return true;
}
// Allow if within ALLOWED_ROOT_DIRECTORY
if (
allowedRootDirectory &&
isPathWithinDirectory(resolvedPath, allowedRootDirectory)
) {
return true;
}
// If any restrictions are configured but path doesn't match, deny
// If restrictions are configured but path doesn't match, deny
return false;
}
@@ -132,10 +124,7 @@ export function isPathWithinDirectory(
// If relative path starts with "..", it's outside the directory
// If relative path is absolute, it's outside the directory
// If relative path is empty or ".", it's the directory itself
return (
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath)
);
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
/**

View File

@@ -5,18 +5,25 @@
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath, getAllowedRootDirectory } from "../../../lib/security.js";
import {
addAllowedPath,
getAllowedRootDirectory,
getDataDirectory,
} from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
export function createConfigHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const allowedRootDirectory = getAllowedRootDirectory();
const dataDirectory = getDataDirectory();
if (!allowedRootDirectory) {
// When ALLOWED_ROOT_DIRECTORY is not set, return DATA_DIR as default directory
res.json({
success: true,
configured: false,
defaultDir: dataDirectory || null,
});
return;
}
@@ -41,6 +48,7 @@ export function createConfigHandler() {
success: true,
configured: true,
workspaceDir: resolvedWorkspaceDir,
defaultDir: resolvedWorkspaceDir,
});
} catch {
res.json({

View File

@@ -8,7 +8,6 @@ import { vi, beforeEach } from "vitest";
// Set test environment variables
process.env.NODE_ENV = "test";
process.env.DATA_DIR = "/tmp/test-data";
process.env.ALLOWED_PROJECT_DIRS = "/tmp/test-projects";
// Reset all mocks before each test
beforeEach(() => {

View File

@@ -11,81 +11,49 @@ describe("security.ts", () => {
});
describe("initAllowedPaths", () => {
it("should parse comma-separated directories from environment", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2,/path3";
it("should load ALLOWED_ROOT_DIRECTORY if set", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, getAllowedPaths, getAllowedRootDirectory } =
await import("@/lib/security.js");
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/path1"));
expect(allowed).toContain(path.resolve("/path2"));
expect(allowed).toContain(path.resolve("/path3"));
});
it("should trim whitespace from paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = " /path1 , /path2 , /path3 ";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/path1"));
expect(allowed).toContain(path.resolve("/path2"));
expect(allowed).toContain(path.resolve("/projects"));
expect(getAllowedRootDirectory()).toBe(path.resolve("/projects"));
});
it("should always include DATA_DIR if set", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "/data/dir";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, getAllowedPaths } =
await import("@/lib/security.js");
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/data/dir"));
});
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
it("should handle both ALLOWED_ROOT_DIRECTORY and DATA_DIR", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/data";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, getAllowedPaths } =
await import("@/lib/security.js");
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toHaveLength(1);
expect(allowed[0]).toBe(path.resolve("/data"));
});
it("should skip empty entries in comma list", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toHaveLength(3);
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve("/data"));
expect(allowed).toHaveLength(2);
});
});
describe("addAllowedPath", () => {
it("should add path to allowed list", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "";
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
@@ -99,7 +67,7 @@ describe("security.ts", () => {
});
it("should resolve relative paths before adding", async () => {
process.env.ALLOWED_PROJECT_DIRS = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "";
const { initAllowedPaths, addAllowedPath, getAllowedPaths } =
@@ -115,14 +83,12 @@ describe("security.ts", () => {
});
describe("isPathAllowed", () => {
it("should allow paths within configured allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
it("should allow paths within ALLOWED_ROOT_DIRECTORY", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed/project";
process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, isPathAllowed } =
await import("@/lib/security.js");
initAllowedPaths();
// Paths within allowed directory should be allowed
@@ -136,13 +102,11 @@ describe("security.ts", () => {
});
it("should allow all paths when no restrictions are configured", async () => {
delete process.env.ALLOWED_PROJECT_DIRS;
delete process.env.DATA_DIR;
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, isPathAllowed } =
await import("@/lib/security.js");
initAllowedPaths();
// All paths should be allowed when no restrictions are configured
@@ -152,17 +116,33 @@ describe("security.ts", () => {
expect(isPathAllowed("/etc/passwd")).toBe(true);
expect(isPathAllowed("/any/path")).toBe(true);
});
it("should allow all paths when DATA_DIR is set but ALLOWED_ROOT_DIRECTORY is not", async () => {
process.env.DATA_DIR = "/data";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, isPathAllowed } =
await import("@/lib/security.js");
initAllowedPaths();
// DATA_DIR should be allowed
expect(isPathAllowed("/data/settings.json")).toBe(true);
// But all other paths should also be allowed when ALLOWED_ROOT_DIRECTORY is not set
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
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 () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, validatePath } =
await import("@/lib/security.js");
initAllowedPaths();
const result = validatePath("/allowed/file.txt");
@@ -170,13 +150,11 @@ describe("security.ts", () => {
});
it("should throw error for paths outside allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, validatePath } =
await import("@/lib/security.js");
initAllowedPaths();
// Disallowed paths should throw PathNotAllowedError
@@ -184,13 +162,11 @@ describe("security.ts", () => {
});
it("should not throw error for any path when no restrictions are configured", async () => {
delete process.env.ALLOWED_PROJECT_DIRS;
delete process.env.DATA_DIR;
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, validatePath } =
await import("@/lib/security.js");
initAllowedPaths();
// All paths are allowed when no restrictions configured
@@ -202,13 +178,11 @@ describe("security.ts", () => {
it("should resolve relative paths within allowed directory", async () => {
const cwd = process.cwd();
process.env.ALLOWED_PROJECT_DIRS = cwd;
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, validatePath } =
await import("@/lib/security.js");
initAllowedPaths();
const result = validatePath("./file.txt");
@@ -218,26 +192,26 @@ describe("security.ts", () => {
describe("getAllowedPaths", () => {
it("should return array of allowed paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/path1,/path2";
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/data";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, getAllowedPaths } =
await import("@/lib/security.js");
initAllowedPaths();
const result = getAllowedPaths();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result.length).toBe(2);
expect(result).toContain(path.resolve("/projects"));
expect(result).toContain(path.resolve("/data"));
});
it("should return resolved paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/test";
process.env.ALLOWED_ROOT_DIRECTORY = "/test";
process.env.DATA_DIR = "";
const { initAllowedPaths, getAllowedPaths } = await import(
"@/lib/security.js"
);
const { initAllowedPaths, getAllowedPaths } =
await import("@/lib/security.js");
initAllowedPaths();
const result = getAllowedPaths();

View File

@@ -40,8 +40,7 @@ export default defineConfig({
PORT: String(serverPort),
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false",
// Allow access to test directories and common project paths
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
},
},
// Frontend Vite dev server
@@ -54,7 +53,8 @@ export default defineConfig({
...process.env,
VITE_SKIP_SETUP: "true",
// Skip electron plugin in CI - no display available for Electron
VITE_SKIP_ELECTRON: process.env.CI === "true" ? "true" : undefined,
VITE_SKIP_ELECTRON:
process.env.CI === "true" ? "true" : undefined,
},
},
],

View File

@@ -1,4 +1,3 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
FolderOpen,
@@ -21,6 +20,11 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getJSON, setJSON } from "@/lib/storage";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
interface DirectoryEntry {
name: string;
@@ -50,38 +54,22 @@ const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
const MAX_RECENT_FOLDERS = 5;
function getRecentFolders(): string[] {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
return getJSON<string[]>(RECENT_FOLDERS_KEY) ?? [];
}
function addRecentFolder(path: string): void {
if (typeof window === "undefined") return;
try {
const recent = getRecentFolders();
// Remove if already exists, then add to front
const filtered = recent.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
} catch {
// Ignore localStorage errors
}
const recent = getRecentFolders();
// Remove if already exists, then add to front
const filtered = recent.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
setJSON(RECENT_FOLDERS_KEY, updated);
}
function removeRecentFolder(path: string): string[] {
if (typeof window === "undefined") return [];
try {
const recent = getRecentFolders();
const updated = recent.filter((p) => p !== path);
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
return updated;
} catch {
return [];
}
const recent = getRecentFolders();
const updated = recent.filter((p) => p !== path);
setJSON(RECENT_FOLDERS_KEY, updated);
return updated;
}
export function FileBrowserDialog({
@@ -110,17 +98,16 @@ export function FileBrowserDialog({
}
}, [open]);
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
}, []);
const handleRemoveRecent = useCallback(
(e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
},
[]
);
const handleSelectRecent = useCallback((path: string) => {
browseDirectory(path);
}, []);
const browseDirectory = async (dirPath?: string) => {
const browseDirectory = useCallback(async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
@@ -155,7 +142,14 @@ export function FileBrowserDialog({
} finally {
setLoading(false);
}
};
}, []);
const handleSelectRecent = useCallback(
(path: string) => {
browseDirectory(path);
},
[browseDirectory]
);
// Reset current path when dialog closes
useEffect(() => {
@@ -169,12 +163,46 @@ export function FileBrowserDialog({
}
}, [open]);
// Load initial path or home directory when dialog opens
// Load initial path or workspace directory when dialog opens
useEffect(() => {
if (open && !currentPath) {
browseDirectory(initialPath);
// Priority order:
// 1. Last selected directory from this file browser (from localStorage)
// 2. initialPath prop (from parent component)
// 3. Default workspace directory
// 4. Home directory
const loadInitialPath = async () => {
try {
// First, check for last selected directory from getDefaultWorkspaceDirectory
// which already implements the priority: last used > Documents/Automaker > DATA_DIR
const defaultDir = await getDefaultWorkspaceDirectory();
// If we have a default directory, use it (unless initialPath is explicitly provided and different)
const pathToUse = initialPath || defaultDir;
if (pathToUse) {
// Pre-fill the path input immediately
setPathInput(pathToUse);
// Then browse to that directory
browseDirectory(pathToUse);
} else {
// No default directory, browse home directory
browseDirectory();
}
} catch (err) {
// If config fetch fails, try initialPath or fall back to home directory
if (initialPath) {
setPathInput(initialPath);
browseDirectory(initialPath);
} else {
browseDirectory();
}
}
};
loadInitialPath();
}
}, [open, initialPath]);
}, [open, initialPath, currentPath, browseDirectory]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
@@ -211,6 +239,8 @@ export function FileBrowserDialog({
const handleSelect = useCallback(() => {
if (currentPath) {
addRecentFolder(currentPath);
// Save to last project directory so it's used as default next time
saveLastProjectDirectory(currentPath);
onSelect(currentPath);
onOpenChange(false);
}
@@ -296,7 +326,9 @@ export function FileBrowserDialog({
title={folder}
>
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
<span className="truncate max-w-[120px]">
{getFolderName(folder)}
</span>
<button
onClick={(e) => handleRemoveRecent(e, folder)}
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
@@ -417,11 +449,20 @@ export function FileBrowserDialog({
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading} title="Select current folder (Cmd+Enter / Ctrl+Enter)">
<Button
size="sm"
onClick={handleSelect}
disabled={!currentPath || loading}
title="Select current folder (Cmd+Enter / Ctrl+Enter)"
>
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "⌘" : "Ctrl"}+
{typeof navigator !== "undefined" &&
navigator.platform?.includes("Mac")
? "⌘"
: "Ctrl"}
+
</kbd>
</Button>
</DialogFooter>

View File

@@ -1,4 +1,3 @@
import { useState, useEffect } from "react";
import {
Dialog,
@@ -26,11 +25,12 @@ import {
} 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";
import { useFileBrowser } from "@/contexts/file-browser-context";
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
interface ValidationErrors {
projectName?: boolean;
@@ -81,25 +81,15 @@ export function NewProjectModal({
// Fetch workspace directory when modal opens
useEffect(() => {
if (open) {
// First, check localStorage for last used directory
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
setWorkspaceDir(lastUsedDir);
return;
}
// Fall back to server config if no saved directory
setIsLoadingWorkspace(true);
const httpClient = getHttpApiClient();
httpClient.workspace
.getConfig()
.then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
getDefaultWorkspaceDirectory()
.then((defaultDir) => {
if (defaultDir) {
setWorkspaceDir(defaultDir);
}
})
.catch((error) => {
console.error("Failed to get workspace config:", error);
console.error("Failed to get default workspace directory:", error);
})
.finally(() => {
setIsLoadingWorkspace(false);
@@ -211,7 +201,7 @@ export function NewProjectModal({
if (selectedPath) {
setWorkspaceDir(selectedPath);
// Save to localStorage for next time
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
saveLastProjectDirectory(selectedPath);
// Clear any workspace error when a valid directory is selected
if (errors.workspaceDir) {
setErrors((prev) => ({ ...prev, workspaceDir: false }));
@@ -296,9 +286,7 @@ export function NewProjectModal({
{projectPath || workspaceDir}
</code>
</>
) : (
<span className="text-red-500">No workspace configured</span>
)}
) : null}
</span>
<Button
type="button"

View File

@@ -8,6 +8,7 @@ import {
PanelLeftClose,
} from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import { getItem, setItem } from "@/lib/storage";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import {
useWorktrees,
@@ -91,13 +92,12 @@ export function WorktreePanel({
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem(WORKTREE_PANEL_COLLAPSED_KEY);
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === "true";
});
useEffect(() => {
localStorage.setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
}, [isCollapsed]);
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);

View File

@@ -1,4 +1,3 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
@@ -20,6 +19,10 @@ import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
import { toast } from "sonner";
import { useNavigate } from "@tanstack/react-router";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
interface InterviewMessage {
id: string;
@@ -65,8 +68,7 @@ const INTERVIEW_QUESTIONS = [
];
export function InterviewView() {
const { addProject, setCurrentProject, setAppSpec } =
useAppStore();
const { addProject, setCurrentProject, setAppSpec } = useAppStore();
const { openFileBrowser } = useFileBrowser();
const navigate = useNavigate();
const [input, setInput] = useState("");
@@ -89,6 +91,35 @@ export function InterviewView() {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Default parent directory using workspace config utility
useEffect(() => {
if (projectPath) return;
let isMounted = true;
const loadWorkspaceDir = async () => {
try {
const defaultDir = await getDefaultWorkspaceDirectory();
if (!isMounted || projectPath) {
return;
}
if (defaultDir) {
setProjectPath(defaultDir);
}
} catch (error) {
console.error("Failed to load default workspace directory:", error);
}
};
loadWorkspaceDir();
return () => {
isMounted = false;
};
}, [projectPath]);
// Initialize with first question
useEffect(() => {
if (messages.length === 0) {
@@ -295,10 +326,12 @@ export function InterviewView() {
title: "Select Base Directory",
description:
"Choose the parent directory where your new project will be created",
initialPath: projectPath || undefined,
});
if (selectedPath) {
setProjectPath(selectedPath);
saveLastProjectDirectory(selectedPath);
}
};
@@ -308,6 +341,7 @@ export function InterviewView() {
setIsGenerating(true);
try {
saveLastProjectDirectory(projectPath);
const api = getElectronAPI();
// Use platform-specific path separator
const pathSep =
@@ -423,8 +457,8 @@ export function InterviewView() {
index < currentQuestionIndex
? "bg-green-500"
: index === currentQuestionIndex
? "bg-primary"
: "bg-zinc-700"
? "bg-primary"
: "bg-zinc-700"
)}
/>
))}

View File

@@ -20,6 +20,7 @@
import { useEffect, useState, useRef } from "react";
import { getHttpApiClient } from "@/lib/http-api-client";
import { isElectron } from "@/lib/electron";
import { getItem, removeItem } from "@/lib/storage";
/**
* State returned by useSettingsMigration hook
@@ -122,7 +123,7 @@ export function useSettingsMigration(): MigrationState {
}
// Check if we have localStorage data to migrate
const automakerStorage = localStorage.getItem("automaker-storage");
const automakerStorage = getItem("automaker-storage");
if (!automakerStorage) {
console.log(
"[Settings Migration] No localStorage data to migrate"
@@ -136,7 +137,7 @@ export function useSettingsMigration(): MigrationState {
// Collect all localStorage data
const localStorageData: Record<string, string> = {};
for (const key of LOCALSTORAGE_KEYS) {
const value = localStorage.getItem(key);
const value = getItem(key);
if (value) {
localStorageData[key] = value;
}
@@ -154,7 +155,7 @@ export function useSettingsMigration(): MigrationState {
// Clear old localStorage keys (but keep automaker-storage for Zustand)
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
localStorage.removeItem(key);
removeItem(key);
}
setState({ checked: true, migrated: true, error: null });
@@ -203,7 +204,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
try {
const api = getHttpApiClient();
const automakerStorage = localStorage.getItem("automaker-storage");
const automakerStorage = getItem("automaker-storage");
if (!automakerStorage) {
return false;

View File

@@ -1,5 +1,6 @@
// Type definitions for Electron IPC API
import type { SessionListItem, Message } from "@/types/electron";
import { getJSON, setJSON, removeItem } from "./storage";
export interface FileEntry {
name: string;
@@ -2667,28 +2668,22 @@ export interface TrashedProject extends Project {
}
export const getStoredProjects = (): Project[] => {
if (typeof window === "undefined") return [];
const stored = localStorage.getItem(STORAGE_KEYS.PROJECTS);
return stored ? JSON.parse(stored) : [];
return getJSON<Project[]>(STORAGE_KEYS.PROJECTS) ?? [];
};
export const saveProjects = (projects: Project[]): void => {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEYS.PROJECTS, JSON.stringify(projects));
setJSON(STORAGE_KEYS.PROJECTS, projects);
};
export const getCurrentProject = (): Project | null => {
if (typeof window === "undefined") return null;
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_PROJECT);
return stored ? JSON.parse(stored) : null;
return getJSON<Project>(STORAGE_KEYS.CURRENT_PROJECT);
};
export const setCurrentProject = (project: Project | null): void => {
if (typeof window === "undefined") return;
if (project) {
localStorage.setItem(STORAGE_KEYS.CURRENT_PROJECT, JSON.stringify(project));
setJSON(STORAGE_KEYS.CURRENT_PROJECT, project);
} else {
localStorage.removeItem(STORAGE_KEYS.CURRENT_PROJECT);
removeItem(STORAGE_KEYS.CURRENT_PROJECT);
}
};
@@ -2709,12 +2704,9 @@ export const removeProject = (projectId: string): void => {
};
export const getStoredTrashedProjects = (): TrashedProject[] => {
if (typeof window === "undefined") return [];
const stored = localStorage.getItem(STORAGE_KEYS.TRASHED_PROJECTS);
return stored ? JSON.parse(stored) : [];
return getJSON<TrashedProject[]>(STORAGE_KEYS.TRASHED_PROJECTS) ?? [];
};
export const saveTrashedProjects = (projects: TrashedProject[]): void => {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEYS.TRASHED_PROJECTS, JSON.stringify(projects));
setJSON(STORAGE_KEYS.TRASHED_PROJECTS, projects);
};

View File

@@ -766,6 +766,7 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
configured: boolean;
workspaceDir?: string;
defaultDir?: string | null;
error?: string;
}> => this.get("/api/workspace/config"),

100
apps/ui/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Centralized localStorage abstraction module
*
* Provides type-safe wrappers for all localStorage operations.
* All localStorage access should go through this module to ensure
* consistent error handling and environment checks.
*/
/**
* Check if localStorage is available in the current environment
*/
function isStorageAvailable(): boolean {
return typeof window !== "undefined" && window.localStorage !== undefined;
}
/**
* Get an item from localStorage
* @param key - The storage key
* @returns The stored value or null if not found/unavailable
*/
export function getItem(key: string): string | null {
if (!isStorageAvailable()) return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/**
* Set an item in localStorage
* @param key - The storage key
* @param value - The value to store
* @returns true if successful, false otherwise
*/
export function setItem(key: string, value: string): boolean {
if (!isStorageAvailable()) return false;
try {
window.localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
/**
* Remove an item from localStorage
* @param key - The storage key to remove
* @returns true if successful, false otherwise
*/
export function removeItem(key: string): boolean {
if (!isStorageAvailable()) return false;
try {
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
/**
* Get a JSON-parsed item from localStorage
* @param key - The storage key
* @returns The parsed value or null if not found/invalid
*/
export function getJSON<T>(key: string): T | null {
const value = getItem(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
/**
* Set a JSON-stringified item in localStorage
* @param key - The storage key
* @param value - The value to stringify and store
* @returns true if successful, false otherwise
*/
export function setJSON<T>(key: string, value: T): boolean {
try {
return setItem(key, JSON.stringify(value));
} catch {
return false;
}
}
/**
* Storage module for named exports
*/
export const storage = {
getItem,
setItem,
removeItem,
getJSON,
setJSON,
isAvailable: isStorageAvailable,
};

View File

@@ -0,0 +1,107 @@
/**
* Utility functions for determining default workspace directories
* Centralizes the logic for determining where projects should be created/opened
*/
/* eslint-disable no-undef */
import { getHttpApiClient } from "./http-api-client";
import { getElectronAPI } from "./electron";
import { getItem, setItem } from "./storage";
import path from "path";
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
/**
* Gets the default Documents/Automaker directory path
* @returns Promise resolving to Documents/Automaker path, or null if unavailable
*/
async function getDefaultDocumentsPath(): Promise<string | null> {
try {
const api = getElectronAPI();
const documentsPath = await api.getPath("documents");
return path.join(documentsPath, "Automaker");
} catch (error) {
if (typeof window !== "undefined" && window.console) {
window.console.error("Failed to get documents path:", error);
}
return null;
}
}
/**
* Determines the default directory for project creation/opening
* Priority order:
* 1. ALLOWED_ROOT_DIRECTORY (if configured)
* 2. Last used directory from localStorage (if ALLOWED_ROOT_DIRECTORY is not set)
* 3. Documents/Automaker (if ALLOWED_ROOT_DIRECTORY is not set)
* 4. DATA_DIR (if ALLOWED_ROOT_DIRECTORY is not set and Documents unavailable)
* 5. null (no default)
*
* @returns Promise resolving to the default directory path, or null if none available
*/
export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
try {
const httpClient = getHttpApiClient();
const result = await httpClient.workspace.getConfig();
if (result.success) {
// If ALLOWED_ROOT_DIRECTORY is configured, use it
if (result.configured && result.workspaceDir) {
return result.workspaceDir;
}
// If ALLOWED_ROOT_DIRECTORY is not set, use priority:
// 1. Last used directory
// 2. Documents/Automaker
// 3. DATA_DIR as fallback
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
return lastUsedDir;
}
// Try to get Documents/Automaker
const documentsPath = await getDefaultDocumentsPath();
if (documentsPath) {
return documentsPath;
}
// Fallback to DATA_DIR if available
if (result.defaultDir) {
return result.defaultDir;
}
}
// If API call failed, still try last used dir and Documents
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
return lastUsedDir;
}
const documentsPath = await getDefaultDocumentsPath();
return documentsPath;
} catch (error) {
if (typeof window !== "undefined" && window.console) {
window.console.error("Failed to get default workspace directory:", error);
}
// On error, try last used dir and Documents
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
return lastUsedDir;
}
const documentsPath = await getDefaultDocumentsPath();
return documentsPath;
}
}
/**
* Saves the last used project directory to localStorage
* @param path - The directory path to save
*/
export function saveLastProjectDirectory(path: string): void {
setItem(LAST_PROJECT_DIR_KEY, path);
}

View File

@@ -116,7 +116,9 @@ async function startStaticServer(): Promise<void> {
return new Promise((resolve, reject) => {
staticServer!.listen(STATIC_PORT, () => {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
console.log(
`[Electron] Static server running at http://localhost:${STATIC_PORT}`
);
resolve();
});
staticServer!.on("error", reject);
@@ -135,7 +137,10 @@ async function startServer(): Promise<void> {
command = "node";
serverPath = path.join(__dirname, "../../server/src/index.ts");
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
const serverNodeModules = path.join(
__dirname,
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath: string;
@@ -170,23 +175,16 @@ async function startServer(): Promise<void> {
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
const defaultRootDirectory = path.join(app.getPath("documents"), "Automaker");
if (!fs.existsSync(defaultRootDirectory)) {
try {
fs.mkdirSync(defaultRootDirectory, { recursive: true });
console.log("[Electron] Created ALLOWED_ROOT_DIRECTORY:", defaultRootDirectory);
} catch (error) {
console.error("[Electron] Failed to create ALLOWED_ROOT_DIRECTORY:", error);
}
}
const env = {
...process.env,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"),
NODE_PATH: serverNodeModules,
ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY || defaultRootDirectory,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
// If not set, server will allow access to all paths
...(process.env.ALLOWED_ROOT_DIRECTORY && {
ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY,
}),
};
console.log("[Electron] Starting backend server...");
@@ -324,7 +322,10 @@ app.whenReady().then(async () => {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn("[Electron] Failed to set dock icon:", (error as Error).message);
console.warn(
"[Electron] Failed to set dock icon:",
(error as Error).message
);
}
}
}
@@ -426,9 +427,12 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
});
// App info
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
return app.getPath(name);
});
ipcMain.handle(
"app:getPath",
async (_, name: Parameters<typeof app.getPath>[0]) => {
return app.getPath(name);
}
);
ipcMain.handle("app:getVersion", async () => {
return app.getVersion();