Merge main into feat/cursor-cli-integration

Carefully merged latest changes from main branch into the Cursor CLI integration
branch. This merge brings in important improvements and fixes while preserving
all Cursor-related functionality.

Key changes from main:
- Sandbox mode security improvements and cloud storage compatibility
- Version-based settings migrations (v2 schema)
- Port configuration centralization
- System paths utilities for CLI detection
- Enhanced error handling in HttpApiClient
- Windows MCP process cleanup fixes
- New validation and build commands
- GitHub issue templates and release process improvements

Resolved conflicts in:
- apps/server/src/routes/context/routes/describe-image.ts
  (Combined Cursor provider routing with secure-fs imports)
- apps/server/src/services/auto-mode-service.ts
  (Merged failure tracking with raw output logging)
- apps/server/tests/unit/services/terminal-service.test.ts
  (Updated to async tests with systemPathExists mocking)
- libs/platform/src/index.ts
  (Combined WSL utilities with system-paths exports)
- libs/types/src/settings.ts
  (Merged DEFAULT_PHASE_MODELS with SETTINGS_VERSION constants)

All Cursor CLI integration features remain intact including:
- CursorProvider and CliProvider base class
- Phase-based model configuration
- Provider registry and factory patterns
- WSL support for Windows
- Model override UI components
- Cursor-specific settings and configurations

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2026-01-01 18:03:48 +01:00
100 changed files with 4782 additions and 3239 deletions

View File

@@ -25,12 +25,15 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "^1.0.0"
"@automaker/types": "1.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -18,13 +18,16 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0"
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -18,12 +18,15 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "^1.0.0"
"@automaker/types": "1.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -17,13 +17,16 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "^1.0.0",
"p-limit": "^6.2.0"
"@automaker/types": "1.0.0",
"p-limit": "6.2.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -0,0 +1,8 @@
/**
* Centralized port configuration for AutoMaker
*
* Re-exports from @automaker/types for backward compatibility.
* The canonical definition is in @automaker/types to allow browser-safe imports.
*/
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from '@automaker/types';

View File

@@ -70,3 +70,66 @@ export {
type WslCliResult,
type WslOptions,
} from './wsl.js';
// System paths for tool detection (GitHub CLI, Claude CLI, Node.js, etc.)
export * as systemPaths from './system-paths.js';
export {
// CLI tool paths
getGitHubCliPaths,
getClaudeCliPaths,
getClaudeConfigDir,
getClaudeCredentialPaths,
getClaudeSettingsPath,
getClaudeStatsCachePath,
getClaudeProjectsDir,
getShellPaths,
getExtendedPath,
// Node.js paths
getNvmPaths,
getFnmPaths,
getNodeSystemPaths,
getScoopNodePath,
getChocolateyNodePath,
getWslVersionPath,
// System path operations
systemPathExists,
systemPathAccess,
systemPathIsExecutable,
systemPathReadFile,
systemPathReadFileSync,
systemPathWriteFileSync,
systemPathReaddir,
systemPathReaddirSync,
systemPathStatSync,
systemPathStat,
isAllowedSystemPath,
// High-level methods
findFirstExistingPath,
findGitHubCliPath,
findClaudeCliPath,
getClaudeAuthIndicators,
type ClaudeAuthIndicators,
// Electron userData operations
setElectronUserDataPath,
getElectronUserDataPath,
isElectronUserDataPath,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
electronUserDataExists,
// Script directory operations
setScriptBaseDir,
getScriptBaseDir,
scriptDirExists,
scriptDirMkdirSync,
scriptDirCreateWriteStream,
// Electron app bundle operations
setElectronAppPaths,
electronAppExists,
electronAppReadFileSync,
electronAppStatSync,
electronAppStat,
electronAppReadFile,
} from './system-paths.js';
// Port configuration
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js';

View File

@@ -3,12 +3,25 @@
*
* Handles finding Node.js when the app is launched from desktop environments
* (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited.
*
* Uses centralized system-paths module for all file system access.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import {
systemPathExists,
systemPathIsExecutable,
systemPathReaddirSync,
systemPathReadFileSync,
getNvmPaths,
getFnmPaths,
getNodeSystemPaths,
getScoopNodePath,
getChocolateyNodePath,
getWslVersionPath,
} from './system-paths.js';
/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */
const VERSION_DIR_PATTERN = /^v?\d+/;
@@ -45,18 +58,11 @@ export interface NodeFinderOptions {
/**
* Check if a file exists and is executable
* On Windows, only checks existence (X_OK is not meaningful)
* Uses centralized systemPathIsExecutable for path validation
*/
function isExecutable(filePath: string): boolean {
try {
if (process.platform === 'win32') {
// On Windows, fs.constants.X_OK is not meaningful - just check existence
fs.accessSync(filePath, fs.constants.F_OK);
} else {
// On Unix-like systems, check for execute permission
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
return systemPathIsExecutable(filePath);
} catch {
return false;
}
@@ -71,11 +77,14 @@ function findNodeFromVersionManager(
basePath: string,
binSubpath: string = 'bin/node'
): string | null {
if (!fs.existsSync(basePath)) return null;
try {
if (!systemPathExists(basePath)) return null;
} catch {
return null;
}
try {
const allVersions = fs
.readdirSync(basePath)
const allVersions = systemPathReaddirSync(basePath)
.filter((v) => VERSION_DIR_PATTERN.test(v))
// Semantic version sort - newest first using localeCompare with numeric option
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
@@ -101,39 +110,30 @@ function findNodeFromVersionManager(
/**
* Find Node.js on macOS
*/
function findNodeMacOS(homeDir: string): NodeFinderResult | null {
// Check Homebrew paths in order of preference
const homebrewPaths = [
// Apple Silicon
'/opt/homebrew/bin/node',
// Intel
'/usr/local/bin/node',
];
for (const nodePath of homebrewPaths) {
function findNodeMacOS(_homeDir: string): NodeFinderResult | null {
// Check system paths (Homebrew, system)
const systemPaths = getNodeSystemPaths();
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'homebrew' };
// Determine source based on path
if (nodePath.includes('homebrew') || nodePath === '/usr/local/bin/node') {
return { nodePath, source: 'homebrew' };
}
return { nodePath, source: 'system' };
}
}
// System Node
if (isExecutable('/usr/bin/node')) {
return { nodePath: '/usr/bin/node', source: 'system' };
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
const nvmPaths = getNvmPaths();
for (const nvmPath of nvmPaths) {
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
}
// fnm installation (multiple possible locations)
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
];
// fnm installation
const fnmPaths = getFnmPaths();
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath);
if (fnmNode) {
@@ -147,15 +147,9 @@ function findNodeMacOS(homeDir: string): NodeFinderResult | null {
/**
* Find Node.js on Linux
*/
function findNodeLinux(homeDir: string): NodeFinderResult | null {
// Common Linux paths
const systemPaths = [
'/usr/bin/node',
'/usr/local/bin/node',
// Snap installation
'/snap/bin/node',
];
function findNodeLinux(_homeDir: string): NodeFinderResult | null {
// Check system paths
const systemPaths = getNodeSystemPaths();
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'system' };
@@ -163,18 +157,16 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null {
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
const nvmPaths = getNvmPaths();
for (const nvmPath of nvmPaths) {
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
}
// fnm installation
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, '.fnm', 'node-versions'),
];
const fnmPaths = getFnmPaths();
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath);
if (fnmNode) {
@@ -188,40 +180,27 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null {
/**
* Find Node.js on Windows
*/
function findNodeWindows(homeDir: string): NodeFinderResult | null {
function findNodeWindows(_homeDir: string): NodeFinderResult | null {
// Program Files paths
const programFilesPaths = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'),
];
for (const nodePath of programFilesPaths) {
const systemPaths = getNodeSystemPaths();
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'program-files' };
}
}
// NVM for Windows
const nvmWindowsPath = path.join(
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
'nvm'
);
const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe');
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm-windows' };
const nvmPaths = getNvmPaths();
for (const nvmPath of nvmPaths) {
const nvmNode = findNodeFromVersionManager(nvmPath, 'node.exe');
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm-windows' };
}
}
// fnm on Windows (prioritize canonical installation path over shell shims)
const fnmWindowsPaths = [
path.join(homeDir, '.fnm', 'node-versions'),
path.join(
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
'fnm',
'node-versions'
),
];
for (const fnmBasePath of fnmWindowsPaths) {
// fnm on Windows
const fnmPaths = getFnmPaths();
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe');
if (fnmNode) {
return { nodePath: fnmNode, source: 'fnm' };
@@ -229,17 +208,13 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null {
}
// Scoop installation
const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
const scoopPath = getScoopNodePath();
if (isExecutable(scoopPath)) {
return { nodePath: scoopPath, source: 'scoop' };
}
// Chocolatey installation
const chocoPath = path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'node.exe'
);
const chocoPath = getChocolateyNodePath();
if (isExecutable(chocoPath)) {
return { nodePath: chocoPath, source: 'chocolatey' };
}

View File

@@ -11,7 +11,7 @@
*/
import fs from 'fs/promises';
import type { Dirent } from 'fs';
import fsSync, { type Dirent, type Stats } from 'fs';
import path from 'path';
import pLimit from 'p-limit';
import { validatePath } from './security.js';
@@ -165,17 +165,26 @@ export async function readFile(
}, `readFile(${filePath})`);
}
/**
* Options for writeFile
*/
export interface WriteFileOptions {
encoding?: BufferEncoding;
mode?: number;
flag?: string;
}
/**
* Wrapper around fs.writeFile that validates path first
*/
export async function writeFile(
filePath: string,
data: string | Buffer,
encoding?: BufferEncoding
optionsOrEncoding?: BufferEncoding | WriteFileOptions
): Promise<void> {
const validatedPath = validatePath(filePath);
return executeWithRetry(
() => fs.writeFile(validatedPath, data, encoding),
() => fs.writeFile(validatedPath, data, optionsOrEncoding),
`writeFile(${filePath})`
);
}
@@ -305,3 +314,316 @@ export function joinPath(...pathSegments: string[]): string {
export function resolvePath(...pathSegments: string[]): string {
return path.resolve(...pathSegments);
}
// =============================================================================
// Synchronous File System Methods
// =============================================================================
/**
* Options for writeFileSync
*/
export interface WriteFileSyncOptions {
encoding?: BufferEncoding;
mode?: number;
flag?: string;
}
/**
* Synchronous wrapper around fs.existsSync that validates path first
*/
export function existsSync(filePath: string): boolean {
const validatedPath = validatePath(filePath);
return fsSync.existsSync(validatedPath);
}
/**
* Synchronous wrapper around fs.readFileSync that validates path first
*/
export function readFileSync(filePath: string, encoding?: BufferEncoding): string | Buffer {
const validatedPath = validatePath(filePath);
if (encoding) {
return fsSync.readFileSync(validatedPath, encoding);
}
return fsSync.readFileSync(validatedPath);
}
/**
* Synchronous wrapper around fs.writeFileSync that validates path first
*/
export function writeFileSync(
filePath: string,
data: string | Buffer,
options?: WriteFileSyncOptions
): void {
const validatedPath = validatePath(filePath);
fsSync.writeFileSync(validatedPath, data, options);
}
/**
* Synchronous wrapper around fs.mkdirSync that validates path first
*/
export function mkdirSync(
dirPath: string,
options?: { recursive?: boolean; mode?: number }
): string | undefined {
const validatedPath = validatePath(dirPath);
return fsSync.mkdirSync(validatedPath, options);
}
/**
* Synchronous wrapper around fs.readdirSync that validates path first
*/
export function readdirSync(dirPath: string, options?: { withFileTypes?: false }): string[];
export function readdirSync(dirPath: string, options: { withFileTypes: true }): Dirent[];
export function readdirSync(
dirPath: string,
options?: { withFileTypes?: boolean }
): string[] | Dirent[] {
const validatedPath = validatePath(dirPath);
if (options?.withFileTypes === true) {
return fsSync.readdirSync(validatedPath, { withFileTypes: true });
}
return fsSync.readdirSync(validatedPath);
}
/**
* Synchronous wrapper around fs.statSync that validates path first
*/
export function statSync(filePath: string): Stats {
const validatedPath = validatePath(filePath);
return fsSync.statSync(validatedPath);
}
/**
* Synchronous wrapper around fs.accessSync that validates path first
*/
export function accessSync(filePath: string, mode?: number): void {
const validatedPath = validatePath(filePath);
fsSync.accessSync(validatedPath, mode);
}
/**
* Synchronous wrapper around fs.unlinkSync that validates path first
*/
export function unlinkSync(filePath: string): void {
const validatedPath = validatePath(filePath);
fsSync.unlinkSync(validatedPath);
}
/**
* Synchronous wrapper around fs.rmSync that validates path first
*/
export function rmSync(filePath: string, options?: { recursive?: boolean; force?: boolean }): void {
const validatedPath = validatePath(filePath);
fsSync.rmSync(validatedPath, options);
}
// =============================================================================
// Environment File Operations
// =============================================================================
/**
* Read and parse an .env file from a validated path
* Returns a record of key-value pairs
*/
export async function readEnvFile(envPath: string): Promise<Record<string, string>> {
const validatedPath = validatePath(envPath);
try {
const content = await executeWithRetry(
() => fs.readFile(validatedPath, 'utf-8'),
`readEnvFile(${envPath})`
);
return parseEnvContent(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
/**
* Read and parse an .env file synchronously from a validated path
*/
export function readEnvFileSync(envPath: string): Record<string, string> {
const validatedPath = validatePath(envPath);
try {
const content = fsSync.readFileSync(validatedPath, 'utf-8');
return parseEnvContent(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
/**
* Parse .env file content into a record
*/
function parseEnvContent(content: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const equalIndex = trimmed.indexOf('=');
if (equalIndex > 0) {
const key = trimmed.slice(0, equalIndex).trim();
const value = trimmed.slice(equalIndex + 1).trim();
result[key] = value;
}
}
return result;
}
/**
* Write or update a key-value pair in an .env file
* Preserves existing content and comments
*/
export async function writeEnvKey(envPath: string, key: string, value: string): Promise<void> {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = await executeWithRetry(
() => fs.readFile(validatedPath, 'utf-8'),
`readFile(${envPath})`
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// File doesn't exist, will create new one
}
const newContent = updateEnvContent(content, key, value);
await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`);
}
/**
* Write or update a key-value pair in an .env file (synchronous)
*/
export function writeEnvKeySync(envPath: string, key: string, value: string): void {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = fsSync.readFileSync(validatedPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// File doesn't exist, will create new one
}
const newContent = updateEnvContent(content, key, value);
fsSync.writeFileSync(validatedPath, newContent);
}
/**
* Remove a key from an .env file
*/
export async function removeEnvKey(envPath: string, key: string): Promise<void> {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = await executeWithRetry(
() => fs.readFile(validatedPath, 'utf-8'),
`readFile(${envPath})`
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; // File doesn't exist, nothing to remove
}
throw error;
}
const newContent = removeEnvKeyFromContent(content, key);
await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`);
}
/**
* Remove a key from an .env file (synchronous)
*/
export function removeEnvKeySync(envPath: string, key: string): void {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = fsSync.readFileSync(validatedPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; // File doesn't exist, nothing to remove
}
throw error;
}
const newContent = removeEnvKeyFromContent(content, key);
fsSync.writeFileSync(validatedPath, newContent);
}
/**
* Update .env content with a new key-value pair
*/
function updateEnvContent(content: string, key: string, value: string): string {
const lines = content.split('\n');
const keyPrefix = `${key}=`;
let found = false;
const newLines = lines.map((line) => {
if (line.trim().startsWith(keyPrefix)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
newLines.push(`${key}=${value}`);
} else {
// Replace last empty line or add to empty file
if (newLines.length === 0 || (newLines.length === 1 && newLines[0] === '')) {
newLines[0] = `${key}=${value}`;
} else {
newLines[newLines.length - 1] = `${key}=${value}`;
}
}
}
// Ensure file ends with newline
let result = newLines.join('\n');
if (!result.endsWith('\n')) {
result += '\n';
}
return result;
}
/**
* Remove a key from .env content
*/
function removeEnvKeyFromContent(content: string, key: string): string {
const lines = content.split('\n');
const keyPrefix = `${key}=`;
const newLines = lines.filter((line) => !line.trim().startsWith(keyPrefix));
// Remove trailing empty lines
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
newLines.pop();
}
// Ensure file ends with newline if there's content
let result = newLines.join('\n');
if (result.length > 0 && !result.endsWith('\n')) {
result += '\n';
}
return result;
}

View File

@@ -0,0 +1,814 @@
/**
* System Paths Configuration
*
* Centralized configuration for ALL system paths that automaker needs to access
* outside of the ALLOWED_ROOT_DIRECTORY. These are well-known system paths for
* tools like GitHub CLI, Claude CLI, Node.js version managers, etc.
*
* ALL file system access must go through this module or secureFs.
* Direct fs imports are NOT allowed anywhere else in the codebase.
*
* Categories of system paths:
* 1. CLI Tools: GitHub CLI, Claude CLI
* 2. Version Managers: NVM, fnm, Volta
* 3. Shells: /bin/zsh, /bin/bash, PowerShell
* 4. Electron userData: API keys, window bounds, app settings
* 5. Script directories: node_modules, logs (relative to script)
*/
import os from 'os';
import path from 'path';
import fsSync from 'fs';
import fs from 'fs/promises';
// =============================================================================
// System Tool Path Definitions
// =============================================================================
/**
* Get common paths where GitHub CLI might be installed
*/
export function getGitHubCliPaths(): string[] {
const isWindows = process.platform === 'win32';
if (isWindows) {
return [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
].filter(Boolean);
}
return [
'/opt/homebrew/bin/gh',
'/usr/local/bin/gh',
path.join(os.homedir(), '.local', 'bin', 'gh'),
'/home/linuxbrew/.linuxbrew/bin/gh',
];
}
/**
* Get common paths where Claude CLI might be installed
*/
export function getClaudeCliPaths(): string[] {
const isWindows = process.platform === 'win32';
if (isWindows) {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
path.join(appData, 'npm', 'claude.cmd'),
path.join(appData, 'npm', 'claude'),
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, '.npm-global', 'bin', 'claude'),
];
}
return [
path.join(os.homedir(), '.local', 'bin', 'claude'),
path.join(os.homedir(), '.claude', 'local', 'claude'),
'/usr/local/bin/claude',
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
];
}
/**
* Get the Claude configuration directory path
*/
export function getClaudeConfigDir(): string {
return path.join(os.homedir(), '.claude');
}
/**
* Get paths to Claude credential files
*/
export function getClaudeCredentialPaths(): string[] {
const claudeDir = getClaudeConfigDir();
return [path.join(claudeDir, '.credentials.json'), path.join(claudeDir, 'credentials.json')];
}
/**
* Get path to Claude settings file
*/
export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
}
/**
* Get path to Claude stats cache file
*/
export function getClaudeStatsCachePath(): string {
return path.join(getClaudeConfigDir(), 'stats-cache.json');
}
/**
* Get path to Claude projects/sessions directory
*/
export function getClaudeProjectsDir(): string {
return path.join(getClaudeConfigDir(), 'projects');
}
/**
* Get common shell paths for shell detection
* Includes both full paths and short names to match $SHELL or PATH entries
*/
export function getShellPaths(): string[] {
if (process.platform === 'win32') {
return [
// Full paths (most specific first)
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe',
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
// COMSPEC environment variable (typically cmd.exe)
process.env.COMSPEC || 'C:\\Windows\\System32\\cmd.exe',
// Short names (for PATH resolution)
'pwsh.exe',
'pwsh',
'powershell.exe',
'powershell',
'cmd.exe',
'cmd',
];
}
// POSIX (macOS, Linux)
return [
// Full paths
'/bin/zsh',
'/bin/bash',
'/bin/sh',
'/usr/bin/zsh',
'/usr/bin/bash',
'/usr/bin/sh',
'/usr/local/bin/zsh',
'/usr/local/bin/bash',
'/opt/homebrew/bin/zsh',
'/opt/homebrew/bin/bash',
// Short names (for PATH resolution or $SHELL matching)
'zsh',
'bash',
'sh',
];
}
// =============================================================================
// Node.js Version Manager Paths
// =============================================================================
/**
* Get NVM installation paths
*/
export function getNvmPaths(): string[] {
const homeDir = os.homedir();
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
return [path.join(appData, 'nvm')];
}
return [path.join(homeDir, '.nvm', 'versions', 'node')];
}
/**
* Get fnm installation paths
*/
export function getFnmPaths(): string[] {
const homeDir = os.homedir();
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
return [
path.join(homeDir, '.fnm', 'node-versions'),
path.join(localAppData, 'fnm', 'node-versions'),
];
}
if (process.platform === 'darwin') {
return [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
];
}
return [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, '.fnm', 'node-versions'),
];
}
/**
* Get common Node.js installation paths (not version managers)
*/
export function getNodeSystemPaths(): string[] {
if (process.platform === 'win32') {
return [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
path.join(
process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)',
'nodejs',
'node.exe'
),
];
}
if (process.platform === 'darwin') {
return ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];
}
// Linux
return ['/usr/bin/node', '/usr/local/bin/node', '/snap/bin/node'];
}
/**
* Get Scoop installation path for Node.js (Windows)
*/
export function getScoopNodePath(): string {
return path.join(os.homedir(), 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
}
/**
* Get Chocolatey installation path for Node.js (Windows)
*/
export function getChocolateyNodePath(): string {
return path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'node.exe'
);
}
/**
* Get WSL detection path
*/
export function getWslVersionPath(): string {
return '/proc/version';
}
/**
* Extended PATH environment for finding system tools
*/
export function getExtendedPath(): string {
const paths = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
];
return paths.filter(Boolean).join(process.platform === 'win32' ? ';' : ':');
}
// =============================================================================
// System Path Access Methods (Unconstrained - only for system tool detection)
// =============================================================================
/**
* Check if a file exists at a system path (synchronous)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
* Only use for checking system tool installation paths.
*/
export function systemPathExists(filePath: string): boolean {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fsSync.existsSync(filePath);
}
/**
* Check if a file is accessible at a system path (async)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
* Only use for checking system tool installation paths.
*/
export async function systemPathAccess(filePath: string): Promise<boolean> {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Check if a file has execute permission (synchronous)
* On Windows, only checks existence (X_OK is not meaningful)
*/
export function systemPathIsExecutable(filePath: string): boolean {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
try {
if (process.platform === 'win32') {
fsSync.accessSync(filePath, fsSync.constants.F_OK);
} else {
fsSync.accessSync(filePath, fsSync.constants.X_OK);
}
return true;
} catch {
return false;
}
}
/**
* Read a file from an allowed system path (async)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
* Only use for reading Claude config files and similar system configs.
*/
export async function systemPathReadFile(
filePath: string,
encoding: BufferEncoding = 'utf-8'
): Promise<string> {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fs.readFile(filePath, encoding);
}
/**
* Read a file from an allowed system path (synchronous)
*/
export function systemPathReadFileSync(
filePath: string,
encoding: BufferEncoding = 'utf-8'
): string {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fsSync.readFileSync(filePath, encoding);
}
/**
* Write a file to an allowed system path (synchronous)
*/
export function systemPathWriteFileSync(
filePath: string,
data: string,
options?: { encoding?: BufferEncoding; mode?: number }
): void {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
fsSync.writeFileSync(filePath, data, options);
}
/**
* Read directory contents from an allowed system path (async)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
*/
export async function systemPathReaddir(dirPath: string): Promise<string[]> {
if (!isAllowedSystemPath(dirPath)) {
throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`);
}
return fs.readdir(dirPath);
}
/**
* Read directory contents from an allowed system path (synchronous)
*/
export function systemPathReaddirSync(dirPath: string): string[] {
if (!isAllowedSystemPath(dirPath)) {
throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`);
}
return fsSync.readdirSync(dirPath);
}
/**
* Get file stats from a system path (synchronous)
*/
export function systemPathStatSync(filePath: string): fsSync.Stats {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fsSync.statSync(filePath);
}
/**
* Get file stats from a system path (async)
*/
export async function systemPathStat(filePath: string): Promise<fsSync.Stats> {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fs.stat(filePath);
}
// =============================================================================
// Path Validation
// =============================================================================
/**
* All paths that are allowed for system tool detection
*/
function getAllAllowedSystemPaths(): string[] {
return [
// GitHub CLI paths
...getGitHubCliPaths(),
// Claude CLI paths
...getClaudeCliPaths(),
// Claude config directory and files
getClaudeConfigDir(),
...getClaudeCredentialPaths(),
getClaudeSettingsPath(),
getClaudeStatsCachePath(),
getClaudeProjectsDir(),
// Shell paths
...getShellPaths(),
// Node.js system paths
...getNodeSystemPaths(),
getScoopNodePath(),
getChocolateyNodePath(),
// WSL detection
getWslVersionPath(),
];
}
/**
* Get all allowed directories (for recursive access)
*/
function getAllAllowedSystemDirs(): string[] {
return [
// Claude config
getClaudeConfigDir(),
getClaudeProjectsDir(),
// Version managers (need recursive access for version directories)
...getNvmPaths(),
...getFnmPaths(),
];
}
/**
* Check if a path is an allowed system path
* Paths must either be exactly in the allowed list, or be inside an allowed directory
*/
export function isAllowedSystemPath(filePath: string): boolean {
const normalizedPath = path.resolve(filePath);
const allowedPaths = getAllAllowedSystemPaths();
// Check for exact match
if (allowedPaths.includes(normalizedPath)) {
return true;
}
// Check if the path is inside an allowed directory
const allowedDirs = getAllAllowedSystemDirs();
for (const allowedDir of allowedDirs) {
const normalizedAllowedDir = path.resolve(allowedDir);
// Check if path is exactly the allowed dir or inside it
if (
normalizedPath === normalizedAllowedDir ||
normalizedPath.startsWith(normalizedAllowedDir + path.sep)
) {
return true;
}
}
return false;
}
// =============================================================================
// Electron userData Operations
// =============================================================================
// Store the Electron userData path (set by Electron main process)
let electronUserDataPath: string | null = null;
/**
* Set the Electron userData path (called from Electron main process)
*/
export function setElectronUserDataPath(userDataPath: string): void {
electronUserDataPath = userDataPath;
}
/**
* Get the Electron userData path
*/
export function getElectronUserDataPath(): string | null {
return electronUserDataPath;
}
/**
* Check if a path is within the Electron userData directory
*/
export function isElectronUserDataPath(filePath: string): boolean {
if (!electronUserDataPath) return false;
const normalizedPath = path.resolve(filePath);
const normalizedUserData = path.resolve(electronUserDataPath);
return (
normalizedPath === normalizedUserData ||
normalizedPath.startsWith(normalizedUserData + path.sep)
);
}
/**
* Read a file from Electron userData directory
*/
export function electronUserDataReadFileSync(
relativePath: string,
encoding: BufferEncoding = 'utf-8'
): string {
if (!electronUserDataPath) {
throw new Error('[SystemPaths] Electron userData path not initialized');
}
const fullPath = path.join(electronUserDataPath, relativePath);
return fsSync.readFileSync(fullPath, encoding);
}
/**
* Write a file to Electron userData directory
*/
export function electronUserDataWriteFileSync(
relativePath: string,
data: string,
options?: { encoding?: BufferEncoding; mode?: number }
): void {
if (!electronUserDataPath) {
throw new Error('[SystemPaths] Electron userData path not initialized');
}
const fullPath = path.join(electronUserDataPath, relativePath);
fsSync.writeFileSync(fullPath, data, options);
}
/**
* Check if a file exists in Electron userData directory
*/
export function electronUserDataExists(relativePath: string): boolean {
if (!electronUserDataPath) return false;
const fullPath = path.join(electronUserDataPath, relativePath);
return fsSync.existsSync(fullPath);
}
// =============================================================================
// Script Directory Operations (for init.mjs and similar)
// =============================================================================
// Store the script's base directory
let scriptBaseDir: string | null = null;
/**
* Set the script base directory
*/
export function setScriptBaseDir(baseDir: string): void {
scriptBaseDir = baseDir;
}
/**
* Get the script base directory
*/
export function getScriptBaseDir(): string | null {
return scriptBaseDir;
}
/**
* Check if a file exists relative to script base directory
*/
export function scriptDirExists(relativePath: string): boolean {
if (!scriptBaseDir) {
throw new Error('[SystemPaths] Script base directory not initialized');
}
const fullPath = path.join(scriptBaseDir, relativePath);
return fsSync.existsSync(fullPath);
}
/**
* Create a directory relative to script base directory
*/
export function scriptDirMkdirSync(relativePath: string, options?: { recursive?: boolean }): void {
if (!scriptBaseDir) {
throw new Error('[SystemPaths] Script base directory not initialized');
}
const fullPath = path.join(scriptBaseDir, relativePath);
fsSync.mkdirSync(fullPath, options);
}
/**
* Create a write stream for a file relative to script base directory
*/
export function scriptDirCreateWriteStream(relativePath: string): fsSync.WriteStream {
if (!scriptBaseDir) {
throw new Error('[SystemPaths] Script base directory not initialized');
}
const fullPath = path.join(scriptBaseDir, relativePath);
return fsSync.createWriteStream(fullPath);
}
// =============================================================================
// Electron App Bundle Operations (for accessing app's own files)
// =============================================================================
// Store the Electron app bundle paths (can have multiple allowed directories)
let electronAppDirs: string[] = [];
let electronResourcesPath: string | null = null;
/**
* Set the Electron app directories (called from Electron main process)
* In development mode, pass the project root to allow access to source files.
* In production mode, pass __dirname and process.resourcesPath.
*
* @param appDirOrDirs - Single directory or array of directories to allow
* @param resourcesPath - Optional resources path (for packaged apps)
*/
export function setElectronAppPaths(appDirOrDirs: string | string[], resourcesPath?: string): void {
electronAppDirs = Array.isArray(appDirOrDirs) ? appDirOrDirs : [appDirOrDirs];
electronResourcesPath = resourcesPath || null;
}
/**
* Check if a path is within the Electron app bundle (any of the allowed directories)
*/
function isElectronAppPath(filePath: string): boolean {
const normalizedPath = path.resolve(filePath);
// Check against all allowed app directories
for (const appDir of electronAppDirs) {
const normalizedAppDir = path.resolve(appDir);
if (
normalizedPath === normalizedAppDir ||
normalizedPath.startsWith(normalizedAppDir + path.sep)
) {
return true;
}
}
// Check against resources path (for packaged apps)
if (electronResourcesPath) {
const normalizedResources = path.resolve(electronResourcesPath);
if (
normalizedPath === normalizedResources ||
normalizedPath.startsWith(normalizedResources + path.sep)
) {
return true;
}
}
return false;
}
/**
* Check if a file exists within the Electron app bundle
*/
export function electronAppExists(filePath: string): boolean {
if (!isElectronAppPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`);
}
return fsSync.existsSync(filePath);
}
/**
* Read a file from the Electron app bundle
*/
export function electronAppReadFileSync(filePath: string): Buffer {
if (!isElectronAppPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`);
}
return fsSync.readFileSync(filePath);
}
/**
* Get file stats from the Electron app bundle
*/
export function electronAppStatSync(filePath: string): fsSync.Stats {
if (!isElectronAppPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`);
}
return fsSync.statSync(filePath);
}
/**
* Get file stats from the Electron app bundle (async with callback for compatibility)
*/
export function electronAppStat(
filePath: string,
callback: (err: NodeJS.ErrnoException | null, stats: fsSync.Stats | undefined) => void
): void {
if (!isElectronAppPath(filePath)) {
callback(
new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`),
undefined
);
return;
}
fsSync.stat(filePath, callback);
}
/**
* Read a file from the Electron app bundle (async with callback for compatibility)
*/
export function electronAppReadFile(
filePath: string,
callback: (err: NodeJS.ErrnoException | null, data: Buffer | undefined) => void
): void {
if (!isElectronAppPath(filePath)) {
callback(
new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`),
undefined
);
return;
}
fsSync.readFile(filePath, callback);
}
// =============================================================================
// High-level Tool Detection Methods
// =============================================================================
/**
* Find the first existing path from a list of system paths
*/
export async function findFirstExistingPath(paths: string[]): Promise<string | null> {
for (const p of paths) {
if (await systemPathAccess(p)) {
return p;
}
}
return null;
}
/**
* Check if GitHub CLI is installed and return its path
*/
export async function findGitHubCliPath(): Promise<string | null> {
return findFirstExistingPath(getGitHubCliPaths());
}
/**
* Check if Claude CLI is installed and return its path
*/
export async function findClaudeCliPath(): Promise<string | null> {
return findFirstExistingPath(getClaudeCliPaths());
}
/**
* Get Claude authentication status by checking various indicators
*/
export interface ClaudeAuthIndicators {
hasCredentialsFile: boolean;
hasSettingsFile: boolean;
hasStatsCacheWithActivity: boolean;
hasProjectsSessions: boolean;
credentials: {
hasOAuthToken: boolean;
hasApiKey: boolean;
} | null;
}
export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const result: ClaudeAuthIndicators = {
hasCredentialsFile: false,
hasSettingsFile: false,
hasStatsCacheWithActivity: false,
hasProjectsSessions: false,
credentials: null,
};
// Check settings file
try {
if (await systemPathAccess(getClaudeSettingsPath())) {
result.hasSettingsFile = true;
}
} catch {
// Ignore errors
}
// Check stats cache for recent activity
try {
const statsContent = await systemPathReadFile(getClaudeStatsCachePath());
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
result.hasStatsCacheWithActivity = true;
}
} catch {
// Ignore errors
}
// Check for sessions in projects directory
try {
const sessions = await systemPathReaddir(getClaudeProjectsDir());
if (sessions.length > 0) {
result.hasProjectsSessions = true;
}
} catch {
// Ignore errors
}
// Check credentials files
const credentialPaths = getClaudeCredentialPaths();
for (const credPath of credentialPaths) {
try {
const content = await systemPathReadFile(credPath);
const credentials = JSON.parse(content);
result.hasCredentialsFile = true;
result.credentials = {
hasOAuthToken: !!(credentials.oauth_token || credentials.access_token),
hasApiKey: !!credentials.api_key,
};
break;
} catch {
// Continue to next path
}
}
return result;
}

View File

@@ -18,12 +18,15 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "^1.0.0"
"@automaker/types": "1.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -16,6 +16,7 @@ import type {
ResolvedBacklogPlanPrompts,
ResolvedEnhancementPrompts,
} from '@automaker/types';
import { STATIC_PORT, SERVER_PORT } from '@automaker/types';
/**
* ========================================================================
@@ -208,6 +209,9 @@ This feature depends on: {{dependencies}}
**Verification:**
{{verificationInstructions}}
{{/if}}
**CRITICAL - Port Protection:**
NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session.
`;
export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation
@@ -299,6 +303,9 @@ You have access to several tools:
4. Ask questions when requirements are unclear
5. Guide users toward good software design principles
**CRITICAL - Port Protection:**
NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session.
Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`;
/**

View File

@@ -15,8 +15,11 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
"@types/node": "22.19.3",
"typescript": "5.9.3"
}
}

View File

@@ -7,6 +7,7 @@ export type ErrorType =
| 'abort'
| 'execution'
| 'rate_limit'
| 'quota_exhausted'
| 'unknown';
/**
@@ -19,6 +20,7 @@ export interface ErrorInfo {
isAuth: boolean;
isCancellation: boolean;
isRateLimit: boolean;
isQuotaExhausted: boolean; // Session/weekly usage limit reached
retryAfter?: number; // Seconds to wait before retrying (for rate limit errors)
originalError: unknown;
}

View File

@@ -161,3 +161,6 @@ export type {
PipelineStatus,
FeatureStatusWithPipeline,
} from './pipeline.js';
// Port configuration
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js';

15
libs/types/src/ports.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Centralized port configuration for AutoMaker
*
* These ports are reserved for the Automaker application and should never be
* killed or terminated by AI agents during feature implementation.
*/
/** Port for the static/UI server (Vite dev server) */
export const STATIC_PORT = 3007;
/** Port for the backend API server (Express + WebSocket) */
export const SERVER_PORT = 3008;
/** Array of all reserved Automaker ports */
export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const;

View File

@@ -435,7 +435,7 @@ export interface GlobalSettings {
// Claude Agent SDK Settings
/** Auto-load CLAUDE.md files using SDK's settingSources option */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash commands (default: true, disable if issues occur) */
/** Enable sandbox mode for bash commands (default: false, enable for additional security) */
enableSandboxMode?: boolean;
// MCP Server Configuration
@@ -571,6 +571,13 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
projectAnalysisModel: 'sonnet',
};
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 2;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
export const PROJECT_SETTINGS_VERSION = 1;
/** Default keyboard shortcut bindings */
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
board: 'K',
@@ -597,7 +604,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
/** Default global settings used when no settings file exists */
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
version: 1,
version: SETTINGS_VERSION,
theme: 'dark',
sidebarOpen: true,
chatHistoryOpen: false,
@@ -627,7 +634,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
worktreePanelCollapsed: false,
lastSelectedSessionByProject: {},
autoLoadClaudeMd: false,
enableSandboxMode: true,
enableSandboxMode: false,
mcpServers: [],
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
@@ -637,7 +644,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
/** Default credentials (empty strings - user must provide API keys) */
export const DEFAULT_CREDENTIALS: Credentials = {
version: 1,
version: CREDENTIALS_VERSION,
apiKeys: {
anthropic: '',
google: '',
@@ -647,12 +654,5 @@ export const DEFAULT_CREDENTIALS: Credentials = {
/** Default project settings (empty - all settings are optional and fall back to global) */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
version: 1,
version: PROJECT_SETTINGS_VERSION,
};
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 1;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
export const PROJECT_SETTINGS_VERSION = 1;

View File

@@ -17,13 +17,16 @@
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/platform": "^1.0.0",
"@automaker/types": "^1.0.0"
"@automaker/platform": "1.0.0",
"@automaker/types": "1.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -10,7 +10,7 @@
*/
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
/**
* Metadata structure for context files
@@ -38,6 +38,16 @@ export interface ContextFilesResult {
formattedPrompt: string;
}
/**
* File system module interface for context loading
* Compatible with secureFs from @automaker/platform
*/
export interface ContextFsModule {
access: (path: string) => Promise<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding?: BufferEncoding) => Promise<string | Buffer>;
}
/**
* Options for loading context files
*/
@@ -45,11 +55,7 @@ export interface LoadContextFilesOptions {
/** Project path to load context from */
projectPath: string;
/** Optional custom secure fs module (for dependency injection) */
fsModule?: {
access: (path: string) => Promise<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding: string) => Promise<string>;
};
fsModule?: ContextFsModule;
}
/**
@@ -64,12 +70,12 @@ function getContextDir(projectPath: string): string {
*/
async function loadContextMetadata(
contextDir: string,
fsModule: typeof fs
fsModule: ContextFsModule
): Promise<ContextMetadata> {
const metadataPath = path.join(contextDir, 'context-metadata.json');
try {
const content = await fsModule.readFile(metadataPath, 'utf-8');
return JSON.parse(content);
return JSON.parse(content as string);
} catch {
// Metadata file doesn't exist yet - that's fine
return { files: {} };
@@ -148,7 +154,7 @@ ${formattedFiles.join('\n\n---\n\n')}
export async function loadContextFiles(
options: LoadContextFilesOptions
): Promise<ContextFilesResult> {
const { projectPath, fsModule = fs } = options;
const { projectPath, fsModule = secureFs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
@@ -169,7 +175,7 @@ export async function loadContextFiles(
}
// Load metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
const metadata = await loadContextMetadata(contextDir, fsModule);
// Load each file with its content and metadata
const files: ContextFileInfo[] = [];
@@ -180,7 +186,7 @@ export async function loadContextFiles(
files.push({
name: fileName,
path: filePath,
content,
content: content as string,
description: metadata.files[fileName]?.description,
});
} catch (error) {
@@ -209,7 +215,7 @@ export async function loadContextFiles(
export async function getContextFilesSummary(
options: LoadContextFilesOptions
): Promise<Array<{ name: string; path: string; description?: string }>> {
const { projectPath, fsModule = fs } = options;
const { projectPath, fsModule = secureFs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
@@ -225,7 +231,7 @@ export async function getContextFilesSummary(
return [];
}
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
const metadata = await loadContextMetadata(contextDir, fsModule);
return textFiles.map((fileName) => ({
name: fileName,

View File

@@ -4,6 +4,7 @@
* Provides utilities for:
* - Detecting abort/cancellation errors
* - Detecting authentication errors
* - Detecting rate limit and quota exhaustion errors
* - Classifying errors by type
* - Generating user-friendly error messages
*/
@@ -52,7 +53,7 @@ export function isAuthenticationError(errorMessage: string): boolean {
}
/**
* Check if an error is a rate limit error
* Check if an error is a rate limit error (429 Too Many Requests)
*
* @param error - The error to check
* @returns True if the error is a rate limit error
@@ -62,6 +63,60 @@ export function isRateLimitError(error: unknown): boolean {
return message.includes('429') || message.includes('rate_limit');
}
/**
* Check if an error indicates quota/usage exhaustion
* This includes session limits, weekly limits, credit/billing issues, and overloaded errors
*
* @param error - The error to check
* @returns True if the error indicates quota exhaustion
*/
export function isQuotaExhaustedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
const lowerMessage = message.toLowerCase();
// Check for overloaded/capacity errors
if (
lowerMessage.includes('overloaded') ||
lowerMessage.includes('overloaded_error') ||
lowerMessage.includes('capacity')
) {
return true;
}
// Check for usage/quota limit patterns
if (
lowerMessage.includes('limit reached') ||
lowerMessage.includes('usage limit') ||
lowerMessage.includes('quota exceeded') ||
lowerMessage.includes('quota_exceeded') ||
lowerMessage.includes('session limit') ||
lowerMessage.includes('weekly limit') ||
lowerMessage.includes('monthly limit')
) {
return true;
}
// Check for billing/credit issues
if (
lowerMessage.includes('credit balance') ||
lowerMessage.includes('insufficient credits') ||
lowerMessage.includes('insufficient balance') ||
lowerMessage.includes('no credits') ||
lowerMessage.includes('out of credits') ||
lowerMessage.includes('billing') ||
lowerMessage.includes('payment required')
) {
return true;
}
// Check for upgrade prompts (often indicates limit reached)
if (lowerMessage.includes('/upgrade') || lowerMessage.includes('extra-usage')) {
return true;
}
return false;
}
/**
* Extract retry-after duration from rate limit error
*
@@ -98,11 +153,15 @@ export function classifyError(error: unknown): ErrorInfo {
const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message);
const isRateLimit = isRateLimitError(error);
const isQuotaExhausted = isQuotaExhaustedError(error);
const retryAfter = isRateLimit ? (extractRetryAfter(error) ?? 60) : undefined;
let type: ErrorType;
if (isAuth) {
type = 'authentication';
} else if (isQuotaExhausted) {
// Quota exhaustion takes priority over rate limit since it's more specific
type = 'quota_exhausted';
} else if (isRateLimit) {
type = 'rate_limit';
} else if (isAbort) {
@@ -122,6 +181,7 @@ export function classifyError(error: unknown): ErrorInfo {
isAuth,
isCancellation,
isRateLimit,
isQuotaExhausted,
retryAfter,
originalError: error,
};
@@ -144,6 +204,10 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
return 'Authentication failed. Please check your API key.';
}
if (info.isQuotaExhausted) {
return 'Usage limit reached. Auto Mode has been paused. Please wait for your quota to reset or upgrade your plan.';
}
if (info.isRateLimit) {
const retryMsg = info.retryAfter
? ` Please wait ${info.retryAfter} seconds before retrying.`

View File

@@ -9,6 +9,7 @@ export {
isCancellationError,
isAuthenticationError,
isRateLimitError,
isQuotaExhaustedError,
extractRetryAfter,
classifyError,
getUserFriendlyErrorMessage,

View File

@@ -4,6 +4,7 @@ import {
isCancellationError,
isAuthenticationError,
isRateLimitError,
isQuotaExhaustedError,
extractRetryAfter,
classifyError,
getUserFriendlyErrorMessage,
@@ -129,6 +130,55 @@ describe('error-handler.ts', () => {
});
});
describe('isQuotaExhaustedError', () => {
it('should return true for overloaded errors', () => {
expect(isQuotaExhaustedError(new Error('overloaded_error: service is busy'))).toBe(true);
expect(isQuotaExhaustedError(new Error('Server is overloaded'))).toBe(true);
expect(isQuotaExhaustedError(new Error('At capacity'))).toBe(true);
});
it('should return true for usage limit errors', () => {
expect(isQuotaExhaustedError(new Error('limit reached'))).toBe(true);
expect(isQuotaExhaustedError(new Error('Usage limit exceeded'))).toBe(true);
expect(isQuotaExhaustedError(new Error('quota exceeded'))).toBe(true);
expect(isQuotaExhaustedError(new Error('quota_exceeded'))).toBe(true);
expect(isQuotaExhaustedError(new Error('session limit reached'))).toBe(true);
expect(isQuotaExhaustedError(new Error('weekly limit hit'))).toBe(true);
expect(isQuotaExhaustedError(new Error('monthly limit reached'))).toBe(true);
});
it('should return true for billing/credit errors', () => {
expect(isQuotaExhaustedError(new Error('credit balance is too low'))).toBe(true);
expect(isQuotaExhaustedError(new Error('insufficient credits'))).toBe(true);
expect(isQuotaExhaustedError(new Error('insufficient balance'))).toBe(true);
expect(isQuotaExhaustedError(new Error('no credits remaining'))).toBe(true);
expect(isQuotaExhaustedError(new Error('out of credits'))).toBe(true);
expect(isQuotaExhaustedError(new Error('billing issue detected'))).toBe(true);
expect(isQuotaExhaustedError(new Error('payment required'))).toBe(true);
});
it('should return true for upgrade prompts', () => {
expect(isQuotaExhaustedError(new Error('Please /upgrade your plan'))).toBe(true);
expect(isQuotaExhaustedError(new Error('extra-usage not enabled'))).toBe(true);
});
it('should return false for regular errors', () => {
expect(isQuotaExhaustedError(new Error('Something went wrong'))).toBe(false);
expect(isQuotaExhaustedError(new Error('Network error'))).toBe(false);
expect(isQuotaExhaustedError(new Error(''))).toBe(false);
});
it('should return false for null/undefined', () => {
expect(isQuotaExhaustedError(null)).toBe(false);
expect(isQuotaExhaustedError(undefined)).toBe(false);
});
it('should handle string errors', () => {
expect(isQuotaExhaustedError('overloaded')).toBe(true);
expect(isQuotaExhaustedError('regular error')).toBe(false);
});
});
describe('extractRetryAfter', () => {
it('should extract retry-after from error message', () => {
const error = new Error('Rate limit exceeded. retry-after: 60');
@@ -170,10 +220,37 @@ describe('error-handler.ts', () => {
expect(result.isAbort).toBe(false);
expect(result.isCancellation).toBe(false);
expect(result.isRateLimit).toBe(false);
expect(result.isQuotaExhausted).toBe(false);
expect(result.message).toBe('Authentication failed');
expect(result.originalError).toBe(error);
});
it('should classify quota exhausted errors', () => {
const error = new Error('overloaded_error: service is busy');
const result = classifyError(error);
expect(result.type).toBe('quota_exhausted');
expect(result.isQuotaExhausted).toBe(true);
expect(result.isRateLimit).toBe(false);
expect(result.isAuth).toBe(false);
});
it('should classify credit balance errors as quota exhausted', () => {
const error = new Error('credit balance is too low');
const result = classifyError(error);
expect(result.type).toBe('quota_exhausted');
expect(result.isQuotaExhausted).toBe(true);
});
it('should classify usage limit errors as quota exhausted', () => {
const error = new Error('usage limit reached');
const result = classifyError(error);
expect(result.type).toBe('quota_exhausted');
expect(result.isQuotaExhausted).toBe(true);
});
it('should classify rate limit errors', () => {
const error = new Error('Error: 429 rate_limit_error');
const result = classifyError(error);
@@ -320,6 +397,14 @@ describe('error-handler.ts', () => {
expect(message).toBe('Authentication failed. Please check your API key.');
});
it('should return friendly message for quota exhausted errors', () => {
const error = new Error('overloaded_error');
const message = getUserFriendlyErrorMessage(error);
expect(message).toContain('Usage limit reached');
expect(message).toContain('Auto Mode has been paused');
});
it('should return friendly message for rate limit errors', () => {
const error = new Error('429 rate_limit_error');
const message = getUserFriendlyErrorMessage(error);