refactor: replace fs with secureFs for improved file handling

This commit updates various modules to utilize the secure file system operations from the secureFs module instead of the native fs module. Key changes include:

- Replaced fs imports with secureFs in multiple route handlers and services to enhance security and consistency in file operations.
- Added centralized validation for working directories in the sdk-options module to ensure all AI model invocations are secure.

These changes aim to improve the security and maintainability of file handling across the application.
This commit is contained in:
Test User
2025-12-21 01:32:26 -05:00
parent 2b5479ae0d
commit 077a63b03b
62 changed files with 4866 additions and 3350 deletions

View File

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

View File

@@ -3,8 +3,8 @@
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
*/
import * as fs from "fs/promises";
import * as path from "path";
import * as secureFs from './secure-fs.js';
import * as path from 'path';
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
@@ -32,11 +32,11 @@ function sanitizeBranchName(branch: string): string {
// - Windows invalid chars: : * ? " < > |
// - Other potentially problematic chars
let safeBranch = branch
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
.replace(/-+/g, "-") // Collapse multiple dashes
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
.replace(/[/\\:*?"<>|]/g, '-') // Replace invalid chars with dash
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/\.+$/g, '') // Remove trailing dots (Windows issue)
.replace(/-+/g, '-') // Collapse multiple dashes
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
// Truncate to safe length (leave room for path components)
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
@@ -44,7 +44,7 @@ function sanitizeBranchName(branch: string): string {
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
safeBranch = `_${safeBranch || "branch"}`;
safeBranch = `_${safeBranch || 'branch'}`;
}
return safeBranch;
@@ -55,14 +55,14 @@ function sanitizeBranchName(branch: string): string {
*/
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
const safeBranch = sanitizeBranchName(branch);
return path.join(projectPath, ".automaker", "worktrees", safeBranch);
return path.join(projectPath, '.automaker', 'worktrees', safeBranch);
}
/**
* Get the path to the worktree metadata file
*/
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json");
return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json');
}
/**
@@ -74,7 +74,7 @@ export async function readWorktreeMetadata(
): Promise<WorktreeMetadata | null> {
try {
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = await fs.readFile(metadataPath, "utf-8");
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
// File doesn't exist or can't be read
@@ -94,10 +94,10 @@ export async function writeWorktreeMetadata(
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
// Ensure directory exists
await fs.mkdir(metadataDir, { recursive: true });
await secureFs.mkdir(metadataDir, { recursive: true });
// Write metadata
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
}
/**
@@ -143,16 +143,16 @@ export async function readAllWorktreeMetadata(
projectPath: string
): Promise<Map<string, WorktreeMetadata>> {
const result = new Map<string, WorktreeMetadata>();
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
const worktreesDir = path.join(projectPath, '.automaker', 'worktrees');
try {
const dirs = await fs.readdir(worktreesDir, { withFileTypes: true });
const dirs = await secureFs.readdir(worktreesDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const metadataPath = path.join(worktreesDir, dir.name, "worktree.json");
const metadataPath = path.join(worktreesDir, dir.name, 'worktree.json');
try {
const content = await fs.readFile(metadataPath, "utf-8");
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
const metadata = JSON.parse(content) as WorktreeMetadata;
result.set(metadata.branch, metadata);
} catch {
@@ -170,13 +170,10 @@ export async function readAllWorktreeMetadata(
/**
* Delete worktree metadata for a branch
*/
export async function deleteWorktreeMetadata(
projectPath: string,
branch: string
): Promise<void> {
export async function deleteWorktreeMetadata(projectPath: string, branch: string): Promise<void> {
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
try {
await fs.rm(metadataDir, { recursive: true, force: true });
await secureFs.rm(metadataDir, { recursive: true, force: true });
} catch {
// Ignore errors if directory doesn't exist
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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