Merge branch 'main' into feat/claude-usage-clean

This commit is contained in:
Mohamad Yahia
2025-12-21 11:15:41 +04:00
committed by GitHub
63 changed files with 4984 additions and 3517 deletions

View File

@@ -9,45 +9,59 @@
* - Chat: Full tool access for interactive coding
*
* Uses model-resolver for consistent model handling across the application.
*
* SECURITY: All factory functions validate the working directory (cwd) against
* ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized
* security check that applies to ALL AI model invocations, regardless of provider.
*/
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import { resolveModelString } from "@automaker/model-resolver";
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from "@automaker/types";
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
/**
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
* This is the centralized security check for ALL AI model invocations.
*
* @param cwd - The working directory to validate
* @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY
*
* This function is called by all create*Options() factory functions to ensure
* that AI models can only operate within allowed directories. This applies to:
* - All current models (Claude, future models)
* - All invocation types (chat, auto-mode, spec generation, etc.)
*/
export function validateWorkingDirectory(cwd: string): void {
const resolvedCwd = path.resolve(cwd);
if (!isPathAllowed(resolvedCwd)) {
const allowedRoot = getAllowedRootDirectory();
throw new PathNotAllowedError(
`Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` +
(allowedRoot
? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
: 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.')
);
}
}
/**
* Tool presets for different use cases
*/
export const TOOL_PRESETS = {
/** Read-only tools for analysis */
readOnly: ["Read", "Glob", "Grep"] as const,
readOnly: ['Read', 'Glob', 'Grep'] as const,
/** Tools for spec generation that needs to read the codebase */
specGeneration: ["Read", "Glob", "Grep"] as const,
specGeneration: ['Read', 'Glob', 'Grep'] as const,
/** Full tool access for feature implementation */
fullAccess: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const,
fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const,
/** Tools for chat/interactive mode */
chat: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const,
chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const,
} as const;
/**
@@ -78,7 +92,7 @@ export const MAX_TURNS = {
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
*/
export function getModelForUseCase(
useCase: "spec" | "features" | "suggestions" | "chat" | "auto" | "default",
useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default',
explicitModel?: string
): string {
// Explicit model takes precedence
@@ -102,12 +116,12 @@ export function getModelForUseCase(
}
const defaultModels: Record<string, string> = {
spec: CLAUDE_MODEL_MAP["haiku"], // used to generate app specs
features: CLAUDE_MODEL_MAP["haiku"], // used to generate features from app specs
suggestions: CLAUDE_MODEL_MAP["haiku"], // used for suggestions
chat: CLAUDE_MODEL_MAP["haiku"], // used for chat
auto: CLAUDE_MODEL_MAP["opus"], // used to implement kanban cards
default: CLAUDE_MODEL_MAP["opus"],
spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs
features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs
suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions
chat: CLAUDE_MODEL_MAP['haiku'], // used for chat
auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards
default: CLAUDE_MODEL_MAP['opus'],
};
return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
@@ -118,7 +132,7 @@ export function getModelForUseCase(
*/
function getBaseOptions(): Partial<Options> {
return {
permissionMode: "acceptEdits",
permissionMode: 'acceptEdits',
};
}
@@ -143,7 +157,7 @@ export interface CreateSdkOptionsConfig {
/** Optional output format for structured outputs */
outputFormat?: {
type: "json_schema";
type: 'json_schema';
schema: Record<string, unknown>;
};
}
@@ -156,16 +170,17 @@ export interface CreateSdkOptionsConfig {
* - Extended turns for thorough exploration
* - Opus model by default (can be overridden)
*/
export function createSpecGenerationOptions(
config: CreateSdkOptionsConfig
): Options {
export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
return {
...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools
// Using "acceptEdits" can cause Claude to write files to unexpected locations
// See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: "default",
model: getModelForUseCase("spec", config.model),
permissionMode: 'default',
model: getModelForUseCase('spec', config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
@@ -183,14 +198,15 @@ export function createSpecGenerationOptions(
* - Quick turns since it's mostly JSON generation
* - Sonnet model by default for speed
*/
export function createFeatureGenerationOptions(
config: CreateSdkOptionsConfig
): Options {
export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
return {
...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools
permissionMode: "default",
model: getModelForUseCase("features", config.model),
permissionMode: 'default',
model: getModelForUseCase('features', config.model),
maxTurns: MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
@@ -207,12 +223,13 @@ export function createFeatureGenerationOptions(
* - Standard turns to allow thorough codebase exploration and structured output generation
* - Opus model by default for thorough analysis
*/
export function createSuggestionsOptions(
config: CreateSdkOptionsConfig
): Options {
export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
return {
...getBaseOptions(),
model: getModelForUseCase("suggestions", config.model),
model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
@@ -232,12 +249,15 @@ export function createSuggestionsOptions(
* - Sandbox enabled for bash safety
*/
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Model priority: explicit model > session model > chat default
const effectiveModel = config.model || config.sessionModel;
return {
...getBaseOptions(),
model: getModelForUseCase("chat", effectiveModel),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
@@ -260,9 +280,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Sandbox enabled for bash safety
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
return {
...getBaseOptions(),
model: getModelForUseCase("auto", config.model),
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
@@ -287,14 +310,15 @@ export function createCustomOptions(
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
}
): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
return {
...getBaseOptions(),
model: getModelForUseCase("default", config.model),
model: getModelForUseCase('default', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: config.allowedTools
? [...config.allowedTools]
: [...TOOL_PRESETS.readOnly],
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
...(config.sandbox && { sandbox: config.sandbox }),
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),

View File

@@ -3,8 +3,8 @@
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
*/
import * as fs from "fs/promises";
import * as path from "path";
import * as secureFs from './secure-fs.js';
import * as path from 'path';
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
@@ -32,11 +32,11 @@ function sanitizeBranchName(branch: string): string {
// - 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
.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);
@@ -44,7 +44,7 @@ function sanitizeBranchName(branch: string): string {
// 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"}`;
safeBranch = `_${safeBranch || 'branch'}`;
}
return safeBranch;
@@ -55,14 +55,14 @@ function sanitizeBranchName(branch: string): string {
*/
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
const safeBranch = sanitizeBranchName(branch);
return path.join(projectPath, ".automaker", "worktrees", safeBranch);
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");
return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json');
}
/**
@@ -74,7 +74,7 @@ export async function readWorktreeMetadata(
): Promise<WorktreeMetadata | null> {
try {
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = await fs.readFile(metadataPath, "utf-8");
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
// File doesn't exist or can't be read
@@ -94,10 +94,10 @@ export async function writeWorktreeMetadata(
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
// Ensure directory exists
await fs.mkdir(metadataDir, { recursive: true });
await secureFs.mkdir(metadataDir, { recursive: true });
// Write metadata
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
}
/**
@@ -143,16 +143,16 @@ export async function readAllWorktreeMetadata(
projectPath: string
): Promise<Map<string, WorktreeMetadata>> {
const result = new Map<string, WorktreeMetadata>();
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
const worktreesDir = path.join(projectPath, '.automaker', 'worktrees');
try {
const dirs = await fs.readdir(worktreesDir, { withFileTypes: true });
const dirs = await secureFs.readdir(worktreesDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const metadataPath = path.join(worktreesDir, dir.name, "worktree.json");
const metadataPath = path.join(worktreesDir, dir.name, 'worktree.json');
try {
const content = await fs.readFile(metadataPath, "utf-8");
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
const metadata = JSON.parse(content) as WorktreeMetadata;
result.set(metadata.branch, metadata);
} catch {
@@ -170,13 +170,10 @@ export async function readAllWorktreeMetadata(
/**
* Delete worktree metadata for a branch
*/
export async function deleteWorktreeMetadata(
projectPath: string,
branch: string
): Promise<void> {
export async function deleteWorktreeMetadata(projectPath: string, branch: string): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
try {
await fs.rm(metadataDir, { recursive: true, force: true });
await secureFs.rm(metadataDir, { recursive: true, force: true });
} catch {
// Ignore errors if directory doesn't exist
}

View File

@@ -2,16 +2,16 @@
* Generate features from existing app_spec.txt
*/
import { query } from "@anthropic-ai/claude-agent-sdk";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "@automaker/utils";
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
import { getAppSpecPath } from "@automaker/platform";
import { query } from '@anthropic-ai/claude-agent-sdk';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
const logger = createLogger("SpecRegeneration");
const logger = createLogger('SpecRegeneration');
const DEFAULT_MAX_FEATURES = 50;
@@ -22,28 +22,26 @@ export async function generateFeaturesFromSpec(
maxFeatures?: number
): Promise<void> {
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
logger.debug("========== generateFeaturesFromSpec() started ==========");
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", featureCount);
logger.debug('========== generateFeaturesFromSpec() started ==========');
logger.debug('projectPath:', projectPath);
logger.debug('maxFeatures:', featureCount);
// Read existing spec from .automaker directory
const specPath = getAppSpecPath(projectPath);
let spec: string;
logger.debug("Reading spec from:", specPath);
logger.debug('Reading spec from:', specPath);
try {
spec = await fs.readFile(specPath, "utf-8");
spec = (await secureFs.readFile(specPath, 'utf-8')) as string;
logger.info(`Spec loaded successfully (${spec.length} chars)`);
logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`);
logger.info(
`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`
);
logger.info(`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`);
} catch (readError) {
logger.error("❌ Failed to read spec file:", readError);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: "No project spec found. Generate spec first.",
logger.error('❌ Failed to read spec file:', readError);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: 'No project spec found. Generate spec first.',
projectPath: projectPath,
});
return;
@@ -82,16 +80,14 @@ Generate ${featureCount} features that build on each other logically.
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
logger.info("========== PROMPT BEING SENT ==========");
logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`);
logger.info(
`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`
);
logger.info("========== END PROMPT PREVIEW ==========");
logger.info(`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`);
logger.info('========== END PROMPT PREVIEW ==========');
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: "Analyzing spec and generating features...\n",
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Analyzing spec and generating features...\n',
projectPath: projectPath,
});
@@ -100,73 +96,67 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
abortController,
});
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
logger.info("Calling Claude Agent SDK query() for features...");
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query() for features...');
logAuthStatus("Right before SDK query() for features");
logAuthStatus('Right before SDK query() for features');
let stream;
try {
stream = query({ prompt, options });
logger.debug("query() returned stream successfully");
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error("❌ query() threw an exception:");
logger.error("Error:", queryError);
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
}
let responseText = "";
let responseText = '';
let messageCount = 0;
logger.debug("Starting to iterate over feature stream...");
logger.debug('Starting to iterate over feature stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.debug(
`Feature stream message #${messageCount}:`,
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
);
if (msg.type === "assistant" && msg.message.content) {
if (msg.type === 'assistant' && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
if (block.type === 'text') {
responseText += block.text;
logger.debug(
`Feature text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
logger.debug(`Feature text block received (${block.text.length} chars)`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
}
}
} else if (msg.type === "result" && (msg as any).subtype === "success") {
logger.debug("Received success result for features");
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.debug('Received success result for features');
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
logger.error("❌ Received error message from feature stream:");
logger.error("Error message:", JSON.stringify(msg, null, 2));
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from feature stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
}
}
} catch (streamError) {
logger.error("❌ Error while iterating feature stream:");
logger.error("Stream error:", streamError);
logger.error('❌ Error while iterating feature stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info("========== FULL RESPONSE TEXT ==========");
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info("========== END RESPONSE TEXT ==========");
logger.info('========== END RESPONSE TEXT ==========');
await parseAndCreateFeatures(projectPath, responseText, events);
logger.debug("========== generateFeaturesFromSpec() completed ==========");
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -2,23 +2,23 @@
* Generate app_spec.txt from project overview
*/
import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { query } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import {
specOutputSchema,
specToXml,
getStructuredSpecPromptInstruction,
type SpecOutput,
} from "../../lib/app-spec-format.js";
import { createLogger } from "@automaker/utils";
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
import { ensureAutomakerDir, getAppSpecPath } from "@automaker/platform";
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
const logger = createLogger("SpecRegeneration");
const logger = createLogger('SpecRegeneration');
export async function generateSpec(
projectPath: string,
@@ -29,17 +29,17 @@ export async function generateSpec(
analyzeProject?: boolean,
maxFeatures?: number
): Promise<void> {
logger.info("========== generateSpec() started ==========");
logger.info("projectPath:", projectPath);
logger.info("projectOverview length:", `${projectOverview.length} chars`);
logger.info("projectOverview preview:", projectOverview.substring(0, 300));
logger.info("generateFeatures:", generateFeatures);
logger.info("analyzeProject:", analyzeProject);
logger.info("maxFeatures:", maxFeatures);
logger.info('========== generateSpec() started ==========');
logger.info('projectPath:', projectPath);
logger.info('projectOverview length:', `${projectOverview.length} chars`);
logger.info('projectOverview preview:', projectOverview.substring(0, 300));
logger.info('generateFeatures:', generateFeatures);
logger.info('analyzeProject:', analyzeProject);
logger.info('maxFeatures:', maxFeatures);
// Build the prompt based on whether we should analyze the project
let analysisInstructions = "";
let techStackDefaults = "";
let analysisInstructions = '';
let techStackDefaults = '';
if (analyzeProject !== false) {
// Default to true - analyze the project
@@ -73,114 +73,110 @@ ${analysisInstructions}
${getStructuredSpecPromptInstruction()}`;
logger.info("========== PROMPT BEING SENT ==========");
logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`);
logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`);
logger.info("========== END PROMPT PREVIEW ==========");
logger.info('========== END PROMPT PREVIEW ==========');
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: "Starting spec generation...\n",
events.emit('spec-regeneration:event', {
type: 'spec_progress',
content: 'Starting spec generation...\n',
});
const options = createSpecGenerationOptions({
cwd: projectPath,
abortController,
outputFormat: {
type: "json_schema",
type: 'json_schema',
schema: specOutputSchema,
},
});
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
logger.info("Calling Claude Agent SDK query()...");
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
logger.info('Calling Claude Agent SDK query()...');
// Log auth status right before the SDK call
logAuthStatus("Right before SDK query()");
logAuthStatus('Right before SDK query()');
let stream;
try {
stream = query({ prompt, options });
logger.debug("query() returned stream successfully");
logger.debug('query() returned stream successfully');
} catch (queryError) {
logger.error("❌ query() threw an exception:");
logger.error("Error:", queryError);
logger.error('❌ query() threw an exception:');
logger.error('Error:', queryError);
throw queryError;
}
let responseText = "";
let responseText = '';
let messageCount = 0;
let structuredOutput: SpecOutput | null = null;
logger.info("Starting to iterate over stream...");
logger.info('Starting to iterate over stream...');
try {
for await (const msg of stream) {
messageCount++;
logger.info(
`Stream message #${messageCount}: type=${msg.type}, subtype=${
(msg as any).subtype
}`
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
);
if (msg.type === "assistant") {
if (msg.type === 'assistant') {
const msgAny = msg as any;
if (msgAny.message?.content) {
for (const block of msgAny.message.content) {
if (block.type === "text") {
if (block.type === 'text') {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: block.text,
projectPath: projectPath,
});
} else if (block.type === "tool_use") {
logger.info("Tool use:", block.name);
events.emit("spec-regeneration:event", {
type: "spec_tool",
} else if (block.type === 'tool_use') {
logger.info('Tool use:', block.name);
events.emit('spec-regeneration:event', {
type: 'spec_tool',
tool: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === "result" && (msg as any).subtype === "success") {
logger.info("Received success result");
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
logger.info('Received success result');
// Check for structured output - this is the reliable way to get spec data
const resultMsg = msg as any;
if (resultMsg.structured_output) {
structuredOutput = resultMsg.structured_output as SpecOutput;
logger.info("✅ Received structured output");
logger.debug("Structured output:", JSON.stringify(structuredOutput, null, 2));
logger.info('✅ Received structured output');
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
} else {
logger.warn("⚠️ No structured output in result, will fall back to text parsing");
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
}
} else if (msg.type === "result") {
} else if (msg.type === 'result') {
// Handle error result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === "error_max_turns") {
logger.error("❌ Hit max turns limit!");
} else if (subtype === "error_max_structured_output_retries") {
logger.error("❌ Failed to produce valid structured output after retries");
throw new Error("Could not produce valid spec output");
if (subtype === 'error_max_turns') {
logger.error('❌ Hit max turns limit!');
} else if (subtype === 'error_max_structured_output_retries') {
logger.error('❌ Failed to produce valid structured output after retries');
throw new Error('Could not produce valid spec output');
}
} else if ((msg as { type: string }).type === "error") {
logger.error("❌ Received error message from stream:");
logger.error("Error message:", JSON.stringify(msg, null, 2));
} else if (msg.type === "user") {
} else if ((msg as { type: string }).type === 'error') {
logger.error('❌ Received error message from stream:');
logger.error('Error message:', JSON.stringify(msg, null, 2));
} else if (msg.type === 'user') {
// Log user messages (tool results)
logger.info(
`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`
);
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
}
}
} catch (streamError) {
logger.error("❌ Error while iterating stream:");
logger.error("Stream error:", streamError);
logger.error('❌ Error while iterating stream:');
logger.error('Stream error:', streamError);
throw streamError;
}
@@ -192,40 +188,42 @@ ${getStructuredSpecPromptInstruction()}`;
if (structuredOutput) {
// Use structured output - convert JSON to XML
logger.info("✅ Using structured output for XML generation");
logger.info('✅ Using structured output for XML generation');
xmlContent = specToXml(structuredOutput);
logger.info(`Generated XML from structured output: ${xmlContent.length} chars`);
} else {
// Fallback: Extract XML content from response text
// Claude might include conversational text before/after
// See: https://github.com/AutoMaker-Org/automaker/issues/149
logger.warn("⚠️ No structured output, falling back to text parsing");
logger.info("========== FINAL RESPONSE TEXT ==========");
logger.info(responseText || "(empty)");
logger.info("========== END RESPONSE TEXT ==========");
logger.warn('⚠️ No structured output, falling back to text parsing');
logger.info('========== FINAL RESPONSE TEXT ==========');
logger.info(responseText || '(empty)');
logger.info('========== END RESPONSE TEXT ==========');
if (!responseText || responseText.trim().length === 0) {
throw new Error("No response text and no structured output - cannot generate spec");
throw new Error('No response text and no structured output - cannot generate spec');
}
const xmlStart = responseText.indexOf("<project_specification>");
const xmlEnd = responseText.lastIndexOf("</project_specification>");
const xmlStart = responseText.indexOf('<project_specification>');
const xmlEnd = responseText.lastIndexOf('</project_specification>');
if (xmlStart !== -1 && xmlEnd !== -1) {
// Extract just the XML content, discarding any conversational text before/after
xmlContent = responseText.substring(xmlStart, xmlEnd + "</project_specification>".length);
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
} else {
// No valid XML structure found in the response text
// This happens when structured output was expected but not received, and the agent
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
// We should NOT save this conversational text as it's not a valid spec
logger.error("❌ Response does not contain valid <project_specification> XML structure");
logger.error("This typically happens when structured output failed and the agent produced conversational text instead of XML");
logger.error('❌ Response does not contain valid <project_specification> XML structure');
logger.error(
'This typically happens when structured output failed and the agent produced conversational text instead of XML'
);
throw new Error(
"Failed to generate spec: No valid XML structure found in response. " +
"The response contained conversational text but no <project_specification> tags. " +
"Please try again."
'Failed to generate spec: No valid XML structure found in response. ' +
'The response contained conversational text but no <project_specification> tags. ' +
'Please try again.'
);
}
}
@@ -234,60 +232,55 @@ ${getStructuredSpecPromptInstruction()}`;
await ensureAutomakerDir(projectPath);
const specPath = getAppSpecPath(projectPath);
logger.info("Saving spec to:", specPath);
logger.info('Saving spec to:', specPath);
logger.info(`Content to save (${xmlContent.length} chars)`);
await fs.writeFile(specPath, xmlContent);
await secureFs.writeFile(specPath, xmlContent);
// Verify the file was written
const savedContent = await fs.readFile(specPath, "utf-8");
const savedContent = await secureFs.readFile(specPath, 'utf-8');
logger.info(`Verified saved file: ${savedContent.length} chars`);
if (savedContent.length === 0) {
logger.error("❌ File was saved but is empty!");
logger.error('❌ File was saved but is empty!');
}
logger.info("Spec saved successfully");
logger.info('Spec saved successfully');
// Emit spec completion event
if (generateFeatures) {
// If features will be generated, emit intermediate completion
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: "[Phase: spec_complete] Spec created! Generating features...\n",
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: '[Phase: spec_complete] Spec created! Generating features...\n',
projectPath: projectPath,
});
} else {
// If no features, emit final completion
events.emit("spec-regeneration:event", {
type: "spec_regeneration_complete",
message: "Spec regeneration complete!",
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
message: 'Spec regeneration complete!',
projectPath: projectPath,
});
}
// If generate features was requested, generate them from the spec
if (generateFeatures) {
logger.info("Starting feature generation from spec...");
logger.info('Starting feature generation from spec...');
// Create a new abort controller for feature generation
const featureAbortController = new AbortController();
try {
await generateFeaturesFromSpec(
projectPath,
events,
featureAbortController,
maxFeatures
);
await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (featureError) {
logger.error("Feature generation failed:", featureError);
logger.error('Feature generation failed:', featureError);
// Don't throw - spec generation succeeded, feature generation is optional
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
error: (featureError as Error).message || "Feature generation failed",
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: (featureError as Error).message || 'Feature generation failed',
projectPath: projectPath,
});
}
}
logger.debug("========== generateSpec() completed ==========");
logger.debug('========== generateSpec() completed ==========');
}

View File

@@ -2,71 +2,71 @@
* Parse agent response and create feature files
*/
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "@automaker/utils";
import { getFeaturesDir } from "@automaker/platform";
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform';
const logger = createLogger("SpecRegeneration");
const logger = createLogger('SpecRegeneration');
export async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
): Promise<void> {
logger.info("========== parseAndCreateFeatures() started ==========");
logger.info('========== parseAndCreateFeatures() started ==========');
logger.info(`Content length: ${content.length} chars`);
logger.info("========== CONTENT RECEIVED FOR PARSING ==========");
logger.info('========== CONTENT RECEIVED FOR PARSING ==========');
logger.info(content);
logger.info("========== END CONTENT ==========");
logger.info('========== END CONTENT ==========');
try {
// Extract JSON from response
logger.info("Extracting JSON from response...");
logger.info('Extracting JSON from response...');
logger.info(`Looking for pattern: /{[\\s\\S]*"features"[\\s\\S]*}/`);
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
if (!jsonMatch) {
logger.error("❌ No valid JSON found in response");
logger.error("Full content received:");
logger.error('❌ No valid JSON found in response');
logger.error('Full content received:');
logger.error(content);
throw new Error("No valid JSON found in response");
throw new Error('No valid JSON found in response');
}
logger.info(`JSON match found (${jsonMatch[0].length} chars)`);
logger.info("========== MATCHED JSON ==========");
logger.info('========== MATCHED JSON ==========');
logger.info(jsonMatch[0]);
logger.info("========== END MATCHED JSON ==========");
logger.info('========== END MATCHED JSON ==========');
const parsed = JSON.parse(jsonMatch[0]);
logger.info(`Parsed ${parsed.features?.length || 0} features`);
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2));
const featuresDir = getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
await secureFs.mkdir(featuresDir, { recursive: true });
const createdFeatures: Array<{ id: string; title: string }> = [];
for (const feature of parsed.features) {
logger.debug("Creating feature:", feature.id);
logger.debug('Creating feature:', feature.id);
const featureDir = path.join(featuresDir, feature.id);
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
const featureData = {
id: feature.id,
category: feature.category || "Uncategorized",
category: feature.category || 'Uncategorized',
title: feature.title,
description: feature.description,
status: "backlog", // Features go to backlog - user must manually start them
status: 'backlog', // Features go to backlog - user must manually start them
priority: feature.priority || 2,
complexity: feature.complexity || "moderate",
complexity: feature.complexity || 'moderate',
dependencies: feature.dependencies || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await fs.writeFile(
path.join(featureDir, "feature.json"),
await secureFs.writeFile(
path.join(featureDir, 'feature.json'),
JSON.stringify(featureData, null, 2)
);
@@ -75,20 +75,20 @@ export async function parseAndCreateFeatures(
logger.info(`✓ Created ${createdFeatures.length} features successfully`);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_complete",
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath,
});
} catch (error) {
logger.error("❌ parseAndCreateFeatures() failed:");
logger.error("Error:", error);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_error",
logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: (error as Error).message,
projectPath: projectPath,
});
}
logger.debug("========== parseAndCreateFeatures() completed ==========");
logger.debug('========== parseAndCreateFeatures() completed ==========');
}

View File

@@ -2,16 +2,12 @@
* POST /browse endpoint - Browse directories for file browser UI
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import os from "os";
import path from "path";
import {
getAllowedRootDirectory,
isPathAllowed,
PathNotAllowedError,
} from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createBrowseHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -22,24 +18,19 @@ export function createBrowseHandler() {
const defaultPath = getAllowedRootDirectory() || os.homedir();
const targetPath = dirPath ? path.resolve(dirPath) : defaultPath;
// Validate that the path is allowed
if (!isPathAllowed(targetPath)) {
throw new PathNotAllowedError(dirPath || targetPath);
}
// Detect available drives on Windows
const detectDrives = async (): Promise<string[]> => {
if (os.platform() !== "win32") {
if (os.platform() !== 'win32') {
return [];
}
const drives: string[] = [];
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (const letter of letters) {
const drivePath = `${letter}:\\`;
try {
await fs.access(drivePath);
await secureFs.access(drivePath);
drives.push(drivePath);
} catch {
// Drive doesn't exist, skip it
@@ -57,21 +48,19 @@ export function createBrowseHandler() {
const drives = await detectDrives();
try {
const stats = await fs.stat(targetPath);
const stats = await secureFs.stat(targetPath);
if (!stats.isDirectory()) {
res
.status(400)
.json({ success: false, error: "Path is not a directory" });
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
// Read directory contents
const entries = await fs.readdir(targetPath, { withFileTypes: true });
const entries = await secureFs.readdir(targetPath, { withFileTypes: true });
// Filter for directories only and add parent directory option
const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => ({
name: entry.name,
path: path.join(targetPath, entry.name),
@@ -87,10 +76,8 @@ export function createBrowseHandler() {
});
} catch (error) {
// Handle permission errors gracefully - still return path info so user can navigate away
const errorMessage =
error instanceof Error ? error.message : "Failed to read directory";
const isPermissionError =
errorMessage.includes("EPERM") || errorMessage.includes("EACCES");
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
if (isPermissionError) {
// Return success with empty directories so user can still navigate to parent
@@ -101,7 +88,7 @@ export function createBrowseHandler() {
directories: [],
drives,
warning:
"Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security",
'Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security',
});
} else {
res.status(400).json({
@@ -117,7 +104,7 @@ export function createBrowseHandler() {
return;
}
logError(error, "Browse directories failed");
logError(error, 'Browse directories failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /delete-board-background endpoint - Delete board background image
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "@automaker/platform";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getBoardDir } from '@automaker/platform';
export function createDeleteBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -16,7 +16,7 @@ export function createDeleteBoardBackgroundHandler() {
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
error: 'projectPath is required',
});
return;
}
@@ -26,10 +26,10 @@ export function createDeleteBoardBackgroundHandler() {
try {
// Try to remove all background files in the board directory
const files = await fs.readdir(boardDir);
const files = await secureFs.readdir(boardDir);
for (const file of files) {
if (file.startsWith("background")) {
await fs.unlink(path.join(boardDir, file));
if (file.startsWith('background')) {
await secureFs.unlink(path.join(boardDir, file));
}
}
} catch {
@@ -38,7 +38,7 @@ export function createDeleteBoardBackgroundHandler() {
res.json({ success: true });
} catch (error) {
logError(error, "Delete board background failed");
logError(error, 'Delete board background failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,10 @@
* POST /delete endpoint - Delete file
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,12 +13,11 @@ export function createDeleteHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
await fs.rm(resolvedPath, { recursive: true });
await secureFs.rm(filePath, { recursive: true });
res.json({ success: true });
} catch (error) {
@@ -28,7 +27,7 @@ export function createDeleteHandler() {
return;
}
logError(error, "Delete file failed");
logError(error, 'Delete file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,10 @@
* POST /exists endpoint - Check if file/directory exists
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createExistsHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -14,21 +13,18 @@ export function createExistsHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = path.resolve(filePath);
// Validate that the path is allowed
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(filePath);
}
try {
await fs.access(resolvedPath);
await secureFs.access(filePath);
res.json({ success: true, exists: true });
} catch {
} catch (accessError) {
// Check if it's a path not allowed error vs file not existing
if (accessError instanceof PathNotAllowedError) {
throw accessError;
}
res.json({ success: true, exists: false });
}
} catch (error) {
@@ -38,7 +34,7 @@ export function createExistsHandler() {
return;
}
logError(error, "Check exists failed");
logError(error, 'Check exists failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,11 @@
* GET /image endpoint - Serve image files
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -16,7 +17,7 @@ export function createImageHandler() {
};
if (!imagePath) {
res.status(400).json({ success: false, error: "path is required" });
res.status(400).json({ success: false, error: 'path is required' });
return;
}
@@ -24,40 +25,41 @@ export function createImageHandler() {
const fullPath = path.isAbsolute(imagePath)
? imagePath
: projectPath
? path.join(projectPath, imagePath)
: imagePath;
? path.join(projectPath, imagePath)
: imagePath;
// Check if file exists
try {
await fs.access(fullPath);
} catch {
res.status(404).json({ success: false, error: "Image not found" });
await secureFs.access(fullPath);
} catch (accessError) {
if (accessError instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: 'Path not allowed' });
return;
}
res.status(404).json({ success: false, error: 'Image not found' });
return;
}
// Read the file
const buffer = await fs.readFile(fullPath);
const buffer = await secureFs.readFile(fullPath);
// Determine MIME type from extension
const ext = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
};
res.setHeader(
"Content-Type",
mimeTypes[ext] || "application/octet-stream"
);
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(buffer);
} catch (error) {
logError(error, "Serve image failed");
logError(error, 'Serve image failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -3,11 +3,11 @@
* Handles symlinks safely to avoid ELOOP errors
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createMkdirHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -15,20 +15,15 @@ export function createMkdirHandler() {
const { dirPath } = req.body as { dirPath: string };
if (!dirPath) {
res.status(400).json({ success: false, error: "dirPath is required" });
res.status(400).json({ success: false, error: 'dirPath is required' });
return;
}
const resolvedPath = path.resolve(dirPath);
// Validate that the path is allowed
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(dirPath);
}
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
const stats = await secureFs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
res.json({ success: true });
@@ -37,19 +32,19 @@ export function createMkdirHandler() {
// It's a file - can't create directory
res.status(400).json({
success: false,
error: "Path exists and is not a directory",
error: 'Path exists and is not a directory',
});
return;
} catch (statError: any) {
// ENOENT means path doesn't exist - we should create it
if (statError.code !== "ENOENT") {
if (statError.code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
throw statError;
}
}
// Path doesn't exist, create it
await fs.mkdir(resolvedPath, { recursive: true });
await secureFs.mkdir(resolvedPath, { recursive: true });
res.json({ success: true });
} catch (error: any) {
@@ -60,15 +55,15 @@ export function createMkdirHandler() {
}
// Handle ELOOP specifically
if (error.code === "ELOOP") {
logError(error, "Create directory failed - symlink loop detected");
if (error.code === 'ELOOP') {
logError(error, 'Create directory failed - symlink loop detected');
res.status(400).json({
success: false,
error: "Cannot create directory: symlink loop detected in path",
error: 'Cannot create directory: symlink loop detected in path',
});
return;
}
logError(error, "Create directory failed");
logError(error, 'Create directory failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,26 +2,21 @@
* POST /read endpoint - Read file
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ["categories.json", "app_spec.txt"];
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
}
function isENOENT(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}
export function createReadHandler() {
@@ -30,12 +25,11 @@ export function createReadHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
const content = await fs.readFile(resolvedPath, "utf-8");
const content = await secureFs.readFile(filePath, 'utf-8');
res.json({ success: true, content });
} catch (error) {
@@ -46,9 +40,9 @@ export function createReadHandler() {
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
if (shouldLog) {
logError(error, "Read file failed");
logError(error, 'Read file failed');
}
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -2,10 +2,10 @@
* POST /readdir endpoint - Read directory
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createReaddirHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,12 +13,11 @@ export function createReaddirHandler() {
const { dirPath } = req.body as { dirPath: string };
if (!dirPath) {
res.status(400).json({ success: false, error: "dirPath is required" });
res.status(400).json({ success: false, error: 'dirPath is required' });
return;
}
const resolvedPath = validatePath(dirPath);
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
const result = entries.map((entry) => ({
name: entry.name,
@@ -34,7 +33,7 @@ export function createReaddirHandler() {
return;
}
logError(error, "Read directory failed");
logError(error, 'Read directory failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,10 @@
* POST /resolve-directory endpoint - Resolve directory path from directory name
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
export function createResolveDirectoryHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -17,9 +17,7 @@ export function createResolveDirectoryHandler() {
};
if (!directoryName) {
res
.status(400)
.json({ success: false, error: "directoryName is required" });
res.status(400).json({ success: false, error: 'directoryName is required' });
return;
}
@@ -27,7 +25,7 @@ export function createResolveDirectoryHandler() {
if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) {
try {
const resolvedPath = path.resolve(directoryName);
const stats = await fs.stat(resolvedPath);
const stats = await secureFs.stat(resolvedPath);
if (stats.isDirectory()) {
res.json({
success: true,
@@ -43,17 +41,11 @@ export function createResolveDirectoryHandler() {
// Search for directory in common locations
const searchPaths: string[] = [
process.cwd(), // Current working directory
process.env.HOME || process.env.USERPROFILE || "", // User home
path.join(
process.env.HOME || process.env.USERPROFILE || "",
"Documents"
),
path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"),
process.env.HOME || process.env.USERPROFILE || '', // User home
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Documents'),
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Desktop'),
// Common project locations
path.join(
process.env.HOME || process.env.USERPROFILE || "",
"Projects"
),
path.join(process.env.HOME || process.env.USERPROFILE || '', 'Projects'),
].filter(Boolean);
// Also check parent of current working directory
@@ -70,7 +62,7 @@ export function createResolveDirectoryHandler() {
for (const searchPath of searchPaths) {
try {
const candidatePath = path.join(searchPath, directoryName);
const stats = await fs.stat(candidatePath);
const stats = await secureFs.stat(candidatePath);
if (stats.isDirectory()) {
// Verify it matches by checking for sample files
@@ -78,15 +70,15 @@ export function createResolveDirectoryHandler() {
let matches = 0;
for (const sampleFile of sampleFiles.slice(0, 5)) {
// Remove directory name prefix from sample file path
const relativeFile = sampleFile.startsWith(directoryName + "/")
const relativeFile = sampleFile.startsWith(directoryName + '/')
? sampleFile.substring(directoryName.length + 1)
: sampleFile.split("/").slice(1).join("/") ||
sampleFile.split("/").pop() ||
: sampleFile.split('/').slice(1).join('/') ||
sampleFile.split('/').pop() ||
sampleFile;
try {
const filePath = path.join(candidatePath, relativeFile);
await fs.access(filePath);
await secureFs.access(filePath);
matches++;
} catch {
// File doesn't exist, continue checking
@@ -118,7 +110,7 @@ export function createResolveDirectoryHandler() {
error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`,
});
} catch (error) {
logError(error, "Resolve directory failed");
logError(error, 'Resolve directory failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /save-board-background endpoint - Save board background image
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "@automaker/platform";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getBoardDir } from '@automaker/platform';
export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -21,31 +21,31 @@ export function createSaveBoardBackgroundHandler() {
if (!data || !filename || !projectPath) {
res.status(400).json({
success: false,
error: "data, filename, and projectPath are required",
error: 'data, filename, and projectPath are required',
});
return;
}
// Get board directory
const boardDir = getBoardDir(projectPath);
await fs.mkdir(boardDir, { recursive: true });
await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous)
const ext = path.extname(filename) || ".png";
const ext = path.extname(filename) || '.png';
const uniqueFilename = `background${ext}`;
const filePath = path.join(boardDir, uniqueFilename);
// Write file
await fs.writeFile(filePath, buffer);
await secureFs.writeFile(filePath, buffer);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {
logError(error, "Save board background failed");
logError(error, 'Save board background failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /save-image endpoint - Save image to .automaker images directory
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import { getImagesDir } from "@automaker/platform";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getImagesDir } from '@automaker/platform';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -21,33 +21,33 @@ export function createSaveImageHandler() {
if (!data || !filename || !projectPath) {
res.status(400).json({
success: false,
error: "data, filename, and projectPath are required",
error: 'data, filename, and projectPath are required',
});
return;
}
// Get images directory
const imagesDir = getImagesDir(projectPath);
await fs.mkdir(imagesDir, { recursive: true });
await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp
const timestamp = Date.now();
const ext = path.extname(filename) || ".png";
const ext = path.extname(filename) || '.png';
const baseName = path.basename(filename, ext);
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
const filePath = path.join(imagesDir, uniqueFilename);
// Write file
await fs.writeFile(filePath, buffer);
await secureFs.writeFile(filePath, buffer);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {
logError(error, "Save image failed");
logError(error, 'Save image failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,10 +2,10 @@
* POST /stat endpoint - Get file stats
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createStatHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -13,12 +13,11 @@ export function createStatHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
const stats = await fs.stat(resolvedPath);
const stats = await secureFs.stat(filePath);
res.json({
success: true,
@@ -36,7 +35,7 @@ export function createStatHandler() {
return;
}
logError(error, "Get file stats failed");
logError(error, 'Get file stats failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /validate-path endpoint - Validate and add path to allowed list
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { isPathAllowed } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -14,7 +14,7 @@ export function createValidatePathHandler() {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
@@ -22,12 +22,10 @@ export function createValidatePathHandler() {
// Check if path exists
try {
const stats = await fs.stat(resolvedPath);
const stats = await secureFs.stat(resolvedPath);
if (!stats.isDirectory()) {
res
.status(400)
.json({ success: false, error: "Path is not a directory" });
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
@@ -37,10 +35,10 @@ export function createValidatePathHandler() {
isAllowed: isPathAllowed(resolvedPath),
});
} catch {
res.status(400).json({ success: false, error: "Path does not exist" });
res.status(400).json({ success: false, error: 'Path does not exist' });
}
} catch (error) {
logError(error, "Validate path failed");
logError(error, 'Validate path failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /write endpoint - Write file
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { validatePath, PathNotAllowedError } from "@automaker/platform";
import { mkdirSafe } from "@automaker/utils";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
export function createWriteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -18,15 +18,13 @@ export function createWriteHandler() {
};
if (!filePath) {
res.status(400).json({ success: false, error: "filePath is required" });
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const resolvedPath = validatePath(filePath);
// Ensure parent directory exists (symlink-safe)
await mkdirSafe(path.dirname(resolvedPath));
await fs.writeFile(resolvedPath, content, "utf-8");
await mkdirSafe(path.dirname(path.resolve(filePath)));
await secureFs.writeFile(filePath, content, 'utf-8');
res.json({ success: true });
} catch (error) {
@@ -36,7 +34,7 @@ export function createWriteHandler() {
return;
}
logError(error, "Write file failed");
logError(error, 'Write file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /clone endpoint - Clone a GitHub template to a new project directory
*/
import type { Request, Response } from "express";
import { spawn } from "child_process";
import path from "path";
import fs from "fs/promises";
import { isPathAllowed } from "@automaker/platform";
import { logger, getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import { spawn } from 'child_process';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { logger, getErrorMessage, logError } from '../common.js';
export function createCloneHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -22,7 +22,7 @@ export function createCloneHandler() {
if (!repoUrl || !projectName || !parentDir) {
res.status(400).json({
success: false,
error: "repoUrl, projectName, and parentDir are required",
error: 'repoUrl, projectName, and parentDir are required',
});
return;
}
@@ -36,17 +36,15 @@ export function createCloneHandler() {
if (!githubUrlPattern.test(repoUrl)) {
res.status(400).json({
success: false,
error: "Invalid GitHub repository URL",
error: 'Invalid GitHub repository URL',
});
return;
}
// Sanitize project name (allow alphanumeric, dash, underscore)
const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-");
const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, '-');
if (sanitizedName !== projectName) {
logger.info(
`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`
);
logger.info(`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`);
}
// Build full project path
@@ -55,41 +53,30 @@ export function createCloneHandler() {
const resolvedParent = path.resolve(parentDir);
const resolvedProject = path.resolve(projectPath);
const relativePath = path.relative(resolvedParent, resolvedProject);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
res.status(400).json({
success: false,
error: "Invalid project name; potential path traversal attempt.",
error: 'Invalid project name; potential path traversal attempt.',
});
return;
}
// Validate that parent directory is within allowed root directory
if (!isPathAllowed(resolvedParent)) {
res.status(403).json({
success: false,
error: `Parent directory not allowed: ${parentDir}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Validate that project path will be within allowed root directory
if (!isPathAllowed(resolvedProject)) {
res.status(403).json({
success: false,
error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Check if directory already exists
// Check if directory already exists (secureFs.access also validates path is allowed)
try {
await fs.access(projectPath);
await secureFs.access(projectPath);
res.status(400).json({
success: false,
error: `Directory "${sanitizedName}" already exists in ${parentDir}`,
});
return;
} catch {
} catch (accessError) {
if (accessError instanceof PathNotAllowedError) {
res.status(403).json({
success: false,
error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Directory doesn't exist, which is what we want
}
@@ -97,35 +84,33 @@ export function createCloneHandler() {
try {
// Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /)
const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir);
const isUnixRoot = parentDir === "/" || parentDir === "";
const isUnixRoot = parentDir === '/' || parentDir === '';
const isRoot = isWindowsRoot || isUnixRoot;
if (isRoot) {
// Root paths always exist, just verify access
logger.info(`[Templates] Using root path: ${parentDir}`);
await fs.access(parentDir);
await secureFs.access(parentDir);
} else {
// Check if parent directory exists
const parentExists = await fs
.access(parentDir)
.then(() => true)
.catch(() => false);
let parentExists = false;
try {
await secureFs.access(parentDir);
parentExists = true;
} catch {
parentExists = false;
}
if (!parentExists) {
logger.info(`[Templates] Creating parent directory: ${parentDir}`);
await fs.mkdir(parentDir, { recursive: true });
await secureFs.mkdir(parentDir, { recursive: true });
} else {
logger.info(`[Templates] Parent directory exists: ${parentDir}`);
}
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
"[Templates] Failed to access parent directory:",
parentDir,
error
);
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('[Templates] Failed to access parent directory:', parentDir, error);
res.status(500).json({
success: false,
error: `Failed to access parent directory: ${errorMessage}`,
@@ -140,17 +125,17 @@ export function createCloneHandler() {
success: boolean;
error?: string;
}>((resolve) => {
const gitProcess = spawn("git", ["clone", repoUrl, projectPath], {
const gitProcess = spawn('git', ['clone', repoUrl, projectPath], {
cwd: parentDir,
});
let stderr = "";
let stderr = '';
gitProcess.stderr.on("data", (data) => {
gitProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
gitProcess.on("close", (code) => {
gitProcess.on('close', (code) => {
if (code === 0) {
resolve({ success: true });
} else {
@@ -161,7 +146,7 @@ export function createCloneHandler() {
}
});
gitProcess.on("error", (error) => {
gitProcess.on('error', (error) => {
resolve({
success: false,
error: `Failed to spawn git: ${error.message}`,
@@ -172,34 +157,34 @@ export function createCloneHandler() {
if (!cloneResult.success) {
res.status(500).json({
success: false,
error: cloneResult.error || "Failed to clone repository",
error: cloneResult.error || 'Failed to clone repository',
});
return;
}
// Remove .git directory to start fresh
try {
const gitDir = path.join(projectPath, ".git");
await fs.rm(gitDir, { recursive: true, force: true });
logger.info("[Templates] Removed .git directory");
const gitDir = path.join(projectPath, '.git');
await secureFs.rm(gitDir, { recursive: true, force: true });
logger.info('[Templates] Removed .git directory');
} catch (error) {
logger.warn("[Templates] Could not remove .git directory:", error);
logger.warn('[Templates] Could not remove .git directory:', error);
// Continue anyway - not critical
}
// Initialize a fresh git repository
await new Promise<void>((resolve) => {
const gitInit = spawn("git", ["init"], {
const gitInit = spawn('git', ['init'], {
cwd: projectPath,
});
gitInit.on("close", () => {
logger.info("[Templates] Initialized fresh git repository");
gitInit.on('close', () => {
logger.info('[Templates] Initialized fresh git repository');
resolve();
});
gitInit.on("error", () => {
logger.warn("[Templates] Could not initialize git");
gitInit.on('error', () => {
logger.warn('[Templates] Could not initialize git');
resolve();
});
});
@@ -212,7 +197,7 @@ export function createCloneHandler() {
projectName: sanitizedName,
});
} catch (error) {
logError(error, "Clone template failed");
logError(error, 'Clone template failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,14 +2,11 @@
* GET /config endpoint - Get workspace configuration status
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import {
getAllowedRootDirectory,
getDataDirectory,
} from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createConfigHandler() {
return async (_req: Request, res: Response): Promise<void> => {
@@ -30,12 +27,12 @@ export function createConfigHandler() {
// Check if the directory exists
try {
const resolvedWorkspaceDir = path.resolve(allowedRootDirectory);
const stats = await fs.stat(resolvedWorkspaceDir);
const stats = await secureFs.stat(resolvedWorkspaceDir);
if (!stats.isDirectory()) {
res.json({
success: true,
configured: false,
error: "ALLOWED_ROOT_DIRECTORY is not a valid directory",
error: 'ALLOWED_ROOT_DIRECTORY is not a valid directory',
});
return;
}
@@ -50,11 +47,11 @@ export function createConfigHandler() {
res.json({
success: true,
configured: false,
error: "ALLOWED_ROOT_DIRECTORY path does not exist",
error: 'ALLOWED_ROOT_DIRECTORY path does not exist',
});
}
} catch (error) {
logError(error, "Get workspace config failed");
logError(error, 'Get workspace config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* GET /directories endpoint - List directories in workspace
*/
import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getAllowedRootDirectory } from "@automaker/platform";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getAllowedRootDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createDirectoriesHandler() {
return async (_req: Request, res: Response): Promise<void> => {
@@ -16,7 +16,7 @@ export function createDirectoriesHandler() {
if (!allowedRootDirectory) {
res.status(400).json({
success: false,
error: "ALLOWED_ROOT_DIRECTORY is not configured",
error: 'ALLOWED_ROOT_DIRECTORY is not configured',
});
return;
}
@@ -25,23 +25,23 @@ export function createDirectoriesHandler() {
// Check if directory exists
try {
await fs.stat(resolvedWorkspaceDir);
await secureFs.stat(resolvedWorkspaceDir);
} catch {
res.status(400).json({
success: false,
error: "Workspace directory path does not exist",
error: 'Workspace directory path does not exist',
});
return;
}
// Read directory contents
const entries = await fs.readdir(resolvedWorkspaceDir, {
const entries = await secureFs.readdir(resolvedWorkspaceDir, {
withFileTypes: true,
});
// Filter to directories only and map to result format
const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => ({
name: entry.name,
path: path.join(resolvedWorkspaceDir, entry.name),
@@ -53,7 +53,7 @@ export function createDirectoriesHandler() {
directories,
});
} catch (error) {
logError(error, "List workspace directories failed");
logError(error, 'List workspace directories failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,18 +2,14 @@
* Common utilities for worktree routes
*/
import { createLogger } from "@automaker/utils";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import {
getErrorMessage as getErrorMessageShared,
createLogError,
} from "../common.js";
import { FeatureLoader } from "../../services/feature-loader.js";
import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger("Worktree");
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
const featureLoader = new FeatureLoader();
@@ -28,10 +24,10 @@ export const MAX_BRANCH_NAME_LENGTH = 250;
// Extended PATH configuration for Electron apps
// ============================================================================
const pathSeparator = process.platform === "win32" ? ";" : ":";
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const additionalPaths: string[] = [];
if (process.platform === "win32") {
if (process.platform === 'win32') {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
@@ -39,23 +35,22 @@ if (process.platform === "win32") {
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env["ProgramFiles(x86)"]) {
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\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
'/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 extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
.filter(Boolean)
.join(pathSeparator);
/**
* Environment variables with extended PATH for executing shell commands.
@@ -85,9 +80,7 @@ export function isValidBranchName(name: string): boolean {
*/
export async function isGhCliAvailable(): Promise<boolean> {
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
const checkCommand = process.platform === 'win32' ? 'where gh' : 'command -v gh';
await execAsync(checkCommand, { env: execEnv });
return true;
} catch {
@@ -95,8 +88,7 @@ export async function isGhCliAvailable(): Promise<boolean> {
}
}
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
"chore: automaker initial commit";
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = 'chore: automaker initial commit';
/**
* Normalize path separators to forward slashes for cross-platform consistency.
@@ -104,7 +96,7 @@ export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
* from git commands (which may use forward slashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
return p.replace(/\\/g, '/');
}
/**
@@ -112,7 +104,7 @@ export function normalizePath(p: string): string {
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
return true;
} catch {
return false;
@@ -124,30 +116,21 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
* These are expected in test environments with mock paths
*/
export function isENOENT(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
}
/**
* Check if a path is a mock/test path that doesn't exist
*/
export function isMockPath(worktreePath: string): boolean {
return worktreePath.startsWith("/mock/") || worktreePath.includes("/mock/");
return worktreePath.startsWith('/mock/') || worktreePath.includes('/mock/');
}
/**
* Conditionally log worktree errors - suppress ENOENT for mock paths
* to reduce noise in test output
*/
export function logWorktreeError(
error: unknown,
message: string,
worktreePath?: string
): void {
export function logWorktreeError(error: unknown, message: string, worktreePath?: string): void {
// Don't log ENOENT errors for mock paths (expected in tests)
if (isENOENT(error) && worktreePath && isMockPath(worktreePath)) {
return;
@@ -165,17 +148,14 @@ export const logError = createLogError(logger);
*/
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --verify HEAD", { cwd: repoPath });
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
return false;
} catch {
try {
await execAsync(
`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`,
{ cwd: repoPath }
);
logger.info(
`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`
);
await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, {
cwd: repoPath,
});
logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`);
return true;
} catch (error) {
const reason = getErrorMessageShared(error);

View File

@@ -5,12 +5,9 @@
* can switch between branches even after worktrees are removed.
*/
import { readFile, writeFile } from "fs/promises";
import path from "path";
import {
getBranchTrackingPath,
ensureAutomakerDir,
} from "@automaker/platform";
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
export interface TrackedBranch {
name: string;
@@ -25,19 +22,17 @@ interface BranchTrackingData {
/**
* Read tracked branches from file
*/
export async function getTrackedBranches(
projectPath: string
): Promise<TrackedBranch[]> {
export async function getTrackedBranches(projectPath: string): Promise<TrackedBranch[]> {
try {
const filePath = getBranchTrackingPath(projectPath);
const content = await readFile(filePath, "utf-8");
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
const data: BranchTrackingData = JSON.parse(content);
return data.branches || [];
} catch (error: any) {
if (error.code === "ENOENT") {
if (error.code === 'ENOENT') {
return [];
}
console.warn("[branch-tracking] Failed to read tracked branches:", error);
console.warn('[branch-tracking] Failed to read tracked branches:', error);
return [];
}
}
@@ -45,23 +40,17 @@ export async function getTrackedBranches(
/**
* Save tracked branches to file
*/
async function saveTrackedBranches(
projectPath: string,
branches: TrackedBranch[]
): Promise<void> {
async function saveTrackedBranches(projectPath: string, branches: TrackedBranch[]): Promise<void> {
const automakerDir = await ensureAutomakerDir(projectPath);
const filePath = path.join(automakerDir, "active-branches.json");
const filePath = path.join(automakerDir, 'active-branches.json');
const data: BranchTrackingData = { branches };
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
await secureFs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Add a branch to tracking
*/
export async function trackBranch(
projectPath: string,
branchName: string
): Promise<void> {
export async function trackBranch(projectPath: string, branchName: string): Promise<void> {
const branches = await getTrackedBranches(projectPath);
// Check if already tracked
@@ -82,10 +71,7 @@ export async function trackBranch(
/**
* Remove a branch from tracking
*/
export async function untrackBranch(
projectPath: string,
branchName: string
): Promise<void> {
export async function untrackBranch(projectPath: string, branchName: string): Promise<void> {
const branches = await getTrackedBranches(projectPath);
const filtered = branches.filter((b) => b.name !== branchName);
@@ -114,10 +100,7 @@ export async function updateBranchActivation(
/**
* Check if a branch is tracked
*/
export async function isBranchTracked(
projectPath: string,
branchName: string
): Promise<boolean> {
export async function isBranchTracked(projectPath: string, branchName: string): Promise<boolean> {
const branches = await getTrackedBranches(projectPath);
return branches.some((b) => b.name === branchName);
}

View File

@@ -7,19 +7,19 @@
* 3. Only creates a new worktree if none exists for the branch
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import { mkdir } from "fs/promises";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
} from "../common.js";
import { trackBranch } from "./branch-tracking.js";
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
const execAsync = promisify(exec);
@@ -31,20 +31,20 @@ async function findExistingWorktreeForBranch(
branchName: string
): Promise<{ path: string; branch: string } | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
const lines = stdout.split("\n");
const lines = stdout.split('\n');
let currentPath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith("branch ")) {
currentBranch = line.slice(7).replace("refs/heads/", "");
} else if (line === "" && currentPath && currentBranch) {
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
} else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
// Resolve to absolute path - git may return relative paths
@@ -86,7 +86,7 @@ export function createCreateHandler() {
if (!projectPath || !branchName) {
res.status(400).json({
success: false,
error: "projectPath and branchName required",
error: 'projectPath and branchName required',
});
return;
}
@@ -94,7 +94,7 @@ export function createCreateHandler() {
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
error: 'Not a git repository',
});
return;
}
@@ -107,7 +107,9 @@ export function createCreateHandler() {
if (existingWorktree) {
// Worktree already exists, return it as success (not an error)
// This handles manually created worktrees or worktrees from previous runs
console.log(`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`);
console.log(
`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`
);
// Track the branch so it persists in the UI
await trackBranch(projectPath, branchName);
@@ -124,12 +126,12 @@ export function createCreateHandler() {
}
// Sanitize branch name for directory usage
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
const worktreesDir = path.join(projectPath, ".worktrees");
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreesDir = path.join(projectPath, '.worktrees');
const worktreePath = path.join(worktreesDir, sanitizedName);
// Create worktrees directory if it doesn't exist
await mkdir(worktreesDir, { recursive: true });
await secureFs.mkdir(worktreesDir, { recursive: true });
// Check if branch exists
let branchExists = false;
@@ -149,7 +151,7 @@ export function createCreateHandler() {
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
} else {
// Create new branch from base or HEAD
const base = baseBranch || "HEAD";
const base = baseBranch || 'HEAD';
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
}
@@ -174,7 +176,7 @@ export function createCreateHandler() {
},
});
} catch (error) {
logError(error, "Create worktree failed");
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,11 +2,11 @@
* POST /diffs endpoint - Get diffs for a worktree
*/
import type { Request, Response } from "express";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { getGitRepositoryDiffs } from "../../common.js";
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { getGitRepositoryDiffs } from '../../common.js';
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -17,21 +17,19 @@ export function createDiffsHandler() {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId required",
});
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
// Check if worktree exists
await fs.access(worktreePath);
await secureFs.access(worktreePath);
// Get diffs from worktree
const result = await getGitRepositoryDiffs(worktreePath);
@@ -43,7 +41,7 @@ export function createDiffsHandler() {
});
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
logError(innerError, "Worktree access failed, falling back to main project");
logError(innerError, 'Worktree access failed, falling back to main project');
try {
const result = await getGitRepositoryDiffs(projectPath);
@@ -54,12 +52,12 @@ export function createDiffsHandler() {
hasChanges: result.hasChanges,
});
} catch (fallbackError) {
logError(fallbackError, "Fallback to main project also failed");
res.json({ success: true, diff: "", files: [], hasChanges: false });
logError(fallbackError, 'Fallback to main project also failed');
res.json({ success: true, diff: '', files: [], hasChanges: false });
}
}
} catch (error) {
logError(error, "Get worktree diffs failed");
logError(error, 'Get worktree diffs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,13 +2,13 @@
* POST /file-diff endpoint - Get diff for a specific file
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import { generateSyntheticDiffForNewFile } from "../../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { generateSyntheticDiffForNewFile } from '../../common.js';
const execAsync = promisify(exec);
@@ -24,24 +24,23 @@ export function createFileDiffHandler() {
if (!projectPath || !featureId || !filePath) {
res.status(400).json({
success: false,
error: "projectPath, featureId, and filePath required",
error: 'projectPath, featureId, and filePath required',
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await fs.access(worktreePath);
await secureFs.access(worktreePath);
// First check if the file is untracked
const { stdout: status } = await execAsync(
`git status --porcelain -- "${filePath}"`,
{ cwd: worktreePath }
);
const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, {
cwd: worktreePath,
});
const isUntracked = status.trim().startsWith("??");
const isUntracked = status.trim().startsWith('??');
let diff: string;
if (isUntracked) {
@@ -49,23 +48,20 @@ export function createFileDiffHandler() {
diff = await generateSyntheticDiffForNewFile(worktreePath, filePath);
} else {
// Use regular git diff for tracked files
const result = await execAsync(
`git diff HEAD -- "${filePath}"`,
{
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
}
);
const result = await execAsync(`git diff HEAD -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024,
});
diff = result.stdout;
}
res.json({ success: true, diff, filePath });
} catch (innerError) {
logError(innerError, "Worktree file diff failed");
res.json({ success: true, diff: "", filePath });
logError(innerError, 'Worktree file diff failed');
res.json({ success: true, diff: '', filePath });
}
} catch (error) {
logError(error, "Get worktree file diff failed");
logError(error, 'Get worktree file diff failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /info endpoint - Get worktree info
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError, normalizePath } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, normalizePath } from '../common.js';
const execAsync = promisify(exec);
@@ -20,20 +20,18 @@ export function createInfoHandler() {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId required",
});
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
});
return;
}
// Check if worktree exists (git worktrees are stored in project directory)
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await fs.access(worktreePath);
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
await secureFs.access(worktreePath);
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
res.json({
@@ -45,7 +43,7 @@ export function createInfoHandler() {
res.json({ success: true, worktreePath: null, branchName: null });
}
} catch (error) {
logError(error, "Get worktree info failed");
logError(error, 'Get worktree info failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /init-git endpoint - Initialize a git repository in a directory
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import { join } from "path";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { join } from 'path';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -21,39 +21,42 @@ export function createInitGitHandler() {
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath required",
error: 'projectPath required',
});
return;
}
// Check if .git already exists
const gitDirPath = join(projectPath, ".git");
if (existsSync(gitDirPath)) {
const gitDirPath = join(projectPath, '.git');
try {
await secureFs.access(gitDirPath);
// .git exists
res.json({
success: true,
result: {
initialized: false,
message: "Git repository already exists",
message: 'Git repository already exists',
},
});
return;
} catch {
// .git doesn't exist, continue with initialization
}
// Initialize git and create an initial empty commit
await execAsync(
`git init && git commit --allow-empty -m "Initial commit"`,
{ cwd: projectPath }
);
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
cwd: projectPath,
});
res.json({
success: true,
result: {
initialized: true,
message: "Git repository initialized with initial commit",
message: 'Git repository initialized with initial commit',
},
});
} catch (error) {
logError(error, "Init git failed");
logError(error, 'Init git failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -5,13 +5,13 @@
* Does NOT include tracked branches - only real worktrees with separate directories.
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
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";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
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);
@@ -28,10 +28,10 @@ interface WorktreeInfo {
async function getCurrentBranch(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync("git branch --show-current", { cwd });
const { stdout } = await execAsync('git branch --show-current', { cwd });
return stdout.trim();
} catch {
return "";
return '';
}
}
@@ -44,7 +44,7 @@ export function createListHandler() {
};
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath required" });
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
@@ -57,28 +57,35 @@ export function createListHandler() {
const currentBranch = await getCurrentBranch(projectPath);
// Get actual worktrees from git
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = [];
const lines = stdout.split("\n");
const lines = stdout.split('\n');
let current: { path?: string; branch?: string } = {};
let isFirst = true;
// First pass: detect removed worktrees
for (const line of lines) {
if (line.startsWith("worktree ")) {
if (line.startsWith('worktree ')) {
current.path = normalizePath(line.slice(9));
} else if (line.startsWith("branch ")) {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
} else if (line.startsWith('branch ')) {
current.branch = line.slice(7).replace('refs/heads/', '');
} else if (line === '') {
if (current.path && current.branch) {
const isMainWorktree = isFirst;
// Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself)
if (!isMainWorktree && !existsSync(current.path)) {
let worktreeExists = false;
try {
await secureFs.access(current.path);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!isMainWorktree && !worktreeExists) {
// Worktree directory doesn't exist - it was manually deleted
removedWorktrees.push({
path: current.path,
@@ -103,7 +110,7 @@ export function createListHandler() {
// Prune removed worktrees from git (only if any were detected)
if (removedWorktrees.length > 0) {
try {
await execAsync("git worktree prune", { cwd: projectPath });
await execAsync('git worktree prune', { cwd: projectPath });
} catch {
// Prune failed, but we'll still report the removed worktrees
}
@@ -116,13 +123,12 @@ export function createListHandler() {
if (includeDetails) {
for (const worktree of worktrees) {
try {
const { stdout: statusOutput } = await execAsync(
"git status --porcelain",
{ cwd: worktree.path }
);
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktree.path,
});
const changedFiles = statusOutput
.trim()
.split("\n")
.split('\n')
.filter((line) => line.trim());
worktree.hasChanges = changedFiles.length > 0;
worktree.changedFilesCount = changedFiles.length;
@@ -141,13 +147,13 @@ export function createListHandler() {
}
}
res.json({
success: true,
res.json({
success: true,
worktrees,
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
});
} catch (error) {
logError(error, "List worktrees failed");
logError(error, 'List worktrees failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -2,12 +2,12 @@
* POST /status endpoint - Get worktree status
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -20,53 +20,50 @@ export function createStatusHandler() {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId required",
});
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId);
const worktreePath = path.join(projectPath, '.worktrees', featureId);
try {
await fs.access(worktreePath);
const { stdout: status } = await execAsync("git status --porcelain", {
await secureFs.access(worktreePath);
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const files = status
.split("\n")
.split('\n')
.filter(Boolean)
.map((line) => line.slice(3));
const { stdout: diffStat } = await execAsync("git diff --stat", {
const { stdout: diffStat } = await execAsync('git diff --stat', {
cwd: worktreePath,
});
const { stdout: logOutput } = await execAsync('git log --oneline -5 --format="%h %s"', {
cwd: worktreePath,
});
const { stdout: logOutput } = await execAsync(
'git log --oneline -5 --format="%h %s"',
{ cwd: worktreePath }
);
res.json({
success: true,
modifiedFiles: files.length,
files,
diffStat: diffStat.trim(),
recentCommits: logOutput.trim().split("\n").filter(Boolean),
recentCommits: logOutput.trim().split('\n').filter(Boolean),
});
} catch {
res.json({
success: true,
modifiedFiles: 0,
files: [],
diffStat: "",
diffStat: '',
recentCommits: [],
});
}
} catch (error) {
logError(error, "Get worktree status failed");
logError(error, 'Get worktree status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};

View File

@@ -3,22 +3,18 @@
* Manages conversation sessions and streams responses via WebSocket
*/
import path from "path";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
import type { ExecuteOptions } from "@automaker/types";
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
} from "@automaker/utils";
import { ProviderFactory } from "../providers/provider-factory.js";
import { createChatOptions } from "../lib/sdk-options.js";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions } from '@automaker/types';
import { readImageAsBase64, buildPromptWithImages, isAbortError } from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform';
interface Message {
id: string;
role: "user" | "assistant";
role: 'user' | 'assistant';
content: string;
images?: Array<{
data: string;
@@ -58,8 +54,8 @@ export class AgentService {
private events: EventEmitter;
constructor(dataDir: string, events: EventEmitter) {
this.stateDir = path.join(dataDir, "agent-sessions");
this.metadataFile = path.join(dataDir, "sessions-metadata.json");
this.stateDir = path.join(dataDir, 'agent-sessions');
this.metadataFile = path.join(dataDir, 'sessions-metadata.json');
this.events = events;
}
@@ -86,12 +82,8 @@ export class AgentService {
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new Error(
`Working directory ${effectiveWorkingDirectory} is not allowed`
);
}
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
this.sessions.set(sessionId, {
messages,
@@ -132,7 +124,7 @@ export class AgentService {
}
if (session.isRunning) {
throw new Error("Agent is already processing a message");
throw new Error('Agent is already processing a message');
}
// Update session model if provided
@@ -142,7 +134,7 @@ export class AgentService {
}
// Read images and convert to base64
const images: Message["images"] = [];
const images: Message['images'] = [];
if (imagePaths && imagePaths.length > 0) {
for (const imagePath of imagePaths) {
try {
@@ -153,10 +145,7 @@ export class AgentService {
filename: imageData.filename,
});
} catch (error) {
console.error(
`[AgentService] Failed to load image ${imagePath}:`,
error
);
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
}
@@ -164,7 +153,7 @@ export class AgentService {
// Add user message
const userMessage: Message = {
id: this.generateId(),
role: "user",
role: 'user',
content: message,
images: images.length > 0 ? images : undefined,
timestamp: new Date().toISOString(),
@@ -182,7 +171,7 @@ export class AgentService {
// Emit user message event
this.emitAgentEvent(sessionId, {
type: "message",
type: 'message',
message: userMessage,
});
@@ -212,15 +201,14 @@ export class AgentService {
// Build options for provider
const options: ExecuteOptions = {
prompt: "", // Will be set below based on images
prompt: '', // Will be set below based on images
model: effectiveModel,
cwd: workingDirectory || session.workingDirectory,
systemPrompt: this.getSystemPrompt(),
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,
conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
};
@@ -239,30 +227,28 @@ export class AgentService {
const stream = provider.executeQuery(options);
let currentAssistantMessage: Message | null = null;
let responseText = "";
let responseText = '';
const toolUses: Array<{ name: string; input: unknown }> = [];
for await (const msg of stream) {
// Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) {
session.sdkSessionId = msg.session_id;
console.log(
`[AgentService] Captured SDK session ID: ${msg.session_id}`
);
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
// Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
}
if (msg.type === "assistant") {
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
if (block.type === 'text') {
responseText += block.text;
if (!currentAssistantMessage) {
currentAssistantMessage = {
id: this.generateId(),
role: "assistant",
role: 'assistant',
content: responseText,
timestamp: new Date().toISOString(),
};
@@ -272,27 +258,27 @@ export class AgentService {
}
this.emitAgentEvent(sessionId, {
type: "stream",
type: 'stream',
messageId: currentAssistantMessage.id,
content: responseText,
isComplete: false,
});
} else if (block.type === "tool_use") {
} else if (block.type === 'tool_use') {
const toolUse = {
name: block.name || "unknown",
name: block.name || 'unknown',
input: block.input,
};
toolUses.push(toolUse);
this.emitAgentEvent(sessionId, {
type: "tool_use",
type: 'tool_use',
tool: toolUse,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success" && msg.result) {
} else if (msg.type === 'result') {
if (msg.subtype === 'success' && msg.result) {
if (currentAssistantMessage) {
currentAssistantMessage.content = msg.result;
responseText = msg.result;
@@ -300,7 +286,7 @@ export class AgentService {
}
this.emitAgentEvent(sessionId, {
type: "complete",
type: 'complete',
messageId: currentAssistantMessage?.id,
content: responseText,
toolUses,
@@ -324,14 +310,14 @@ export class AgentService {
return { success: false, aborted: true };
}
console.error("[AgentService] Error:", error);
console.error('[AgentService] Error:', error);
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: "assistant",
role: 'assistant',
content: `Error: ${(error as Error).message}`,
timestamp: new Date().toISOString(),
isError: true,
@@ -341,7 +327,7 @@ export class AgentService {
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: "error",
type: 'error',
error: (error as Error).message,
message: errorMessage,
});
@@ -356,7 +342,7 @@ export class AgentService {
getHistory(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
return { success: false, error: 'Session not found' };
}
return {
@@ -372,7 +358,7 @@ export class AgentService {
async stopExecution(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
return { success: false, error: 'Session not found' };
}
if (session.abortController) {
@@ -404,7 +390,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = (await secureFs.readFile(sessionFile, "utf-8")) as string;
const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return [];
@@ -415,23 +401,16 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await secureFs.writeFile(
sessionFile,
JSON.stringify(messages, null, 2),
"utf-8"
);
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
await this.updateSessionTimestamp(sessionId);
} catch (error) {
console.error("[AgentService] Failed to save session:", error);
console.error('[AgentService] Failed to save session:', error);
}
}
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = (await secureFs.readFile(
this.metadataFile,
"utf-8"
)) as string;
const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return {};
@@ -439,11 +418,7 @@ export class AgentService {
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await secureFs.writeFile(
this.metadataFile,
JSON.stringify(metadata, null, 2),
"utf-8"
);
await secureFs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8');
}
async updateSessionTimestamp(sessionId: string): Promise<void> {
@@ -463,8 +438,7 @@ export class AgentService {
}
return sessions.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
@@ -478,21 +452,15 @@ export class AgentService {
const metadata = await this.loadMetadata();
// Determine the effective working directory
const effectiveWorkingDirectory =
workingDirectory || projectPath || process.cwd();
const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new PathNotAllowedError(effectiveWorkingDirectory);
}
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Validate that projectPath is allowed if provided
if (projectPath) {
const resolvedProjectPath = path.resolve(projectPath);
if (!isPathAllowed(resolvedProjectPath)) {
throw new PathNotAllowedError(projectPath);
}
validateWorkingDirectory(projectPath);
}
const session: SessionMetadata = {
@@ -569,11 +537,8 @@ export class AgentService {
return true;
}
private emitAgentEvent(
sessionId: string,
data: Record<string, unknown>
): void {
this.events.emit("agent:stream", { sessionId, ...data });
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit('agent:stream', { sessionId, ...data });
}
private getSystemPrompt(): string {

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,10 @@
* Developers should configure their projects to use the PORT environment variable.
*/
import { spawn, execSync, type ChildProcess } from "child_process";
import { existsSync } from "fs";
import path from "path";
import net from "net";
import { spawn, execSync, type ChildProcess } from 'child_process';
import * as secureFs from '../lib/secure-fs.js';
import path from 'path';
import net from 'net';
export interface DevServerInfo {
worktreePath: string;
@@ -40,12 +40,12 @@ class DevServerService {
// Then check if the system has it in use
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.once("listening", () => {
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, "127.0.0.1");
server.listen(port, '127.0.0.1');
});
}
@@ -54,21 +54,21 @@ class DevServerService {
*/
private killProcessOnPort(port: number): void {
try {
if (process.platform === "win32") {
if (process.platform === 'win32') {
// Windows: find and kill process on port
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf-8" });
const lines = result.trim().split("\n");
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' });
const lines = result.trim().split('\n');
const pids = new Set<string>();
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== "0") {
if (pid && pid !== '0') {
pids.add(pid);
}
}
for (const pid of pids) {
try {
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" });
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
@@ -77,11 +77,11 @@ class DevServerService {
} else {
// macOS/Linux: use lsof to find and kill process
try {
const result = execSync(`lsof -ti:${port}`, { encoding: "utf-8" });
const pids = result.trim().split("\n").filter(Boolean);
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8' });
const pids = result.trim().split('\n').filter(Boolean);
for (const pid of pids) {
try {
execSync(`kill -9 ${pid}`, { stdio: "ignore" });
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
@@ -127,37 +127,47 @@ class DevServerService {
throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`);
}
/**
* Helper to check if a file exists using secureFs
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await secureFs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Detect the package manager used in a directory
*/
private detectPackageManager(
dir: string
): "npm" | "yarn" | "pnpm" | "bun" | null {
if (existsSync(path.join(dir, "bun.lockb"))) return "bun";
if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
if (existsSync(path.join(dir, "yarn.lock"))) return "yarn";
if (existsSync(path.join(dir, "package-lock.json"))) return "npm";
if (existsSync(path.join(dir, "package.json"))) return "npm"; // Default
private async detectPackageManager(dir: string): Promise<'npm' | 'yarn' | 'pnpm' | 'bun' | null> {
if (await this.fileExists(path.join(dir, 'bun.lockb'))) return 'bun';
if (await this.fileExists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
if (await this.fileExists(path.join(dir, 'yarn.lock'))) return 'yarn';
if (await this.fileExists(path.join(dir, 'package-lock.json'))) return 'npm';
if (await this.fileExists(path.join(dir, 'package.json'))) return 'npm'; // Default
return null;
}
/**
* Get the dev script command for a directory
*/
private getDevCommand(dir: string): { cmd: string; args: string[] } | null {
const pm = this.detectPackageManager(dir);
private async getDevCommand(dir: string): Promise<{ cmd: string; args: string[] } | null> {
const pm = await this.detectPackageManager(dir);
if (!pm) return null;
switch (pm) {
case "bun":
return { cmd: "bun", args: ["run", "dev"] };
case "pnpm":
return { cmd: "pnpm", args: ["run", "dev"] };
case "yarn":
return { cmd: "yarn", args: ["dev"] };
case "npm":
case 'bun':
return { cmd: 'bun', args: ['run', 'dev'] };
case 'pnpm':
return { cmd: 'pnpm', args: ['run', 'dev'] };
case 'yarn':
return { cmd: 'yarn', args: ['dev'] };
case 'npm':
default:
return { cmd: "npm", args: ["run", "dev"] };
return { cmd: 'npm', args: ['run', 'dev'] };
}
}
@@ -192,7 +202,7 @@ class DevServerService {
}
// Verify the worktree exists
if (!existsSync(worktreePath)) {
if (!(await this.fileExists(worktreePath))) {
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
@@ -200,8 +210,8 @@ class DevServerService {
}
// Check for package.json
const packageJsonPath = path.join(worktreePath, "package.json");
if (!existsSync(packageJsonPath)) {
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
@@ -209,7 +219,7 @@ class DevServerService {
}
// Get dev command
const devCommand = this.getDevCommand(worktreePath);
const devCommand = await this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
@@ -224,7 +234,7 @@ class DevServerService {
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Port allocation failed",
error: error instanceof Error ? error.message : 'Port allocation failed',
};
}
@@ -241,14 +251,10 @@ class DevServerService {
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(`[DevServerService] Starting dev server on port ${port}`);
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
console.log(
`[DevServerService] Starting dev server on port ${port}`
);
console.log(
`[DevServerService] Working directory (cwd): ${worktreePath}`
);
console.log(
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(" ")} with PORT=${port}`
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
);
// Spawn the dev process with PORT environment variable
@@ -260,7 +266,7 @@ class DevServerService {
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
@@ -269,29 +275,27 @@ class DevServerService {
// Log output for debugging
if (devProcess.stdout) {
devProcess.stdout.on("data", (data: Buffer) => {
devProcess.stdout.on('data', (data: Buffer) => {
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
});
}
if (devProcess.stderr) {
devProcess.stderr.on("data", (data: Buffer) => {
devProcess.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim();
console.error(`[DevServer:${port}] ${msg}`);
});
}
devProcess.on("error", (error) => {
devProcess.on('error', (error) => {
console.error(`[DevServerService] Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
devProcess.on("exit", (code) => {
console.log(
`[DevServerService] Process for ${worktreePath} exited with code ${code}`
);
devProcess.on('exit', (code) => {
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
@@ -348,7 +352,9 @@ class DevServerService {
// If we don't have a record of this server, it may have crashed/exited on its own
// Return success so the frontend can clear its state
if (!server) {
console.log(`[DevServerService] No server record for ${worktreePath}, may have already stopped`);
console.log(
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
);
return {
success: true,
result: {
@@ -362,7 +368,7 @@ class DevServerService {
// Kill the process
if (server.process && !server.process.killed) {
server.process.kill("SIGTERM");
server.process.kill('SIGTERM');
}
// Free the port
@@ -447,13 +453,13 @@ export function getDevServerService(): DevServerService {
}
// Cleanup on process exit
process.on("SIGTERM", async () => {
process.on('SIGTERM', async () => {
if (devServerServiceInstance) {
await devServerServiceInstance.stopAll();
}
});
process.on("SIGINT", async () => {
process.on('SIGINT', async () => {
if (devServerServiceInstance) {
await devServerServiceInstance.stopAll();
}

View File

@@ -1,37 +1,33 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import path from "path";
import os from "os";
import fs from "fs/promises";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
// Mock child_process
vi.mock("child_process", () => ({
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
}));
// Mock fs existsSync
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>();
return {
...actual,
existsSync: vi.fn(),
};
});
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn(),
}));
// Mock net
vi.mock("net", () => ({
vi.mock('net', () => ({
default: {
createServer: vi.fn(),
},
createServer: vi.fn(),
}));
import { spawn, execSync } from "child_process";
import { existsSync } from "fs";
import net from "net";
import { spawn, execSync } from 'child_process';
import * as secureFs from '@/lib/secure-fs.js';
import net from 'net';
describe("dev-server-service.ts", () => {
describe('dev-server-service.ts', () => {
let testDir: string;
beforeEach(async () => {
@@ -41,20 +37,20 @@ describe("dev-server-service.ts", () => {
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
// Default mock for existsSync - return true
vi.mocked(existsSync).mockReturnValue(true);
// Default mock for secureFs.access - return resolved (file exists)
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default mock for net.createServer - port available
const mockServer = new EventEmitter() as any;
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
process.nextTick(() => mockServer.emit("listening"));
process.nextTick(() => mockServer.emit('listening'));
});
mockServer.close = vi.fn();
vi.mocked(net.createServer).mockReturnValue(mockServer);
// Default mock for execSync - no process on port
vi.mocked(execSync).mockImplementation(() => {
throw new Error("No process found");
throw new Error('No process found');
});
});
@@ -66,11 +62,9 @@ describe("dev-server-service.ts", () => {
}
});
describe("getDevServerService", () => {
it("should return a singleton instance", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('getDevServerService', () => {
it('should return a singleton instance', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const instance1 = getDevServerService();
const instance2 = getDevServerService();
@@ -79,148 +73,125 @@ describe("dev-server-service.ts", () => {
});
});
describe("startDevServer", () => {
it("should return error if worktree path does not exist", async () => {
vi.mocked(existsSync).mockReturnValue(false);
describe('startDevServer', () => {
it('should return error if worktree path does not exist', async () => {
vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('File not found'));
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(
"/project",
"/nonexistent/worktree"
);
const result = await service.startDevServer('/project', '/nonexistent/worktree');
expect(result.success).toBe(false);
expect(result.error).toContain("does not exist");
expect(result.error).toContain('does not exist');
});
it("should return error if no package.json found", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("package.json")) return false;
return true;
it('should return error if no package.json found', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
if (typeof p === 'string' && p.includes('package.json')) {
throw new Error('File not found');
}
return undefined;
});
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
expect(result.success).toBe(false);
expect(result.error).toContain("No package.json found");
expect(result.error).toContain('No package.json found');
});
it("should detect npm as package manager with package-lock.json", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return false;
if (p.includes("pnpm-lock.yaml")) return false;
if (p.includes("yarn.lock")) return false;
if (p.includes("package-lock.json")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect npm as package manager with package-lock.json', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found');
if (pathStr.includes('yarn.lock')) throw new Error('Not found');
if (pathStr.includes('package-lock.json')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith(
"npm",
["run", "dev"],
expect.any(Object)
);
expect(spawn).toHaveBeenCalledWith('npm', ['run', 'dev'], expect.any(Object));
});
it("should detect yarn as package manager with yarn.lock", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return false;
if (p.includes("pnpm-lock.yaml")) return false;
if (p.includes("yarn.lock")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect yarn as package manager with yarn.lock', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found');
if (pathStr.includes('yarn.lock')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith("yarn", ["dev"], expect.any(Object));
expect(spawn).toHaveBeenCalledWith('yarn', ['dev'], expect.any(Object));
});
it("should detect pnpm as package manager with pnpm-lock.yaml", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return false;
if (p.includes("pnpm-lock.yaml")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect pnpm as package manager with pnpm-lock.yaml', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) throw new Error('Not found');
if (pathStr.includes('pnpm-lock.yaml')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith(
"pnpm",
["run", "dev"],
expect.any(Object)
);
expect(spawn).toHaveBeenCalledWith('pnpm', ['run', 'dev'], expect.any(Object));
});
it("should detect bun as package manager with bun.lockb", async () => {
vi.mocked(existsSync).mockImplementation((p: any) => {
if (p.includes("bun.lockb")) return true;
if (p.includes("package.json")) return true;
return true;
it('should detect bun as package manager with bun.lockb', async () => {
vi.mocked(secureFs.access).mockImplementation(async (p: any) => {
const pathStr = typeof p === 'string' ? p : '';
if (pathStr.includes('bun.lockb')) return undefined;
if (pathStr.includes('package.json')) return undefined;
return undefined;
});
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
expect(spawn).toHaveBeenCalledWith(
"bun",
["run", "dev"],
expect.any(Object)
);
expect(spawn).toHaveBeenCalledWith('bun', ['run', 'dev'], expect.any(Object));
});
it("should return existing server info if already running", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should return existing server info if already running', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
// Start first server
@@ -230,18 +201,16 @@ describe("dev-server-service.ts", () => {
// Try to start again - should return existing
const result2 = await service.startDevServer(testDir, testDir);
expect(result2.success).toBe(true);
expect(result2.result?.message).toContain("already running");
expect(result2.result?.message).toContain('already running');
});
it("should start dev server successfully", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should start dev server successfully', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
@@ -249,32 +218,28 @@ describe("dev-server-service.ts", () => {
expect(result.success).toBe(true);
expect(result.result).toBeDefined();
expect(result.result?.port).toBeGreaterThanOrEqual(3001);
expect(result.result?.url).toContain("http://localhost:");
expect(result.result?.url).toContain('http://localhost:');
});
});
describe("stopDevServer", () => {
it("should return success if server not found", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('stopDevServer', () => {
it('should return success if server not found', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.stopDevServer("/nonexistent/path");
const result = await service.stopDevServer('/nonexistent/path');
expect(result.success).toBe(true);
expect(result.result?.message).toContain("already stopped");
expect(result.result?.message).toContain('already stopped');
});
it("should stop a running server", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should stop a running server', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
// Start server
@@ -284,15 +249,13 @@ describe("dev-server-service.ts", () => {
const result = await service.stopDevServer(testDir);
expect(result.success).toBe(true);
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
});
describe("listDevServers", () => {
it("should return empty list when no servers running", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('listDevServers', () => {
it('should return empty list when no servers running', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = service.listDevServers();
@@ -301,15 +264,13 @@ describe("dev-server-service.ts", () => {
expect(result.result.servers).toEqual([]);
});
it("should list running servers", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should list running servers', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -322,25 +283,21 @@ describe("dev-server-service.ts", () => {
});
});
describe("isRunning", () => {
it("should return false for non-running server", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('isRunning', () => {
it('should return false for non-running server', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
expect(service.isRunning("/some/path")).toBe(false);
expect(service.isRunning('/some/path')).toBe(false);
});
it("should return true for running server", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should return true for running server', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -349,25 +306,21 @@ describe("dev-server-service.ts", () => {
});
});
describe("getServerInfo", () => {
it("should return undefined for non-running server", async () => {
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
describe('getServerInfo', () => {
it('should return undefined for non-running server', async () => {
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
expect(service.getServerInfo("/some/path")).toBeUndefined();
expect(service.getServerInfo('/some/path')).toBeUndefined();
});
it("should return info for running server", async () => {
vi.mocked(existsSync).mockReturnValue(true);
it('should return info for running server', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -379,16 +332,14 @@ describe("dev-server-service.ts", () => {
});
});
describe("getAllocatedPorts", () => {
it("should return allocated ports", async () => {
vi.mocked(existsSync).mockReturnValue(true);
describe('getAllocatedPorts', () => {
it('should return allocated ports', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
@@ -399,16 +350,14 @@ describe("dev-server-service.ts", () => {
});
});
describe("stopAll", () => {
it("should stop all running servers", async () => {
vi.mocked(existsSync).mockReturnValue(true);
describe('stopAll', () => {
it('should stop all running servers', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import(
"@/services/dev-server-service.js"
);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
import { Label } from "@/components/ui/label";
import { Palette } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { cn } from "@/lib/utils";
import type { Theme, Project } from "../shared/types";
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Palette, Moon, Sun } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import { cn } from '@/lib/utils';
import type { Theme, Project } from '../shared/types';
interface AppearanceSectionProps {
effectiveTheme: Theme;
@@ -15,13 +16,17 @@ export function AppearanceSection({
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
@@ -37,43 +42,69 @@ export function AppearanceSection({
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Theme{" "}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
</span>
</Label>
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Theme{' '}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
</span>
</Label>
{/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button
onClick={() => setActiveTab('dark')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
activeTab === 'dark'
? 'bg-brand-500 text-white shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Moon className="w-3.5 h-3.5" />
Dark
</button>
<button
onClick={() => setActiveTab('light')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
activeTab === 'light'
? 'bg-brand-500 text-white shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Sun className="w-3.5 h-3.5" />
Light
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{themeOptions.map(({ value, label, Icon, testId }) => {
{themesToShow.map(({ value, label, Icon, testId, color }) => {
const isActive = effectiveTheme === value;
return (
<button
key={value}
onClick={() => onThemeChange(value)}
className={cn(
"group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl",
"text-sm font-medium transition-all duration-200 ease-out",
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
'text-sm font-medium transition-all duration-200 ease-out',
isActive
? [
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
"border-2 border-brand-500/40",
"text-foreground",
"shadow-md shadow-brand-500/10",
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
'border-2 border-brand-500/40',
'text-foreground',
'shadow-md shadow-brand-500/10',
]
: [
"bg-accent/30 hover:bg-accent/50",
"border border-border/50 hover:border-border",
"text-muted-foreground hover:text-foreground",
"hover:shadow-sm",
'bg-accent/30 hover:bg-accent/50',
'border border-border/50 hover:border-border',
'text-muted-foreground hover:text-foreground',
'hover:shadow-sm',
],
"hover:scale-[1.02] active:scale-[0.98]"
'hover:scale-[1.02] active:scale-[0.98]'
)}
data-testid={testId}
>
<Icon className={cn(
"w-4 h-4 transition-all duration-200",
isActive ? "text-brand-500" : "group-hover:text-brand-400"
)} />
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
<span>{label}</span>
</button>
);

View File

@@ -1,4 +1,6 @@
// Shared TypeScript types for settings view components
// Theme type is now imported from the central theme-options config
export { type Theme } from '@/config/theme-options';
export interface CliStatus {
success: boolean;
@@ -17,31 +19,13 @@ export interface CliStatus {
error?: string;
}
export type Theme =
| "dark"
| "light"
| "retro"
| "dracula"
| "nord"
| "monokai"
| "tokyonight"
| "solarized"
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave"
| "red"
| "cream"
| "sunset"
| "gray";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
export type KanbanDetailLevel = 'minimal' | 'standard' | 'detailed';
export interface Project {
id: string;
name: string;
path: string;
theme?: Theme;
theme?: string;
}
export interface ApiKeys {

View File

@@ -1,8 +1,9 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, ArrowLeft, Check } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { ArrowRight, ArrowLeft, Check, Moon, Sun } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
interface ThemeStepProps {
onNext: () => void;
@@ -11,6 +12,7 @@ interface ThemeStepProps {
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const handleThemeHover = (themeValue: string) => {
setPreviewTheme(themeValue as typeof theme);
@@ -25,19 +27,47 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
setPreviewTheme(null);
};
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-3xl font-bold text-foreground mb-3">
Choose Your Theme
</h2>
<h2 className="text-3xl font-bold text-foreground mb-3">Choose Your Theme</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Pick a theme that suits your style. Hover to preview, click to select.
</p>
</div>
{/* Dark/Light Tabs */}
<div className="flex justify-center gap-2">
<button
onClick={() => setActiveTab('dark')}
className={cn(
'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium transition-all duration-200',
activeTab === 'dark'
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
: 'bg-accent/50 text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<Moon className="w-4 h-4" />
Dark Themes
</button>
<button
onClick={() => setActiveTab('light')}
className={cn(
'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium transition-all duration-200',
activeTab === 'light'
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
: 'bg-accent/50 text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<Sun className="w-4 h-4" />
Light Themes
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{themeOptions.map((option) => {
{themesToShow.map((option) => {
const Icon = option.Icon;
const isSelected = theme === option.value;
@@ -49,11 +79,11 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
onMouseLeave={handleThemeLeave}
onClick={() => handleThemeClick(option.value)}
className={cn(
"relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200",
"hover:scale-105 hover:shadow-lg",
'relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200',
'hover:scale-105 hover:shadow-lg',
isSelected
? "border-brand-500 bg-brand-500/10"
: "border-border hover:border-brand-400 bg-card"
? 'border-brand-500 bg-brand-500/10'
: 'border-border hover:border-brand-400 bg-card'
)}
>
{isSelected && (
@@ -61,10 +91,8 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
<Check className="w-4 h-4 text-brand-500" />
</div>
)}
<Icon className="w-6 h-6 text-foreground" />
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
<Icon className="w-6 h-6" style={{ color: option.color }} />
<span className="text-sm font-medium text-foreground">{option.label}</span>
</button>
);
})}

File diff suppressed because it is too large Load Diff

View File

@@ -2,115 +2,342 @@ import {
type LucideIcon,
Atom,
Cat,
Cherry,
CloudSun,
Coffee,
Eclipse,
Feather,
Flame,
Flower2,
Ghost,
Github,
Heart,
Leaf,
Moon,
Palmtree,
Radio,
Scroll,
Snowflake,
Sparkles,
Square,
Sun,
Sunrise,
Terminal,
Trees,
} from "lucide-react";
import { Theme } from "@/components/views/settings-view/shared/types";
Waves,
Wind,
} from 'lucide-react';
// Theme value type - all available themes
export type Theme =
// Dark themes (16)
| 'dark'
| 'retro'
| 'dracula'
| 'nord'
| 'monokai'
| 'tokyonight'
| 'solarized'
| 'gruvbox'
| 'catppuccin'
| 'onedark'
| 'synthwave'
| 'red'
| 'sunset'
| 'gray'
| 'forest'
| 'ocean'
// Light themes (16)
| 'light'
| 'cream'
| 'solarizedlight'
| 'github'
| 'paper'
| 'rose'
| 'mint'
| 'lavender'
| 'sand'
| 'sky'
| 'peach'
| 'snow'
| 'sepia'
| 'gruvboxlight'
| 'nordlight'
| 'blossom';
export interface ThemeOption {
value: Theme;
label: string;
Icon: LucideIcon;
testId: string;
isDark: boolean;
color: string; // Primary/brand color for icon display
}
// All theme options with dark/light categorization
export const themeOptions: ReadonlyArray<ThemeOption> = [
{ value: "dark", label: "Dark", Icon: Moon, testId: "dark-mode-button" },
{ value: "light", label: "Light", Icon: Sun, testId: "light-mode-button" },
// Dark themes (16)
{
value: "retro",
label: "Retro",
value: 'dark',
label: 'Dark',
Icon: Moon,
testId: 'dark-mode-button',
isDark: true,
color: '#3b82f6',
},
{
value: 'retro',
label: 'Retro',
Icon: Terminal,
testId: "retro-mode-button",
testId: 'retro-mode-button',
isDark: true,
color: '#22c55e',
},
{
value: "dracula",
label: "Dracula",
value: 'dracula',
label: 'Dracula',
Icon: Ghost,
testId: "dracula-mode-button",
testId: 'dracula-mode-button',
isDark: true,
color: '#bd93f9',
},
{
value: "nord",
label: "Nord",
value: 'nord',
label: 'Nord',
Icon: Snowflake,
testId: "nord-mode-button",
testId: 'nord-mode-button',
isDark: true,
color: '#88c0d0',
},
{
value: "monokai",
label: "Monokai",
value: 'monokai',
label: 'Monokai',
Icon: Flame,
testId: "monokai-mode-button",
testId: 'monokai-mode-button',
isDark: true,
color: '#f92672',
},
{
value: "tokyonight",
label: "Tokyo Night",
value: 'tokyonight',
label: 'Tokyo Night',
Icon: Sparkles,
testId: "tokyonight-mode-button",
testId: 'tokyonight-mode-button',
isDark: true,
color: '#bb9af7',
},
{
value: "solarized",
label: "Solarized",
value: 'solarized',
label: 'Solarized Dark',
Icon: Eclipse,
testId: "solarized-mode-button",
testId: 'solarized-mode-button',
isDark: true,
color: '#268bd2',
},
{
value: "gruvbox",
label: "Gruvbox",
value: 'gruvbox',
label: 'Gruvbox',
Icon: Trees,
testId: "gruvbox-mode-button",
testId: 'gruvbox-mode-button',
isDark: true,
color: '#fe8019',
},
{
value: "catppuccin",
label: "Catppuccin",
value: 'catppuccin',
label: 'Catppuccin',
Icon: Cat,
testId: "catppuccin-mode-button",
testId: 'catppuccin-mode-button',
isDark: true,
color: '#cba6f7',
},
{
value: "onedark",
label: "One Dark",
value: 'onedark',
label: 'One Dark',
Icon: Atom,
testId: "onedark-mode-button",
testId: 'onedark-mode-button',
isDark: true,
color: '#61afef',
},
{
value: "synthwave",
label: "Synthwave",
value: 'synthwave',
label: 'Synthwave',
Icon: Radio,
testId: "synthwave-mode-button",
testId: 'synthwave-mode-button',
isDark: true,
color: '#ff7edb',
},
{
value: "red",
label: "Red",
value: 'red',
label: 'Red',
Icon: Heart,
testId: "red-mode-button",
testId: 'red-mode-button',
isDark: true,
color: '#ef4444',
},
{
value: "cream",
label: "Cream",
Icon: Coffee,
testId: "cream-mode-button",
},
{
value: "sunset",
label: "Sunset",
value: 'sunset',
label: 'Sunset',
Icon: CloudSun,
testId: "sunset-mode-button",
testId: 'sunset-mode-button',
isDark: true,
color: '#f97316',
},
{
value: "gray",
label: "Gray",
value: 'gray',
label: 'Gray',
Icon: Square,
testId: "gray-mode-button",
testId: 'gray-mode-button',
isDark: true,
color: '#6b7280',
},
{
value: 'forest',
label: 'Forest',
Icon: Leaf,
testId: 'forest-mode-button',
isDark: true,
color: '#22c55e',
},
{
value: 'ocean',
label: 'Ocean',
Icon: Waves,
testId: 'ocean-mode-button',
isDark: true,
color: '#06b6d4',
},
// Light themes (16)
{
value: 'light',
label: 'Light',
Icon: Sun,
testId: 'light-mode-button',
isDark: false,
color: '#3b82f6',
},
{
value: 'cream',
label: 'Cream',
Icon: Coffee,
testId: 'cream-mode-button',
isDark: false,
color: '#b45309',
},
{
value: 'solarizedlight',
label: 'Solarized Light',
Icon: Sunrise,
testId: 'solarizedlight-mode-button',
isDark: false,
color: '#268bd2',
},
{
value: 'github',
label: 'GitHub',
Icon: Github,
testId: 'github-mode-button',
isDark: false,
color: '#0969da',
},
{
value: 'paper',
label: 'Paper',
Icon: Scroll,
testId: 'paper-mode-button',
isDark: false,
color: '#374151',
},
{
value: 'rose',
label: 'Rose',
Icon: Flower2,
testId: 'rose-mode-button',
isDark: false,
color: '#e11d48',
},
{
value: 'mint',
label: 'Mint',
Icon: Wind,
testId: 'mint-mode-button',
isDark: false,
color: '#0d9488',
},
{
value: 'lavender',
label: 'Lavender',
Icon: Feather,
testId: 'lavender-mode-button',
isDark: false,
color: '#8b5cf6',
},
{
value: 'sand',
label: 'Sand',
Icon: Palmtree,
testId: 'sand-mode-button',
isDark: false,
color: '#d97706',
},
{
value: 'sky',
label: 'Sky',
Icon: Sun,
testId: 'sky-mode-button',
isDark: false,
color: '#0284c7',
},
{
value: 'peach',
label: 'Peach',
Icon: Cherry,
testId: 'peach-mode-button',
isDark: false,
color: '#ea580c',
},
{
value: 'snow',
label: 'Snow',
Icon: Snowflake,
testId: 'snow-mode-button',
isDark: false,
color: '#3b82f6',
},
{
value: 'sepia',
label: 'Sepia',
Icon: Coffee,
testId: 'sepia-mode-button',
isDark: false,
color: '#92400e',
},
{
value: 'gruvboxlight',
label: 'Gruvbox Light',
Icon: Trees,
testId: 'gruvboxlight-mode-button',
isDark: false,
color: '#d65d0e',
},
{
value: 'nordlight',
label: 'Nord Light',
Icon: Snowflake,
testId: 'nordlight-mode-button',
isDark: false,
color: '#5e81ac',
},
{
value: 'blossom',
label: 'Blossom',
Icon: Cherry,
testId: 'blossom-mode-button',
isDark: false,
color: '#ec4899',
},
];
// Helper: Get only dark themes
export const darkThemes = themeOptions.filter((t) => t.isDark);
// Helper: Get only light themes
export const lightThemes = themeOptions.filter((t) => !t.isDark);

View File

@@ -1,28 +1,26 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import { Toaster } from "sonner";
import { ThemeOption, themeOptions } from "@/config/theme-options";
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
import { Sidebar } from '@/components/layout/sidebar';
import {
FileBrowserProvider,
useFileBrowser,
setGlobalFileBrowser,
} from '@/contexts/file-browser-context';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
function RootLayoutContent() {
const location = useLocation();
const {
setIpcConnected,
theme,
currentProject,
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { setIpcConnected, theme, currentProject, previewTheme, getEffectiveTheme } = useAppStore();
const { setupComplete } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const [setupHydrated, setSetupHydrated] = useState(() =>
useSetupStore.persist?.hasHydrated?.() ?? false
const [setupHydrated, setSetupHydrated] = useState(
() => useSetupStore.persist?.hasHydrated?.() ?? false
);
const { openFileBrowser } = useFileBrowser();
@@ -31,14 +29,14 @@ function RootLayoutContent() {
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return;
}
if (activeElement.getAttribute("contenteditable") === "true") {
if (activeElement.getAttribute('contenteditable') === 'true') {
return;
}
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
const role = activeElement.getAttribute('role');
if (role === 'textbox' || role === 'searchbox' || role === 'combobox') {
return;
}
}
@@ -47,20 +45,22 @@ function RootLayoutContent() {
return;
}
if (event.key === "\\") {
if (event.key === '\\') {
event.preventDefault();
setStreamerPanelOpen((prev) => !prev);
}
}, []);
useEffect(() => {
window.addEventListener("keydown", handleStreamerPanelShortcut);
window.addEventListener('keydown', handleStreamerPanelShortcut);
return () => {
window.removeEventListener("keydown", handleStreamerPanelShortcut);
window.removeEventListener('keydown', handleStreamerPanelShortcut);
};
}, [handleStreamerPanelShortcut]);
const effectiveTheme = getEffectiveTheme();
// Defer the theme value to keep UI responsive during rapid hover changes
const deferredTheme = useDeferredValue(effectiveTheme);
useEffect(() => {
setIsMounted(true);
@@ -78,7 +78,7 @@ function RootLayoutContent() {
});
return () => {
if (typeof unsubscribe === "function") {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
@@ -88,10 +88,10 @@ function RootLayoutContent() {
useEffect(() => {
if (!setupHydrated) return;
if (!setupComplete && location.pathname !== "/setup") {
navigate({ to: "/setup" });
} else if (setupComplete && location.pathname === "/setup") {
navigate({ to: "/" });
if (!setupComplete && location.pathname !== '/setup') {
navigate({ to: '/setup' });
} else if (setupComplete && location.pathname === '/setup') {
navigate({ to: '/' });
}
}, [setupComplete, setupHydrated, location.pathname, navigate]);
@@ -105,9 +105,9 @@ function RootLayoutContent() {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong");
setIpcConnected(result === 'pong');
} catch (error) {
console.error("IPC connection failed:", error);
console.error('IPC connection failed:', error);
setIpcConnected(false);
}
};
@@ -117,34 +117,34 @@ function RootLayoutContent() {
// Restore to board view if a project was previously open
useEffect(() => {
if (isMounted && currentProject && location.pathname === "/") {
navigate({ to: "/board" });
if (isMounted && currentProject && location.pathname === '/') {
navigate({ to: '/board' });
}
}, [isMounted, currentProject, location.pathname, navigate]);
// Apply theme class to document
// Apply theme class to document - use deferred value to avoid blocking UI
useEffect(() => {
const root = document.documentElement;
// Remove all theme classes dynamically from themeOptions
const themeClasses = themeOptions
.map((option) => option.value)
.filter((theme) => theme !== "system" as ThemeOption['value']);
.filter((theme) => theme !== ('system' as ThemeOption['value']));
root.classList.remove(...themeClasses);
if (effectiveTheme === "dark") {
root.classList.add("dark");
} else if (effectiveTheme === "system") {
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(isDark ? "dark" : "light");
} else if (effectiveTheme && effectiveTheme !== "light") {
root.classList.add(effectiveTheme);
if (deferredTheme === 'dark') {
root.classList.add('dark');
} else if (deferredTheme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.add(isDark ? 'dark' : 'light');
} else if (deferredTheme && deferredTheme !== 'light') {
root.classList.add(deferredTheme);
} else {
root.classList.add("light");
root.classList.add('light');
}
}, [effectiveTheme, previewTheme, currentProject, theme]);
}, [deferredTheme]);
// Setup view is full-screen without sidebar
const isSetupRoute = location.pathname === "/setup";
const isSetupRoute = location.pathname === '/setup';
if (isSetupRoute) {
return (
@@ -159,7 +159,7 @@ function RootLayoutContent() {
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
>
<Outlet />
</div>
@@ -167,7 +167,7 @@ function RootLayoutContent() {
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
}`}
/>
<Toaster richColors position="bottom-right" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@import 'tw-animate-css';
/* Dark themes */
@custom-variant dark (&:is(.dark *));
@custom-variant retro (&:is(.retro *));
@custom-variant dracula (&:is(.dracula *));
@@ -13,9 +14,27 @@
@custom-variant onedark (&:is(.onedark *));
@custom-variant synthwave (&:is(.synthwave *));
@custom-variant red (&:is(.red *));
@custom-variant cream (&:is(.cream *));
@custom-variant sunset (&:is(.sunset *));
@custom-variant gray (&:is(.gray *));
@custom-variant forest (&:is(.forest *));
@custom-variant ocean (&:is(.ocean *));
/* Light themes */
@custom-variant cream (&:is(.cream *));
@custom-variant solarizedlight (&:is(.solarizedlight *));
@custom-variant github (&:is(.github *));
@custom-variant paper (&:is(.paper *));
@custom-variant rose (&:is(.rose *));
@custom-variant mint (&:is(.mint *));
@custom-variant lavender (&:is(.lavender *));
@custom-variant sand (&:is(.sand *));
@custom-variant sky (&:is(.sky *));
@custom-variant peach (&:is(.peach *));
@custom-variant snow (&:is(.snow *));
@custom-variant sepia (&:is(.sepia *));
@custom-variant gruvboxlight (&:is(.gruvboxlight *));
@custom-variant nordlight (&:is(.nordlight *));
@custom-variant blossom (&:is(.blossom *));
@theme inline {
--color-background: var(--background);
@@ -201,7 +220,7 @@
/* Card and popover backgrounds */
--card: oklch(0.14 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.10 0 0);
--popover: oklch(0.1 0 0);
--popover-foreground: oklch(1 0 0);
/* Brand colors - purple/violet theme */
@@ -336,7 +355,6 @@
--running-indicator-text: oklch(0.6 0.22 265);
}
@layer base {
* {
@apply border-border outline-ring/50;
@@ -351,19 +369,19 @@
/* Ensure all clickable elements show pointer cursor */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
[role='button']:not([aria-disabled='true']),
a[href],
input[type="button"]:not(:disabled),
input[type="submit"]:not(:disabled),
input[type="reset"]:not(:disabled),
input[type='button']:not(:disabled),
input[type='submit']:not(:disabled),
input[type='reset']:not(:disabled),
select:not(:disabled),
[tabindex]:not([tabindex="-1"]):not(:disabled) {
[tabindex]:not([tabindex='-1']):not(:disabled) {
cursor: pointer;
}
/* Disabled elements should show not-allowed cursor */
button:disabled,
[role="button"][aria-disabled="true"],
[role='button'][aria-disabled='true'],
input:disabled,
select:disabled,
textarea:disabled {
@@ -372,12 +390,44 @@
}
/* Custom scrollbar for dark themes */
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar {
:is(
.dark,
.retro,
.dracula,
.nord,
.monokai,
.tokyonight,
.solarized,
.gruvbox,
.catppuccin,
.onedark,
.synthwave,
.red,
.sunset,
.gray
)
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track {
:is(
.dark,
.retro,
.dracula,
.nord,
.monokai,
.tokyonight,
.solarized,
.gruvbox,
.catppuccin,
.onedark,
.synthwave,
.red,
.sunset,
.gray
)
::-webkit-scrollbar-track {
background: var(--muted);
}
@@ -392,11 +442,11 @@
/* Retro Scrollbar override */
.retro ::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 0;
background: var(--primary);
border-radius: 0;
}
.retro ::-webkit-scrollbar-track {
background: var(--background);
background: var(--background);
}
/* Red theme scrollbar */
@@ -518,8 +568,6 @@
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Hover state utilities */
.hover-glass {
@@ -543,8 +591,6 @@
background: var(--background);
}
/* Action button utilities */
.bg-action-view {
background-color: var(--action-view);
@@ -588,8 +634,9 @@
}
.shadow-running-indicator\/50 {
box-shadow: 0 10px 15px -3px color-mix(in oklch, var(--running-indicator), transparent 50%),
0 4px 6px -4px color-mix(in oklch, var(--running-indicator), transparent 50%);
box-shadow:
0 10px 15px -3px color-mix(in oklch, var(--running-indicator), transparent 50%),
0 4px 6px -4px color-mix(in oklch, var(--running-indicator), transparent 50%);
}
.text-running-indicator {
@@ -632,132 +679,78 @@
/* Retro Overrides for Utilities */
.retro * {
border-radius: 0 !important;
border-radius: 0 !important;
}
/* Animated Outline Button Styles */
.animated-outline-gradient {
/* Default gradient - purple to blue */
background: conic-gradient(from 90deg at 50% 50%, #a855f7 0%, #3b82f6 50%, #a855f7 100%);
/* Default gradient - purple to blue */
background: conic-gradient(from 90deg at 50% 50%, #a855f7 0%, #3b82f6 50%, #a855f7 100%);
}
/* Light mode - deeper purple to blue gradient for better visibility */
/* Dark mode - purple to blue gradient */
/* Retro mode - unique scanline + neon effect */
@keyframes retro-glow {
from {
filter: brightness(1) drop-shadow(0 0 2px #00ff41);
}
to {
filter: brightness(1.2) drop-shadow(0 0 8px #00ff41);
}
from {
filter: brightness(1) drop-shadow(0 0 2px #00ff41);
}
to {
filter: brightness(1.2) drop-shadow(0 0 8px #00ff41);
}
}
/* Dracula animated-outline - purple/pink */
/* Nord animated-outline - frost blue */
/* Monokai animated-outline - pink/yellow */
/* Tokyo Night animated-outline - blue/magenta */
/* Solarized animated-outline - blue/cyan */
/* Gruvbox animated-outline - yellow/orange */
/* Catppuccin animated-outline - mauve/pink */
/* One Dark animated-outline - blue/magenta */
/* Synthwave animated-outline - hot pink/cyan with glow */
@keyframes synthwave-glow {
from {
filter: brightness(1) drop-shadow(0 0 3px #f97e72);
}
to {
filter: brightness(1.3) drop-shadow(0 0 10px #ff7edb);
}
from {
filter: brightness(1) drop-shadow(0 0 3px #f97e72);
}
to {
filter: brightness(1.3) drop-shadow(0 0 10px #ff7edb);
}
}
/* Slider Theme Styles */
/* Dracula slider */
/* Nord slider */
/* Monokai slider */
/* Tokyo Night slider */
/* Solarized slider */
/* Gruvbox slider */
/* Catppuccin slider */
/* One Dark slider */
/* Synthwave slider */
/* Line clamp utilities for text overflow prevention */
.line-clamp-2 {
display: -webkit-box;
@@ -807,136 +800,28 @@
/* Light theme - professional and readable */
/* Dark theme - high contrast */
/* Retro theme - neon green on black */
/* Dracula theme */
/* Nord theme */
/* Monokai theme */
/* Tokyo Night theme */
/* Solarized theme */
/* Gruvbox theme */
/* Catppuccin theme */
/* One Dark theme */
/* Synthwave theme */
/* XML Editor container styles */
.xml-editor {
position: relative;

View File

@@ -2,21 +2,39 @@
* Bundles all individual theme styles so the build pipeline
* doesn't tree-shake their CSS when imported dynamically.
*/
import "./themes/dark.css";
import "./themes/light.css";
import "./themes/retro.css";
import "./themes/dracula.css";
import "./themes/nord.css";
import "./themes/monokai.css";
import "./themes/tokyonight.css";
import "./themes/solarized.css";
import "./themes/gruvbox.css";
import "./themes/catppuccin.css";
import "./themes/onedark.css";
import "./themes/synthwave.css";
import "./themes/red.css";
import "./themes/cream.css";
import "./themes/sunset.css";
import "./themes/gray.css";
// Dark themes (16)
import './themes/dark.css';
import './themes/retro.css';
import './themes/dracula.css';
import './themes/nord.css';
import './themes/monokai.css';
import './themes/tokyonight.css';
import './themes/solarized.css';
import './themes/gruvbox.css';
import './themes/catppuccin.css';
import './themes/onedark.css';
import './themes/synthwave.css';
import './themes/red.css';
import './themes/sunset.css';
import './themes/gray.css';
import './themes/forest.css';
import './themes/ocean.css';
// Light themes (16)
import './themes/light.css';
import './themes/cream.css';
import './themes/solarizedlight.css';
import './themes/github.css';
import './themes/paper.css';
import './themes/rose.css';
import './themes/mint.css';
import './themes/lavender.css';
import './themes/sand.css';
import './themes/sky.css';
import './themes/peach.css';
import './themes/snow.css';
import './themes/sepia.css';
import './themes/gruvboxlight.css';
import './themes/nordlight.css';
import './themes/blossom.css';

View File

@@ -0,0 +1,92 @@
/* Blossom Theme - Cherry blossom pink */
.blossom {
--background: oklch(0.98 0.02 330);
--background-50: oklch(0.98 0.02 330 / 0.5);
--background-80: oklch(0.98 0.02 330 / 0.8);
--foreground: oklch(0.25 0.04 340);
--foreground-secondary: oklch(0.45 0.03 340);
--foreground-muted: oklch(0.55 0.025 340);
--card: oklch(0.99 0.015 330);
--card-foreground: oklch(0.25 0.04 340);
--popover: oklch(0.99 0.015 330);
--popover-foreground: oklch(0.25 0.04 340);
--primary: oklch(0.65 0.15 340);
--primary-foreground: oklch(0.99 0.015 330);
--brand-400: oklch(0.7 0.13 340);
--brand-500: oklch(0.65 0.15 340);
--brand-600: oklch(0.6 0.17 340);
--secondary: oklch(0.95 0.025 330);
--secondary-foreground: oklch(0.25 0.04 340);
--muted: oklch(0.94 0.028 330);
--muted-foreground: oklch(0.5 0.03 340);
--accent: oklch(0.92 0.032 330);
--accent-foreground: oklch(0.25 0.04 340);
--destructive: oklch(0.55 0.2 15);
--border: oklch(0.9 0.025 330);
--border-glass: oklch(0.65 0.15 340 / 0.15);
--input: oklch(0.98 0.018 330);
--ring: oklch(0.65 0.15 340);
--chart-1: oklch(0.65 0.15 340);
--chart-2: oklch(0.6 0.15 300);
--chart-3: oklch(0.55 0.15 15);
--chart-4: oklch(0.6 0.12 350);
--chart-5: oklch(0.55 0.1 280);
--sidebar: oklch(0.97 0.022 330);
--sidebar-foreground: oklch(0.25 0.04 340);
--sidebar-primary: oklch(0.65 0.15 340);
--sidebar-primary-foreground: oklch(0.99 0.015 330);
--sidebar-accent: oklch(0.94 0.028 330);
--sidebar-accent-foreground: oklch(0.25 0.04 340);
--sidebar-border: oklch(0.9 0.025 330);
--sidebar-ring: oklch(0.65 0.15 340);
--action-view: oklch(0.65 0.15 340);
--action-view-hover: oklch(0.6 0.17 340);
--action-followup: oklch(0.6 0.15 300);
--action-followup-hover: oklch(0.55 0.17 300);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.65 0.15 340);
--running-indicator-text: oklch(0.6 0.17 340);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 15);
--status-error-bg: oklch(0.55 0.2 15 / 0.15);
--status-info: oklch(0.55 0.15 230);
--status-info-bg: oklch(0.55 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.025 340);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.blossom .content-bg {
background: linear-gradient(
135deg,
oklch(0.98 0.02 330),
oklch(0.97 0.025 330),
oklch(0.98 0.02 330)
);
}
.blossom .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #ec4899 0%, #f472b6 50%, #ec4899 100%);
}
.blossom .animated-outline-inner {
background: oklch(0.99 0.015 330) !important;
color: #ec4899 !important;
}

View File

@@ -0,0 +1,98 @@
/* Forest Theme - Deep green dark theme */
.forest {
--background: oklch(0.12 0.02 150);
--background-50: oklch(0.12 0.02 150 / 0.5);
--background-80: oklch(0.12 0.02 150 / 0.8);
--foreground: oklch(0.95 0.02 150);
--foreground-secondary: oklch(0.7 0.03 150);
--foreground-muted: oklch(0.6 0.03 150);
--card: oklch(0.16 0.025 150);
--card-foreground: oklch(0.95 0.02 150);
--popover: oklch(0.14 0.022 150);
--popover-foreground: oklch(0.95 0.02 150);
--primary: oklch(0.6 0.18 145);
--primary-foreground: oklch(0.12 0.02 150);
--brand-400: oklch(0.65 0.16 145);
--brand-500: oklch(0.6 0.18 145);
--brand-600: oklch(0.55 0.2 145);
--secondary: oklch(1 0 0 / 0.05);
--secondary-foreground: oklch(0.95 0.02 150);
--muted: oklch(0.2 0.03 150);
--muted-foreground: oklch(0.65 0.03 150);
--accent: oklch(1 0 0 / 0.08);
--accent-foreground: oklch(0.95 0.02 150);
--destructive: oklch(0.6 0.22 25);
--border: oklch(0.25 0.04 150);
--border-glass: oklch(1 0 0 / 0.1);
--input: oklch(0.14 0.022 150);
--ring: oklch(0.6 0.18 145);
--chart-1: oklch(0.6 0.18 145);
--chart-2: oklch(0.65 0.15 100);
--chart-3: oklch(0.7 0.12 180);
--chart-4: oklch(0.6 0.2 200);
--chart-5: oklch(0.55 0.15 50);
--sidebar: oklch(0.1 0.02 150 / 0.8);
--sidebar-foreground: oklch(0.95 0.02 150);
--sidebar-primary: oklch(0.6 0.18 145);
--sidebar-primary-foreground: oklch(0.12 0.02 150);
--sidebar-accent: oklch(1 0 0 / 0.05);
--sidebar-accent-foreground: oklch(0.95 0.02 150);
--sidebar-border: oklch(1 0 0 / 0.1);
--sidebar-ring: oklch(0.6 0.18 145);
--action-view: oklch(0.6 0.18 145);
--action-view-hover: oklch(0.55 0.2 145);
--action-followup: oklch(0.6 0.15 180);
--action-followup-hover: oklch(0.55 0.17 180);
--action-commit: oklch(0.6 0.18 145);
--action-commit-hover: oklch(0.55 0.2 145);
--action-verify: oklch(0.6 0.18 145);
--action-verify-hover: oklch(0.55 0.2 145);
--running-indicator: oklch(0.6 0.18 145);
--running-indicator-text: oklch(0.65 0.16 145);
--status-success: oklch(0.65 0.18 145);
--status-success-bg: oklch(0.65 0.18 145 / 0.2);
--status-warning: oklch(0.7 0.15 80);
--status-warning-bg: oklch(0.7 0.15 80 / 0.2);
--status-error: oklch(0.6 0.22 25);
--status-error-bg: oklch(0.6 0.22 25 / 0.2);
--status-info: oklch(0.6 0.15 180);
--status-info-bg: oklch(0.6 0.15 180 / 0.2);
--status-backlog: oklch(0.55 0.03 150);
--status-in-progress: oklch(0.7 0.15 80);
--status-waiting: oklch(0.65 0.12 60);
--shadow-xs: 0 1px 2px rgba(0, 20, 10, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 20, 10, 0.4), 0 1px 2px rgba(0, 20, 10, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 20, 10, 0.4), 0 2px 4px -1px rgba(0, 20, 10, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 20, 10, 0.4), 0 4px 6px -2px rgba(0, 20, 10, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 20, 10, 0.5), 0 10px 10px -5px rgba(0, 20, 10, 0.3);
}
.forest .content-bg {
background: linear-gradient(
135deg,
oklch(0.12 0.02 150),
oklch(0.15 0.025 150),
oklch(0.12 0.02 150)
);
}
.forest .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #22c55e 0%, #16a34a 50%, #22c55e 100%);
}
.forest .animated-outline-inner {
background: oklch(0.16 0.025 150) !important;
color: #86efac !important;
}

View File

@@ -0,0 +1,87 @@
/* GitHub Light Theme */
.github {
--background: oklch(0.99 0 0);
--background-50: oklch(0.99 0 0 / 0.5);
--background-80: oklch(0.99 0 0 / 0.8);
--foreground: oklch(0.2 0 0);
--foreground-secondary: oklch(0.4 0 0);
--foreground-muted: oklch(0.55 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0 0);
--primary: oklch(0.5 0.15 250);
--primary-foreground: oklch(1 0 0);
--brand-400: oklch(0.55 0.13 250);
--brand-500: oklch(0.5 0.15 250);
--brand-600: oklch(0.45 0.17 250);
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.2 0 0);
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.2 0 0);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.88 0 0);
--border-glass: oklch(0.2 0 0 / 0.1);
--input: oklch(0.98 0 0);
--ring: oklch(0.5 0.15 250);
--chart-1: oklch(0.5 0.15 250);
--chart-2: oklch(0.55 0.18 145);
--chart-3: oklch(0.6 0.15 45);
--chart-4: oklch(0.55 0.15 300);
--chart-5: oklch(0.55 0.18 25);
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.2 0 0);
--sidebar-primary: oklch(0.5 0.15 250);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0.2 0 0);
--sidebar-border: oklch(0.88 0 0);
--sidebar-ring: oklch(0.5 0.15 250);
--action-view: oklch(0.5 0.15 250);
--action-view-hover: oklch(0.45 0.17 250);
--action-followup: oklch(0.55 0.15 230);
--action-followup-hover: oklch(0.5 0.17 230);
--action-commit: oklch(0.55 0.18 145);
--action-commit-hover: oklch(0.5 0.2 145);
--action-verify: oklch(0.55 0.18 145);
--action-verify-hover: oklch(0.5 0.2 145);
--running-indicator: oklch(0.5 0.15 250);
--running-indicator-text: oklch(0.45 0.17 250);
--status-success: oklch(0.55 0.18 145);
--status-success-bg: oklch(0.55 0.18 145 / 0.15);
--status-warning: oklch(0.65 0.15 80);
--status-warning-bg: oklch(0.65 0.15 80 / 0.15);
--status-error: oklch(0.55 0.22 25);
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
--status-info: oklch(0.5 0.15 250);
--status-info-bg: oklch(0.5 0.15 250 / 0.15);
--status-backlog: oklch(0.55 0 0);
--status-in-progress: oklch(0.65 0.15 80);
--status-waiting: oklch(0.6 0.12 50);
}
.github .content-bg {
background: linear-gradient(135deg, oklch(0.99 0 0), oklch(0.98 0 0), oklch(0.99 0 0));
}
.github .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #0969da 0%, #1f883d 50%, #0969da 100%);
}
.github .animated-outline-inner {
background: oklch(1 0 0) !important;
color: #0969da !important;
}

View File

@@ -0,0 +1,92 @@
/* Gruvbox Light Theme */
.gruvboxlight {
--background: oklch(0.96 0.02 85);
--background-50: oklch(0.96 0.02 85 / 0.5);
--background-80: oklch(0.96 0.02 85 / 0.8);
--foreground: oklch(0.25 0.04 65);
--foreground-secondary: oklch(0.4 0.04 65);
--foreground-muted: oklch(0.55 0.03 65);
--card: oklch(0.98 0.015 85);
--card-foreground: oklch(0.25 0.04 65);
--popover: oklch(0.97 0.018 85);
--popover-foreground: oklch(0.25 0.04 65);
--primary: oklch(0.55 0.15 70);
--primary-foreground: oklch(0.98 0.015 85);
--brand-400: oklch(0.6 0.13 70);
--brand-500: oklch(0.55 0.15 70);
--brand-600: oklch(0.5 0.17 70);
--secondary: oklch(0.93 0.025 85);
--secondary-foreground: oklch(0.25 0.04 65);
--muted: oklch(0.92 0.028 85);
--muted-foreground: oklch(0.5 0.03 65);
--accent: oklch(0.9 0.032 85);
--accent-foreground: oklch(0.25 0.04 65);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.87 0.03 85);
--border-glass: oklch(0.55 0.15 70 / 0.15);
--input: oklch(0.97 0.018 85);
--ring: oklch(0.55 0.15 70);
--chart-1: oklch(0.55 0.15 70);
--chart-2: oklch(0.55 0.18 25);
--chart-3: oklch(0.5 0.15 145);
--chart-4: oklch(0.55 0.15 230);
--chart-5: oklch(0.55 0.15 300);
--sidebar: oklch(0.95 0.022 85);
--sidebar-foreground: oklch(0.25 0.04 65);
--sidebar-primary: oklch(0.55 0.15 70);
--sidebar-primary-foreground: oklch(0.98 0.015 85);
--sidebar-accent: oklch(0.92 0.028 85);
--sidebar-accent-foreground: oklch(0.25 0.04 65);
--sidebar-border: oklch(0.87 0.03 85);
--sidebar-ring: oklch(0.55 0.15 70);
--action-view: oklch(0.55 0.15 70);
--action-view-hover: oklch(0.5 0.17 70);
--action-followup: oklch(0.55 0.15 230);
--action-followup-hover: oklch(0.5 0.17 230);
--action-commit: oklch(0.5 0.15 145);
--action-commit-hover: oklch(0.45 0.17 145);
--action-verify: oklch(0.5 0.15 145);
--action-verify-hover: oklch(0.45 0.17 145);
--running-indicator: oklch(0.55 0.15 70);
--running-indicator-text: oklch(0.5 0.17 70);
--status-success: oklch(0.5 0.15 145);
--status-success-bg: oklch(0.5 0.15 145 / 0.15);
--status-warning: oklch(0.6 0.15 70);
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 25);
--status-error-bg: oklch(0.55 0.2 25 / 0.15);
--status-info: oklch(0.55 0.15 230);
--status-info-bg: oklch(0.55 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.03 65);
--status-in-progress: oklch(0.6 0.15 70);
--status-waiting: oklch(0.58 0.12 55);
}
.gruvboxlight .content-bg {
background: linear-gradient(
135deg,
oklch(0.96 0.02 85),
oklch(0.95 0.025 85),
oklch(0.96 0.02 85)
);
}
.gruvboxlight .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #d79921 0%, #b57614 50%, #d79921 100%);
}
.gruvboxlight .animated-outline-inner {
background: oklch(0.98 0.015 85) !important;
color: #b57614 !important;
}

View File

@@ -0,0 +1,92 @@
/* Lavender Theme - Soft purple/lavender */
.lavender {
--background: oklch(0.97 0.02 285);
--background-50: oklch(0.97 0.02 285 / 0.5);
--background-80: oklch(0.97 0.02 285 / 0.8);
--foreground: oklch(0.25 0.04 285);
--foreground-secondary: oklch(0.45 0.04 285);
--foreground-muted: oklch(0.55 0.03 285);
--card: oklch(0.98 0.015 285);
--card-foreground: oklch(0.25 0.04 285);
--popover: oklch(0.98 0.015 285);
--popover-foreground: oklch(0.25 0.04 285);
--primary: oklch(0.55 0.18 280);
--primary-foreground: oklch(0.98 0.015 285);
--brand-400: oklch(0.6 0.16 280);
--brand-500: oklch(0.55 0.18 280);
--brand-600: oklch(0.5 0.2 280);
--secondary: oklch(0.94 0.025 285);
--secondary-foreground: oklch(0.25 0.04 285);
--muted: oklch(0.93 0.028 285);
--muted-foreground: oklch(0.5 0.04 285);
--accent: oklch(0.91 0.032 285);
--accent-foreground: oklch(0.25 0.04 285);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.88 0.025 285);
--border-glass: oklch(0.55 0.18 280 / 0.15);
--input: oklch(0.97 0.018 285);
--ring: oklch(0.55 0.18 280);
--chart-1: oklch(0.55 0.18 280);
--chart-2: oklch(0.6 0.15 320);
--chart-3: oklch(0.55 0.15 250);
--chart-4: oklch(0.6 0.12 200);
--chart-5: oklch(0.55 0.15 350);
--sidebar: oklch(0.96 0.022 285);
--sidebar-foreground: oklch(0.25 0.04 285);
--sidebar-primary: oklch(0.55 0.18 280);
--sidebar-primary-foreground: oklch(0.98 0.015 285);
--sidebar-accent: oklch(0.93 0.028 285);
--sidebar-accent-foreground: oklch(0.25 0.04 285);
--sidebar-border: oklch(0.88 0.025 285);
--sidebar-ring: oklch(0.55 0.18 280);
--action-view: oklch(0.55 0.18 280);
--action-view-hover: oklch(0.5 0.2 280);
--action-followup: oklch(0.55 0.15 250);
--action-followup-hover: oklch(0.5 0.17 250);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.55 0.18 280);
--running-indicator-text: oklch(0.5 0.2 280);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 25);
--status-error-bg: oklch(0.55 0.2 25 / 0.15);
--status-info: oklch(0.55 0.15 250);
--status-info-bg: oklch(0.55 0.15 250 / 0.15);
--status-backlog: oklch(0.6 0.03 285);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.lavender .content-bg {
background: linear-gradient(
135deg,
oklch(0.97 0.02 285),
oklch(0.96 0.025 285),
oklch(0.97 0.02 285)
);
}
.lavender .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #a855f7 0%, #8b5cf6 50%, #a855f7 100%);
}
.lavender .animated-outline-inner {
background: oklch(0.98 0.015 285) !important;
color: #a855f7 !important;
}

View File

@@ -0,0 +1,92 @@
/* Mint Theme - Fresh mint green */
.mint {
--background: oklch(0.98 0.015 160);
--background-50: oklch(0.98 0.015 160 / 0.5);
--background-80: oklch(0.98 0.015 160 / 0.8);
--foreground: oklch(0.2 0.03 160);
--foreground-secondary: oklch(0.4 0.03 160);
--foreground-muted: oklch(0.55 0.02 160);
--card: oklch(0.99 0.01 160);
--card-foreground: oklch(0.2 0.03 160);
--popover: oklch(0.99 0.01 160);
--popover-foreground: oklch(0.2 0.03 160);
--primary: oklch(0.55 0.15 165);
--primary-foreground: oklch(0.99 0.01 160);
--brand-400: oklch(0.6 0.13 165);
--brand-500: oklch(0.55 0.15 165);
--brand-600: oklch(0.5 0.17 165);
--secondary: oklch(0.95 0.018 160);
--secondary-foreground: oklch(0.2 0.03 160);
--muted: oklch(0.94 0.02 160);
--muted-foreground: oklch(0.5 0.03 160);
--accent: oklch(0.92 0.025 160);
--accent-foreground: oklch(0.2 0.03 160);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.88 0.02 160);
--border-glass: oklch(0.55 0.15 165 / 0.15);
--input: oklch(0.98 0.012 160);
--ring: oklch(0.55 0.15 165);
--chart-1: oklch(0.55 0.15 165);
--chart-2: oklch(0.6 0.12 200);
--chart-3: oklch(0.55 0.15 140);
--chart-4: oklch(0.6 0.12 180);
--chart-5: oklch(0.55 0.1 230);
--sidebar: oklch(0.97 0.015 160);
--sidebar-foreground: oklch(0.2 0.03 160);
--sidebar-primary: oklch(0.55 0.15 165);
--sidebar-primary-foreground: oklch(0.99 0.01 160);
--sidebar-accent: oklch(0.94 0.02 160);
--sidebar-accent-foreground: oklch(0.2 0.03 160);
--sidebar-border: oklch(0.88 0.02 160);
--sidebar-ring: oklch(0.55 0.15 165);
--action-view: oklch(0.55 0.15 165);
--action-view-hover: oklch(0.5 0.17 165);
--action-followup: oklch(0.55 0.12 200);
--action-followup-hover: oklch(0.5 0.14 200);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.55 0.15 165);
--running-indicator-text: oklch(0.5 0.17 165);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 25);
--status-error-bg: oklch(0.55 0.2 25 / 0.15);
--status-info: oklch(0.55 0.12 200);
--status-info-bg: oklch(0.55 0.12 200 / 0.15);
--status-backlog: oklch(0.6 0.02 160);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.mint .content-bg {
background: linear-gradient(
135deg,
oklch(0.98 0.015 160),
oklch(0.97 0.018 160),
oklch(0.98 0.015 160)
);
}
.mint .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #10b981 0%, #14b8a6 50%, #10b981 100%);
}
.mint .animated-outline-inner {
background: oklch(0.99 0.01 160) !important;
color: #10b981 !important;
}

View File

@@ -0,0 +1,92 @@
/* Nord Light Theme */
.nordlight {
--background: oklch(0.97 0.01 220);
--background-50: oklch(0.97 0.01 220 / 0.5);
--background-80: oklch(0.97 0.01 220 / 0.8);
--foreground: oklch(0.25 0.03 220);
--foreground-secondary: oklch(0.45 0.025 220);
--foreground-muted: oklch(0.55 0.02 220);
--card: oklch(0.99 0.008 220);
--card-foreground: oklch(0.25 0.03 220);
--popover: oklch(0.98 0.01 220);
--popover-foreground: oklch(0.25 0.03 220);
--primary: oklch(0.55 0.12 225);
--primary-foreground: oklch(0.99 0.008 220);
--brand-400: oklch(0.6 0.1 225);
--brand-500: oklch(0.55 0.12 225);
--brand-600: oklch(0.5 0.14 225);
--secondary: oklch(0.94 0.012 220);
--secondary-foreground: oklch(0.25 0.03 220);
--muted: oklch(0.93 0.015 220);
--muted-foreground: oklch(0.5 0.025 220);
--accent: oklch(0.91 0.018 220);
--accent-foreground: oklch(0.25 0.03 220);
--destructive: oklch(0.55 0.18 25);
--border: oklch(0.88 0.015 220);
--border-glass: oklch(0.55 0.12 225 / 0.12);
--input: oklch(0.98 0.01 220);
--ring: oklch(0.55 0.12 225);
--chart-1: oklch(0.55 0.12 225);
--chart-2: oklch(0.55 0.15 25);
--chart-3: oklch(0.55 0.12 145);
--chart-4: oklch(0.55 0.12 300);
--chart-5: oklch(0.6 0.12 80);
--sidebar: oklch(0.96 0.01 220);
--sidebar-foreground: oklch(0.25 0.03 220);
--sidebar-primary: oklch(0.55 0.12 225);
--sidebar-primary-foreground: oklch(0.99 0.008 220);
--sidebar-accent: oklch(0.93 0.015 220);
--sidebar-accent-foreground: oklch(0.25 0.03 220);
--sidebar-border: oklch(0.88 0.015 220);
--sidebar-ring: oklch(0.55 0.12 225);
--action-view: oklch(0.55 0.12 225);
--action-view-hover: oklch(0.5 0.14 225);
--action-followup: oklch(0.55 0.12 200);
--action-followup-hover: oklch(0.5 0.14 200);
--action-commit: oklch(0.55 0.12 145);
--action-commit-hover: oklch(0.5 0.14 145);
--action-verify: oklch(0.55 0.12 145);
--action-verify-hover: oklch(0.5 0.14 145);
--running-indicator: oklch(0.55 0.12 225);
--running-indicator-text: oklch(0.5 0.14 225);
--status-success: oklch(0.55 0.12 145);
--status-success-bg: oklch(0.55 0.12 145 / 0.15);
--status-warning: oklch(0.65 0.12 80);
--status-warning-bg: oklch(0.65 0.12 80 / 0.15);
--status-error: oklch(0.55 0.18 25);
--status-error-bg: oklch(0.55 0.18 25 / 0.15);
--status-info: oklch(0.55 0.12 225);
--status-info-bg: oklch(0.55 0.12 225 / 0.15);
--status-backlog: oklch(0.6 0.02 220);
--status-in-progress: oklch(0.65 0.12 80);
--status-waiting: oklch(0.6 0.1 55);
}
.nordlight .content-bg {
background: linear-gradient(
135deg,
oklch(0.97 0.01 220),
oklch(0.96 0.012 220),
oklch(0.97 0.01 220)
);
}
.nordlight .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #5e81ac 0%, #81a1c1 50%, #5e81ac 100%);
}
.nordlight .animated-outline-inner {
background: oklch(0.99 0.008 220) !important;
color: #5e81ac !important;
}

View File

@@ -0,0 +1,98 @@
/* Ocean Theme - Deep blue dark theme */
.ocean {
--background: oklch(0.12 0.03 230);
--background-50: oklch(0.12 0.03 230 / 0.5);
--background-80: oklch(0.12 0.03 230 / 0.8);
--foreground: oklch(0.95 0.02 230);
--foreground-secondary: oklch(0.7 0.03 230);
--foreground-muted: oklch(0.6 0.03 230);
--card: oklch(0.16 0.035 230);
--card-foreground: oklch(0.95 0.02 230);
--popover: oklch(0.14 0.032 230);
--popover-foreground: oklch(0.95 0.02 230);
--primary: oklch(0.6 0.18 230);
--primary-foreground: oklch(0.12 0.03 230);
--brand-400: oklch(0.65 0.16 230);
--brand-500: oklch(0.6 0.18 230);
--brand-600: oklch(0.55 0.2 235);
--secondary: oklch(1 0 0 / 0.05);
--secondary-foreground: oklch(0.95 0.02 230);
--muted: oklch(0.2 0.04 230);
--muted-foreground: oklch(0.65 0.03 230);
--accent: oklch(1 0 0 / 0.08);
--accent-foreground: oklch(0.95 0.02 230);
--destructive: oklch(0.6 0.22 25);
--border: oklch(0.25 0.05 230);
--border-glass: oklch(1 0 0 / 0.1);
--input: oklch(0.14 0.032 230);
--ring: oklch(0.6 0.18 230);
--chart-1: oklch(0.6 0.18 230);
--chart-2: oklch(0.6 0.15 200);
--chart-3: oklch(0.65 0.15 180);
--chart-4: oklch(0.6 0.2 260);
--chart-5: oklch(0.55 0.15 300);
--sidebar: oklch(0.1 0.03 230 / 0.8);
--sidebar-foreground: oklch(0.95 0.02 230);
--sidebar-primary: oklch(0.6 0.18 230);
--sidebar-primary-foreground: oklch(0.12 0.03 230);
--sidebar-accent: oklch(1 0 0 / 0.05);
--sidebar-accent-foreground: oklch(0.95 0.02 230);
--sidebar-border: oklch(1 0 0 / 0.1);
--sidebar-ring: oklch(0.6 0.18 230);
--action-view: oklch(0.6 0.18 230);
--action-view-hover: oklch(0.55 0.2 235);
--action-followup: oklch(0.6 0.15 200);
--action-followup-hover: oklch(0.55 0.17 200);
--action-commit: oklch(0.6 0.15 180);
--action-commit-hover: oklch(0.55 0.17 180);
--action-verify: oklch(0.6 0.15 180);
--action-verify-hover: oklch(0.55 0.17 180);
--running-indicator: oklch(0.6 0.18 230);
--running-indicator-text: oklch(0.65 0.16 230);
--status-success: oklch(0.6 0.15 180);
--status-success-bg: oklch(0.6 0.15 180 / 0.2);
--status-warning: oklch(0.7 0.15 80);
--status-warning-bg: oklch(0.7 0.15 80 / 0.2);
--status-error: oklch(0.6 0.22 25);
--status-error-bg: oklch(0.6 0.22 25 / 0.2);
--status-info: oklch(0.6 0.18 230);
--status-info-bg: oklch(0.6 0.18 230 / 0.2);
--status-backlog: oklch(0.55 0.03 230);
--status-in-progress: oklch(0.7 0.15 80);
--status-waiting: oklch(0.65 0.12 60);
--shadow-xs: 0 1px 2px rgba(0, 10, 30, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 10, 30, 0.4), 0 1px 2px rgba(0, 10, 30, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 10, 30, 0.4), 0 2px 4px -1px rgba(0, 10, 30, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 10, 30, 0.4), 0 4px 6px -2px rgba(0, 10, 30, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 10, 30, 0.5), 0 10px 10px -5px rgba(0, 10, 30, 0.3);
}
.ocean .content-bg {
background: linear-gradient(
135deg,
oklch(0.12 0.03 230),
oklch(0.15 0.035 230),
oklch(0.12 0.03 230)
);
}
.ocean .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #3b82f6 0%, #0ea5e9 50%, #3b82f6 100%);
}
.ocean .animated-outline-inner {
background: oklch(0.16 0.035 230) !important;
color: #93c5fd !important;
}

View File

@@ -0,0 +1,92 @@
/* Paper Theme - Clean minimal white */
.paper {
--background: oklch(0.99 0 0);
--background-50: oklch(0.99 0 0 / 0.5);
--background-80: oklch(0.99 0 0 / 0.8);
--foreground: oklch(0.15 0 0);
--foreground-secondary: oklch(0.35 0 0);
--foreground-muted: oklch(0.5 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.3 0 0);
--primary-foreground: oklch(1 0 0);
--brand-400: oklch(0.35 0 0);
--brand-500: oklch(0.3 0 0);
--brand-600: oklch(0.25 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.95 0 0);
--accent-foreground: oklch(0.15 0 0);
--destructive: oklch(0.5 0.2 25);
--border: oklch(0.9 0 0);
--border-glass: oklch(0.15 0 0 / 0.08);
--input: oklch(0.98 0 0);
--ring: oklch(0.3 0 0);
--chart-1: oklch(0.3 0 0);
--chart-2: oklch(0.5 0 0);
--chart-3: oklch(0.4 0 0);
--chart-4: oklch(0.6 0 0);
--chart-5: oklch(0.35 0 0);
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-primary: oklch(0.3 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.95 0 0);
--sidebar-accent-foreground: oklch(0.15 0 0);
--sidebar-border: oklch(0.9 0 0);
--sidebar-ring: oklch(0.3 0 0);
--action-view: oklch(0.3 0 0);
--action-view-hover: oklch(0.25 0 0);
--action-followup: oklch(0.4 0 0);
--action-followup-hover: oklch(0.35 0 0);
--action-commit: oklch(0.35 0.1 145);
--action-commit-hover: oklch(0.3 0.12 145);
--action-verify: oklch(0.35 0.1 145);
--action-verify-hover: oklch(0.3 0.12 145);
--running-indicator: oklch(0.3 0 0);
--running-indicator-text: oklch(0.25 0 0);
--status-success: oklch(0.4 0.15 145);
--status-success-bg: oklch(0.4 0.15 145 / 0.12);
--status-warning: oklch(0.55 0.15 70);
--status-warning-bg: oklch(0.55 0.15 70 / 0.12);
--status-error: oklch(0.5 0.2 25);
--status-error-bg: oklch(0.5 0.2 25 / 0.12);
--status-info: oklch(0.45 0.12 230);
--status-info-bg: oklch(0.45 0.12 230 / 0.12);
--status-backlog: oklch(0.55 0 0);
--status-in-progress: oklch(0.55 0.15 70);
--status-waiting: oklch(0.5 0.1 50);
}
.paper .content-bg {
background: linear-gradient(135deg, oklch(0.99 0 0), oklch(0.98 0 0), oklch(0.99 0 0));
}
.paper .animated-outline-gradient {
background: conic-gradient(
from 90deg at 50% 50%,
oklch(0.3 0 0) 0%,
oklch(0.5 0 0) 50%,
oklch(0.3 0 0) 100%
);
}
.paper .animated-outline-inner {
background: oklch(1 0 0) !important;
color: oklch(0.2 0 0) !important;
}

View File

@@ -0,0 +1,92 @@
/* Peach Theme - Soft peach/coral tones */
.peach {
--background: oklch(0.98 0.02 50);
--background-50: oklch(0.98 0.02 50 / 0.5);
--background-80: oklch(0.98 0.02 50 / 0.8);
--foreground: oklch(0.25 0.04 40);
--foreground-secondary: oklch(0.45 0.03 40);
--foreground-muted: oklch(0.55 0.03 40);
--card: oklch(0.99 0.015 50);
--card-foreground: oklch(0.25 0.04 40);
--popover: oklch(0.99 0.015 50);
--popover-foreground: oklch(0.25 0.04 40);
--primary: oklch(0.65 0.15 40);
--primary-foreground: oklch(0.99 0.015 50);
--brand-400: oklch(0.7 0.13 40);
--brand-500: oklch(0.65 0.15 40);
--brand-600: oklch(0.6 0.17 40);
--secondary: oklch(0.95 0.025 50);
--secondary-foreground: oklch(0.25 0.04 40);
--muted: oklch(0.94 0.028 50);
--muted-foreground: oklch(0.5 0.03 40);
--accent: oklch(0.92 0.032 50);
--accent-foreground: oklch(0.25 0.04 40);
--destructive: oklch(0.55 0.2 20);
--border: oklch(0.9 0.025 50);
--border-glass: oklch(0.65 0.15 40 / 0.15);
--input: oklch(0.98 0.018 50);
--ring: oklch(0.65 0.15 40);
--chart-1: oklch(0.65 0.15 40);
--chart-2: oklch(0.6 0.15 25);
--chart-3: oklch(0.6 0.12 70);
--chart-4: oklch(0.55 0.12 350);
--chart-5: oklch(0.55 0.1 100);
--sidebar: oklch(0.97 0.022 50);
--sidebar-foreground: oklch(0.25 0.04 40);
--sidebar-primary: oklch(0.65 0.15 40);
--sidebar-primary-foreground: oklch(0.99 0.015 50);
--sidebar-accent: oklch(0.94 0.028 50);
--sidebar-accent-foreground: oklch(0.25 0.04 40);
--sidebar-border: oklch(0.9 0.025 50);
--sidebar-ring: oklch(0.65 0.15 40);
--action-view: oklch(0.65 0.15 40);
--action-view-hover: oklch(0.6 0.17 40);
--action-followup: oklch(0.6 0.15 25);
--action-followup-hover: oklch(0.55 0.17 25);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.65 0.15 40);
--running-indicator-text: oklch(0.6 0.17 40);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 20);
--status-error-bg: oklch(0.55 0.2 20 / 0.15);
--status-info: oklch(0.55 0.12 230);
--status-info-bg: oklch(0.55 0.12 230 / 0.15);
--status-backlog: oklch(0.6 0.03 40);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.peach .content-bg {
background: linear-gradient(
135deg,
oklch(0.98 0.02 50),
oklch(0.97 0.025 50),
oklch(0.98 0.02 50)
);
}
.peach .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #fb923c 0%, #f97316 50%, #fb923c 100%);
}
.peach .animated-outline-inner {
background: oklch(0.99 0.015 50) !important;
color: #f97316 !important;
}

View File

@@ -0,0 +1,92 @@
/* Rose Theme - Soft pink/rose tones */
.rose {
--background: oklch(0.98 0.01 350);
--background-50: oklch(0.98 0.01 350 / 0.5);
--background-80: oklch(0.98 0.01 350 / 0.8);
--foreground: oklch(0.25 0.03 350);
--foreground-secondary: oklch(0.45 0.03 350);
--foreground-muted: oklch(0.55 0.02 350);
--card: oklch(0.99 0.008 350);
--card-foreground: oklch(0.25 0.03 350);
--popover: oklch(0.99 0.008 350);
--popover-foreground: oklch(0.25 0.03 350);
--primary: oklch(0.6 0.18 350);
--primary-foreground: oklch(0.99 0.008 350);
--brand-400: oklch(0.65 0.16 350);
--brand-500: oklch(0.6 0.18 350);
--brand-600: oklch(0.55 0.2 350);
--secondary: oklch(0.95 0.015 350);
--secondary-foreground: oklch(0.25 0.03 350);
--muted: oklch(0.94 0.02 350);
--muted-foreground: oklch(0.5 0.03 350);
--accent: oklch(0.92 0.025 350);
--accent-foreground: oklch(0.25 0.03 350);
--destructive: oklch(0.55 0.2 15);
--border: oklch(0.9 0.02 350);
--border-glass: oklch(0.6 0.18 350 / 0.15);
--input: oklch(0.98 0.01 350);
--ring: oklch(0.6 0.18 350);
--chart-1: oklch(0.6 0.18 350);
--chart-2: oklch(0.65 0.15 320);
--chart-3: oklch(0.55 0.15 15);
--chart-4: oklch(0.6 0.15 280);
--chart-5: oklch(0.55 0.12 200);
--sidebar: oklch(0.97 0.012 350);
--sidebar-foreground: oklch(0.25 0.03 350);
--sidebar-primary: oklch(0.6 0.18 350);
--sidebar-primary-foreground: oklch(0.99 0.008 350);
--sidebar-accent: oklch(0.94 0.02 350);
--sidebar-accent-foreground: oklch(0.25 0.03 350);
--sidebar-border: oklch(0.9 0.02 350);
--sidebar-ring: oklch(0.6 0.18 350);
--action-view: oklch(0.6 0.18 350);
--action-view-hover: oklch(0.55 0.2 350);
--action-followup: oklch(0.6 0.15 320);
--action-followup-hover: oklch(0.55 0.17 320);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.6 0.18 350);
--running-indicator-text: oklch(0.55 0.2 350);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 15);
--status-error-bg: oklch(0.55 0.2 15 / 0.15);
--status-info: oklch(0.55 0.15 230);
--status-info-bg: oklch(0.55 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.02 350);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.rose .content-bg {
background: linear-gradient(
135deg,
oklch(0.98 0.01 350),
oklch(0.97 0.015 350),
oklch(0.98 0.01 350)
);
}
.rose .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #f43f5e 0%, #ec4899 50%, #f43f5e 100%);
}
.rose .animated-outline-inner {
background: oklch(0.99 0.008 350) !important;
color: #f43f5e !important;
}

View File

@@ -0,0 +1,92 @@
/* Sand Theme - Sandy beige tones */
.sand {
--background: oklch(0.96 0.02 80);
--background-50: oklch(0.96 0.02 80 / 0.5);
--background-80: oklch(0.96 0.02 80 / 0.8);
--foreground: oklch(0.25 0.03 70);
--foreground-secondary: oklch(0.45 0.03 70);
--foreground-muted: oklch(0.55 0.02 70);
--card: oklch(0.98 0.015 80);
--card-foreground: oklch(0.25 0.03 70);
--popover: oklch(0.97 0.015 80);
--popover-foreground: oklch(0.25 0.03 70);
--primary: oklch(0.55 0.12 60);
--primary-foreground: oklch(0.98 0.015 80);
--brand-400: oklch(0.6 0.1 60);
--brand-500: oklch(0.55 0.12 60);
--brand-600: oklch(0.5 0.14 60);
--secondary: oklch(0.93 0.025 80);
--secondary-foreground: oklch(0.25 0.03 70);
--muted: oklch(0.92 0.028 80);
--muted-foreground: oklch(0.5 0.03 70);
--accent: oklch(0.9 0.03 80);
--accent-foreground: oklch(0.25 0.03 70);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.87 0.03 80);
--border-glass: oklch(0.55 0.12 60 / 0.15);
--input: oklch(0.97 0.018 80);
--ring: oklch(0.55 0.12 60);
--chart-1: oklch(0.55 0.12 60);
--chart-2: oklch(0.6 0.1 40);
--chart-3: oklch(0.55 0.1 100);
--chart-4: oklch(0.5 0.08 30);
--chart-5: oklch(0.55 0.08 120);
--sidebar: oklch(0.95 0.022 80);
--sidebar-foreground: oklch(0.25 0.03 70);
--sidebar-primary: oklch(0.55 0.12 60);
--sidebar-primary-foreground: oklch(0.98 0.015 80);
--sidebar-accent: oklch(0.92 0.028 80);
--sidebar-accent-foreground: oklch(0.25 0.03 70);
--sidebar-border: oklch(0.87 0.03 80);
--sidebar-ring: oklch(0.55 0.12 60);
--action-view: oklch(0.55 0.12 60);
--action-view-hover: oklch(0.5 0.14 60);
--action-followup: oklch(0.55 0.1 40);
--action-followup-hover: oklch(0.5 0.12 40);
--action-commit: oklch(0.55 0.12 130);
--action-commit-hover: oklch(0.5 0.14 130);
--action-verify: oklch(0.55 0.12 130);
--action-verify-hover: oklch(0.5 0.14 130);
--running-indicator: oklch(0.55 0.12 60);
--running-indicator-text: oklch(0.5 0.14 60);
--status-success: oklch(0.55 0.12 130);
--status-success-bg: oklch(0.55 0.12 130 / 0.15);
--status-warning: oklch(0.6 0.12 70);
--status-warning-bg: oklch(0.6 0.12 70 / 0.15);
--status-error: oklch(0.55 0.18 25);
--status-error-bg: oklch(0.55 0.18 25 / 0.15);
--status-info: oklch(0.5 0.12 230);
--status-info-bg: oklch(0.5 0.12 230 / 0.15);
--status-backlog: oklch(0.6 0.03 70);
--status-in-progress: oklch(0.6 0.12 70);
--status-waiting: oklch(0.58 0.1 55);
}
.sand .content-bg {
background: linear-gradient(
135deg,
oklch(0.96 0.02 80),
oklch(0.95 0.025 80),
oklch(0.96 0.02 80)
);
}
.sand .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #d97706 0%, #b45309 50%, #d97706 100%);
}
.sand .animated-outline-inner {
background: oklch(0.98 0.015 80) !important;
color: #d97706 !important;
}

View File

@@ -0,0 +1,92 @@
/* Sepia Theme - Warm sepia/parchment tones */
.sepia {
--background: oklch(0.95 0.025 70);
--background-50: oklch(0.95 0.025 70 / 0.5);
--background-80: oklch(0.95 0.025 70 / 0.8);
--foreground: oklch(0.25 0.04 50);
--foreground-secondary: oklch(0.45 0.03 50);
--foreground-muted: oklch(0.55 0.03 50);
--card: oklch(0.97 0.02 70);
--card-foreground: oklch(0.25 0.04 50);
--popover: oklch(0.96 0.022 70);
--popover-foreground: oklch(0.25 0.04 50);
--primary: oklch(0.5 0.12 50);
--primary-foreground: oklch(0.97 0.02 70);
--brand-400: oklch(0.55 0.1 50);
--brand-500: oklch(0.5 0.12 50);
--brand-600: oklch(0.45 0.14 50);
--secondary: oklch(0.92 0.028 70);
--secondary-foreground: oklch(0.25 0.04 50);
--muted: oklch(0.91 0.03 70);
--muted-foreground: oklch(0.5 0.03 50);
--accent: oklch(0.89 0.035 70);
--accent-foreground: oklch(0.25 0.04 50);
--destructive: oklch(0.55 0.18 25);
--border: oklch(0.86 0.035 70);
--border-glass: oklch(0.5 0.12 50 / 0.15);
--input: oklch(0.96 0.022 70);
--ring: oklch(0.5 0.12 50);
--chart-1: oklch(0.5 0.12 50);
--chart-2: oklch(0.55 0.1 35);
--chart-3: oklch(0.5 0.08 80);
--chart-4: oklch(0.45 0.1 30);
--chart-5: oklch(0.5 0.06 100);
--sidebar: oklch(0.94 0.026 70);
--sidebar-foreground: oklch(0.25 0.04 50);
--sidebar-primary: oklch(0.5 0.12 50);
--sidebar-primary-foreground: oklch(0.97 0.02 70);
--sidebar-accent: oklch(0.91 0.03 70);
--sidebar-accent-foreground: oklch(0.25 0.04 50);
--sidebar-border: oklch(0.86 0.035 70);
--sidebar-ring: oklch(0.5 0.12 50);
--action-view: oklch(0.5 0.12 50);
--action-view-hover: oklch(0.45 0.14 50);
--action-followup: oklch(0.5 0.1 35);
--action-followup-hover: oklch(0.45 0.12 35);
--action-commit: oklch(0.5 0.12 130);
--action-commit-hover: oklch(0.45 0.14 130);
--action-verify: oklch(0.5 0.12 130);
--action-verify-hover: oklch(0.45 0.14 130);
--running-indicator: oklch(0.5 0.12 50);
--running-indicator-text: oklch(0.45 0.14 50);
--status-success: oklch(0.5 0.12 130);
--status-success-bg: oklch(0.5 0.12 130 / 0.15);
--status-warning: oklch(0.58 0.12 70);
--status-warning-bg: oklch(0.58 0.12 70 / 0.15);
--status-error: oklch(0.55 0.18 25);
--status-error-bg: oklch(0.55 0.18 25 / 0.15);
--status-info: oklch(0.5 0.1 230);
--status-info-bg: oklch(0.5 0.1 230 / 0.15);
--status-backlog: oklch(0.58 0.03 50);
--status-in-progress: oklch(0.58 0.12 70);
--status-waiting: oklch(0.55 0.1 55);
}
.sepia .content-bg {
background: linear-gradient(
135deg,
oklch(0.95 0.025 70),
oklch(0.94 0.028 70),
oklch(0.95 0.025 70)
);
}
.sepia .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #92400e 0%, #a16207 50%, #92400e 100%);
}
.sepia .animated-outline-inner {
background: oklch(0.97 0.02 70) !important;
color: #92400e !important;
}

View File

@@ -0,0 +1,92 @@
/* Sky Theme - Light sky blue */
.sky {
--background: oklch(0.98 0.015 220);
--background-50: oklch(0.98 0.015 220 / 0.5);
--background-80: oklch(0.98 0.015 220 / 0.8);
--foreground: oklch(0.2 0.03 220);
--foreground-secondary: oklch(0.4 0.03 220);
--foreground-muted: oklch(0.55 0.02 220);
--card: oklch(0.99 0.01 220);
--card-foreground: oklch(0.2 0.03 220);
--popover: oklch(0.99 0.01 220);
--popover-foreground: oklch(0.2 0.03 220);
--primary: oklch(0.55 0.15 225);
--primary-foreground: oklch(0.99 0.01 220);
--brand-400: oklch(0.6 0.13 225);
--brand-500: oklch(0.55 0.15 225);
--brand-600: oklch(0.5 0.17 225);
--secondary: oklch(0.95 0.018 220);
--secondary-foreground: oklch(0.2 0.03 220);
--muted: oklch(0.94 0.02 220);
--muted-foreground: oklch(0.5 0.03 220);
--accent: oklch(0.92 0.025 220);
--accent-foreground: oklch(0.2 0.03 220);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.88 0.02 220);
--border-glass: oklch(0.55 0.15 225 / 0.15);
--input: oklch(0.98 0.012 220);
--ring: oklch(0.55 0.15 225);
--chart-1: oklch(0.55 0.15 225);
--chart-2: oklch(0.6 0.12 200);
--chart-3: oklch(0.55 0.15 250);
--chart-4: oklch(0.6 0.12 180);
--chart-5: oklch(0.55 0.1 270);
--sidebar: oklch(0.97 0.015 220);
--sidebar-foreground: oklch(0.2 0.03 220);
--sidebar-primary: oklch(0.55 0.15 225);
--sidebar-primary-foreground: oklch(0.99 0.01 220);
--sidebar-accent: oklch(0.94 0.02 220);
--sidebar-accent-foreground: oklch(0.2 0.03 220);
--sidebar-border: oklch(0.88 0.02 220);
--sidebar-ring: oklch(0.55 0.15 225);
--action-view: oklch(0.55 0.15 225);
--action-view-hover: oklch(0.5 0.17 225);
--action-followup: oklch(0.55 0.12 200);
--action-followup-hover: oklch(0.5 0.14 200);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.55 0.15 225);
--running-indicator-text: oklch(0.5 0.17 225);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.15);
--status-error: oklch(0.55 0.2 25);
--status-error-bg: oklch(0.55 0.2 25 / 0.15);
--status-info: oklch(0.55 0.15 225);
--status-info-bg: oklch(0.55 0.15 225 / 0.15);
--status-backlog: oklch(0.6 0.02 220);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.sky .content-bg {
background: linear-gradient(
135deg,
oklch(0.98 0.015 220),
oklch(0.97 0.018 220),
oklch(0.98 0.015 220)
);
}
.sky .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #0ea5e9 0%, #38bdf8 50%, #0ea5e9 100%);
}
.sky .animated-outline-inner {
background: oklch(0.99 0.01 220) !important;
color: #0ea5e9 !important;
}

View File

@@ -0,0 +1,92 @@
/* Snow Theme - Clean white with cool blue tints */
.snow {
--background: oklch(0.99 0.005 240);
--background-50: oklch(0.99 0.005 240 / 0.5);
--background-80: oklch(0.99 0.005 240 / 0.8);
--foreground: oklch(0.2 0.02 240);
--foreground-secondary: oklch(0.4 0.02 240);
--foreground-muted: oklch(0.55 0.015 240);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.02 240);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 240);
--primary: oklch(0.5 0.15 240);
--primary-foreground: oklch(1 0 0);
--brand-400: oklch(0.55 0.13 240);
--brand-500: oklch(0.5 0.15 240);
--brand-600: oklch(0.45 0.17 240);
--secondary: oklch(0.97 0.008 240);
--secondary-foreground: oklch(0.2 0.02 240);
--muted: oklch(0.96 0.01 240);
--muted-foreground: oklch(0.5 0.015 240);
--accent: oklch(0.95 0.012 240);
--accent-foreground: oklch(0.2 0.02 240);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.92 0.008 240);
--border-glass: oklch(0.5 0.15 240 / 0.1);
--input: oklch(0.98 0.005 240);
--ring: oklch(0.5 0.15 240);
--chart-1: oklch(0.5 0.15 240);
--chart-2: oklch(0.55 0.12 200);
--chart-3: oklch(0.5 0.15 280);
--chart-4: oklch(0.55 0.12 180);
--chart-5: oklch(0.5 0.1 260);
--sidebar: oklch(0.98 0.006 240);
--sidebar-foreground: oklch(0.2 0.02 240);
--sidebar-primary: oklch(0.5 0.15 240);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.96 0.01 240);
--sidebar-accent-foreground: oklch(0.2 0.02 240);
--sidebar-border: oklch(0.92 0.008 240);
--sidebar-ring: oklch(0.5 0.15 240);
--action-view: oklch(0.5 0.15 240);
--action-view-hover: oklch(0.45 0.17 240);
--action-followup: oklch(0.5 0.12 200);
--action-followup-hover: oklch(0.45 0.14 200);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.5 0.15 240);
--running-indicator-text: oklch(0.45 0.17 240);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.12);
--status-warning: oklch(0.65 0.15 70);
--status-warning-bg: oklch(0.65 0.15 70 / 0.12);
--status-error: oklch(0.55 0.2 25);
--status-error-bg: oklch(0.55 0.2 25 / 0.12);
--status-info: oklch(0.5 0.15 240);
--status-info-bg: oklch(0.5 0.15 240 / 0.12);
--status-backlog: oklch(0.6 0.015 240);
--status-in-progress: oklch(0.65 0.15 70);
--status-waiting: oklch(0.6 0.12 50);
}
.snow .content-bg {
background: linear-gradient(
135deg,
oklch(0.99 0.005 240),
oklch(0.98 0.008 240),
oklch(0.99 0.005 240)
);
}
.snow .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #6366f1 0%, #8b5cf6 50%, #6366f1 100%);
}
.snow .animated-outline-inner {
background: oklch(1 0 0) !important;
color: #6366f1 !important;
}

View File

@@ -0,0 +1,92 @@
/* Solarized Light Theme */
.solarizedlight {
--background: oklch(0.98 0.01 90);
--background-50: oklch(0.98 0.01 90 / 0.5);
--background-80: oklch(0.98 0.01 90 / 0.8);
--foreground: oklch(0.35 0.05 200);
--foreground-secondary: oklch(0.5 0.05 200);
--foreground-muted: oklch(0.6 0.04 200);
--card: oklch(0.95 0.01 90);
--card-foreground: oklch(0.35 0.05 200);
--popover: oklch(0.96 0.01 90);
--popover-foreground: oklch(0.35 0.05 200);
--primary: oklch(0.55 0.15 230);
--primary-foreground: oklch(0.98 0.01 90);
--brand-400: oklch(0.6 0.13 230);
--brand-500: oklch(0.55 0.15 230);
--brand-600: oklch(0.5 0.17 230);
--secondary: oklch(0.92 0.01 90);
--secondary-foreground: oklch(0.35 0.05 200);
--muted: oklch(0.93 0.01 90);
--muted-foreground: oklch(0.5 0.04 200);
--accent: oklch(0.9 0.02 90);
--accent-foreground: oklch(0.35 0.05 200);
--destructive: oklch(0.55 0.2 25);
--border: oklch(0.88 0.015 90);
--border-glass: oklch(0.35 0.05 200 / 0.1);
--input: oklch(0.97 0.008 90);
--ring: oklch(0.55 0.15 230);
--chart-1: oklch(0.55 0.15 230);
--chart-2: oklch(0.6 0.18 25);
--chart-3: oklch(0.55 0.15 300);
--chart-4: oklch(0.6 0.15 180);
--chart-5: oklch(0.65 0.12 80);
--sidebar: oklch(0.96 0.01 90);
--sidebar-foreground: oklch(0.35 0.05 200);
--sidebar-primary: oklch(0.55 0.15 230);
--sidebar-primary-foreground: oklch(0.98 0.01 90);
--sidebar-accent: oklch(0.92 0.01 90);
--sidebar-accent-foreground: oklch(0.35 0.05 200);
--sidebar-border: oklch(0.88 0.015 90);
--sidebar-ring: oklch(0.55 0.15 230);
--action-view: oklch(0.55 0.15 230);
--action-view-hover: oklch(0.5 0.17 230);
--action-followup: oklch(0.6 0.18 25);
--action-followup-hover: oklch(0.55 0.2 25);
--action-commit: oklch(0.55 0.15 145);
--action-commit-hover: oklch(0.5 0.17 145);
--action-verify: oklch(0.55 0.15 145);
--action-verify-hover: oklch(0.5 0.17 145);
--running-indicator: oklch(0.55 0.15 230);
--running-indicator-text: oklch(0.5 0.17 230);
--status-success: oklch(0.55 0.15 145);
--status-success-bg: oklch(0.55 0.15 145 / 0.15);
--status-warning: oklch(0.65 0.15 80);
--status-warning-bg: oklch(0.65 0.15 80 / 0.15);
--status-error: oklch(0.55 0.2 25);
--status-error-bg: oklch(0.55 0.2 25 / 0.15);
--status-info: oklch(0.55 0.15 230);
--status-info-bg: oklch(0.55 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.04 200);
--status-in-progress: oklch(0.65 0.15 80);
--status-waiting: oklch(0.6 0.12 50);
}
.solarizedlight .content-bg {
background: linear-gradient(
135deg,
oklch(0.98 0.01 90),
oklch(0.96 0.012 90),
oklch(0.98 0.01 90)
);
}
.solarizedlight .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #268bd2 0%, #2aa198 50%, #268bd2 100%);
}
.solarizedlight .animated-outline-inner {
background: oklch(0.98 0.01 90) !important;
color: #268bd2 !important;
}

View File

@@ -3,15 +3,15 @@
*/
import { createLogger } from '@automaker/utils';
import fs from "fs/promises";
import path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { secureFs } from '@automaker/platform';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { BINARY_EXTENSIONS, type FileStatus } from './types.js';
import { isGitRepo, parseGitStatus } from './status.js';
const execAsync = promisify(exec);
const logger = createLogger("GitUtils");
const logger = createLogger('GitUtils');
// Max file size for generating synthetic diffs (1MB)
const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024;
@@ -45,7 +45,7 @@ Binary file ${relativePath} added
}
// Get file stats to check size
const stats = await fs.stat(fullPath);
const stats = await secureFs.stat(fullPath);
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
const sizeKB = Math.round(stats.size / 1024);
return `diff --git a/${relativePath} b/${relativePath}
@@ -59,18 +59,18 @@ index 0000000..0000000
}
// Read file content
const content = await fs.readFile(fullPath, "utf-8");
const hasTrailingNewline = content.endsWith("\n");
const lines = content.split("\n");
const content = (await secureFs.readFile(fullPath, 'utf-8')) as string;
const hasTrailingNewline = content.endsWith('\n');
const lines = content.split('\n');
// Remove trailing empty line if the file ends with newline
if (lines.length > 0 && lines.at(-1) === "") {
if (lines.length > 0 && lines.at(-1) === '') {
lines.pop();
}
// Generate diff format
const lineCount = lines.length;
const addedLines = lines.map(line => `+${line}`).join("\n");
const addedLines = lines.map((line) => `+${line}`).join('\n');
let diff = `diff --git a/${relativePath} b/${relativePath}
new file mode 100644
@@ -82,10 +82,10 @@ ${addedLines}`;
// Add "No newline at end of file" indicator if needed
if (!hasTrailingNewline && content.length > 0) {
diff += "\n\\ No newline at end of file";
diff += '\n\\ No newline at end of file';
}
return diff + "\n";
return diff + '\n';
} catch (error) {
// Log the error for debugging
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
@@ -110,7 +110,7 @@ export async function appendUntrackedFileDiffs(
files: Array<{ status: string; path: string }>
): Promise<string> {
// Find untracked files (status "?")
const untrackedFiles = files.filter(f => f.status === "?");
const untrackedFiles = files.filter((f) => f.status === '?');
if (untrackedFiles.length === 0) {
return existingDiff;
@@ -118,11 +118,11 @@ export async function appendUntrackedFileDiffs(
// Generate synthetic diffs for each untracked file
const syntheticDiffs = await Promise.all(
untrackedFiles.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
untrackedFiles.map((f) => generateSyntheticDiffForNewFile(basePath, f.path))
);
// Combine existing diff with synthetic diffs
const combinedDiff = existingDiff + syntheticDiffs.join("");
const combinedDiff = existingDiff + syntheticDiffs.join('');
return combinedDiff;
}
@@ -133,25 +133,39 @@ export async function appendUntrackedFileDiffs(
*/
export async function listAllFilesInDirectory(
basePath: string,
relativePath: string = ""
relativePath: string = ''
): Promise<string[]> {
const files: string[] = [];
const fullPath = path.join(basePath, relativePath);
// Directories to skip
const skipDirs = new Set([
"node_modules", ".git", ".automaker", "dist", "build",
".next", ".nuxt", "__pycache__", ".cache", "coverage",
".venv", "venv", "target", "vendor", ".gradle",
"out", "tmp", ".tmp"
'node_modules',
'.git',
'.automaker',
'dist',
'build',
'.next',
'.nuxt',
'__pycache__',
'.cache',
'coverage',
'.venv',
'venv',
'target',
'vendor',
'.gradle',
'out',
'tmp',
'.tmp',
]);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
const entries = await secureFs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
// Skip hidden files/folders (except we want to allow some)
if (entry.name.startsWith(".") && entry.name !== ".env") {
if (entry.name.startsWith('.') && entry.name !== '.env') {
continue;
}
@@ -183,19 +197,19 @@ export async function generateDiffsForNonGitDirectory(
): Promise<{ diff: string; files: FileStatus[] }> {
const allFiles = await listAllFilesInDirectory(basePath);
const files: FileStatus[] = allFiles.map(filePath => ({
status: "?",
const files: FileStatus[] = allFiles.map((filePath) => ({
status: '?',
path: filePath,
statusText: "New",
statusText: 'New',
}));
// Generate synthetic diffs for all files
const syntheticDiffs = await Promise.all(
files.map(f => generateSyntheticDiffForNewFile(basePath, f.path))
files.map((f) => generateSyntheticDiffForNewFile(basePath, f.path))
);
return {
diff: syntheticDiffs.join(""),
diff: syntheticDiffs.join(''),
files,
};
}
@@ -221,11 +235,11 @@ export async function getGitRepositoryDiffs(
}
// Get git diff and status
const { stdout: diff } = await execAsync("git diff HEAD", {
const { stdout: diff } = await execAsync('git diff HEAD', {
cwd: repoPath,
maxBuffer: 10 * 1024 * 1024,
});
const { stdout: status } = await execAsync("git status --porcelain", {
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: repoPath,
});

View File

@@ -14,42 +14,61 @@ export type { AgentModel };
/**
* ThemeMode - Available color themes for the UI
*
* Includes system theme and multiple color schemes:
* Includes system theme and multiple color schemes organized by dark/light:
* - 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
* - Dark themes (16): dark, retro, dracula, nord, monokai, tokyonight, solarized,
* gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean
* - Light themes (16): light, cream, solarizedlight, github, paper, rose, mint,
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom
*/
export type ThemeMode =
| "light"
| "dark"
| "system"
| "retro"
| "dracula"
| "nord"
| "monokai"
| "tokyonight"
| "solarized"
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave"
| "red"
| "cream"
| "sunset"
| "gray";
| 'system'
// Dark themes (16)
| 'dark'
| 'retro'
| 'dracula'
| 'nord'
| 'monokai'
| 'tokyonight'
| 'solarized'
| 'gruvbox'
| 'catppuccin'
| 'onedark'
| 'synthwave'
| 'red'
| 'sunset'
| 'gray'
| 'forest'
| 'ocean'
// Light themes (16)
| 'light'
| 'cream'
| 'solarizedlight'
| 'github'
| 'paper'
| 'rose'
| 'mint'
| 'lavender'
| 'sand'
| 'sky'
| 'peach'
| 'snow'
| 'sepia'
| 'gruvboxlight'
| 'nordlight'
| 'blossom';
/** KanbanCardDetailLevel - Controls how much information is displayed on kanban cards */
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
/** PlanningMode - Planning levels for feature generation workflows */
export type PlanningMode = "skip" | "lite" | "spec" | "full";
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
/** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = "claude";
export type ModelProvider = 'claude';
/**
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
@@ -356,45 +375,45 @@ export interface ProjectSettings {
/** 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",
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",
theme: 'dark',
sidebarOpen: true,
chatHistoryOpen: false,
kanbanCardDetailLevel: "standard",
kanbanCardDetailLevel: 'standard',
maxConcurrency: 3,
defaultSkipTests: true,
enableDependencyBlocking: true,
useWorktrees: false,
showProfilesOnly: false,
defaultPlanningMode: "skip",
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
muteDoneSound: false,
enhancementModel: "sonnet",
enhancementModel: 'sonnet',
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [],
projects: [],
@@ -411,9 +430,9 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
export const DEFAULT_CREDENTIALS: Credentials = {
version: 1,
apiKeys: {
anthropic: "",
google: "",
openai: "",
anthropic: '',
google: '',
openai: '',
},
};