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