mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +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();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
@@ -10,6 +10,9 @@
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -35,87 +38,87 @@
|
||||
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.6",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-router": "1.141.6",
|
||||
"@uiw/react-codemirror": "4.25.4",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/addon-search": "0.15.0",
|
||||
"@xterm/addon-web-links": "0.11.0",
|
||||
"@xterm/addon-webgl": "0.18.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"@xyflow/react": "12.10.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"dagre": "0.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"geist": "1.5.1",
|
||||
"lucide-react": "0.562.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zustand": "^5.0.9"
|
||||
"react-markdown": "10.1.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"rehype-raw": "7.0.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "^1.29.2",
|
||||
"lightningcss-darwin-x64": "^1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "^1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "^1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-x64-musl": "^1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "^1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "^1.29.2"
|
||||
"lightningcss-darwin-arm64": "1.29.2",
|
||||
"lightningcss-darwin-x64": "1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "1.29.2",
|
||||
"lightningcss-linux-x64-musl": "1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "1.29.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/router-plugin": "^1.141.7",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.50.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"@electron/rebuild": "4.0.2",
|
||||
"@eslint/js": "9.0.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@tanstack/router-plugin": "1.141.7",
|
||||
"@types/dagre": "0.7.53",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
||||
"@typescript-eslint/parser": "8.50.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"cross-env": "10.1.0",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"electron-builder": "26.0.12",
|
||||
"eslint": "9.39.2",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
"vite": "7.3.0",
|
||||
"vite-plugin-electron": "0.29.0",
|
||||
"vite-plugin-electron-renderer": "0.14.6"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.automaker.app",
|
||||
|
||||
@@ -49,6 +49,8 @@ export default defineConfig({
|
||||
// Hide the API key banner to reduce log noise
|
||||
AUTOMAKER_HIDE_API_KEY: 'true',
|
||||
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
|
||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true',
|
||||
},
|
||||
},
|
||||
// Frontend Vite dev server
|
||||
|
||||
38
apps/ui/public/readme_logo.svg
Normal file
38
apps/ui/public/readme_logo.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1456" height="330" viewBox="0 0 1456 330" role="img" aria-label="Automaker Logo">
|
||||
<defs>
|
||||
<!-- Brand Gradient -->
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6B5BFF" />
|
||||
<stop offset="100%" stop-color="#2EC7FF" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Shadow filter -->
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Optional subtle background to ensure contrast on all GitHub themes -->
|
||||
<rect width="1456" height="330" rx="20" fill="#09090b" />
|
||||
|
||||
<!-- Logo Icon (Left Side) -->
|
||||
<g transform="translate(80, 65)">
|
||||
<!-- Rounded square background -->
|
||||
<rect width="200" height="200" rx="50" fill="url(#bg)" />
|
||||
|
||||
<!-- Icon paths -->
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)" transform="translate(100, 100)">
|
||||
<!-- Left bracket < -->
|
||||
<path d="M-30 -30 L-55 0 L-30 30" />
|
||||
<!-- Slash / -->
|
||||
<path d="M15 -45 L-15 45" />
|
||||
<!-- Right bracket > -->
|
||||
<path d="M30 -30 L55 0 L30 30" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Text Section -->
|
||||
<text x="320" y="215" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="160" font-weight="800" letter-spacing="-4" fill="#FFFFFF">
|
||||
automaker<tspan fill="#6B5BFF">.</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
92
apps/ui/scripts/bump-version.mjs
Executable file
92
apps/ui/scripts/bump-version.mjs
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bumps the version in apps/ui/package.json and apps/server/package.json
|
||||
* Usage: node scripts/bump-version.mjs [major|minor|patch]
|
||||
* Example: node scripts/bump-version.mjs patch
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const bumpType = process.argv[2]?.toLowerCase();
|
||||
|
||||
if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) {
|
||||
console.error('Error: Bump type argument is required');
|
||||
console.error('Usage: node scripts/bump-version.mjs [major|minor|patch]');
|
||||
console.error('Example: node scripts/bump-version.mjs patch');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const uiPackageJsonPath = join(__dirname, '..', 'package.json');
|
||||
const serverPackageJsonPath = join(__dirname, '..', '..', 'server', 'package.json');
|
||||
|
||||
function bumpVersion(packageJsonPath, packageName) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const oldVersion = packageJson.version;
|
||||
|
||||
// Parse version
|
||||
const versionParts = oldVersion.split('.').map(Number);
|
||||
if (versionParts.length !== 3) {
|
||||
console.error(`Error: Invalid version format in ${packageName}: ${oldVersion}`);
|
||||
console.error('Expected format: X.Y.Z (e.g., 1.2.3)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Bump version
|
||||
let [major, minor, patch] = versionParts;
|
||||
|
||||
switch (bumpType) {
|
||||
case 'major':
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
break;
|
||||
case 'patch':
|
||||
patch += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const newVersion = `${major}.${minor}.${patch}`;
|
||||
packageJson.version = newVersion;
|
||||
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
|
||||
|
||||
return newVersion;
|
||||
} catch (error) {
|
||||
console.error(`Error bumping version in ${packageName}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Bump UI package version
|
||||
const uiOldVersion = JSON.parse(readFileSync(uiPackageJsonPath, 'utf8')).version;
|
||||
const uiNewVersion = bumpVersion(uiPackageJsonPath, '@automaker/ui');
|
||||
|
||||
// Bump server package version (sync with UI)
|
||||
const serverOldVersion = JSON.parse(readFileSync(serverPackageJsonPath, 'utf8')).version;
|
||||
const serverNewVersion = bumpVersion(serverPackageJsonPath, '@automaker/server');
|
||||
|
||||
// Verify versions match
|
||||
if (uiNewVersion !== serverNewVersion) {
|
||||
console.error(`Error: Version mismatch! UI: ${uiNewVersion}, Server: ${serverNewVersion}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ Bumped version from ${uiOldVersion} to ${uiNewVersion} (${bumpType})`);
|
||||
console.log(`📦 Updated @automaker/ui: ${uiOldVersion} -> ${uiNewVersion}`);
|
||||
console.log(`📦 Updated @automaker/server: ${serverOldVersion} -> ${serverNewVersion}`);
|
||||
console.log(`📦 Version is now: ${uiNewVersion}`);
|
||||
} catch (error) {
|
||||
console.error(`Error bumping version: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -62,7 +62,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
// Update preview image when background settings change
|
||||
useEffect(() => {
|
||||
if (currentProject && backgroundSettings.imagePath) {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
// Add cache-busting query parameter to force browser to reload image
|
||||
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
|
||||
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
||||
|
||||
@@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-
|
||||
export { DeleteSessionDialog } from './delete-session-dialog';
|
||||
export { FileBrowserDialog } from './file-browser-dialog';
|
||||
export { NewProjectModal } from './new-project-modal';
|
||||
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
||||
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
||||
export { WorkspacePickerModal } from './workspace-picker-modal';
|
||||
|
||||
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Sandbox Rejection Screen
|
||||
*
|
||||
* Shown in web mode when user denies the sandbox risk confirmation.
|
||||
* Prompts them to either restart the app in a container or reload to try again.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRejectionScreen() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleReload = () => {
|
||||
// Clear the rejection state and reload
|
||||
sessionStorage.removeItem('automaker-sandbox-denied');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-destructive/10 p-4">
|
||||
<ShieldX className="w-12 h-12 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Access Denied</h1>
|
||||
<p className="text-muted-foreground">
|
||||
You declined to accept the risks of running Automaker outside a sandbox environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Automaker in a containerized sandbox environment:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReload}
|
||||
className="gap-2"
|
||||
data-testid="sandbox-retry"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload & Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Sandbox Risk Confirmation Dialog
|
||||
*
|
||||
* Shows when the app is running outside a containerized environment.
|
||||
* Users must acknowledge the risks before proceeding.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SandboxRiskDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-lg"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
Sandbox Environment Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="text-muted-foreground">
|
||||
<strong>Warning:</strong> This application is running outside of a containerized
|
||||
sandbox environment. AI agents will have direct access to your filesystem and can
|
||||
execute commands on your system.
|
||||
</p>
|
||||
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>Agents can read, modify, or delete files on your system</li>
|
||||
<li>Agents can execute arbitrary commands and install software</li>
|
||||
<li>Agents can access environment variables and credentials</li>
|
||||
<li>Unintended side effects from agent actions may affect your system</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||
Deny & Exit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="px-4"
|
||||
data-testid="sandbox-confirm"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ interface AutomakerLogoProps {
|
||||
}
|
||||
|
||||
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -17,7 +19,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{!sidebarOpen ? (
|
||||
<div className="relative flex items-center justify-center rounded-lg">
|
||||
<div className="relative flex flex-col items-center justify-center rounded-lg gap-0.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
@@ -61,54 +63,62 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
<div className={cn('flex flex-col', 'hidden lg:flex')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||
import {
|
||||
sanitizeFilename,
|
||||
@@ -93,7 +94,7 @@ export function DescriptionImageDropZone({
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback(
|
||||
(imagePath: string): string => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
const projectPath = currentProject?.path || '';
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
},
|
||||
|
||||
@@ -206,6 +206,7 @@ export function BoardView() {
|
||||
checkContextExists,
|
||||
features: hookFeatures,
|
||||
isLoading,
|
||||
featuresWithContext,
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export function CardActions({
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : hasContext && onResume ? (
|
||||
) : onResume ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -158,21 +158,6 @@ export function CardActions({
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : onVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-feature-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
{onViewOutput && !feature.skipTests && (
|
||||
<Button
|
||||
|
||||
@@ -105,9 +105,21 @@ export function AgentOutputModal({
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
console.log(
|
||||
'[AgentOutputModal] Received event:',
|
||||
event.type,
|
||||
'featureId:',
|
||||
'featureId' in event ? event.featureId : 'none',
|
||||
'modalFeatureId:',
|
||||
featureId
|
||||
);
|
||||
|
||||
// Filter events for this specific feature only (skip events without featureId)
|
||||
if ('featureId' in event && event.featureId !== featureId) {
|
||||
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -435,21 +435,33 @@ export function useBoardActions({
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
console.log('[Board] handleResumeFeature called for feature:', feature.id);
|
||||
if (!currentProject) {
|
||||
console.error('[Board] No current project');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error('Auto mode API not available');
|
||||
console.error('[Board] Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Board] Calling resumeFeature API...', {
|
||||
projectPath: currentProject.path,
|
||||
featureId: feature.id,
|
||||
useWorktrees,
|
||||
});
|
||||
|
||||
const result = await api.autoMode.resumeFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
console.log('[Board] resumeFeature result:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[Board] Feature resume started successfully');
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
|
||||
interface UseBoardBackgroundProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -23,7 +24,7 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${
|
||||
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'
|
||||
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
|
||||
}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UseBoardEffectsProps {
|
||||
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||
features: any[];
|
||||
isLoading: boolean;
|
||||
featuresWithContext: Set<string>;
|
||||
setFeaturesWithContext: (set: Set<string>) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +26,14 @@ export function useBoardEffects({
|
||||
checkContextExists,
|
||||
features,
|
||||
isLoading,
|
||||
featuresWithContext,
|
||||
setFeaturesWithContext,
|
||||
}: UseBoardEffectsProps) {
|
||||
// Keep a ref to the current featuresWithContext for use in event handlers
|
||||
const featuresWithContextRef = useRef(featuresWithContext);
|
||||
useEffect(() => {
|
||||
featuresWithContextRef.current = featuresWithContext;
|
||||
}, [featuresWithContext]);
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
@@ -146,4 +153,30 @@ export function useBoardEffects({
|
||||
checkAllContexts();
|
||||
}
|
||||
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||
|
||||
// Re-check context when a feature stops, completes, or errors
|
||||
// This ensures hasContext is updated even if the features array doesn't change
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent(async (event) => {
|
||||
// When a feature stops (error/abort) or completes, re-check its context
|
||||
if (
|
||||
(event.type === 'auto_mode_error' || event.type === 'auto_mode_feature_complete') &&
|
||||
event.featureId
|
||||
) {
|
||||
const hasContext = await checkContextExists(event.featureId);
|
||||
if (hasContext) {
|
||||
const newSet = new Set(featuresWithContextRef.current);
|
||||
newSet.add(event.featureId);
|
||||
setFeaturesWithContext(newSet);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [checkContextExists, setFeaturesWithContext]);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SquarePlus,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
import {
|
||||
useAppStore,
|
||||
type TerminalPanelContent,
|
||||
@@ -272,7 +273,7 @@ export function TerminalView() {
|
||||
// Get the default run script from terminal settings
|
||||
const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript);
|
||||
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
|
||||
// Helper to collect all session IDs from all tabs
|
||||
const collectAllSessionIds = useCallback((): string[] => {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from '@/config/terminal-themes';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getApiKey, getSessionToken } from '@/lib/http-api-client';
|
||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||
|
||||
// Font size constraints
|
||||
const MIN_FONT_SIZE = 8;
|
||||
@@ -483,7 +483,7 @@ export function TerminalPanel({
|
||||
[closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal]
|
||||
);
|
||||
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
||||
|
||||
// Fetch a short-lived WebSocket token for secure authentication
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
||||
import { isElectron } from '@/lib/electron';
|
||||
import { getItem, removeItem } from '@/lib/storage';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -99,6 +99,10 @@ export function useSettingsMigration(): MigrationState {
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for API key to be initialized before making any API calls
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
await waitForApiKeyInit();
|
||||
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Check if server has settings files
|
||||
|
||||
@@ -9,16 +9,10 @@
|
||||
* Use this instead of raw fetch() for all authenticated API calls.
|
||||
*/
|
||||
|
||||
import { getApiKey, getSessionToken } from './http-api-client';
|
||||
import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client';
|
||||
|
||||
// Server URL - configurable via environment variable
|
||||
const getServerUrl = (): string => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
}
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
// Server URL - uses shared cached URL from http-api-client
|
||||
const getServerUrl = (): string => getServerUrlSync();
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ import type {
|
||||
} from '@/types/electron';
|
||||
|
||||
// Import HTTP API client (ES module)
|
||||
import { getHttpApiClient } from './http-api-client';
|
||||
import { getHttpApiClient, getServerUrlSync } from './http-api-client';
|
||||
|
||||
// Feature type - Import from app-store
|
||||
import type { Feature } from '@/store/app-store';
|
||||
@@ -432,6 +432,7 @@ export interface SaveImageResult {
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
quit?: () => Promise<void>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
openDirectory: () => Promise<DialogResult>;
|
||||
openFile: (options?: object) => Promise<DialogResult>;
|
||||
@@ -694,7 +695,7 @@ export const checkServerAvailable = async (): Promise<boolean> => {
|
||||
|
||||
serverCheckPromise = (async () => {
|
||||
try {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
const response = await fetch(`${serverUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(2000),
|
||||
|
||||
@@ -32,8 +32,31 @@ import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||
|
||||
// Server URL - configurable via environment variable
|
||||
// Cached server URL (set during initialization in Electron mode)
|
||||
let cachedServerUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Initialize server URL from Electron IPC.
|
||||
* Must be called early in Electron mode before making API requests.
|
||||
*/
|
||||
export const initServerUrl = async (): Promise<void> => {
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getServerUrl) {
|
||||
try {
|
||||
cachedServerUrl = await window.electronAPI.getServerUrl();
|
||||
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Server URL - uses cached value from IPC or environment variable
|
||||
const getServerUrl = (): string => {
|
||||
// Use cached URL from Electron IPC if available
|
||||
if (cachedServerUrl) {
|
||||
return cachedServerUrl;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
@@ -41,9 +64,15 @@ const getServerUrl = (): string => {
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the server URL (exported for use in other modules)
|
||||
*/
|
||||
export const getServerUrlSync = (): string => getServerUrl();
|
||||
|
||||
// Cached API key for authentication (Electron mode only)
|
||||
let cachedApiKey: string | null = null;
|
||||
let apiKeyInitialized = false;
|
||||
let apiKeyInitPromise: Promise<void> | null = null;
|
||||
|
||||
// Cached session token for authentication (Web mode - explicit header auth)
|
||||
let cachedSessionToken: string | null = null;
|
||||
@@ -52,6 +81,17 @@ let cachedSessionToken: string | null = null;
|
||||
// Exported for use in WebSocket connections that need auth
|
||||
export const getApiKey = (): string | null => cachedApiKey;
|
||||
|
||||
/**
|
||||
* Wait for API key initialization to complete.
|
||||
* Returns immediately if already initialized.
|
||||
*/
|
||||
export const waitForApiKeyInit = (): Promise<void> => {
|
||||
if (apiKeyInitialized) return Promise.resolve();
|
||||
if (apiKeyInitPromise) return apiKeyInitPromise;
|
||||
// If not started yet, start it now
|
||||
return initApiKey();
|
||||
};
|
||||
|
||||
// Get session token for Web mode (returns cached value after login or token fetch)
|
||||
export const getSessionToken = (): string | null => cachedSessionToken;
|
||||
|
||||
@@ -73,30 +113,46 @@ export const isElectronMode = (): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize API key for Electron mode authentication.
|
||||
* Initialize API key and server URL for Electron mode authentication.
|
||||
* In web mode, authentication uses HTTP-only cookies instead.
|
||||
*
|
||||
* This should be called early in app initialization.
|
||||
*/
|
||||
export const initApiKey = async (): Promise<void> => {
|
||||
// Return existing promise if already in progress
|
||||
if (apiKeyInitPromise) return apiKeyInitPromise;
|
||||
|
||||
// Return immediately if already initialized
|
||||
if (apiKeyInitialized) return;
|
||||
apiKeyInitialized = true;
|
||||
|
||||
// Only Electron mode uses API key header auth
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
|
||||
// Create and store the promise so concurrent calls wait for the same initialization
|
||||
apiKeyInitPromise = (async () => {
|
||||
try {
|
||||
cachedApiKey = await window.electronAPI.getApiKey();
|
||||
if (cachedApiKey) {
|
||||
console.log('[HTTP Client] Using API key from Electron');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
|
||||
}
|
||||
}
|
||||
// Initialize server URL from Electron IPC first (needed for API requests)
|
||||
await initServerUrl();
|
||||
|
||||
// In web mode, authentication is handled via HTTP-only cookies
|
||||
console.log('[HTTP Client] Web mode - using cookie-based authentication');
|
||||
// Only Electron mode uses API key header auth
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
|
||||
try {
|
||||
cachedApiKey = await window.electronAPI.getApiKey();
|
||||
if (cachedApiKey) {
|
||||
console.log('[HTTP Client] Using API key from Electron');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// In web mode, authentication is handled via HTTP-only cookies
|
||||
console.log('[HTTP Client] Web mode - using cookie-based authentication');
|
||||
} finally {
|
||||
// Mark as initialized after completion, regardless of success or failure
|
||||
apiKeyInitialized = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return apiKeyInitPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -269,6 +325,32 @@ export const verifySession = async (): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the server is running in a containerized (sandbox) environment.
|
||||
* This endpoint is unauthenticated so it can be checked before login.
|
||||
*/
|
||||
export const checkSandboxEnvironment = async (): Promise<{
|
||||
isContainerized: boolean;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[HTTP Client] Failed to check sandbox environment');
|
||||
return { isContainerized: false, error: 'Failed to check environment' };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { isContainerized: data.isContainerized ?? false };
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Sandbox environment check failed:', error);
|
||||
return { isContainerized: false, error: 'Network error' };
|
||||
}
|
||||
};
|
||||
|
||||
type EventType =
|
||||
| 'agent:stream'
|
||||
| 'auto-mode:event'
|
||||
@@ -296,7 +378,17 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrl();
|
||||
this.connectWebSocket();
|
||||
// Wait for API key initialization before connecting WebSocket
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
waitForApiKeyInit()
|
||||
.then(() => {
|
||||
this.connectWebSocket();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
||||
// Still attempt WebSocket connection - it may work with cookie auth
|
||||
this.connectWebSocket();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,8 +481,17 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(
|
||||
'[HttpApiClient] WebSocket message:',
|
||||
data.type,
|
||||
'hasPayload:',
|
||||
!!data.payload,
|
||||
'callbacksRegistered:',
|
||||
this.eventCallbacks.has(data.type)
|
||||
);
|
||||
const callbacks = this.eventCallbacks.get(data.type);
|
||||
if (callbacks) {
|
||||
console.log('[HttpApiClient] Dispatching to', callbacks.size, 'callbacks');
|
||||
callbacks.forEach((cb) => cb(data.payload));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -460,6 +561,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
@@ -470,6 +573,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
@@ -478,6 +583,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
@@ -488,6 +595,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
@@ -566,14 +675,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const result = await this.post<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
isAllowed?: boolean;
|
||||
error?: string;
|
||||
}>('/api/fs/validate-path', { filePath: path });
|
||||
|
||||
if (result.success && result.path) {
|
||||
if (result.success && result.path && result.isAllowed !== false) {
|
||||
return { canceled: false, filePaths: [result.path] };
|
||||
}
|
||||
|
||||
console.error('Invalid directory:', result.error);
|
||||
console.error('Invalid directory:', result.error || 'Path not allowed');
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
@@ -1586,3 +1696,10 @@ export function getHttpApiClient(): HttpApiClient {
|
||||
}
|
||||
return httpApiClientInstance;
|
||||
}
|
||||
|
||||
// Start API key initialization immediately when this module is imported
|
||||
// This ensures the init promise is created early, even before React components mount
|
||||
// The actual async work happens in the background and won't block module loading
|
||||
initApiKey().catch((error) => {
|
||||
console.error('[HTTP Client] Failed to initialize API key:', error);
|
||||
});
|
||||
|
||||
@@ -3,15 +3,37 @@
|
||||
*
|
||||
* This version spawns the backend server and uses HTTP API for most operations.
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
*
|
||||
* SECURITY: All file system access uses centralized methods from @automaker/platform.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import http, { Server } from 'http';
|
||||
import net from 'net';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
import {
|
||||
findNodeExecutable,
|
||||
buildEnhancedPath,
|
||||
initAllowedPaths,
|
||||
isPathAllowed,
|
||||
getAllowedRootDirectory,
|
||||
// Electron userData operations
|
||||
setElectronUserDataPath,
|
||||
electronUserDataReadFileSync,
|
||||
electronUserDataWriteFileSync,
|
||||
electronUserDataExists,
|
||||
// Electron app bundle operations
|
||||
setElectronAppPaths,
|
||||
electronAppExists,
|
||||
electronAppReadFileSync,
|
||||
electronAppStatSync,
|
||||
electronAppStat,
|
||||
electronAppReadFile,
|
||||
// System path operations
|
||||
systemPathExists,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
@@ -30,8 +52,51 @@ if (isDev) {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let staticServer: Server | null = null;
|
||||
const SERVER_PORT = 3008;
|
||||
const STATIC_PORT = 3007;
|
||||
|
||||
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
|
||||
// When launched via root init.mjs we pass:
|
||||
// - PORT (backend)
|
||||
// - TEST_PORT (vite dev server / static)
|
||||
const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10);
|
||||
|
||||
// Actual ports in use (set during startup)
|
||||
let serverPort = DEFAULT_SERVER_PORT;
|
||||
let staticPort = DEFAULT_STATIC_PORT;
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
*/
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
// Use Node's default binding semantics (matches most dev servers)
|
||||
// This avoids false-positives when a port is taken on IPv6/dual-stack.
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from the preferred port
|
||||
* Tries up to 100 ports in sequence
|
||||
*/
|
||||
async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||
for (let offset = 0; offset < 100; offset++) {
|
||||
const port = preferredPort + offset;
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find an available port starting from ${preferredPort}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Window sizing constants for kanban layout
|
||||
@@ -64,21 +129,19 @@ let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let apiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Get path to API key file in user data directory
|
||||
* Get the relative path to API key file within userData
|
||||
*/
|
||||
function getApiKeyPath(): string {
|
||||
return path.join(app.getPath('userData'), '.api-key');
|
||||
}
|
||||
const API_KEY_FILENAME = '.api-key';
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - load from file or generate new one.
|
||||
* This key is passed to the server for CSRF protection.
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
const keyPath = getApiKeyPath();
|
||||
try {
|
||||
if (fs.existsSync(keyPath)) {
|
||||
const key = fs.readFileSync(keyPath, 'utf-8').trim();
|
||||
if (electronUserDataExists(API_KEY_FILENAME)) {
|
||||
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
|
||||
if (key) {
|
||||
apiKey = key;
|
||||
console.log('[Electron] Loaded existing API key');
|
||||
@@ -92,7 +155,7 @@ function ensureApiKey(): string {
|
||||
// Generate new key
|
||||
apiKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Electron] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to save API key:', error);
|
||||
@@ -102,6 +165,7 @@ function ensureApiKey(): string {
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
* Uses centralized electronApp methods for path validation.
|
||||
*/
|
||||
function getIconPath(): string | null {
|
||||
let iconFile: string;
|
||||
@@ -117,8 +181,13 @@ function getIconPath(): string | null {
|
||||
? path.join(__dirname, '../public', iconFile)
|
||||
: path.join(__dirname, '../dist/public', iconFile);
|
||||
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
try {
|
||||
if (!electronAppExists(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Electron] Icon check failed: ${iconPath}`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -126,20 +195,18 @@ function getIconPath(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to window bounds settings file
|
||||
* Relative path to window bounds settings file within userData
|
||||
*/
|
||||
function getWindowBoundsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'window-bounds.json');
|
||||
}
|
||||
const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
|
||||
|
||||
/**
|
||||
* Load saved window bounds from disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function loadWindowBounds(): WindowBounds | null {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
if (fs.existsSync(boundsPath)) {
|
||||
const data = fs.readFileSync(boundsPath, 'utf-8');
|
||||
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
|
||||
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
|
||||
const bounds = JSON.parse(data) as WindowBounds;
|
||||
// Validate the loaded data has required fields
|
||||
if (
|
||||
@@ -159,11 +226,11 @@ function loadWindowBounds(): WindowBounds | null {
|
||||
|
||||
/**
|
||||
* Save window bounds to disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function saveWindowBounds(bounds: WindowBounds): void {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
|
||||
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
|
||||
console.log('[Electron] Window bounds saved');
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
|
||||
@@ -241,6 +308,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
* Uses centralized electronApp methods for serving static files from app bundle.
|
||||
*/
|
||||
async function startStaticServer(): Promise<void> {
|
||||
const staticPath = path.join(__dirname, '../dist');
|
||||
@@ -253,20 +321,24 @@ async function startStaticServer(): Promise<void> {
|
||||
} else if (!path.extname(filePath)) {
|
||||
// For client-side routing, serve index.html for paths without extensions
|
||||
const possibleFile = filePath + '.html';
|
||||
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
|
||||
try {
|
||||
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (electronAppExists(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
} catch {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (fs.existsSync(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
}
|
||||
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
electronAppStat(filePath, (err, stats) => {
|
||||
if (err || !stats?.isFile()) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
electronAppReadFile(filePath, (error, content) => {
|
||||
if (error || !content) {
|
||||
response.writeHead(500);
|
||||
response.end('Server Error');
|
||||
return;
|
||||
@@ -298,8 +370,8 @@ async function startStaticServer(): Promise<void> {
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
staticServer!.listen(STATIC_PORT, () => {
|
||||
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||
staticServer!.listen(staticPort, () => {
|
||||
console.log(`[Electron] Static server running at http://localhost:${staticPort}`);
|
||||
resolve();
|
||||
});
|
||||
staticServer!.on('error', reject);
|
||||
@@ -308,6 +380,7 @@ async function startStaticServer(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
* Uses centralized methods for path validation.
|
||||
*/
|
||||
async function startServer(): Promise<void> {
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
@@ -318,8 +391,20 @@ async function startServer(): Promise<void> {
|
||||
const command = nodeResult.nodePath;
|
||||
|
||||
// Validate that the found Node executable actually exists
|
||||
if (command !== 'node' && !fs.existsSync(command)) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
// systemPathExists is used because node-finder returns system paths
|
||||
if (command !== 'node') {
|
||||
let exists: boolean;
|
||||
try {
|
||||
exists = systemPathExists(command);
|
||||
} catch (error) {
|
||||
const originalError = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
|
||||
);
|
||||
}
|
||||
if (!exists) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
}
|
||||
}
|
||||
|
||||
let args: string[];
|
||||
@@ -332,11 +417,22 @@ async function startServer(): Promise<void> {
|
||||
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
|
||||
|
||||
let tsxCliPath: string;
|
||||
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
} else {
|
||||
// Check for tsx in app bundle paths
|
||||
try {
|
||||
if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
} else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
} else {
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
@@ -351,7 +447,11 @@ async function startServer(): Promise<void> {
|
||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||
args = [serverPath];
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
try {
|
||||
if (!electronAppExists(serverPath)) {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
}
|
||||
@@ -360,6 +460,13 @@ async function startServer(): Promise<void> {
|
||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||
: path.join(__dirname, '../../server/node_modules');
|
||||
|
||||
// Server root directory - where .env file is located
|
||||
// In dev: apps/server (not apps/server/src)
|
||||
// In production: resources/server
|
||||
const serverRoot = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server')
|
||||
: path.join(__dirname, '../../server');
|
||||
|
||||
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||
if (enhancedPath !== process.env.PATH) {
|
||||
@@ -369,7 +476,7 @@ async function startServer(): Promise<void> {
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: enhancedPath,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
PORT: serverPort.toString(),
|
||||
DATA_DIR: app.getPath('userData'),
|
||||
NODE_PATH: serverNodeModules,
|
||||
// Pass API key to server for CSRF protection
|
||||
@@ -381,12 +488,15 @@ async function startServer(): Promise<void> {
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`[Electron] Server will use port ${serverPort}`);
|
||||
|
||||
console.log('[Electron] Starting backend server...');
|
||||
console.log('[Electron] Server path:', serverPath);
|
||||
console.log('[Electron] Server root (cwd):', serverRoot);
|
||||
console.log('[Electron] NODE_PATH:', serverNodeModules);
|
||||
|
||||
serverProcess = spawn(command, args, {
|
||||
cwd: path.dirname(serverPath),
|
||||
cwd: serverRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -419,7 +529,7 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
||||
const req = http.get(`http://localhost:${serverPort}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -484,9 +594,9 @@ function createWindow(): void {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
} else if (isDev) {
|
||||
// Fallback for dev without Vite server URL
|
||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||
mainWindow.loadURL(`http://localhost:${staticPort}`);
|
||||
} else {
|
||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||
mainWindow.loadURL(`http://localhost:${staticPort}`);
|
||||
}
|
||||
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
|
||||
@@ -541,6 +651,28 @@ app.whenReady().then(async () => {
|
||||
console.warn('[Electron] Failed to set userData path:', (error as Error).message);
|
||||
}
|
||||
|
||||
// Initialize centralized path helpers for Electron
|
||||
// This must be done before any file operations
|
||||
setElectronUserDataPath(app.getPath('userData'));
|
||||
|
||||
// In development mode, allow access to the entire project root (for source files, node_modules, etc.)
|
||||
// In production, only allow access to the built app directory and resources
|
||||
if (isDev) {
|
||||
// __dirname is apps/ui/dist-electron, so go up 3 levels to get project root
|
||||
const projectRoot = path.join(__dirname, '../../..');
|
||||
setElectronAppPaths([__dirname, projectRoot]);
|
||||
} else {
|
||||
setElectronAppPaths(__dirname, process.resourcesPath);
|
||||
}
|
||||
console.log('[Electron] Initialized path security helpers');
|
||||
|
||||
// Initialize security settings for path validation
|
||||
// Set DATA_DIR before initializing so it's available for security checks
|
||||
process.env.DATA_DIR = app.getPath('userData');
|
||||
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
|
||||
// (it will be passed to server process, but we also need it in main process for dialog validation)
|
||||
initAllowedPaths();
|
||||
|
||||
if (process.platform === 'darwin' && app.dock) {
|
||||
const iconPath = getIconPath();
|
||||
if (iconPath) {
|
||||
@@ -556,6 +688,21 @@ app.whenReady().then(async () => {
|
||||
ensureApiKey();
|
||||
|
||||
try {
|
||||
// Find available ports (prevents conflicts with other apps using same ports)
|
||||
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
|
||||
if (serverPort !== DEFAULT_SERVER_PORT) {
|
||||
console.log(
|
||||
`[Electron] Default server port ${DEFAULT_SERVER_PORT} in use, using port ${serverPort}`
|
||||
);
|
||||
}
|
||||
|
||||
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
|
||||
if (staticPort !== DEFAULT_STATIC_PORT) {
|
||||
console.log(
|
||||
`[Electron] Default static port ${DEFAULT_STATIC_PORT} in use, using port ${staticPort}`
|
||||
);
|
||||
}
|
||||
|
||||
// Start static file server in production
|
||||
if (app.isPackaged) {
|
||||
await startStaticServer();
|
||||
@@ -589,7 +736,29 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS, keep the app and servers running when all windows are closed
|
||||
// (standard macOS behavior). On other platforms, stop servers and quit.
|
||||
if (process.platform !== 'darwin') {
|
||||
if (serverProcess && serverProcess.pid) {
|
||||
console.log('[Electron] All windows closed, stopping server...');
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to kill server process:', (error as Error).message);
|
||||
}
|
||||
} else {
|
||||
serverProcess.kill('SIGTERM');
|
||||
}
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
if (staticServer) {
|
||||
console.log('[Electron] Stopping static server...');
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -631,6 +800,22 @@ ipcMain.handle('dialog:openDirectory', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
|
||||
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
if (!isPathAllowed(selectedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
|
||||
: 'The selected directory is not allowed.';
|
||||
|
||||
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
|
||||
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -720,7 +905,7 @@ ipcMain.handle('ping', async () => {
|
||||
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
return `http://localhost:${serverPort}`;
|
||||
});
|
||||
|
||||
// Get API key for authentication
|
||||
@@ -736,3 +921,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
|
||||
// Always use the smaller minimum width - horizontal scrolling handles any overflow
|
||||
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
|
||||
});
|
||||
|
||||
// Quit the application (used when user denies sandbox risk confirmation)
|
||||
ipcMain.handle('app:quit', () => {
|
||||
console.log('[Electron] Quitting application via IPC request');
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Window management
|
||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||
|
||||
// App control
|
||||
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
|
||||
});
|
||||
|
||||
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||
|
||||
@@ -8,10 +8,22 @@ import {
|
||||
} from '@/contexts/file-browser-context';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client';
|
||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||
import { isMac } from '@/lib/utils';
|
||||
import {
|
||||
initApiKey,
|
||||
isElectronMode,
|
||||
verifySession,
|
||||
checkSandboxEnvironment,
|
||||
} from '@/lib/http-api-client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||
|
||||
// Session storage key for sandbox risk acknowledgment
|
||||
const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged';
|
||||
const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied';
|
||||
|
||||
function RootLayoutContent() {
|
||||
const location = useLocation();
|
||||
@@ -27,6 +39,20 @@ function RootLayoutContent() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Sandbox environment check state
|
||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => {
|
||||
// Check if user previously denied in this session
|
||||
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) {
|
||||
return 'denied';
|
||||
}
|
||||
// Check if user previously acknowledged in this session
|
||||
if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) {
|
||||
return 'confirmed';
|
||||
}
|
||||
return 'pending';
|
||||
});
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
@@ -73,6 +99,63 @@ function RootLayoutContent() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Check sandbox environment on mount
|
||||
useEffect(() => {
|
||||
// Skip if already decided
|
||||
if (sandboxStatus !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSandbox = async () => {
|
||||
try {
|
||||
const result = await checkSandboxEnvironment();
|
||||
|
||||
if (result.isContainerized) {
|
||||
// Running in a container, no warning needed
|
||||
setSandboxStatus('containerized');
|
||||
} else {
|
||||
// Not containerized, show warning dialog
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to check environment:', error);
|
||||
// On error, assume not containerized and show warning
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
};
|
||||
|
||||
checkSandbox();
|
||||
}, [sandboxStatus]);
|
||||
|
||||
// Handle sandbox risk confirmation
|
||||
const handleSandboxConfirm = useCallback(() => {
|
||||
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
|
||||
setSandboxStatus('confirmed');
|
||||
}, []);
|
||||
|
||||
// Handle sandbox risk denial
|
||||
const handleSandboxDeny = useCallback(async () => {
|
||||
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
|
||||
|
||||
if (isElectron()) {
|
||||
// In Electron mode, quit the application
|
||||
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
|
||||
try {
|
||||
const electronAPI = window.electronAPI;
|
||||
if (electronAPI?.quit) {
|
||||
await electronAPI.quit();
|
||||
} else {
|
||||
console.error('[Sandbox] quit() not available on electronAPI');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to quit app:', error);
|
||||
}
|
||||
} else {
|
||||
// In web mode, show rejection screen
|
||||
setSandboxStatus('denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize authentication
|
||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||
// - Web mode: Uses HTTP-only session cookie
|
||||
@@ -201,11 +284,31 @@ function RootLayoutContent() {
|
||||
const isSetupRoute = location.pathname === '/setup';
|
||||
const isLoginRoute = location.pathname === '/login';
|
||||
|
||||
// Show rejection screen if user denied sandbox risk (web mode only)
|
||||
if (sandboxStatus === 'denied' && !isElectron()) {
|
||||
return <SandboxRejectionScreen />;
|
||||
}
|
||||
|
||||
// Show loading while checking sandbox environment
|
||||
if (sandboxStatus === 'pending') {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<div className="text-muted-foreground">Checking environment...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login page (full screen, no sidebar)
|
||||
if (isLoginRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
{/* Show sandbox dialog on top of login page if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -228,12 +331,25 @@ function RootLayoutContent() {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
{/* Show sandbox dialog on top of setup page if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
{/* Full-width titlebar drag region for Electron window dragging */}
|
||||
{isElectron() && (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<Sidebar />
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
@@ -249,6 +365,13 @@ function RootLayoutContent() {
|
||||
}`}
|
||||
/>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
|
||||
{/* Show sandbox dialog if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -975,7 +975,7 @@ const initialState: AppState = {
|
||||
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur)
|
||||
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
||||
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
||||
|
||||
@@ -172,6 +172,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
}),
|
||||
{
|
||||
name: 'automaker-setup',
|
||||
version: 1, // Add version field for proper hydration (matches app-store pattern)
|
||||
partialize: (state) => ({
|
||||
isFirstRun: state.isFirstRun,
|
||||
setupComplete: state.setupComplete,
|
||||
|
||||
1
apps/ui/src/types/electron.d.ts
vendored
1
apps/ui/src/types/electron.d.ts
vendored
@@ -465,6 +465,7 @@ export interface AutoModeAPI {
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
quit?: () => Promise<void>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Dialog APIs
|
||||
|
||||
3
apps/ui/src/vite-env.d.ts
vendored
3
apps/ui/src/vite-env.d.ts
vendored
@@ -9,3 +9,6 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
// Global constants defined in vite.config.mts
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -80,13 +80,21 @@ export async function createTestGitRepo(tempDir: string): 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
|
||||
fs.writeFileSync(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 });
|
||||
@@ -248,9 +256,18 @@ export async function commitFile(
|
||||
content: string,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
// Use environment variables instead of git config to avoid affecting user's git config
|
||||
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',
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(repoPath, filePath), content);
|
||||
await execAsync(`git add "${filePath}"`, { cwd: repoPath });
|
||||
await execAsync(`git commit -m "${message}"`, { cwd: repoPath });
|
||||
await execAsync(`git add "${filePath}"`, { cwd: repoPath, env: gitEnv });
|
||||
await execAsync(`git commit -m "${message}"`, { cwd: repoPath, env: gitEnv });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
@@ -8,6 +9,10 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Read version from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||
const appVersion = packageJson.version;
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
// Only skip electron plugin during dev server in CI (no display available for Electron)
|
||||
// Always include it during build - we need dist-electron/main.js for electron-builder
|
||||
@@ -65,5 +70,8 @@ export default defineConfig(({ command }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user