mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
8
libs/platform/src/config/ports.ts
Normal file
8
libs/platform/src/config/ports.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
814
libs/platform/src/system-paths.ts
Normal file
814
libs/platform/src/system-paths.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
15
libs/types/src/ports.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
isCancellationError,
|
||||
isAuthenticationError,
|
||||
isRateLimitError,
|
||||
isQuotaExhaustedError,
|
||||
extractRetryAfter,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user