mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge branch 'main' into fix/windows-mcp-orphaned-processes
This commit is contained in:
@@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY=
|
||||
|
||||
# CORS origin - which domains can access the API
|
||||
# Use "*" for development, set specific origin for production
|
||||
CORS_ORIGIN=*
|
||||
CORS_ORIGIN=http://localhost:3007
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Server
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -21,35 +24,35 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/git-utils": "^1.0.0",
|
||||
"@automaker/model-resolver": "^1.0.0",
|
||||
"@automaker/platform": "^1.0.0",
|
||||
"@automaker/prompts": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"morgan": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
"@automaker/platform": "1.0.0",
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.25.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.3"
|
||||
"ws": "8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.16"
|
||||
"@types/cookie": "0.6.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/morgan": "1.9.10",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vitest/ui": "4.0.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,11 @@ app.use(
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
|
||||
if (
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:') ||
|
||||
origin.startsWith('http://[::1]:')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as secureFs from './secure-fs.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
@@ -41,8 +41,8 @@ setInterval(() => {
|
||||
*/
|
||||
function loadSessions(): void {
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) {
|
||||
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
|
||||
if (secureFs.existsSync(SESSIONS_FILE)) {
|
||||
const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
|
||||
const sessions = JSON.parse(data) as Array<
|
||||
[string, { createdAt: number; expiresAt: number }]
|
||||
>;
|
||||
@@ -74,9 +74,9 @@ function loadSessions(): void {
|
||||
*/
|
||||
async function saveSessions(): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
const sessions = Array.from(validSessions.entries());
|
||||
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||
await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
@@ -101,8 +101,8 @@ function ensureApiKey(): string {
|
||||
|
||||
// Try to read from file
|
||||
try {
|
||||
if (fs.existsSync(API_KEY_FILE)) {
|
||||
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
|
||||
if (secureFs.existsSync(API_KEY_FILE)) {
|
||||
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
|
||||
if (key) {
|
||||
console.log('[Auth] Loaded API key from file');
|
||||
return key;
|
||||
@@ -115,8 +115,8 @@ function ensureApiKey(): string {
|
||||
// Generate new key
|
||||
const newKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Auth] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save API key:', error);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
export const {
|
||||
// Async methods
|
||||
access,
|
||||
readFile,
|
||||
writeFile,
|
||||
@@ -20,6 +21,16 @@ export const {
|
||||
lstat,
|
||||
joinPath,
|
||||
resolvePath,
|
||||
// Sync methods
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
accessSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
// Throttling configuration and monitoring
|
||||
configureThrottling,
|
||||
getThrottlingConfig,
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function getEnableSandboxModeSetting(
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.enableSandboxMode ?? true;
|
||||
const result = globalSettings.enableSandboxMode ?? false;
|
||||
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
33
apps/server/src/lib/version.ts
Normal file
33
apps/server/src/lib/version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Version utility - Reads version from package.json
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the version from package.json
|
||||
* Caches the result for performance
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
const version = packageJson.version || '0.0.0';
|
||||
cachedVersion = version;
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read version from package.json:', error);
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,32 @@ import type {
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
'TERM',
|
||||
'USER',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables
|
||||
*/
|
||||
function buildEnv(): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'claude';
|
||||
@@ -57,6 +83,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
|
||||
@@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import * as fs from 'fs';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
@@ -57,13 +57,13 @@ function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unk
|
||||
*/
|
||||
function findActualFilePath(requestedPath: string): string | null {
|
||||
// First, try the exact path
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
if (secureFs.existsSync(requestedPath)) {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
// Try with Unicode normalization
|
||||
const normalizedPath = requestedPath.normalize('NFC');
|
||||
if (fs.existsSync(normalizedPath)) {
|
||||
if (secureFs.existsSync(normalizedPath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ function findActualFilePath(requestedPath: string): string | null {
|
||||
const dir = path.dirname(requestedPath);
|
||||
const baseName = path.basename(requestedPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
if (!secureFs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
const files = secureFs.readdirSync(dir);
|
||||
|
||||
// Normalize the requested basename for comparison
|
||||
// Replace various space-like characters with regular space for comparison
|
||||
@@ -281,9 +281,9 @@ export function createDescribeImageHandler(
|
||||
}
|
||||
|
||||
// Log path + stats (this is often where issues start: missing file, perms, size)
|
||||
let stat: fs.Stats | null = null;
|
||||
let stat: ReturnType<typeof secureFs.statSync> | null = null;
|
||||
try {
|
||||
stat = fs.statSync(actualPath);
|
||||
stat = secureFs.statSync(actualPath);
|
||||
logger.info(
|
||||
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createBrowseHandler() {
|
||||
@@ -40,9 +40,16 @@ export function createBrowseHandler() {
|
||||
return drives;
|
||||
};
|
||||
|
||||
// Get parent directory
|
||||
// Get parent directory - only if it's within the allowed root
|
||||
const parentPath = path.dirname(targetPath);
|
||||
const hasParent = parentPath !== targetPath;
|
||||
|
||||
// Determine if parent navigation should be allowed:
|
||||
// 1. Must have a different parent (not at filesystem root)
|
||||
// 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it
|
||||
const hasParent = parentPath !== targetPath && isPathAllowed(parentPath);
|
||||
|
||||
// Security: Don't expose parent path outside allowed root
|
||||
const safeParentPath = hasParent ? parentPath : null;
|
||||
|
||||
// Get available drives
|
||||
const drives = await detectDrives();
|
||||
@@ -70,7 +77,7 @@ export function createBrowseHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: targetPath,
|
||||
parentPath: hasParent ? parentPath : null,
|
||||
parentPath: safeParentPath,
|
||||
directories,
|
||||
drives,
|
||||
});
|
||||
@@ -84,7 +91,7 @@ export function createBrowseHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: targetPath,
|
||||
parentPath: hasParent ? parentPath : null,
|
||||
parentPath: safeParentPath,
|
||||
directories: [],
|
||||
drives,
|
||||
warning:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { isPathAllowed } from '@automaker/platform';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
@@ -20,6 +20,20 @@ export function createValidatePathHandler() {
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists
|
||||
if (!isPathAllowed(resolvedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
|
||||
: `Path not allowed: ${filePath}`;
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
isAllowed: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
const stats = await secureFs.stat(resolvedPath);
|
||||
@@ -32,7 +46,7 @@ export function createValidatePathHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
isAllowed: isPathAllowed(resolvedPath),
|
||||
isAllowed: true,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({ success: false, error: 'Path does not exist' });
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*
|
||||
* NOTE: Only the basic health check (/) is unauthenticated.
|
||||
* NOTE: Only the basic health check (/) and environment check are unauthenticated.
|
||||
* The /detailed endpoint requires authentication.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createIndexHandler } from './routes/index.js';
|
||||
import { createEnvironmentHandler } from './routes/environment.js';
|
||||
|
||||
/**
|
||||
* Create unauthenticated health routes (basic check only)
|
||||
@@ -18,6 +19,10 @@ export function createHealthRoutes(): Router {
|
||||
// Basic health check - no sensitive info
|
||||
router.get('/', createIndexHandler());
|
||||
|
||||
// Environment info including containerization status
|
||||
// This is unauthenticated so the UI can check on startup
|
||||
router.get('/environment', createEnvironmentHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getAuthStatus } from '../../../lib/auth.js';
|
||||
import { getVersion } from '../../../lib/version.js';
|
||||
|
||||
export function createDetailedHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
version: getVersion(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
dataDir: process.env.DATA_DIR || './data',
|
||||
|
||||
20
apps/server/src/routes/health/routes/environment.ts
Normal file
20
apps/server/src/routes/health/routes/environment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* GET /environment endpoint - Environment information including containerization status
|
||||
*
|
||||
* This endpoint is unauthenticated so the UI can check it on startup
|
||||
* before login to determine if sandbox risk warnings should be shown.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export interface EnvironmentResponse {
|
||||
isContainerized: boolean;
|
||||
}
|
||||
|
||||
export function createEnvironmentHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
isContainerized: process.env.IS_CONTAINERIZED === 'true',
|
||||
} satisfies EnvironmentResponse);
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getVersion } from '../../../lib/version.js';
|
||||
|
||||
export function createIndexHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
version: getVersion(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
@@ -35,36 +35,13 @@ export function getAllApiKeys(): Record<string, string> {
|
||||
|
||||
/**
|
||||
* Helper to persist API keys to .env file
|
||||
* Uses centralized secureFs.writeEnvKey for path validation
|
||||
*/
|
||||
export async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
try {
|
||||
let envContent = '';
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, 'utf-8');
|
||||
} catch {
|
||||
// .env file doesn't exist, we'll create it
|
||||
}
|
||||
|
||||
// Parse existing env content
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
let found = false;
|
||||
const newLines = lines.map((line) => {
|
||||
if (keyRegex.test(line)) {
|
||||
found = true;
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
// Add the key at the end
|
||||
newLines.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join('\n'));
|
||||
await secureFs.writeEnvKey(envPath, key, value);
|
||||
logger.info(`[Setup] Persisted ${key} to .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to persist ${key} to .env:`, error);
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
||||
import { getApiKey } from './common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -37,42 +35,25 @@ export async function getClaudeStatus() {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, try common locations based on platform
|
||||
const commonPaths = isWindows
|
||||
? (() => {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
return [
|
||||
// Windows-specific paths
|
||||
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'),
|
||||
];
|
||||
})()
|
||||
: [
|
||||
// Unix (Linux/macOS) paths
|
||||
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'),
|
||||
];
|
||||
// Not in PATH, try common locations from centralized system paths
|
||||
const commonPaths = getClaudeCliPaths();
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
cliPath = p;
|
||||
installed = true;
|
||||
method = 'local';
|
||||
if (await systemPathAccess(p)) {
|
||||
cliPath = p;
|
||||
installed = true;
|
||||
method = 'local';
|
||||
|
||||
// Get version from this path
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
// Get version from this path
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
@@ -82,7 +63,7 @@ export async function getClaudeStatus() {
|
||||
// Check authentication - detect all possible auth methods
|
||||
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
||||
// apiKeys.anthropic stores direct API keys for pay-per-use
|
||||
let auth = {
|
||||
const auth = {
|
||||
authenticated: false,
|
||||
method: 'none' as string,
|
||||
hasCredentialsFile: false,
|
||||
@@ -97,76 +78,36 @@ export async function getClaudeStatus() {
|
||||
hasRecentActivity: false,
|
||||
};
|
||||
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
// Use centralized system paths to check Claude authentication indicators
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
|
||||
// Check for recent Claude CLI activity - indicates working authentication
|
||||
// The stats-cache.json file is only populated when the CLI is working properly
|
||||
const statsCachePath = path.join(claudeDir, 'stats-cache.json');
|
||||
try {
|
||||
const statsContent = await fs.readFile(statsCachePath, 'utf-8');
|
||||
const stats = JSON.parse(statsContent);
|
||||
// Check for recent activity (indicates working authentication)
|
||||
if (indicators.hasStatsCacheWithActivity) {
|
||||
auth.hasRecentActivity = true;
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
|
||||
// Check if there's any activity (which means the CLI is authenticated and working)
|
||||
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
|
||||
auth.hasRecentActivity = true;
|
||||
auth.hasCliAuth = true;
|
||||
// Check for settings + sessions (indicates CLI is set up)
|
||||
if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) {
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
|
||||
// Check credentials file
|
||||
if (indicators.hasCredentialsFile && indicators.credentials) {
|
||||
auth.hasCredentialsFile = true;
|
||||
if (indicators.credentials.hasOAuthToken) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
} catch {
|
||||
// Stats file doesn't exist or is invalid
|
||||
}
|
||||
|
||||
// Check for settings.json - indicates CLI has been set up
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
try {
|
||||
await fs.access(settingsPath);
|
||||
// If settings exist but no activity, CLI might be set up but not authenticated
|
||||
if (!auth.hasCliAuth) {
|
||||
// Try to check for other indicators of auth
|
||||
const sessionsDir = path.join(claudeDir, 'projects');
|
||||
try {
|
||||
const sessions = await fs.readdir(sessionsDir);
|
||||
if (sessions.length > 0) {
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
} catch {
|
||||
// Sessions directory doesn't exist
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Settings file doesn't exist
|
||||
}
|
||||
|
||||
// Check for credentials file (OAuth tokens from claude login)
|
||||
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
|
||||
const credentialsPaths = [
|
||||
path.join(claudeDir, '.credentials.json'),
|
||||
path.join(claudeDir, 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credentialsPath of credentialsPaths) {
|
||||
try {
|
||||
const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
|
||||
const credentials = JSON.parse(credentialsContent);
|
||||
auth.hasCredentialsFile = true;
|
||||
|
||||
// Check what type of token is in credentials
|
||||
if (credentials.oauth_token || credentials.access_token) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'oauth_token'; // Stored OAuth token from credentials file
|
||||
} else if (credentials.api_key) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'api_key'; // Stored API key in credentials file
|
||||
}
|
||||
break; // Found and processed credentials file
|
||||
} catch {
|
||||
// No credentials file at this path or invalid format
|
||||
auth.method = 'oauth_token';
|
||||
} else if (indicators.credentials.hasApiKey) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'api_key';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,21 +115,21 @@ export async function getClaudeStatus() {
|
||||
if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
|
||||
auth.method = 'api_key_env';
|
||||
}
|
||||
|
||||
// In-memory stored OAuth token (from setup wizard - subscription auth)
|
||||
if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
|
||||
auth.authenticated = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
|
||||
auth.method = 'oauth_token';
|
||||
}
|
||||
|
||||
// In-memory stored API key (from settings UI - pay-per-use)
|
||||
if (!auth.authenticated && getApiKey('anthropic')) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = 'api_key'; // Manually stored API key
|
||||
auth.method = 'api_key';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,40 +5,22 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
|
||||
// In-memory storage reference (imported from common.ts pattern)
|
||||
// We need to modify common.ts to export a deleteApiKey function
|
||||
import { setApiKey } from '../common.js';
|
||||
|
||||
/**
|
||||
* Remove an API key from the .env file
|
||||
* Uses centralized secureFs.removeEnvKey for path validation
|
||||
*/
|
||||
async function removeApiKeyFromEnv(key: string): Promise<void> {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
try {
|
||||
let envContent = '';
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, 'utf-8');
|
||||
} catch {
|
||||
// .env file doesn't exist, nothing to delete
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse existing env content and remove the key
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
const newLines = lines.filter((line) => !keyRegex.test(line));
|
||||
|
||||
// Remove empty lines at the end
|
||||
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
|
||||
newLines.pop();
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : ''));
|
||||
await secureFs.removeEnvKey(envPath, key);
|
||||
logger.info(`[Setup] Removed ${key} from .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);
|
||||
|
||||
@@ -5,27 +5,14 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
PATH: getExtendedPath(),
|
||||
};
|
||||
|
||||
export interface GhStatus {
|
||||
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
|
||||
status.path = stdout.trim().split(/\r?\n/)[0];
|
||||
status.installed = true;
|
||||
} catch {
|
||||
// gh not in PATH, try common locations
|
||||
const commonPaths = isWindows
|
||||
? [
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
|
||||
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
|
||||
]
|
||||
: [
|
||||
'/opt/homebrew/bin/gh',
|
||||
'/usr/local/bin/gh',
|
||||
path.join(os.homedir(), '.local', 'bin', 'gh'),
|
||||
'/home/linuxbrew/.linuxbrew/bin/gh',
|
||||
];
|
||||
// gh not in PATH, try common locations from centralized system paths
|
||||
const commonPaths = getGitHubCliPaths();
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
if (await systemPathAccess(p)) {
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ export function createSessionsListHandler() {
|
||||
}
|
||||
|
||||
export function createSessionsCreateHandler() {
|
||||
return (req: Request, res: Response): void => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminalService = getTerminalService();
|
||||
const { cwd, cols, rows, shell } = req.body;
|
||||
|
||||
const session = terminalService.createSession({
|
||||
const session = await terminalService.createSession({
|
||||
cwd,
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
|
||||
@@ -158,8 +158,13 @@ export const logError = createLogError(logger);
|
||||
/**
|
||||
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
|
||||
* Returns true if an empty commit was created, false if the repo already had commits.
|
||||
* @param repoPath - Path to the git repository
|
||||
* @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL)
|
||||
*/
|
||||
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
export async function ensureInitialCommit(
|
||||
repoPath: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
||||
return false;
|
||||
@@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, {
|
||||
cwd: repoPath,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`);
|
||||
return true;
|
||||
|
||||
@@ -100,7 +100,14 @@ export function createCreateHandler() {
|
||||
}
|
||||
|
||||
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
|
||||
await ensureInitialCommit(projectPath);
|
||||
// Pass git identity env vars so commits work without global git config
|
||||
const gitEnv = {
|
||||
GIT_AUTHOR_NAME: 'Automaker',
|
||||
GIT_AUTHOR_EMAIL: 'automaker@localhost',
|
||||
GIT_COMMITTER_NAME: 'Automaker',
|
||||
GIT_COMMITTER_EMAIL: 'automaker@localhost',
|
||||
};
|
||||
await ensureInitialCommit(projectPath, gitEnv);
|
||||
|
||||
// First, check if git already has a worktree for this branch (anywhere)
|
||||
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
|
||||
|
||||
@@ -190,6 +190,10 @@ interface AutoModeConfig {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
// Constants for consecutive failure tracking
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||
|
||||
export class AutoModeService {
|
||||
private events: EventEmitter;
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
@@ -200,12 +204,89 @@ export class AutoModeService {
|
||||
private config: AutoModeConfig | null = null;
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private settingsService: SettingsService | null = null;
|
||||
// Track consecutive failures to detect quota/API issues
|
||||
private consecutiveFailures: { timestamp: number; error: string }[] = [];
|
||||
private pausedDueToFailures = false;
|
||||
|
||||
constructor(events: EventEmitter, settingsService?: SettingsService) {
|
||||
this.events = events;
|
||||
this.settingsService = settingsService ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
*/
|
||||
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Add this failure
|
||||
this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
||||
|
||||
// Remove old failures outside the window
|
||||
this.consecutiveFailures = this.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the threshold
|
||||
if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
||||
return true; // Should pause
|
||||
}
|
||||
|
||||
// Also immediately pause for known quota/rate limit errors
|
||||
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion.
|
||||
* This will pause the auto loop to prevent repeated failures.
|
||||
*/
|
||||
private signalShouldPause(errorInfo: { type: string; message: string }): void {
|
||||
if (this.pausedDueToFailures) {
|
||||
return; // Already paused
|
||||
}
|
||||
|
||||
this.pausedDueToFailures = true;
|
||||
const failureCount = this.consecutiveFailures.length;
|
||||
console.log(
|
||||
`[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
||||
errorType: errorInfo.type,
|
||||
originalError: errorInfo.message,
|
||||
failureCount,
|
||||
projectPath: this.config?.projectPath,
|
||||
});
|
||||
|
||||
// Stop the auto loop
|
||||
this.stopAutoLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode)
|
||||
*/
|
||||
private resetFailureTracking(): void {
|
||||
this.consecutiveFailures = [];
|
||||
this.pausedDueToFailures = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count
|
||||
*/
|
||||
private recordSuccess(): void {
|
||||
this.consecutiveFailures = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop - continuously picks and executes pending features
|
||||
*/
|
||||
@@ -214,6 +295,9 @@ export class AutoModeService {
|
||||
throw new Error('Auto mode is already running');
|
||||
}
|
||||
|
||||
// Reset failure tracking when user manually starts auto mode
|
||||
this.resetFailureTracking();
|
||||
|
||||
this.autoLoopRunning = true;
|
||||
this.autoLoopAbortController = new AbortController();
|
||||
this.config = {
|
||||
@@ -502,6 +586,9 @@ export class AutoModeService {
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -529,6 +616,21 @@ export class AutoModeService {
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Track this failure and check if we should pause auto mode
|
||||
// This handles both specific quota/rate limit errors AND generic failures
|
||||
// that may indicate quota exhaustion (SDK doesn't always return useful errors)
|
||||
const shouldPause = this.trackFailureAndCheckPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
if (shouldPause) {
|
||||
this.signalShouldPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
|
||||
@@ -689,6 +791,11 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
this.cancelPlanApproval(featureId);
|
||||
|
||||
running.abortController.abort();
|
||||
|
||||
// Remove from running features immediately to allow resume
|
||||
// The abort signal will still propagate to stop any ongoing execution
|
||||
this.runningFeatures.delete(featureId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -926,6 +1033,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -941,6 +1051,19 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Track this failure and check if we should pause auto mode
|
||||
const shouldPause = this.trackFailureAndCheckPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
if (shouldPause) {
|
||||
this.signalShouldPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
@@ -1940,7 +2063,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
};
|
||||
|
||||
// Execute via provider
|
||||
console.log(`[AutoMode] Starting stream for feature ${featureId}...`);
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
console.log(`[AutoMode] Stream created, starting to iterate...`);
|
||||
// Initialize with previous content if this is a follow-up, with a separator
|
||||
let responseText = previousContent
|
||||
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
||||
@@ -1978,6 +2103,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
};
|
||||
|
||||
streamLoop: for await (const msg of stream) {
|
||||
console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || '');
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
@@ -2433,6 +2559,9 @@ Implement all the changes described in the plan above.`;
|
||||
|
||||
// Only emit progress for non-marker text (marker was already handled above)
|
||||
if (!specDetected) {
|
||||
console.log(
|
||||
`[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}`
|
||||
);
|
||||
this.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
content: block.text,
|
||||
|
||||
@@ -185,9 +185,8 @@ export class FeatureLoader {
|
||||
})) as any[];
|
||||
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
// Load each feature
|
||||
const features: Feature[] = [];
|
||||
for (const dir of featureDirs) {
|
||||
// Load all features concurrently (secureFs has built-in concurrency limiting)
|
||||
const featurePromises = featureDirs.map(async (dir) => {
|
||||
const featureId = dir.name;
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
|
||||
@@ -199,13 +198,13 @@ export class FeatureLoader {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
||||
);
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
features.push(feature);
|
||||
return feature as Feature;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
continue;
|
||||
return null;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
||||
@@ -216,8 +215,12 @@ export class FeatureLoader {
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(featurePromises);
|
||||
const features = results.filter((f): f is Feature => f !== null);
|
||||
|
||||
// Sort by creation order (feature IDs contain timestamp)
|
||||
features.sort((a, b) => {
|
||||
|
||||
@@ -124,6 +124,8 @@ export class SettingsService {
|
||||
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
|
||||
* compatibility during schema migrations.
|
||||
*
|
||||
* Also applies version-based migrations for breaking changes.
|
||||
*
|
||||
* @returns Promise resolving to complete GlobalSettings object
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
@@ -131,7 +133,7 @@ export class SettingsService {
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
@@ -139,6 +141,32 @@ export class SettingsService {
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
};
|
||||
|
||||
// Version-based migrations
|
||||
const storedVersion = settings.version || 1;
|
||||
let needsSave = false;
|
||||
|
||||
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
|
||||
// Sandbox mode can cause issues on some systems, so we're disabling it by default
|
||||
if (storedVersion < 2) {
|
||||
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
|
||||
result.enableSandboxMode = false;
|
||||
result.version = SETTINGS_VERSION;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Save migrated settings if needed
|
||||
if (needsSave) {
|
||||
try {
|
||||
await ensureDataDir(this.dataDir);
|
||||
await atomicWriteJson(settingsPath, result);
|
||||
logger.info('Settings migration complete');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save migrated settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
import * as pty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// secureFs is used for user-controllable paths (working directory validation)
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
|
||||
import {
|
||||
systemPathExists,
|
||||
systemPathReadFileSync,
|
||||
getWslVersionPath,
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
@@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
* Uses getShellPaths() to iterate through allowed shell paths
|
||||
*/
|
||||
detectShell(): { shell: string; args: string[] } {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Check if running in WSL
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
// In WSL, prefer the user's configured shell or bash
|
||||
const userShell = process.env.SHELL || '/bin/bash';
|
||||
if (fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to first available POSIX shell
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
}
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'win32': {
|
||||
// Windows: prefer PowerShell, fall back to cmd
|
||||
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
|
||||
if (fs.existsSync(pwshCore)) {
|
||||
return { shell: pwshCore, args: [] };
|
||||
// For all platforms: first try user's shell if set
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(pwsh)) {
|
||||
return { shell: pwsh, args: [] };
|
||||
}
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
|
||||
case 'darwin': {
|
||||
// macOS: prefer user's shell, then zsh, then bash
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/zsh')) {
|
||||
return { shell: '/bin/zsh', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
case 'linux':
|
||||
default: {
|
||||
// Linux: prefer user's shell, then bash, then sh
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/bash')) {
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through allowed shell paths and return first existing one
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallbacks based on platform
|
||||
if (platform === 'win32') {
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,8 +168,9 @@ export class TerminalService extends EventEmitter {
|
||||
isWSL(): boolean {
|
||||
try {
|
||||
// Check /proc/version for Microsoft/WSL indicators
|
||||
if (fs.existsSync('/proc/version')) {
|
||||
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
||||
const wslVersionPath = getWslVersionPath();
|
||||
if (systemPathExists(wslVersionPath)) {
|
||||
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
|
||||
return version.includes('microsoft') || version.includes('wsl');
|
||||
}
|
||||
// Check for WSL environment variable
|
||||
@@ -157,8 +204,9 @@ export class TerminalService extends EventEmitter {
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
* Includes basic sanitization against null bytes and path normalization
|
||||
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
|
||||
*/
|
||||
private resolveWorkingDirectory(requestedCwd?: string): string {
|
||||
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// If no cwd requested, use home
|
||||
@@ -187,15 +235,19 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
// This prevents terminals from being opened in directories outside the allowed workspace
|
||||
try {
|
||||
const stat = fs.statSync(cwd);
|
||||
if (stat.isDirectory()) {
|
||||
const statResult = await secureFs.stat(cwd);
|
||||
if (statResult.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||
console.warn(
|
||||
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
|
||||
);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -228,7 +280,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Create a new terminal session
|
||||
* Returns null if the maximum session limit has been reached
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
@@ -241,12 +293,23 @@ export class TerminalService extends EventEmitter {
|
||||
const shell = options.shell || detectedShell;
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
const cwd = await this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with some useful defaults
|
||||
// These settings ensure consistent terminal behavior across platforms
|
||||
// First, create a clean copy of process.env excluding Automaker-specific variables
|
||||
// that could pollute user shells (e.g., PORT would affect Next.js/other dev servers)
|
||||
const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH'];
|
||||
const cleanEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !automakerEnvVars.includes(key)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'automaker-terminal',
|
||||
|
||||
@@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync('git init', { cwd: tmpDir });
|
||||
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||
|
||||
// Use environment variables instead of git config to avoid affecting user's git config
|
||||
// These env vars override git config without modifying it
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test User',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Test User',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
};
|
||||
|
||||
// Create initial commit
|
||||
await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n');
|
||||
await execAsync('git add .', { cwd: tmpDir });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync('git branch -M main', { cwd: tmpDir });
|
||||
|
||||
@@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => {
|
||||
async function initRepoWithoutCommit() {
|
||||
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
|
||||
await execAsync('git init', { cwd: repoPath });
|
||||
await execAsync('git config user.email "test@example.com"', {
|
||||
cwd: repoPath,
|
||||
});
|
||||
await execAsync('git config user.name "Test User"', { cwd: repoPath });
|
||||
// Don't set git config - use environment variables in commit operations instead
|
||||
// to avoid affecting user's git config
|
||||
// Intentionally skip creating an initial commit
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TerminalService, getTerminalService } from '@/services/terminal-service.js';
|
||||
import * as pty from 'node-pty';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as platform from '@automaker/platform';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
|
||||
vi.mock('node-pty');
|
||||
vi.mock('fs');
|
||||
vi.mock('os');
|
||||
vi.mock('@automaker/platform', async () => {
|
||||
const actual = await vi.importActual('@automaker/platform');
|
||||
return {
|
||||
...actual,
|
||||
systemPathExists: vi.fn(),
|
||||
systemPathReadFileSync: vi.fn(),
|
||||
getWslVersionPath: vi.fn(),
|
||||
getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing
|
||||
isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
|
||||
describe('terminal-service.ts', () => {
|
||||
let service: TerminalService;
|
||||
let mockPtyProcess: any;
|
||||
|
||||
// Shell paths for each platform (matching system-paths.ts)
|
||||
const linuxShellPaths = [
|
||||
'/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',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sh',
|
||||
];
|
||||
|
||||
const windowsShellPaths = [
|
||||
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe',
|
||||
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'C:\\Windows\\System32\\cmd.exe',
|
||||
'pwsh.exe',
|
||||
'pwsh',
|
||||
'powershell.exe',
|
||||
'powershell',
|
||||
'cmd.exe',
|
||||
'cmd',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TerminalService();
|
||||
@@ -29,6 +71,13 @@ describe('terminal-service.ts', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
|
||||
// Default mocks for system paths and secureFs
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('');
|
||||
vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version');
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,7 +87,8 @@ describe('terminal-service.ts', () => {
|
||||
describe('detectShell', () => {
|
||||
it('should detect PowerShell Core on Windows when available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
});
|
||||
|
||||
@@ -50,7 +100,8 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
it('should fall back to PowerShell on Windows if Core not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
});
|
||||
|
||||
@@ -62,7 +113,8 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
it('should fall back to cmd.exe on Windows if no PowerShell', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -73,7 +125,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect user shell on macOS', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -84,7 +136,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to zsh on macOS if user shell not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/zsh';
|
||||
});
|
||||
|
||||
@@ -97,7 +149,10 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to bash on macOS if zsh not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
// zsh not available, but bash is
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/bash';
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -108,7 +163,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect user shell on Linux', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -119,7 +174,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to bash on Linux if user shell not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/bash';
|
||||
});
|
||||
|
||||
@@ -132,7 +187,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to sh on Linux if bash not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -143,8 +198,10 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect WSL and use appropriate shell', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
|
||||
'Linux version 5.10.0-microsoft-standard-WSL2'
|
||||
);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -155,43 +212,45 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
describe('isWSL', () => {
|
||||
it('should return true if /proc/version contains microsoft', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
|
||||
'Linux version 5.10.0-microsoft-standard-WSL2'
|
||||
);
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if /proc/version contains wsl', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if WSL_DISTRO_NAME is set', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if WSLENV is set', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if not in WSL', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
|
||||
expect(service.isWSL()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if error reading /proc/version', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
@@ -203,7 +262,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should return platform information', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const info = service.getPlatformInfo();
|
||||
@@ -216,20 +275,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new terminal session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should create a new terminal session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/test/dir',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
expect(session.id).toMatch(/^term-/);
|
||||
expect(session.cwd).toBe('/test/dir');
|
||||
expect(session.shell).toBe('/bin/bash');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toMatch(/^term-/);
|
||||
expect(session!.cwd).toBe('/test/dir');
|
||||
expect(session!.shell).toBe('/bin/bash');
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
'/bin/bash',
|
||||
['--login'],
|
||||
@@ -241,12 +301,12 @@ describe('terminal-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default cols and rows if not provided', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should use default cols and rows if not provided', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -258,66 +318,68 @@ describe('terminal-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to home directory if cwd does not exist', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
it('should fall back to home directory if cwd does not exist', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/nonexistent',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/home/user');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should fall back to home directory if cwd is not a directory', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
it('should fall back to home directory if cwd is not a directory', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/file.txt',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/home/user');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should fix double slashes in path', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should fix double slashes in path', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '//test/dir',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/test/dir');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/test/dir');
|
||||
});
|
||||
|
||||
it('should preserve WSL UNC paths', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should preserve WSL UNC paths', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '//wsl$/Ubuntu/home',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('//wsl$/Ubuntu/home');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('//wsl$/Ubuntu/home');
|
||||
});
|
||||
|
||||
it('should handle data events from PTY', () => {
|
||||
it('should handle data events from PTY', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const dataCallback = vi.fn();
|
||||
service.onData(dataCallback);
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
// Simulate data event
|
||||
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
|
||||
@@ -331,33 +393,34 @@ describe('terminal-service.ts', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle exit events from PTY', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle exit events from PTY', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const exitCallback = vi.fn();
|
||||
service.onExit(exitCallback);
|
||||
|
||||
const session = service.createSession();
|
||||
const session = await service.createSession();
|
||||
|
||||
// Simulate exit event
|
||||
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
|
||||
onExitHandler({ exitCode: 0 });
|
||||
|
||||
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
expect(session).not.toBeNull();
|
||||
expect(exitCallback).toHaveBeenCalledWith(session!.id, 0);
|
||||
expect(service.getSession(session!.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
it('should write data to existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should write data to existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.write(session.id, 'ls\n');
|
||||
const session = await service.createSession();
|
||||
const result = service.write(session!.id, 'ls\n');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
|
||||
@@ -372,13 +435,13 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
it('should resize existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should resize existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
const session = await service.createSession();
|
||||
const result = service.resize(session!.id, 120, 40);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
||||
@@ -391,30 +454,30 @@ describe('terminal-service.ts', () => {
|
||||
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle resize errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle resize errors', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.resize.mockImplementation(() => {
|
||||
throw new Error('Resize failed');
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
const session = await service.createSession();
|
||||
const result = service.resize(session!.id, 120, 40);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession', () => {
|
||||
it('should kill existing session', () => {
|
||||
it('should kill existing session', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const result = service.killSession(session!.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
@@ -423,7 +486,7 @@ describe('terminal-service.ts', () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
expect(service.getSession(session!.id)).toBeUndefined();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -434,29 +497,29 @@ describe('terminal-service.ts', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle kill errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle kill errors', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const result = service.killSession(session!.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const retrieved = service.getSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const retrieved = service.getSession(session!.id);
|
||||
|
||||
expect(retrieved).toBe(session);
|
||||
});
|
||||
@@ -469,15 +532,15 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getScrollback', () => {
|
||||
it('should return scrollback buffer for existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return scrollback buffer for existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
session.scrollbackBuffer = 'test scrollback';
|
||||
const session = await service.createSession();
|
||||
session!.scrollbackBuffer = 'test scrollback';
|
||||
|
||||
const scrollback = service.getScrollback(session.id);
|
||||
const scrollback = service.getScrollback(session!.id);
|
||||
|
||||
expect(scrollback).toBe('test scrollback');
|
||||
});
|
||||
@@ -490,19 +553,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAllSessions', () => {
|
||||
it('should return all active sessions', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return all active sessions', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session1 = service.createSession({ cwd: '/dir1' });
|
||||
const session2 = service.createSession({ cwd: '/dir2' });
|
||||
const session1 = await service.createSession({ cwd: '/dir1' });
|
||||
const session2 = await service.createSession({ cwd: '/dir2' });
|
||||
|
||||
const sessions = service.getAllSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].id).toBe(session1.id);
|
||||
expect(sessions[1].id).toBe(session2.id);
|
||||
expect(session1).not.toBeNull();
|
||||
expect(session2).not.toBeNull();
|
||||
expect(sessions[0].id).toBe(session1!.id);
|
||||
expect(sessions[1].id).toBe(session2!.id);
|
||||
expect(sessions[0].cwd).toBe('/dir1');
|
||||
expect(sessions[1].cwd).toBe('/dir2');
|
||||
});
|
||||
@@ -535,30 +600,32 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up all sessions', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should clean up all sessions', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session1 = service.createSession();
|
||||
const session2 = service.createSession();
|
||||
const session1 = await service.createSession();
|
||||
const session2 = await service.createSession();
|
||||
|
||||
service.cleanup();
|
||||
|
||||
expect(service.getSession(session1.id)).toBeUndefined();
|
||||
expect(service.getSession(session2.id)).toBeUndefined();
|
||||
expect(session1).not.toBeNull();
|
||||
expect(session2).not.toBeNull();
|
||||
expect(service.getSession(session1!.id)).toBeUndefined();
|
||||
expect(service.getSession(session2!.id)).toBeUndefined();
|
||||
expect(service.getAllSessions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle cleanup errors gracefully', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle cleanup errors gracefully', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
expect(() => service.cleanup()).not.toThrow();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user