mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge origin/main into feature/shared-packages
Resolved conflicts: - list.ts: Keep @automaker/git-utils import, add worktree-metadata import - feature-loader.ts: Use Feature type from @automaker/types - automaker-paths.test.ts: Import from @automaker/platform - kanban-card.tsx: Accept deletion (split into components/) - subprocess.test.ts: Keep libs/platform location Added missing exports to @automaker/platform: - getGlobalSettingsPath, getCredentialsPath, getProjectSettingsPath, ensureDataDir Added title and titleGenerating fields to @automaker/types Feature interface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,10 +37,12 @@ import {
|
||||
isTerminalEnabled,
|
||||
isTerminalPasswordRequired,
|
||||
} from "./routes/terminal/index.js";
|
||||
import { createSettingsRoutes } from "./routes/settings/index.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
import { SettingsService } from "./services/settings-service.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||
|
||||
// Load environment variables
|
||||
@@ -108,6 +110,7 @@ const events: EventEmitter = createEventEmitter();
|
||||
const agentService = new AgentService(DATA_DIR, events);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events);
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -137,6 +140,7 @@ app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
app.use("/api/terminal", createTerminalRoutes());
|
||||
app.use("/api/settings", createSettingsRoutes(settingsService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
183
apps/server/src/lib/worktree-metadata.ts
Normal file
183
apps/server/src/lib/worktree-metadata.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Worktree metadata storage utilities
|
||||
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
||||
*/
|
||||
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
/** Maximum length for sanitized branch names in filesystem paths */
|
||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize branch name for cross-platform filesystem safety
|
||||
*/
|
||||
function sanitizeBranchName(branch: string): string {
|
||||
// Replace characters that are invalid or problematic on various filesystems:
|
||||
// - Forward and backslashes (path separators)
|
||||
// - Windows invalid chars: : * ? " < > |
|
||||
// - Other potentially problematic chars
|
||||
let safeBranch = branch
|
||||
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
|
||||
.replace(/\s+/g, "_") // Replace spaces with underscores
|
||||
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
|
||||
.replace(/-+/g, "-") // Collapse multiple dashes
|
||||
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
|
||||
|
||||
// Truncate to safe length (leave room for path components)
|
||||
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
|
||||
|
||||
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
|
||||
safeBranch = `_${safeBranch || "branch"}`;
|
||||
}
|
||||
|
||||
return safeBranch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the worktree metadata directory
|
||||
*/
|
||||
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
|
||||
const safeBranch = sanitizeBranchName(branch);
|
||||
return path.join(projectPath, ".automaker", "worktrees", safeBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the worktree metadata file
|
||||
*/
|
||||
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
|
||||
return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read worktree metadata for a branch
|
||||
*/
|
||||
export async function readWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<WorktreeMetadata | null> {
|
||||
try {
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
const content = await fs.readFile(metadataPath, "utf-8");
|
||||
return JSON.parse(content) as WorktreeMetadata;
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write worktree metadata for a branch
|
||||
*/
|
||||
export async function writeWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
metadata: WorktreeMetadata
|
||||
): Promise<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(metadataDir, { recursive: true });
|
||||
|
||||
// Write metadata
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update PR info in worktree metadata
|
||||
*/
|
||||
export async function updateWorktreePRInfo(
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
prInfo: WorktreePRInfo
|
||||
): Promise<void> {
|
||||
// Read existing metadata or create new
|
||||
let metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
|
||||
if (!metadata) {
|
||||
metadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Update PR info
|
||||
metadata.pr = prInfo;
|
||||
|
||||
// Write back
|
||||
await writeWorktreeMetadata(projectPath, branch, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR info for a branch from metadata
|
||||
*/
|
||||
export async function getWorktreePRInfo(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<WorktreePRInfo | null> {
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
return metadata?.pr || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all worktree metadata for a project
|
||||
*/
|
||||
export async function readAllWorktreeMetadata(
|
||||
projectPath: string
|
||||
): Promise<Map<string, WorktreeMetadata>> {
|
||||
const result = new Map<string, WorktreeMetadata>();
|
||||
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
|
||||
|
||||
try {
|
||||
const dirs = await fs.readdir(worktreesDir, { withFileTypes: true });
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (dir.isDirectory()) {
|
||||
const metadataPath = path.join(worktreesDir, dir.name, "worktree.json");
|
||||
try {
|
||||
const content = await fs.readFile(metadataPath, "utf-8");
|
||||
const metadata = JSON.parse(content) as WorktreeMetadata;
|
||||
result.set(metadata.branch, metadata);
|
||||
} catch {
|
||||
// Skip if file doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete worktree metadata for a branch
|
||||
*/
|
||||
export async function deleteWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
try {
|
||||
await fs.rm(metadataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { createCreateHandler } from "./routes/create.js";
|
||||
import { createUpdateHandler } from "./routes/update.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createAgentOutputHandler } from "./routes/agent-output.js";
|
||||
import { createGenerateTitleHandler } from "./routes/generate-title.js";
|
||||
|
||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
const router = Router();
|
||||
@@ -20,6 +21,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
router.post("/update", createUpdateHandler(featureLoader));
|
||||
router.post("/delete", createDeleteHandler(featureLoader));
|
||||
router.post("/agent-output", createAgentOutputHandler(featureLoader));
|
||||
router.post("/generate-title", createGenerateTitleHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
137
apps/server/src/routes/features/routes/generate-title.ts
Normal file
137
apps/server/src/routes/features/routes/generate-title.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||
*
|
||||
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
|
||||
|
||||
const logger = createLogger("GenerateTitle");
|
||||
|
||||
interface GenerateTitleRequestBody {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleSuccessResponse {
|
||||
success: true;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the title, nothing else
|
||||
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
|
||||
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
|
||||
- No quotes, periods, or extra formatting
|
||||
- Capture the essence of the feature in a scannable way`;
|
||||
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function createGenerateTitleHandler(): (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== "string") {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: "description is required and must be a string",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedDescription.length === 0) {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: "description cannot be empty",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: "acceptEdits",
|
||||
},
|
||||
});
|
||||
|
||||
const title = await extractTextFromStream(stream);
|
||||
|
||||
if (!title || title.trim().length === 0) {
|
||||
logger.warn("Received empty response from Claude");
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: "Failed to generate title - empty response",
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generated title: ${title.trim()}`);
|
||||
|
||||
const response: GenerateTitleSuccessResponse = {
|
||||
success: true,
|
||||
title: title.trim(),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
logger.error("Title generation failed:", errorMessage);
|
||||
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
29
apps/server/src/routes/settings/common.ts
Normal file
29
apps/server/src/routes/settings/common.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Common utilities for settings routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all settings endpoints.
|
||||
* Re-exports error handling helpers from the parent routes module.
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
|
||||
/** Logger instance for settings-related operations */
|
||||
export const logger = createLogger("Settings");
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*
|
||||
* Re-exported from parent routes common module for consistency.
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*
|
||||
* Convenience function for logging errors with the Settings logger.
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
67
apps/server/src/routes/settings/index.ts
Normal file
67
apps/server/src/routes/settings/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Settings routes - HTTP API for persistent file-based settings
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Status checking (migration readiness)
|
||||
* - Global settings CRUD
|
||||
* - Credentials management
|
||||
* - Project-specific settings
|
||||
* - localStorage to file migration
|
||||
*
|
||||
* All endpoints use handler factories that receive the SettingsService instance.
|
||||
* Mounted at /api/settings in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import type { SettingsService } from "../../services/settings-service.js";
|
||||
import { createGetGlobalHandler } from "./routes/get-global.js";
|
||||
import { createUpdateGlobalHandler } from "./routes/update-global.js";
|
||||
import { createGetCredentialsHandler } from "./routes/get-credentials.js";
|
||||
import { createUpdateCredentialsHandler } from "./routes/update-credentials.js";
|
||||
import { createGetProjectHandler } from "./routes/get-project.js";
|
||||
import { createUpdateProjectHandler } from "./routes/update-project.js";
|
||||
import { createMigrateHandler } from "./routes/migrate.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
|
||||
/**
|
||||
* Create settings router with all endpoints
|
||||
*
|
||||
* Registers handlers for all settings-related HTTP endpoints.
|
||||
* Each handler is created with the provided SettingsService instance.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /status - Check migration status and data availability
|
||||
* - GET /global - Get global settings
|
||||
* - PUT /global - Update global settings
|
||||
* - GET /credentials - Get masked credentials (safe for UI)
|
||||
* - PUT /credentials - Update API keys
|
||||
* - POST /project - Get project settings (requires projectPath in body)
|
||||
* - PUT /project - Update project settings
|
||||
* - POST /migrate - Migrate settings from localStorage
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express Router configured with all settings endpoints
|
||||
*/
|
||||
export function createSettingsRoutes(settingsService: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Status endpoint (check if migration needed)
|
||||
router.get("/status", createStatusHandler(settingsService));
|
||||
|
||||
// Global settings
|
||||
router.get("/global", createGetGlobalHandler(settingsService));
|
||||
router.put("/global", createUpdateGlobalHandler(settingsService));
|
||||
|
||||
// Credentials (separate for security)
|
||||
router.get("/credentials", createGetCredentialsHandler(settingsService));
|
||||
router.put("/credentials", createUpdateCredentialsHandler(settingsService));
|
||||
|
||||
// Project settings
|
||||
router.post("/project", createGetProjectHandler(settingsService));
|
||||
router.put("/project", createUpdateProjectHandler(settingsService));
|
||||
|
||||
// Migration from localStorage
|
||||
router.post("/migrate", createMigrateHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
35
apps/server/src/routes/settings/routes/get-credentials.ts
Normal file
35
apps/server/src/routes/settings/routes/get-credentials.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* GET /api/settings/credentials - Get API key status (masked for security)
|
||||
*
|
||||
* Returns masked credentials showing which providers have keys configured.
|
||||
* Each provider shows: `{ configured: boolean, masked: string }`
|
||||
* Masked shows first 4 and last 4 characters for verification.
|
||||
*
|
||||
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for GET /api/settings/credentials
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createGetCredentialsHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const credentials = await settingsService.getMaskedCredentials();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
credentials,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get credentials failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
34
apps/server/src/routes/settings/routes/get-global.ts
Normal file
34
apps/server/src/routes/settings/routes/get-global.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* GET /api/settings/global - Retrieve global user settings
|
||||
*
|
||||
* Returns the complete GlobalSettings object with all user preferences,
|
||||
* keyboard shortcuts, AI profiles, and project history.
|
||||
*
|
||||
* Response: `{ "success": true, "settings": GlobalSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for GET /api/settings/global
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createGetGlobalHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get global settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
45
apps/server/src/routes/settings/routes/get-project.ts
Normal file
45
apps/server/src/routes/settings/routes/get-project.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* POST /api/settings/project - Get project-specific settings
|
||||
*
|
||||
* Retrieves settings overrides for a specific project. Uses POST because
|
||||
* projectPath may contain special characters that don't work well in URLs.
|
||||
*
|
||||
* Request body: `{ projectPath: string }`
|
||||
* Response: `{ "success": true, "settings": ProjectSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/settings/project
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createGetProjectHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
|
||||
if (!projectPath || typeof projectPath !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingsService.getProjectSettings(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get project settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
88
apps/server/src/routes/settings/routes/migrate.ts
Normal file
88
apps/server/src/routes/settings/routes/migrate.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* POST /api/settings/migrate - Migrate settings from localStorage to file storage
|
||||
*
|
||||
* Called during onboarding when UI detects localStorage data but no settings files.
|
||||
* Extracts settings from various localStorage keys and writes to new file structure.
|
||||
* Collects errors but continues on partial failures (graceful degradation).
|
||||
*
|
||||
* Request body:
|
||||
* ```json
|
||||
* {
|
||||
* "data": {
|
||||
* "automaker-storage"?: string,
|
||||
* "automaker-setup"?: string,
|
||||
* "worktree-panel-collapsed"?: string,
|
||||
* "file-browser-recent-folders"?: string,
|
||||
* "automaker:lastProjectDir"?: string
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Response:
|
||||
* ```json
|
||||
* {
|
||||
* "success": boolean,
|
||||
* "migratedGlobalSettings": boolean,
|
||||
* "migratedCredentials": boolean,
|
||||
* "migratedProjectCount": number,
|
||||
* "errors": string[]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError, logger } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/settings/migrate
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createMigrateHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { data } = req.body as {
|
||||
data?: {
|
||||
"automaker-storage"?: string;
|
||||
"automaker-setup"?: string;
|
||||
"worktree-panel-collapsed"?: string;
|
||||
"file-browser-recent-folders"?: string;
|
||||
"automaker:lastProjectDir"?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "data object is required containing localStorage data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Starting settings migration from localStorage");
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(data);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(
|
||||
`Migration successful: ${result.migratedProjectCount} projects migrated`
|
||||
);
|
||||
} else {
|
||||
logger.warn(`Migration completed with errors: ${result.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
migratedGlobalSettings: result.migratedGlobalSettings,
|
||||
migratedCredentials: result.migratedCredentials,
|
||||
migratedProjectCount: result.migratedProjectCount,
|
||||
errors: result.errors,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Migration failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
47
apps/server/src/routes/settings/routes/status.ts
Normal file
47
apps/server/src/routes/settings/routes/status.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/settings/status - Get settings migration and availability status
|
||||
*
|
||||
* Checks which settings files exist to determine if migration from localStorage
|
||||
* is needed. Used by UI during onboarding to decide whether to show migration flow.
|
||||
*
|
||||
* Response:
|
||||
* ```json
|
||||
* {
|
||||
* "success": true,
|
||||
* "hasGlobalSettings": boolean,
|
||||
* "hasCredentials": boolean,
|
||||
* "dataDir": string,
|
||||
* "needsMigration": boolean
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for GET /api/settings/status
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createStatusHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const hasGlobalSettings = await settingsService.hasGlobalSettings();
|
||||
const hasCredentials = await settingsService.hasCredentials();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasGlobalSettings,
|
||||
hasCredentials,
|
||||
dataDir: settingsService.getDataDir(),
|
||||
needsMigration: !hasGlobalSettings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get settings status failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
51
apps/server/src/routes/settings/routes/update-credentials.ts
Normal file
51
apps/server/src/routes/settings/routes/update-credentials.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* PUT /api/settings/credentials - Update API credentials
|
||||
*
|
||||
* Updates API keys for Anthropic, Google, or OpenAI. Partial updates supported.
|
||||
* Returns masked credentials for verification without exposing full keys.
|
||||
*
|
||||
* Request body: `Partial<Credentials>` (usually just apiKeys)
|
||||
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import type { Credentials } from "../../../types/settings.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/credentials
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUpdateCredentialsHandler(
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const updates = req.body as Partial<Credentials>;
|
||||
|
||||
if (!updates || typeof updates !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request body - expected credentials object",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await settingsService.updateCredentials(updates);
|
||||
|
||||
// Return masked credentials for confirmation
|
||||
const masked = await settingsService.getMaskedCredentials();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
credentials: masked,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Update credentials failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
46
apps/server/src/routes/settings/routes/update-global.ts
Normal file
46
apps/server/src/routes/settings/routes/update-global.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* PUT /api/settings/global - Update global user settings
|
||||
*
|
||||
* Accepts partial GlobalSettings update. Fields provided are merged into
|
||||
* existing settings (not replaced). Returns updated settings.
|
||||
*
|
||||
* Request body: `Partial<GlobalSettings>`
|
||||
* Response: `{ "success": true, "settings": GlobalSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import type { GlobalSettings } from "../../../types/settings.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/global
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const updates = req.body as Partial<GlobalSettings>;
|
||||
|
||||
if (!updates || typeof updates !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request body - expected settings object",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Update global settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
60
apps/server/src/routes/settings/routes/update-project.ts
Normal file
60
apps/server/src/routes/settings/routes/update-project.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PUT /api/settings/project - Update project-specific settings
|
||||
*
|
||||
* Updates settings for a specific project. Partial updates supported.
|
||||
* Project settings override global settings when present.
|
||||
*
|
||||
* Request body: `{ projectPath: string, updates: Partial<ProjectSettings> }`
|
||||
* Response: `{ "success": true, "settings": ProjectSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import type { ProjectSettings } from "../../../types/settings.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/project
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUpdateProjectHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, updates } = req.body as {
|
||||
projectPath?: string;
|
||||
updates?: Partial<ProjectSettings>;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates || typeof updates !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "updates object is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateProjectSettings(
|
||||
projectPath,
|
||||
updates
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Update project settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,9 +14,87 @@ import {
|
||||
import { FeatureLoader } from "../../services/feature-loader.js";
|
||||
|
||||
const logger = createLogger("Worktree");
|
||||
const execAsync = promisify(exec);
|
||||
export const execAsync = promisify(exec);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Maximum allowed length for git branch names */
|
||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
// ============================================================================
|
||||
|
||||
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env["ProgramFiles(x86)"]) {
|
||||
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
||||
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
...additionalPaths.filter(Boolean),
|
||||
].filter(Boolean).join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
* Electron apps don't inherit the user's shell PATH, so we need to add
|
||||
* common tool installation locations.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Validation utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate branch name to prevent command injection.
|
||||
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
||||
* We also reject shell metacharacters for safety.
|
||||
*/
|
||||
export function isValidBranchName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is available on the system
|
||||
*/
|
||||
export async function isGhCliAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const checkCommand = process.platform === "win32"
|
||||
? "where gh"
|
||||
: "command -v gh";
|
||||
await execAsync(checkCommand, { env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
|
||||
"chore: automaker initial commit";
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createMergeHandler } from "./routes/merge.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createCreatePRHandler } from "./routes/create-pr.js";
|
||||
import { createPRInfoHandler } from "./routes/pr-info.js";
|
||||
import { createCommitHandler } from "./routes/commit.js";
|
||||
import { createPushHandler } from "./routes/push.js";
|
||||
import { createPullHandler } from "./routes/pull.js";
|
||||
@@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post("/create", createCreateHandler());
|
||||
router.post("/delete", createDeleteHandler());
|
||||
router.post("/create-pr", createCreatePRHandler());
|
||||
router.post("/pr-info", createPRInfoHandler());
|
||||
router.post("/commit", createCommitHandler());
|
||||
router.post("/push", createPushHandler());
|
||||
router.post("/pull", createPullHandler());
|
||||
|
||||
@@ -3,53 +3,22 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
// This is needed because Electron apps don't inherit the user's shell PATH
|
||||
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env["ProgramFiles(x86)"]) {
|
||||
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
||||
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
...additionalPaths.filter(Boolean),
|
||||
].filter(Boolean).join(pathSeparator);
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
execAsync,
|
||||
execEnv,
|
||||
isValidBranchName,
|
||||
isGhCliAvailable,
|
||||
} from "../common.js";
|
||||
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js";
|
||||
|
||||
export function createCreatePRHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
||||
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
||||
worktreePath: string;
|
||||
projectPath?: string;
|
||||
commitMessage?: string;
|
||||
prTitle?: string;
|
||||
prBody?: string;
|
||||
@@ -65,6 +34,10 @@ export function createCreatePRHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use projectPath if provided, otherwise derive from worktreePath
|
||||
// For worktrees, projectPath is needed to store metadata in the main project's .automaker folder
|
||||
const effectiveProjectPath = projectPath || worktreePath;
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
@@ -72,6 +45,15 @@ export function createCreatePRHandler() {
|
||||
);
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
// Validate branch name for security
|
||||
if (!isValidBranchName(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid branch name contains unsafe characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
@@ -143,18 +125,8 @@ export function createCreatePRHandler() {
|
||||
let browserUrl: string | null = null;
|
||||
let ghCliAvailable = false;
|
||||
|
||||
// Check if gh CLI is available (cross-platform)
|
||||
try {
|
||||
const checkCommand = process.platform === "win32"
|
||||
? "where gh"
|
||||
: "command -v gh";
|
||||
await execAsync(checkCommand, { env: execEnv });
|
||||
ghCliAvailable = true;
|
||||
} catch {
|
||||
ghCliAvailable = false;
|
||||
}
|
||||
|
||||
// Get repository URL for browser fallback
|
||||
// Get repository URL and detect fork workflow FIRST
|
||||
// This is needed for both the existing PR check and PR creation
|
||||
let repoUrl: string | null = null;
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
@@ -180,7 +152,7 @@ export function createCreatePRHandler() {
|
||||
// Try HTTPS format: https://github.com/owner/repo.git
|
||||
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
||||
}
|
||||
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
if (remoteName === "upstream") {
|
||||
@@ -206,7 +178,7 @@ export function createCreatePRHandler() {
|
||||
env: execEnv,
|
||||
});
|
||||
const url = originUrl.trim();
|
||||
|
||||
|
||||
// Parse URL to extract owner/repo
|
||||
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
||||
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
||||
@@ -220,6 +192,9 @@ export function createCreatePRHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if gh CLI is available (cross-platform)
|
||||
ghCliAvailable = await isGhCliAvailable();
|
||||
|
||||
// Construct browser URL for PR creation
|
||||
if (repoUrl) {
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
@@ -234,32 +209,136 @@ export function createCreatePRHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
let prNumber: number | undefined;
|
||||
let prAlreadyExisted = false;
|
||||
|
||||
if (ghCliAvailable) {
|
||||
// First, check if a PR already exists for this branch using gh pr list
|
||||
// This is more reliable than gh pr view as it explicitly searches by branch name
|
||||
// For forks, we need to use owner:branch format for the head parameter
|
||||
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : "";
|
||||
|
||||
console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
||||
try {
|
||||
// Build gh pr create command
|
||||
let prCmd = `gh pr create --base "${base}"`;
|
||||
|
||||
// If this is a fork (has upstream remote), specify the repo and head
|
||||
if (upstreamRepo && originOwner) {
|
||||
// For forks: --repo specifies where to create PR, --head specifies source
|
||||
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
|
||||
} else {
|
||||
// Not a fork, just specify the head branch
|
||||
prCmd += ` --head "${branchName}"`;
|
||||
}
|
||||
|
||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
||||
prCmd = prCmd.trim();
|
||||
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
|
||||
console.log(`[CreatePR] Running: ${listCmd}`);
|
||||
const { stdout: existingPrOutput } = await execAsync(listCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
prUrl = prOutput.trim();
|
||||
} catch (ghError: unknown) {
|
||||
// gh CLI failed
|
||||
const err = ghError as { stderr?: string; message?: string };
|
||||
prError = err.stderr || err.message || "PR creation failed";
|
||||
console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`);
|
||||
|
||||
const existingPrs = JSON.parse(existingPrOutput);
|
||||
|
||||
if (Array.isArray(existingPrs) && existingPrs.length > 0) {
|
||||
const existingPr = existingPrs[0];
|
||||
// PR already exists - use it and store metadata
|
||||
console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`);
|
||||
prUrl = existingPr.url;
|
||||
prNumber = existingPr.number;
|
||||
prAlreadyExisted = true;
|
||||
|
||||
// Store the existing PR info in metadata
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: existingPr.number,
|
||||
url: existingPr.url,
|
||||
title: existingPr.title || title,
|
||||
state: existingPr.state || "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`);
|
||||
} else {
|
||||
console.log(`[CreatePR] No existing PR found for branch ${branchName}`);
|
||||
}
|
||||
} catch (listError) {
|
||||
// gh pr list failed - log but continue to try creating
|
||||
console.log(`[CreatePR] gh pr list failed (this is ok, will try to create):`, listError);
|
||||
}
|
||||
|
||||
// Only create a new PR if one doesn't already exist
|
||||
if (!prUrl) {
|
||||
try {
|
||||
// Build gh pr create command
|
||||
let prCmd = `gh pr create --base "${base}"`;
|
||||
|
||||
// If this is a fork (has upstream remote), specify the repo and head
|
||||
if (upstreamRepo && originOwner) {
|
||||
// For forks: --repo specifies where to create PR, --head specifies source
|
||||
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
|
||||
} else {
|
||||
// Not a fork, just specify the head branch
|
||||
prCmd += ` --head "${branchName}"`;
|
||||
}
|
||||
|
||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
||||
prCmd = prCmd.trim();
|
||||
|
||||
console.log(`[CreatePR] Creating PR with command: ${prCmd}`);
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
prUrl = prOutput.trim();
|
||||
console.log(`[CreatePR] PR created: ${prUrl}`);
|
||||
|
||||
// Extract PR number and store metadata for newly created PR
|
||||
if (prUrl) {
|
||||
const prMatch = prUrl.match(/\/pull\/(\d+)/);
|
||||
prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined;
|
||||
|
||||
if (prNumber) {
|
||||
try {
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: prNumber,
|
||||
url: prUrl,
|
||||
title,
|
||||
state: draft ? "draft" : "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`);
|
||||
} catch (metadataError) {
|
||||
console.error("[CreatePR] Failed to store PR metadata:", metadataError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ghError: unknown) {
|
||||
// gh CLI failed - check if it's "already exists" error and try to fetch the PR
|
||||
const err = ghError as { stderr?: string; message?: string };
|
||||
const errorMessage = err.stderr || err.message || "PR creation failed";
|
||||
console.log(`[CreatePR] gh pr create failed: ${errorMessage}`);
|
||||
|
||||
// If error indicates PR already exists, try to fetch it
|
||||
if (errorMessage.toLowerCase().includes("already exists")) {
|
||||
console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`);
|
||||
try {
|
||||
const { stdout: viewOutput } = await execAsync(
|
||||
`gh pr view --json number,title,url,state`,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const existingPr = JSON.parse(viewOutput);
|
||||
if (existingPr.url) {
|
||||
prUrl = existingPr.url;
|
||||
prNumber = existingPr.number;
|
||||
prAlreadyExisted = true;
|
||||
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: existingPr.number,
|
||||
url: existingPr.url,
|
||||
title: existingPr.title || title,
|
||||
state: existingPr.state || "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`);
|
||||
}
|
||||
} catch (viewError) {
|
||||
console.error("[CreatePR] Failed to fetch existing PR:", viewError);
|
||||
prError = errorMessage;
|
||||
}
|
||||
} else {
|
||||
prError = errorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prError = "gh_cli_not_available";
|
||||
@@ -274,7 +353,9 @@ export function createCreatePRHandler() {
|
||||
commitHash,
|
||||
pushed: true,
|
||||
prUrl,
|
||||
prNumber,
|
||||
prCreated: !!prUrl,
|
||||
prAlreadyExisted,
|
||||
prError: prError || undefined,
|
||||
browserUrl: browserUrl || undefined,
|
||||
ghCliAvailable,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { promisify } from "util";
|
||||
import { existsSync } from "fs";
|
||||
import { isGitRepo } from "@automaker/git-utils";
|
||||
import { getErrorMessage, logError, normalizePath } from "../common.js";
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -22,6 +23,7 @@ interface WorktreeInfo {
|
||||
hasWorktree: boolean; // Always true for items in this list
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
||||
}
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
@@ -107,6 +109,9 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Read all worktree metadata to get PR info
|
||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||
|
||||
// If includeDetails is requested, fetch change status for each worktree
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
@@ -128,6 +133,14 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add PR info from metadata for each worktree
|
||||
for (const worktree of worktrees) {
|
||||
const metadata = allMetadata.get(worktree.branch);
|
||||
if (metadata?.pr) {
|
||||
worktree.pr = metadata.pr;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
worktrees,
|
||||
|
||||
269
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
269
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* POST /pr-info endpoint - Get PR info and comments for a branch
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
execAsync,
|
||||
execEnv,
|
||||
isValidBranchName,
|
||||
isGhCliAvailable,
|
||||
} from "../common.js";
|
||||
|
||||
export interface PRComment {
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: PRComment[];
|
||||
reviewComments: PRComment[];
|
||||
}
|
||||
|
||||
export function createPRInfoHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, branchName } = req.body as {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || !branchName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "worktreePath and branchName required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name to prevent command injection
|
||||
if (!isValidBranchName(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid branch name contains unsafe characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if gh CLI is available
|
||||
const ghCliAvailable = await isGhCliAvailable();
|
||||
|
||||
if (!ghCliAvailable) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: false,
|
||||
error: "gh CLI not available",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect repository information (supports fork workflows)
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
let originRepo: string | null = null;
|
||||
|
||||
try {
|
||||
const { stdout: remotes } = await execAsync("git remote -v", {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
const lines = remotes.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
let match =
|
||||
line.match(
|
||||
/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
) ||
|
||||
line.match(
|
||||
/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
) ||
|
||||
line.match(
|
||||
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
if (remoteName === "upstream") {
|
||||
upstreamRepo = `${owner}/${repo}`;
|
||||
} else if (remoteName === "origin") {
|
||||
originOwner = owner;
|
||||
originRepo = repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore remote parsing errors
|
||||
}
|
||||
|
||||
if (!originOwner || !originRepo) {
|
||||
try {
|
||||
const { stdout: originUrl } = await execAsync(
|
||||
"git config --get remote.origin.url",
|
||||
{
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
}
|
||||
);
|
||||
const match = originUrl
|
||||
.trim()
|
||||
.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
if (!originOwner) {
|
||||
originOwner = match[1];
|
||||
}
|
||||
if (!originRepo) {
|
||||
originRepo = match[2];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback errors
|
||||
}
|
||||
}
|
||||
|
||||
const targetRepo =
|
||||
upstreamRepo || (originOwner && originRepo
|
||||
? `${originOwner}/${originRepo}`
|
||||
: null);
|
||||
const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : "";
|
||||
const headRef =
|
||||
upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||
|
||||
// Get PR info for the branch using gh CLI
|
||||
try {
|
||||
// First, find the PR associated with this branch
|
||||
const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`;
|
||||
const { stdout: prListOutput } = await execAsync(
|
||||
listCmd,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
|
||||
const prList = JSON.parse(prListOutput);
|
||||
|
||||
if (prList.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = prList[0];
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Get regular PR comments (issue comments)
|
||||
let comments: PRComment[] = [];
|
||||
try {
|
||||
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`;
|
||||
const { stdout: commentsOutput } = await execAsync(
|
||||
viewCmd,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const commentsData = JSON.parse(commentsOutput);
|
||||
comments = (commentsData.comments || []).map((c: {
|
||||
id: number;
|
||||
author: { login: string };
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}) => ({
|
||||
id: c.id,
|
||||
author: c.author?.login || "unknown",
|
||||
body: c.body,
|
||||
createdAt: c.createdAt,
|
||||
isReviewComment: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("[PRInfo] Failed to fetch PR comments:", error);
|
||||
}
|
||||
|
||||
// Get review comments (inline code comments)
|
||||
let reviewComments: PRComment[] = [];
|
||||
// Only fetch review comments if we have repository info
|
||||
if (targetRepo) {
|
||||
try {
|
||||
const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`;
|
||||
const reviewsCmd = `gh api ${reviewsEndpoint}`;
|
||||
const { stdout: reviewsOutput } = await execAsync(
|
||||
reviewsCmd,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const reviewsData = JSON.parse(reviewsOutput);
|
||||
reviewComments = reviewsData.map((c: {
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line?: number;
|
||||
original_line?: number;
|
||||
created_at: string;
|
||||
}) => ({
|
||||
id: c.id,
|
||||
author: c.user?.login || "unknown",
|
||||
body: c.body,
|
||||
path: c.path,
|
||||
line: c.line || c.original_line,
|
||||
createdAt: c.created_at,
|
||||
isReviewComment: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("[PRInfo] Failed to fetch review comments:", error);
|
||||
}
|
||||
} else {
|
||||
console.warn("[PRInfo] Cannot fetch review comments: repository info not available");
|
||||
}
|
||||
|
||||
const prInfo: PRInfo = {
|
||||
number: prNumber,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
author: pr.author?.login || "unknown",
|
||||
body: pr.body || "",
|
||||
comments,
|
||||
reviewComments,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: true,
|
||||
ghCliAvailable: true,
|
||||
prInfo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// gh CLI failed - might not be authenticated or no remote
|
||||
logError(error, "Failed to get PR info");
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: true,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "PR info handler failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
628
apps/server/src/services/settings-service.ts
Normal file
628
apps/server/src/services/settings-service.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* Settings Service - Handles reading/writing settings to JSON files
|
||||
*
|
||||
* Provides persistent storage for:
|
||||
* - Global settings (DATA_DIR/settings.json)
|
||||
* - Credentials (DATA_DIR/credentials.json)
|
||||
* - Per-project settings ({projectPath}/.automaker/settings.json)
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import {
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
ensureAutomakerDir,
|
||||
} from "../lib/automaker-paths.js";
|
||||
import type {
|
||||
GlobalSettings,
|
||||
Credentials,
|
||||
ProjectSettings,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
} from "../types/settings.js";
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from "../types/settings.js";
|
||||
|
||||
const logger = createLogger("SettingsService");
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, content, "utf-8");
|
||||
await fs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsService - Manages persistent storage of user settings and credentials
|
||||
*
|
||||
* Handles reading and writing settings to JSON files with atomic operations
|
||||
* for reliability. Provides three levels of settings:
|
||||
* - Global settings: shared preferences in {dataDir}/settings.json
|
||||
* - Credentials: sensitive API keys in {dataDir}/credentials.json
|
||||
* - Project settings: per-project overrides in {projectPath}/.automaker/settings.json
|
||||
*
|
||||
* All operations are atomic (write to temp file, then rename) to prevent corruption.
|
||||
* Missing files are treated as empty and return defaults on read.
|
||||
* Updates use deep merge for nested objects like keyboardShortcuts and apiKeys.
|
||||
*/
|
||||
export class SettingsService {
|
||||
private dataDir: string;
|
||||
|
||||
/**
|
||||
* Create a new SettingsService instance
|
||||
*
|
||||
* @param dataDir - Absolute path to global data directory (e.g., ~/.automaker)
|
||||
*/
|
||||
constructor(dataDir: string) {
|
||||
this.dataDir = dataDir;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get global settings with defaults applied for any missing fields
|
||||
*
|
||||
* Reads from {dataDir}/settings.json. If file doesn't exist, returns defaults.
|
||||
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
|
||||
* compatibility during schema migrations.
|
||||
*
|
||||
* @returns Promise resolving to complete GlobalSettings object
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
const settings = await readJsonFile<GlobalSettings>(
|
||||
settingsPath,
|
||||
DEFAULT_GLOBAL_SETTINGS
|
||||
);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings with partial changes
|
||||
*
|
||||
* Performs a deep merge: nested objects like keyboardShortcuts are merged,
|
||||
* not replaced. Updates are written atomically. Creates dataDir if needed.
|
||||
*
|
||||
* @param updates - Partial GlobalSettings to merge (only provided fields are updated)
|
||||
* @returns Promise resolving to complete updated GlobalSettings
|
||||
*/
|
||||
async updateGlobalSettings(
|
||||
updates: Partial<GlobalSettings>
|
||||
): Promise<GlobalSettings> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
const current = await this.getGlobalSettings();
|
||||
const updated: GlobalSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
version: SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge keyboard shortcuts if provided
|
||||
if (updates.keyboardShortcuts) {
|
||||
updated.keyboardShortcuts = {
|
||||
...current.keyboardShortcuts,
|
||||
...updates.keyboardShortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info("Global settings updated");
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if global settings file exists
|
||||
*
|
||||
* Used to determine if user has previously configured settings.
|
||||
*
|
||||
* @returns Promise resolving to true if {dataDir}/settings.json exists
|
||||
*/
|
||||
async hasGlobalSettings(): Promise<boolean> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
return fileExists(settingsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Credentials
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get credentials with defaults applied
|
||||
*
|
||||
* Reads from {dataDir}/credentials.json. If file doesn't exist, returns
|
||||
* defaults (empty API keys). Used primarily by backend for API authentication.
|
||||
* UI should use getMaskedCredentials() instead.
|
||||
*
|
||||
* @returns Promise resolving to complete Credentials object
|
||||
*/
|
||||
async getCredentials(): Promise<Credentials> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
const credentials = await readJsonFile<Credentials>(
|
||||
credentialsPath,
|
||||
DEFAULT_CREDENTIALS
|
||||
);
|
||||
|
||||
return {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
...credentials,
|
||||
apiKeys: {
|
||||
...DEFAULT_CREDENTIALS.apiKeys,
|
||||
...credentials.apiKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credentials with partial changes
|
||||
*
|
||||
* Updates individual API keys. Uses deep merge for apiKeys object.
|
||||
* Creates dataDir if needed. Credentials are written atomically.
|
||||
* WARNING: Use only in secure contexts - keys are unencrypted.
|
||||
*
|
||||
* @param updates - Partial Credentials (usually just apiKeys)
|
||||
* @returns Promise resolving to complete updated Credentials object
|
||||
*/
|
||||
async updateCredentials(
|
||||
updates: Partial<Credentials>
|
||||
): Promise<Credentials> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
|
||||
const current = await this.getCredentials();
|
||||
const updated: Credentials = {
|
||||
...current,
|
||||
...updates,
|
||||
version: CREDENTIALS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge api keys if provided
|
||||
if (updates.apiKeys) {
|
||||
updated.apiKeys = {
|
||||
...current.apiKeys,
|
||||
...updates.apiKeys,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(credentialsPath, updated);
|
||||
logger.info("Credentials updated");
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked credentials safe for UI display
|
||||
*
|
||||
* Returns API keys masked for security (first 4 and last 4 chars visible).
|
||||
* Use this for showing credential status in UI without exposing full keys.
|
||||
* Each key includes a 'configured' boolean and masked string representation.
|
||||
*
|
||||
* @returns Promise resolving to masked credentials object with each provider's status
|
||||
*/
|
||||
async getMaskedCredentials(): Promise<{
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
}> {
|
||||
const credentials = await this.getCredentials();
|
||||
|
||||
const maskKey = (key: string): string => {
|
||||
if (!key || key.length < 8) return "";
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
return {
|
||||
anthropic: {
|
||||
configured: !!credentials.apiKeys.anthropic,
|
||||
masked: maskKey(credentials.apiKeys.anthropic),
|
||||
},
|
||||
google: {
|
||||
configured: !!credentials.apiKeys.google,
|
||||
masked: maskKey(credentials.apiKeys.google),
|
||||
},
|
||||
openai: {
|
||||
configured: !!credentials.apiKeys.openai,
|
||||
masked: maskKey(credentials.apiKeys.openai),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials file exists
|
||||
*
|
||||
* Used to determine if user has configured any API keys.
|
||||
*
|
||||
* @returns Promise resolving to true if {dataDir}/credentials.json exists
|
||||
*/
|
||||
async hasCredentials(): Promise<boolean> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
return fileExists(credentialsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get project-specific settings with defaults applied
|
||||
*
|
||||
* Reads from {projectPath}/.automaker/settings.json. If file doesn't exist,
|
||||
* returns defaults. Project settings are optional - missing values fall back
|
||||
* to global settings on the UI side.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to complete ProjectSettings object
|
||||
*/
|
||||
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
const settings = await readJsonFile<ProjectSettings>(
|
||||
settingsPath,
|
||||
DEFAULT_PROJECT_SETTINGS
|
||||
);
|
||||
|
||||
return {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project-specific settings with partial changes
|
||||
*
|
||||
* Performs a deep merge on boardBackground. Creates .automaker directory
|
||||
* in project if needed. Updates are written atomically.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param updates - Partial ProjectSettings to merge
|
||||
* @returns Promise resolving to complete updated ProjectSettings
|
||||
*/
|
||||
async updateProjectSettings(
|
||||
projectPath: string,
|
||||
updates: Partial<ProjectSettings>
|
||||
): Promise<ProjectSettings> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
|
||||
const current = await this.getProjectSettings(projectPath);
|
||||
const updated: ProjectSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
version: PROJECT_SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge board background if provided
|
||||
if (updates.boardBackground) {
|
||||
updated.boardBackground = {
|
||||
...current.boardBackground,
|
||||
...updates.boardBackground,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info(`Project settings updated for ${projectPath}`);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if project settings file exists
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to true if {projectPath}/.automaker/settings.json exists
|
||||
*/
|
||||
async hasProjectSettings(projectPath: string): Promise<boolean> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
return fileExists(settingsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Migrate settings from localStorage to file-based storage
|
||||
*
|
||||
* Called during onboarding when UI detects localStorage data but no settings files.
|
||||
* Extracts global settings, credentials, and per-project settings from various
|
||||
* localStorage keys and writes them to the new file-based storage.
|
||||
* Collects errors but continues on partial failures.
|
||||
*
|
||||
* @param localStorageData - Object containing localStorage key/value pairs to migrate
|
||||
* @returns Promise resolving to migration result with success status and error list
|
||||
*/
|
||||
async migrateFromLocalStorage(localStorageData: {
|
||||
"automaker-storage"?: string;
|
||||
"automaker-setup"?: string;
|
||||
"worktree-panel-collapsed"?: string;
|
||||
"file-browser-recent-folders"?: string;
|
||||
"automaker:lastProjectDir"?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
migratedCredentials: boolean;
|
||||
migratedProjectCount: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let migratedGlobalSettings = false;
|
||||
let migratedCredentials = false;
|
||||
let migratedProjectCount = 0;
|
||||
|
||||
try {
|
||||
// Parse the main automaker-storage
|
||||
let appState: Record<string, unknown> = {};
|
||||
if (localStorageData["automaker-storage"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorageData["automaker-storage"]);
|
||||
appState = parsed.state || parsed;
|
||||
} catch (e) {
|
||||
errors.push(`Failed to parse automaker-storage: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract global settings
|
||||
const globalSettings: Partial<GlobalSettings> = {
|
||||
theme: (appState.theme as GlobalSettings["theme"]) || "dark",
|
||||
sidebarOpen:
|
||||
appState.sidebarOpen !== undefined
|
||||
? (appState.sidebarOpen as boolean)
|
||||
: true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
kanbanCardDetailLevel:
|
||||
(appState.kanbanCardDetailLevel as GlobalSettings["kanbanCardDetailLevel"]) ||
|
||||
"standard",
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined
|
||||
? (appState.defaultSkipTests as boolean)
|
||||
: true,
|
||||
enableDependencyBlocking:
|
||||
appState.enableDependencyBlocking !== undefined
|
||||
? (appState.enableDependencyBlocking as boolean)
|
||||
: true,
|
||||
useWorktrees: (appState.useWorktrees as boolean) || false,
|
||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
||||
defaultPlanningMode:
|
||||
(appState.defaultPlanningMode as GlobalSettings["defaultPlanningMode"]) ||
|
||||
"skip",
|
||||
defaultRequirePlanApproval:
|
||||
(appState.defaultRequirePlanApproval as boolean) || false,
|
||||
defaultAIProfileId:
|
||||
(appState.defaultAIProfileId as string | null) || null,
|
||||
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
||||
enhancementModel:
|
||||
(appState.enhancementModel as GlobalSettings["enhancementModel"]) ||
|
||||
"sonnet",
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects:
|
||||
(appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
|
||||
lastSelectedSessionByProject:
|
||||
(appState.lastSelectedSessionByProject as Record<string, string>) ||
|
||||
{},
|
||||
};
|
||||
|
||||
// Add direct localStorage values
|
||||
if (localStorageData["automaker:lastProjectDir"]) {
|
||||
globalSettings.lastProjectDir =
|
||||
localStorageData["automaker:lastProjectDir"];
|
||||
}
|
||||
|
||||
if (localStorageData["file-browser-recent-folders"]) {
|
||||
try {
|
||||
globalSettings.recentFolders = JSON.parse(
|
||||
localStorageData["file-browser-recent-folders"]
|
||||
);
|
||||
} catch {
|
||||
globalSettings.recentFolders = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (localStorageData["worktree-panel-collapsed"]) {
|
||||
globalSettings.worktreePanelCollapsed =
|
||||
localStorageData["worktree-panel-collapsed"] === "true";
|
||||
}
|
||||
|
||||
// Save global settings
|
||||
await this.updateGlobalSettings(globalSettings);
|
||||
migratedGlobalSettings = true;
|
||||
logger.info("Migrated global settings from localStorage");
|
||||
|
||||
// Extract and save credentials
|
||||
if (appState.apiKeys) {
|
||||
const apiKeys = appState.apiKeys as {
|
||||
anthropic?: string;
|
||||
google?: string;
|
||||
openai?: string;
|
||||
};
|
||||
await this.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: apiKeys.anthropic || "",
|
||||
google: apiKeys.google || "",
|
||||
openai: apiKeys.openai || "",
|
||||
},
|
||||
});
|
||||
migratedCredentials = true;
|
||||
logger.info("Migrated credentials from localStorage");
|
||||
}
|
||||
|
||||
// Migrate per-project settings
|
||||
const boardBackgroundByProject = appState.boardBackgroundByProject as
|
||||
| Record<string, BoardBackgroundSettings>
|
||||
| undefined;
|
||||
const currentWorktreeByProject = appState.currentWorktreeByProject as
|
||||
| Record<string, { path: string | null; branch: string }>
|
||||
| undefined;
|
||||
const worktreesByProject = appState.worktreesByProject as
|
||||
| Record<string, WorktreeInfo[]>
|
||||
| undefined;
|
||||
|
||||
// Get unique project paths that have per-project settings
|
||||
const projectPaths = new Set<string>();
|
||||
if (boardBackgroundByProject) {
|
||||
Object.keys(boardBackgroundByProject).forEach((p) =>
|
||||
projectPaths.add(p)
|
||||
);
|
||||
}
|
||||
if (currentWorktreeByProject) {
|
||||
Object.keys(currentWorktreeByProject).forEach((p) =>
|
||||
projectPaths.add(p)
|
||||
);
|
||||
}
|
||||
if (worktreesByProject) {
|
||||
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
|
||||
// Also check projects list for theme settings
|
||||
const projects = (appState.projects as ProjectRef[]) || [];
|
||||
for (const project of projects) {
|
||||
if (project.theme) {
|
||||
projectPaths.add(project.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate each project's settings
|
||||
for (const projectPath of projectPaths) {
|
||||
try {
|
||||
const projectSettings: Partial<ProjectSettings> = {};
|
||||
|
||||
// Get theme from project object
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
if (project?.theme) {
|
||||
projectSettings.theme =
|
||||
project.theme as ProjectSettings["theme"];
|
||||
}
|
||||
|
||||
if (boardBackgroundByProject?.[projectPath]) {
|
||||
projectSettings.boardBackground =
|
||||
boardBackgroundByProject[projectPath];
|
||||
}
|
||||
|
||||
if (currentWorktreeByProject?.[projectPath]) {
|
||||
projectSettings.currentWorktree =
|
||||
currentWorktreeByProject[projectPath];
|
||||
}
|
||||
|
||||
if (worktreesByProject?.[projectPath]) {
|
||||
projectSettings.worktrees = worktreesByProject[projectPath];
|
||||
}
|
||||
|
||||
if (Object.keys(projectSettings).length > 0) {
|
||||
await this.updateProjectSettings(projectPath, projectSettings);
|
||||
migratedProjectCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Migration complete: ${migratedProjectCount} projects migrated`
|
||||
);
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
migratedGlobalSettings,
|
||||
migratedCredentials,
|
||||
migratedProjectCount,
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Migration failed:", error);
|
||||
errors.push(`Migration failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
migratedGlobalSettings,
|
||||
migratedCredentials,
|
||||
migratedProjectCount,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data directory path
|
||||
*
|
||||
* Returns the absolute path to the directory where global settings and
|
||||
* credentials are stored. Useful for logging, debugging, and validation.
|
||||
*
|
||||
* @returns Absolute path to data directory
|
||||
*/
|
||||
getDataDir(): string {
|
||||
return this.dataDir;
|
||||
}
|
||||
}
|
||||
428
apps/server/src/types/settings.ts
Normal file
428
apps/server/src/types/settings.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Settings Types - Shared types for file-based settings storage
|
||||
*
|
||||
* Defines the structure for global settings, credentials, and per-project settings
|
||||
* that are persisted to disk in JSON format. These types are used by both the server
|
||||
* (for file I/O via SettingsService) and the UI (for state management and sync).
|
||||
*/
|
||||
|
||||
/**
|
||||
* ThemeMode - Available color themes for the UI
|
||||
*
|
||||
* Includes system theme and multiple color schemes:
|
||||
* - System: Respects OS dark/light mode preference
|
||||
* - Light/Dark: Basic light and dark variants
|
||||
* - Color Schemes: Retro, Dracula, Nord, Monokai, Tokyo Night, Solarized, Gruvbox,
|
||||
* Catppuccin, OneDark, Synthwave, Red, Cream, Sunset, Gray
|
||||
*/
|
||||
export type ThemeMode =
|
||||
| "light"
|
||||
| "dark"
|
||||
| "system"
|
||||
| "retro"
|
||||
| "dracula"
|
||||
| "nord"
|
||||
| "monokai"
|
||||
| "tokyonight"
|
||||
| "solarized"
|
||||
| "gruvbox"
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave"
|
||||
| "red"
|
||||
| "cream"
|
||||
| "sunset"
|
||||
| "gray";
|
||||
|
||||
/** KanbanCardDetailLevel - Controls how much information is displayed on kanban cards */
|
||||
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
/** AgentModel - Available Claude models for feature generation and planning */
|
||||
export type AgentModel = "opus" | "sonnet" | "haiku";
|
||||
|
||||
/** PlanningMode - Planning levels for feature generation workflows */
|
||||
export type PlanningMode = "skip" | "lite" | "spec" | "full";
|
||||
|
||||
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
|
||||
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
||||
|
||||
/** ModelProvider - AI model provider for credentials and API key management */
|
||||
export type ModelProvider = "claude";
|
||||
|
||||
/**
|
||||
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
||||
*
|
||||
* Each property maps an action to a keyboard shortcut string
|
||||
* (e.g., "Ctrl+K", "Alt+N", "Shift+P")
|
||||
*/
|
||||
export interface KeyboardShortcuts {
|
||||
/** Open board view */
|
||||
board: string;
|
||||
/** Open agent panel */
|
||||
agent: string;
|
||||
/** Open feature spec editor */
|
||||
spec: string;
|
||||
/** Open context files panel */
|
||||
context: string;
|
||||
/** Open settings */
|
||||
settings: string;
|
||||
/** Open AI profiles */
|
||||
profiles: string;
|
||||
/** Open terminal */
|
||||
terminal: string;
|
||||
/** Toggle sidebar visibility */
|
||||
toggleSidebar: string;
|
||||
/** Add new feature */
|
||||
addFeature: string;
|
||||
/** Add context file */
|
||||
addContextFile: string;
|
||||
/** Start next feature generation */
|
||||
startNext: string;
|
||||
/** Create new chat session */
|
||||
newSession: string;
|
||||
/** Open project picker */
|
||||
openProject: string;
|
||||
/** Open project picker (alternate) */
|
||||
projectPicker: string;
|
||||
/** Cycle to previous project */
|
||||
cyclePrevProject: string;
|
||||
/** Cycle to next project */
|
||||
cycleNextProject: string;
|
||||
/** Add new AI profile */
|
||||
addProfile: string;
|
||||
/** Split terminal right */
|
||||
splitTerminalRight: string;
|
||||
/** Split terminal down */
|
||||
splitTerminalDown: string;
|
||||
/** Close current terminal */
|
||||
closeTerminal: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AIProfile - Configuration for an AI model with specific parameters
|
||||
*
|
||||
* Profiles can be built-in defaults or user-created. They define which model to use,
|
||||
* thinking level, and other parameters for feature generation tasks.
|
||||
*/
|
||||
export interface AIProfile {
|
||||
/** Unique identifier for the profile */
|
||||
id: string;
|
||||
/** Display name for the profile */
|
||||
name: string;
|
||||
/** User-friendly description */
|
||||
description: string;
|
||||
/** Which Claude model to use (opus, sonnet, haiku) */
|
||||
model: AgentModel;
|
||||
/** Extended thinking level for reasoning-based tasks */
|
||||
thinkingLevel: ThinkingLevel;
|
||||
/** Provider (currently only "claude") */
|
||||
provider: ModelProvider;
|
||||
/** Whether this is a built-in default profile */
|
||||
isBuiltIn: boolean;
|
||||
/** Optional icon identifier or emoji */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectRef - Minimal reference to a project stored in global settings
|
||||
*
|
||||
* Used for the projects list and project history. Full project data is loaded separately.
|
||||
*/
|
||||
export interface ProjectRef {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Absolute filesystem path to project directory */
|
||||
path: string;
|
||||
/** ISO timestamp of last time project was opened */
|
||||
lastOpened?: string;
|
||||
/** Project-specific theme override (or undefined to use global) */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrashedProjectRef - Reference to a project in the trash/recycle bin
|
||||
*
|
||||
* Extends ProjectRef with deletion metadata. User can permanently delete or restore.
|
||||
*/
|
||||
export interface TrashedProjectRef extends ProjectRef {
|
||||
/** ISO timestamp when project was moved to trash */
|
||||
trashedAt: string;
|
||||
/** Whether project folder was deleted from disk */
|
||||
deletedFromDisk?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatSessionRef - Minimal reference to a chat session
|
||||
*
|
||||
* Used for session lists and history. Full session content is stored separately.
|
||||
*/
|
||||
export interface ChatSessionRef {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
/** User-given or AI-generated title */
|
||||
title: string;
|
||||
/** Project that session belongs to */
|
||||
projectId: string;
|
||||
/** ISO timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO timestamp of last message */
|
||||
updatedAt: string;
|
||||
/** Whether session is archived */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSettings - User preferences and state stored globally in {DATA_DIR}/settings.json
|
||||
*
|
||||
* This is the main settings file that persists user preferences across sessions.
|
||||
* Includes theme, UI state, feature defaults, keyboard shortcuts, AI profiles, and projects.
|
||||
* Format: JSON with version field for migration support.
|
||||
*/
|
||||
export interface GlobalSettings {
|
||||
/** Version number for schema migration */
|
||||
version: number;
|
||||
|
||||
// Theme Configuration
|
||||
/** Currently selected theme */
|
||||
theme: ThemeMode;
|
||||
|
||||
// UI State Preferences
|
||||
/** Whether sidebar is currently open */
|
||||
sidebarOpen: boolean;
|
||||
/** Whether chat history panel is open */
|
||||
chatHistoryOpen: boolean;
|
||||
/** How much detail to show on kanban cards */
|
||||
kanbanCardDetailLevel: KanbanCardDetailLevel;
|
||||
|
||||
// Feature Generation Defaults
|
||||
/** Max features to generate concurrently */
|
||||
maxConcurrency: number;
|
||||
/** Default: skip tests during feature generation */
|
||||
defaultSkipTests: boolean;
|
||||
/** Default: enable dependency blocking */
|
||||
enableDependencyBlocking: boolean;
|
||||
/** Default: use git worktrees for feature branches */
|
||||
useWorktrees: boolean;
|
||||
/** Default: only show AI profiles (hide other settings) */
|
||||
showProfilesOnly: boolean;
|
||||
/** Default: planning approach (skip/lite/spec/full) */
|
||||
defaultPlanningMode: PlanningMode;
|
||||
/** Default: require manual approval before generating */
|
||||
defaultRequirePlanApproval: boolean;
|
||||
/** ID of currently selected AI profile (null = use built-in) */
|
||||
defaultAIProfileId: string | null;
|
||||
|
||||
// Audio Preferences
|
||||
/** Mute completion notification sound */
|
||||
muteDoneSound: boolean;
|
||||
|
||||
// AI Model Selection
|
||||
/** Which model to use for feature name/description enhancement */
|
||||
enhancementModel: AgentModel;
|
||||
|
||||
// Input Configuration
|
||||
/** User's keyboard shortcut bindings */
|
||||
keyboardShortcuts: KeyboardShortcuts;
|
||||
|
||||
// AI Profiles
|
||||
/** User-created AI profiles */
|
||||
aiProfiles: AIProfile[];
|
||||
|
||||
// Project Management
|
||||
/** List of active projects */
|
||||
projects: ProjectRef[];
|
||||
/** Projects in trash/recycle bin */
|
||||
trashedProjects: TrashedProjectRef[];
|
||||
/** History of recently opened project IDs */
|
||||
projectHistory: string[];
|
||||
/** Current position in project history for navigation */
|
||||
projectHistoryIndex: number;
|
||||
|
||||
// File Browser and UI Preferences
|
||||
/** Last directory opened in file picker */
|
||||
lastProjectDir?: string;
|
||||
/** Recently accessed folders for quick access */
|
||||
recentFolders: string[];
|
||||
/** Whether worktree panel is collapsed in current view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
|
||||
// Session Tracking
|
||||
/** Maps project path -> last selected session ID in that project */
|
||||
lastSelectedSessionByProject: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials - API keys stored in {DATA_DIR}/credentials.json
|
||||
*
|
||||
* Sensitive data stored separately from general settings.
|
||||
* Keys should never be exposed in UI or logs.
|
||||
*/
|
||||
export interface Credentials {
|
||||
/** Version number for schema migration */
|
||||
version: number;
|
||||
/** API keys for various providers */
|
||||
apiKeys: {
|
||||
/** Anthropic Claude API key */
|
||||
anthropic: string;
|
||||
/** Google API key (for embeddings or other services) */
|
||||
google: string;
|
||||
/** OpenAI API key (for compatibility or alternative providers) */
|
||||
openai: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BoardBackgroundSettings - Kanban board appearance customization
|
||||
*
|
||||
* Controls background images, opacity, borders, and visual effects for the board.
|
||||
*/
|
||||
export interface BoardBackgroundSettings {
|
||||
/** Path to background image file (null = no image) */
|
||||
imagePath: string | null;
|
||||
/** Version/timestamp of image for cache busting */
|
||||
imageVersion?: number;
|
||||
/** Opacity of cards (0-1) */
|
||||
cardOpacity: number;
|
||||
/** Opacity of columns (0-1) */
|
||||
columnOpacity: number;
|
||||
/** Show border around columns */
|
||||
columnBorderEnabled: boolean;
|
||||
/** Apply glassmorphism effect to cards */
|
||||
cardGlassmorphism: boolean;
|
||||
/** Show border around cards */
|
||||
cardBorderEnabled: boolean;
|
||||
/** Opacity of card borders (0-1) */
|
||||
cardBorderOpacity: number;
|
||||
/** Hide scrollbar in board view */
|
||||
hideScrollbar: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WorktreeInfo - Information about a git worktree
|
||||
*
|
||||
* Tracks worktree location, branch, and dirty state for project management.
|
||||
*/
|
||||
export interface WorktreeInfo {
|
||||
/** Absolute path to worktree directory */
|
||||
path: string;
|
||||
/** Branch checked out in this worktree */
|
||||
branch: string;
|
||||
/** Whether this is the main worktree */
|
||||
isMain: boolean;
|
||||
/** Whether worktree has uncommitted changes */
|
||||
hasChanges?: boolean;
|
||||
/** Number of files with changes */
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectSettings - Project-specific overrides stored in {projectPath}/.automaker/settings.json
|
||||
*
|
||||
* Allows per-project customization without affecting global settings.
|
||||
* All fields are optional - missing values fall back to global settings.
|
||||
*/
|
||||
export interface ProjectSettings {
|
||||
/** Version number for schema migration */
|
||||
version: number;
|
||||
|
||||
// Theme Configuration (project-specific override)
|
||||
/** Project theme (undefined = use global setting) */
|
||||
theme?: ThemeMode;
|
||||
|
||||
// Worktree Management
|
||||
/** Project-specific worktree preference override */
|
||||
useWorktrees?: boolean;
|
||||
/** Current worktree being used in this project */
|
||||
currentWorktree?: { path: string | null; branch: string };
|
||||
/** List of worktrees available in this project */
|
||||
worktrees?: WorktreeInfo[];
|
||||
|
||||
// Board Customization
|
||||
/** Project-specific board background settings */
|
||||
boardBackground?: BoardBackgroundSettings;
|
||||
|
||||
// Session Tracking
|
||||
/** Last chat session selected in this project */
|
||||
lastSelectedSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values and constants
|
||||
*/
|
||||
|
||||
/** Default keyboard shortcut bindings */
|
||||
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||
board: "K",
|
||||
agent: "A",
|
||||
spec: "D",
|
||||
context: "C",
|
||||
settings: "S",
|
||||
profiles: "M",
|
||||
terminal: "T",
|
||||
toggleSidebar: "`",
|
||||
addFeature: "N",
|
||||
addContextFile: "N",
|
||||
startNext: "G",
|
||||
newSession: "N",
|
||||
openProject: "O",
|
||||
projectPicker: "P",
|
||||
cyclePrevProject: "Q",
|
||||
cycleNextProject: "E",
|
||||
addProfile: "N",
|
||||
splitTerminalRight: "Alt+D",
|
||||
splitTerminalDown: "Alt+S",
|
||||
closeTerminal: "Alt+W",
|
||||
};
|
||||
|
||||
/** Default global settings used when no settings file exists */
|
||||
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
version: 1,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
chatHistoryOpen: false,
|
||||
kanbanCardDetailLevel: "standard",
|
||||
maxConcurrency: 3,
|
||||
defaultSkipTests: true,
|
||||
enableDependencyBlocking: true,
|
||||
useWorktrees: false,
|
||||
showProfilesOnly: false,
|
||||
defaultPlanningMode: "skip",
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultAIProfileId: null,
|
||||
muteDoneSound: false,
|
||||
enhancementModel: "sonnet",
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
aiProfiles: [],
|
||||
projects: [],
|
||||
trashedProjects: [],
|
||||
projectHistory: [],
|
||||
projectHistoryIndex: -1,
|
||||
lastProjectDir: undefined,
|
||||
recentFolders: [],
|
||||
worktreePanelCollapsed: false,
|
||||
lastSelectedSessionByProject: {},
|
||||
};
|
||||
|
||||
/** Default credentials (empty strings - user must provide API keys) */
|
||||
export const DEFAULT_CREDENTIALS: Credentials = {
|
||||
version: 1,
|
||||
apiKeys: {
|
||||
anthropic: "",
|
||||
google: "",
|
||||
openai: "",
|
||||
},
|
||||
};
|
||||
|
||||
/** Default project settings (empty - all settings are optional and fall back to global) */
|
||||
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
/** Current version of the global settings schema */
|
||||
export const SETTINGS_VERSION = 1;
|
||||
/** Current version of the credentials schema */
|
||||
export const CREDENTIALS_VERSION = 1;
|
||||
/** Current version of the project settings schema */
|
||||
export const PROJECT_SETTINGS_VERSION = 1;
|
||||
Reference in New Issue
Block a user