mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge branch 'main' of github.com:AutoMaker-Org/automaker into improve-context-page
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "child_process";
|
||||
import * as os from "os";
|
||||
import * as pty from "node-pty";
|
||||
import { ClaudeUsage } from "../routes/claude/types.js";
|
||||
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
|
||||
@@ -15,21 +15,21 @@ import { ClaudeUsage } from "../routes/claude/types.js";
|
||||
* - Windows: Uses node-pty for PTY
|
||||
*/
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = "claude";
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
private isWindows = os.platform() === "win32";
|
||||
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 checkCmd = this.isWindows ? 'where' : 'which';
|
||||
const proc = spawn(checkCmd, [this.claudeBinary]);
|
||||
proc.on("close", (code) => {
|
||||
proc.on('close', (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
proc.on("error", () => {
|
||||
proc.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
@@ -59,12 +59,12 @@ export class ClaudeUsageService {
|
||||
*/
|
||||
private executeClaudeUsageCommandMac(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
// Use a simple working directory (home or tmp)
|
||||
const workingDirectory = process.env.HOME || "/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
|
||||
@@ -86,11 +86,11 @@ export class ClaudeUsageService {
|
||||
expect eof
|
||||
`;
|
||||
|
||||
const proc = spawn("expect", ["-c", expectScript], {
|
||||
const proc = spawn('expect', ['-c', expectScript], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,26 +98,30 @@ export class ClaudeUsageService {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
proc.kill();
|
||||
reject(new Error("Command timed out"));
|
||||
reject(new Error('Command timed out'));
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
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")) {
|
||||
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;
|
||||
}
|
||||
@@ -128,11 +132,11 @@ export class ClaudeUsageService {
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
reject(new Error("No output from claude command"));
|
||||
reject(new Error('No output from claude command'));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
@@ -147,20 +151,20 @@ export class ClaudeUsageService {
|
||||
*/
|
||||
private executeClaudeUsageCommandWindows(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
let output = '';
|
||||
let settled = false;
|
||||
let hasSeenUsageData = false;
|
||||
|
||||
const workingDirectory = process.env.USERPROFILE || os.homedir() || "C:\\";
|
||||
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
|
||||
|
||||
const ptyProcess = pty.spawn("cmd.exe", ["/c", "claude", "/usage"], {
|
||||
name: "xterm-256color",
|
||||
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
|
||||
@@ -168,7 +172,7 @@ export class ClaudeUsageService {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ptyProcess.kill();
|
||||
reject(new Error("Command timed out"));
|
||||
reject(new Error('Command timed out'));
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
@@ -176,21 +180,21 @@ export class ClaudeUsageService {
|
||||
output += data;
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session")
|
||||
if (!hasSeenUsageData && output.includes("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
|
||||
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")) {
|
||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
ptyProcess.write("\x1b"); // Send escape key
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
@@ -202,7 +206,7 @@ export class ClaudeUsageService {
|
||||
settled = true;
|
||||
|
||||
// Check for authentication errors in output
|
||||
if (output.includes("token_expired") || output.includes("authentication_error")) {
|
||||
if (output.includes('token_expired') || output.includes('authentication_error')) {
|
||||
reject(new Error("Authentication required - please run 'claude login'"));
|
||||
return;
|
||||
}
|
||||
@@ -212,7 +216,7 @@ export class ClaudeUsageService {
|
||||
} else if (exitCode !== 0) {
|
||||
reject(new Error(`Command exited with code ${exitCode}`));
|
||||
} else {
|
||||
reject(new Error("No output from claude command"));
|
||||
reject(new Error('No output from claude command'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -223,7 +227,7 @@ export class ClaudeUsageService {
|
||||
*/
|
||||
private stripAnsiCodes(text: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
|
||||
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,21 +252,24 @@ export class ClaudeUsageService {
|
||||
*/
|
||||
private parseUsageOutput(rawOutput: string): ClaudeUsage {
|
||||
const output = this.stripAnsiCodes(rawOutput);
|
||||
const lines = output.split("\n").map(l => l.trim()).filter(l => l);
|
||||
const lines = output
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l);
|
||||
|
||||
// Parse session usage
|
||||
const sessionData = this.parseSection(lines, "Current session", "session");
|
||||
const sessionData = this.parseSection(lines, 'Current session', 'session');
|
||||
|
||||
// Parse weekly usage (all models)
|
||||
const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly");
|
||||
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");
|
||||
let sonnetData = this.parseSection(lines, 'Current week (Sonnet only)', 'sonnet');
|
||||
if (sonnetData.percentage === 0) {
|
||||
sonnetData = this.parseSection(lines, "Current week (Sonnet)", "sonnet");
|
||||
sonnetData = this.parseSection(lines, 'Current week (Sonnet)', 'sonnet');
|
||||
}
|
||||
if (sonnetData.percentage === 0) {
|
||||
sonnetData = this.parseSection(lines, "Current week (Opus)", "sonnet");
|
||||
sonnetData = this.parseSection(lines, 'Current week (Opus)', 'sonnet');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -294,10 +301,14 @@ export class ClaudeUsageService {
|
||||
/**
|
||||
* 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 } {
|
||||
private parseSection(
|
||||
lines: string[],
|
||||
sectionLabel: string,
|
||||
type: string
|
||||
): { percentage: number; resetTime: string; resetText: string } {
|
||||
let percentage = 0;
|
||||
let resetTime = this.getDefaultResetTime(type);
|
||||
let resetText = "";
|
||||
let resetText = '';
|
||||
|
||||
// Find the LAST occurrence of the section (terminal output has multiple screen refreshes)
|
||||
let sectionIndex = -1;
|
||||
@@ -321,14 +332,14 @@ export class ClaudeUsageService {
|
||||
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";
|
||||
const isUsed = percentMatch[2].toLowerCase() === 'used';
|
||||
// Convert "left" to "used" percentage (our UI shows % used)
|
||||
percentage = isUsed ? value : (100 - value);
|
||||
percentage = isUsed ? value : 100 - value;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reset time - only take the first match
|
||||
if (!resetText && line.toLowerCase().includes("reset")) {
|
||||
if (!resetText && line.toLowerCase().includes('reset')) {
|
||||
resetText = line;
|
||||
}
|
||||
}
|
||||
@@ -337,7 +348,7 @@ export class ClaudeUsageService {
|
||||
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();
|
||||
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
|
||||
}
|
||||
|
||||
return { percentage, resetTime, resetText };
|
||||
@@ -350,7 +361,9 @@ export class ClaudeUsageService {
|
||||
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);
|
||||
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;
|
||||
@@ -374,9 +387,9 @@ export class ClaudeUsageService {
|
||||
const ampm = simpleTimeMatch[3].toLowerCase();
|
||||
|
||||
// Convert 12-hour to 24-hour
|
||||
if (ampm === "pm" && hours !== 12) {
|
||||
if (ampm === 'pm' && hours !== 12) {
|
||||
hours += 12;
|
||||
} else if (ampm === "am" && hours === 12) {
|
||||
} else if (ampm === 'am' && hours === 12) {
|
||||
hours = 0;
|
||||
}
|
||||
|
||||
@@ -392,7 +405,9 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
@@ -401,16 +416,26 @@ export class ClaudeUsageService {
|
||||
const ampm = dateMatch[5].toLowerCase();
|
||||
|
||||
// Convert 12-hour to 24-hour
|
||||
if (ampm === "pm" && hours !== 12) {
|
||||
if (ampm === 'pm' && hours !== 12) {
|
||||
hours += 12;
|
||||
} else if (ampm === "am" && 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
|
||||
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)];
|
||||
|
||||
@@ -435,7 +460,7 @@ export class ClaudeUsageService {
|
||||
private getDefaultResetTime(type: string): string {
|
||||
const now = new Date();
|
||||
|
||||
if (type === "session") {
|
||||
if (type === 'session') {
|
||||
// Session resets in ~5 hours
|
||||
return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString();
|
||||
} else {
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
* Each feature is stored in .automaker/features/{featureId}/feature.json
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import type { Feature } from "@automaker/types";
|
||||
import { createLogger } from "@automaker/utils";
|
||||
import * as secureFs from "../lib/secure-fs.js";
|
||||
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 "@automaker/platform";
|
||||
} from '@automaker/platform';
|
||||
|
||||
const logger = createLogger("FeatureLoader");
|
||||
const logger = createLogger('FeatureLoader');
|
||||
|
||||
// Re-export Feature type for convenience
|
||||
export type { Feature };
|
||||
@@ -39,24 +39,16 @@ export class FeatureLoader {
|
||||
*/
|
||||
private async deleteOrphanedImages(
|
||||
projectPath: string,
|
||||
oldPaths:
|
||||
| Array<string | { path: string; [key: string]: unknown }>
|
||||
| undefined,
|
||||
newPaths:
|
||||
| Array<string | { path: string; [key: string]: unknown }>
|
||||
| undefined
|
||||
oldPaths: Array<string | { path: string; [key: string]: unknown }> | undefined,
|
||||
newPaths: Array<string | { path: string; [key: string]: unknown }> | undefined
|
||||
): Promise<void> {
|
||||
if (!oldPaths || oldPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -67,10 +59,7 @@ export class FeatureLoader {
|
||||
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting (file may already be gone)
|
||||
logger.warn(
|
||||
`[FeatureLoader] Failed to delete image: ${oldPath}`,
|
||||
error
|
||||
);
|
||||
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,9 +72,7 @@ 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;
|
||||
}
|
||||
@@ -93,14 +80,11 @@ export class FeatureLoader {
|
||||
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
|
||||
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/`)) {
|
||||
@@ -117,9 +101,7 @@ export class FeatureLoader {
|
||||
try {
|
||||
await secureFs.access(fullOriginalPath);
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
|
||||
);
|
||||
logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -129,9 +111,7 @@ export class FeatureLoader {
|
||||
|
||||
// Copy the file
|
||||
await secureFs.copyFile(fullOriginalPath, newPath);
|
||||
console.log(
|
||||
`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`
|
||||
);
|
||||
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
|
||||
|
||||
// Try to delete the original temp file
|
||||
try {
|
||||
@@ -141,7 +121,7 @@ export class FeatureLoader {
|
||||
}
|
||||
|
||||
// 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 });
|
||||
@@ -168,20 +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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,10 +192,7 @@ export class FeatureLoader {
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
|
||||
try {
|
||||
const content = (await secureFs.readFile(
|
||||
featureJsonPath,
|
||||
"utf-8"
|
||||
)) as string;
|
||||
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(content);
|
||||
|
||||
if (!feature.id) {
|
||||
@@ -233,7 +204,7 @@ 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) {
|
||||
logger.warn(
|
||||
@@ -250,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) {
|
||||
logger.error("Failed to get all features:", error);
|
||||
logger.error('Failed to get all features:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -268,19 +239,13 @@ export class FeatureLoader {
|
||||
async get(projectPath: string, featureId: string): Promise<Feature | null> {
|
||||
try {
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
const content = (await secureFs.readFile(
|
||||
featureJsonPath,
|
||||
"utf-8"
|
||||
)) as string;
|
||||
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;
|
||||
}
|
||||
logger.error(
|
||||
`[FeatureLoader] Failed to get feature ${featureId}:`,
|
||||
error
|
||||
);
|
||||
logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -288,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);
|
||||
@@ -311,19 +273,15 @@ 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 secureFs.writeFile(
|
||||
featureJsonPath,
|
||||
JSON.stringify(feature, null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8');
|
||||
|
||||
logger.info(`Created feature ${featureId}`);
|
||||
return feature;
|
||||
@@ -346,36 +304,22 @@ 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 secureFs.writeFile(
|
||||
featureJsonPath,
|
||||
JSON.stringify(updatedFeature, null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8');
|
||||
|
||||
logger.info(`Updated feature ${featureId}`);
|
||||
return updatedFeature;
|
||||
@@ -391,10 +335,7 @@ export class FeatureLoader {
|
||||
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[FeatureLoader] Failed to delete feature ${featureId}:`,
|
||||
error
|
||||
);
|
||||
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -402,25 +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 secureFs.readFile(
|
||||
agentOutputPath,
|
||||
"utf-8"
|
||||
)) as string;
|
||||
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;
|
||||
}
|
||||
logger.error(
|
||||
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
|
||||
error
|
||||
);
|
||||
logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -428,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 secureFs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
||||
await secureFs.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 secureFs.unlink(agentOutputPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* - Per-project settings ({projectPath}/.automaker/settings.json)
|
||||
*/
|
||||
|
||||
import { createLogger } from "@automaker/utils";
|
||||
import * as secureFs from "../lib/secure-fs.js";
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
|
||||
import {
|
||||
getGlobalSettingsPath,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
ensureAutomakerDir,
|
||||
} from "@automaker/platform";
|
||||
} from '@automaker/platform';
|
||||
import type {
|
||||
GlobalSettings,
|
||||
Credentials,
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
} from "../types/settings.js";
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from "../types/settings.js";
|
||||
} from '../types/settings.js';
|
||||
|
||||
const logger = createLogger("SettingsService");
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
@@ -47,7 +47,7 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, "utf-8");
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
@@ -65,10 +65,10 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, "utf-8")) as string;
|
||||
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") {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
@@ -128,10 +128,7 @@ export class SettingsService {
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
const settings = await readJsonFile<GlobalSettings>(
|
||||
settingsPath,
|
||||
DEFAULT_GLOBAL_SETTINGS
|
||||
);
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
@@ -153,9 +150,7 @@ export class SettingsService {
|
||||
* @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> {
|
||||
async updateGlobalSettings(updates: Partial<GlobalSettings>): Promise<GlobalSettings> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
@@ -175,7 +170,7 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info("Global settings updated");
|
||||
logger.info('Global settings updated');
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -207,10 +202,7 @@ export class SettingsService {
|
||||
*/
|
||||
async getCredentials(): Promise<Credentials> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
const credentials = await readJsonFile<Credentials>(
|
||||
credentialsPath,
|
||||
DEFAULT_CREDENTIALS
|
||||
);
|
||||
const credentials = await readJsonFile<Credentials>(credentialsPath, DEFAULT_CREDENTIALS);
|
||||
|
||||
return {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
@@ -252,7 +244,7 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
await atomicWriteJson(credentialsPath, updated);
|
||||
logger.info("Credentials updated");
|
||||
logger.info('Credentials updated');
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -272,7 +264,7 @@ export class SettingsService {
|
||||
const credentials = await this.getCredentials();
|
||||
|
||||
const maskKey = (key: string): string => {
|
||||
if (!key || key.length < 8) return "";
|
||||
if (!key || key.length < 8) return '';
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
@@ -312,10 +304,7 @@ export class SettingsService {
|
||||
*/
|
||||
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
const settings = await readJsonFile<ProjectSettings>(
|
||||
settingsPath,
|
||||
DEFAULT_PROJECT_SETTINGS
|
||||
);
|
||||
const settings = await readJsonFile<ProjectSettings>(settingsPath, DEFAULT_PROJECT_SETTINGS);
|
||||
|
||||
return {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
@@ -388,11 +377,11 @@ export class SettingsService {
|
||||
* @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;
|
||||
'automaker-storage'?: string;
|
||||
'automaker-setup'?: string;
|
||||
'worktree-panel-collapsed'?: string;
|
||||
'file-browser-recent-folders'?: string;
|
||||
'automaker:lastProjectDir'?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
@@ -408,9 +397,9 @@ export class SettingsService {
|
||||
try {
|
||||
// Parse the main automaker-storage
|
||||
let appState: Record<string, unknown> = {};
|
||||
if (localStorageData["automaker-storage"]) {
|
||||
if (localStorageData['automaker-storage']) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorageData["automaker-storage"]);
|
||||
const parsed = JSON.parse(localStorageData['automaker-storage']);
|
||||
appState = parsed.state || parsed;
|
||||
} catch (e) {
|
||||
errors.push(`Failed to parse automaker-storage: ${e}`);
|
||||
@@ -419,20 +408,14 @@ export class SettingsService {
|
||||
|
||||
// Extract global settings
|
||||
const globalSettings: Partial<GlobalSettings> = {
|
||||
theme: (appState.theme as GlobalSettings["theme"]) || "dark",
|
||||
sidebarOpen:
|
||||
appState.sidebarOpen !== undefined
|
||||
? (appState.sidebarOpen as boolean)
|
||||
: true,
|
||||
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",
|
||||
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined
|
||||
? (appState.defaultSkipTests as boolean)
|
||||
: true,
|
||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||
enableDependencyBlocking:
|
||||
appState.enableDependencyBlocking !== undefined
|
||||
? (appState.enableDependencyBlocking as boolean)
|
||||
@@ -440,55 +423,48 @@ export class SettingsService {
|
||||
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,
|
||||
(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",
|
||||
(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[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
|
||||
lastSelectedSessionByProject:
|
||||
(appState.lastSelectedSessionByProject as Record<string, string>) ||
|
||||
{},
|
||||
(appState.lastSelectedSessionByProject as Record<string, string>) || {},
|
||||
};
|
||||
|
||||
// Add direct localStorage values
|
||||
if (localStorageData["automaker:lastProjectDir"]) {
|
||||
globalSettings.lastProjectDir =
|
||||
localStorageData["automaker:lastProjectDir"];
|
||||
if (localStorageData['automaker:lastProjectDir']) {
|
||||
globalSettings.lastProjectDir = localStorageData['automaker:lastProjectDir'];
|
||||
}
|
||||
|
||||
if (localStorageData["file-browser-recent-folders"]) {
|
||||
if (localStorageData['file-browser-recent-folders']) {
|
||||
try {
|
||||
globalSettings.recentFolders = JSON.parse(
|
||||
localStorageData["file-browser-recent-folders"]
|
||||
localStorageData['file-browser-recent-folders']
|
||||
);
|
||||
} catch {
|
||||
globalSettings.recentFolders = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (localStorageData["worktree-panel-collapsed"]) {
|
||||
if (localStorageData['worktree-panel-collapsed']) {
|
||||
globalSettings.worktreePanelCollapsed =
|
||||
localStorageData["worktree-panel-collapsed"] === "true";
|
||||
localStorageData['worktree-panel-collapsed'] === 'true';
|
||||
}
|
||||
|
||||
// Save global settings
|
||||
await this.updateGlobalSettings(globalSettings);
|
||||
migratedGlobalSettings = true;
|
||||
logger.info("Migrated global settings from localStorage");
|
||||
logger.info('Migrated global settings from localStorage');
|
||||
|
||||
// Extract and save credentials
|
||||
if (appState.apiKeys) {
|
||||
@@ -499,13 +475,13 @@ export class SettingsService {
|
||||
};
|
||||
await this.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: apiKeys.anthropic || "",
|
||||
google: apiKeys.google || "",
|
||||
openai: apiKeys.openai || "",
|
||||
anthropic: apiKeys.anthropic || '',
|
||||
google: apiKeys.google || '',
|
||||
openai: apiKeys.openai || '',
|
||||
},
|
||||
});
|
||||
migratedCredentials = true;
|
||||
logger.info("Migrated credentials from localStorage");
|
||||
logger.info('Migrated credentials from localStorage');
|
||||
}
|
||||
|
||||
// Migrate per-project settings
|
||||
@@ -522,14 +498,10 @@ export class SettingsService {
|
||||
// Get unique project paths that have per-project settings
|
||||
const projectPaths = new Set<string>();
|
||||
if (boardBackgroundByProject) {
|
||||
Object.keys(boardBackgroundByProject).forEach((p) =>
|
||||
projectPaths.add(p)
|
||||
);
|
||||
Object.keys(boardBackgroundByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
if (currentWorktreeByProject) {
|
||||
Object.keys(currentWorktreeByProject).forEach((p) =>
|
||||
projectPaths.add(p)
|
||||
);
|
||||
Object.keys(currentWorktreeByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
if (worktreesByProject) {
|
||||
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
|
||||
@@ -551,17 +523,15 @@ export class SettingsService {
|
||||
// Get theme from project object
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
if (project?.theme) {
|
||||
projectSettings.theme = project.theme as ProjectSettings["theme"];
|
||||
projectSettings.theme = project.theme as ProjectSettings['theme'];
|
||||
}
|
||||
|
||||
if (boardBackgroundByProject?.[projectPath]) {
|
||||
projectSettings.boardBackground =
|
||||
boardBackgroundByProject[projectPath];
|
||||
projectSettings.boardBackground = boardBackgroundByProject[projectPath];
|
||||
}
|
||||
|
||||
if (currentWorktreeByProject?.[projectPath]) {
|
||||
projectSettings.currentWorktree =
|
||||
currentWorktreeByProject[projectPath];
|
||||
projectSettings.currentWorktree = currentWorktreeByProject[projectPath];
|
||||
}
|
||||
|
||||
if (worktreesByProject?.[projectPath]) {
|
||||
@@ -573,15 +543,11 @@ export class SettingsService {
|
||||
migratedProjectCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`Failed to migrate project settings for ${projectPath}: ${e}`
|
||||
);
|
||||
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Migration complete: ${migratedProjectCount} projects migrated`
|
||||
);
|
||||
logger.info(`Migration complete: ${migratedProjectCount} projects migrated`);
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
@@ -591,7 +557,7 @@ export class SettingsService {
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Migration failed:", error);
|
||||
logger.error('Migration failed:', error);
|
||||
errors.push(`Migration failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -5,17 +5,29 @@
|
||||
* Supports cross-platform shell detection including WSL.
|
||||
*/
|
||||
|
||||
import * as pty from "node-pty";
|
||||
import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs";
|
||||
import * as pty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
// Session limit constants - shared with routes/settings.ts
|
||||
export const MIN_MAX_SESSIONS = 1;
|
||||
export const MAX_MAX_SESSIONS = 1000;
|
||||
|
||||
// Maximum number of concurrent terminal sessions
|
||||
// Can be overridden via TERMINAL_MAX_SESSIONS environment variable
|
||||
// Default set to 1000 - effectively unlimited for most use cases
|
||||
let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
|
||||
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
|
||||
// Using 4ms for responsive input feedback while still preventing flood
|
||||
// Note: 16ms caused perceived input lag, especially with backspace
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
@@ -53,20 +65,20 @@ export class TerminalService extends EventEmitter {
|
||||
const platform = os.platform();
|
||||
|
||||
// Check if running in WSL
|
||||
if (platform === "linux" && this.isWSL()) {
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
// In WSL, prefer the user's configured shell or bash
|
||||
const userShell = process.env.SHELL || "/bin/bash";
|
||||
const userShell = process.env.SHELL || '/bin/bash';
|
||||
if (fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ["--login"] };
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
return { shell: "/bin/bash", args: ["--login"] };
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case "win32": {
|
||||
case 'win32': {
|
||||
// Windows: prefer PowerShell, fall back to cmd
|
||||
const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||
const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
|
||||
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
|
||||
if (fs.existsSync(pwshCore)) {
|
||||
return { shell: pwshCore, args: [] };
|
||||
@@ -74,32 +86,32 @@ export class TerminalService extends EventEmitter {
|
||||
if (fs.existsSync(pwsh)) {
|
||||
return { shell: pwsh, args: [] };
|
||||
}
|
||||
return { shell: "cmd.exe", args: [] };
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
|
||||
case "darwin": {
|
||||
case 'darwin': {
|
||||
// macOS: prefer user's shell, then zsh, then bash
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ["--login"] };
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync("/bin/zsh")) {
|
||||
return { shell: "/bin/zsh", args: ["--login"] };
|
||||
if (fs.existsSync('/bin/zsh')) {
|
||||
return { shell: '/bin/zsh', args: ['--login'] };
|
||||
}
|
||||
return { shell: "/bin/bash", args: ["--login"] };
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
case "linux":
|
||||
case 'linux':
|
||||
default: {
|
||||
// Linux: prefer user's shell, then bash, then sh
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ["--login"] };
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync("/bin/bash")) {
|
||||
return { shell: "/bin/bash", args: ["--login"] };
|
||||
if (fs.existsSync('/bin/bash')) {
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
return { shell: "/bin/sh", args: [] };
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,9 +122,9 @@ export class TerminalService extends EventEmitter {
|
||||
isWSL(): boolean {
|
||||
try {
|
||||
// Check /proc/version for Microsoft/WSL indicators
|
||||
if (fs.existsSync("/proc/version")) {
|
||||
const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase();
|
||||
return version.includes("microsoft") || version.includes("wsl");
|
||||
if (fs.existsSync('/proc/version')) {
|
||||
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
||||
return version.includes('microsoft') || version.includes('wsl');
|
||||
}
|
||||
// Check for WSL environment variable
|
||||
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
|
||||
@@ -144,6 +156,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
* Includes basic sanitization against null bytes and path normalization
|
||||
*/
|
||||
private resolveWorkingDirectory(requestedCwd?: string): string {
|
||||
const homeDir = os.homedir();
|
||||
@@ -156,11 +169,23 @@ export class TerminalService extends EventEmitter {
|
||||
// Clean up the path
|
||||
let cwd = requestedCwd.trim();
|
||||
|
||||
// Reject paths with null bytes (could bypass path checks)
|
||||
if (cwd.includes('\0')) {
|
||||
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
return homeDir;
|
||||
}
|
||||
|
||||
// Fix double slashes at start (but not for Windows UNC paths)
|
||||
if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) {
|
||||
if (cwd.startsWith('//') && !cwd.startsWith('//wsl')) {
|
||||
cwd = cwd.slice(1);
|
||||
}
|
||||
|
||||
// Normalize the path to resolve . and .. segments
|
||||
// Skip normalization for WSL UNC paths as path.resolve would break them
|
||||
if (!cwd.startsWith('//wsl')) {
|
||||
cwd = path.resolve(cwd);
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
try {
|
||||
const stat = fs.statSync(cwd);
|
||||
@@ -176,10 +201,41 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal session
|
||||
* Get current session count
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession {
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
getSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed sessions
|
||||
*/
|
||||
getMaxSessions(): number {
|
||||
return maxSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set maximum allowed sessions (can be called dynamically)
|
||||
*/
|
||||
setMaxSessions(limit: number): void {
|
||||
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
||||
maxSessions = limit;
|
||||
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal session
|
||||
* Returns null if the maximum session limit has been reached
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||
const shell = options.shell || detectedShell;
|
||||
@@ -188,18 +244,22 @@ export class TerminalService extends EventEmitter {
|
||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with some useful defaults
|
||||
// These settings ensure consistent terminal behavior across platforms
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
TERM_PROGRAM: "automaker-terminal",
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'automaker-terminal',
|
||||
// Ensure proper locale for character handling
|
||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: "xterm-256color",
|
||||
name: 'xterm-256color',
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
cwd,
|
||||
@@ -212,8 +272,8 @@ export class TerminalService extends EventEmitter {
|
||||
cwd,
|
||||
createdAt: new Date(),
|
||||
shell,
|
||||
scrollbackBuffer: "",
|
||||
outputBuffer: "",
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
resizeInProgress: false,
|
||||
resizeDebounceTimeout: null,
|
||||
@@ -233,12 +293,12 @@ export class TerminalService extends EventEmitter {
|
||||
// Schedule another flush for remaining data
|
||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
session.outputBuffer = "";
|
||||
session.outputBuffer = '';
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
|
||||
this.emit("data", id, dataToSend);
|
||||
this.emit('data', id, dataToSend);
|
||||
};
|
||||
|
||||
// Forward data events with throttling
|
||||
@@ -271,7 +331,7 @@ export class TerminalService extends EventEmitter {
|
||||
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit("exit", id, exitCode);
|
||||
this.emit('exit', id, exitCode);
|
||||
});
|
||||
|
||||
console.log(`[Terminal] Session ${id} created successfully`);
|
||||
@@ -333,6 +393,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Kill a terminal session
|
||||
* Attempts graceful SIGTERM first, then SIGKILL after 1 second if still alive
|
||||
*/
|
||||
killSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
@@ -350,12 +411,32 @@ export class TerminalService extends EventEmitter {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
session.pty.kill();
|
||||
this.sessions.delete(sessionId);
|
||||
console.log(`[Terminal] Session ${sessionId} killed`);
|
||||
|
||||
// First try graceful SIGTERM to allow process cleanup
|
||||
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
|
||||
session.pty.kill('SIGTERM');
|
||||
|
||||
// Schedule SIGKILL fallback if process doesn't exit gracefully
|
||||
// The onExit handler will remove session from map when it actually exits
|
||||
setTimeout(() => {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
try {
|
||||
session.pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
// Force remove from map if still present
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
console.log(`[Terminal] Session ${sessionId} kill initiated`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||
// Still try to remove from map even if kill fails
|
||||
this.sessions.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -386,7 +467,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Clear any pending output that hasn't been flushed yet
|
||||
// This data is already in scrollbackBuffer
|
||||
session.outputBuffer = "";
|
||||
session.outputBuffer = '';
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
|
||||
Reference in New Issue
Block a user