mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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=*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
100
apps/ui/src/lib/storage.ts
Normal 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,
|
||||
};
|
||||
107
apps/ui/src/lib/workspace-config.ts
Normal file
107
apps/ui/src/lib/workspace-config.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user