mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
refactor: replace fs with secureFs for improved file handling
This commit updates various modules to utilize the secure file system operations from the secureFs module instead of the native fs module. Key changes include: - Replaced fs imports with secureFs in multiple route handlers and services to enhance security and consistency in file operations. - Added centralized validation for working directories in the sdk-options module to ensure all AI model invocations are secure. These changes aim to improve the security and maintainability of file handling across the application.
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ==========');
|
||||
}
|
||||
|
||||
@@ -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 ==========');
|
||||
}
|
||||
|
||||
@@ -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 ==========');
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
92
apps/ui/src/styles/themes/blossom.css
Normal file
92
apps/ui/src/styles/themes/blossom.css
Normal 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;
|
||||
}
|
||||
98
apps/ui/src/styles/themes/forest.css
Normal file
98
apps/ui/src/styles/themes/forest.css
Normal 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;
|
||||
}
|
||||
87
apps/ui/src/styles/themes/github.css
Normal file
87
apps/ui/src/styles/themes/github.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/gruvboxlight.css
Normal file
92
apps/ui/src/styles/themes/gruvboxlight.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/lavender.css
Normal file
92
apps/ui/src/styles/themes/lavender.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/mint.css
Normal file
92
apps/ui/src/styles/themes/mint.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/nordlight.css
Normal file
92
apps/ui/src/styles/themes/nordlight.css
Normal 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;
|
||||
}
|
||||
98
apps/ui/src/styles/themes/ocean.css
Normal file
98
apps/ui/src/styles/themes/ocean.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/paper.css
Normal file
92
apps/ui/src/styles/themes/paper.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/peach.css
Normal file
92
apps/ui/src/styles/themes/peach.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/rose.css
Normal file
92
apps/ui/src/styles/themes/rose.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/sand.css
Normal file
92
apps/ui/src/styles/themes/sand.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/sepia.css
Normal file
92
apps/ui/src/styles/themes/sepia.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/sky.css
Normal file
92
apps/ui/src/styles/themes/sky.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/snow.css
Normal file
92
apps/ui/src/styles/themes/snow.css
Normal 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;
|
||||
}
|
||||
92
apps/ui/src/styles/themes/solarizedlight.css
Normal file
92
apps/ui/src/styles/themes/solarizedlight.css
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user