Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -3,20 +3,18 @@
* Manages conversation sessions and streams responses via WebSocket
*/
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import { readImageAsBase64 } from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { createChatOptions } from "../lib/sdk-options.js";
import { isAbortError } from "../lib/error-handler.js";
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;
@@ -56,13 +54,13 @@ 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;
}
async initialize(): Promise<void> {
await fs.mkdir(this.stateDir, { recursive: true });
await secureFs.mkdir(this.stateDir, { recursive: true });
}
/**
@@ -80,11 +78,18 @@ export class AgentService {
const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId];
// Determine the effective working directory
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
this.sessions.set(sessionId, {
messages,
isRunning: false,
abortController: null,
workingDirectory: workingDirectory || process.cwd(),
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
});
}
@@ -119,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
@@ -129,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 {
@@ -140,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);
}
}
}
@@ -151,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(),
@@ -169,7 +171,7 @@ export class AgentService {
// Emit user message event
this.emitAgentEvent(sessionId, {
type: "message",
type: 'message',
message: userMessage,
});
@@ -199,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
};
@@ -226,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(),
};
@@ -259,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;
@@ -287,7 +286,7 @@ export class AgentService {
}
this.emitAgentEvent(sessionId, {
type: "complete",
type: 'complete',
messageId: currentAssistantMessage?.id,
content: responseText,
toolUses,
@@ -311,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,
@@ -328,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,
});
@@ -343,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 {
@@ -359,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) {
@@ -391,7 +390,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await fs.readFile(sessionFile, "utf-8");
const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return [];
@@ -402,20 +401,16 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.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 fs.readFile(this.metadataFile, "utf-8");
const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return {};
@@ -423,11 +418,7 @@ export class AgentService {
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await fs.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> {
@@ -447,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()
);
}
@@ -461,11 +451,23 @@ export class AgentService {
const sessionId = this.generateId();
const metadata = await this.loadMetadata();
// Determine the effective working directory
const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Validate that projectPath is allowed if provided
if (projectPath) {
validateWorkingDirectory(projectPath);
}
const session: SessionMetadata = {
id: sessionId,
name,
projectPath,
workingDirectory: workingDirectory || projectPath || process.cwd(),
workingDirectory: resolvedWorkingDirectory,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
model,
@@ -524,7 +526,7 @@ export class AgentService {
// Delete session file
try {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
await fs.unlink(sessionFile);
await secureFs.unlink(sessionFile);
} catch {
// File may not exist
}
@@ -535,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

@@ -0,0 +1,477 @@
import { spawn } from 'child_process';
import * as os from 'os';
import * as pty from 'node-pty';
import { ClaudeUsage } from '../routes/claude/types.js';
/**
* Claude Usage Service
*
* Fetches usage data by executing the Claude CLI's /usage command.
* This approach doesn't require any API keys - it relies on the user
* having already authenticated via `claude login`.
*
* Platform-specific implementations:
* - macOS: Uses 'expect' command for PTY
* - Windows: Uses node-pty for PTY
*/
export class ClaudeUsageService {
private claudeBinary = 'claude';
private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32';
/**
* Check if Claude CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
return new Promise((resolve) => {
const checkCmd = this.isWindows ? 'where' : 'which';
const proc = spawn(checkCmd, [this.claudeBinary]);
proc.on('close', (code) => {
resolve(code === 0);
});
proc.on('error', () => {
resolve(false);
});
});
}
/**
* Fetch usage data by executing the Claude CLI
*/
async fetchUsageData(): Promise<ClaudeUsage> {
const output = await this.executeClaudeUsageCommand();
return this.parseUsageOutput(output);
}
/**
* Execute the claude /usage command and return the output
* Uses platform-specific PTY implementation
*/
private executeClaudeUsageCommand(): Promise<string> {
if (this.isWindows) {
return this.executeClaudeUsageCommandWindows();
}
return this.executeClaudeUsageCommandMac();
}
/**
* macOS implementation using 'expect' command
*/
private executeClaudeUsageCommandMac(): Promise<string> {
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
let settled = false;
// Use a simple working directory (home or tmp)
const workingDirectory = process.env.HOME || '/tmp';
// Use 'expect' with an inline script to run claude /usage with a PTY
// Wait for "Current session" header, then wait for full output before exiting
const expectScript = `
set timeout 20
spawn claude /usage
expect {
"Current session" {
sleep 2
send "\\x1b"
}
"Esc to cancel" {
sleep 3
send "\\x1b"
}
timeout {}
eof {}
}
expect eof
`;
const proc = spawn('expect', ['-c', expectScript], {
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
},
});
const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
proc.kill();
reject(new Error('Command timed out'));
}
}, this.timeout);
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
clearTimeout(timeoutId);
if (settled) return;
settled = true;
// Check for authentication errors in output
if (
stdout.includes('token_expired') ||
stdout.includes('authentication_error') ||
stderr.includes('token_expired') ||
stderr.includes('authentication_error')
) {
reject(new Error("Authentication required - please run 'claude login'"));
return;
}
// Even if exit code is non-zero, we might have useful output
if (stdout.trim()) {
resolve(stdout);
} else if (code !== 0) {
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
reject(new Error('No output from claude command'));
}
});
proc.on('error', (err) => {
clearTimeout(timeoutId);
if (!settled) {
settled = true;
reject(new Error(`Failed to execute claude: ${err.message}`));
}
});
});
}
/**
* Windows implementation using node-pty
*/
private executeClaudeUsageCommandWindows(): Promise<string> {
return new Promise((resolve, reject) => {
let output = '';
let settled = false;
let hasSeenUsageData = false;
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
ptyProcess.kill();
reject(new Error('Command timed out'));
}
}, this.timeout);
ptyProcess.onData((data) => {
output += data;
// Check if we've seen the usage data (look for "Current session")
if (!hasSeenUsageData && output.includes('Current session')) {
hasSeenUsageData = true;
// Wait for full output, then send escape to exit
setTimeout(() => {
if (!settled) {
ptyProcess.write('\x1b'); // Send escape key
}
}, 2000);
}
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
setTimeout(() => {
if (!settled) {
ptyProcess.write('\x1b'); // Send escape key
}
}, 3000);
}
});
ptyProcess.onExit(({ exitCode }) => {
clearTimeout(timeoutId);
if (settled) return;
settled = true;
// Check for authentication errors in output
if (output.includes('token_expired') || output.includes('authentication_error')) {
reject(new Error("Authentication required - please run 'claude login'"));
return;
}
if (output.trim()) {
resolve(output);
} else if (exitCode !== 0) {
reject(new Error(`Command exited with code ${exitCode}`));
} else {
reject(new Error('No output from claude command'));
}
});
});
}
/**
* Strip ANSI escape codes from text
*/
private stripAnsiCodes(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
}
/**
* Parse the Claude CLI output to extract usage information
*
* Expected output format:
* ```
* Claude Code v1.0.27
*
* Current session
* ████████████████░░░░ 65% left
* Resets in 2h 15m
*
* Current week (all models)
* ██████████░░░░░░░░░░ 35% left
* Resets Jan 15, 3:30pm (America/Los_Angeles)
*
* Current week (Opus)
* ████████████████████ 80% left
* Resets Jan 15, 3:30pm (America/Los_Angeles)
* ```
*/
private parseUsageOutput(rawOutput: string): ClaudeUsage {
const output = this.stripAnsiCodes(rawOutput);
const lines = output
.split('\n')
.map((l) => l.trim())
.filter((l) => l);
// Parse session usage
const sessionData = this.parseSection(lines, 'Current session', 'session');
// Parse weekly usage (all models)
const weeklyData = this.parseSection(lines, 'Current week (all models)', 'weekly');
// Parse Sonnet/Opus usage - try different labels
let sonnetData = this.parseSection(lines, 'Current week (Sonnet only)', 'sonnet');
if (sonnetData.percentage === 0) {
sonnetData = this.parseSection(lines, 'Current week (Sonnet)', 'sonnet');
}
if (sonnetData.percentage === 0) {
sonnetData = this.parseSection(lines, 'Current week (Opus)', 'sonnet');
}
return {
sessionTokensUsed: 0, // Not available from CLI
sessionLimit: 0, // Not available from CLI
sessionPercentage: sessionData.percentage,
sessionResetTime: sessionData.resetTime,
sessionResetText: sessionData.resetText,
weeklyTokensUsed: 0, // Not available from CLI
weeklyLimit: 0, // Not available from CLI
weeklyPercentage: weeklyData.percentage,
weeklyResetTime: weeklyData.resetTime,
weeklyResetText: weeklyData.resetText,
sonnetWeeklyTokensUsed: 0, // Not available from CLI
sonnetWeeklyPercentage: sonnetData.percentage,
sonnetResetText: sonnetData.resetText,
costUsed: null, // Not available from CLI
costLimit: null,
costCurrency: null,
lastUpdated: new Date().toISOString(),
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}
/**
* Parse a section of the usage output to extract percentage and reset time
*/
private parseSection(
lines: string[],
sectionLabel: string,
type: string
): { percentage: number; resetTime: string; resetText: string } {
let percentage = 0;
let resetTime = this.getDefaultResetTime(type);
let resetText = '';
// Find the LAST occurrence of the section (terminal output has multiple screen refreshes)
let sectionIndex = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].toLowerCase().includes(sectionLabel.toLowerCase())) {
sectionIndex = i;
break;
}
}
if (sectionIndex === -1) {
return { percentage, resetTime, resetText };
}
// Look at the lines following the section header (within a window of 5 lines)
const searchWindow = lines.slice(sectionIndex, sectionIndex + 5);
for (const line of searchWindow) {
// Extract percentage - only take the first match (avoid picking up next section's data)
if (percentage === 0) {
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
if (percentMatch) {
const value = parseInt(percentMatch[1], 10);
const isUsed = percentMatch[2].toLowerCase() === 'used';
// Convert "left" to "used" percentage (our UI shows % used)
percentage = isUsed ? value : 100 - value;
}
}
// Extract reset time - only take the first match
if (!resetText && line.toLowerCase().includes('reset')) {
resetText = line;
}
}
// Parse the reset time if we found one
if (resetText) {
resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
}
return { percentage, resetTime, resetText };
}
/**
* Parse reset time from text like "Resets in 2h 15m", "Resets 11am", or "Resets Dec 22 at 8pm"
*/
private parseResetTime(text: string, type: string): string {
const now = new Date();
// Try to parse duration format: "Resets in 2h 15m" or "Resets in 30m"
const durationMatch = text.match(
/(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i
);
if (durationMatch) {
let hours = 0;
let minutes = 0;
if (durationMatch[1]) {
hours = parseInt(durationMatch[1], 10);
minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0;
} else if (durationMatch[3]) {
minutes = parseInt(durationMatch[3], 10);
}
const resetDate = new Date(now.getTime() + (hours * 60 + minutes) * 60 * 1000);
return resetDate.toISOString();
}
// Try to parse simple time-only format: "Resets 11am" or "Resets 3pm"
const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (simpleTimeMatch) {
let hours = parseInt(simpleTimeMatch[1], 10);
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
const ampm = simpleTimeMatch[3].toLowerCase();
// Convert 12-hour to 24-hour
if (ampm === 'pm' && hours !== 12) {
hours += 12;
} else if (ampm === 'am' && hours === 12) {
hours = 0;
}
// Create date for today at specified time
const resetDate = new Date(now);
resetDate.setHours(hours, minutes, 0, 0);
// If time has passed, use tomorrow
if (resetDate <= now) {
resetDate.setDate(resetDate.getDate() + 1);
}
return resetDate.toISOString();
}
// Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm"
const dateMatch = text.match(
/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
);
if (dateMatch) {
const monthName = dateMatch[1];
const day = parseInt(dateMatch[2], 10);
let hours = parseInt(dateMatch[3], 10);
const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0;
const ampm = dateMatch[5].toLowerCase();
// Convert 12-hour to 24-hour
if (ampm === 'pm' && hours !== 12) {
hours += 12;
} else if (ampm === 'am' && hours === 12) {
hours = 0;
}
// Parse month name
const months: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
};
const month = months[monthName.toLowerCase().substring(0, 3)];
if (month !== undefined) {
let year = now.getFullYear();
// If the date appears to be in the past, assume next year
const resetDate = new Date(year, month, day, hours, minutes);
if (resetDate < now) {
resetDate.setFullYear(year + 1);
}
return resetDate.toISOString();
}
}
// Fallback to default
return this.getDefaultResetTime(type);
}
/**
* Get default reset time based on usage type
*/
private getDefaultResetTime(type: string): string {
const now = new Date();
if (type === 'session') {
// Session resets in ~5 hours
return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString();
} else {
// Weekly resets on next Monday around noon
const result = new Date(now);
const currentDay = now.getDay();
let daysUntilMonday = (1 + 7 - currentDay) % 7;
if (daysUntilMonday === 0) daysUntilMonday = 7;
result.setDate(result.getDate() + daysUntilMonday);
result.setHours(12, 59, 0, 0);
return result.toISOString();
}
}
}

View File

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

View File

@@ -3,50 +3,21 @@
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
import path from "path";
import fs from "fs/promises";
import path from 'path';
import type { Feature } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getFeaturesDir,
getFeatureDir,
getFeatureImagesDir,
ensureAutomakerDir,
} from "../lib/automaker-paths.js";
} from '@automaker/platform';
export interface Feature {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps?: string[];
passes?: boolean;
priority?: number;
status?: string;
dependencies?: string[];
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;
thinkingLevel?: string;
planningMode?: 'skip' | 'lite' | 'spec' | 'full';
requirePlanApproval?: boolean;
planSpec?: {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
content?: string;
version: number;
generatedAt?: string;
approvedAt?: string;
reviewedByUser: boolean;
tasksCompleted?: number;
tasksTotal?: number;
};
error?: string;
summary?: string;
startedAt?: string;
[key: string]: unknown; // Keep catch-all for extensibility
}
const logger = createLogger('FeatureLoader');
// Re-export Feature type for convenience
export type { Feature };
export class FeatureLoader {
/**
@@ -76,26 +47,19 @@ export class FeatureLoader {
}
// Build sets of paths for comparison
const oldPathSet = new Set(
oldPaths.map((p) => (typeof p === "string" ? p : p.path))
);
const newPathSet = new Set(
(newPaths || []).map((p) => (typeof p === "string" ? p : p.path))
);
const oldPathSet = new Set(oldPaths.map((p) => (typeof p === 'string' ? p : p.path)));
const newPathSet = new Set((newPaths || []).map((p) => (typeof p === 'string' ? p : p.path)));
// Find images that were removed
for (const oldPath of oldPathSet) {
if (!newPathSet.has(oldPath)) {
try {
// Paths are now absolute
await fs.unlink(oldPath);
await secureFs.unlink(oldPath);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
console.warn(
`[FeatureLoader] Failed to delete image: ${oldPath}`,
error
);
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
}
}
}
@@ -108,23 +72,19 @@ export class FeatureLoader {
projectPath: string,
featureId: string,
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
): Promise<
Array<string | { path: string; [key: string]: unknown }> | undefined
> {
): Promise<Array<string | { path: string; [key: string]: unknown }> | undefined> {
if (!imagePaths || imagePaths.length === 0) {
return imagePaths;
}
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await fs.mkdir(featureImagesDir, { recursive: true });
await secureFs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
[];
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = [];
for (const imagePath of imagePaths) {
try {
const originalPath =
typeof imagePath === "string" ? imagePath : imagePath.path;
const originalPath = typeof imagePath === 'string' ? imagePath : imagePath.path;
// Skip if already in feature directory (already absolute path in external storage)
if (originalPath.includes(`/features/${featureId}/images/`)) {
@@ -139,11 +99,9 @@ export class FeatureLoader {
// Check if file exists
try {
await fs.access(fullOriginalPath);
await secureFs.access(fullOriginalPath);
} catch {
console.warn(
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
);
logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
continue;
}
@@ -152,28 +110,27 @@ export class FeatureLoader {
const newPath = path.join(featureImagesDir, filename);
// Copy the file
await fs.copyFile(fullOriginalPath, newPath);
console.log(
`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`
);
await secureFs.copyFile(fullOriginalPath, newPath);
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
// Try to delete the original temp file
try {
await fs.unlink(fullOriginalPath);
await secureFs.unlink(fullOriginalPath);
} catch {
// Ignore errors when deleting temp file
}
// Update the path in the result (use absolute path)
if (typeof imagePath === "string") {
if (typeof imagePath === 'string') {
updatedPaths.push(newPath);
} else {
updatedPaths.push({ ...imagePath, path: newPath });
}
} catch (error) {
console.error(`[FeatureLoader] Failed to migrate image:`, error);
// Keep original path if migration fails
updatedPaths.push(imagePath);
logger.error(`Failed to migrate image:`, error);
// Rethrow error to let caller decide how to handle it
// Keeping original path could lead to broken references
throw error;
}
}
@@ -191,14 +148,14 @@ export class FeatureLoader {
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
return path.join(this.getFeatureDir(projectPath, featureId), 'feature.json');
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md');
}
/**
@@ -217,13 +174,15 @@ export class FeatureLoader {
// Check if features directory exists
try {
await fs.access(featuresDir);
await secureFs.access(featuresDir);
} catch {
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const entries = (await secureFs.readdir(featuresDir, {
withFileTypes: true,
})) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
@@ -233,11 +192,11 @@ export class FeatureLoader {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
const feature = JSON.parse(content);
if (!feature.id) {
console.warn(
logger.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
@@ -245,14 +204,14 @@ export class FeatureLoader {
features.push(feature);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
} else if (error instanceof SyntaxError) {
console.warn(
logger.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
} else {
console.error(
logger.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
(error as Error).message
);
@@ -262,14 +221,14 @@ export class FeatureLoader {
// Sort by creation order (feature IDs contain timestamp)
features.sort((a, b) => {
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
const aTime = a.id ? parseInt(a.id.split('-')[1] || '0') : 0;
const bTime = b.id ? parseInt(b.id.split('-')[1] || '0') : 0;
return aTime - bTime;
});
return features;
} catch (error) {
console.error("[FeatureLoader] Failed to get all features:", error);
logger.error('Failed to get all features:', error);
return [];
}
}
@@ -280,16 +239,13 @@ export class FeatureLoader {
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
console.error(
`[FeatureLoader] Failed to get feature ${featureId}:`,
error
);
logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
throw error;
}
}
@@ -297,10 +253,7 @@ export class FeatureLoader {
/**
* Create a new feature
*/
async create(
projectPath: string,
featureData: Partial<Feature>
): Promise<Feature> {
async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
@@ -309,7 +262,7 @@ export class FeatureLoader {
await ensureAutomakerDir(projectPath);
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
// Migrate images from temp directory to feature directory
const migratedImagePaths = await this.migrateImages(
@@ -320,21 +273,17 @@ export class FeatureLoader {
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || "Uncategorized",
description: featureData.description || "",
category: featureData.category || 'Uncategorized',
description: featureData.description || '',
...featureData,
id: featureId,
imagePaths: migratedImagePaths,
};
// Write feature.json
await fs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
);
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8');
console.log(`[FeatureLoader] Created feature ${featureId}`);
logger.info(`Created feature ${featureId}`);
return feature;
}
@@ -355,38 +304,24 @@ export class FeatureLoader {
let updatedImagePaths = updates.imagePaths;
if (updates.imagePaths !== undefined) {
// Delete orphaned images (images that were removed)
await this.deleteOrphanedImages(
projectPath,
feature.imagePaths,
updates.imagePaths
);
await this.deleteOrphanedImages(projectPath, feature.imagePaths, updates.imagePaths);
// Migrate any new images
updatedImagePaths = await this.migrateImages(
projectPath,
featureId,
updates.imagePaths
);
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
}
// Merge updates
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined
? { imagePaths: updatedImagePaths }
: {}),
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
};
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
);
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8');
console.log(`[FeatureLoader] Updated feature ${featureId}`);
logger.info(`Updated feature ${featureId}`);
return updatedFeature;
}
@@ -396,14 +331,11 @@ export class FeatureLoader {
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
await secureFs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
console.error(
`[FeatureLoader] Failed to delete feature ${featureId}:`,
error
);
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
return false;
}
}
@@ -411,22 +343,16 @@ export class FeatureLoader {
/**
* Get agent output for a feature
*/
async getAgentOutput(
projectPath: string,
featureId: string
): Promise<string | null> {
async getAgentOutput(projectPath: string, featureId: string): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
const content = (await secureFs.readFile(agentOutputPath, 'utf-8')) as string;
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
console.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);
logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error);
throw error;
}
}
@@ -434,30 +360,23 @@ export class FeatureLoader {
/**
* Save agent output for a feature
*/
async saveAgentOutput(
projectPath: string,
featureId: string,
content: string
): Promise<void> {
async saveAgentOutput(projectPath: string, featureId: string, content: string): Promise<void> {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
await secureFs.writeFile(agentOutputPath, content, 'utf-8');
}
/**
* Delete agent output for a feature
*/
async deleteAgentOutput(
projectPath: string,
featureId: string
): Promise<void> {
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
await secureFs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}

View File

@@ -0,0 +1,583 @@
/**
* Settings Service - Handles reading/writing settings to JSON files
*
* Provides persistent storage for:
* - Global settings (DATA_DIR/settings.json)
* - Credentials (DATA_DIR/credentials.json)
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getGlobalSettingsPath,
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
ensureAutomakerDir,
} from '@automaker/platform';
import type {
GlobalSettings,
Credentials,
ProjectSettings,
KeyboardShortcuts,
AIProfile,
ProjectRef,
TrashedProjectRef,
BoardBackgroundSettings,
WorktreeInfo,
} from '../types/settings.js';
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from '../types/settings.js';
const logger = createLogger('SettingsService');
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Check if a file exists
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await secureFs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* SettingsService - Manages persistent storage of user settings and credentials
*
* Handles reading and writing settings to JSON files with atomic operations
* for reliability. Provides three levels of settings:
* - Global settings: shared preferences in {dataDir}/settings.json
* - Credentials: sensitive API keys in {dataDir}/credentials.json
* - Project settings: per-project overrides in {projectPath}/.automaker/settings.json
*
* All operations are atomic (write to temp file, then rename) to prevent corruption.
* Missing files are treated as empty and return defaults on read.
* Updates use deep merge for nested objects like keyboardShortcuts and apiKeys.
*/
export class SettingsService {
private dataDir: string;
/**
* Create a new SettingsService instance
*
* @param dataDir - Absolute path to global data directory (e.g., ~/.automaker)
*/
constructor(dataDir: string) {
this.dataDir = dataDir;
}
// ============================================================================
// Global Settings
// ============================================================================
/**
* Get global settings with defaults applied for any missing fields
*
* Reads from {dataDir}/settings.json. If file doesn't exist, returns defaults.
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
* compatibility during schema migrations.
*
* @returns Promise resolving to complete GlobalSettings object
*/
async getGlobalSettings(): Promise<GlobalSettings> {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
// Apply any missing defaults (for backwards compatibility)
return {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
},
};
}
/**
* Update global settings with partial changes
*
* Performs a deep merge: nested objects like keyboardShortcuts are merged,
* not replaced. Updates are written atomically. Creates dataDir if needed.
*
* @param updates - Partial GlobalSettings to merge (only provided fields are updated)
* @returns Promise resolving to complete updated GlobalSettings
*/
async updateGlobalSettings(updates: Partial<GlobalSettings>): Promise<GlobalSettings> {
await ensureDataDir(this.dataDir);
const settingsPath = getGlobalSettingsPath(this.dataDir);
const current = await this.getGlobalSettings();
const updated: GlobalSettings = {
...current,
...updates,
version: SETTINGS_VERSION,
};
// Deep merge keyboard shortcuts if provided
if (updates.keyboardShortcuts) {
updated.keyboardShortcuts = {
...current.keyboardShortcuts,
...updates.keyboardShortcuts,
};
}
await atomicWriteJson(settingsPath, updated);
logger.info('Global settings updated');
return updated;
}
/**
* Check if global settings file exists
*
* Used to determine if user has previously configured settings.
*
* @returns Promise resolving to true if {dataDir}/settings.json exists
*/
async hasGlobalSettings(): Promise<boolean> {
const settingsPath = getGlobalSettingsPath(this.dataDir);
return fileExists(settingsPath);
}
// ============================================================================
// Credentials
// ============================================================================
/**
* Get credentials with defaults applied
*
* Reads from {dataDir}/credentials.json. If file doesn't exist, returns
* defaults (empty API keys). Used primarily by backend for API authentication.
* UI should use getMaskedCredentials() instead.
*
* @returns Promise resolving to complete Credentials object
*/
async getCredentials(): Promise<Credentials> {
const credentialsPath = getCredentialsPath(this.dataDir);
const credentials = await readJsonFile<Credentials>(credentialsPath, DEFAULT_CREDENTIALS);
return {
...DEFAULT_CREDENTIALS,
...credentials,
apiKeys: {
...DEFAULT_CREDENTIALS.apiKeys,
...credentials.apiKeys,
},
};
}
/**
* Update credentials with partial changes
*
* Updates individual API keys. Uses deep merge for apiKeys object.
* Creates dataDir if needed. Credentials are written atomically.
* WARNING: Use only in secure contexts - keys are unencrypted.
*
* @param updates - Partial Credentials (usually just apiKeys)
* @returns Promise resolving to complete updated Credentials object
*/
async updateCredentials(updates: Partial<Credentials>): Promise<Credentials> {
await ensureDataDir(this.dataDir);
const credentialsPath = getCredentialsPath(this.dataDir);
const current = await this.getCredentials();
const updated: Credentials = {
...current,
...updates,
version: CREDENTIALS_VERSION,
};
// Deep merge api keys if provided
if (updates.apiKeys) {
updated.apiKeys = {
...current.apiKeys,
...updates.apiKeys,
};
}
await atomicWriteJson(credentialsPath, updated);
logger.info('Credentials updated');
return updated;
}
/**
* Get masked credentials safe for UI display
*
* Returns API keys masked for security (first 4 and last 4 chars visible).
* Use this for showing credential status in UI without exposing full keys.
* Each key includes a 'configured' boolean and masked string representation.
*
* @returns Promise resolving to masked credentials object with each provider's status
*/
async getMaskedCredentials(): Promise<{
anthropic: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
const maskKey = (key: string): string => {
if (!key || key.length < 8) return '';
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
return {
anthropic: {
configured: !!credentials.apiKeys.anthropic,
masked: maskKey(credentials.apiKeys.anthropic),
},
};
}
/**
* Check if credentials file exists
*
* Used to determine if user has configured any API keys.
*
* @returns Promise resolving to true if {dataDir}/credentials.json exists
*/
async hasCredentials(): Promise<boolean> {
const credentialsPath = getCredentialsPath(this.dataDir);
return fileExists(credentialsPath);
}
// ============================================================================
// Project Settings
// ============================================================================
/**
* Get project-specific settings with defaults applied
*
* Reads from {projectPath}/.automaker/settings.json. If file doesn't exist,
* returns defaults. Project settings are optional - missing values fall back
* to global settings on the UI side.
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to complete ProjectSettings object
*/
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
const settingsPath = getProjectSettingsPath(projectPath);
const settings = await readJsonFile<ProjectSettings>(settingsPath, DEFAULT_PROJECT_SETTINGS);
return {
...DEFAULT_PROJECT_SETTINGS,
...settings,
};
}
/**
* Update project-specific settings with partial changes
*
* Performs a deep merge on boardBackground. Creates .automaker directory
* in project if needed. Updates are written atomically.
*
* @param projectPath - Absolute path to project directory
* @param updates - Partial ProjectSettings to merge
* @returns Promise resolving to complete updated ProjectSettings
*/
async updateProjectSettings(
projectPath: string,
updates: Partial<ProjectSettings>
): Promise<ProjectSettings> {
await ensureAutomakerDir(projectPath);
const settingsPath = getProjectSettingsPath(projectPath);
const current = await this.getProjectSettings(projectPath);
const updated: ProjectSettings = {
...current,
...updates,
version: PROJECT_SETTINGS_VERSION,
};
// Deep merge board background if provided
if (updates.boardBackground) {
updated.boardBackground = {
...current.boardBackground,
...updates.boardBackground,
};
}
await atomicWriteJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);
return updated;
}
/**
* Check if project settings file exists
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to true if {projectPath}/.automaker/settings.json exists
*/
async hasProjectSettings(projectPath: string): Promise<boolean> {
const settingsPath = getProjectSettingsPath(projectPath);
return fileExists(settingsPath);
}
// ============================================================================
// Migration
// ============================================================================
/**
* Migrate settings from localStorage to file-based storage
*
* Called during onboarding when UI detects localStorage data but no settings files.
* Extracts global settings, credentials, and per-project settings from various
* localStorage keys and writes them to the new file-based storage.
* Collects errors but continues on partial failures.
*
* @param localStorageData - Object containing localStorage key/value pairs to migrate
* @returns Promise resolving to migration result with success status and error list
*/
async migrateFromLocalStorage(localStorageData: {
'automaker-storage'?: string;
'automaker-setup'?: string;
'worktree-panel-collapsed'?: string;
'file-browser-recent-folders'?: string;
'automaker:lastProjectDir'?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> {
const errors: string[] = [];
let migratedGlobalSettings = false;
let migratedCredentials = false;
let migratedProjectCount = 0;
try {
// Parse the main automaker-storage
let appState: Record<string, unknown> = {};
if (localStorageData['automaker-storage']) {
try {
const parsed = JSON.parse(localStorageData['automaker-storage']);
appState = parsed.state || parsed;
} catch (e) {
errors.push(`Failed to parse automaker-storage: ${e}`);
}
}
// Extract global settings
const globalSettings: Partial<GlobalSettings> = {
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
kanbanCardDetailLevel:
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
maxConcurrency: (appState.maxConcurrency as number) || 3,
defaultSkipTests:
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
enableDependencyBlocking:
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode:
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
muteDoneSound: (appState.muteDoneSound as boolean) || false,
enhancementModel:
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
lastSelectedSessionByProject:
(appState.lastSelectedSessionByProject as Record<string, string>) || {},
};
// Add direct localStorage values
if (localStorageData['automaker:lastProjectDir']) {
globalSettings.lastProjectDir = localStorageData['automaker:lastProjectDir'];
}
if (localStorageData['file-browser-recent-folders']) {
try {
globalSettings.recentFolders = JSON.parse(
localStorageData['file-browser-recent-folders']
);
} catch {
globalSettings.recentFolders = [];
}
}
if (localStorageData['worktree-panel-collapsed']) {
globalSettings.worktreePanelCollapsed =
localStorageData['worktree-panel-collapsed'] === 'true';
}
// Save global settings
await this.updateGlobalSettings(globalSettings);
migratedGlobalSettings = true;
logger.info('Migrated global settings from localStorage');
// Extract and save credentials
if (appState.apiKeys) {
const apiKeys = appState.apiKeys as {
anthropic?: string;
google?: string;
openai?: string;
};
await this.updateCredentials({
apiKeys: {
anthropic: apiKeys.anthropic || '',
google: apiKeys.google || '',
openai: apiKeys.openai || '',
},
});
migratedCredentials = true;
logger.info('Migrated credentials from localStorage');
}
// Migrate per-project settings
const boardBackgroundByProject = appState.boardBackgroundByProject as
| Record<string, BoardBackgroundSettings>
| undefined;
const currentWorktreeByProject = appState.currentWorktreeByProject as
| Record<string, { path: string | null; branch: string }>
| undefined;
const worktreesByProject = appState.worktreesByProject as
| Record<string, WorktreeInfo[]>
| undefined;
// Get unique project paths that have per-project settings
const projectPaths = new Set<string>();
if (boardBackgroundByProject) {
Object.keys(boardBackgroundByProject).forEach((p) => projectPaths.add(p));
}
if (currentWorktreeByProject) {
Object.keys(currentWorktreeByProject).forEach((p) => projectPaths.add(p));
}
if (worktreesByProject) {
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
}
// Also check projects list for theme settings
const projects = (appState.projects as ProjectRef[]) || [];
for (const project of projects) {
if (project.theme) {
projectPaths.add(project.path);
}
}
// Migrate each project's settings
for (const projectPath of projectPaths) {
try {
const projectSettings: Partial<ProjectSettings> = {};
// Get theme from project object
const project = projects.find((p) => p.path === projectPath);
if (project?.theme) {
projectSettings.theme = project.theme as ProjectSettings['theme'];
}
if (boardBackgroundByProject?.[projectPath]) {
projectSettings.boardBackground = boardBackgroundByProject[projectPath];
}
if (currentWorktreeByProject?.[projectPath]) {
projectSettings.currentWorktree = currentWorktreeByProject[projectPath];
}
if (worktreesByProject?.[projectPath]) {
projectSettings.worktrees = worktreesByProject[projectPath];
}
if (Object.keys(projectSettings).length > 0) {
await this.updateProjectSettings(projectPath, projectSettings);
migratedProjectCount++;
}
} catch (e) {
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
}
}
logger.info(`Migration complete: ${migratedProjectCount} projects migrated`);
return {
success: errors.length === 0,
migratedGlobalSettings,
migratedCredentials,
migratedProjectCount,
errors,
};
} catch (error) {
logger.error('Migration failed:', error);
errors.push(`Migration failed: ${error}`);
return {
success: false,
migratedGlobalSettings,
migratedCredentials,
migratedProjectCount,
errors,
};
}
}
/**
* Get the data directory path
*
* Returns the absolute path to the directory where global settings and
* credentials are stored. Useful for logging, debugging, and validation.
*
* @returns Absolute path to data directory
*/
getDataDir(): string {
return this.dataDir;
}
}