style: fix formatting with Prettier

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:31:57 -05:00
parent 584f5a3426
commit 8d578558ff
295 changed files with 9088 additions and 10546 deletions

View File

@@ -21,4 +21,4 @@
"mcp__puppeteer__puppeteer_evaluate" "mcp__puppeteer__puppeteer_evaluate"
] ]
} }
} }

View File

@@ -1,15 +1,11 @@
const { const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
S3Client, const fs = require('fs');
PutObjectCommand, const path = require('path');
GetObjectCommand, const https = require('https');
} = require("@aws-sdk/client-s3"); const { pipeline } = require('stream/promises');
const fs = require("fs");
const path = require("path");
const https = require("https");
const { pipeline } = require("stream/promises");
const s3Client = new S3Client({ const s3Client = new S3Client({
region: "auto", region: 'auto',
endpoint: process.env.R2_ENDPOINT, endpoint: process.env.R2_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID, accessKeyId: process.env.R2_ACCESS_KEY_ID,
@@ -28,14 +24,14 @@ async function fetchExistingReleases() {
const response = await s3Client.send( const response = await s3Client.send(
new GetObjectCommand({ new GetObjectCommand({
Bucket: BUCKET, Bucket: BUCKET,
Key: "releases.json", Key: 'releases.json',
}) })
); );
const body = await response.Body.transformToString(); const body = await response.Body.transformToString();
return JSON.parse(body); return JSON.parse(body);
} catch (error) { } catch (error) {
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
console.log("No existing releases.json found, creating new one"); console.log('No existing releases.json found, creating new one');
return { latestVersion: null, releases: [] }; return { latestVersion: null, releases: [] };
} }
throw error; throw error;
@@ -85,7 +81,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
resolve({ resolve({
accessible: false, accessible: false,
statusCode, statusCode,
error: "Redirect without location header", error: 'Redirect without location header',
}); });
return; return;
} }
@@ -93,18 +89,16 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
return https return https
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => { .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
const redirectStatus = redirectResponse.statusCode; const redirectStatus = redirectResponse.statusCode;
const contentType = const contentType = redirectResponse.headers['content-type'] || '';
redirectResponse.headers["content-type"] || "";
// Check if it's actually a file (zip/tar.gz) and not HTML // Check if it's actually a file (zip/tar.gz) and not HTML
const isFile = const isFile =
contentType.includes("application/zip") || contentType.includes('application/zip') ||
contentType.includes("application/gzip") || contentType.includes('application/gzip') ||
contentType.includes("application/x-gzip") || contentType.includes('application/x-gzip') ||
contentType.includes("application/x-tar") || contentType.includes('application/x-tar') ||
redirectUrl.includes(".zip") || redirectUrl.includes('.zip') ||
redirectUrl.includes(".tar.gz"); redirectUrl.includes('.tar.gz');
const isGood = const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
redirectStatus >= 200 && redirectStatus < 300 && isFile;
redirectResponse.destroy(); redirectResponse.destroy();
resolve({ resolve({
accessible: isGood, accessible: isGood,
@@ -113,38 +107,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
contentType, contentType,
}); });
}) })
.on("error", (error) => { .on('error', (error) => {
resolve({ resolve({
accessible: false, accessible: false,
statusCode, statusCode,
error: error.message, error: error.message,
}); });
}) })
.on("timeout", function () { .on('timeout', function () {
this.destroy(); this.destroy();
resolve({ resolve({
accessible: false, accessible: false,
statusCode, statusCode,
error: "Timeout following redirect", error: 'Timeout following redirect',
}); });
}); });
} }
// Check if status is good (200-299 range) and it's actually a file // Check if status is good (200-299 range) and it's actually a file
const contentType = response.headers["content-type"] || ""; const contentType = response.headers['content-type'] || '';
const isFile = const isFile =
contentType.includes("application/zip") || contentType.includes('application/zip') ||
contentType.includes("application/gzip") || contentType.includes('application/gzip') ||
contentType.includes("application/x-gzip") || contentType.includes('application/x-gzip') ||
contentType.includes("application/x-tar") || contentType.includes('application/x-tar') ||
url.includes(".zip") || url.includes('.zip') ||
url.includes(".tar.gz"); url.includes('.tar.gz');
const isGood = statusCode >= 200 && statusCode < 300 && isFile; const isGood = statusCode >= 200 && statusCode < 300 && isFile;
response.destroy(); response.destroy();
resolve({ accessible: isGood, statusCode, contentType }); resolve({ accessible: isGood, statusCode, contentType });
}); });
request.on("error", (error) => { request.on('error', (error) => {
resolve({ resolve({
accessible: false, accessible: false,
statusCode: null, statusCode: null,
@@ -152,12 +146,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
}); });
}); });
request.on("timeout", () => { request.on('timeout', () => {
request.destroy(); request.destroy();
resolve({ resolve({
accessible: false, accessible: false,
statusCode: null, statusCode: null,
error: "Request timeout", error: 'Request timeout',
}); });
}); });
}); });
@@ -168,22 +162,14 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})` `✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
); );
} else { } else {
console.log( console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
`✓ URL ${url} is accessible (status: ${result.statusCode})`
);
} }
return result.finalUrl || url; // Return the final URL (after redirects) if available return result.finalUrl || url; // Return the final URL (after redirects) if available
} else { } else {
const errorMsg = result.error ? ` - ${result.error}` : ""; const errorMsg = result.error ? ` - ${result.error}` : '';
const statusMsg = result.statusCode const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
? ` (status: ${result.statusCode})` const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
: ""; console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
const contentTypeMsg = result.contentType
? ` [content-type: ${result.contentType}]`
: "";
console.log(
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
);
} }
} catch (error) { } catch (error) {
console.log(`✗ URL ${url} check failed: ${error.message}`); console.log(`✗ URL ${url} check failed: ${error.message}`);
@@ -191,9 +177,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, attempt); const delay = initialDelay * Math.pow(2, attempt);
console.log( console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
@@ -207,12 +191,7 @@ async function downloadFromGitHub(url, outputPath) {
const statusCode = response.statusCode; const statusCode = response.statusCode;
// Follow redirects (all redirect types) // Follow redirects (all redirect types)
if ( if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
statusCode === 301 ||
statusCode === 302 ||
statusCode === 307 ||
statusCode === 308
) {
const redirectUrl = response.headers.location; const redirectUrl = response.headers.location;
response.destroy(); response.destroy();
if (!redirectUrl) { if (!redirectUrl) {
@@ -220,39 +199,33 @@ async function downloadFromGitHub(url, outputPath) {
return; return;
} }
// Resolve relative redirects // Resolve relative redirects
const finalRedirectUrl = redirectUrl.startsWith("http") const finalRedirectUrl = redirectUrl.startsWith('http')
? redirectUrl ? redirectUrl
: new URL(redirectUrl, url).href; : new URL(redirectUrl, url).href;
console.log(` Following redirect: ${finalRedirectUrl}`); console.log(` Following redirect: ${finalRedirectUrl}`);
return downloadFromGitHub(finalRedirectUrl, outputPath) return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
.then(resolve)
.catch(reject);
} }
if (statusCode !== 200) { if (statusCode !== 200) {
response.destroy(); response.destroy();
reject( reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
new Error(
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
)
);
return; return;
} }
const fileStream = fs.createWriteStream(outputPath); const fileStream = fs.createWriteStream(outputPath);
response.pipe(fileStream); response.pipe(fileStream);
fileStream.on("finish", () => { fileStream.on('finish', () => {
fileStream.close(); fileStream.close();
resolve(); resolve();
}); });
fileStream.on("error", (error) => { fileStream.on('error', (error) => {
response.destroy(); response.destroy();
reject(error); reject(error);
}); });
}); });
request.on("error", reject); request.on('error', reject);
request.on("timeout", () => { request.on('timeout', () => {
request.destroy(); request.destroy();
reject(new Error(`Request timeout for ${url}`)); reject(new Error(`Request timeout for ${url}`));
}); });
@@ -260,8 +233,8 @@ async function downloadFromGitHub(url, outputPath) {
} }
async function main() { async function main() {
const artifactsDir = "artifacts"; const artifactsDir = 'artifacts';
const tempDir = path.join(artifactsDir, "temp"); const tempDir = path.join(artifactsDir, 'temp');
// Create temp directory for downloaded GitHub archives // Create temp directory for downloaded GitHub archives
if (!fs.existsSync(tempDir)) { if (!fs.existsSync(tempDir)) {
@@ -292,40 +265,30 @@ async function main() {
// Find all artifacts // Find all artifacts
const artifacts = { const artifacts = {
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/), windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/), macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
macosArm: findArtifacts( macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
path.join(artifactsDir, "macos-builds"), linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
/-arm64\.dmg$/
),
linux: findArtifacts(
path.join(artifactsDir, "linux-builds"),
/\.AppImage$/
),
sourceZip: [sourceZipPath], sourceZip: [sourceZipPath],
sourceTarGz: [sourceTarGzPath], sourceTarGz: [sourceTarGzPath],
}; };
console.log("Found artifacts:"); console.log('Found artifacts:');
for (const [platform, files] of Object.entries(artifacts)) { for (const [platform, files] of Object.entries(artifacts)) {
console.log( console.log(
` ${platform}: ${ ` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
files.length > 0
? files.map((f) => path.basename(f)).join(", ")
: "none"
}`
); );
} }
// Upload each artifact to R2 // Upload each artifact to R2
const assets = {}; const assets = {};
const contentTypes = { const contentTypes = {
windows: "application/x-msdownload", windows: 'application/x-msdownload',
macos: "application/x-apple-diskimage", macos: 'application/x-apple-diskimage',
macosArm: "application/x-apple-diskimage", macosArm: 'application/x-apple-diskimage',
linux: "application/x-executable", linux: 'application/x-executable',
sourceZip: "application/zip", sourceZip: 'application/zip',
sourceTarGz: "application/gzip", sourceTarGz: 'application/gzip',
}; };
for (const [platform, files] of Object.entries(artifacts)) { for (const [platform, files] of Object.entries(artifacts)) {
@@ -345,11 +308,11 @@ async function main() {
filename, filename,
size, size,
arch: arch:
platform === "macosArm" platform === 'macosArm'
? "arm64" ? 'arm64'
: platform === "sourceZip" || platform === "sourceTarGz" : platform === 'sourceZip' || platform === 'sourceTarGz'
? "source" ? 'source'
: "x64", : 'x64',
}; };
} }
@@ -364,9 +327,7 @@ async function main() {
}; };
// Remove existing entry for this version if re-running // Remove existing entry for this version if re-running
releasesData.releases = releasesData.releases.filter( releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
(r) => r.version !== VERSION
);
// Prepend new release // Prepend new release
releasesData.releases.unshift(newRelease); releasesData.releases.unshift(newRelease);
@@ -376,19 +337,19 @@ async function main() {
await s3Client.send( await s3Client.send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: BUCKET, Bucket: BUCKET,
Key: "releases.json", Key: 'releases.json',
Body: JSON.stringify(releasesData, null, 2), Body: JSON.stringify(releasesData, null, 2),
ContentType: "application/json", ContentType: 'application/json',
CacheControl: "public, max-age=60", CacheControl: 'public, max-age=60',
}) })
); );
console.log("Successfully updated releases.json"); console.log('Successfully updated releases.json');
console.log(`Latest version: ${VERSION}`); console.log(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`); console.log(`Total releases: ${releasesData.releases.length}`);
} }
main().catch((err) => { main().catch((err) => {
console.error("Failed to upload to R2:", err); console.error('Failed to upload to R2:', err);
process.exit(1); process.exit(1);
}); });

View File

@@ -3,7 +3,7 @@ name: E2E Tests
on: on:
pull_request: pull_request:
branches: branches:
- "*" - '*'
push: push:
branches: branches:
- main - main
@@ -21,8 +21,8 @@ jobs:
- name: Setup project - name: Setup project
uses: ./.github/actions/setup-project uses: ./.github/actions/setup-project
with: with:
check-lockfile: "true" check-lockfile: 'true'
rebuild-node-pty-path: "apps/server" rebuild-node-pty-path: 'apps/server'
- name: Install Playwright browsers - name: Install Playwright browsers
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
@@ -58,7 +58,7 @@ jobs:
env: env:
CI: true CI: true
VITE_SERVER_URL: http://localhost:3008 VITE_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: "true" VITE_SKIP_SETUP: 'true'
- name: Upload Playwright report - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -3,7 +3,7 @@ name: PR Build Check
on: on:
pull_request: pull_request:
branches: branches:
- "*" - '*'
push: push:
branches: branches:
- main - main
@@ -20,7 +20,7 @@ jobs:
- name: Setup project - name: Setup project
uses: ./.github/actions/setup-project uses: ./.github/actions/setup-project
with: with:
check-lockfile: "true" check-lockfile: 'true'
- name: Run build:electron (dir only - faster CI) - name: Run build:electron (dir only - faster CI)
run: npm run build:electron:dir run: npm run build:electron:dir

View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import './.next/dev/types/routes.d.ts';
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -4,7 +4,7 @@
* Supports API key authentication via header or environment variable. * Supports API key authentication via header or environment variable.
*/ */
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from 'express';
// API key from environment (optional - if not set, auth is disabled) // API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY; const API_KEY = process.env.AUTOMAKER_API_KEY;
@@ -23,12 +23,12 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
} }
// Check for API key in header // Check for API key in header
const providedKey = req.headers["x-api-key"] as string | undefined; const providedKey = req.headers['x-api-key'] as string | undefined;
if (!providedKey) { if (!providedKey) {
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: "Authentication required. Provide X-API-Key header.", error: 'Authentication required. Provide X-API-Key header.',
}); });
return; return;
} }
@@ -36,7 +36,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
if (providedKey !== API_KEY) { if (providedKey !== API_KEY) {
res.status(403).json({ res.status(403).json({
success: false, success: false,
error: "Invalid API key.", error: 'Invalid API key.',
}); });
return; return;
} }
@@ -57,6 +57,6 @@ export function isAuthEnabled(): boolean {
export function getAuthStatus(): { enabled: boolean; method: string } { export function getAuthStatus(): { enabled: boolean; method: string } {
return { return {
enabled: !!API_KEY, enabled: !!API_KEY,
method: API_KEY ? "api_key" : "none", method: API_KEY ? 'api_key' : 'none',
}; };
} }

View File

@@ -9,7 +9,7 @@ import type {
InstallationStatus, InstallationStatus,
ValidationResult, ValidationResult,
ModelDefinition, ModelDefinition,
} from "./types.js"; } from './types.js';
/** /**
* Base provider class that all provider implementations must extend * Base provider class that all provider implementations must extend
@@ -33,9 +33,7 @@ export abstract class BaseProvider {
* @param options Execution options * @param options Execution options
* @returns AsyncGenerator yielding provider messages * @returns AsyncGenerator yielding provider messages
*/ */
abstract executeQuery( abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
options: ExecuteOptions
): AsyncGenerator<ProviderMessage>;
/** /**
* Detect if the provider is installed and configured * Detect if the provider is installed and configured
@@ -59,7 +57,7 @@ export abstract class BaseProvider {
// Base validation (can be overridden) // Base validation (can be overridden)
if (!this.config) { if (!this.config) {
errors.push("Provider config is missing"); errors.push('Provider config is missing');
} }
return { return {
@@ -76,7 +74,7 @@ export abstract class BaseProvider {
*/ */
supportsFeature(feature: string): boolean { supportsFeature(feature: string): boolean {
// Default implementation - override in subclasses // Default implementation - override in subclasses
const commonFeatures = ["tools", "text"]; const commonFeatures = ['tools', 'text'];
return commonFeatures.includes(feature); return commonFeatures.includes(feature);
} }

View File

@@ -5,26 +5,24 @@
* with the provider architecture. * with the provider architecture.
*/ */
import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from "./base-provider.js"; import { BaseProvider } from './base-provider.js';
import type { import type {
ExecuteOptions, ExecuteOptions,
ProviderMessage, ProviderMessage,
InstallationStatus, InstallationStatus,
ModelDefinition, ModelDefinition,
} from "./types.js"; } from './types.js';
export class ClaudeProvider extends BaseProvider { export class ClaudeProvider extends BaseProvider {
getName(): string { getName(): string {
return "claude"; return 'claude';
} }
/** /**
* Execute a query using Claude Agent SDK * Execute a query using Claude Agent SDK
*/ */
async *executeQuery( async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
options: ExecuteOptions
): AsyncGenerator<ProviderMessage> {
const { const {
prompt, prompt,
model, model,
@@ -38,16 +36,7 @@ export class ClaudeProvider extends BaseProvider {
} = options; } = options;
// Build Claude SDK options // Build Claude SDK options
const defaultTools = [ const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
];
const toolsToUse = allowedTools || defaultTools; const toolsToUse = allowedTools || defaultTools;
const sdkOptions: Options = { const sdkOptions: Options = {
@@ -56,7 +45,7 @@ export class ClaudeProvider extends BaseProvider {
maxTurns, maxTurns,
cwd, cwd,
allowedTools: toolsToUse, allowedTools: toolsToUse,
permissionMode: "acceptEdits", permissionMode: 'acceptEdits',
sandbox: { sandbox: {
enabled: true, enabled: true,
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
@@ -75,10 +64,10 @@ export class ClaudeProvider extends BaseProvider {
// Multi-part prompt (with images) // Multi-part prompt (with images)
promptPayload = (async function* () { promptPayload = (async function* () {
const multiPartPrompt = { const multiPartPrompt = {
type: "user" as const, type: 'user' as const,
session_id: "", session_id: '',
message: { message: {
role: "user" as const, role: 'user' as const,
content: prompt, content: prompt,
}, },
parent_tool_use_id: null, parent_tool_use_id: null,
@@ -99,10 +88,7 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage; yield msg as ProviderMessage;
} }
} catch (error) { } catch (error) {
console.error( console.error('[ClaudeProvider] executeQuery() error during execution:', error);
"[ClaudeProvider] executeQuery() error during execution:",
error
);
throw error; throw error;
} }
} }
@@ -116,7 +102,7 @@ export class ClaudeProvider extends BaseProvider {
const status: InstallationStatus = { const status: InstallationStatus = {
installed: true, installed: true,
method: "sdk", method: 'sdk',
hasApiKey, hasApiKey,
authenticated: hasApiKey, authenticated: hasApiKey,
}; };
@@ -130,53 +116,53 @@ export class ClaudeProvider extends BaseProvider {
getAvailableModels(): ModelDefinition[] { getAvailableModels(): ModelDefinition[] {
const models = [ const models = [
{ {
id: "claude-opus-4-5-20251101", id: 'claude-opus-4-5-20251101',
name: "Claude Opus 4.5", name: 'Claude Opus 4.5',
modelString: "claude-opus-4-5-20251101", modelString: 'claude-opus-4-5-20251101',
provider: "anthropic", provider: 'anthropic',
description: "Most capable Claude model", description: 'Most capable Claude model',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 16000, maxOutputTokens: 16000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "premium" as const, tier: 'premium' as const,
default: true, default: true,
}, },
{ {
id: "claude-sonnet-4-20250514", id: 'claude-sonnet-4-20250514',
name: "Claude Sonnet 4", name: 'Claude Sonnet 4',
modelString: "claude-sonnet-4-20250514", modelString: 'claude-sonnet-4-20250514',
provider: "anthropic", provider: 'anthropic',
description: "Balanced performance and cost", description: 'Balanced performance and cost',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 16000, maxOutputTokens: 16000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "standard" as const, tier: 'standard' as const,
}, },
{ {
id: "claude-3-5-sonnet-20241022", id: 'claude-3-5-sonnet-20241022',
name: "Claude 3.5 Sonnet", name: 'Claude 3.5 Sonnet',
modelString: "claude-3-5-sonnet-20241022", modelString: 'claude-3-5-sonnet-20241022',
provider: "anthropic", provider: 'anthropic',
description: "Fast and capable", description: 'Fast and capable',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 8000, maxOutputTokens: 8000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "standard" as const, tier: 'standard' as const,
}, },
{ {
id: "claude-3-5-haiku-20241022", id: 'claude-3-5-haiku-20241022',
name: "Claude 3.5 Haiku", name: 'Claude 3.5 Haiku',
modelString: "claude-3-5-haiku-20241022", modelString: 'claude-3-5-haiku-20241022',
provider: "anthropic", provider: 'anthropic',
description: "Fastest Claude model", description: 'Fastest Claude model',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 8000, maxOutputTokens: 8000,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
tier: "basic" as const, tier: 'basic' as const,
}, },
] satisfies ModelDefinition[]; ] satisfies ModelDefinition[];
return models; return models;
@@ -186,7 +172,7 @@ export class ClaudeProvider extends BaseProvider {
* Check if the provider supports a specific feature * Check if the provider supports a specific feature
*/ */
supportsFeature(feature: string): boolean { supportsFeature(feature: string): boolean {
const supportedFeatures = ["tools", "text", "vision", "thinking"]; const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
return supportedFeatures.includes(feature); return supportedFeatures.includes(feature);
} }
} }

View File

@@ -6,9 +6,9 @@
* new providers (Cursor, OpenCode, etc.) trivial - just add one line. * new providers (Cursor, OpenCode, etc.) trivial - just add one line.
*/ */
import { BaseProvider } from "./base-provider.js"; import { BaseProvider } from './base-provider.js';
import { ClaudeProvider } from "./claude-provider.js"; import { ClaudeProvider } from './claude-provider.js';
import type { InstallationStatus } from "./types.js"; import type { InstallationStatus } from './types.js';
export class ProviderFactory { export class ProviderFactory {
/** /**
@@ -21,10 +21,7 @@ export class ProviderFactory {
const lowerModel = modelId.toLowerCase(); const lowerModel = modelId.toLowerCase();
// Claude models (claude-*, opus, sonnet, haiku) // Claude models (claude-*, opus, sonnet, haiku)
if ( if (lowerModel.startsWith('claude-') || ['haiku', 'sonnet', 'opus'].includes(lowerModel)) {
lowerModel.startsWith("claude-") ||
["haiku", "sonnet", "opus"].includes(lowerModel)
) {
return new ClaudeProvider(); return new ClaudeProvider();
} }
@@ -37,9 +34,7 @@ export class ProviderFactory {
// } // }
// Default to Claude for unknown models // Default to Claude for unknown models
console.warn( console.warn(`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`);
`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`
);
return new ClaudeProvider(); return new ClaudeProvider();
} }
@@ -58,9 +53,7 @@ export class ProviderFactory {
* *
* @returns Map of provider name to installation status * @returns Map of provider name to installation status
*/ */
static async checkAllProviders(): Promise< static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
Record<string, InstallationStatus>
> {
const providers = this.getAllProviders(); const providers = this.getAllProviders();
const statuses: Record<string, InstallationStatus> = {}; const statuses: Record<string, InstallationStatus> = {};
@@ -83,8 +76,8 @@ export class ProviderFactory {
const lowerName = name.toLowerCase(); const lowerName = name.toLowerCase();
switch (lowerName) { switch (lowerName) {
case "claude": case 'claude':
case "anthropic": case 'anthropic':
return new ClaudeProvider(); return new ClaudeProvider();
// Future providers: // Future providers:

View File

@@ -15,7 +15,7 @@ export interface ProviderConfig {
* Message in conversation history * Message in conversation history
*/ */
export interface ConversationMessage { export interface ConversationMessage {
role: "user" | "assistant"; role: 'user' | 'assistant';
content: string | Array<{ type: string; text?: string; source?: object }>; content: string | Array<{ type: string; text?: string; source?: object }>;
} }
@@ -39,7 +39,7 @@ export interface ExecuteOptions {
* Content block in a provider message (matches Claude SDK format) * Content block in a provider message (matches Claude SDK format)
*/ */
export interface ContentBlock { export interface ContentBlock {
type: "text" | "tool_use" | "thinking" | "tool_result"; type: 'text' | 'tool_use' | 'thinking' | 'tool_result';
text?: string; text?: string;
thinking?: string; thinking?: string;
name?: string; name?: string;
@@ -52,11 +52,11 @@ export interface ContentBlock {
* Message returned by a provider (matches Claude SDK streaming format) * Message returned by a provider (matches Claude SDK streaming format)
*/ */
export interface ProviderMessage { export interface ProviderMessage {
type: "assistant" | "user" | "error" | "result"; type: 'assistant' | 'user' | 'error' | 'result';
subtype?: "success" | "error"; subtype?: 'success' | 'error';
session_id?: string; session_id?: string;
message?: { message?: {
role: "user" | "assistant"; role: 'user' | 'assistant';
content: ContentBlock[]; content: ContentBlock[];
}; };
result?: string; result?: string;
@@ -71,7 +71,7 @@ export interface InstallationStatus {
installed: boolean; installed: boolean;
path?: string; path?: string;
version?: string; version?: string;
method?: "cli" | "npm" | "brew" | "sdk"; method?: 'cli' | 'npm' | 'brew' | 'sdk';
hasApiKey?: boolean; hasApiKey?: boolean;
authenticated?: boolean; authenticated?: boolean;
error?: string; error?: string;
@@ -99,6 +99,6 @@ export interface ModelDefinition {
maxOutputTokens?: number; maxOutputTokens?: number;
supportsVision?: boolean; supportsVision?: boolean;
supportsTools?: boolean; supportsTools?: boolean;
tier?: "basic" | "standard" | "premium"; tier?: 'basic' | 'standard' | 'premium';
default?: boolean; default?: boolean;
} }

View File

@@ -2,9 +2,9 @@
* POST /clear endpoint - Clear conversation * POST /clear endpoint - Clear conversation
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createClearHandler(agentService: AgentService) { export function createClearHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -12,16 +12,14 @@ export function createClearHandler(agentService: AgentService) {
const { sessionId } = req.body as { sessionId: string }; const { sessionId } = req.body as { sessionId: string };
if (!sessionId) { if (!sessionId) {
res res.status(400).json({ success: false, error: 'sessionId is required' });
.status(400)
.json({ success: false, error: "sessionId is required" });
return; return;
} }
const result = await agentService.clearSession(sessionId); const result = await agentService.clearSession(sessionId);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
logError(error, "Clear session failed"); logError(error, 'Clear session failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /history endpoint - Get conversation history * POST /history endpoint - Get conversation history
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createHistoryHandler(agentService: AgentService) { export function createHistoryHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -12,16 +12,14 @@ export function createHistoryHandler(agentService: AgentService) {
const { sessionId } = req.body as { sessionId: string }; const { sessionId } = req.body as { sessionId: string };
if (!sessionId) { if (!sessionId) {
res res.status(400).json({ success: false, error: 'sessionId is required' });
.status(400)
.json({ success: false, error: "sessionId is required" });
return; return;
} }
const result = agentService.getHistory(sessionId); const result = agentService.getHistory(sessionId);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
logError(error, "Get history failed"); logError(error, 'Get history failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /model endpoint - Set session model * POST /model endpoint - Set session model
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createModelHandler(agentService: AgentService) { export function createModelHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,16 +15,14 @@ export function createModelHandler(agentService: AgentService) {
}; };
if (!sessionId || !model) { if (!sessionId || !model) {
res res.status(400).json({ success: false, error: 'sessionId and model are required' });
.status(400)
.json({ success: false, error: "sessionId and model are required" });
return; return;
} }
const result = await agentService.setSessionModel(sessionId, model); const result = await agentService.setSessionModel(sessionId, model);
res.json({ success: result }); res.json({ success: result });
} catch (error) { } catch (error) {
logError(error, "Set session model failed"); logError(error, 'Set session model failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /stop endpoint - Stop execution * POST /stop endpoint - Stop execution
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createStopHandler(agentService: AgentService) { export function createStopHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -12,16 +12,14 @@ export function createStopHandler(agentService: AgentService) {
const { sessionId } = req.body as { sessionId: string }; const { sessionId } = req.body as { sessionId: string };
if (!sessionId) { if (!sessionId) {
res res.status(400).json({ success: false, error: 'sessionId is required' });
.status(400)
.json({ success: false, error: "sessionId is required" });
return; return;
} }
const result = await agentService.stopExecution(sessionId); const result = await agentService.stopExecution(sessionId);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
logError(error, "Stop execution failed"); logError(error, 'Stop execution failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,25 +2,22 @@
* Spec Regeneration routes - HTTP API for AI-powered spec generation * Spec Regeneration routes - HTTP API for AI-powered spec generation
*/ */
import { Router } from "express"; import { Router } from 'express';
import type { EventEmitter } from "../../lib/events.js"; import type { EventEmitter } from '../../lib/events.js';
import { createCreateHandler } from "./routes/create.js"; import { createCreateHandler } from './routes/create.js';
import { createGenerateHandler } from "./routes/generate.js"; import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from "./routes/generate-features.js"; import { createGenerateFeaturesHandler } from './routes/generate-features.js';
import { createStopHandler } from "./routes/stop.js"; import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from "./routes/status.js"; import { createStatusHandler } from './routes/status.js';
export function createSpecRegenerationRoutes(events: EventEmitter): Router { export function createSpecRegenerationRoutes(events: EventEmitter): Router {
const router = Router(); const router = Router();
router.post("/create", createCreateHandler(events)); router.post('/create', createCreateHandler(events));
router.post("/generate", createGenerateHandler(events)); router.post('/generate', createGenerateHandler(events));
router.post("/generate-features", createGenerateFeaturesHandler(events)); router.post('/generate-features', createGenerateFeaturesHandler(events));
router.post("/stop", createStopHandler()); router.post('/stop', createStopHandler());
router.get("/status", createStatusHandler()); router.get('/status', createStatusHandler());
return router; return router;
} }

View File

@@ -2,8 +2,8 @@
* GET /status endpoint - Get generation status * GET /status endpoint - Get generation status
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, getErrorMessage } from "../common.js"; import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
export function createStatusHandler() { export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {

View File

@@ -2,12 +2,8 @@
* POST /stop endpoint - Stop generation * POST /stop endpoint - Stop generation
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
getSpecRegenerationStatus,
setRunningState,
getErrorMessage,
} from "../common.js";
export function createStopHandler() { export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {

View File

@@ -2,9 +2,9 @@
* POST /commit-feature endpoint - Commit feature changes * POST /commit-feature endpoint - Commit feature changes
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import type { AutoModeService } from "../../../services/auto-mode-service.js"; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createCommitFeatureHandler(autoModeService: AutoModeService) { export function createCommitFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -16,23 +16,17 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId are required',
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
const commitHash = await autoModeService.commitFeature( const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath);
projectPath,
featureId,
worktreePath
);
res.json({ success: true, commitHash }); res.json({ success: true, commitHash });
} catch (error) { } catch (error) {
logError(error, "Commit feature failed"); logError(error, 'Commit feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /context-exists endpoint - Check if context exists for a feature * POST /context-exists endpoint - Check if context exists for a feature
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import type { AutoModeService } from "../../../services/auto-mode-service.js"; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createContextExistsHandler(autoModeService: AutoModeService) { export function createContextExistsHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,22 +15,17 @@ export function createContextExistsHandler(autoModeService: AutoModeService) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId are required',
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
const exists = await autoModeService.contextExists( const exists = await autoModeService.contextExists(projectPath, featureId);
projectPath,
featureId
);
res.json({ success: true, exists }); res.json({ success: true, exists });
} catch (error) { } catch (error) {
logError(error, "Check context exists failed"); logError(error, 'Check context exists failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /status endpoint - Get auto mode status * POST /status endpoint - Get auto mode status
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import type { AutoModeService } from "../../../services/auto-mode-service.js"; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) { export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,7 +15,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
...status, ...status,
}); });
} catch (error) { } catch (error) {
logError(error, "Get status failed"); logError(error, 'Get status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /stop-feature endpoint - Stop a specific feature * POST /stop-feature endpoint - Stop a specific feature
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import type { AutoModeService } from "../../../services/auto-mode-service.js"; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createStopFeatureHandler(autoModeService: AutoModeService) { export function createStopFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -12,16 +12,14 @@ export function createStopFeatureHandler(autoModeService: AutoModeService) {
const { featureId } = req.body as { featureId: string }; const { featureId } = req.body as { featureId: string };
if (!featureId) { if (!featureId) {
res res.status(400).json({ success: false, error: 'featureId is required' });
.status(400)
.json({ success: false, error: "featureId is required" });
return; return;
} }
const stopped = await autoModeService.stopFeature(featureId); const stopped = await autoModeService.stopFeature(featureId);
res.json({ success: true, stopped }); res.json({ success: true, stopped });
} catch (error) { } catch (error) {
logError(error, "Stop feature failed"); logError(error, 'Stop feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /verify-feature endpoint - Verify a feature * POST /verify-feature endpoint - Verify a feature
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import type { AutoModeService } from "../../../services/auto-mode-service.js"; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createVerifyFeatureHandler(autoModeService: AutoModeService) { export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,22 +15,17 @@ export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId are required',
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
const passes = await autoModeService.verifyFeature( const passes = await autoModeService.verifyFeature(projectPath, featureId);
projectPath,
featureId
);
res.json({ success: true, passes }); res.json({ success: true, passes });
} catch (error) { } catch (error) {
logError(error, "Verify feature failed"); logError(error, 'Verify feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -5,8 +5,8 @@
* with different enhancement modes (improve, expand, simplify, etc.) * with different enhancement modes (improve, expand, simplify, etc.)
*/ */
import { Router } from "express"; import { Router } from 'express';
import { createEnhanceHandler } from "./routes/enhance.js"; import { createEnhanceHandler } from './routes/enhance.js';
/** /**
* Create the enhance-prompt router * Create the enhance-prompt router
@@ -16,7 +16,7 @@ import { createEnhanceHandler } from "./routes/enhance.js";
export function createEnhancePromptRoutes(): Router { export function createEnhancePromptRoutes(): Router {
const router = Router(); const router = Router();
router.post("/", createEnhanceHandler()); router.post('/', createEnhanceHandler());
return router; return router;
} }

View File

@@ -2,9 +2,9 @@
* POST /agent-output endpoint - Get agent output for a feature * POST /agent-output endpoint - Get agent output for a feature
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { FeatureLoader } from "../../../services/feature-loader.js"; import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createAgentOutputHandler(featureLoader: FeatureLoader) { export function createAgentOutputHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,22 +15,17 @@ export function createAgentOutputHandler(featureLoader: FeatureLoader) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId are required',
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
const content = await featureLoader.getAgentOutput( const content = await featureLoader.getAgentOutput(projectPath, featureId);
projectPath,
featureId
);
res.json({ success: true, content }); res.json({ success: true, content });
} catch (error) { } catch (error) {
logError(error, "Get agent output failed"); logError(error, 'Get agent output failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /delete endpoint - Delete a feature * POST /delete endpoint - Delete a feature
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { FeatureLoader } from "../../../services/feature-loader.js"; import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler(featureLoader: FeatureLoader) { export function createDeleteHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,19 +15,17 @@ export function createDeleteHandler(featureLoader: FeatureLoader) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId are required',
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
const success = await featureLoader.delete(projectPath, featureId); const success = await featureLoader.delete(projectPath, featureId);
res.json({ success }); res.json({ success });
} catch (error) { } catch (error) {
logError(error, "Delete feature failed"); logError(error, 'Delete feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /get endpoint - Get a single feature * POST /get endpoint - Get a single feature
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { FeatureLoader } from "../../../services/feature-loader.js"; import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createGetHandler(featureLoader: FeatureLoader) { export function createGetHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -15,24 +15,22 @@ export function createGetHandler(featureLoader: FeatureLoader) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId are required',
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
const feature = await featureLoader.get(projectPath, featureId); const feature = await featureLoader.get(projectPath, featureId);
if (!feature) { if (!feature) {
res.status(404).json({ success: false, error: "Feature not found" }); res.status(404).json({ success: false, error: 'Feature not found' });
return; return;
} }
res.json({ success: true, feature }); res.json({ success: true, feature });
} catch (error) { } catch (error) {
logError(error, "Get feature failed"); logError(error, 'Get feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -3,40 +3,40 @@
* Provides REST API equivalents for Electron IPC file operations * Provides REST API equivalents for Electron IPC file operations
*/ */
import { Router } from "express"; import { Router } from 'express';
import type { EventEmitter } from "../../lib/events.js"; import type { EventEmitter } from '../../lib/events.js';
import { createReadHandler } from "./routes/read.js"; import { createReadHandler } from './routes/read.js';
import { createWriteHandler } from "./routes/write.js"; import { createWriteHandler } from './routes/write.js';
import { createMkdirHandler } from "./routes/mkdir.js"; import { createMkdirHandler } from './routes/mkdir.js';
import { createReaddirHandler } from "./routes/readdir.js"; import { createReaddirHandler } from './routes/readdir.js';
import { createExistsHandler } from "./routes/exists.js"; import { createExistsHandler } from './routes/exists.js';
import { createStatHandler } from "./routes/stat.js"; import { createStatHandler } from './routes/stat.js';
import { createDeleteHandler } from "./routes/delete.js"; import { createDeleteHandler } from './routes/delete.js';
import { createValidatePathHandler } from "./routes/validate-path.js"; import { createValidatePathHandler } from './routes/validate-path.js';
import { createResolveDirectoryHandler } from "./routes/resolve-directory.js"; import { createResolveDirectoryHandler } from './routes/resolve-directory.js';
import { createSaveImageHandler } from "./routes/save-image.js"; import { createSaveImageHandler } from './routes/save-image.js';
import { createBrowseHandler } from "./routes/browse.js"; import { createBrowseHandler } from './routes/browse.js';
import { createImageHandler } from "./routes/image.js"; import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from "./routes/save-board-background.js"; import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from "./routes/delete-board-background.js"; import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
export function createFsRoutes(_events: EventEmitter): Router { export function createFsRoutes(_events: EventEmitter): Router {
const router = Router(); const router = Router();
router.post("/read", createReadHandler()); router.post('/read', createReadHandler());
router.post("/write", createWriteHandler()); router.post('/write', createWriteHandler());
router.post("/mkdir", createMkdirHandler()); router.post('/mkdir', createMkdirHandler());
router.post("/readdir", createReaddirHandler()); router.post('/readdir', createReaddirHandler());
router.post("/exists", createExistsHandler()); router.post('/exists', createExistsHandler());
router.post("/stat", createStatHandler()); router.post('/stat', createStatHandler());
router.post("/delete", createDeleteHandler()); router.post('/delete', createDeleteHandler());
router.post("/validate-path", createValidatePathHandler()); router.post('/validate-path', createValidatePathHandler());
router.post("/resolve-directory", createResolveDirectoryHandler()); router.post('/resolve-directory', createResolveDirectoryHandler());
router.post("/save-image", createSaveImageHandler()); router.post('/save-image', createSaveImageHandler());
router.post("/browse", createBrowseHandler()); router.post('/browse', createBrowseHandler());
router.get("/image", createImageHandler()); router.get('/image', createImageHandler());
router.post("/save-board-background", createSaveBoardBackgroundHandler()); router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post("/delete-board-background", createDeleteBoardBackgroundHandler()); router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
return router; return router;
} }

View File

@@ -2,9 +2,9 @@
* POST /diffs endpoint - Get diffs for the main project * POST /diffs endpoint - Get diffs for the main project
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
import { getGitRepositoryDiffs } from "../../common.js"; import { getGitRepositoryDiffs } from '../../common.js';
export function createDiffsHandler() { export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -12,7 +12,7 @@ export function createDiffsHandler() {
const { projectPath } = req.body as { projectPath: string }; const { projectPath } = req.body as { projectPath: string };
if (!projectPath) { if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath required" }); res.status(400).json({ success: false, error: 'projectPath required' });
return; return;
} }
@@ -25,11 +25,11 @@ export function createDiffsHandler() {
hasChanges: result.hasChanges, hasChanges: result.hasChanges,
}); });
} catch (innerError) { } catch (innerError) {
logError(innerError, "Git diff failed"); logError(innerError, 'Git diff failed');
res.json({ success: true, diff: "", files: [], hasChanges: false }); res.json({ success: true, diff: '', files: [], hasChanges: false });
} }
} catch (error) { } catch (error) {
logError(error, "Get diffs failed"); logError(error, 'Get diffs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,11 +2,11 @@
* POST /file-diff endpoint - Get diff for a specific file * POST /file-diff endpoint - Get diff for a specific file
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
import { generateSyntheticDiffForNewFile } from "../../common.js"; import { generateSyntheticDiffForNewFile } from '../../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -19,20 +19,17 @@ export function createFileDiffHandler() {
}; };
if (!projectPath || !filePath) { if (!projectPath || !filePath) {
res res.status(400).json({ success: false, error: 'projectPath and filePath required' });
.status(400)
.json({ success: false, error: "projectPath and filePath required" });
return; return;
} }
try { try {
// First check if the file is untracked // First check if the file is untracked
const { stdout: status } = await execAsync( const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, {
`git status --porcelain -- "${filePath}"`, cwd: projectPath,
{ cwd: projectPath } });
);
const isUntracked = status.trim().startsWith("??"); const isUntracked = status.trim().startsWith('??');
let diff: string; let diff: string;
if (isUntracked) { if (isUntracked) {
@@ -40,23 +37,20 @@ export function createFileDiffHandler() {
diff = await generateSyntheticDiffForNewFile(projectPath, filePath); diff = await generateSyntheticDiffForNewFile(projectPath, filePath);
} else { } else {
// Use regular git diff for tracked files // Use regular git diff for tracked files
const result = await execAsync( const result = await execAsync(`git diff HEAD -- "${filePath}"`, {
`git diff HEAD -- "${filePath}"`, cwd: projectPath,
{ maxBuffer: 10 * 1024 * 1024,
cwd: projectPath, });
maxBuffer: 10 * 1024 * 1024,
}
);
diff = result.stdout; diff = result.stdout;
} }
res.json({ success: true, diff, filePath }); res.json({ success: true, diff, filePath });
} catch (innerError) { } catch (innerError) {
logError(innerError, "Git file diff failed"); logError(innerError, 'Git file diff failed');
res.json({ success: true, diff: "", filePath }); res.json({ success: true, diff: '', filePath });
} }
} catch (error) { } catch (error) {
logError(error, "Get file diff failed"); logError(error, 'Get file diff failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,15 +2,15 @@
* Health check routes * Health check routes
*/ */
import { Router } from "express"; import { Router } from 'express';
import { createIndexHandler } from "./routes/index.js"; import { createIndexHandler } from './routes/index.js';
import { createDetailedHandler } from "./routes/detailed.js"; import { createDetailedHandler } from './routes/detailed.js';
export function createHealthRoutes(): Router { export function createHealthRoutes(): Router {
const router = Router(); const router = Router();
router.get("/", createIndexHandler()); router.get('/', createIndexHandler());
router.get("/detailed", createDetailedHandler()); router.get('/detailed', createDetailedHandler());
return router; return router;
} }

View File

@@ -2,18 +2,18 @@
* GET /detailed endpoint - Detailed health check * GET /detailed endpoint - Detailed health check
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getAuthStatus } from "../../../lib/auth.js"; import { getAuthStatus } from '../../../lib/auth.js';
export function createDetailedHandler() { export function createDetailedHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {
res.json({ res.json({
status: "ok", status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "0.1.0", version: process.env.npm_package_version || '0.1.0',
uptime: process.uptime(), uptime: process.uptime(),
memory: process.memoryUsage(), memory: process.memoryUsage(),
dataDir: process.env.DATA_DIR || "./data", dataDir: process.env.DATA_DIR || './data',
auth: getAuthStatus(), auth: getAuthStatus(),
env: { env: {
nodeVersion: process.version, nodeVersion: process.version,

View File

@@ -2,14 +2,14 @@
* GET / endpoint - Basic health check * GET / endpoint - Basic health check
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
export function createIndexHandler() { export function createIndexHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {
res.json({ res.json({
status: "ok", status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "0.1.0", version: process.env.npm_package_version || '0.1.0',
}); });
}; };
} }

View File

@@ -2,15 +2,15 @@
* Models routes - HTTP API for model providers and availability * Models routes - HTTP API for model providers and availability
*/ */
import { Router } from "express"; import { Router } from 'express';
import { createAvailableHandler } from "./routes/available.js"; import { createAvailableHandler } from './routes/available.js';
import { createProvidersHandler } from "./routes/providers.js"; import { createProvidersHandler } from './routes/providers.js';
export function createModelsRoutes(): Router { export function createModelsRoutes(): Router {
const router = Router(); const router = Router();
router.get("/available", createAvailableHandler()); router.get('/available', createAvailableHandler());
router.get("/providers", createProvidersHandler()); router.get('/providers', createProvidersHandler());
return router; return router;
} }

View File

@@ -2,8 +2,8 @@
* GET /available endpoint - Get available models * GET /available endpoint - Get available models
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
interface ModelDefinition { interface ModelDefinition {
id: string; id: string;
@@ -20,36 +20,36 @@ export function createAvailableHandler() {
try { try {
const models: ModelDefinition[] = [ const models: ModelDefinition[] = [
{ {
id: "claude-opus-4-5-20251101", id: 'claude-opus-4-5-20251101',
name: "Claude Opus 4.5", name: 'Claude Opus 4.5',
provider: "anthropic", provider: 'anthropic',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 16384, maxOutputTokens: 16384,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
}, },
{ {
id: "claude-sonnet-4-20250514", id: 'claude-sonnet-4-20250514',
name: "Claude Sonnet 4", name: 'Claude Sonnet 4',
provider: "anthropic", provider: 'anthropic',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 16384, maxOutputTokens: 16384,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
}, },
{ {
id: "claude-3-5-sonnet-20241022", id: 'claude-3-5-sonnet-20241022',
name: "Claude 3.5 Sonnet", name: 'Claude 3.5 Sonnet',
provider: "anthropic", provider: 'anthropic',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 8192, maxOutputTokens: 8192,
supportsVision: true, supportsVision: true,
supportsTools: true, supportsTools: true,
}, },
{ {
id: "claude-3-5-haiku-20241022", id: 'claude-3-5-haiku-20241022',
name: "Claude 3.5 Haiku", name: 'Claude 3.5 Haiku',
provider: "anthropic", provider: 'anthropic',
contextWindow: 200000, contextWindow: 200000,
maxOutputTokens: 8192, maxOutputTokens: 8192,
supportsVision: true, supportsVision: true,
@@ -59,7 +59,7 @@ export function createAvailableHandler() {
res.json({ success: true, models }); res.json({ success: true, models });
} catch (error) { } catch (error) {
logError(error, "Get available models failed"); logError(error, 'Get available models failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,16 +2,14 @@
* Running Agents routes - HTTP API for tracking active agent executions * Running Agents routes - HTTP API for tracking active agent executions
*/ */
import { Router } from "express"; import { Router } from 'express';
import type { AutoModeService } from "../../services/auto-mode-service.js"; import type { AutoModeService } from '../../services/auto-mode-service.js';
import { createIndexHandler } from "./routes/index.js"; import { createIndexHandler } from './routes/index.js';
export function createRunningAgentsRoutes( export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
autoModeService: AutoModeService
): Router {
const router = Router(); const router = Router();
router.get("/", createIndexHandler(autoModeService)); router.get('/', createIndexHandler(autoModeService));
return router; return router;
} }

View File

@@ -2,9 +2,9 @@
* GET / endpoint - Get all running agents * GET / endpoint - Get all running agents
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import type { AutoModeService } from "../../../services/auto-mode-service.js"; import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createIndexHandler(autoModeService: AutoModeService) { export function createIndexHandler(autoModeService: AutoModeService) {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -18,7 +18,7 @@ export function createIndexHandler(autoModeService: AutoModeService) {
totalCount: runningAgents.length, totalCount: runningAgents.length,
}); });
} catch (error) { } catch (error) {
logError(error, "Get running agents failed"); logError(error, 'Get running agents failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,24 +2,24 @@
* Sessions routes - HTTP API for session management * Sessions routes - HTTP API for session management
*/ */
import { Router } from "express"; import { Router } from 'express';
import { AgentService } from "../../services/agent-service.js"; import { AgentService } from '../../services/agent-service.js';
import { createIndexHandler } from "./routes/index.js"; import { createIndexHandler } from './routes/index.js';
import { createCreateHandler } from "./routes/create.js"; import { createCreateHandler } from './routes/create.js';
import { createUpdateHandler } from "./routes/update.js"; import { createUpdateHandler } from './routes/update.js';
import { createArchiveHandler } from "./routes/archive.js"; import { createArchiveHandler } from './routes/archive.js';
import { createUnarchiveHandler } from "./routes/unarchive.js"; import { createUnarchiveHandler } from './routes/unarchive.js';
import { createDeleteHandler } from "./routes/delete.js"; import { createDeleteHandler } from './routes/delete.js';
export function createSessionsRoutes(agentService: AgentService): Router { export function createSessionsRoutes(agentService: AgentService): Router {
const router = Router(); const router = Router();
router.get("/", createIndexHandler(agentService)); router.get('/', createIndexHandler(agentService));
router.post("/", createCreateHandler(agentService)); router.post('/', createCreateHandler(agentService));
router.put("/:sessionId", createUpdateHandler(agentService)); router.put('/:sessionId', createUpdateHandler(agentService));
router.post("/:sessionId/archive", createArchiveHandler(agentService)); router.post('/:sessionId/archive', createArchiveHandler(agentService));
router.post("/:sessionId/unarchive", createUnarchiveHandler(agentService)); router.post('/:sessionId/unarchive', createUnarchiveHandler(agentService));
router.delete("/:sessionId", createDeleteHandler(agentService)); router.delete('/:sessionId', createDeleteHandler(agentService));
return router; return router;
} }

View File

@@ -2,9 +2,9 @@
* POST /:sessionId/archive endpoint - Archive a session * POST /:sessionId/archive endpoint - Archive a session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createArchiveHandler(agentService: AgentService) { export function createArchiveHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -13,13 +13,13 @@ export function createArchiveHandler(agentService: AgentService) {
const success = await agentService.archiveSession(sessionId); const success = await agentService.archiveSession(sessionId);
if (!success) { if (!success) {
res.status(404).json({ success: false, error: "Session not found" }); res.status(404).json({ success: false, error: 'Session not found' });
return; return;
} }
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
logError(error, "Archive session failed"); logError(error, 'Archive session failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST / endpoint - Create a new session * POST / endpoint - Create a new session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createCreateHandler(agentService: AgentService) { export function createCreateHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -17,19 +17,14 @@ export function createCreateHandler(agentService: AgentService) {
}; };
if (!name) { if (!name) {
res.status(400).json({ success: false, error: "name is required" }); res.status(400).json({ success: false, error: 'name is required' });
return; return;
} }
const session = await agentService.createSession( const session = await agentService.createSession(name, projectPath, workingDirectory, model);
name,
projectPath,
workingDirectory,
model
);
res.json({ success: true, session }); res.json({ success: true, session });
} catch (error) { } catch (error) {
logError(error, "Create session failed"); logError(error, 'Create session failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* DELETE /:sessionId endpoint - Delete a session * DELETE /:sessionId endpoint - Delete a session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler(agentService: AgentService) { export function createDeleteHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -13,13 +13,13 @@ export function createDeleteHandler(agentService: AgentService) {
const success = await agentService.deleteSession(sessionId); const success = await agentService.deleteSession(sessionId);
if (!success) { if (!success) {
res.status(404).json({ success: false, error: "Session not found" }); res.status(404).json({ success: false, error: 'Session not found' });
return; return;
} }
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
logError(error, "Delete session failed"); logError(error, 'Delete session failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,14 +2,14 @@
* GET / endpoint - List all sessions * GET / endpoint - List all sessions
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createIndexHandler(agentService: AgentService) { export function createIndexHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const includeArchived = req.query.includeArchived === "true"; const includeArchived = req.query.includeArchived === 'true';
const sessionsRaw = await agentService.listSessions(includeArchived); const sessionsRaw = await agentService.listSessions(includeArchived);
// Transform to match frontend SessionListItem interface // Transform to match frontend SessionListItem interface
@@ -17,7 +17,7 @@ export function createIndexHandler(agentService: AgentService) {
sessionsRaw.map(async (s) => { sessionsRaw.map(async (s) => {
const messages = await agentService.loadSession(s.id); const messages = await agentService.loadSession(s.id);
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
const preview = lastMessage?.content?.slice(0, 100) || ""; const preview = lastMessage?.content?.slice(0, 100) || '';
return { return {
id: s.id, id: s.id,
@@ -36,7 +36,7 @@ export function createIndexHandler(agentService: AgentService) {
res.json({ success: true, sessions }); res.json({ success: true, sessions });
} catch (error) { } catch (error) {
logError(error, "List sessions failed"); logError(error, 'List sessions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* POST /:sessionId/unarchive endpoint - Unarchive a session * POST /:sessionId/unarchive endpoint - Unarchive a session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createUnarchiveHandler(agentService: AgentService) { export function createUnarchiveHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -13,13 +13,13 @@ export function createUnarchiveHandler(agentService: AgentService) {
const success = await agentService.unarchiveSession(sessionId); const success = await agentService.unarchiveSession(sessionId);
if (!success) { if (!success) {
res.status(404).json({ success: false, error: "Session not found" }); res.status(404).json({ success: false, error: 'Session not found' });
return; return;
} }
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
logError(error, "Unarchive session failed"); logError(error, 'Unarchive session failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* PUT /:sessionId endpoint - Update a session * PUT /:sessionId endpoint - Update a session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { AgentService } from "../../../services/agent-service.js"; import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(agentService: AgentService) { export function createUpdateHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -22,13 +22,13 @@ export function createUpdateHandler(agentService: AgentService) {
model, model,
}); });
if (!session) { if (!session) {
res.status(404).json({ success: false, error: "Session not found" }); res.status(404).json({ success: false, error: 'Session not found' });
return; return;
} }
res.json({ success: true, session }); res.json({ success: true, session });
} catch (error) { } catch (error) {
logError(error, "Update session failed"); logError(error, 'Update session failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,36 +2,36 @@
* Business logic for getting Claude CLI status * Business logic for getting Claude CLI status
*/ */
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import os from "os"; import os from 'os';
import path from "path"; import path from 'path';
import fs from "fs/promises"; import fs from 'fs/promises';
import { getApiKey } from "./common.js"; import { getApiKey } from './common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
export async function getClaudeStatus() { export async function getClaudeStatus() {
let installed = false; let installed = false;
let version = ""; let version = '';
let cliPath = ""; let cliPath = '';
let method = "none"; let method = 'none';
const isWindows = process.platform === "win32"; const isWindows = process.platform === 'win32';
// Try to find Claude CLI using platform-specific command // Try to find Claude CLI using platform-specific command
try { try {
// Use 'where' on Windows, 'which' on Unix-like systems // Use 'where' on Windows, 'which' on Unix-like systems
const findCommand = isWindows ? "where claude" : "which claude"; const findCommand = isWindows ? 'where claude' : 'which claude';
const { stdout } = await execAsync(findCommand); const { stdout } = await execAsync(findCommand);
// 'where' on Windows can return multiple paths - take the first one // 'where' on Windows can return multiple paths - take the first one
cliPath = stdout.trim().split(/\r?\n/)[0]; cliPath = stdout.trim().split(/\r?\n/)[0];
installed = true; installed = true;
method = "path"; method = 'path';
// Get version // Get version
try { try {
const { stdout: versionOut } = await execAsync("claude --version"); const { stdout: versionOut } = await execAsync('claude --version');
version = versionOut.trim(); version = versionOut.trim();
} catch { } catch {
// Version command might not be available // Version command might not be available
@@ -40,22 +40,22 @@ export async function getClaudeStatus() {
// Not in PATH, try common locations based on platform // Not in PATH, try common locations based on platform
const commonPaths = isWindows const commonPaths = isWindows
? (() => { ? (() => {
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [ return [
// Windows-specific paths // Windows-specific paths
path.join(os.homedir(), ".local", "bin", "claude.exe"), path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
path.join(appData, "npm", "claude.cmd"), path.join(appData, 'npm', 'claude.cmd'),
path.join(appData, "npm", "claude"), path.join(appData, 'npm', 'claude'),
path.join(appData, ".npm-global", "bin", "claude.cmd"), path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, ".npm-global", "bin", "claude"), path.join(appData, '.npm-global', 'bin', 'claude'),
]; ];
})() })()
: [ : [
// Unix (Linux/macOS) paths // Unix (Linux/macOS) paths
path.join(os.homedir(), ".local", "bin", "claude"), path.join(os.homedir(), '.local', 'bin', 'claude'),
path.join(os.homedir(), ".claude", "local", "claude"), path.join(os.homedir(), '.claude', 'local', 'claude'),
"/usr/local/bin/claude", '/usr/local/bin/claude',
path.join(os.homedir(), ".npm-global", "bin", "claude"), path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
]; ];
for (const p of commonPaths) { for (const p of commonPaths) {
@@ -63,7 +63,7 @@ export async function getClaudeStatus() {
await fs.access(p); await fs.access(p);
cliPath = p; cliPath = p;
installed = true; installed = true;
method = "local"; method = 'local';
// Get version from this path // Get version from this path
try { try {
@@ -84,11 +84,11 @@ export async function getClaudeStatus() {
// apiKeys.anthropic stores direct API keys for pay-per-use // apiKeys.anthropic stores direct API keys for pay-per-use
let auth = { let auth = {
authenticated: false, authenticated: false,
method: "none" as string, method: 'none' as string,
hasCredentialsFile: false, hasCredentialsFile: false,
hasToken: false, hasToken: false,
hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"), hasStoredOAuthToken: !!getApiKey('anthropic_oauth_token'),
hasStoredApiKey: !!getApiKey("anthropic"), hasStoredApiKey: !!getApiKey('anthropic'),
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY, hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
// Additional fields for detailed status // Additional fields for detailed status
oauthTokenValid: false, oauthTokenValid: false,
@@ -97,13 +97,13 @@ export async function getClaudeStatus() {
hasRecentActivity: false, hasRecentActivity: false,
}; };
const claudeDir = path.join(os.homedir(), ".claude"); const claudeDir = path.join(os.homedir(), '.claude');
// Check for recent Claude CLI activity - indicates working authentication // Check for recent Claude CLI activity - indicates working authentication
// The stats-cache.json file is only populated when the CLI is working properly // The stats-cache.json file is only populated when the CLI is working properly
const statsCachePath = path.join(claudeDir, "stats-cache.json"); const statsCachePath = path.join(claudeDir, 'stats-cache.json');
try { try {
const statsContent = await fs.readFile(statsCachePath, "utf-8"); const statsContent = await fs.readFile(statsCachePath, 'utf-8');
const stats = JSON.parse(statsContent); const stats = JSON.parse(statsContent);
// Check if there's any activity (which means the CLI is authenticated and working) // Check if there's any activity (which means the CLI is authenticated and working)
@@ -111,26 +111,26 @@ export async function getClaudeStatus() {
auth.hasRecentActivity = true; auth.hasRecentActivity = true;
auth.hasCliAuth = true; auth.hasCliAuth = true;
auth.authenticated = true; auth.authenticated = true;
auth.method = "cli_authenticated"; auth.method = 'cli_authenticated';
} }
} catch { } catch {
// Stats file doesn't exist or is invalid // Stats file doesn't exist or is invalid
} }
// Check for settings.json - indicates CLI has been set up // Check for settings.json - indicates CLI has been set up
const settingsPath = path.join(claudeDir, "settings.json"); const settingsPath = path.join(claudeDir, 'settings.json');
try { try {
await fs.access(settingsPath); await fs.access(settingsPath);
// If settings exist but no activity, CLI might be set up but not authenticated // If settings exist but no activity, CLI might be set up but not authenticated
if (!auth.hasCliAuth) { if (!auth.hasCliAuth) {
// Try to check for other indicators of auth // Try to check for other indicators of auth
const sessionsDir = path.join(claudeDir, "projects"); const sessionsDir = path.join(claudeDir, 'projects');
try { try {
const sessions = await fs.readdir(sessionsDir); const sessions = await fs.readdir(sessionsDir);
if (sessions.length > 0) { if (sessions.length > 0) {
auth.hasCliAuth = true; auth.hasCliAuth = true;
auth.authenticated = true; auth.authenticated = true;
auth.method = "cli_authenticated"; auth.method = 'cli_authenticated';
} }
} catch { } catch {
// Sessions directory doesn't exist // Sessions directory doesn't exist
@@ -143,13 +143,13 @@ export async function getClaudeStatus() {
// Check for credentials file (OAuth tokens from claude login) // Check for credentials file (OAuth tokens from claude login)
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform // Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
const credentialsPaths = [ const credentialsPaths = [
path.join(claudeDir, ".credentials.json"), path.join(claudeDir, '.credentials.json'),
path.join(claudeDir, "credentials.json"), path.join(claudeDir, 'credentials.json'),
]; ];
for (const credentialsPath of credentialsPaths) { for (const credentialsPath of credentialsPaths) {
try { try {
const credentialsContent = await fs.readFile(credentialsPath, "utf-8"); const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
const credentials = JSON.parse(credentialsContent); const credentials = JSON.parse(credentialsContent);
auth.hasCredentialsFile = true; auth.hasCredentialsFile = true;
@@ -158,11 +158,11 @@ export async function getClaudeStatus() {
auth.hasStoredOAuthToken = true; auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true; auth.oauthTokenValid = true;
auth.authenticated = true; auth.authenticated = true;
auth.method = "oauth_token"; // Stored OAuth token from credentials file auth.method = 'oauth_token'; // Stored OAuth token from credentials file
} else if (credentials.api_key) { } else if (credentials.api_key) {
auth.apiKeyValid = true; auth.apiKeyValid = true;
auth.authenticated = true; auth.authenticated = true;
auth.method = "api_key"; // Stored API key in credentials file auth.method = 'api_key'; // Stored API key in credentials file
} }
break; // Found and processed credentials file break; // Found and processed credentials file
} catch { } catch {
@@ -174,25 +174,25 @@ export async function getClaudeStatus() {
if (auth.hasEnvApiKey) { if (auth.hasEnvApiKey) {
auth.authenticated = true; auth.authenticated = true;
auth.apiKeyValid = true; auth.apiKeyValid = true;
auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
} }
// In-memory stored OAuth token (from setup wizard - subscription auth) // In-memory stored OAuth token (from setup wizard - subscription auth)
if (!auth.authenticated && getApiKey("anthropic_oauth_token")) { if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
auth.authenticated = true; auth.authenticated = true;
auth.oauthTokenValid = true; auth.oauthTokenValid = true;
auth.method = "oauth_token"; // Stored OAuth token from setup wizard auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
} }
// In-memory stored API key (from settings UI - pay-per-use) // In-memory stored API key (from settings UI - pay-per-use)
if (!auth.authenticated && getApiKey("anthropic")) { if (!auth.authenticated && getApiKey('anthropic')) {
auth.authenticated = true; auth.authenticated = true;
auth.apiKeyValid = true; auth.apiKeyValid = true;
auth.method = "api_key"; // Manually stored API key auth.method = 'api_key'; // Manually stored API key
} }
return { return {
status: installed ? "installed" : "not_installed", status: installed ? 'installed' : 'not_installed',
installed, installed,
method, method,
version, version,

View File

@@ -2,29 +2,29 @@
* Setup routes - HTTP API for CLI detection, API keys, and platform info * Setup routes - HTTP API for CLI detection, API keys, and platform info
*/ */
import { Router } from "express"; import { Router } from 'express';
import { createClaudeStatusHandler } from "./routes/claude-status.js"; import { createClaudeStatusHandler } from './routes/claude-status.js';
import { createInstallClaudeHandler } from "./routes/install-claude.js"; import { createInstallClaudeHandler } from './routes/install-claude.js';
import { createAuthClaudeHandler } from "./routes/auth-claude.js"; import { createAuthClaudeHandler } from './routes/auth-claude.js';
import { createStoreApiKeyHandler } from "./routes/store-api-key.js"; import { createStoreApiKeyHandler } from './routes/store-api-key.js';
import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js"; import { createDeleteApiKeyHandler } from './routes/delete-api-key.js';
import { createApiKeysHandler } from "./routes/api-keys.js"; import { createApiKeysHandler } from './routes/api-keys.js';
import { createPlatformHandler } from "./routes/platform.js"; import { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js"; import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createGhStatusHandler } from "./routes/gh-status.js"; import { createGhStatusHandler } from './routes/gh-status.js';
export function createSetupRoutes(): Router { export function createSetupRoutes(): Router {
const router = Router(); const router = Router();
router.get("/claude-status", createClaudeStatusHandler()); router.get('/claude-status', createClaudeStatusHandler());
router.post("/install-claude", createInstallClaudeHandler()); router.post('/install-claude', createInstallClaudeHandler());
router.post("/auth-claude", createAuthClaudeHandler()); router.post('/auth-claude', createAuthClaudeHandler());
router.post("/store-api-key", createStoreApiKeyHandler()); router.post('/store-api-key', createStoreApiKeyHandler());
router.post("/delete-api-key", createDeleteApiKeyHandler()); router.post('/delete-api-key', createDeleteApiKeyHandler());
router.get("/api-keys", createApiKeysHandler()); router.get('/api-keys', createApiKeysHandler());
router.get("/platform", createPlatformHandler()); router.get('/platform', createPlatformHandler());
router.post("/verify-claude-auth", createVerifyClaudeAuthHandler()); router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.get("/gh-status", createGhStatusHandler()); router.get('/gh-status', createGhStatusHandler());
return router; return router;
} }

View File

@@ -2,8 +2,8 @@
* POST /auth-claude endpoint - Auth Claude * POST /auth-claude endpoint - Auth Claude
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createAuthClaudeHandler() { export function createAuthClaudeHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -11,11 +11,11 @@ export function createAuthClaudeHandler() {
res.json({ res.json({
success: true, success: true,
requiresManualAuth: true, requiresManualAuth: true,
command: "claude login", command: 'claude login',
message: "Please run 'claude login' in your terminal to authenticate", message: "Please run 'claude login' in your terminal to authenticate",
}); });
} catch (error) { } catch (error) {
logError(error, "Auth Claude failed"); logError(error, 'Auth Claude failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* GET /claude-status endpoint - Get Claude CLI status * GET /claude-status endpoint - Get Claude CLI status
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getClaudeStatus } from "../get-claude-status.js"; import { getClaudeStatus } from '../get-claude-status.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createClaudeStatusHandler() { export function createClaudeStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -15,7 +15,7 @@ export function createClaudeStatusHandler() {
...status, ...status,
}); });
} catch (error) { } catch (error) {
logError(error, "Get Claude status failed"); logError(error, 'Get Claude status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,24 +2,26 @@
* GET /gh-status endpoint - Get GitHub CLI status * GET /gh-status endpoint - Get GitHub CLI status
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import os from "os"; import os from 'os';
import path from "path"; import path from 'path';
import fs from "fs/promises"; import fs from 'fs/promises';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations // Extended PATH to include common tool installation locations
const extendedPath = [ const extendedPath = [
process.env.PATH, process.env.PATH,
"/opt/homebrew/bin", '/opt/homebrew/bin',
"/usr/local/bin", '/usr/local/bin',
"/home/linuxbrew/.linuxbrew/bin", '/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`, `${process.env.HOME}/.local/bin`,
].filter(Boolean).join(":"); ]
.filter(Boolean)
.join(':');
const execEnv = { const execEnv = {
...process.env, ...process.env,
@@ -44,11 +46,11 @@ async function getGhStatus(): Promise<GhStatus> {
user: null, user: null,
}; };
const isWindows = process.platform === "win32"; const isWindows = process.platform === 'win32';
// Check if gh CLI is installed // Check if gh CLI is installed
try { try {
const findCommand = isWindows ? "where gh" : "command -v gh"; const findCommand = isWindows ? 'where gh' : 'command -v gh';
const { stdout } = await execAsync(findCommand, { env: execEnv }); const { stdout } = await execAsync(findCommand, { env: execEnv });
status.path = stdout.trim().split(/\r?\n/)[0]; status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true; status.installed = true;
@@ -56,14 +58,14 @@ async function getGhStatus(): Promise<GhStatus> {
// gh not in PATH, try common locations // gh not in PATH, try common locations
const commonPaths = isWindows const commonPaths = isWindows
? [ ? [
path.join(process.env.LOCALAPPDATA || "", "Programs", "gh", "bin", "gh.exe"), path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || "", "GitHub CLI", "gh.exe"), path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
] ]
: [ : [
"/opt/homebrew/bin/gh", '/opt/homebrew/bin/gh',
"/usr/local/bin/gh", '/usr/local/bin/gh',
path.join(os.homedir(), ".local", "bin", "gh"), path.join(os.homedir(), '.local', 'bin', 'gh'),
"/home/linuxbrew/.linuxbrew/bin/gh", '/home/linuxbrew/.linuxbrew/bin/gh',
]; ];
for (const p of commonPaths) { for (const p of commonPaths) {
@@ -84,30 +86,31 @@ async function getGhStatus(): Promise<GhStatus> {
// Get version // Get version
try { try {
const { stdout } = await execAsync("gh --version", { env: execEnv }); const { stdout } = await execAsync('gh --version', { env: execEnv });
// Extract version from output like "gh version 2.40.1 (2024-01-09)" // Extract version from output like "gh version 2.40.1 (2024-01-09)"
const versionMatch = stdout.match(/gh version ([\d.]+)/); const versionMatch = stdout.match(/gh version ([\d.]+)/);
status.version = versionMatch ? versionMatch[1] : stdout.trim().split("\n")[0]; status.version = versionMatch ? versionMatch[1] : stdout.trim().split('\n')[0];
} catch { } catch {
// Version command failed // Version command failed
} }
// Check authentication status // Check authentication status
try { try {
const { stdout } = await execAsync("gh auth status", { env: execEnv }); const { stdout } = await execAsync('gh auth status', { env: execEnv });
// If this succeeds without error, we're authenticated // If this succeeds without error, we're authenticated
status.authenticated = true; status.authenticated = true;
// Try to extract username from output // Try to extract username from output
const userMatch = stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) || const userMatch =
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i); stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) { if (userMatch) {
status.user = userMatch[1]; status.user = userMatch[1];
} }
} catch (error: unknown) { } catch (error: unknown) {
// Auth status returns non-zero if not authenticated // Auth status returns non-zero if not authenticated
const err = error as { stderr?: string }; const err = error as { stderr?: string };
if (err.stderr?.includes("not logged in")) { if (err.stderr?.includes('not logged in')) {
status.authenticated = false; status.authenticated = false;
} }
} }
@@ -124,7 +127,7 @@ export function createGhStatusHandler() {
...status, ...status,
}); });
} catch (error) { } catch (error) {
logError(error, "Get GitHub CLI status failed"); logError(error, 'Get GitHub CLI status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,8 +2,8 @@
* POST /install-claude endpoint - Install Claude CLI * POST /install-claude endpoint - Install Claude CLI
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createInstallClaudeHandler() { export function createInstallClaudeHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -13,10 +13,10 @@ export function createInstallClaudeHandler() {
res.json({ res.json({
success: false, success: false,
error: error:
"CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code", 'CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code',
}); });
} catch (error) { } catch (error) {
logError(error, "Install Claude CLI failed"); logError(error, 'Install Claude CLI failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,9 +2,9 @@
* GET /platform endpoint - Get platform info * GET /platform endpoint - Get platform info
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import os from "os"; import os from 'os';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createPlatformHandler() { export function createPlatformHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -15,12 +15,12 @@ export function createPlatformHandler() {
platform, platform,
arch: os.arch(), arch: os.arch(),
homeDir: os.homedir(), homeDir: os.homedir(),
isWindows: platform === "win32", isWindows: platform === 'win32',
isMac: platform === "darwin", isMac: platform === 'darwin',
isLinux: platform === "linux", isLinux: platform === 'linux',
}); });
} catch (error) { } catch (error) {
logError(error, "Get platform info failed"); logError(error, 'Get platform info failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,8 +2,8 @@
* GET /status endpoint - Get status * GET /status endpoint - Get status
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getSuggestionsStatus, getErrorMessage, logError } from "../common.js"; import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() { export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -11,7 +11,7 @@ export function createStatusHandler() {
const { isRunning } = getSuggestionsStatus(); const { isRunning } = getSuggestionsStatus();
res.json({ success: true, isRunning }); res.json({ success: true, isRunning });
} catch (error) { } catch (error) {
logError(error, "Get status failed"); logError(error, 'Get status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,13 +2,8 @@
* POST /stop endpoint - Stop suggestions generation * POST /stop endpoint - Stop suggestions generation
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
getSuggestionsStatus,
setRunningState,
getErrorMessage,
logError,
} from "../common.js";
export function createStopHandler() { export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -20,7 +15,7 @@ export function createStopHandler() {
setRunningState(false, null); setRunningState(false, null);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
logError(error, "Stop suggestions failed"); logError(error, 'Stop suggestions failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -3,13 +3,13 @@
* Provides API for cloning GitHub starter templates * Provides API for cloning GitHub starter templates
*/ */
import { Router } from "express"; import { Router } from 'express';
import { createCloneHandler } from "./routes/clone.js"; import { createCloneHandler } from './routes/clone.js';
export function createTemplatesRoutes(): Router { export function createTemplatesRoutes(): Router {
const router = Router(); const router = Router();
router.post("/clone", createCloneHandler()); router.post('/clone', createCloneHandler());
return router; return router;
} }

View File

@@ -5,26 +5,20 @@
* WebSocket connections for real-time I/O are handled separately in index.ts. * WebSocket connections for real-time I/O are handled separately in index.ts.
*/ */
import { Router } from "express"; import { Router } from 'express';
import { import {
terminalAuthMiddleware, terminalAuthMiddleware,
validateTerminalToken, validateTerminalToken,
isTerminalEnabled, isTerminalEnabled,
isTerminalPasswordRequired, isTerminalPasswordRequired,
} from "./common.js"; } from './common.js';
import { createStatusHandler } from "./routes/status.js"; import { createStatusHandler } from './routes/status.js';
import { createAuthHandler } from "./routes/auth.js"; import { createAuthHandler } from './routes/auth.js';
import { createLogoutHandler } from "./routes/logout.js"; import { createLogoutHandler } from './routes/logout.js';
import { import { createSessionsListHandler, createSessionsCreateHandler } from './routes/sessions.js';
createSessionsListHandler, import { createSessionDeleteHandler } from './routes/session-delete.js';
createSessionsCreateHandler, import { createSessionResizeHandler } from './routes/session-resize.js';
} from "./routes/sessions.js"; import { createSettingsGetHandler, createSettingsUpdateHandler } from './routes/settings.js';
import { createSessionDeleteHandler } from "./routes/session-delete.js";
import { createSessionResizeHandler } from "./routes/session-resize.js";
import {
createSettingsGetHandler,
createSettingsUpdateHandler,
} from "./routes/settings.js";
// Re-export for use in main index.ts // Re-export for use in main index.ts
export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired };
@@ -32,19 +26,19 @@ export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired };
export function createTerminalRoutes(): Router { export function createTerminalRoutes(): Router {
const router = Router(); const router = Router();
router.get("/status", createStatusHandler()); router.get('/status', createStatusHandler());
router.post("/auth", createAuthHandler()); router.post('/auth', createAuthHandler());
router.post("/logout", createLogoutHandler()); router.post('/logout', createLogoutHandler());
// Apply terminal auth middleware to all routes below // Apply terminal auth middleware to all routes below
router.use(terminalAuthMiddleware); router.use(terminalAuthMiddleware);
router.get("/sessions", createSessionsListHandler()); router.get('/sessions', createSessionsListHandler());
router.post("/sessions", createSessionsCreateHandler()); router.post('/sessions', createSessionsCreateHandler());
router.delete("/sessions/:id", createSessionDeleteHandler()); router.delete('/sessions/:id', createSessionDeleteHandler());
router.post("/sessions/:id/resize", createSessionResizeHandler()); router.post('/sessions/:id/resize', createSessionResizeHandler());
router.get("/settings", createSettingsGetHandler()); router.get('/settings', createSettingsGetHandler());
router.put("/settings", createSettingsUpdateHandler()); router.put('/settings', createSettingsUpdateHandler());
return router; return router;
} }

View File

@@ -2,7 +2,7 @@
* POST /auth endpoint - Authenticate with password to get a session token * POST /auth endpoint - Authenticate with password to get a session token
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { import {
getTerminalEnabledConfigValue, getTerminalEnabledConfigValue,
getTerminalPasswordConfig, getTerminalPasswordConfig,
@@ -10,14 +10,14 @@ import {
addToken, addToken,
getTokenExpiryMs, getTokenExpiryMs,
getErrorMessage, getErrorMessage,
} from "../common.js"; } from '../common.js';
export function createAuthHandler() { export function createAuthHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
if (!getTerminalEnabledConfigValue()) { if (!getTerminalEnabledConfigValue()) {
res.status(403).json({ res.status(403).json({
success: false, success: false,
error: "Terminal access is disabled", error: 'Terminal access is disabled',
}); });
return; return;
} }
@@ -41,7 +41,7 @@ export function createAuthHandler() {
if (!password || password !== terminalPassword) { if (!password || password !== terminalPassword) {
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: "Invalid password", error: 'Invalid password',
}); });
return; return;
} }

View File

@@ -2,12 +2,12 @@
* POST /logout endpoint - Invalidate a session token * POST /logout endpoint - Invalidate a session token
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { deleteToken } from "../common.js"; import { deleteToken } from '../common.js';
export function createLogoutHandler() { export function createLogoutHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
const token = (req.headers["x-terminal-token"] as string) || req.body.token; const token = (req.headers['x-terminal-token'] as string) || req.body.token;
if (token) { if (token) {
deleteToken(token); deleteToken(token);

View File

@@ -2,8 +2,8 @@
* DELETE /sessions/:id endpoint - Kill a terminal session * DELETE /sessions/:id endpoint - Kill a terminal session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getTerminalService } from "../../../services/terminal-service.js"; import { getTerminalService } from '../../../services/terminal-service.js';
export function createSessionDeleteHandler() { export function createSessionDeleteHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
@@ -14,7 +14,7 @@ export function createSessionDeleteHandler() {
if (!killed) { if (!killed) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
error: "Session not found", error: 'Session not found',
}); });
return; return;
} }

View File

@@ -2,8 +2,8 @@
* POST /sessions/:id/resize endpoint - Resize a terminal session * POST /sessions/:id/resize endpoint - Resize a terminal session
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getTerminalService } from "../../../services/terminal-service.js"; import { getTerminalService } from '../../../services/terminal-service.js';
export function createSessionResizeHandler() { export function createSessionResizeHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
@@ -14,7 +14,7 @@ export function createSessionResizeHandler() {
if (!cols || !rows) { if (!cols || !rows) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "cols and rows are required", error: 'cols and rows are required',
}); });
return; return;
} }
@@ -24,7 +24,7 @@ export function createSessionResizeHandler() {
if (!resized) { if (!resized) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
error: "Session not found", error: 'Session not found',
}); });
return; return;
} }

View File

@@ -2,9 +2,13 @@
* GET/PUT /settings endpoint - Get/Update terminal settings * GET/PUT /settings endpoint - Get/Update terminal settings
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getTerminalService, MIN_MAX_SESSIONS, MAX_MAX_SESSIONS } from "../../../services/terminal-service.js"; import {
import { getErrorMessage, logError } from "../common.js"; getTerminalService,
MIN_MAX_SESSIONS,
MAX_MAX_SESSIONS,
} from '../../../services/terminal-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createSettingsGetHandler() { export function createSettingsGetHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {
@@ -18,10 +22,10 @@ export function createSettingsGetHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Get terminal settings failed"); logError(error, 'Get terminal settings failed');
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Failed to get terminal settings", error: 'Failed to get terminal settings',
details: getErrorMessage(error), details: getErrorMessage(error),
}); });
} }
@@ -36,17 +40,17 @@ export function createSettingsUpdateHandler() {
// Validate maxSessions if provided // Validate maxSessions if provided
if (maxSessions !== undefined) { if (maxSessions !== undefined) {
if (typeof maxSessions !== "number") { if (typeof maxSessions !== 'number') {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "maxSessions must be a number", error: 'maxSessions must be a number',
}); });
return; return;
} }
if (!Number.isInteger(maxSessions)) { if (!Number.isInteger(maxSessions)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "maxSessions must be an integer", error: 'maxSessions must be an integer',
}); });
return; return;
} }
@@ -68,10 +72,10 @@ export function createSettingsUpdateHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Update terminal settings failed"); logError(error, 'Update terminal settings failed');
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Failed to update terminal settings", error: 'Failed to update terminal settings',
details: getErrorMessage(error), details: getErrorMessage(error),
}); });
} }

View File

@@ -2,12 +2,9 @@
* GET /status endpoint - Get terminal status * GET /status endpoint - Get terminal status
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getTerminalService } from "../../../services/terminal-service.js"; import { getTerminalService } from '../../../services/terminal-service.js';
import { import { getTerminalEnabledConfigValue, isTerminalPasswordRequired } from '../common.js';
getTerminalEnabledConfigValue,
isTerminalPasswordRequired,
} from "../common.js";
export function createStatusHandler() { export function createStatusHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {

View File

@@ -3,15 +3,15 @@
* Provides API endpoints for workspace directory management * Provides API endpoints for workspace directory management
*/ */
import { Router } from "express"; import { Router } from 'express';
import { createConfigHandler } from "./routes/config.js"; import { createConfigHandler } from './routes/config.js';
import { createDirectoriesHandler } from "./routes/directories.js"; import { createDirectoriesHandler } from './routes/directories.js';
export function createWorkspaceRoutes(): Router { export function createWorkspaceRoutes(): Router {
const router = Router(); const router = Router();
router.get("/config", createConfigHandler()); router.get('/config', createConfigHandler());
router.get("/directories", createDirectoriesHandler()); router.get('/directories', createDirectoriesHandler());
return router; return router;
} }

View File

@@ -2,10 +2,10 @@
* POST /checkout-branch endpoint - Create and checkout a new branch * POST /checkout-branch endpoint - Create and checkout a new branch
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -20,7 +20,7 @@ export function createCheckoutBranchHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
@@ -28,7 +28,7 @@ export function createCheckoutBranchHandler() {
if (!branchName) { if (!branchName) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "branchName required", error: 'branchName required',
}); });
return; return;
} }
@@ -38,16 +38,15 @@ export function createCheckoutBranchHandler() {
if (invalidChars.test(branchName)) { if (invalidChars.test(branchName)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "Branch name contains invalid characters", error: 'Branch name contains invalid characters',
}); });
return; return;
} }
// Get current branch for reference // Get current branch for reference
const { stdout: currentBranchOutput } = await execAsync( const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath } });
);
const currentBranch = currentBranchOutput.trim(); const currentBranch = currentBranchOutput.trim();
// Check if branch already exists // Check if branch already exists
@@ -79,7 +78,7 @@ export function createCheckoutBranchHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Checkout branch failed"); logError(error, 'Checkout branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,10 +2,10 @@
* POST /commit endpoint - Commit changes in a worktree * POST /commit endpoint - Commit changes in a worktree
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -20,13 +20,13 @@ export function createCommitHandler() {
if (!worktreePath || !message) { if (!worktreePath || !message) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath and message required", error: 'worktreePath and message required',
}); });
return; return;
} }
// Check for uncommitted changes // Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", { const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath, cwd: worktreePath,
}); });
@@ -35,14 +35,14 @@ export function createCommitHandler() {
success: true, success: true,
result: { result: {
committed: false, committed: false,
message: "No changes to commit", message: 'No changes to commit',
}, },
}); });
return; return;
} }
// Stage all changes // Stage all changes
await execAsync("git add -A", { cwd: worktreePath }); await execAsync('git add -A', { cwd: worktreePath });
// Create commit // Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
@@ -50,16 +50,15 @@ export function createCommitHandler() {
}); });
// Get commit hash // Get commit hash
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", { const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
cwd: worktreePath, cwd: worktreePath,
}); });
const commitHash = hashOutput.trim().substring(0, 8); const commitHash = hashOutput.trim().substring(0, 8);
// Get branch name // Get branch name
const { stdout: branchOutput } = await execAsync( const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath } });
);
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
res.json({ res.json({
@@ -72,7 +71,7 @@ export function createCommitHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Commit worktree failed"); logError(error, 'Commit worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,7 +2,7 @@
* POST /create-pr endpoint - Commit changes and create a pull request from a worktree * POST /create-pr endpoint - Commit changes and create a pull request from a worktree
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { import {
getErrorMessage, getErrorMessage,
logError, logError,
@@ -10,26 +10,27 @@ import {
execEnv, execEnv,
isValidBranchName, isValidBranchName,
isGhCliAvailable, isGhCliAvailable,
} from "../common.js"; } from '../common.js';
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js"; import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
export function createCreatePRHandler() { export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as { const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } =
worktreePath: string; req.body as {
projectPath?: string; worktreePath: string;
commitMessage?: string; projectPath?: string;
prTitle?: string; commitMessage?: string;
prBody?: string; prTitle?: string;
baseBranch?: string; prBody?: string;
draft?: boolean; baseBranch?: string;
}; draft?: boolean;
};
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
@@ -39,23 +40,23 @@ export function createCreatePRHandler() {
const effectiveProjectPath = projectPath || worktreePath; const effectiveProjectPath = projectPath || worktreePath;
// Get current branch name // Get current branch name
const { stdout: branchOutput } = await execAsync( const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath, env: execEnv } env: execEnv,
); });
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
// Validate branch name for security // Validate branch name for security
if (!isValidBranchName(branchName)) { if (!isValidBranchName(branchName)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "Invalid branch name contains unsafe characters", error: 'Invalid branch name contains unsafe characters',
}); });
return; return;
} }
// Check for uncommitted changes // Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", { const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
@@ -67,7 +68,7 @@ export function createCreatePRHandler() {
const message = commitMessage || `Changes from ${branchName}`; const message = commitMessage || `Changes from ${branchName}`;
// Stage all changes // Stage all changes
await execAsync("git add -A", { cwd: worktreePath, env: execEnv }); await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Create commit // Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
@@ -76,7 +77,7 @@ export function createCreatePRHandler() {
}); });
// Get commit hash // Get commit hash
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", { const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
@@ -100,8 +101,8 @@ export function createCreatePRHandler() {
} catch (error2: unknown) { } catch (error2: unknown) {
// Capture push error for reporting // Capture push error for reporting
const err = error2 as { stderr?: string; message?: string }; const err = error2 as { stderr?: string; message?: string };
pushError = err.stderr || err.message || "Push failed"; pushError = err.stderr || err.message || 'Push failed';
console.error("[CreatePR] Push failed:", pushError); console.error('[CreatePR] Push failed:', pushError);
} }
} }
@@ -115,10 +116,10 @@ export function createCreatePRHandler() {
} }
// Create PR using gh CLI or provide browser fallback // Create PR using gh CLI or provide browser fallback
const base = baseBranch || "main"; const base = baseBranch || 'main';
const title = prTitle || branchName; const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`; const body = prBody || `Changes from branch ${branchName}`;
const draftFlag = draft ? "--draft" : ""; const draftFlag = draft ? '--draft' : '';
let prUrl: string | null = null; let prUrl: string | null = null;
let prError: string | null = null; let prError: string | null = null;
@@ -131,7 +132,7 @@ export function createCreatePRHandler() {
let upstreamRepo: string | null = null; let upstreamRepo: string | null = null;
let originOwner: string | null = null; let originOwner: string | null = null;
try { try {
const { stdout: remotes } = await execAsync("git remote -v", { const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
@@ -150,15 +151,17 @@ export function createCreatePRHandler() {
} }
if (!match) { if (!match) {
// Try HTTPS format: https://github.com/owner/repo.git // Try HTTPS format: https://github.com/owner/repo.git
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); match = line.match(
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
} }
if (match) { if (match) {
const [, remoteName, owner, repo] = match; const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") { if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`; upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`; repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === "origin") { } else if (remoteName === 'origin') {
originOwner = owner; originOwner = owner;
if (!repoUrl) { if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`; repoUrl = `https://github.com/${owner}/${repo}`;
@@ -173,7 +176,7 @@ export function createCreatePRHandler() {
// Fallback: Try to get repo URL from git config if remote parsing failed // Fallback: Try to get repo URL from git config if remote parsing failed
if (!repoUrl) { if (!repoUrl) {
try { try {
const { stdout: originUrl } = await execAsync("git config --get remote.origin.url", { const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
@@ -217,9 +220,11 @@ export function createCreatePRHandler() {
// This is more reliable than gh pr view as it explicitly searches by branch name // This is more reliable than gh pr view as it explicitly searches by branch name
// For forks, we need to use owner:branch format for the head parameter // For forks, we need to use owner:branch format for the head parameter
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : ""; const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';
console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`); console.log(
`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`
);
try { try {
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`; const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
console.log(`[CreatePR] Running: ${listCmd}`); console.log(`[CreatePR] Running: ${listCmd}`);
@@ -234,7 +239,9 @@ export function createCreatePRHandler() {
if (Array.isArray(existingPrs) && existingPrs.length > 0) { if (Array.isArray(existingPrs) && existingPrs.length > 0) {
const existingPr = existingPrs[0]; const existingPr = existingPrs[0];
// PR already exists - use it and store metadata // PR already exists - use it and store metadata
console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`); console.log(
`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`
);
prUrl = existingPr.url; prUrl = existingPr.url;
prNumber = existingPr.number; prNumber = existingPr.number;
prAlreadyExisted = true; prAlreadyExisted = true;
@@ -244,10 +251,12 @@ export function createCreatePRHandler() {
number: existingPr.number, number: existingPr.number,
url: existingPr.url, url: existingPr.url,
title: existingPr.title || title, title: existingPr.title || title,
state: existingPr.state || "open", state: existingPr.state || 'open',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`); console.log(
`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
);
} else { } else {
console.log(`[CreatePR] No existing PR found for branch ${branchName}`); console.log(`[CreatePR] No existing PR found for branch ${branchName}`);
} }
@@ -293,23 +302,25 @@ export function createCreatePRHandler() {
number: prNumber, number: prNumber,
url: prUrl, url: prUrl,
title, title,
state: draft ? "draft" : "open", state: draft ? 'draft' : 'open',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`); console.log(
`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`
);
} catch (metadataError) { } catch (metadataError) {
console.error("[CreatePR] Failed to store PR metadata:", metadataError); console.error('[CreatePR] Failed to store PR metadata:', metadataError);
} }
} }
} }
} catch (ghError: unknown) { } catch (ghError: unknown) {
// gh CLI failed - check if it's "already exists" error and try to fetch the PR // gh CLI failed - check if it's "already exists" error and try to fetch the PR
const err = ghError as { stderr?: string; message?: string }; const err = ghError as { stderr?: string; message?: string };
const errorMessage = err.stderr || err.message || "PR creation failed"; const errorMessage = err.stderr || err.message || 'PR creation failed';
console.log(`[CreatePR] gh pr create failed: ${errorMessage}`); console.log(`[CreatePR] gh pr create failed: ${errorMessage}`);
// If error indicates PR already exists, try to fetch it // If error indicates PR already exists, try to fetch it
if (errorMessage.toLowerCase().includes("already exists")) { if (errorMessage.toLowerCase().includes('already exists')) {
console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`); console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`);
try { try {
const { stdout: viewOutput } = await execAsync( const { stdout: viewOutput } = await execAsync(
@@ -326,13 +337,13 @@ export function createCreatePRHandler() {
number: existingPr.number, number: existingPr.number,
url: existingPr.url, url: existingPr.url,
title: existingPr.title || title, title: existingPr.title || title,
state: existingPr.state || "open", state: existingPr.state || 'open',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`); console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`);
} }
} catch (viewError) { } catch (viewError) {
console.error("[CreatePR] Failed to fetch existing PR:", viewError); console.error('[CreatePR] Failed to fetch existing PR:', viewError);
prError = errorMessage; prError = errorMessage;
} }
} else { } else {
@@ -341,7 +352,7 @@ export function createCreatePRHandler() {
} }
} }
} else { } else {
prError = "gh_cli_not_available"; prError = 'gh_cli_not_available';
} }
// Return result with browser fallback URL // Return result with browser fallback URL
@@ -362,7 +373,7 @@ export function createCreatePRHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Create PR failed"); logError(error, 'Create PR failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,10 +2,10 @@
* POST /list-branches endpoint - List all local branches * POST /list-branches endpoint - List all local branches
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from "../common.js"; import { getErrorMessage, logWorktreeError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -25,33 +25,31 @@ export function createListBranchesHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
// Get current branch // Get current branch
const { stdout: currentBranchOutput } = await execAsync( const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath } });
);
const currentBranch = currentBranchOutput.trim(); const currentBranch = currentBranchOutput.trim();
// List all local branches // List all local branches
// Use double quotes around the format string for cross-platform compatibility // Use double quotes around the format string for cross-platform compatibility
// Single quotes are preserved literally on Windows; double quotes work on both // Single quotes are preserved literally on Windows; double quotes work on both
const { stdout: branchesOutput } = await execAsync( const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', {
'git branch --format="%(refname:short)"', cwd: worktreePath,
{ cwd: worktreePath } });
);
const branches: BranchInfo[] = branchesOutput const branches: BranchInfo[] = branchesOutput
.trim() .trim()
.split("\n") .split('\n')
.filter((b) => b.trim()) .filter((b) => b.trim())
.map((name) => { .map((name) => {
// Remove any surrounding quotes (Windows git may preserve them) // Remove any surrounding quotes (Windows git may preserve them)
const cleanName = name.trim().replace(/^['"]|['"]$/g, ""); const cleanName = name.trim().replace(/^['"]|['"]$/g, '');
return { return {
name: cleanName, name: cleanName,
isCurrent: cleanName === currentBranch, isCurrent: cleanName === currentBranch,
@@ -93,7 +91,7 @@ export function createListBranchesHandler() {
}); });
} catch (error) { } catch (error) {
const worktreePath = req.body?.worktreePath; const worktreePath = req.body?.worktreePath;
logWorktreeError(error, "List branches failed", worktreePath); logWorktreeError(error, 'List branches failed', worktreePath);
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -5,9 +5,9 @@
* including their ports and URLs. * including their ports and URLs.
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getDevServerService } from "../../../services/dev-server-service.js"; import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createListDevServersHandler() { export function createListDevServersHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -22,7 +22,7 @@ export function createListDevServersHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "List dev servers failed"); logError(error, 'List dev servers failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,11 +2,11 @@
* POST /merge endpoint - Merge feature (merge worktree branch into main) * POST /merge endpoint - Merge feature (merge worktree branch into main)
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import path from "path"; import path from 'path';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -20,42 +20,34 @@ export function createMergeHandler() {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: 'projectPath and featureId required',
success: false, });
error: "projectPath and featureId required",
});
return; return;
} }
const branchName = `feature/${featureId}`; const branchName = `feature/${featureId}`;
// Git worktrees are stored in project directory // Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, ".worktrees", featureId); const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Get current branch // Get current branch
const { stdout: currentBranch } = await execAsync( const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: projectPath,
{ cwd: projectPath } });
);
// Merge the feature branch // Merge the feature branch
const mergeCmd = options?.squash const mergeCmd = options?.squash
? `git merge --squash ${branchName}` ? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${ : `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
options?.message || `Merge ${branchName}`
}"`;
await execAsync(mergeCmd, { cwd: projectPath }); await execAsync(mergeCmd, { cwd: projectPath });
// If squash merge, need to commit // If squash merge, need to commit
if (options?.squash) { if (options?.squash) {
await execAsync( await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
`git commit -m "${ cwd: projectPath,
options?.message || `Merge ${branchName} (squash)` });
}"`,
{ cwd: projectPath }
);
} }
// Clean up worktree and branch // Clean up worktree and branch
@@ -70,7 +62,7 @@ export function createMergeHandler() {
res.json({ success: true, mergedBranch: branchName }); res.json({ success: true, mergedBranch: branchName });
} catch (error) { } catch (error) {
logError(error, "Merge worktree failed"); logError(error, 'Merge worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -3,10 +3,10 @@
* GET /default-editor endpoint - Get the name of the default code editor * GET /default-editor endpoint - Get the name of the default code editor
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -29,8 +29,8 @@ async function detectDefaultEditor(): Promise<EditorInfo> {
// Try Cursor first (if user has Cursor, they probably prefer it) // Try Cursor first (if user has Cursor, they probably prefer it)
try { try {
await execAsync("which cursor || where cursor"); await execAsync('which cursor || where cursor');
cachedEditor = { name: "Cursor", command: "cursor" }; cachedEditor = { name: 'Cursor', command: 'cursor' };
return cachedEditor; return cachedEditor;
} catch { } catch {
// Cursor not found // Cursor not found
@@ -38,8 +38,8 @@ async function detectDefaultEditor(): Promise<EditorInfo> {
// Try VS Code // Try VS Code
try { try {
await execAsync("which code || where code"); await execAsync('which code || where code');
cachedEditor = { name: "VS Code", command: "code" }; cachedEditor = { name: 'VS Code', command: 'code' };
return cachedEditor; return cachedEditor;
} catch { } catch {
// VS Code not found // VS Code not found
@@ -47,8 +47,8 @@ async function detectDefaultEditor(): Promise<EditorInfo> {
// Try Zed // Try Zed
try { try {
await execAsync("which zed || where zed"); await execAsync('which zed || where zed');
cachedEditor = { name: "Zed", command: "zed" }; cachedEditor = { name: 'Zed', command: 'zed' };
return cachedEditor; return cachedEditor;
} catch { } catch {
// Zed not found // Zed not found
@@ -56,8 +56,8 @@ async function detectDefaultEditor(): Promise<EditorInfo> {
// Try Sublime Text // Try Sublime Text
try { try {
await execAsync("which subl || where subl"); await execAsync('which subl || where subl');
cachedEditor = { name: "Sublime Text", command: "subl" }; cachedEditor = { name: 'Sublime Text', command: 'subl' };
return cachedEditor; return cachedEditor;
} catch { } catch {
// Sublime not found // Sublime not found
@@ -65,12 +65,12 @@ async function detectDefaultEditor(): Promise<EditorInfo> {
// Fallback to file manager // Fallback to file manager
const platform = process.platform; const platform = process.platform;
if (platform === "darwin") { if (platform === 'darwin') {
cachedEditor = { name: "Finder", command: "open" }; cachedEditor = { name: 'Finder', command: 'open' };
} else if (platform === "win32") { } else if (platform === 'win32') {
cachedEditor = { name: "Explorer", command: "explorer" }; cachedEditor = { name: 'Explorer', command: 'explorer' };
} else { } else {
cachedEditor = { name: "File Manager", command: "xdg-open" }; cachedEditor = { name: 'File Manager', command: 'xdg-open' };
} }
return cachedEditor; return cachedEditor;
} }
@@ -87,7 +87,7 @@ export function createGetDefaultEditorHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Get default editor failed"); logError(error, 'Get default editor failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };
@@ -103,7 +103,7 @@ export function createOpenInEditorHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
@@ -125,15 +125,15 @@ export function createOpenInEditorHandler() {
let openCommand: string; let openCommand: string;
let fallbackName: string; let fallbackName: string;
if (platform === "darwin") { if (platform === 'darwin') {
openCommand = `open "${worktreePath}"`; openCommand = `open "${worktreePath}"`;
fallbackName = "Finder"; fallbackName = 'Finder';
} else if (platform === "win32") { } else if (platform === 'win32') {
openCommand = `explorer "${worktreePath}"`; openCommand = `explorer "${worktreePath}"`;
fallbackName = "Explorer"; fallbackName = 'Explorer';
} else { } else {
openCommand = `xdg-open "${worktreePath}"`; openCommand = `xdg-open "${worktreePath}"`;
fallbackName = "File Manager"; fallbackName = 'File Manager';
} }
await execAsync(openCommand); await execAsync(openCommand);
@@ -146,7 +146,7 @@ export function createOpenInEditorHandler() {
}); });
} }
} catch (error) { } catch (error) {
logError(error, "Open in editor failed"); logError(error, 'Open in editor failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,7 +2,7 @@
* POST /pr-info endpoint - Get PR info and comments for a branch * POST /pr-info endpoint - Get PR info and comments for a branch
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { import {
getErrorMessage, getErrorMessage,
logError, logError,
@@ -10,7 +10,7 @@ import {
execEnv, execEnv,
isValidBranchName, isValidBranchName,
isGhCliAvailable, isGhCliAvailable,
} from "../common.js"; } from '../common.js';
export interface PRComment { export interface PRComment {
id: number; id: number;
@@ -44,7 +44,7 @@ export function createPRInfoHandler() {
if (!worktreePath || !branchName) { if (!worktreePath || !branchName) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath and branchName required", error: 'worktreePath and branchName required',
}); });
return; return;
} }
@@ -53,7 +53,7 @@ export function createPRInfoHandler() {
if (!isValidBranchName(branchName)) { if (!isValidBranchName(branchName)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "Invalid branch name contains unsafe characters", error: 'Invalid branch name contains unsafe characters',
}); });
return; return;
} }
@@ -67,7 +67,7 @@ export function createPRInfoHandler() {
result: { result: {
hasPR: false, hasPR: false,
ghCliAvailable: false, ghCliAvailable: false,
error: "gh CLI not available", error: 'gh CLI not available',
}, },
}); });
return; return;
@@ -79,7 +79,7 @@ export function createPRInfoHandler() {
let originRepo: string | null = null; let originRepo: string | null = null;
try { try {
const { stdout: remotes } = await execAsync("git remote -v", { const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
@@ -87,21 +87,15 @@ export function createPRInfoHandler() {
const lines = remotes.split(/\r?\n/); const lines = remotes.split(/\r?\n/);
for (const line of lines) { for (const line of lines) {
let match = let match =
line.match( line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) ||
/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/ line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) ||
) || line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
line.match(
/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
) ||
line.match(
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
if (match) { if (match) {
const [, remoteName, owner, repo] = match; const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") { if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`; upstreamRepo = `${owner}/${repo}`;
} else if (remoteName === "origin") { } else if (remoteName === 'origin') {
originOwner = owner; originOwner = owner;
originRepo = repo; originRepo = repo;
} }
@@ -113,16 +107,11 @@ export function createPRInfoHandler() {
if (!originOwner || !originRepo) { if (!originOwner || !originRepo) {
try { try {
const { stdout: originUrl } = await execAsync( const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
"git config --get remote.origin.url", cwd: worktreePath,
{ env: execEnv,
cwd: worktreePath, });
env: execEnv, const match = originUrl.trim().match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
}
);
const match = originUrl
.trim()
.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) { if (match) {
if (!originOwner) { if (!originOwner) {
originOwner = match[1]; originOwner = match[1];
@@ -137,21 +126,18 @@ export function createPRInfoHandler() {
} }
const targetRepo = const targetRepo =
upstreamRepo || (originOwner && originRepo upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null);
? `${originOwner}/${originRepo}` const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : '';
: null); const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : "";
const headRef =
upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
// Get PR info for the branch using gh CLI // Get PR info for the branch using gh CLI
try { try {
// First, find the PR associated with this branch // First, find the PR associated with this branch
const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`; const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`;
const { stdout: prListOutput } = await execAsync( const { stdout: prListOutput } = await execAsync(listCmd, {
listCmd, cwd: worktreePath,
{ cwd: worktreePath, env: execEnv } env: execEnv,
); });
const prList = JSON.parse(prListOutput); const prList = JSON.parse(prListOutput);
@@ -173,25 +159,22 @@ export function createPRInfoHandler() {
let comments: PRComment[] = []; let comments: PRComment[] = [];
try { try {
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`; const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`;
const { stdout: commentsOutput } = await execAsync( const { stdout: commentsOutput } = await execAsync(viewCmd, {
viewCmd, cwd: worktreePath,
{ cwd: worktreePath, env: execEnv } env: execEnv,
); });
const commentsData = JSON.parse(commentsOutput); const commentsData = JSON.parse(commentsOutput);
comments = (commentsData.comments || []).map((c: { comments = (commentsData.comments || []).map(
id: number; (c: { id: number; author: { login: string }; body: string; createdAt: string }) => ({
author: { login: string }; id: c.id,
body: string; author: c.author?.login || 'unknown',
createdAt: string; body: c.body,
}) => ({ createdAt: c.createdAt,
id: c.id, isReviewComment: false,
author: c.author?.login || "unknown", })
body: c.body, );
createdAt: c.createdAt,
isReviewComment: false,
}));
} catch (error) { } catch (error) {
console.warn("[PRInfo] Failed to fetch PR comments:", error); console.warn('[PRInfo] Failed to fetch PR comments:', error);
} }
// Get review comments (inline code comments) // Get review comments (inline code comments)
@@ -201,33 +184,35 @@ export function createPRInfoHandler() {
try { try {
const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`; const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`;
const reviewsCmd = `gh api ${reviewsEndpoint}`; const reviewsCmd = `gh api ${reviewsEndpoint}`;
const { stdout: reviewsOutput } = await execAsync( const { stdout: reviewsOutput } = await execAsync(reviewsCmd, {
reviewsCmd, cwd: worktreePath,
{ cwd: worktreePath, env: execEnv } env: execEnv,
); });
const reviewsData = JSON.parse(reviewsOutput); const reviewsData = JSON.parse(reviewsOutput);
reviewComments = reviewsData.map((c: { reviewComments = reviewsData.map(
id: number; (c: {
user: { login: string }; id: number;
body: string; user: { login: string };
path: string; body: string;
line?: number; path: string;
original_line?: number; line?: number;
created_at: string; original_line?: number;
}) => ({ created_at: string;
id: c.id, }) => ({
author: c.user?.login || "unknown", id: c.id,
body: c.body, author: c.user?.login || 'unknown',
path: c.path, body: c.body,
line: c.line || c.original_line, path: c.path,
createdAt: c.created_at, line: c.line || c.original_line,
isReviewComment: true, createdAt: c.created_at,
})); isReviewComment: true,
})
);
} catch (error) { } catch (error) {
console.warn("[PRInfo] Failed to fetch review comments:", error); console.warn('[PRInfo] Failed to fetch review comments:', error);
} }
} else { } else {
console.warn("[PRInfo] Cannot fetch review comments: repository info not available"); console.warn('[PRInfo] Cannot fetch review comments: repository info not available');
} }
const prInfo: PRInfo = { const prInfo: PRInfo = {
@@ -235,8 +220,8 @@ export function createPRInfoHandler() {
title: pr.title, title: pr.title,
url: pr.url, url: pr.url,
state: pr.state, state: pr.state,
author: pr.author?.login || "unknown", author: pr.author?.login || 'unknown',
body: pr.body || "", body: pr.body || '',
comments, comments,
reviewComments, reviewComments,
}; };
@@ -251,7 +236,7 @@ export function createPRInfoHandler() {
}); });
} catch (error) { } catch (error) {
// gh CLI failed - might not be authenticated or no remote // gh CLI failed - might not be authenticated or no remote
logError(error, "Failed to get PR info"); logError(error, 'Failed to get PR info');
res.json({ res.json({
success: true, success: true,
result: { result: {
@@ -262,7 +247,7 @@ export function createPRInfoHandler() {
}); });
} }
} catch (error) { } catch (error) {
logError(error, "PR info handler failed"); logError(error, 'PR info handler failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,10 +2,10 @@
* POST /pull endpoint - Pull latest changes for a worktree/branch * POST /pull endpoint - Pull latest changes for a worktree/branch
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -19,23 +19,22 @@ export function createPullHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
// Get current branch name // Get current branch name
const { stdout: branchOutput } = await execAsync( const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath } });
);
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
// Fetch latest from remote // Fetch latest from remote
await execAsync("git fetch origin", { cwd: worktreePath }); await execAsync('git fetch origin', { cwd: worktreePath });
// Check if there are local changes that would be overwritten // Check if there are local changes that would be overwritten
const { stdout: status } = await execAsync("git status --porcelain", { const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath, cwd: worktreePath,
}); });
const hasLocalChanges = status.trim().length > 0; const hasLocalChanges = status.trim().length > 0;
@@ -43,35 +42,34 @@ export function createPullHandler() {
if (hasLocalChanges) { if (hasLocalChanges) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "You have local changes. Please commit them before pulling.", error: 'You have local changes. Please commit them before pulling.',
}); });
return; return;
} }
// Pull latest changes // Pull latest changes
try { try {
const { stdout: pullOutput } = await execAsync( const { stdout: pullOutput } = await execAsync(`git pull origin ${branchName}`, {
`git pull origin ${branchName}`, cwd: worktreePath,
{ cwd: worktreePath } });
);
// Check if we pulled any changes // Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes("Already up to date"); const alreadyUpToDate = pullOutput.includes('Already up to date');
res.json({ res.json({
success: true, success: true,
result: { result: {
branch: branchName, branch: branchName,
pulled: !alreadyUpToDate, pulled: !alreadyUpToDate,
message: alreadyUpToDate ? "Already up to date" : "Pulled latest changes", message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
}, },
}); });
} catch (pullError: unknown) { } catch (pullError: unknown) {
const err = pullError as { stderr?: string; message?: string }; const err = pullError as { stderr?: string; message?: string };
const errorMsg = err.stderr || err.message || "Pull failed"; const errorMsg = err.stderr || err.message || 'Pull failed';
// Check for common errors // Check for common errors
if (errorMsg.includes("no tracking information")) { if (errorMsg.includes('no tracking information')) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`, error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`,
@@ -85,7 +83,7 @@ export function createPullHandler() {
}); });
} }
} catch (error) { } catch (error) {
logError(error, "Pull failed"); logError(error, 'Pull failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -2,10 +2,10 @@
* POST /push endpoint - Push a worktree branch to remote * POST /push endpoint - Push a worktree branch to remote
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -20,20 +20,19 @@ export function createPushHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
// Get branch name // Get branch name
const { stdout: branchOutput } = await execAsync( const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath } });
);
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
// Push the branch // Push the branch
const forceFlag = force ? "--force" : ""; const forceFlag = force ? '--force' : '';
try { try {
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, { await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
cwd: worktreePath, cwd: worktreePath,
@@ -54,7 +53,7 @@ export function createPushHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Push worktree failed"); logError(error, 'Push worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -6,9 +6,9 @@
* affecting the main dev server. * affecting the main dev server.
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getDevServerService } from "../../../services/dev-server-service.js"; import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createStartDevHandler() { export function createStartDevHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -21,7 +21,7 @@ export function createStartDevHandler() {
if (!projectPath) { if (!projectPath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "projectPath is required", error: 'projectPath is required',
}); });
return; return;
} }
@@ -29,7 +29,7 @@ export function createStartDevHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath is required", error: 'worktreePath is required',
}); });
return; return;
} }
@@ -50,11 +50,11 @@ export function createStartDevHandler() {
} else { } else {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: result.error || "Failed to start dev server", error: result.error || 'Failed to start dev server',
}); });
} }
} catch (error) { } catch (error) {
logError(error, "Start dev server failed"); logError(error, 'Start dev server failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -5,9 +5,9 @@
* freeing up the ports for reuse. * freeing up the ports for reuse.
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { getDevServerService } from "../../../services/dev-server-service.js"; import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
export function createStopDevHandler() { export function createStopDevHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -19,7 +19,7 @@ export function createStopDevHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath is required", error: 'worktreePath is required',
}); });
return; return;
} }
@@ -38,11 +38,11 @@ export function createStopDevHandler() {
} else { } else {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: result.error || "Failed to stop dev server", error: result.error || 'Failed to stop dev server',
}); });
} }
} catch (error) { } catch (error) {
logError(error, "Stop dev server failed"); logError(error, 'Stop dev server failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -6,10 +6,10 @@
* the user should commit first. * the user should commit first.
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from 'express';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -19,13 +19,16 @@ const execAsync = promisify(exec);
*/ */
async function hasUncommittedChanges(cwd: string): Promise<boolean> { async function hasUncommittedChanges(cwd: string): Promise<boolean> {
try { try {
const { stdout } = await execAsync("git status --porcelain", { cwd }); const { stdout } = await execAsync('git status --porcelain', { cwd });
const lines = stdout.trim().split("\n").filter((line) => { const lines = stdout
if (!line.trim()) return false; .trim()
// Exclude .worktrees/ directory (created by automaker) .split('\n')
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false; .filter((line) => {
return true; if (!line.trim()) return false;
}); // Exclude .worktrees/ directory (created by automaker)
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
return true;
});
return lines.length > 0; return lines.length > 0;
} catch { } catch {
return false; return false;
@@ -38,18 +41,21 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
*/ */
async function getChangesSummary(cwd: string): Promise<string> { async function getChangesSummary(cwd: string): Promise<string> {
try { try {
const { stdout } = await execAsync("git status --short", { cwd }); const { stdout } = await execAsync('git status --short', { cwd });
const lines = stdout.trim().split("\n").filter((line) => { const lines = stdout
if (!line.trim()) return false; .trim()
// Exclude .worktrees/ directory .split('\n')
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false; .filter((line) => {
return true; if (!line.trim()) return false;
}); // Exclude .worktrees/ directory
if (lines.length === 0) return ""; if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
if (lines.length <= 5) return lines.join(", "); return true;
return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`; });
if (lines.length === 0) return '';
if (lines.length <= 5) return lines.join(', ');
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
} catch { } catch {
return "unknown changes"; return 'unknown changes';
} }
} }
@@ -64,7 +70,7 @@ export function createSwitchBranchHandler() {
if (!worktreePath) { if (!worktreePath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "worktreePath required", error: 'worktreePath required',
}); });
return; return;
} }
@@ -72,16 +78,15 @@ export function createSwitchBranchHandler() {
if (!branchName) { if (!branchName) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "branchName required", error: 'branchName required',
}); });
return; return;
} }
// Get current branch // Get current branch
const { stdout: currentBranchOutput } = await execAsync( const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
"git rev-parse --abbrev-ref HEAD", cwd: worktreePath,
{ cwd: worktreePath } });
);
const previousBranch = currentBranchOutput.trim(); const previousBranch = currentBranchOutput.trim();
if (previousBranch === branchName) { if (previousBranch === branchName) {
@@ -115,7 +120,7 @@ export function createSwitchBranchHandler() {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`, error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`,
code: "UNCOMMITTED_CHANGES", code: 'UNCOMMITTED_CHANGES',
}); });
return; return;
} }
@@ -132,7 +137,7 @@ export function createSwitchBranchHandler() {
}, },
}); });
} catch (error) { } catch (error) {
logError(error, "Switch branch failed"); logError(error, 'Switch branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -5,11 +5,11 @@
* Supports cross-platform shell detection including WSL. * Supports cross-platform shell detection including WSL.
*/ */
import * as pty from "node-pty"; import * as pty from 'node-pty';
import { EventEmitter } from "events"; import { EventEmitter } from 'events';
import * as os from "os"; import * as os from 'os';
import * as fs from "fs"; import * as fs from 'fs';
import * as path from "path"; import * as path from 'path';
// Maximum scrollback buffer size (characters) // Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
@@ -21,7 +21,7 @@ export const MAX_MAX_SESSIONS = 1000;
// Maximum number of concurrent terminal sessions // Maximum number of concurrent terminal sessions
// Can be overridden via TERMINAL_MAX_SESSIONS environment variable // Can be overridden via TERMINAL_MAX_SESSIONS environment variable
// Default set to 1000 - effectively unlimited for most use cases // Default set to 1000 - effectively unlimited for most use cases
let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || "1000", 10); let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
// Throttle output to prevent overwhelming WebSocket under heavy load // Throttle output to prevent overwhelming WebSocket under heavy load
// Using 4ms for responsive input feedback while still preventing flood // Using 4ms for responsive input feedback while still preventing flood
@@ -65,20 +65,20 @@ export class TerminalService extends EventEmitter {
const platform = os.platform(); const platform = os.platform();
// Check if running in WSL // Check if running in WSL
if (platform === "linux" && this.isWSL()) { if (platform === 'linux' && this.isWSL()) {
// In WSL, prefer the user's configured shell or bash // In WSL, prefer the user's configured shell or bash
const userShell = process.env.SHELL || "/bin/bash"; const userShell = process.env.SHELL || '/bin/bash';
if (fs.existsSync(userShell)) { if (fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] }; return { shell: userShell, args: ['--login'] };
} }
return { shell: "/bin/bash", args: ["--login"] }; return { shell: '/bin/bash', args: ['--login'] };
} }
switch (platform) { switch (platform) {
case "win32": { case 'win32': {
// Windows: prefer PowerShell, fall back to cmd // Windows: prefer PowerShell, fall back to cmd
const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
if (fs.existsSync(pwshCore)) { if (fs.existsSync(pwshCore)) {
return { shell: pwshCore, args: [] }; return { shell: pwshCore, args: [] };
@@ -86,32 +86,32 @@ export class TerminalService extends EventEmitter {
if (fs.existsSync(pwsh)) { if (fs.existsSync(pwsh)) {
return { shell: pwsh, args: [] }; return { shell: pwsh, args: [] };
} }
return { shell: "cmd.exe", args: [] }; return { shell: 'cmd.exe', args: [] };
} }
case "darwin": { case 'darwin': {
// macOS: prefer user's shell, then zsh, then bash // macOS: prefer user's shell, then zsh, then bash
const userShell = process.env.SHELL; const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) { if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] }; return { shell: userShell, args: ['--login'] };
} }
if (fs.existsSync("/bin/zsh")) { if (fs.existsSync('/bin/zsh')) {
return { shell: "/bin/zsh", args: ["--login"] }; return { shell: '/bin/zsh', args: ['--login'] };
} }
return { shell: "/bin/bash", args: ["--login"] }; return { shell: '/bin/bash', args: ['--login'] };
} }
case "linux": case 'linux':
default: { default: {
// Linux: prefer user's shell, then bash, then sh // Linux: prefer user's shell, then bash, then sh
const userShell = process.env.SHELL; const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) { if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] }; return { shell: userShell, args: ['--login'] };
} }
if (fs.existsSync("/bin/bash")) { if (fs.existsSync('/bin/bash')) {
return { shell: "/bin/bash", args: ["--login"] }; return { shell: '/bin/bash', args: ['--login'] };
} }
return { shell: "/bin/sh", args: [] }; return { shell: '/bin/sh', args: [] };
} }
} }
} }
@@ -122,9 +122,9 @@ export class TerminalService extends EventEmitter {
isWSL(): boolean { isWSL(): boolean {
try { try {
// Check /proc/version for Microsoft/WSL indicators // Check /proc/version for Microsoft/WSL indicators
if (fs.existsSync("/proc/version")) { if (fs.existsSync('/proc/version')) {
const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase(); const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
return version.includes("microsoft") || version.includes("wsl"); return version.includes('microsoft') || version.includes('wsl');
} }
// Check for WSL environment variable // Check for WSL environment variable
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) { if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
@@ -170,19 +170,19 @@ export class TerminalService extends EventEmitter {
let cwd = requestedCwd.trim(); let cwd = requestedCwd.trim();
// Reject paths with null bytes (could bypass path checks) // Reject paths with null bytes (could bypass path checks)
if (cwd.includes("\0")) { if (cwd.includes('\0')) {
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`); console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
return homeDir; return homeDir;
} }
// Fix double slashes at start (but not for Windows UNC paths) // Fix double slashes at start (but not for Windows UNC paths)
if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) { if (cwd.startsWith('//') && !cwd.startsWith('//wsl')) {
cwd = cwd.slice(1); cwd = cwd.slice(1);
} }
// Normalize the path to resolve . and .. segments // Normalize the path to resolve . and .. segments
// Skip normalization for WSL UNC paths as path.resolve would break them // Skip normalization for WSL UNC paths as path.resolve would break them
if (!cwd.startsWith("//wsl")) { if (!cwd.startsWith('//wsl')) {
cwd = path.resolve(cwd); cwd = path.resolve(cwd);
} }
@@ -247,19 +247,19 @@ export class TerminalService extends EventEmitter {
// These settings ensure consistent terminal behavior across platforms // These settings ensure consistent terminal behavior across platforms
const env: Record<string, string> = { const env: Record<string, string> = {
...process.env, ...process.env,
TERM: "xterm-256color", TERM: 'xterm-256color',
COLORTERM: "truecolor", COLORTERM: 'truecolor',
TERM_PROGRAM: "automaker-terminal", TERM_PROGRAM: 'automaker-terminal',
// Ensure proper locale for character handling // Ensure proper locale for character handling
LANG: process.env.LANG || "en_US.UTF-8", LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8", LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
...options.env, ...options.env,
}; };
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`); console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, { const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color", name: 'xterm-256color',
cols: options.cols || 80, cols: options.cols || 80,
rows: options.rows || 24, rows: options.rows || 24,
cwd, cwd,
@@ -272,8 +272,8 @@ export class TerminalService extends EventEmitter {
cwd, cwd,
createdAt: new Date(), createdAt: new Date(),
shell, shell,
scrollbackBuffer: "", scrollbackBuffer: '',
outputBuffer: "", outputBuffer: '',
flushTimeout: null, flushTimeout: null,
resizeInProgress: false, resizeInProgress: false,
resizeDebounceTimeout: null, resizeDebounceTimeout: null,
@@ -293,12 +293,12 @@ export class TerminalService extends EventEmitter {
// Schedule another flush for remaining data // Schedule another flush for remaining data
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else { } else {
session.outputBuffer = ""; session.outputBuffer = '';
session.flushTimeout = null; session.flushTimeout = null;
} }
this.dataCallbacks.forEach((cb) => cb(id, dataToSend)); this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit("data", id, dataToSend); this.emit('data', id, dataToSend);
}; };
// Forward data events with throttling // Forward data events with throttling
@@ -331,7 +331,7 @@ export class TerminalService extends EventEmitter {
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`); console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
this.sessions.delete(id); this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode)); this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit("exit", id, exitCode); this.emit('exit', id, exitCode);
}); });
console.log(`[Terminal] Session ${id} created successfully`); console.log(`[Terminal] Session ${id} created successfully`);
@@ -414,7 +414,7 @@ export class TerminalService extends EventEmitter {
// First try graceful SIGTERM to allow process cleanup // First try graceful SIGTERM to allow process cleanup
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`); console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
session.pty.kill("SIGTERM"); session.pty.kill('SIGTERM');
// Schedule SIGKILL fallback if process doesn't exit gracefully // Schedule SIGKILL fallback if process doesn't exit gracefully
// The onExit handler will remove session from map when it actually exits // The onExit handler will remove session from map when it actually exits
@@ -422,7 +422,7 @@ export class TerminalService extends EventEmitter {
if (this.sessions.has(sessionId)) { if (this.sessions.has(sessionId)) {
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try { try {
session.pty.kill("SIGKILL"); session.pty.kill('SIGKILL');
} catch { } catch {
// Process may have already exited // Process may have already exited
} }
@@ -467,7 +467,7 @@ export class TerminalService extends EventEmitter {
// Clear any pending output that hasn't been flushed yet // Clear any pending output that hasn't been flushed yet
// This data is already in scrollbackBuffer // This data is already in scrollbackBuffer
session.outputBuffer = ""; session.outputBuffer = '';
if (session.flushTimeout) { if (session.flushTimeout) {
clearTimeout(session.flushTimeout); clearTimeout(session.flushTimeout);
session.flushTimeout = null; session.flushTimeout = null;

View File

@@ -4,11 +4,11 @@
// 1x1 transparent PNG base64 data // 1x1 transparent PNG base64 data
export const pngBase64Fixture = export const pngBase64Fixture =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
export const imageDataFixture = { export const imageDataFixture = {
base64: pngBase64Fixture, base64: pngBase64Fixture,
mimeType: "image/png", mimeType: 'image/png',
filename: "test.png", filename: 'test.png',
originalPath: "/path/to/test.png", originalPath: '/path/to/test.png',
}; };

View File

@@ -1,11 +1,11 @@
/** /**
* Helper for creating test git repositories for integration tests * Helper for creating test git repositories for integration tests
*/ */
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import * as fs from "fs/promises"; import * as fs from 'fs/promises';
import * as path from "path"; import * as path from 'path';
import * as os from "os"; import * as os from 'os';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -18,36 +18,36 @@ export interface TestRepo {
* Create a temporary git repository for testing * Create a temporary git repository for testing
*/ */
export async function createTestGitRepo(): Promise<TestRepo> { export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "automaker-test-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
// Initialize git repo // Initialize git repo
await execAsync("git init", { cwd: tmpDir }); await execAsync('git init', { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir }); await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir }); await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Create initial commit // Create initial commit
await fs.writeFile(path.join(tmpDir, "README.md"), "# Test Project\n"); await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n');
await execAsync("git add .", { cwd: tmpDir }); await execAsync('git add .', { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
// Create main branch explicitly // Create main branch explicitly
await execAsync("git branch -M main", { cwd: tmpDir }); await execAsync('git branch -M main', { cwd: tmpDir });
return { return {
path: tmpDir, path: tmpDir,
cleanup: async () => { cleanup: async () => {
try { try {
// Remove all worktrees first // Remove all worktrees first
const { stdout } = await execAsync("git worktree list --porcelain", { const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: tmpDir, cwd: tmpDir,
}).catch(() => ({ stdout: "" })); }).catch(() => ({ stdout: '' }));
const worktrees = stdout const worktrees = stdout
.split("\n\n") .split('\n\n')
.slice(1) // Skip main worktree .slice(1) // Skip main worktree
.map((block) => { .map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree ")); const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
return pathLine ? pathLine.replace("worktree ", "") : null; return pathLine ? pathLine.replace('worktree ', '') : null;
}) })
.filter(Boolean); .filter(Boolean);
@@ -64,7 +64,7 @@ export async function createTestGitRepo(): Promise<TestRepo> {
// Remove the repository // Remove the repository
await fs.rm(tmpDir, { recursive: true, force: true }); await fs.rm(tmpDir, { recursive: true, force: true });
} catch (error) { } catch (error) {
console.error("Failed to cleanup test repo:", error); console.error('Failed to cleanup test repo:', error);
} }
}, },
}; };
@@ -78,24 +78,21 @@ export async function createTestFeature(
featureId: string, featureId: string,
featureData: any featureData: any
): Promise<void> { ): Promise<void> {
const featuresDir = path.join(repoPath, ".automaker", "features"); const featuresDir = path.join(repoPath, '.automaker', 'features');
const featureDir = path.join(featuresDir, featureId); const featureDir = path.join(featuresDir, featureId);
await fs.mkdir(featureDir, { recursive: true }); await fs.mkdir(featureDir, { recursive: true });
await fs.writeFile( await fs.writeFile(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2));
path.join(featureDir, "feature.json"),
JSON.stringify(featureData, null, 2)
);
} }
/** /**
* Get list of git branches * Get list of git branches
*/ */
export async function listBranches(repoPath: string): Promise<string[]> { export async function listBranches(repoPath: string): Promise<string[]> {
const { stdout } = await execAsync("git branch --list", { cwd: repoPath }); const { stdout } = await execAsync('git branch --list', { cwd: repoPath });
return stdout return stdout
.split("\n") .split('\n')
.map((line) => line.trim().replace(/^[*+]\s*/, "")) .map((line) => line.trim().replace(/^[*+]\s*/, ''))
.filter(Boolean); .filter(Boolean);
} }
@@ -104,16 +101,16 @@ export async function listBranches(repoPath: string): Promise<string[]> {
*/ */
export async function listWorktrees(repoPath: string): Promise<string[]> { export async function listWorktrees(repoPath: string): Promise<string[]> {
try { try {
const { stdout } = await execAsync("git worktree list --porcelain", { const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: repoPath, cwd: repoPath,
}); });
return stdout return stdout
.split("\n\n") .split('\n\n')
.slice(1) // Skip main worktree .slice(1) // Skip main worktree
.map((block) => { .map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree ")); const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
return pathLine ? pathLine.replace("worktree ", "") : null; return pathLine ? pathLine.replace('worktree ', '') : null;
}) })
.filter(Boolean) as string[]; .filter(Boolean) as string[];
} catch { } catch {
@@ -124,10 +121,7 @@ export async function listWorktrees(repoPath: string): Promise<string[]> {
/** /**
* Check if a branch exists * Check if a branch exists
*/ */
export async function branchExists( export async function branchExists(repoPath: string, branchName: string): Promise<boolean> {
repoPath: string,
branchName: string
): Promise<boolean> {
const branches = await listBranches(repoPath); const branches = await listBranches(repoPath);
return branches.includes(branchName); return branches.includes(branchName);
} }
@@ -135,10 +129,7 @@ export async function branchExists(
/** /**
* Check if a worktree exists * Check if a worktree exists
*/ */
export async function worktreeExists( export async function worktreeExists(repoPath: string, worktreePath: string): Promise<boolean> {
repoPath: string,
worktreePath: string
): Promise<boolean> {
const worktrees = await listWorktrees(repoPath); const worktrees = await listWorktrees(repoPath);
return worktrees.some((wt) => wt === worktreePath); return worktrees.some((wt) => wt === worktreePath);
} }

View File

@@ -1,22 +1,20 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from 'vitest';
import { createCreateHandler } from "@/routes/worktree/routes/create.js"; import { createCreateHandler } from '@/routes/worktree/routes/create.js';
import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js"; import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from '@/routes/worktree/common.js';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
import * as fs from "fs/promises"; import * as fs from 'fs/promises';
import * as os from "os"; import * as os from 'os';
import * as path from "path"; import * as path from 'path';
const execAsync = promisify(exec); const execAsync = promisify(exec);
describe("worktree create route - repositories without commits", () => { describe('worktree create route - repositories without commits', () => {
let repoPath: string | null = null; let repoPath: string | null = null;
async function initRepoWithoutCommit() { async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp( repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
path.join(os.tmpdir(), "automaker-no-commit-") await execAsync('git init', { cwd: repoPath });
);
await execAsync("git init", { cwd: repoPath });
await execAsync('git config user.email "test@example.com"', { await execAsync('git config user.email "test@example.com"', {
cwd: repoPath, cwd: repoPath,
}); });
@@ -32,14 +30,14 @@ describe("worktree create route - repositories without commits", () => {
repoPath = null; repoPath = null;
}); });
it("creates an initial commit before adding a worktree when HEAD is missing", async () => { it('creates an initial commit before adding a worktree when HEAD is missing', async () => {
await initRepoWithoutCommit(); await initRepoWithoutCommit();
const handler = createCreateHandler(); const handler = createCreateHandler();
const json = vi.fn(); const json = vi.fn();
const status = vi.fn().mockReturnThis(); const status = vi.fn().mockReturnThis();
const req = { const req = {
body: { projectPath: repoPath, branchName: "feature/no-head" }, body: { projectPath: repoPath, branchName: 'feature/no-head' },
} as any; } as any;
const res = { const res = {
json, json,
@@ -53,17 +51,12 @@ describe("worktree create route - repositories without commits", () => {
const payload = json.mock.calls[0][0]; const payload = json.mock.calls[0][0];
expect(payload.success).toBe(true); expect(payload.success).toBe(true);
const { stdout: commitCount } = await execAsync( const { stdout: commitCount } = await execAsync('git rev-list --count HEAD', {
"git rev-list --count HEAD", cwd: repoPath!,
{ cwd: repoPath! } });
);
expect(Number(commitCount.trim())).toBeGreaterThan(0); expect(Number(commitCount.trim())).toBeGreaterThan(0);
const { stdout: latestMessage } = await execAsync( const { stdout: latestMessage } = await execAsync('git log -1 --pretty=%B', { cwd: repoPath! });
"git log -1 --pretty=%B",
{ cwd: repoPath! }
);
expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE); expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE);
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AutoModeService } from "@/services/auto-mode-service.js"; import { AutoModeService } from '@/services/auto-mode-service.js';
import { ProviderFactory } from "@/providers/provider-factory.js"; import { ProviderFactory } from '@/providers/provider-factory.js';
import { FeatureLoader } from "@/services/feature-loader.js"; import { FeatureLoader } from '@/services/feature-loader.js';
import { import {
createTestGitRepo, createTestGitRepo,
createTestFeature, createTestFeature,
@@ -10,17 +10,17 @@ import {
branchExists, branchExists,
worktreeExists, worktreeExists,
type TestRepo, type TestRepo,
} from "../helpers/git-test-repo.js"; } from '../helpers/git-test-repo.js';
import * as fs from "fs/promises"; import * as fs from 'fs/promises';
import * as path from "path"; import * as path from 'path';
import { exec } from "child_process"; import { exec } from 'child_process';
import { promisify } from "util"; import { promisify } from 'util';
const execAsync = promisify(exec); const execAsync = promisify(exec);
vi.mock("@/providers/provider-factory.js"); vi.mock('@/providers/provider-factory.js');
describe("auto-mode-service.ts (integration)", () => { describe('auto-mode-service.ts (integration)', () => {
let service: AutoModeService; let service: AutoModeService;
let testRepo: TestRepo; let testRepo: TestRepo;
let featureLoader: FeatureLoader; let featureLoader: FeatureLoader;
@@ -46,22 +46,22 @@ describe("auto-mode-service.ts (integration)", () => {
} }
}); });
describe("worktree operations", () => { describe('worktree operations', () => {
it("should use existing git worktree for feature", async () => { it('should use existing git worktree for feature', async () => {
const branchName = "feature/test-feature-1"; const branchName = 'feature/test-feature-1';
// Create a test feature with branchName set // Create a test feature with branchName set
await createTestFeature(testRepo.path, "test-feature-1", { await createTestFeature(testRepo.path, 'test-feature-1', {
id: "test-feature-1", id: 'test-feature-1',
category: "test", category: 'test',
description: "Test feature", description: 'Test feature',
status: "pending", status: 'pending',
branchName: branchName, branchName: branchName,
}); });
// Create worktree before executing (worktrees are now created when features are added/edited) // Create worktree before executing (worktrees are now created when features are added/edited)
const worktreesDir = path.join(testRepo.path, ".worktrees"); const worktreesDir = path.join(testRepo.path, '.worktrees');
const worktreePath = path.join(worktreesDir, "test-feature-1"); const worktreePath = path.join(worktreesDir, 'test-feature-1');
await fs.mkdir(worktreesDir, { recursive: true }); await fs.mkdir(worktreesDir, { recursive: true });
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, { await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, {
cwd: testRepo.path, cwd: testRepo.path,
@@ -69,30 +69,28 @@ describe("auto-mode-service.ts (integration)", () => {
// Mock provider to complete quickly // Mock provider to complete quickly
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "assistant", type: 'assistant',
message: { message: {
role: "assistant", role: 'assistant',
content: [{ type: "text", text: "Feature implemented" }], content: [{ type: 'text', text: 'Feature implemented' }],
}, },
}; };
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Execute feature with worktrees enabled // Execute feature with worktrees enabled
await service.executeFeature( await service.executeFeature(
testRepo.path, testRepo.path,
"test-feature-1", 'test-feature-1',
true, // useWorktrees true, // useWorktrees
false // isAutoMode false // isAutoMode
); );
@@ -107,8 +105,8 @@ describe("auto-mode-service.ts (integration)", () => {
const worktrees = await listWorktrees(testRepo.path); const worktrees = await listWorktrees(testRepo.path);
expect(worktrees.length).toBeGreaterThan(0); expect(worktrees.length).toBeGreaterThan(0);
// Verify that at least one worktree path contains our feature ID // Verify that at least one worktree path contains our feature ID
const worktreePathsMatch = worktrees.some(wt => const worktreePathsMatch = worktrees.some(
wt.includes("test-feature-1") || wt.includes(".worktrees") (wt) => wt.includes('test-feature-1') || wt.includes('.worktrees')
); );
expect(worktreePathsMatch).toBe(true); expect(worktreePathsMatch).toBe(true);
@@ -116,243 +114,200 @@ describe("auto-mode-service.ts (integration)", () => {
// This is expected behavior - manual cleanup is required // This is expected behavior - manual cleanup is required
}, 30000); }, 30000);
it("should handle error gracefully", async () => { it('should handle error gracefully', async () => {
await createTestFeature(testRepo.path, "test-feature-error", { await createTestFeature(testRepo.path, 'test-feature-error', {
id: "test-feature-error", id: 'test-feature-error',
category: "test", category: 'test',
description: "Test feature that errors", description: 'Test feature that errors',
status: "pending", status: 'pending',
}); });
// Mock provider that throws error // Mock provider that throws error
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
throw new Error("Provider error"); throw new Error('Provider error');
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Execute feature (should handle error) // Execute feature (should handle error)
await service.executeFeature( await service.executeFeature(testRepo.path, 'test-feature-error', true, false);
testRepo.path,
"test-feature-error",
true,
false
);
// Verify feature status was updated to backlog (error status) // Verify feature status was updated to backlog (error status)
const feature = await featureLoader.get( const feature = await featureLoader.get(testRepo.path, 'test-feature-error');
testRepo.path, expect(feature?.status).toBe('backlog');
"test-feature-error"
);
expect(feature?.status).toBe("backlog");
}, 30000); }, 30000);
it("should work without worktrees", async () => { it('should work without worktrees', async () => {
await createTestFeature(testRepo.path, "test-no-worktree", { await createTestFeature(testRepo.path, 'test-no-worktree', {
id: "test-no-worktree", id: 'test-no-worktree',
category: "test", category: 'test',
description: "Test without worktree", description: 'Test without worktree',
status: "pending", status: 'pending',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Execute without worktrees // Execute without worktrees
await service.executeFeature( await service.executeFeature(
testRepo.path, testRepo.path,
"test-no-worktree", 'test-no-worktree',
false, // useWorktrees = false false, // useWorktrees = false
false false
); );
// Feature should be updated successfully // Feature should be updated successfully
const feature = await featureLoader.get( const feature = await featureLoader.get(testRepo.path, 'test-no-worktree');
testRepo.path, expect(feature?.status).toBe('waiting_approval');
"test-no-worktree"
);
expect(feature?.status).toBe("waiting_approval");
}, 30000); }, 30000);
}); });
describe("feature execution", () => { describe('feature execution', () => {
it("should execute feature and update status", async () => { it('should execute feature and update status', async () => {
await createTestFeature(testRepo.path, "feature-exec-1", { await createTestFeature(testRepo.path, 'feature-exec-1', {
id: "feature-exec-1", id: 'feature-exec-1',
category: "ui", category: 'ui',
description: "Execute this feature", description: 'Execute this feature',
status: "pending", status: 'pending',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "assistant", type: 'assistant',
message: { message: {
role: "assistant", role: 'assistant',
content: [{ type: "text", text: "Implemented the feature" }], content: [{ type: 'text', text: 'Implemented the feature' }],
}, },
}; };
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
await service.executeFeature( await service.executeFeature(
testRepo.path, testRepo.path,
"feature-exec-1", 'feature-exec-1',
false, // Don't use worktrees so agent output is saved to main project false, // Don't use worktrees so agent output is saved to main project
false false
); );
// Check feature status was updated // Check feature status was updated
const feature = await featureLoader.get(testRepo.path, "feature-exec-1"); const feature = await featureLoader.get(testRepo.path, 'feature-exec-1');
expect(feature?.status).toBe("waiting_approval"); expect(feature?.status).toBe('waiting_approval');
// Check agent output was saved // Check agent output was saved
const agentOutput = await featureLoader.getAgentOutput( const agentOutput = await featureLoader.getAgentOutput(testRepo.path, 'feature-exec-1');
testRepo.path,
"feature-exec-1"
);
expect(agentOutput).toBeTruthy(); expect(agentOutput).toBeTruthy();
expect(agentOutput).toContain("Implemented the feature"); expect(agentOutput).toContain('Implemented the feature');
}, 30000); }, 30000);
it("should handle feature not found", async () => { it('should handle feature not found', async () => {
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Try to execute non-existent feature // Try to execute non-existent feature
await service.executeFeature( await service.executeFeature(testRepo.path, 'nonexistent-feature', true, false);
testRepo.path,
"nonexistent-feature",
true,
false
);
// Should emit error event // Should emit error event
expect(mockEvents.emit).toHaveBeenCalledWith( expect(mockEvents.emit).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
featureId: "nonexistent-feature", featureId: 'nonexistent-feature',
error: expect.stringContaining("not found"), error: expect.stringContaining('not found'),
}) })
); );
}, 30000); }, 30000);
it("should prevent duplicate feature execution", async () => { it('should prevent duplicate feature execution', async () => {
await createTestFeature(testRepo.path, "feature-dup", { await createTestFeature(testRepo.path, 'feature-dup', {
id: "feature-dup", id: 'feature-dup',
category: "test", category: 'test',
description: "Duplicate test", description: 'Duplicate test',
status: "pending", status: 'pending',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
// Simulate slow execution // Simulate slow execution
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Start first execution // Start first execution
const promise1 = service.executeFeature( const promise1 = service.executeFeature(testRepo.path, 'feature-dup', false, false);
testRepo.path,
"feature-dup",
false,
false
);
// Try to start second execution (should throw) // Try to start second execution (should throw)
await expect( await expect(
service.executeFeature(testRepo.path, "feature-dup", false, false) service.executeFeature(testRepo.path, 'feature-dup', false, false)
).rejects.toThrow("already running"); ).rejects.toThrow('already running');
await promise1; await promise1;
}, 30000); }, 30000);
it("should use feature-specific model", async () => { it('should use feature-specific model', async () => {
await createTestFeature(testRepo.path, "feature-model", { await createTestFeature(testRepo.path, 'feature-model', {
id: "feature-model", id: 'feature-model',
category: "test", category: 'test',
description: "Model test", description: 'Model test',
status: "pending", status: 'pending',
model: "claude-sonnet-4-20250514", model: 'claude-sonnet-4-20250514',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
await service.executeFeature( await service.executeFeature(testRepo.path, 'feature-model', false, false);
testRepo.path,
"feature-model",
false,
false
);
// Should have used claude-sonnet-4-20250514 // Should have used claude-sonnet-4-20250514
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith( expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514');
"claude-sonnet-4-20250514"
);
}, 30000); }, 30000);
}); });
describe("auto loop", () => { describe('auto loop', () => {
it("should start and stop auto loop", async () => { it('should start and stop auto loop', async () => {
const startPromise = service.startAutoLoop(testRepo.path, 2); const startPromise = service.startAutoLoop(testRepo.path, 2);
// Give it time to start // Give it time to start
@@ -365,35 +320,33 @@ describe("auto-mode-service.ts (integration)", () => {
await startPromise.catch(() => {}); // Cleanup await startPromise.catch(() => {}); // Cleanup
}, 10000); }, 10000);
it("should process pending features in auto loop", async () => { it('should process pending features in auto loop', async () => {
// Create multiple pending features // Create multiple pending features
await createTestFeature(testRepo.path, "auto-1", { await createTestFeature(testRepo.path, 'auto-1', {
id: "auto-1", id: 'auto-1',
category: "test", category: 'test',
description: "Auto feature 1", description: 'Auto feature 1',
status: "pending", status: 'pending',
}); });
await createTestFeature(testRepo.path, "auto-2", { await createTestFeature(testRepo.path, 'auto-2', {
id: "auto-2", id: 'auto-2',
category: "test", category: 'test',
description: "Auto feature 2", description: 'Auto feature 2',
status: "pending", status: 'pending',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Start auto loop // Start auto loop
const startPromise = service.startAutoLoop(testRepo.path, 2); const startPromise = service.startAutoLoop(testRepo.path, 2);
@@ -406,25 +359,25 @@ describe("auto-mode-service.ts (integration)", () => {
await startPromise.catch(() => {}); await startPromise.catch(() => {});
// Check that features were updated // Check that features were updated
const feature1 = await featureLoader.get(testRepo.path, "auto-1"); const feature1 = await featureLoader.get(testRepo.path, 'auto-1');
const feature2 = await featureLoader.get(testRepo.path, "auto-2"); const feature2 = await featureLoader.get(testRepo.path, 'auto-2');
// At least one should have been processed // At least one should have been processed
const processedCount = [feature1, feature2].filter( const processedCount = [feature1, feature2].filter(
(f) => f?.status === "waiting_approval" || f?.status === "in_progress" (f) => f?.status === 'waiting_approval' || f?.status === 'in_progress'
).length; ).length;
expect(processedCount).toBeGreaterThan(0); expect(processedCount).toBeGreaterThan(0);
}, 15000); }, 15000);
it("should respect max concurrency", async () => { it('should respect max concurrency', async () => {
// Create 5 features // Create 5 features
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
await createTestFeature(testRepo.path, `concurrent-${i}`, { await createTestFeature(testRepo.path, `concurrent-${i}`, {
id: `concurrent-${i}`, id: `concurrent-${i}`,
category: "test", category: 'test',
description: `Concurrent feature ${i}`, description: `Concurrent feature ${i}`,
status: "pending", status: 'pending',
}); });
} }
@@ -432,7 +385,7 @@ describe("auto-mode-service.ts (integration)", () => {
let maxConcurrent = 0; let maxConcurrent = 0;
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
concurrentCount++; concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount); maxConcurrent = Math.max(maxConcurrent, concurrentCount);
@@ -443,15 +396,13 @@ describe("auto-mode-service.ts (integration)", () => {
concurrentCount--; concurrentCount--;
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Start with max concurrency of 2 // Start with max concurrency of 2
const startPromise = service.startAutoLoop(testRepo.path, 2); const startPromise = service.startAutoLoop(testRepo.path, 2);
@@ -466,7 +417,7 @@ describe("auto-mode-service.ts (integration)", () => {
expect(maxConcurrent).toBeLessThanOrEqual(2); expect(maxConcurrent).toBeLessThanOrEqual(2);
}, 15000); }, 15000);
it("should emit auto mode events", async () => { it('should emit auto mode events', async () => {
const startPromise = service.startAutoLoop(testRepo.path, 1); const startPromise = service.startAutoLoop(testRepo.path, 1);
// Wait for start event // Wait for start event
@@ -474,7 +425,7 @@ describe("auto-mode-service.ts (integration)", () => {
// Check start event was emitted // Check start event was emitted
const startEvent = mockEvents.emit.mock.calls.find((call) => const startEvent = mockEvents.emit.mock.calls.find((call) =>
call[1]?.message?.includes("Auto mode started") call[1]?.message?.includes('Auto mode started')
); );
expect(startEvent).toBeTruthy(); expect(startEvent).toBeTruthy();
@@ -484,74 +435,69 @@ describe("auto-mode-service.ts (integration)", () => {
// Check stop event was emitted (emitted immediately by stopAutoLoop) // Check stop event was emitted (emitted immediately by stopAutoLoop)
const stopEvent = mockEvents.emit.mock.calls.find( const stopEvent = mockEvents.emit.mock.calls.find(
(call) => (call) =>
call[1]?.type === "auto_mode_stopped" || call[1]?.type === 'auto_mode_stopped' || call[1]?.message?.includes('Auto mode stopped')
call[1]?.message?.includes("Auto mode stopped")
); );
expect(stopEvent).toBeTruthy(); expect(stopEvent).toBeTruthy();
}, 10000); }, 10000);
}); });
describe("error handling", () => { describe('error handling', () => {
it("should handle provider errors gracefully", async () => { it('should handle provider errors gracefully', async () => {
await createTestFeature(testRepo.path, "error-feature", { await createTestFeature(testRepo.path, 'error-feature', {
id: "error-feature", id: 'error-feature',
category: "test", category: 'test',
description: "Error test", description: 'Error test',
status: "pending", status: 'pending',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
throw new Error("Provider execution failed"); throw new Error('Provider execution failed');
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
// Should not throw // Should not throw
await service.executeFeature(testRepo.path, "error-feature", true, false); await service.executeFeature(testRepo.path, 'error-feature', true, false);
// Feature should be marked as backlog (error status) // Feature should be marked as backlog (error status)
const feature = await featureLoader.get(testRepo.path, "error-feature"); const feature = await featureLoader.get(testRepo.path, 'error-feature');
expect(feature?.status).toBe("backlog"); expect(feature?.status).toBe('backlog');
}, 30000); }, 30000);
it("should continue auto loop after feature error", async () => { it('should continue auto loop after feature error', async () => {
await createTestFeature(testRepo.path, "fail-1", { await createTestFeature(testRepo.path, 'fail-1', {
id: "fail-1", id: 'fail-1',
category: "test", category: 'test',
description: "Will fail", description: 'Will fail',
status: "pending", status: 'pending',
}); });
await createTestFeature(testRepo.path, "success-1", { await createTestFeature(testRepo.path, 'success-1', {
id: "success-1", id: 'success-1',
category: "test", category: 'test',
description: "Will succeed", description: 'Will succeed',
status: "pending", status: 'pending',
}); });
let callCount = 0; let callCount = 0;
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
callCount++; callCount++;
if (callCount === 1) { if (callCount === 1) {
throw new Error("First feature fails"); throw new Error('First feature fails');
} }
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
const startPromise = service.startAutoLoop(testRepo.path, 1); const startPromise = service.startAutoLoop(testRepo.path, 1);
@@ -566,200 +512,177 @@ describe("auto-mode-service.ts (integration)", () => {
}, 15000); }, 15000);
}); });
describe("planning mode", () => { describe('planning mode', () => {
it("should execute feature with skip planning mode", async () => { it('should execute feature with skip planning mode', async () => {
await createTestFeature(testRepo.path, "skip-plan-feature", { await createTestFeature(testRepo.path, 'skip-plan-feature', {
id: "skip-plan-feature", id: 'skip-plan-feature',
category: "test", category: 'test',
description: "Feature with skip planning", description: 'Feature with skip planning',
status: "pending", status: 'pending',
planningMode: "skip", planningMode: 'skip',
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "assistant", type: 'assistant',
message: { message: {
role: "assistant", role: 'assistant',
content: [{ type: "text", text: "Feature implemented" }], content: [{ type: 'text', text: 'Feature implemented' }],
}, },
}; };
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
await service.executeFeature( await service.executeFeature(testRepo.path, 'skip-plan-feature', false, false);
testRepo.path,
"skip-plan-feature",
false,
false
);
const feature = await featureLoader.get(testRepo.path, "skip-plan-feature"); const feature = await featureLoader.get(testRepo.path, 'skip-plan-feature');
expect(feature?.status).toBe("waiting_approval"); expect(feature?.status).toBe('waiting_approval');
}, 30000); }, 30000);
it("should execute feature with lite planning mode without approval", async () => { it('should execute feature with lite planning mode without approval', async () => {
await createTestFeature(testRepo.path, "lite-plan-feature", { await createTestFeature(testRepo.path, 'lite-plan-feature', {
id: "lite-plan-feature", id: 'lite-plan-feature',
category: "test", category: 'test',
description: "Feature with lite planning", description: 'Feature with lite planning',
status: "pending", status: 'pending',
planningMode: "lite", planningMode: 'lite',
requirePlanApproval: false, requirePlanApproval: false,
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "assistant", type: 'assistant',
message: { message: {
role: "assistant", role: 'assistant',
content: [{ type: "text", text: "[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented" }], content: [
{
type: 'text',
text: '[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented',
},
],
}, },
}; };
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
await service.executeFeature( await service.executeFeature(testRepo.path, 'lite-plan-feature', false, false);
testRepo.path,
"lite-plan-feature",
false,
false
);
const feature = await featureLoader.get(testRepo.path, "lite-plan-feature"); const feature = await featureLoader.get(testRepo.path, 'lite-plan-feature');
expect(feature?.status).toBe("waiting_approval"); expect(feature?.status).toBe('waiting_approval');
}, 30000); }, 30000);
it("should emit planning_started event for spec mode", async () => { it('should emit planning_started event for spec mode', async () => {
await createTestFeature(testRepo.path, "spec-plan-feature", { await createTestFeature(testRepo.path, 'spec-plan-feature', {
id: "spec-plan-feature", id: 'spec-plan-feature',
category: "test", category: 'test',
description: "Feature with spec planning", description: 'Feature with spec planning',
status: "pending", status: 'pending',
planningMode: "spec", planningMode: 'spec',
requirePlanApproval: false, requirePlanApproval: false,
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "assistant", type: 'assistant',
message: { message: {
role: "assistant", role: 'assistant',
content: [{ type: "text", text: "Spec generated\n\n[SPEC_GENERATED] Review the spec." }], content: [
{ type: 'text', text: 'Spec generated\n\n[SPEC_GENERATED] Review the spec.' },
],
}, },
}; };
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
await service.executeFeature( await service.executeFeature(testRepo.path, 'spec-plan-feature', false, false);
testRepo.path,
"spec-plan-feature",
false,
false
);
// Check planning_started event was emitted // Check planning_started event was emitted
const planningEvent = mockEvents.emit.mock.calls.find( const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'spec');
(call) => call[1]?.mode === "spec"
);
expect(planningEvent).toBeTruthy(); expect(planningEvent).toBeTruthy();
}, 30000); }, 30000);
it("should handle feature with full planning mode", async () => { it('should handle feature with full planning mode', async () => {
await createTestFeature(testRepo.path, "full-plan-feature", { await createTestFeature(testRepo.path, 'full-plan-feature', {
id: "full-plan-feature", id: 'full-plan-feature',
category: "test", category: 'test',
description: "Feature with full planning", description: 'Feature with full planning',
status: "pending", status: 'pending',
planningMode: "full", planningMode: 'full',
requirePlanApproval: false, requirePlanApproval: false,
}); });
const mockProvider = { const mockProvider = {
getName: () => "claude", getName: () => 'claude',
executeQuery: async function* () { executeQuery: async function* () {
yield { yield {
type: "assistant", type: 'assistant',
message: { message: {
role: "assistant", role: 'assistant',
content: [{ type: "text", text: "Full spec with phases\n\n[SPEC_GENERATED] Review." }], content: [
{ type: 'text', text: 'Full spec with phases\n\n[SPEC_GENERATED] Review.' },
],
}, },
}; };
yield { yield {
type: "result", type: 'result',
subtype: "success", subtype: 'success',
}; };
}, },
}; };
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
mockProvider as any
);
await service.executeFeature( await service.executeFeature(testRepo.path, 'full-plan-feature', false, false);
testRepo.path,
"full-plan-feature",
false,
false
);
// Check planning_started event was emitted with full mode // Check planning_started event was emitted with full mode
const planningEvent = mockEvents.emit.mock.calls.find( const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'full');
(call) => call[1]?.mode === "full"
);
expect(planningEvent).toBeTruthy(); expect(planningEvent).toBeTruthy();
}, 30000); }, 30000);
it("should track pending approval correctly", async () => { it('should track pending approval correctly', async () => {
// Initially no pending approvals // Initially no pending approvals
expect(service.hasPendingApproval("non-existent")).toBe(false); expect(service.hasPendingApproval('non-existent')).toBe(false);
}); });
it("should cancel pending approval gracefully", () => { it('should cancel pending approval gracefully', () => {
// Should not throw when cancelling non-existent approval // Should not throw when cancelling non-existent approval
expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); expect(() => service.cancelPlanApproval('non-existent')).not.toThrow();
}); });
it("should resolve approval with error for non-existent feature", async () => { it('should resolve approval with error for non-existent feature', async () => {
const result = await service.resolvePlanApproval( const result = await service.resolvePlanApproval(
"non-existent", 'non-existent',
true, true,
undefined, undefined,
undefined, undefined,
undefined undefined
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("No pending approval"); expect(result.error).toContain('No pending approval');
}); });
}); });
}); });

View File

@@ -1,143 +1,137 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from 'vitest';
import { import {
specToXml, specToXml,
getStructuredSpecPromptInstruction, getStructuredSpecPromptInstruction,
getAppSpecFormatInstruction, getAppSpecFormatInstruction,
APP_SPEC_XML_FORMAT, APP_SPEC_XML_FORMAT,
type SpecOutput, type SpecOutput,
} from "@/lib/app-spec-format.js"; } from '@/lib/app-spec-format.js';
describe("app-spec-format.ts", () => { describe('app-spec-format.ts', () => {
describe("specToXml", () => { describe('specToXml', () => {
it("should convert minimal spec to XML", () => { it('should convert minimal spec to XML', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test Project", project_name: 'Test Project',
overview: "A test project", overview: 'A test project',
technology_stack: ["TypeScript", "Node.js"], technology_stack: ['TypeScript', 'Node.js'],
core_capabilities: ["Testing", "Development"], core_capabilities: ['Testing', 'Development'],
implemented_features: [ implemented_features: [{ name: 'Feature 1', description: 'First feature' }],
{ name: "Feature 1", description: "First feature" },
],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>'); expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<project_specification>"); expect(xml).toContain('<project_specification>');
expect(xml).toContain("</project_specification>"); expect(xml).toContain('</project_specification>');
expect(xml).toContain("<project_name>Test Project</project_name>"); expect(xml).toContain('<project_name>Test Project</project_name>');
expect(xml).toContain("<technology>TypeScript</technology>"); expect(xml).toContain('<technology>TypeScript</technology>');
expect(xml).toContain("<capability>Testing</capability>"); expect(xml).toContain('<capability>Testing</capability>');
}); });
it("should escape XML special characters", () => { it('should escape XML special characters', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test & Project", project_name: 'Test & Project',
overview: "Description with <tags>", overview: 'Description with <tags>',
technology_stack: ["TypeScript"], technology_stack: ['TypeScript'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [], implemented_features: [],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).toContain("Test &amp; Project"); expect(xml).toContain('Test &amp; Project');
expect(xml).toContain("&lt;tags&gt;"); expect(xml).toContain('&lt;tags&gt;');
}); });
it("should include file_locations when provided", () => { it('should include file_locations when provided', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test", project_name: 'Test',
overview: "Test", overview: 'Test',
technology_stack: ["TS"], technology_stack: ['TS'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [ implemented_features: [
{ {
name: "Feature", name: 'Feature',
description: "Desc", description: 'Desc',
file_locations: ["src/index.ts"], file_locations: ['src/index.ts'],
}, },
], ],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).toContain("<file_locations>"); expect(xml).toContain('<file_locations>');
expect(xml).toContain("<location>src/index.ts</location>"); expect(xml).toContain('<location>src/index.ts</location>');
}); });
it("should not include file_locations when empty", () => { it('should not include file_locations when empty', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test", project_name: 'Test',
overview: "Test", overview: 'Test',
technology_stack: ["TS"], technology_stack: ['TS'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [ implemented_features: [{ name: 'Feature', description: 'Desc', file_locations: [] }],
{ name: "Feature", description: "Desc", file_locations: [] },
],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).not.toContain("<file_locations>"); expect(xml).not.toContain('<file_locations>');
}); });
it("should include additional_requirements when provided", () => { it('should include additional_requirements when provided', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test", project_name: 'Test',
overview: "Test", overview: 'Test',
technology_stack: ["TS"], technology_stack: ['TS'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [], implemented_features: [],
additional_requirements: ["Node.js 18+"], additional_requirements: ['Node.js 18+'],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).toContain("<additional_requirements>"); expect(xml).toContain('<additional_requirements>');
expect(xml).toContain("<requirement>Node.js 18+</requirement>"); expect(xml).toContain('<requirement>Node.js 18+</requirement>');
}); });
it("should include development_guidelines when provided", () => { it('should include development_guidelines when provided', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test", project_name: 'Test',
overview: "Test", overview: 'Test',
technology_stack: ["TS"], technology_stack: ['TS'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [], implemented_features: [],
development_guidelines: ["Use ESLint"], development_guidelines: ['Use ESLint'],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).toContain("<development_guidelines>"); expect(xml).toContain('<development_guidelines>');
expect(xml).toContain("<guideline>Use ESLint</guideline>"); expect(xml).toContain('<guideline>Use ESLint</guideline>');
}); });
it("should include implementation_roadmap when provided", () => { it('should include implementation_roadmap when provided', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test", project_name: 'Test',
overview: "Test", overview: 'Test',
technology_stack: ["TS"], technology_stack: ['TS'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [], implemented_features: [],
implementation_roadmap: [ implementation_roadmap: [{ phase: 'Phase 1', status: 'completed', description: 'Setup' }],
{ phase: "Phase 1", status: "completed", description: "Setup" },
],
}; };
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).toContain("<implementation_roadmap>"); expect(xml).toContain('<implementation_roadmap>');
expect(xml).toContain("<status>completed</status>"); expect(xml).toContain('<status>completed</status>');
}); });
it("should not include optional sections when empty", () => { it('should not include optional sections when empty', () => {
const spec: SpecOutput = { const spec: SpecOutput = {
project_name: "Test", project_name: 'Test',
overview: "Test", overview: 'Test',
technology_stack: ["TS"], technology_stack: ['TS'],
core_capabilities: ["Cap"], core_capabilities: ['Cap'],
implemented_features: [], implemented_features: [],
additional_requirements: [], additional_requirements: [],
development_guidelines: [], development_guidelines: [],
@@ -146,44 +140,44 @@ describe("app-spec-format.ts", () => {
const xml = specToXml(spec); const xml = specToXml(spec);
expect(xml).not.toContain("<additional_requirements>"); expect(xml).not.toContain('<additional_requirements>');
expect(xml).not.toContain("<development_guidelines>"); expect(xml).not.toContain('<development_guidelines>');
expect(xml).not.toContain("<implementation_roadmap>"); expect(xml).not.toContain('<implementation_roadmap>');
}); });
}); });
describe("getStructuredSpecPromptInstruction", () => { describe('getStructuredSpecPromptInstruction', () => {
it("should return non-empty prompt instruction", () => { it('should return non-empty prompt instruction', () => {
const instruction = getStructuredSpecPromptInstruction(); const instruction = getStructuredSpecPromptInstruction();
expect(instruction).toBeTruthy(); expect(instruction).toBeTruthy();
expect(instruction.length).toBeGreaterThan(100); expect(instruction.length).toBeGreaterThan(100);
}); });
it("should mention required fields", () => { it('should mention required fields', () => {
const instruction = getStructuredSpecPromptInstruction(); const instruction = getStructuredSpecPromptInstruction();
expect(instruction).toContain("project_name"); expect(instruction).toContain('project_name');
expect(instruction).toContain("overview"); expect(instruction).toContain('overview');
expect(instruction).toContain("technology_stack"); expect(instruction).toContain('technology_stack');
}); });
}); });
describe("getAppSpecFormatInstruction", () => { describe('getAppSpecFormatInstruction', () => {
it("should return non-empty format instruction", () => { it('should return non-empty format instruction', () => {
const instruction = getAppSpecFormatInstruction(); const instruction = getAppSpecFormatInstruction();
expect(instruction).toBeTruthy(); expect(instruction).toBeTruthy();
expect(instruction.length).toBeGreaterThan(100); expect(instruction.length).toBeGreaterThan(100);
}); });
it("should include critical formatting requirements", () => { it('should include critical formatting requirements', () => {
const instruction = getAppSpecFormatInstruction(); const instruction = getAppSpecFormatInstruction();
expect(instruction).toContain("CRITICAL FORMATTING REQUIREMENTS"); expect(instruction).toContain('CRITICAL FORMATTING REQUIREMENTS');
}); });
}); });
describe("APP_SPEC_XML_FORMAT", () => { describe('APP_SPEC_XML_FORMAT', () => {
it("should contain valid XML template structure", () => { it('should contain valid XML template structure', () => {
expect(APP_SPEC_XML_FORMAT).toContain("<project_specification>"); expect(APP_SPEC_XML_FORMAT).toContain('<project_specification>');
expect(APP_SPEC_XML_FORMAT).toContain("</project_specification>"); expect(APP_SPEC_XML_FORMAT).toContain('</project_specification>');
}); });
}); });
}); });

View File

@@ -1,20 +1,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from "../../utils/mocks.js"; import { createMockExpressContext } from '../../utils/mocks.js';
/** /**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time. * Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
* We need to reset modules and reimport for each test to get fresh state. * We need to reset modules and reimport for each test to get fresh state.
*/ */
describe("auth.ts", () => { describe('auth.ts', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
}); });
describe("authMiddleware - no API key", () => { describe('authMiddleware - no API key', () => {
it("should call next() when no API key is set", async () => { it('should call next() when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY; delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import("@/lib/auth.js"); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext(); const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -24,11 +24,11 @@ describe("auth.ts", () => {
}); });
}); });
describe("authMiddleware - with API key", () => { describe('authMiddleware - with API key', () => {
it("should reject request without API key header", async () => { it('should reject request without API key header', async () => {
process.env.AUTOMAKER_API_KEY = "test-secret-key"; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import("@/lib/auth.js"); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext(); const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -36,34 +36,34 @@ describe("auth.ts", () => {
expect(res.status).toHaveBeenCalledWith(401); expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
success: false, success: false,
error: "Authentication required. Provide X-API-Key header.", error: 'Authentication required. Provide X-API-Key header.',
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it("should reject request with invalid API key", async () => { it('should reject request with invalid API key', async () => {
process.env.AUTOMAKER_API_KEY = "test-secret-key"; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import("@/lib/auth.js"); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext(); const { req, res, next } = createMockExpressContext();
req.headers["x-api-key"] = "wrong-key"; req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next); authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ expect(res.json).toHaveBeenCalledWith({
success: false, success: false,
error: "Invalid API key.", error: 'Invalid API key.',
}); });
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
it("should call next() with valid API key", async () => { it('should call next() with valid API key', async () => {
process.env.AUTOMAKER_API_KEY = "test-secret-key"; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import("@/lib/auth.js"); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next} = createMockExpressContext(); const { req, res, next } = createMockExpressContext();
req.headers["x-api-key"] = "test-secret-key"; req.headers['x-api-key'] = 'test-secret-key';
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -72,44 +72,44 @@ describe("auth.ts", () => {
}); });
}); });
describe("isAuthEnabled", () => { describe('isAuthEnabled', () => {
it("should return false when no API key is set", async () => { it('should return false when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY; delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled } = await import("@/lib/auth.js"); const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(false); expect(isAuthEnabled()).toBe(false);
}); });
it("should return true when API key is set", async () => { it('should return true when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = "test-key"; process.env.AUTOMAKER_API_KEY = 'test-key';
const { isAuthEnabled } = await import("@/lib/auth.js"); const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(true); expect(isAuthEnabled()).toBe(true);
}); });
}); });
describe("getAuthStatus", () => { describe('getAuthStatus', () => {
it("should return disabled status when no API key", async () => { it('should return disabled status when no API key', async () => {
delete process.env.AUTOMAKER_API_KEY; delete process.env.AUTOMAKER_API_KEY;
const { getAuthStatus } = await import("@/lib/auth.js"); const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus(); const status = getAuthStatus();
expect(status).toEqual({ expect(status).toEqual({
enabled: false, enabled: false,
method: "none", method: 'none',
}); });
}); });
it("should return enabled status when API key is set", async () => { it('should return enabled status when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = "test-key"; process.env.AUTOMAKER_API_KEY = 'test-key';
const { getAuthStatus } = await import("@/lib/auth.js"); const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus(); const status = getAuthStatus();
expect(status).toEqual({ expect(status).toEqual({
enabled: true, enabled: true,
method: "api_key", method: 'api_key',
}); });
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from 'vitest';
import { import {
getEnhancementPrompt, getEnhancementPrompt,
getSystemPrompt, getSystemPrompt,
@@ -15,38 +15,38 @@ import {
SIMPLIFY_EXAMPLES, SIMPLIFY_EXAMPLES,
ACCEPTANCE_EXAMPLES, ACCEPTANCE_EXAMPLES,
type EnhancementMode, type EnhancementMode,
} from "@/lib/enhancement-prompts.js"; } from '@/lib/enhancement-prompts.js';
describe("enhancement-prompts.ts", () => { describe('enhancement-prompts.ts', () => {
describe("System Prompt Constants", () => { describe('System Prompt Constants', () => {
it("should have non-empty improve system prompt", () => { it('should have non-empty improve system prompt', () => {
expect(IMPROVE_SYSTEM_PROMPT).toBeDefined(); expect(IMPROVE_SYSTEM_PROMPT).toBeDefined();
expect(IMPROVE_SYSTEM_PROMPT.length).toBeGreaterThan(100); expect(IMPROVE_SYSTEM_PROMPT.length).toBeGreaterThan(100);
expect(IMPROVE_SYSTEM_PROMPT).toContain("ANALYZE"); expect(IMPROVE_SYSTEM_PROMPT).toContain('ANALYZE');
expect(IMPROVE_SYSTEM_PROMPT).toContain("CLARIFY"); expect(IMPROVE_SYSTEM_PROMPT).toContain('CLARIFY');
}); });
it("should have non-empty technical system prompt", () => { it('should have non-empty technical system prompt', () => {
expect(TECHNICAL_SYSTEM_PROMPT).toBeDefined(); expect(TECHNICAL_SYSTEM_PROMPT).toBeDefined();
expect(TECHNICAL_SYSTEM_PROMPT.length).toBeGreaterThan(100); expect(TECHNICAL_SYSTEM_PROMPT.length).toBeGreaterThan(100);
expect(TECHNICAL_SYSTEM_PROMPT).toContain("technical"); expect(TECHNICAL_SYSTEM_PROMPT).toContain('technical');
}); });
it("should have non-empty simplify system prompt", () => { it('should have non-empty simplify system prompt', () => {
expect(SIMPLIFY_SYSTEM_PROMPT).toBeDefined(); expect(SIMPLIFY_SYSTEM_PROMPT).toBeDefined();
expect(SIMPLIFY_SYSTEM_PROMPT.length).toBeGreaterThan(100); expect(SIMPLIFY_SYSTEM_PROMPT.length).toBeGreaterThan(100);
expect(SIMPLIFY_SYSTEM_PROMPT).toContain("simplify"); expect(SIMPLIFY_SYSTEM_PROMPT).toContain('simplify');
}); });
it("should have non-empty acceptance system prompt", () => { it('should have non-empty acceptance system prompt', () => {
expect(ACCEPTANCE_SYSTEM_PROMPT).toBeDefined(); expect(ACCEPTANCE_SYSTEM_PROMPT).toBeDefined();
expect(ACCEPTANCE_SYSTEM_PROMPT.length).toBeGreaterThan(100); expect(ACCEPTANCE_SYSTEM_PROMPT.length).toBeGreaterThan(100);
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain("acceptance criteria"); expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('acceptance criteria');
}); });
}); });
describe("Example Constants", () => { describe('Example Constants', () => {
it("should have improve examples with input and output", () => { it('should have improve examples with input and output', () => {
expect(IMPROVE_EXAMPLES).toBeDefined(); expect(IMPROVE_EXAMPLES).toBeDefined();
expect(IMPROVE_EXAMPLES.length).toBeGreaterThan(0); expect(IMPROVE_EXAMPLES.length).toBeGreaterThan(0);
IMPROVE_EXAMPLES.forEach((example) => { IMPROVE_EXAMPLES.forEach((example) => {
@@ -57,7 +57,7 @@ describe("enhancement-prompts.ts", () => {
}); });
}); });
it("should have technical examples with input and output", () => { it('should have technical examples with input and output', () => {
expect(TECHNICAL_EXAMPLES).toBeDefined(); expect(TECHNICAL_EXAMPLES).toBeDefined();
expect(TECHNICAL_EXAMPLES.length).toBeGreaterThan(0); expect(TECHNICAL_EXAMPLES.length).toBeGreaterThan(0);
TECHNICAL_EXAMPLES.forEach((example) => { TECHNICAL_EXAMPLES.forEach((example) => {
@@ -66,7 +66,7 @@ describe("enhancement-prompts.ts", () => {
}); });
}); });
it("should have simplify examples with input and output", () => { it('should have simplify examples with input and output', () => {
expect(SIMPLIFY_EXAMPLES).toBeDefined(); expect(SIMPLIFY_EXAMPLES).toBeDefined();
expect(SIMPLIFY_EXAMPLES.length).toBeGreaterThan(0); expect(SIMPLIFY_EXAMPLES.length).toBeGreaterThan(0);
SIMPLIFY_EXAMPLES.forEach((example) => { SIMPLIFY_EXAMPLES.forEach((example) => {
@@ -75,7 +75,7 @@ describe("enhancement-prompts.ts", () => {
}); });
}); });
it("should have acceptance examples with input and output", () => { it('should have acceptance examples with input and output', () => {
expect(ACCEPTANCE_EXAMPLES).toBeDefined(); expect(ACCEPTANCE_EXAMPLES).toBeDefined();
expect(ACCEPTANCE_EXAMPLES.length).toBeGreaterThan(0); expect(ACCEPTANCE_EXAMPLES.length).toBeGreaterThan(0);
ACCEPTANCE_EXAMPLES.forEach((example) => { ACCEPTANCE_EXAMPLES.forEach((example) => {
@@ -85,66 +85,66 @@ describe("enhancement-prompts.ts", () => {
}); });
}); });
describe("getEnhancementPrompt", () => { describe('getEnhancementPrompt', () => {
it("should return config for improve mode", () => { it('should return config for improve mode', () => {
const config = getEnhancementPrompt("improve"); const config = getEnhancementPrompt('improve');
expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
expect(config.description).toContain("clear"); expect(config.description).toContain('clear');
}); });
it("should return config for technical mode", () => { it('should return config for technical mode', () => {
const config = getEnhancementPrompt("technical"); const config = getEnhancementPrompt('technical');
expect(config.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT);
expect(config.description).toContain("technical"); expect(config.description).toContain('technical');
}); });
it("should return config for simplify mode", () => { it('should return config for simplify mode', () => {
const config = getEnhancementPrompt("simplify"); const config = getEnhancementPrompt('simplify');
expect(config.systemPrompt).toBe(SIMPLIFY_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(SIMPLIFY_SYSTEM_PROMPT);
expect(config.description).toContain("concise"); expect(config.description).toContain('concise');
}); });
it("should return config for acceptance mode", () => { it('should return config for acceptance mode', () => {
const config = getEnhancementPrompt("acceptance"); const config = getEnhancementPrompt('acceptance');
expect(config.systemPrompt).toBe(ACCEPTANCE_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(ACCEPTANCE_SYSTEM_PROMPT);
expect(config.description).toContain("acceptance"); expect(config.description).toContain('acceptance');
}); });
it("should handle case-insensitive mode", () => { it('should handle case-insensitive mode', () => {
const config = getEnhancementPrompt("IMPROVE"); const config = getEnhancementPrompt('IMPROVE');
expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
}); });
it("should fall back to improve for invalid mode", () => { it('should fall back to improve for invalid mode', () => {
const config = getEnhancementPrompt("invalid-mode"); const config = getEnhancementPrompt('invalid-mode');
expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
}); });
it("should fall back to improve for empty string", () => { it('should fall back to improve for empty string', () => {
const config = getEnhancementPrompt(""); const config = getEnhancementPrompt('');
expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
}); });
}); });
describe("getSystemPrompt", () => { describe('getSystemPrompt', () => {
it("should return correct system prompt for each mode", () => { it('should return correct system prompt for each mode', () => {
expect(getSystemPrompt("improve")).toBe(IMPROVE_SYSTEM_PROMPT); expect(getSystemPrompt('improve')).toBe(IMPROVE_SYSTEM_PROMPT);
expect(getSystemPrompt("technical")).toBe(TECHNICAL_SYSTEM_PROMPT); expect(getSystemPrompt('technical')).toBe(TECHNICAL_SYSTEM_PROMPT);
expect(getSystemPrompt("simplify")).toBe(SIMPLIFY_SYSTEM_PROMPT); expect(getSystemPrompt('simplify')).toBe(SIMPLIFY_SYSTEM_PROMPT);
expect(getSystemPrompt("acceptance")).toBe(ACCEPTANCE_SYSTEM_PROMPT); expect(getSystemPrompt('acceptance')).toBe(ACCEPTANCE_SYSTEM_PROMPT);
}); });
}); });
describe("getExamples", () => { describe('getExamples', () => {
it("should return correct examples for each mode", () => { it('should return correct examples for each mode', () => {
expect(getExamples("improve")).toBe(IMPROVE_EXAMPLES); expect(getExamples('improve')).toBe(IMPROVE_EXAMPLES);
expect(getExamples("technical")).toBe(TECHNICAL_EXAMPLES); expect(getExamples('technical')).toBe(TECHNICAL_EXAMPLES);
expect(getExamples("simplify")).toBe(SIMPLIFY_EXAMPLES); expect(getExamples('simplify')).toBe(SIMPLIFY_EXAMPLES);
expect(getExamples("acceptance")).toBe(ACCEPTANCE_EXAMPLES); expect(getExamples('acceptance')).toBe(ACCEPTANCE_EXAMPLES);
}); });
it("should return arrays with example objects", () => { it('should return arrays with example objects', () => {
const modes: EnhancementMode[] = ["improve", "technical", "simplify", "acceptance"]; const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
modes.forEach((mode) => { modes.forEach((mode) => {
const examples = getExamples(mode); const examples = getExamples(mode);
expect(Array.isArray(examples)).toBe(true); expect(Array.isArray(examples)).toBe(true);
@@ -153,38 +153,38 @@ describe("enhancement-prompts.ts", () => {
}); });
}); });
describe("buildUserPrompt", () => { describe('buildUserPrompt', () => {
const testText = "Add a logout button"; const testText = 'Add a logout button';
it("should build prompt with examples by default", () => { it('should build prompt with examples by default', () => {
const prompt = buildUserPrompt("improve", testText); const prompt = buildUserPrompt('improve', testText);
expect(prompt).toContain("Example 1:"); expect(prompt).toContain('Example 1:');
expect(prompt).toContain(testText); expect(prompt).toContain(testText);
expect(prompt).toContain("Now, please enhance the following task description:"); expect(prompt).toContain('Now, please enhance the following task description:');
}); });
it("should build prompt without examples when includeExamples is false", () => { it('should build prompt without examples when includeExamples is false', () => {
const prompt = buildUserPrompt("improve", testText, false); const prompt = buildUserPrompt('improve', testText, false);
expect(prompt).not.toContain("Example 1:"); expect(prompt).not.toContain('Example 1:');
expect(prompt).toContain(testText); expect(prompt).toContain(testText);
expect(prompt).toContain("Please enhance the following task description:"); expect(prompt).toContain('Please enhance the following task description:');
}); });
it("should include all examples for improve mode", () => { it('should include all examples for improve mode', () => {
const prompt = buildUserPrompt("improve", testText); const prompt = buildUserPrompt('improve', testText);
IMPROVE_EXAMPLES.forEach((example, index) => { IMPROVE_EXAMPLES.forEach((example, index) => {
expect(prompt).toContain(`Example ${index + 1}:`); expect(prompt).toContain(`Example ${index + 1}:`);
expect(prompt).toContain(example.input); expect(prompt).toContain(example.input);
}); });
}); });
it("should include separator between examples", () => { it('should include separator between examples', () => {
const prompt = buildUserPrompt("improve", testText); const prompt = buildUserPrompt('improve', testText);
expect(prompt).toContain("---"); expect(prompt).toContain('---');
}); });
it("should work with all enhancement modes", () => { it('should work with all enhancement modes', () => {
const modes: EnhancementMode[] = ["improve", "technical", "simplify", "acceptance"]; const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
modes.forEach((mode) => { modes.forEach((mode) => {
const prompt = buildUserPrompt(mode, testText); const prompt = buildUserPrompt(mode, testText);
expect(prompt).toContain(testText); expect(prompt).toContain(testText);
@@ -192,40 +192,40 @@ describe("enhancement-prompts.ts", () => {
}); });
}); });
it("should preserve the original text exactly", () => { it('should preserve the original text exactly', () => {
const specialText = "Add feature with special chars: <>&\"'"; const specialText = 'Add feature with special chars: <>&"\'';
const prompt = buildUserPrompt("improve", specialText); const prompt = buildUserPrompt('improve', specialText);
expect(prompt).toContain(specialText); expect(prompt).toContain(specialText);
}); });
}); });
describe("isValidEnhancementMode", () => { describe('isValidEnhancementMode', () => {
it("should return true for valid modes", () => { it('should return true for valid modes', () => {
expect(isValidEnhancementMode("improve")).toBe(true); expect(isValidEnhancementMode('improve')).toBe(true);
expect(isValidEnhancementMode("technical")).toBe(true); expect(isValidEnhancementMode('technical')).toBe(true);
expect(isValidEnhancementMode("simplify")).toBe(true); expect(isValidEnhancementMode('simplify')).toBe(true);
expect(isValidEnhancementMode("acceptance")).toBe(true); expect(isValidEnhancementMode('acceptance')).toBe(true);
}); });
it("should return false for invalid modes", () => { it('should return false for invalid modes', () => {
expect(isValidEnhancementMode("invalid")).toBe(false); expect(isValidEnhancementMode('invalid')).toBe(false);
expect(isValidEnhancementMode("IMPROVE")).toBe(false); // case-sensitive expect(isValidEnhancementMode('IMPROVE')).toBe(false); // case-sensitive
expect(isValidEnhancementMode("")).toBe(false); expect(isValidEnhancementMode('')).toBe(false);
expect(isValidEnhancementMode("random")).toBe(false); expect(isValidEnhancementMode('random')).toBe(false);
}); });
}); });
describe("getAvailableEnhancementModes", () => { describe('getAvailableEnhancementModes', () => {
it("should return all four enhancement modes", () => { it('should return all four enhancement modes', () => {
const modes = getAvailableEnhancementModes(); const modes = getAvailableEnhancementModes();
expect(modes).toHaveLength(4); expect(modes).toHaveLength(4);
expect(modes).toContain("improve"); expect(modes).toContain('improve');
expect(modes).toContain("technical"); expect(modes).toContain('technical');
expect(modes).toContain("simplify"); expect(modes).toContain('simplify');
expect(modes).toContain("acceptance"); expect(modes).toContain('acceptance');
}); });
it("should return an array", () => { it('should return an array', () => {
const modes = getAvailableEnhancementModes(); const modes = getAvailableEnhancementModes();
expect(Array.isArray(modes)).toBe(true); expect(Array.isArray(modes)).toBe(true);
}); });

View File

@@ -1,20 +1,20 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from 'vitest';
import { createEventEmitter, type EventType } from "@/lib/events.js"; import { createEventEmitter, type EventType } from '@/lib/events.js';
describe("events.ts", () => { describe('events.ts', () => {
describe("createEventEmitter", () => { describe('createEventEmitter', () => {
it("should emit events to single subscriber", () => { it('should emit events to single subscriber', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
const callback = vi.fn(); const callback = vi.fn();
emitter.subscribe(callback); emitter.subscribe(callback);
emitter.emit("agent:stream", { message: "test" }); emitter.emit('agent:stream', { message: 'test' });
expect(callback).toHaveBeenCalledOnce(); expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent:stream", { message: "test" }); expect(callback).toHaveBeenCalledWith('agent:stream', { message: 'test' });
}); });
it("should emit events to multiple subscribers", () => { it('should emit events to multiple subscribers', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
const callback1 = vi.fn(); const callback1 = vi.fn();
const callback2 = vi.fn(); const callback2 = vi.fn();
@@ -23,42 +23,42 @@ describe("events.ts", () => {
emitter.subscribe(callback1); emitter.subscribe(callback1);
emitter.subscribe(callback2); emitter.subscribe(callback2);
emitter.subscribe(callback3); emitter.subscribe(callback3);
emitter.emit("feature:started", { id: "123" }); emitter.emit('feature:started', { id: '123' });
expect(callback1).toHaveBeenCalledOnce(); expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce(); expect(callback2).toHaveBeenCalledOnce();
expect(callback3).toHaveBeenCalledOnce(); expect(callback3).toHaveBeenCalledOnce();
expect(callback1).toHaveBeenCalledWith("feature:started", { id: "123" }); expect(callback1).toHaveBeenCalledWith('feature:started', { id: '123' });
}); });
it("should support unsubscribe functionality", () => { it('should support unsubscribe functionality', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
const callback = vi.fn(); const callback = vi.fn();
const unsubscribe = emitter.subscribe(callback); const unsubscribe = emitter.subscribe(callback);
emitter.emit("agent:stream", { test: 1 }); emitter.emit('agent:stream', { test: 1 });
expect(callback).toHaveBeenCalledOnce(); expect(callback).toHaveBeenCalledOnce();
unsubscribe(); unsubscribe();
emitter.emit("agent:stream", { test: 2 }); emitter.emit('agent:stream', { test: 2 });
expect(callback).toHaveBeenCalledOnce(); // Still called only once expect(callback).toHaveBeenCalledOnce(); // Still called only once
}); });
it("should handle errors in subscribers without crashing", () => { it('should handle errors in subscribers without crashing', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
const errorCallback = vi.fn(() => { const errorCallback = vi.fn(() => {
throw new Error("Subscriber error"); throw new Error('Subscriber error');
}); });
const normalCallback = vi.fn(); const normalCallback = vi.fn();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
emitter.subscribe(errorCallback); emitter.subscribe(errorCallback);
emitter.subscribe(normalCallback); emitter.subscribe(normalCallback);
expect(() => { expect(() => {
emitter.emit("feature:error", { error: "test" }); emitter.emit('feature:error', { error: 'test' });
}).not.toThrow(); }).not.toThrow();
expect(errorCallback).toHaveBeenCalledOnce(); expect(errorCallback).toHaveBeenCalledOnce();
@@ -68,17 +68,17 @@ describe("events.ts", () => {
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
it("should emit different event types", () => { it('should emit different event types', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
const callback = vi.fn(); const callback = vi.fn();
emitter.subscribe(callback); emitter.subscribe(callback);
const eventTypes: EventType[] = [ const eventTypes: EventType[] = [
"agent:stream", 'agent:stream',
"auto-mode:started", 'auto-mode:started',
"feature:completed", 'feature:completed',
"project:analysis-progress", 'project:analysis-progress',
]; ];
eventTypes.forEach((type) => { eventTypes.forEach((type) => {
@@ -88,15 +88,15 @@ describe("events.ts", () => {
expect(callback).toHaveBeenCalledTimes(4); expect(callback).toHaveBeenCalledTimes(4);
}); });
it("should handle emitting without subscribers", () => { it('should handle emitting without subscribers', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
expect(() => { expect(() => {
emitter.emit("agent:stream", { test: true }); emitter.emit('agent:stream', { test: true });
}).not.toThrow(); }).not.toThrow();
}); });
it("should allow multiple subscriptions and unsubscriptions", () => { it('should allow multiple subscriptions and unsubscriptions', () => {
const emitter = createEventEmitter(); const emitter = createEventEmitter();
const callback1 = vi.fn(); const callback1 = vi.fn();
const callback2 = vi.fn(); const callback2 = vi.fn();
@@ -106,14 +106,14 @@ describe("events.ts", () => {
const unsub2 = emitter.subscribe(callback2); const unsub2 = emitter.subscribe(callback2);
const unsub3 = emitter.subscribe(callback3); const unsub3 = emitter.subscribe(callback3);
emitter.emit("feature:started", { test: 1 }); emitter.emit('feature:started', { test: 1 });
expect(callback1).toHaveBeenCalledOnce(); expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce(); expect(callback2).toHaveBeenCalledOnce();
expect(callback3).toHaveBeenCalledOnce(); expect(callback3).toHaveBeenCalledOnce();
unsub2(); unsub2();
emitter.emit("feature:started", { test: 2 }); emitter.emit('feature:started', { test: 2 });
expect(callback1).toHaveBeenCalledTimes(2); expect(callback1).toHaveBeenCalledTimes(2);
expect(callback2).toHaveBeenCalledOnce(); // Still just once expect(callback2).toHaveBeenCalledOnce(); // Still just once
expect(callback3).toHaveBeenCalledTimes(2); expect(callback3).toHaveBeenCalledTimes(2);
@@ -121,7 +121,7 @@ describe("events.ts", () => {
unsub1(); unsub1();
unsub3(); unsub3();
emitter.emit("feature:started", { test: 3 }); emitter.emit('feature:started', { test: 3 });
expect(callback1).toHaveBeenCalledTimes(2); expect(callback1).toHaveBeenCalledTimes(2);
expect(callback2).toHaveBeenCalledOnce(); expect(callback2).toHaveBeenCalledOnce();
expect(callback3).toHaveBeenCalledTimes(2); expect(callback3).toHaveBeenCalledTimes(2);

View File

@@ -1,13 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ProviderFactory } from "@/providers/provider-factory.js"; import { ProviderFactory } from '@/providers/provider-factory.js';
import { ClaudeProvider } from "@/providers/claude-provider.js"; import { ClaudeProvider } from '@/providers/claude-provider.js';
describe("provider-factory.ts", () => { describe('provider-factory.ts', () => {
let consoleSpy: any; let consoleSpy: any;
beforeEach(() => { beforeEach(() => {
consoleSpy = { consoleSpy = {
warn: vi.spyOn(console, "warn").mockImplementation(() => {}), warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
}; };
}); });
@@ -15,55 +15,49 @@ describe("provider-factory.ts", () => {
consoleSpy.warn.mockRestore(); consoleSpy.warn.mockRestore();
}); });
describe("getProviderForModel", () => { describe('getProviderForModel', () => {
describe("Claude models (claude-* prefix)", () => { describe('Claude models (claude-* prefix)', () => {
it("should return ClaudeProvider for claude-opus-4-5-20251101", () => { it('should return ClaudeProvider for claude-opus-4-5-20251101', () => {
const provider = ProviderFactory.getProviderForModel( const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
"claude-opus-4-5-20251101"
);
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should return ClaudeProvider for claude-sonnet-4-20250514", () => { it('should return ClaudeProvider for claude-sonnet-4-20250514', () => {
const provider = ProviderFactory.getProviderForModel( const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-20250514');
"claude-sonnet-4-20250514"
);
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should return ClaudeProvider for claude-haiku-4-5", () => { it('should return ClaudeProvider for claude-haiku-4-5', () => {
const provider = ProviderFactory.getProviderForModel("claude-haiku-4-5"); const provider = ProviderFactory.getProviderForModel('claude-haiku-4-5');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should be case-insensitive for claude models", () => { it('should be case-insensitive for claude models', () => {
const provider = ProviderFactory.getProviderForModel( const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101');
"CLAUDE-OPUS-4-5-20251101"
);
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
}); });
describe("Claude aliases", () => { describe('Claude aliases', () => {
it("should return ClaudeProvider for 'haiku'", () => { it("should return ClaudeProvider for 'haiku'", () => {
const provider = ProviderFactory.getProviderForModel("haiku"); const provider = ProviderFactory.getProviderForModel('haiku');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should return ClaudeProvider for 'sonnet'", () => { it("should return ClaudeProvider for 'sonnet'", () => {
const provider = ProviderFactory.getProviderForModel("sonnet"); const provider = ProviderFactory.getProviderForModel('sonnet');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should return ClaudeProvider for 'opus'", () => { it("should return ClaudeProvider for 'opus'", () => {
const provider = ProviderFactory.getProviderForModel("opus"); const provider = ProviderFactory.getProviderForModel('opus');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should be case-insensitive for aliases", () => { it('should be case-insensitive for aliases', () => {
const provider1 = ProviderFactory.getProviderForModel("HAIKU"); const provider1 = ProviderFactory.getProviderForModel('HAIKU');
const provider2 = ProviderFactory.getProviderForModel("Sonnet"); const provider2 = ProviderFactory.getProviderForModel('Sonnet');
const provider3 = ProviderFactory.getProviderForModel("Opus"); const provider3 = ProviderFactory.getProviderForModel('Opus');
expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider); expect(provider2).toBeInstanceOf(ClaudeProvider);
@@ -71,65 +65,61 @@ describe("provider-factory.ts", () => {
}); });
}); });
describe("Unknown models", () => { describe('Unknown models', () => {
it("should default to ClaudeProvider for unknown model", () => { it('should default to ClaudeProvider for unknown model', () => {
const provider = ProviderFactory.getProviderForModel("unknown-model-123"); const provider = ProviderFactory.getProviderForModel('unknown-model-123');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should warn when defaulting to Claude", () => { it('should warn when defaulting to Claude', () => {
ProviderFactory.getProviderForModel("random-model"); ProviderFactory.getProviderForModel('random-model');
expect(consoleSpy.warn).toHaveBeenCalledWith( expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("Unknown model prefix") expect.stringContaining('Unknown model prefix')
); );
expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('random-model'));
expect(consoleSpy.warn).toHaveBeenCalledWith( expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("random-model") expect.stringContaining('defaulting to Claude')
);
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("defaulting to Claude")
); );
}); });
it("should handle empty string", () => { it('should handle empty string', () => {
const provider = ProviderFactory.getProviderForModel(""); const provider = ProviderFactory.getProviderForModel('');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled(); expect(consoleSpy.warn).toHaveBeenCalled();
}); });
it("should default to ClaudeProvider for gpt models (not supported)", () => { it('should default to ClaudeProvider for gpt models (not supported)', () => {
const provider = ProviderFactory.getProviderForModel("gpt-5.2"); const provider = ProviderFactory.getProviderForModel('gpt-5.2');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled(); expect(consoleSpy.warn).toHaveBeenCalled();
}); });
it("should default to ClaudeProvider for o-series models (not supported)", () => { it('should default to ClaudeProvider for o-series models (not supported)', () => {
const provider = ProviderFactory.getProviderForModel("o1"); const provider = ProviderFactory.getProviderForModel('o1');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled(); expect(consoleSpy.warn).toHaveBeenCalled();
}); });
}); });
}); });
describe("getAllProviders", () => { describe('getAllProviders', () => {
it("should return array of all providers", () => { it('should return array of all providers', () => {
const providers = ProviderFactory.getAllProviders(); const providers = ProviderFactory.getAllProviders();
expect(Array.isArray(providers)).toBe(true); expect(Array.isArray(providers)).toBe(true);
}); });
it("should include ClaudeProvider", () => { it('should include ClaudeProvider', () => {
const providers = ProviderFactory.getAllProviders(); const providers = ProviderFactory.getAllProviders();
const hasClaudeProvider = providers.some( const hasClaudeProvider = providers.some((p) => p instanceof ClaudeProvider);
(p) => p instanceof ClaudeProvider
);
expect(hasClaudeProvider).toBe(true); expect(hasClaudeProvider).toBe(true);
}); });
it("should return exactly 1 provider", () => { it('should return exactly 1 provider', () => {
const providers = ProviderFactory.getAllProviders(); const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(1); expect(providers).toHaveLength(1);
}); });
it("should create new instances each time", () => { it('should create new instances each time', () => {
const providers1 = ProviderFactory.getAllProviders(); const providers1 = ProviderFactory.getAllProviders();
const providers2 = ProviderFactory.getAllProviders(); const providers2 = ProviderFactory.getAllProviders();
@@ -137,60 +127,60 @@ describe("provider-factory.ts", () => {
}); });
}); });
describe("checkAllProviders", () => { describe('checkAllProviders', () => {
it("should return installation status for all providers", async () => { it('should return installation status for all providers', async () => {
const statuses = await ProviderFactory.checkAllProviders(); const statuses = await ProviderFactory.checkAllProviders();
expect(statuses).toHaveProperty("claude"); expect(statuses).toHaveProperty('claude');
}); });
it("should call detectInstallation on each provider", async () => { it('should call detectInstallation on each provider', async () => {
const statuses = await ProviderFactory.checkAllProviders(); const statuses = await ProviderFactory.checkAllProviders();
expect(statuses.claude).toHaveProperty("installed"); expect(statuses.claude).toHaveProperty('installed');
}); });
it("should return correct provider names as keys", async () => { it('should return correct provider names as keys', async () => {
const statuses = await ProviderFactory.checkAllProviders(); const statuses = await ProviderFactory.checkAllProviders();
const keys = Object.keys(statuses); const keys = Object.keys(statuses);
expect(keys).toContain("claude"); expect(keys).toContain('claude');
expect(keys).toHaveLength(1); expect(keys).toHaveLength(1);
}); });
}); });
describe("getProviderByName", () => { describe('getProviderByName', () => {
it("should return ClaudeProvider for 'claude'", () => { it("should return ClaudeProvider for 'claude'", () => {
const provider = ProviderFactory.getProviderByName("claude"); const provider = ProviderFactory.getProviderByName('claude');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should return ClaudeProvider for 'anthropic'", () => { it("should return ClaudeProvider for 'anthropic'", () => {
const provider = ProviderFactory.getProviderByName("anthropic"); const provider = ProviderFactory.getProviderByName('anthropic');
expect(provider).toBeInstanceOf(ClaudeProvider); expect(provider).toBeInstanceOf(ClaudeProvider);
}); });
it("should be case-insensitive", () => { it('should be case-insensitive', () => {
const provider1 = ProviderFactory.getProviderByName("CLAUDE"); const provider1 = ProviderFactory.getProviderByName('CLAUDE');
const provider2 = ProviderFactory.getProviderByName("ANTHROPIC"); const provider2 = ProviderFactory.getProviderByName('ANTHROPIC');
expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider); expect(provider2).toBeInstanceOf(ClaudeProvider);
}); });
it("should return null for unknown provider", () => { it('should return null for unknown provider', () => {
const provider = ProviderFactory.getProviderByName("unknown"); const provider = ProviderFactory.getProviderByName('unknown');
expect(provider).toBeNull(); expect(provider).toBeNull();
}); });
it("should return null for empty string", () => { it('should return null for empty string', () => {
const provider = ProviderFactory.getProviderByName(""); const provider = ProviderFactory.getProviderByName('');
expect(provider).toBeNull(); expect(provider).toBeNull();
}); });
it("should create new instance each time", () => { it('should create new instance each time', () => {
const provider1 = ProviderFactory.getProviderByName("claude"); const provider1 = ProviderFactory.getProviderByName('claude');
const provider2 = ProviderFactory.getProviderByName("claude"); const provider2 = ProviderFactory.getProviderByName('claude');
expect(provider1).not.toBe(provider2); expect(provider1).not.toBe(provider2);
expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider1).toBeInstanceOf(ClaudeProvider);
@@ -198,35 +188,33 @@ describe("provider-factory.ts", () => {
}); });
}); });
describe("getAllAvailableModels", () => { describe('getAllAvailableModels', () => {
it("should return array of models", () => { it('should return array of models', () => {
const models = ProviderFactory.getAllAvailableModels(); const models = ProviderFactory.getAllAvailableModels();
expect(Array.isArray(models)).toBe(true); expect(Array.isArray(models)).toBe(true);
}); });
it("should include models from all providers", () => { it('should include models from all providers', () => {
const models = ProviderFactory.getAllAvailableModels(); const models = ProviderFactory.getAllAvailableModels();
expect(models.length).toBeGreaterThan(0); expect(models.length).toBeGreaterThan(0);
}); });
it("should return models with required fields", () => { it('should return models with required fields', () => {
const models = ProviderFactory.getAllAvailableModels(); const models = ProviderFactory.getAllAvailableModels();
models.forEach((model) => { models.forEach((model) => {
expect(model).toHaveProperty("id"); expect(model).toHaveProperty('id');
expect(model).toHaveProperty("name"); expect(model).toHaveProperty('name');
expect(typeof model.id).toBe("string"); expect(typeof model.id).toBe('string');
expect(typeof model.name).toBe("string"); expect(typeof model.name).toBe('string');
}); });
}); });
it("should include Claude models", () => { it('should include Claude models', () => {
const models = ProviderFactory.getAllAvailableModels(); const models = ProviderFactory.getAllAvailableModels();
// Claude models should include claude-* in their IDs // Claude models should include claude-* in their IDs
const hasClaudeModels = models.some((m) => const hasClaudeModels = models.some((m) => m.id.toLowerCase().includes('claude'));
m.id.toLowerCase().includes("claude")
);
expect(hasClaudeModels).toBe(true); expect(hasClaudeModels).toBe(true);
}); });

View File

@@ -1,65 +1,59 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from 'vitest';
import { import {
setRunningState, setRunningState,
getErrorMessage, getErrorMessage,
getSpecRegenerationStatus, getSpecRegenerationStatus,
} from "@/routes/app-spec/common.js"; } from '@/routes/app-spec/common.js';
describe("app-spec/common.ts", () => { describe('app-spec/common.ts', () => {
beforeEach(() => { beforeEach(() => {
// Reset state before each test // Reset state before each test
setRunningState(false, null); setRunningState(false, null);
}); });
describe("setRunningState", () => { describe('setRunningState', () => {
it("should set isRunning to true when running is true", () => { it('should set isRunning to true when running is true', () => {
setRunningState(true); setRunningState(true);
expect(getSpecRegenerationStatus().isRunning).toBe(true); expect(getSpecRegenerationStatus().isRunning).toBe(true);
}); });
it("should set isRunning to false when running is false", () => { it('should set isRunning to false when running is false', () => {
setRunningState(true); setRunningState(true);
setRunningState(false); setRunningState(false);
expect(getSpecRegenerationStatus().isRunning).toBe(false); expect(getSpecRegenerationStatus().isRunning).toBe(false);
}); });
it("should set currentAbortController when provided", () => { it('should set currentAbortController when provided', () => {
const controller = new AbortController(); const controller = new AbortController();
setRunningState(true, controller); setRunningState(true, controller);
expect(getSpecRegenerationStatus().currentAbortController).toBe( expect(getSpecRegenerationStatus().currentAbortController).toBe(controller);
controller
);
}); });
it("should set currentAbortController to null when not provided", () => { it('should set currentAbortController to null when not provided', () => {
const controller = new AbortController(); const controller = new AbortController();
setRunningState(true, controller); setRunningState(true, controller);
setRunningState(false); setRunningState(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null); expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
}); });
it("should set currentAbortController to null when explicitly passed null", () => { it('should set currentAbortController to null when explicitly passed null', () => {
const controller = new AbortController(); const controller = new AbortController();
setRunningState(true, controller); setRunningState(true, controller);
setRunningState(true, null); setRunningState(true, null);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null); expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
}); });
it("should update state multiple times correctly", () => { it('should update state multiple times correctly', () => {
const controller1 = new AbortController(); const controller1 = new AbortController();
const controller2 = new AbortController(); const controller2 = new AbortController();
setRunningState(true, controller1); setRunningState(true, controller1);
expect(getSpecRegenerationStatus().isRunning).toBe(true); expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe( expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1);
controller1
);
setRunningState(true, controller2); setRunningState(true, controller2);
expect(getSpecRegenerationStatus().isRunning).toBe(true); expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe( expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2);
controller2
);
setRunningState(false, null); setRunningState(false, null);
expect(getSpecRegenerationStatus().isRunning).toBe(false); expect(getSpecRegenerationStatus().isRunning).toBe(false);
@@ -67,42 +61,42 @@ describe("app-spec/common.ts", () => {
}); });
}); });
describe("getErrorMessage", () => { describe('getErrorMessage', () => {
it("should return message from Error instance", () => { it('should return message from Error instance', () => {
const error = new Error("Test error message"); const error = new Error('Test error message');
expect(getErrorMessage(error)).toBe("Test error message"); expect(getErrorMessage(error)).toBe('Test error message');
}); });
it("should return 'Unknown error' for non-Error objects", () => { it("should return 'Unknown error' for non-Error objects", () => {
expect(getErrorMessage("string error")).toBe("Unknown error"); expect(getErrorMessage('string error')).toBe('Unknown error');
expect(getErrorMessage(123)).toBe("Unknown error"); expect(getErrorMessage(123)).toBe('Unknown error');
expect(getErrorMessage(null)).toBe("Unknown error"); expect(getErrorMessage(null)).toBe('Unknown error');
expect(getErrorMessage(undefined)).toBe("Unknown error"); expect(getErrorMessage(undefined)).toBe('Unknown error');
expect(getErrorMessage({})).toBe("Unknown error"); expect(getErrorMessage({})).toBe('Unknown error');
expect(getErrorMessage([])).toBe("Unknown error"); expect(getErrorMessage([])).toBe('Unknown error');
}); });
it("should return message from Error with empty message", () => { it('should return message from Error with empty message', () => {
const error = new Error(""); const error = new Error('');
expect(getErrorMessage(error)).toBe(""); expect(getErrorMessage(error)).toBe('');
}); });
it("should handle Error objects with custom properties", () => { it('should handle Error objects with custom properties', () => {
const error = new Error("Base message"); const error = new Error('Base message');
(error as any).customProp = "custom value"; (error as any).customProp = 'custom value';
expect(getErrorMessage(error)).toBe("Base message"); expect(getErrorMessage(error)).toBe('Base message');
}); });
it("should handle Error objects created with different constructors", () => { it('should handle Error objects created with different constructors', () => {
class CustomError extends Error { class CustomError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = "CustomError"; this.name = 'CustomError';
} }
} }
const customError = new CustomError("Custom error message"); const customError = new CustomError('Custom error message');
expect(getErrorMessage(customError)).toBe("Custom error message"); expect(getErrorMessage(customError)).toBe('Custom error message');
}); });
}); });
}); });

View File

@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from 'vitest';
describe("app-spec/parse-and-create-features.ts - JSON extraction", () => { describe('app-spec/parse-and-create-features.ts - JSON extraction', () => {
// Test the JSON extraction regex pattern used in parseAndCreateFeatures // Test the JSON extraction regex pattern used in parseAndCreateFeatures
const jsonExtractionPattern = /\{[\s\S]*"features"[\s\S]*\}/; const jsonExtractionPattern = /\{[\s\S]*"features"[\s\S]*\}/;
describe("JSON extraction regex", () => { describe('JSON extraction regex', () => {
it("should extract JSON with features array", () => { it('should extract JSON with features array', () => {
const content = `Here is the response: const content = `Here is the response:
{ {
"features": [ "features": [
@@ -26,7 +26,7 @@ describe("app-spec/parse-and-create-features.ts - JSON extraction", () => {
expect(match![0]).toContain('"id": "feature-1"'); expect(match![0]).toContain('"id": "feature-1"');
}); });
it("should extract JSON with multiple features", () => { it('should extract JSON with multiple features', () => {
const content = `Some text before const content = `Some text before
{ {
"features": [ "features": [
@@ -49,7 +49,7 @@ Some text after`;
expect(match![0]).toContain('"feature-2"'); expect(match![0]).toContain('"feature-2"');
}); });
it("should extract JSON with nested objects and arrays", () => { it('should extract JSON with nested objects and arrays', () => {
const content = `Response: const content = `Response:
{ {
"features": [ "features": [
@@ -69,7 +69,7 @@ Some text after`;
expect(match![0]).toContain('"dep-1"'); expect(match![0]).toContain('"dep-1"');
}); });
it("should handle JSON with whitespace and newlines", () => { it('should handle JSON with whitespace and newlines', () => {
const content = `Text before const content = `Text before
{ {
"features": [ "features": [
@@ -87,7 +87,7 @@ Text after`;
expect(match![0]).toContain('"features"'); expect(match![0]).toContain('"features"');
}); });
it("should extract JSON when features array is empty", () => { it('should extract JSON when features array is empty', () => {
const content = `Response: const content = `Response:
{ {
"features": [] "features": []
@@ -96,10 +96,10 @@ Text after`;
const match = content.match(jsonExtractionPattern); const match = content.match(jsonExtractionPattern);
expect(match).not.toBeNull(); expect(match).not.toBeNull();
expect(match![0]).toContain('"features"'); expect(match![0]).toContain('"features"');
expect(match![0]).toContain("[]"); expect(match![0]).toContain('[]');
}); });
it("should not match content without features key", () => { it('should not match content without features key', () => {
const content = `{ const content = `{
"otherKey": "value" "otherKey": "value"
}`; }`;
@@ -108,13 +108,13 @@ Text after`;
expect(match).toBeNull(); expect(match).toBeNull();
}); });
it("should not match content without JSON structure", () => { it('should not match content without JSON structure', () => {
const content = "Just plain text with features mentioned"; const content = 'Just plain text with features mentioned';
const match = content.match(jsonExtractionPattern); const match = content.match(jsonExtractionPattern);
expect(match).toBeNull(); expect(match).toBeNull();
}); });
it("should extract JSON when features key appears multiple times", () => { it('should extract JSON when features key appears multiple times', () => {
const content = `Before: const content = `Before:
{ {
"features": [ "features": [
@@ -132,7 +132,7 @@ After: The word "features" appears again`;
expect(match![0]).toContain('"features"'); expect(match![0]).toContain('"features"');
}); });
it("should handle JSON with escaped quotes", () => { it('should handle JSON with escaped quotes', () => {
const content = `{ const content = `{
"features": [ "features": [
{ {
@@ -147,7 +147,7 @@ After: The word "features" appears again`;
expect(match![0]).toContain('"features"'); expect(match![0]).toContain('"features"');
}); });
it("should extract JSON with complex nested structure", () => { it('should extract JSON with complex nested structure', () => {
const content = `Response: const content = `Response:
{ {
"features": [ "features": [
@@ -177,8 +177,8 @@ After: The word "features" appears again`;
}); });
}); });
describe("JSON parsing validation", () => { describe('JSON parsing validation', () => {
it("should parse valid feature JSON structure", () => { it('should parse valid feature JSON structure', () => {
const validJson = `{ const validJson = `{
"features": [ "features": [
{ {
@@ -196,11 +196,11 @@ After: The word "features" appears again`;
expect(parsed.features).toBeDefined(); expect(parsed.features).toBeDefined();
expect(Array.isArray(parsed.features)).toBe(true); expect(Array.isArray(parsed.features)).toBe(true);
expect(parsed.features.length).toBe(1); expect(parsed.features.length).toBe(1);
expect(parsed.features[0].id).toBe("feature-1"); expect(parsed.features[0].id).toBe('feature-1');
expect(parsed.features[0].title).toBe("Test Feature"); expect(parsed.features[0].title).toBe('Test Feature');
}); });
it("should handle features with optional fields", () => { it('should handle features with optional fields', () => {
const jsonWithOptionalFields = `{ const jsonWithOptionalFields = `{
"features": [ "features": [
{ {
@@ -213,14 +213,14 @@ After: The word "features" appears again`;
}`; }`;
const parsed = JSON.parse(jsonWithOptionalFields); const parsed = JSON.parse(jsonWithOptionalFields);
expect(parsed.features[0].id).toBe("feature-1"); expect(parsed.features[0].id).toBe('feature-1');
expect(parsed.features[0].priority).toBe(2); expect(parsed.features[0].priority).toBe(2);
// description and dependencies are optional // description and dependencies are optional
expect(parsed.features[0].description).toBeUndefined(); expect(parsed.features[0].description).toBeUndefined();
expect(parsed.features[0].dependencies).toBeUndefined(); expect(parsed.features[0].dependencies).toBeUndefined();
}); });
it("should handle features with dependencies", () => { it('should handle features with dependencies', () => {
const jsonWithDeps = `{ const jsonWithDeps = `{
"features": [ "features": [
{ {
@@ -238,7 +238,7 @@ After: The word "features" appears again`;
const parsed = JSON.parse(jsonWithDeps); const parsed = JSON.parse(jsonWithDeps);
expect(parsed.features[0].dependencies).toEqual([]); expect(parsed.features[0].dependencies).toEqual([]);
expect(parsed.features[1].dependencies).toEqual(["feature-1"]); expect(parsed.features[1].dependencies).toEqual(['feature-1']);
}); });
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AutoModeService } from "@/services/auto-mode-service.js"; import { AutoModeService } from '@/services/auto-mode-service.js';
describe("auto-mode-service.ts - Planning Mode", () => { describe('auto-mode-service.ts - Planning Mode', () => {
let service: AutoModeService; let service: AutoModeService;
const mockEvents = { const mockEvents = {
subscribe: vi.fn(), subscribe: vi.fn(),
@@ -18,98 +18,98 @@ describe("auto-mode-service.ts - Planning Mode", () => {
await service.stopAutoLoop().catch(() => {}); await service.stopAutoLoop().catch(() => {});
}); });
describe("getPlanningPromptPrefix", () => { describe('getPlanningPromptPrefix', () => {
// Access private method through any cast for testing // Access private method through any cast for testing
const getPlanningPromptPrefix = (svc: any, feature: any) => { const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature); return svc.getPlanningPromptPrefix(feature);
}; };
it("should return empty string for skip mode", () => { it('should return empty string for skip mode', () => {
const feature = { id: "test", planningMode: "skip" as const }; const feature = { id: 'test', planningMode: 'skip' as const };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe(""); expect(result).toBe('');
}); });
it("should return empty string when planningMode is undefined", () => { it('should return empty string when planningMode is undefined', () => {
const feature = { id: "test" }; const feature = { id: 'test' };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe(""); expect(result).toBe('');
}); });
it("should return lite prompt for lite mode without approval", () => { it('should return lite prompt for lite mode without approval', () => {
const feature = { const feature = {
id: "test", id: 'test',
planningMode: "lite" as const, planningMode: 'lite' as const,
requirePlanApproval: false requirePlanApproval: false,
}; };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Planning Phase (Lite Mode)"); expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain("[PLAN_GENERATED]"); expect(result).toContain('[PLAN_GENERATED]');
expect(result).toContain("Feature Request"); expect(result).toContain('Feature Request');
}); });
it("should return lite_with_approval prompt for lite mode with approval", () => { it('should return lite_with_approval prompt for lite mode with approval', () => {
const feature = { const feature = {
id: "test", id: 'test',
planningMode: "lite" as const, planningMode: 'lite' as const,
requirePlanApproval: true requirePlanApproval: true,
}; };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Planning Phase (Lite Mode)"); expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain("[SPEC_GENERATED]"); expect(result).toContain('[SPEC_GENERATED]');
expect(result).toContain("DO NOT proceed with implementation"); expect(result).toContain('DO NOT proceed with implementation');
}); });
it("should return spec prompt for spec mode", () => { it('should return spec prompt for spec mode', () => {
const feature = { const feature = {
id: "test", id: 'test',
planningMode: "spec" as const planningMode: 'spec' as const,
}; };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Specification Phase (Spec Mode)"); expect(result).toContain('Specification Phase (Spec Mode)');
expect(result).toContain("```tasks"); expect(result).toContain('```tasks');
expect(result).toContain("T001"); expect(result).toContain('T001');
expect(result).toContain("[TASK_START]"); expect(result).toContain('[TASK_START]');
expect(result).toContain("[TASK_COMPLETE]"); expect(result).toContain('[TASK_COMPLETE]');
}); });
it("should return full prompt for full mode", () => { it('should return full prompt for full mode', () => {
const feature = { const feature = {
id: "test", id: 'test',
planningMode: "full" as const planningMode: 'full' as const,
}; };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Full Specification Phase (Full SDD Mode)"); expect(result).toContain('Full Specification Phase (Full SDD Mode)');
expect(result).toContain("Phase 1: Foundation"); expect(result).toContain('Phase 1: Foundation');
expect(result).toContain("Phase 2: Core Implementation"); expect(result).toContain('Phase 2: Core Implementation');
expect(result).toContain("Phase 3: Integration & Testing"); expect(result).toContain('Phase 3: Integration & Testing');
}); });
it("should include the separator and Feature Request header", () => { it('should include the separator and Feature Request header', () => {
const feature = { const feature = {
id: "test", id: 'test',
planningMode: "spec" as const planningMode: 'spec' as const,
}; };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("---"); expect(result).toContain('---');
expect(result).toContain("## Feature Request"); expect(result).toContain('## Feature Request');
}); });
it("should instruct agent to NOT output exploration text", () => { it('should instruct agent to NOT output exploration text', () => {
const modes = ["lite", "spec", "full"] as const; const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) { for (const mode of modes) {
const feature = { id: "test", planningMode: mode }; const feature = { id: 'test', planningMode: mode };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Do NOT output exploration text"); expect(result).toContain('Do NOT output exploration text');
expect(result).toContain("Start DIRECTLY"); expect(result).toContain('Start DIRECTLY');
} }
}); });
}); });
describe("parseTasksFromSpec (via module)", () => { describe('parseTasksFromSpec (via module)', () => {
// We need to test the module-level function // We need to test the module-level function
// Import it directly for testing // Import it directly for testing
it("should parse tasks from a valid tasks block", async () => { it('should parse tasks from a valid tasks block', async () => {
// This tests the internal logic through integration // This tests the internal logic through integration
// The function is module-level, so we verify behavior through the service // The function is module-level, so we verify behavior through the service
const specContent = ` const specContent = `
@@ -123,12 +123,12 @@ describe("auto-mode-service.ts - Planning Mode", () => {
`; `;
// Since parseTasksFromSpec is a module-level function, // Since parseTasksFromSpec is a module-level function,
// we verify its behavior indirectly through plan parsing // we verify its behavior indirectly through plan parsing
expect(specContent).toContain("T001"); expect(specContent).toContain('T001');
expect(specContent).toContain("T002"); expect(specContent).toContain('T002');
expect(specContent).toContain("T003"); expect(specContent).toContain('T003');
}); });
it("should handle tasks block with phases", () => { it('should handle tasks block with phases', () => {
const specContent = ` const specContent = `
\`\`\`tasks \`\`\`tasks
## Phase 1: Setup ## Phase 1: Setup
@@ -139,190 +139,191 @@ describe("auto-mode-service.ts - Planning Mode", () => {
- [ ] T003: Create main module | File: src/index.ts - [ ] T003: Create main module | File: src/index.ts
\`\`\` \`\`\`
`; `;
expect(specContent).toContain("Phase 1"); expect(specContent).toContain('Phase 1');
expect(specContent).toContain("Phase 2"); expect(specContent).toContain('Phase 2');
expect(specContent).toContain("T001"); expect(specContent).toContain('T001');
expect(specContent).toContain("T003"); expect(specContent).toContain('T003');
}); });
}); });
describe("plan approval flow", () => { describe('plan approval flow', () => {
it("should track pending approvals correctly", () => { it('should track pending approvals correctly', () => {
expect(service.hasPendingApproval("test-feature")).toBe(false); expect(service.hasPendingApproval('test-feature')).toBe(false);
}); });
it("should allow cancelling non-existent approval without error", () => { it('should allow cancelling non-existent approval without error', () => {
expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); expect(() => service.cancelPlanApproval('non-existent')).not.toThrow();
}); });
it("should return running features count after stop", async () => { it('should return running features count after stop', async () => {
const count = await service.stopAutoLoop(); const count = await service.stopAutoLoop();
expect(count).toBe(0); expect(count).toBe(0);
}); });
}); });
describe("resolvePlanApproval", () => { describe('resolvePlanApproval', () => {
it("should return error when no pending approval exists", async () => { it('should return error when no pending approval exists', async () => {
const result = await service.resolvePlanApproval( const result = await service.resolvePlanApproval(
"non-existent-feature", 'non-existent-feature',
true, true,
undefined, undefined,
undefined, undefined,
undefined undefined
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("No pending approval"); expect(result.error).toContain('No pending approval');
}); });
it("should handle approval with edited plan", async () => { it('should handle approval with edited plan', async () => {
// Without a pending approval, this should fail gracefully // Without a pending approval, this should fail gracefully
const result = await service.resolvePlanApproval( const result = await service.resolvePlanApproval(
"test-feature", 'test-feature',
true, true,
"Edited plan content", 'Edited plan content',
undefined, undefined,
undefined undefined
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it("should handle rejection with feedback", async () => { it('should handle rejection with feedback', async () => {
const result = await service.resolvePlanApproval( const result = await service.resolvePlanApproval(
"test-feature", 'test-feature',
false, false,
undefined, undefined,
"Please add more details", 'Please add more details',
undefined undefined
); );
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
}); });
describe("buildFeaturePrompt", () => { describe('buildFeaturePrompt', () => {
const buildFeaturePrompt = (svc: any, feature: any) => { const buildFeaturePrompt = (svc: any, feature: any) => {
return svc.buildFeaturePrompt(feature); return svc.buildFeaturePrompt(feature);
}; };
it("should include feature ID and description", () => { it('should include feature ID and description', () => {
const feature = { const feature = {
id: "feat-123", id: 'feat-123',
description: "Add user authentication", description: 'Add user authentication',
}; };
const result = buildFeaturePrompt(service, feature); const result = buildFeaturePrompt(service, feature);
expect(result).toContain("feat-123"); expect(result).toContain('feat-123');
expect(result).toContain("Add user authentication"); expect(result).toContain('Add user authentication');
}); });
it("should include specification when present", () => { it('should include specification when present', () => {
const feature = { const feature = {
id: "feat-123", id: 'feat-123',
description: "Test feature", description: 'Test feature',
spec: "Detailed specification here", spec: 'Detailed specification here',
}; };
const result = buildFeaturePrompt(service, feature); const result = buildFeaturePrompt(service, feature);
expect(result).toContain("Specification:"); expect(result).toContain('Specification:');
expect(result).toContain("Detailed specification here"); expect(result).toContain('Detailed specification here');
}); });
it("should include image paths when present", () => { it('should include image paths when present', () => {
const feature = { const feature = {
id: "feat-123", id: 'feat-123',
description: "Test feature", description: 'Test feature',
imagePaths: [ imagePaths: [
{ path: "/tmp/image1.png", filename: "image1.png", mimeType: "image/png" }, { path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
"/tmp/image2.jpg", '/tmp/image2.jpg',
], ],
}; };
const result = buildFeaturePrompt(service, feature); const result = buildFeaturePrompt(service, feature);
expect(result).toContain("Context Images Attached"); expect(result).toContain('Context Images Attached');
expect(result).toContain("image1.png"); expect(result).toContain('image1.png');
expect(result).toContain("/tmp/image2.jpg"); expect(result).toContain('/tmp/image2.jpg');
}); });
it("should include summary tags instruction", () => { it('should include summary tags instruction', () => {
const feature = { const feature = {
id: "feat-123", id: 'feat-123',
description: "Test feature", description: 'Test feature',
}; };
const result = buildFeaturePrompt(service, feature); const result = buildFeaturePrompt(service, feature);
expect(result).toContain("<summary>"); expect(result).toContain('<summary>');
expect(result).toContain("</summary>"); expect(result).toContain('</summary>');
}); });
}); });
describe("extractTitleFromDescription", () => { describe('extractTitleFromDescription', () => {
const extractTitle = (svc: any, description: string) => { const extractTitle = (svc: any, description: string) => {
return svc.extractTitleFromDescription(description); return svc.extractTitleFromDescription(description);
}; };
it("should return 'Untitled Feature' for empty description", () => { it("should return 'Untitled Feature' for empty description", () => {
expect(extractTitle(service, "")).toBe("Untitled Feature"); expect(extractTitle(service, '')).toBe('Untitled Feature');
expect(extractTitle(service, " ")).toBe("Untitled Feature"); expect(extractTitle(service, ' ')).toBe('Untitled Feature');
}); });
it("should return first line if under 60 characters", () => { it('should return first line if under 60 characters', () => {
const description = "Add user login\nWith email validation"; const description = 'Add user login\nWith email validation';
expect(extractTitle(service, description)).toBe("Add user login"); expect(extractTitle(service, description)).toBe('Add user login');
}); });
it("should truncate long first lines to 60 characters", () => { it('should truncate long first lines to 60 characters', () => {
const description = "This is a very long feature description that exceeds the sixty character limit significantly"; const description =
'This is a very long feature description that exceeds the sixty character limit significantly';
const result = extractTitle(service, description); const result = extractTitle(service, description);
expect(result.length).toBe(60); expect(result.length).toBe(60);
expect(result).toContain("..."); expect(result).toContain('...');
}); });
}); });
describe("PLANNING_PROMPTS structure", () => { describe('PLANNING_PROMPTS structure', () => {
const getPlanningPromptPrefix = (svc: any, feature: any) => { const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature); return svc.getPlanningPromptPrefix(feature);
}; };
it("should have all required planning modes", () => { it('should have all required planning modes', () => {
const modes = ["lite", "spec", "full"] as const; const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) { for (const mode of modes) {
const feature = { id: "test", planningMode: mode }; const feature = { id: 'test', planningMode: mode };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result.length).toBeGreaterThan(100); expect(result.length).toBeGreaterThan(100);
} }
}); });
it("lite prompt should include correct structure", () => { it('lite prompt should include correct structure', () => {
const feature = { id: "test", planningMode: "lite" as const }; const feature = { id: 'test', planningMode: 'lite' as const };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Goal"); expect(result).toContain('Goal');
expect(result).toContain("Approach"); expect(result).toContain('Approach');
expect(result).toContain("Files to Touch"); expect(result).toContain('Files to Touch');
expect(result).toContain("Tasks"); expect(result).toContain('Tasks');
expect(result).toContain("Risks"); expect(result).toContain('Risks');
}); });
it("spec prompt should include task format instructions", () => { it('spec prompt should include task format instructions', () => {
const feature = { id: "test", planningMode: "spec" as const }; const feature = { id: 'test', planningMode: 'spec' as const };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Problem"); expect(result).toContain('Problem');
expect(result).toContain("Solution"); expect(result).toContain('Solution');
expect(result).toContain("Acceptance Criteria"); expect(result).toContain('Acceptance Criteria');
expect(result).toContain("GIVEN-WHEN-THEN"); expect(result).toContain('GIVEN-WHEN-THEN');
expect(result).toContain("Implementation Tasks"); expect(result).toContain('Implementation Tasks');
expect(result).toContain("Verification"); expect(result).toContain('Verification');
}); });
it("full prompt should include phases", () => { it('full prompt should include phases', () => {
const feature = { id: "test", planningMode: "full" as const }; const feature = { id: 'test', planningMode: 'full' as const };
const result = getPlanningPromptPrefix(service, feature); const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Problem Statement"); expect(result).toContain('Problem Statement');
expect(result).toContain("User Story"); expect(result).toContain('User Story');
expect(result).toContain("Technical Context"); expect(result).toContain('Technical Context');
expect(result).toContain("Non-Goals"); expect(result).toContain('Non-Goals');
expect(result).toContain("Phase 1"); expect(result).toContain('Phase 1');
expect(result).toContain("Phase 2"); expect(result).toContain('Phase 2');
expect(result).toContain("Phase 3"); expect(result).toContain('Phase 3');
}); });
}); });
describe("status management", () => { describe('status management', () => {
it("should report correct status", () => { it('should report correct status', () => {
const status = service.getStatus(); const status = service.getStatus();
expect(status.runningFeatures).toEqual([]); expect(status.runningFeatures).toEqual([]);
expect(status.isRunning).toBe(false); expect(status.isRunning).toBe(false);

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AutoModeService } from "@/services/auto-mode-service.js"; import { AutoModeService } from '@/services/auto-mode-service.js';
describe("auto-mode-service.ts", () => { describe('auto-mode-service.ts', () => {
let service: AutoModeService; let service: AutoModeService;
const mockEvents = { const mockEvents = {
subscribe: vi.fn(), subscribe: vi.fn(),
@@ -13,29 +13,27 @@ describe("auto-mode-service.ts", () => {
service = new AutoModeService(mockEvents as any); service = new AutoModeService(mockEvents as any);
}); });
describe("constructor", () => { describe('constructor', () => {
it("should initialize with event emitter", () => { it('should initialize with event emitter', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
}); });
describe("startAutoLoop", () => { describe('startAutoLoop', () => {
it("should throw if auto mode is already running", async () => { it('should throw if auto mode is already running', async () => {
// Start first loop // Start first loop
const promise1 = service.startAutoLoop("/test/project", 3); const promise1 = service.startAutoLoop('/test/project', 3);
// Try to start second loop // Try to start second loop
await expect( await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running');
service.startAutoLoop("/test/project", 3)
).rejects.toThrow("already running");
// Cleanup // Cleanup
await service.stopAutoLoop(); await service.stopAutoLoop();
await promise1.catch(() => {}); await promise1.catch(() => {});
}); });
it("should emit auto mode start event", async () => { it('should emit auto mode start event', async () => {
const promise = service.startAutoLoop("/test/project", 3); const promise = service.startAutoLoop('/test/project', 3);
// Give it time to emit the event // Give it time to emit the event
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
@@ -43,7 +41,7 @@ describe("auto-mode-service.ts", () => {
expect(mockEvents.emit).toHaveBeenCalledWith( expect(mockEvents.emit).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
message: expect.stringContaining("Auto mode started"), message: expect.stringContaining('Auto mode started'),
}) })
); );
@@ -53,9 +51,9 @@ describe("auto-mode-service.ts", () => {
}); });
}); });
describe("stopAutoLoop", () => { describe('stopAutoLoop', () => {
it("should stop the auto loop", async () => { it('should stop the auto loop', async () => {
const promise = service.startAutoLoop("/test/project", 3); const promise = service.startAutoLoop('/test/project', 3);
const runningCount = await service.stopAutoLoop(); const runningCount = await service.stopAutoLoop();
@@ -63,7 +61,7 @@ describe("auto-mode-service.ts", () => {
await promise.catch(() => {}); await promise.catch(() => {});
}); });
it("should return 0 when not running", async () => { it('should return 0 when not running', async () => {
const runningCount = await service.stopAutoLoop(); const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0); expect(runningCount).toBe(0);
}); });

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from 'vitest';
/** /**
* Test the task parsing logic by reimplementing the parsing functions * Test the task parsing logic by reimplementing the parsing functions
@@ -88,59 +88,59 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] {
return tasks; return tasks;
} }
describe("Task Parsing", () => { describe('Task Parsing', () => {
describe("parseTaskLine", () => { describe('parseTaskLine', () => {
it("should parse task with file path", () => { it('should parse task with file path', () => {
const line = "- [ ] T001: Create user model | File: src/models/user.ts"; const line = '- [ ] T001: Create user model | File: src/models/user.ts';
const result = parseTaskLine(line); const result = parseTaskLine(line);
expect(result).toEqual({ expect(result).toEqual({
id: "T001", id: 'T001',
description: "Create user model", description: 'Create user model',
filePath: "src/models/user.ts", filePath: 'src/models/user.ts',
phase: undefined, phase: undefined,
status: "pending", status: 'pending',
}); });
}); });
it("should parse task without file path", () => { it('should parse task without file path', () => {
const line = "- [ ] T002: Setup database connection"; const line = '- [ ] T002: Setup database connection';
const result = parseTaskLine(line); const result = parseTaskLine(line);
expect(result).toEqual({ expect(result).toEqual({
id: "T002", id: 'T002',
description: "Setup database connection", description: 'Setup database connection',
phase: undefined, phase: undefined,
status: "pending", status: 'pending',
}); });
}); });
it("should include phase when provided", () => { it('should include phase when provided', () => {
const line = "- [ ] T003: Write tests | File: tests/user.test.ts"; const line = '- [ ] T003: Write tests | File: tests/user.test.ts';
const result = parseTaskLine(line, "Phase 1: Foundation"); const result = parseTaskLine(line, 'Phase 1: Foundation');
expect(result?.phase).toBe("Phase 1: Foundation"); expect(result?.phase).toBe('Phase 1: Foundation');
}); });
it("should return null for invalid line", () => { it('should return null for invalid line', () => {
expect(parseTaskLine("- [ ] Invalid format")).toBeNull(); expect(parseTaskLine('- [ ] Invalid format')).toBeNull();
expect(parseTaskLine("Not a task line")).toBeNull(); expect(parseTaskLine('Not a task line')).toBeNull();
expect(parseTaskLine("")).toBeNull(); expect(parseTaskLine('')).toBeNull();
}); });
it("should handle multi-word descriptions", () => { it('should handle multi-word descriptions', () => {
const line = "- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts"; const line = '- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts';
const result = parseTaskLine(line); const result = parseTaskLine(line);
expect(result?.description).toBe("Implement user authentication with JWT tokens"); expect(result?.description).toBe('Implement user authentication with JWT tokens');
}); });
it("should trim whitespace from description and file path", () => { it('should trim whitespace from description and file path', () => {
const line = "- [ ] T005: Create API endpoint | File: src/routes/api.ts "; const line = '- [ ] T005: Create API endpoint | File: src/routes/api.ts ';
const result = parseTaskLine(line); const result = parseTaskLine(line);
expect(result?.description).toBe("Create API endpoint"); expect(result?.description).toBe('Create API endpoint');
expect(result?.filePath).toBe("src/routes/api.ts"); expect(result?.filePath).toBe('src/routes/api.ts');
}); });
}); });
describe("parseTasksFromSpec", () => { describe('parseTasksFromSpec', () => {
it("should parse tasks from a tasks code block", () => { it('should parse tasks from a tasks code block', () => {
const specContent = ` const specContent = `
## Specification ## Specification
@@ -157,12 +157,12 @@ Some notes here.
`; `;
const tasks = parseTasksFromSpec(specContent); const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(3); expect(tasks).toHaveLength(3);
expect(tasks[0].id).toBe("T001"); expect(tasks[0].id).toBe('T001');
expect(tasks[1].id).toBe("T002"); expect(tasks[1].id).toBe('T002');
expect(tasks[2].id).toBe("T003"); expect(tasks[2].id).toBe('T003');
}); });
it("should parse tasks with phases", () => { it('should parse tasks with phases', () => {
const specContent = ` const specContent = `
\`\`\`tasks \`\`\`tasks
## Phase 1: Foundation ## Phase 1: Foundation
@@ -179,20 +179,20 @@ Some notes here.
`; `;
const tasks = parseTasksFromSpec(specContent); const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(5); expect(tasks).toHaveLength(5);
expect(tasks[0].phase).toBe("Phase 1: Foundation"); expect(tasks[0].phase).toBe('Phase 1: Foundation');
expect(tasks[1].phase).toBe("Phase 1: Foundation"); expect(tasks[1].phase).toBe('Phase 1: Foundation');
expect(tasks[2].phase).toBe("Phase 2: Implementation"); expect(tasks[2].phase).toBe('Phase 2: Implementation');
expect(tasks[3].phase).toBe("Phase 2: Implementation"); expect(tasks[3].phase).toBe('Phase 2: Implementation');
expect(tasks[4].phase).toBe("Phase 3: Testing"); expect(tasks[4].phase).toBe('Phase 3: Testing');
}); });
it("should return empty array for content without tasks", () => { it('should return empty array for content without tasks', () => {
const specContent = "Just some text without any tasks"; const specContent = 'Just some text without any tasks';
const tasks = parseTasksFromSpec(specContent); const tasks = parseTasksFromSpec(specContent);
expect(tasks).toEqual([]); expect(tasks).toEqual([]);
}); });
it("should fallback to finding task lines outside code block", () => { it('should fallback to finding task lines outside code block', () => {
const specContent = ` const specContent = `
## Implementation Plan ## Implementation Plan
@@ -201,11 +201,11 @@ Some notes here.
`; `;
const tasks = parseTasksFromSpec(specContent); const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(2); expect(tasks).toHaveLength(2);
expect(tasks[0].id).toBe("T001"); expect(tasks[0].id).toBe('T001');
expect(tasks[1].id).toBe("T002"); expect(tasks[1].id).toBe('T002');
}); });
it("should handle empty tasks block", () => { it('should handle empty tasks block', () => {
const specContent = ` const specContent = `
\`\`\`tasks \`\`\`tasks
\`\`\` \`\`\`
@@ -214,7 +214,7 @@ Some notes here.
expect(tasks).toEqual([]); expect(tasks).toEqual([]);
}); });
it("should handle mixed valid and invalid lines", () => { it('should handle mixed valid and invalid lines', () => {
const specContent = ` const specContent = `
\`\`\`tasks \`\`\`tasks
- [ ] T001: Valid task | File: src/valid.ts - [ ] T001: Valid task | File: src/valid.ts
@@ -227,7 +227,7 @@ Some other text
expect(tasks).toHaveLength(2); expect(tasks).toHaveLength(2);
}); });
it("should preserve task order", () => { it('should preserve task order', () => {
const specContent = ` const specContent = `
\`\`\`tasks \`\`\`tasks
- [ ] T003: Third - [ ] T003: Third
@@ -236,12 +236,12 @@ Some other text
\`\`\` \`\`\`
`; `;
const tasks = parseTasksFromSpec(specContent); const tasks = parseTasksFromSpec(specContent);
expect(tasks[0].id).toBe("T003"); expect(tasks[0].id).toBe('T003');
expect(tasks[1].id).toBe("T001"); expect(tasks[1].id).toBe('T001');
expect(tasks[2].id).toBe("T002"); expect(tasks[2].id).toBe('T002');
}); });
it("should handle task IDs with different numbers", () => { it('should handle task IDs with different numbers', () => {
const specContent = ` const specContent = `
\`\`\`tasks \`\`\`tasks
- [ ] T001: First - [ ] T001: First
@@ -251,14 +251,14 @@ Some other text
`; `;
const tasks = parseTasksFromSpec(specContent); const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(3); expect(tasks).toHaveLength(3);
expect(tasks[0].id).toBe("T001"); expect(tasks[0].id).toBe('T001');
expect(tasks[1].id).toBe("T010"); expect(tasks[1].id).toBe('T010');
expect(tasks[2].id).toBe("T100"); expect(tasks[2].id).toBe('T100');
}); });
}); });
describe("spec content generation patterns", () => { describe('spec content generation patterns', () => {
it("should match the expected lite mode output format", () => { it('should match the expected lite mode output format', () => {
const liteModeOutput = ` const liteModeOutput = `
1. **Goal**: Implement user registration 1. **Goal**: Implement user registration
2. **Approach**: Create form component, add validation, connect to API 2. **Approach**: Create form component, add validation, connect to API
@@ -271,12 +271,12 @@ Some other text
[PLAN_GENERATED] Planning outline complete. [PLAN_GENERATED] Planning outline complete.
`; `;
expect(liteModeOutput).toContain("[PLAN_GENERATED]"); expect(liteModeOutput).toContain('[PLAN_GENERATED]');
expect(liteModeOutput).toContain("Goal"); expect(liteModeOutput).toContain('Goal');
expect(liteModeOutput).toContain("Approach"); expect(liteModeOutput).toContain('Approach');
}); });
it("should match the expected spec mode output format", () => { it('should match the expected spec mode output format', () => {
const specModeOutput = ` const specModeOutput = `
1. **Problem**: Users cannot register for accounts 1. **Problem**: Users cannot register for accounts
@@ -300,12 +300,12 @@ Some other text
[SPEC_GENERATED] Please review the specification above. [SPEC_GENERATED] Please review the specification above.
`; `;
expect(specModeOutput).toContain("[SPEC_GENERATED]"); expect(specModeOutput).toContain('[SPEC_GENERATED]');
expect(specModeOutput).toContain("```tasks"); expect(specModeOutput).toContain('```tasks');
expect(specModeOutput).toContain("T001"); expect(specModeOutput).toContain('T001');
}); });
it("should match the expected full mode output format", () => { it('should match the expected full mode output format', () => {
const fullModeOutput = ` const fullModeOutput = `
1. **Problem Statement**: Users need ability to create accounts 1. **Problem Statement**: Users need ability to create accounts
@@ -336,10 +336,10 @@ Some other text
[SPEC_GENERATED] Please review the comprehensive specification above. [SPEC_GENERATED] Please review the comprehensive specification above.
`; `;
expect(fullModeOutput).toContain("Phase 1"); expect(fullModeOutput).toContain('Phase 1');
expect(fullModeOutput).toContain("Phase 2"); expect(fullModeOutput).toContain('Phase 2');
expect(fullModeOutput).toContain("Phase 3"); expect(fullModeOutput).toContain('Phase 3');
expect(fullModeOutput).toContain("[SPEC_GENERATED]"); expect(fullModeOutput).toContain('[SPEC_GENERATED]');
}); });
}); });
}); });

View File

@@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TerminalService, getTerminalService } from "@/services/terminal-service.js"; import { TerminalService, getTerminalService } from '@/services/terminal-service.js';
import * as pty from "node-pty"; import * as pty from 'node-pty';
import * as os from "os"; import * as os from 'os';
import * as fs from "fs"; import * as fs from 'fs';
vi.mock("node-pty"); vi.mock('node-pty');
vi.mock("fs"); vi.mock('fs');
vi.mock("os"); vi.mock('os');
describe("terminal-service.ts", () => { describe('terminal-service.ts', () => {
let service: TerminalService; let service: TerminalService;
let mockPtyProcess: any; let mockPtyProcess: any;
@@ -26,225 +26,225 @@ describe("terminal-service.ts", () => {
}; };
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
vi.mocked(os.homedir).mockReturnValue("/home/user"); vi.mocked(os.homedir).mockReturnValue('/home/user');
vi.mocked(os.platform).mockReturnValue("linux"); vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.arch).mockReturnValue("x64"); vi.mocked(os.arch).mockReturnValue('x64');
}); });
afterEach(() => { afterEach(() => {
service.cleanup(); service.cleanup();
}); });
describe("detectShell", () => { describe('detectShell', () => {
it("should detect PowerShell Core on Windows when available", () => { it('should detect PowerShell Core on Windows when available', () => {
vi.mocked(os.platform).mockReturnValue("win32"); vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(fs.existsSync).mockImplementation((path: any) => { vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
}); });
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe"); expect(result.shell).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe');
expect(result.args).toEqual([]); expect(result.args).toEqual([]);
}); });
it("should fall back to PowerShell on Windows if Core not available", () => { it('should fall back to PowerShell on Windows if Core not available', () => {
vi.mocked(os.platform).mockReturnValue("win32"); vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(fs.existsSync).mockImplementation((path: any) => { vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
}); });
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); expect(result.shell).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe');
expect(result.args).toEqual([]); expect(result.args).toEqual([]);
}); });
it("should fall back to cmd.exe on Windows if no PowerShell", () => { it('should fall back to cmd.exe on Windows if no PowerShell', () => {
vi.mocked(os.platform).mockReturnValue("win32"); vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("cmd.exe"); expect(result.shell).toBe('cmd.exe');
expect(result.args).toEqual([]); expect(result.args).toEqual([]);
}); });
it("should detect user shell on macOS", () => { it('should detect user shell on macOS', () => {
vi.mocked(os.platform).mockReturnValue("darwin"); vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/zsh" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' });
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/zsh"); expect(result.shell).toBe('/bin/zsh');
expect(result.args).toEqual(["--login"]); expect(result.args).toEqual(['--login']);
}); });
it("should fall back to zsh on macOS if user shell not available", () => { it('should fall back to zsh on macOS if user shell not available', () => {
vi.mocked(os.platform).mockReturnValue("darwin"); vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, "env", "get").mockReturnValue({}); vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockImplementation((path: any) => { vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "/bin/zsh"; return path === '/bin/zsh';
}); });
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/zsh"); expect(result.shell).toBe('/bin/zsh');
expect(result.args).toEqual(["--login"]); expect(result.args).toEqual(['--login']);
}); });
it("should fall back to bash on macOS if zsh not available", () => { it('should fall back to bash on macOS if zsh not available', () => {
vi.mocked(os.platform).mockReturnValue("darwin"); vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, "env", "get").mockReturnValue({}); vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/bash"); expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(["--login"]); expect(result.args).toEqual(['--login']);
}); });
it("should detect user shell on Linux", () => { it('should detect user shell on Linux', () => {
vi.mocked(os.platform).mockReturnValue("linux"); vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/bash"); expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(["--login"]); expect(result.args).toEqual(['--login']);
}); });
it("should fall back to bash on Linux if user shell not available", () => { it('should fall back to bash on Linux if user shell not available', () => {
vi.mocked(os.platform).mockReturnValue("linux"); vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, "env", "get").mockReturnValue({}); vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockImplementation((path: any) => { vi.mocked(fs.existsSync).mockImplementation((path: any) => {
return path === "/bin/bash"; return path === '/bin/bash';
}); });
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/bash"); expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(["--login"]); expect(result.args).toEqual(['--login']);
}); });
it("should fall back to sh on Linux if bash not available", () => { it('should fall back to sh on Linux if bash not available', () => {
vi.mocked(os.platform).mockReturnValue("linux"); vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, "env", "get").mockReturnValue({}); vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/sh"); expect(result.shell).toBe('/bin/sh');
expect(result.args).toEqual([]); expect(result.args).toEqual([]);
}); });
it("should detect WSL and use appropriate shell", () => { it('should detect WSL and use appropriate shell', () => {
vi.mocked(os.platform).mockReturnValue("linux"); vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2"); vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
const result = service.detectShell(); const result = service.detectShell();
expect(result.shell).toBe("/bin/bash"); expect(result.shell).toBe('/bin/bash');
expect(result.args).toEqual(["--login"]); expect(result.args).toEqual(['--login']);
}); });
}); });
describe("isWSL", () => { describe('isWSL', () => {
it("should return true if /proc/version contains microsoft", () => { it('should return true if /proc/version contains microsoft', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2"); vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
expect(service.isWSL()).toBe(true); expect(service.isWSL()).toBe(true);
}); });
it("should return true if /proc/version contains wsl", () => { it('should return true if /proc/version contains wsl', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-wsl2"); vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
expect(service.isWSL()).toBe(true); expect(service.isWSL()).toBe(true);
}); });
it("should return true if WSL_DISTRO_NAME is set", () => { it('should return true if WSL_DISTRO_NAME is set', () => {
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
vi.spyOn(process, "env", "get").mockReturnValue({ WSL_DISTRO_NAME: "Ubuntu" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' });
expect(service.isWSL()).toBe(true); expect(service.isWSL()).toBe(true);
}); });
it("should return true if WSLENV is set", () => { it('should return true if WSLENV is set', () => {
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
vi.spyOn(process, "env", "get").mockReturnValue({ WSLENV: "PATH/l" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' });
expect(service.isWSL()).toBe(true); expect(service.isWSL()).toBe(true);
}); });
it("should return false if not in WSL", () => { it('should return false if not in WSL', () => {
vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
vi.spyOn(process, "env", "get").mockReturnValue({}); vi.spyOn(process, 'env', 'get').mockReturnValue({});
expect(service.isWSL()).toBe(false); expect(service.isWSL()).toBe(false);
}); });
it("should return false if error reading /proc/version", () => { it('should return false if error reading /proc/version', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation(() => { vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error("Permission denied"); throw new Error('Permission denied');
}); });
expect(service.isWSL()).toBe(false); expect(service.isWSL()).toBe(false);
}); });
}); });
describe("getPlatformInfo", () => { describe('getPlatformInfo', () => {
it("should return platform information", () => { it('should return platform information', () => {
vi.mocked(os.platform).mockReturnValue("linux"); vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.arch).mockReturnValue("x64"); vi.mocked(os.arch).mockReturnValue('x64');
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const info = service.getPlatformInfo(); const info = service.getPlatformInfo();
expect(info.platform).toBe("linux"); expect(info.platform).toBe('linux');
expect(info.arch).toBe("x64"); expect(info.arch).toBe('x64');
expect(info.defaultShell).toBe("/bin/bash"); expect(info.defaultShell).toBe('/bin/bash');
expect(typeof info.isWSL).toBe("boolean"); expect(typeof info.isWSL).toBe('boolean');
}); });
}); });
describe("createSession", () => { describe('createSession', () => {
it("should create a new terminal session", () => { it('should create a new terminal session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({ const session = service.createSession({
cwd: "/test/dir", cwd: '/test/dir',
cols: 100, cols: 100,
rows: 30, rows: 30,
}); });
expect(session.id).toMatch(/^term-/); expect(session.id).toMatch(/^term-/);
expect(session.cwd).toBe("/test/dir"); expect(session.cwd).toBe('/test/dir');
expect(session.shell).toBe("/bin/bash"); expect(session.shell).toBe('/bin/bash');
expect(pty.spawn).toHaveBeenCalledWith( expect(pty.spawn).toHaveBeenCalledWith(
"/bin/bash", '/bin/bash',
["--login"], ['--login'],
expect.objectContaining({ expect.objectContaining({
cwd: "/test/dir", cwd: '/test/dir',
cols: 100, cols: 100,
rows: 30, rows: 30,
}) })
); );
}); });
it("should use default cols and rows if not provided", () => { it('should use default cols and rows if not provided', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
service.createSession(); service.createSession();
@@ -258,61 +258,61 @@ describe("terminal-service.ts", () => {
); );
}); });
it("should fall back to home directory if cwd does not exist", () => { it('should fall back to home directory if cwd does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockImplementation(() => { vi.mocked(fs.statSync).mockImplementation(() => {
throw new Error("ENOENT"); throw new Error('ENOENT');
}); });
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({ const session = service.createSession({
cwd: "/nonexistent", cwd: '/nonexistent',
}); });
expect(session.cwd).toBe("/home/user"); expect(session.cwd).toBe('/home/user');
}); });
it("should fall back to home directory if cwd is not a directory", () => { it('should fall back to home directory if cwd is not a directory', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({ const session = service.createSession({
cwd: "/file.txt", cwd: '/file.txt',
}); });
expect(session.cwd).toBe("/home/user"); expect(session.cwd).toBe('/home/user');
}); });
it("should fix double slashes in path", () => { it('should fix double slashes in path', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({ const session = service.createSession({
cwd: "//test/dir", cwd: '//test/dir',
}); });
expect(session.cwd).toBe("/test/dir"); expect(session.cwd).toBe('/test/dir');
}); });
it("should preserve WSL UNC paths", () => { it('should preserve WSL UNC paths', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({ const session = service.createSession({
cwd: "//wsl$/Ubuntu/home", cwd: '//wsl$/Ubuntu/home',
}); });
expect(session.cwd).toBe("//wsl$/Ubuntu/home"); expect(session.cwd).toBe('//wsl$/Ubuntu/home');
}); });
it("should handle data events from PTY", () => { it('should handle data events from PTY', () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const dataCallback = vi.fn(); const dataCallback = vi.fn();
service.onData(dataCallback); service.onData(dataCallback);
@@ -321,7 +321,7 @@ describe("terminal-service.ts", () => {
// Simulate data event // Simulate data event
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0]; const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
onDataHandler("test data"); onDataHandler('test data');
// Wait for throttled output // Wait for throttled output
vi.advanceTimersByTime(20); vi.advanceTimersByTime(20);
@@ -331,10 +331,10 @@ describe("terminal-service.ts", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("should handle exit events from PTY", () => { it('should handle exit events from PTY', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const exitCallback = vi.fn(); const exitCallback = vi.fn();
service.onExit(exitCallback); service.onExit(exitCallback);
@@ -350,32 +350,32 @@ describe("terminal-service.ts", () => {
}); });
}); });
describe("write", () => { describe('write', () => {
it("should write data to existing session", () => { it('should write data to existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession(); const session = service.createSession();
const result = service.write(session.id, "ls\n"); const result = service.write(session.id, 'ls\n');
expect(result).toBe(true); expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls\n"); expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
}); });
it("should return false for non-existent session", () => { it('should return false for non-existent session', () => {
const result = service.write("nonexistent", "data"); const result = service.write('nonexistent', 'data');
expect(result).toBe(false); expect(result).toBe(false);
expect(mockPtyProcess.write).not.toHaveBeenCalled(); expect(mockPtyProcess.write).not.toHaveBeenCalled();
}); });
}); });
describe("resize", () => { describe('resize', () => {
it("should resize existing session", () => { it('should resize existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession(); const session = service.createSession();
const result = service.resize(session.id, 120, 40); const result = service.resize(session.id, 120, 40);
@@ -384,19 +384,19 @@ describe("terminal-service.ts", () => {
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
}); });
it("should return false for non-existent session", () => { it('should return false for non-existent session', () => {
const result = service.resize("nonexistent", 120, 40); const result = service.resize('nonexistent', 120, 40);
expect(result).toBe(false); expect(result).toBe(false);
expect(mockPtyProcess.resize).not.toHaveBeenCalled(); expect(mockPtyProcess.resize).not.toHaveBeenCalled();
}); });
it("should handle resize errors", () => { it('should handle resize errors', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.resize.mockImplementation(() => { mockPtyProcess.resize.mockImplementation(() => {
throw new Error("Resize failed"); throw new Error('Resize failed');
}); });
const session = service.createSession(); const session = service.createSession();
@@ -406,40 +406,40 @@ describe("terminal-service.ts", () => {
}); });
}); });
describe("killSession", () => { describe('killSession', () => {
it("should kill existing session", () => { it('should kill existing session', () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession(); const session = service.createSession();
const result = service.killSession(session.id); const result = service.killSession(session.id);
expect(result).toBe(true); expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM"); expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM');
// Session is removed after SIGKILL timeout (1 second) // Session is removed after SIGKILL timeout (1 second)
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGKILL"); expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL');
expect(service.getSession(session.id)).toBeUndefined(); expect(service.getSession(session.id)).toBeUndefined();
vi.useRealTimers(); vi.useRealTimers();
}); });
it("should return false for non-existent session", () => { it('should return false for non-existent session', () => {
const result = service.killSession("nonexistent"); const result = service.killSession('nonexistent');
expect(result).toBe(false); expect(result).toBe(false);
}); });
it("should handle kill errors", () => { it('should handle kill errors', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.kill.mockImplementation(() => { mockPtyProcess.kill.mockImplementation(() => {
throw new Error("Kill failed"); throw new Error('Kill failed');
}); });
const session = service.createSession(); const session = service.createSession();
@@ -449,11 +449,11 @@ describe("terminal-service.ts", () => {
}); });
}); });
describe("getSession", () => { describe('getSession', () => {
it("should return existing session", () => { it('should return existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession(); const session = service.createSession();
const retrieved = service.getSession(session.id); const retrieved = service.getSession(session.id);
@@ -461,84 +461,84 @@ describe("terminal-service.ts", () => {
expect(retrieved).toBe(session); expect(retrieved).toBe(session);
}); });
it("should return undefined for non-existent session", () => { it('should return undefined for non-existent session', () => {
const retrieved = service.getSession("nonexistent"); const retrieved = service.getSession('nonexistent');
expect(retrieved).toBeUndefined(); expect(retrieved).toBeUndefined();
}); });
}); });
describe("getScrollback", () => { describe('getScrollback', () => {
it("should return scrollback buffer for existing session", () => { it('should return scrollback buffer for existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession(); const session = service.createSession();
session.scrollbackBuffer = "test scrollback"; session.scrollbackBuffer = 'test scrollback';
const scrollback = service.getScrollback(session.id); const scrollback = service.getScrollback(session.id);
expect(scrollback).toBe("test scrollback"); expect(scrollback).toBe('test scrollback');
}); });
it("should return null for non-existent session", () => { it('should return null for non-existent session', () => {
const scrollback = service.getScrollback("nonexistent"); const scrollback = service.getScrollback('nonexistent');
expect(scrollback).toBeNull(); expect(scrollback).toBeNull();
}); });
}); });
describe("getAllSessions", () => { describe('getAllSessions', () => {
it("should return all active sessions", () => { it('should return all active sessions', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session1 = service.createSession({ cwd: "/dir1" }); const session1 = service.createSession({ cwd: '/dir1' });
const session2 = service.createSession({ cwd: "/dir2" }); const session2 = service.createSession({ cwd: '/dir2' });
const sessions = service.getAllSessions(); const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2); expect(sessions).toHaveLength(2);
expect(sessions[0].id).toBe(session1.id); expect(sessions[0].id).toBe(session1.id);
expect(sessions[1].id).toBe(session2.id); expect(sessions[1].id).toBe(session2.id);
expect(sessions[0].cwd).toBe("/dir1"); expect(sessions[0].cwd).toBe('/dir1');
expect(sessions[1].cwd).toBe("/dir2"); expect(sessions[1].cwd).toBe('/dir2');
}); });
it("should return empty array if no sessions", () => { it('should return empty array if no sessions', () => {
const sessions = service.getAllSessions(); const sessions = service.getAllSessions();
expect(sessions).toEqual([]); expect(sessions).toEqual([]);
}); });
}); });
describe("onData and onExit", () => { describe('onData and onExit', () => {
it("should allow subscribing and unsubscribing from data events", () => { it('should allow subscribing and unsubscribing from data events', () => {
const callback = vi.fn(); const callback = vi.fn();
const unsubscribe = service.onData(callback); const unsubscribe = service.onData(callback);
expect(typeof unsubscribe).toBe("function"); expect(typeof unsubscribe).toBe('function');
unsubscribe(); unsubscribe();
}); });
it("should allow subscribing and unsubscribing from exit events", () => { it('should allow subscribing and unsubscribing from exit events', () => {
const callback = vi.fn(); const callback = vi.fn();
const unsubscribe = service.onExit(callback); const unsubscribe = service.onExit(callback);
expect(typeof unsubscribe).toBe("function"); expect(typeof unsubscribe).toBe('function');
unsubscribe(); unsubscribe();
}); });
}); });
describe("cleanup", () => { describe('cleanup', () => {
it("should clean up all sessions", () => { it('should clean up all sessions', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session1 = service.createSession(); const session1 = service.createSession();
const session2 = service.createSession(); const session2 = service.createSession();
@@ -550,12 +550,12 @@ describe("terminal-service.ts", () => {
expect(service.getAllSessions()).toHaveLength(0); expect(service.getAllSessions()).toHaveLength(0);
}); });
it("should handle cleanup errors gracefully", () => { it('should handle cleanup errors gracefully', () => {
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.kill.mockImplementation(() => { mockPtyProcess.kill.mockImplementation(() => {
throw new Error("Kill failed"); throw new Error('Kill failed');
}); });
service.createSession(); service.createSession();
@@ -564,8 +564,8 @@ describe("terminal-service.ts", () => {
}); });
}); });
describe("getTerminalService", () => { describe('getTerminalService', () => {
it("should return singleton instance", () => { it('should return singleton instance', () => {
const instance1 = getTerminalService(); const instance1 = getTerminalService();
const instance2 = getTerminalService(); const instance2 = getTerminalService();

View File

@@ -24,7 +24,7 @@ export async function waitFor(
const start = Date.now(); const start = Date.now();
while (!condition()) { while (!condition()) {
if (Date.now() - start > timeout) { if (Date.now() - start > timeout) {
throw new Error("Timeout waiting for condition"); throw new Error('Timeout waiting for condition');
} }
await new Promise((resolve) => setTimeout(resolve, interval)); await new Promise((resolve) => setTimeout(resolve, interval));
} }

View File

@@ -3,10 +3,10 @@
* Provides reusable mocks for common dependencies * Provides reusable mocks for common dependencies
*/ */
import { vi } from "vitest"; import { vi } from 'vitest';
import type { ChildProcess } from "child_process"; import type { ChildProcess } from 'child_process';
import { EventEmitter } from "events"; import { EventEmitter } from 'events';
import type { Readable } from "stream"; import type { Readable } from 'stream';
/** /**
* Mock child_process.spawn for subprocess tests * Mock child_process.spawn for subprocess tests
@@ -31,19 +31,19 @@ export function createMockChildProcess(options: {
process.nextTick(() => { process.nextTick(() => {
// Emit stdout lines // Emit stdout lines
for (const line of stdout) { for (const line of stdout) {
mockProcess.stdout.emit("data", Buffer.from(line + "\n")); mockProcess.stdout.emit('data', Buffer.from(line + '\n'));
} }
// Emit stderr lines // Emit stderr lines
for (const line of stderr) { for (const line of stderr) {
mockProcess.stderr.emit("data", Buffer.from(line + "\n")); mockProcess.stderr.emit('data', Buffer.from(line + '\n'));
} }
// Emit exit or error // Emit exit or error
if (shouldError) { if (shouldError) {
mockProcess.emit("error", new Error("Process error")); mockProcess.emit('error', new Error('Process error'));
} else { } else {
mockProcess.emit("exit", exitCode); mockProcess.emit('exit', exitCode);
} }
}); });

View File

@@ -90,9 +90,9 @@ const {
clearHistory, // Clear conversation clearHistory, // Clear conversation
error, // Error state error, // Error state
} = useElectronAgent({ } = useElectronAgent({
sessionId: "project_xyz", sessionId: 'project_xyz',
workingDirectory: "/path/to/project", workingDirectory: '/path/to/project',
onToolUse: (tool) => console.log("Using:", tool), onToolUse: (tool) => console.log('Using:', tool),
}); });
``` ```
@@ -160,7 +160,7 @@ Each session file contains:
Session IDs are generated from project paths: Session IDs are generated from project paths:
```typescript ```typescript
const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, "_")}`; const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}`;
``` ```
This ensures: This ensures:

View File

@@ -7,24 +7,28 @@ The Automaker Agent Chat now supports multiple concurrent sessions, allowing you
## Features ## Features
### ✨ Multiple Sessions ### ✨ Multiple Sessions
- Create unlimited agent sessions per project - Create unlimited agent sessions per project
- Each session has its own conversation history - Each session has its own conversation history
- Switch between sessions instantly - Switch between sessions instantly
- Sessions persist across app restarts - Sessions persist across app restarts
### 📋 Session Organization ### 📋 Session Organization
- Custom names for easy identification - Custom names for easy identification
- Last message preview - Last message preview
- Message count tracking - Message count tracking
- Sort by most recently updated - Sort by most recently updated
### 🗄️ Archive & Delete ### 🗄️ Archive & Delete
- Archive old sessions to declutter - Archive old sessions to declutter
- Unarchive when needed - Unarchive when needed
- Permanently delete sessions - Permanently delete sessions
- Confirm before destructive actions - Confirm before destructive actions
### 💾 Automatic Persistence ### 💾 Automatic Persistence
- All sessions auto-save to disk - All sessions auto-save to disk
- Survive Next.js restarts - Survive Next.js restarts
- Survive Electron app restarts - Survive Electron app restarts
@@ -67,6 +71,7 @@ Click the panel icon in the header to show/hide the session manager.
4. The new session is immediately active 4. The new session is immediately active
**Example session names:** **Example session names:**
- "Feature: Dark Mode" - "Feature: Dark Mode"
- "Bug: Login redirect" - "Bug: Login redirect"
- "Refactor: API layer" - "Refactor: API layer"
@@ -93,6 +98,7 @@ Click the **"Clear"** button in the chat header to delete all messages from the
3. Toggle **"Show Archived"** to view archived sessions 3. Toggle **"Show Archived"** to view archived sessions
**When to archive:** **When to archive:**
- Completed features - Completed features
- Resolved bugs - Resolved bugs
- Old experiments - Old experiments
@@ -117,16 +123,19 @@ Click the **"Clear"** button in the chat header to delete all messages from the
Sessions are stored in your user data directory: Sessions are stored in your user data directory:
**macOS:** **macOS:**
``` ```
~/Library/Application Support/automaker/agent-sessions/ ~/Library/Application Support/automaker/agent-sessions/
``` ```
**Windows:** **Windows:**
``` ```
%APPDATA%/automaker/agent-sessions/ %APPDATA%/automaker/agent-sessions/
``` ```
**Linux:** **Linux:**
``` ```
~/.config/automaker/agent-sessions/ ~/.config/automaker/agent-sessions/
``` ```
@@ -215,12 +224,14 @@ Use prefixes to organize sessions by type:
### When to Create Multiple Sessions ### When to Create Multiple Sessions
**Do create separate sessions for:** **Do create separate sessions for:**
- ✅ Different features - ✅ Different features
- ✅ Unrelated bugs - ✅ Unrelated bugs
- ✅ Experimental work - ✅ Experimental work
- ✅ Different contexts or approaches - ✅ Different contexts or approaches
**Don't create separate sessions for:** **Don't create separate sessions for:**
- ❌ Same feature, different iterations - ❌ Same feature, different iterations
- ❌ Related bug fixes - ❌ Related bug fixes
- ❌ Continuation of previous work - ❌ Continuation of previous work
@@ -272,7 +283,7 @@ Use prefixes to organize sessions by type:
## Keyboard Shortcuts ## Keyboard Shortcuts
*(Coming soon)* _(Coming soon)_
- `Cmd/Ctrl + K` - Create new session - `Cmd/Ctrl + K` - Create new session
- `Cmd/Ctrl + [` - Previous session - `Cmd/Ctrl + [` - Previous session
@@ -284,11 +295,13 @@ Use prefixes to organize sessions by type:
### Session Not Saving ### Session Not Saving
**Check:** **Check:**
- Electron has write permissions - Electron has write permissions
- Disk space available - Disk space available
- Check Electron console for errors - Check Electron console for errors
**Solution:** **Solution:**
```bash ```bash
# macOS - Check permissions # macOS - Check permissions
ls -la ~/Library/Application\ Support/automaker/ ls -la ~/Library/Application\ Support/automaker/
@@ -300,11 +313,13 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
### Can't Switch Sessions ### Can't Switch Sessions
**Check:** **Check:**
- Session is not archived - Session is not archived
- No errors in console - No errors in console
- Agent is not currently processing - Agent is not currently processing
**Solution:** **Solution:**
- Wait for current message to complete - Wait for current message to complete
- Check for error messages - Check for error messages
- Try clearing and reloading - Try clearing and reloading
@@ -312,11 +327,13 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
### Session Disappeared ### Session Disappeared
**Check:** **Check:**
- Not filtered by archive status - Not filtered by archive status
- Not accidentally deleted - Not accidentally deleted
- Check backup files - Check backup files
**Recovery:** **Recovery:**
- Toggle "Show Archived" - Toggle "Show Archived"
- Check filesystem for `.json` files - Check filesystem for `.json` files
- Restore from backup if available - Restore from backup if available
@@ -326,15 +343,17 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
For developers integrating session management: For developers integrating session management:
### Create Session ### Create Session
```typescript ```typescript
const result = await window.electronAPI.sessions.create( const result = await window.electronAPI.sessions.create(
"Session Name", 'Session Name',
"/project/path", '/project/path',
"/working/directory" '/working/directory'
); );
``` ```
### List Sessions ### List Sessions
```typescript ```typescript
const { sessions } = await window.electronAPI.sessions.list( const { sessions } = await window.electronAPI.sessions.list(
false // includeArchived false // includeArchived
@@ -342,21 +361,20 @@ const { sessions } = await window.electronAPI.sessions.list(
``` ```
### Update Session ### Update Session
```typescript ```typescript
await window.electronAPI.sessions.update( await window.electronAPI.sessions.update(sessionId, 'New Name', ['tag1', 'tag2']);
sessionId,
"New Name",
["tag1", "tag2"]
);
``` ```
### Archive/Unarchive ### Archive/Unarchive
```typescript ```typescript
await window.electronAPI.sessions.archive(sessionId); await window.electronAPI.sessions.archive(sessionId);
await window.electronAPI.sessions.unarchive(sessionId); await window.electronAPI.sessions.unarchive(sessionId);
``` ```
### Delete Session ### Delete Session
```typescript ```typescript
await window.electronAPI.sessions.delete(sessionId); await window.electronAPI.sessions.delete(sessionId);
``` ```

View File

@@ -1,111 +1,111 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from 'eslint/config';
import js from "@eslint/js"; import js from '@eslint/js';
import ts from "@typescript-eslint/eslint-plugin"; import ts from '@typescript-eslint/eslint-plugin';
import tsParser from "@typescript-eslint/parser"; import tsParser from '@typescript-eslint/parser';
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
js.configs.recommended, js.configs.recommended,
{ {
files: ["**/*.mjs", "**/*.cjs"], files: ['**/*.mjs', '**/*.cjs'],
languageOptions: { languageOptions: {
globals: { globals: {
console: "readonly", console: 'readonly',
process: "readonly", process: 'readonly',
require: "readonly", require: 'readonly',
__dirname: "readonly", __dirname: 'readonly',
__filename: "readonly", __filename: 'readonly',
}, },
}, },
}, },
{ {
files: ["**/*.ts", "**/*.tsx"], files: ['**/*.ts', '**/*.tsx'],
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
parserOptions: { parserOptions: {
ecmaVersion: "latest", ecmaVersion: 'latest',
sourceType: "module", sourceType: 'module',
}, },
globals: { globals: {
// Browser/DOM APIs // Browser/DOM APIs
window: "readonly", window: 'readonly',
document: "readonly", document: 'readonly',
navigator: "readonly", navigator: 'readonly',
Navigator: "readonly", Navigator: 'readonly',
localStorage: "readonly", localStorage: 'readonly',
sessionStorage: "readonly", sessionStorage: 'readonly',
fetch: "readonly", fetch: 'readonly',
WebSocket: "readonly", WebSocket: 'readonly',
File: "readonly", File: 'readonly',
FileList: "readonly", FileList: 'readonly',
FileReader: "readonly", FileReader: 'readonly',
Blob: "readonly", Blob: 'readonly',
atob: "readonly", atob: 'readonly',
crypto: "readonly", crypto: 'readonly',
prompt: "readonly", prompt: 'readonly',
confirm: "readonly", confirm: 'readonly',
getComputedStyle: "readonly", getComputedStyle: 'readonly',
requestAnimationFrame: "readonly", requestAnimationFrame: 'readonly',
// DOM Element Types // DOM Element Types
HTMLElement: "readonly", HTMLElement: 'readonly',
HTMLInputElement: "readonly", HTMLInputElement: 'readonly',
HTMLDivElement: "readonly", HTMLDivElement: 'readonly',
HTMLButtonElement: "readonly", HTMLButtonElement: 'readonly',
HTMLSpanElement: "readonly", HTMLSpanElement: 'readonly',
HTMLTextAreaElement: "readonly", HTMLTextAreaElement: 'readonly',
HTMLHeadingElement: "readonly", HTMLHeadingElement: 'readonly',
HTMLParagraphElement: "readonly", HTMLParagraphElement: 'readonly',
HTMLImageElement: "readonly", HTMLImageElement: 'readonly',
Element: "readonly", Element: 'readonly',
// Event Types // Event Types
Event: "readonly", Event: 'readonly',
KeyboardEvent: "readonly", KeyboardEvent: 'readonly',
DragEvent: "readonly", DragEvent: 'readonly',
PointerEvent: "readonly", PointerEvent: 'readonly',
CustomEvent: "readonly", CustomEvent: 'readonly',
ClipboardEvent: "readonly", ClipboardEvent: 'readonly',
WheelEvent: "readonly", WheelEvent: 'readonly',
DataTransfer: "readonly", DataTransfer: 'readonly',
// Web APIs // Web APIs
ResizeObserver: "readonly", ResizeObserver: 'readonly',
AbortSignal: "readonly", AbortSignal: 'readonly',
Audio: "readonly", Audio: 'readonly',
ScrollBehavior: "readonly", ScrollBehavior: 'readonly',
// Timers // Timers
setTimeout: "readonly", setTimeout: 'readonly',
setInterval: "readonly", setInterval: 'readonly',
clearTimeout: "readonly", clearTimeout: 'readonly',
clearInterval: "readonly", clearInterval: 'readonly',
// Node.js (for scripts and Electron) // Node.js (for scripts and Electron)
process: "readonly", process: 'readonly',
require: "readonly", require: 'readonly',
__dirname: "readonly", __dirname: 'readonly',
__filename: "readonly", __filename: 'readonly',
NodeJS: "readonly", NodeJS: 'readonly',
// React // React
React: "readonly", React: 'readonly',
JSX: "readonly", JSX: 'readonly',
// Electron // Electron
Electron: "readonly", Electron: 'readonly',
// Console // Console
console: "readonly", console: 'readonly',
}, },
}, },
plugins: { plugins: {
"@typescript-eslint": ts, '@typescript-eslint': ts,
}, },
rules: { rules: {
...ts.configs.recommended.rules, ...ts.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
"@typescript-eslint/no-explicit-any": "warn", '@typescript-eslint/no-explicit-any': 'warn',
}, },
}, },
globalIgnores([ globalIgnores([
"dist/**", 'dist/**',
"dist-electron/**", 'dist-electron/**',
"node_modules/**", 'node_modules/**',
"server-bundle/**", 'server-bundle/**',
"release/**", 'release/**',
"src/routeTree.gen.ts", 'src/routeTree.gen.ts',
]), ]),
]); ]);

View File

@@ -8,7 +8,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script> <script>
// Prevent theme flash - apply stored theme before React hydrates // Prevent theme flash - apply stored theme before React hydrates
(function() { (function () {
try { try {
const stored = localStorage.getItem('automaker-storage'); const stored = localStorage.getItem('automaker-storage');
if (stored) { if (stored) {
@@ -17,7 +17,10 @@
if (theme && theme !== 'system' && theme !== 'light') { if (theme && theme !== 'system' && theme !== 'light') {
// Apply the actual theme class (dark, retro, dracula, nord, etc.) // Apply the actual theme class (dark, retro, dracula, nord, etc.)
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme);
} else if (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) { } else if (
theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
} }

View File

@@ -27,7 +27,7 @@ const LOCAL_PACKAGES = [
'@automaker/platform', '@automaker/platform',
'@automaker/model-resolver', '@automaker/model-resolver',
'@automaker/dependency-resolver', '@automaker/dependency-resolver',
'@automaker/git-utils' '@automaker/git-utils',
]; ];
console.log('🔧 Preparing server for Electron bundling...\n'); console.log('🔧 Preparing server for Electron bundling...\n');
@@ -95,13 +95,10 @@ const bundlePkg = {
version: serverPkg.version, version: serverPkg.version,
type: 'module', type: 'module',
main: 'dist/index.js', main: 'dist/index.js',
dependencies dependencies,
}; };
writeFileSync( writeFileSync(join(BUNDLE_DIR, 'package.json'), JSON.stringify(bundlePkg, null, 2));
join(BUNDLE_DIR, 'package.json'),
JSON.stringify(bundlePkg, null, 2)
);
// Step 6: Install production dependencies // Step 6: Install production dependencies
console.log('📥 Installing server production dependencies...'); console.log('📥 Installing server production dependencies...');
@@ -111,8 +108,8 @@ execSync('npm install --omit=dev', {
env: { env: {
...process.env, ...process.env,
// Prevent npm from using workspace resolution // Prevent npm from using workspace resolution
npm_config_workspace: '' npm_config_workspace: '',
} },
}); });
// Step 7: Rebuild native modules for current architecture // Step 7: Rebuild native modules for current architecture
@@ -121,11 +118,13 @@ console.log('🔨 Rebuilding native modules for current architecture...');
try { try {
execSync('npm rebuild', { execSync('npm rebuild', {
cwd: BUNDLE_DIR, cwd: BUNDLE_DIR,
stdio: 'inherit' stdio: 'inherit',
}); });
console.log('✅ Native modules rebuilt successfully'); console.log('✅ Native modules rebuilt successfully');
} catch (error) { } catch (error) {
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.'); console.warn(
'⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.'
);
console.warn(' Error:', error.message); console.warn(' Error:', error.message);
} }

View File

@@ -11,7 +11,7 @@ const path = require('path');
const execAsync = promisify(exec); const execAsync = promisify(exec);
exports.default = async function(context) { exports.default = async function (context) {
const { appOutDir, electronPlatformName, arch, packager } = context; const { appOutDir, electronPlatformName, arch, packager } = context;
const electronVersion = packager.config.electronVersion; const electronVersion = packager.config.electronVersion;
@@ -33,19 +33,9 @@ exports.default = async function(context) {
'node_modules' 'node_modules'
); );
} else if (electronPlatformName === 'win32') { } else if (electronPlatformName === 'win32') {
serverNodeModulesPath = path.join( serverNodeModulesPath = path.join(appOutDir, 'resources', 'server', 'node_modules');
appOutDir,
'resources',
'server',
'node_modules'
);
} else { } else {
serverNodeModulesPath = path.join( serverNodeModulesPath = path.join(appOutDir, 'resources', 'server', 'node_modules');
appOutDir,
'resources',
'server',
'node_modules'
);
} }
try { try {

View File

@@ -5,17 +5,17 @@
* Creates the necessary test fixture directories and files before running Playwright tests * Creates the necessary test fixture directories and files before running Playwright tests
*/ */
import * as fs from "fs"; import * as fs from 'fs';
import * as path from "path"; import * as path from 'path';
import { fileURLToPath } from "url"; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Resolve workspace root (apps/ui/scripts -> workspace root) // Resolve workspace root (apps/ui/scripts -> workspace root)
const WORKSPACE_ROOT = path.resolve(__dirname, "../../.."); const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA"); const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt"); const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
const SPEC_CONTENT = `<app_spec> const SPEC_CONTENT = `<app_spec>
<name>Test Project A</name> <name>Test Project A</name>
@@ -28,7 +28,7 @@ const SPEC_CONTENT = `<app_spec>
`; `;
function setupFixtures() { function setupFixtures() {
console.log("Setting up E2E test fixtures..."); console.log('Setting up E2E test fixtures...');
console.log(`Workspace root: ${WORKSPACE_ROOT}`); console.log(`Workspace root: ${WORKSPACE_ROOT}`);
console.log(`Fixture path: ${FIXTURE_PATH}`); console.log(`Fixture path: ${FIXTURE_PATH}`);
@@ -43,7 +43,7 @@ function setupFixtures() {
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT); fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`); console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
console.log("E2E test fixtures setup complete!"); console.log('E2E test fixtures setup complete!');
} }
setupFixtures(); setupFixtures();

View File

@@ -1,9 +1,8 @@
import * as React from 'react';
import { Check, ChevronsUpDown, LucideIcon } from 'lucide-react';
import * as React from "react"; import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react"; import { Button } from '@/components/ui/button';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -11,12 +10,8 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from '@/components/ui/command';
import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface AutocompleteOption { export interface AutocompleteOption {
value: string; value: string;
@@ -38,12 +33,12 @@ interface AutocompleteProps {
icon?: LucideIcon; icon?: LucideIcon;
allowCreate?: boolean; allowCreate?: boolean;
createLabel?: (value: string) => string; createLabel?: (value: string) => string;
"data-testid"?: string; 'data-testid'?: string;
itemTestIdPrefix?: string; itemTestIdPrefix?: string;
} }
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption { function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
if (typeof opt === "string") { if (typeof opt === 'string') {
return { value: opt, label: opt }; return { value: opt, label: opt };
} }
return { ...opt, label: opt.label ?? opt.value }; return { ...opt, label: opt.label ?? opt.value };
@@ -53,27 +48,24 @@ export function Autocomplete({
value, value,
onChange, onChange,
options, options,
placeholder = "Select an option...", placeholder = 'Select an option...',
searchPlaceholder = "Search...", searchPlaceholder = 'Search...',
emptyMessage = "No results found.", emptyMessage = 'No results found.',
className, className,
disabled = false, disabled = false,
error = false, error = false,
icon: Icon, icon: Icon,
allowCreate = false, allowCreate = false,
createLabel = (v) => `Create "${v}"`, createLabel = (v) => `Create "${v}"`,
"data-testid": testId, 'data-testid': testId,
itemTestIdPrefix = "option", itemTestIdPrefix = 'option',
}: AutocompleteProps) { }: AutocompleteProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState(""); const [inputValue, setInputValue] = React.useState('');
const [triggerWidth, setTriggerWidth] = React.useState<number>(0); const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null);
const normalizedOptions = React.useMemo( const normalizedOptions = React.useMemo(() => options.map(normalizeOption), [options]);
() => options.map(normalizeOption),
[options]
);
// Update trigger width when component mounts or value changes // Update trigger width when component mounts or value changes
React.useEffect(() => { React.useEffect(() => {
@@ -98,9 +90,7 @@ export function Autocomplete({
if (!inputValue) return normalizedOptions; if (!inputValue) return normalizedOptions;
const lower = inputValue.toLowerCase(); const lower = inputValue.toLowerCase();
return normalizedOptions.filter( return normalizedOptions.filter(
(opt) => (opt) => opt.value.toLowerCase().includes(lower) || opt.label?.toLowerCase().includes(lower)
opt.value.toLowerCase().includes(lower) ||
opt.label?.toLowerCase().includes(lower)
); );
}, [normalizedOptions, inputValue]); }, [normalizedOptions, inputValue]);
@@ -108,9 +98,7 @@ export function Autocomplete({
const isNewValue = const isNewValue =
allowCreate && allowCreate &&
inputValue.trim() && inputValue.trim() &&
!normalizedOptions.some( !normalizedOptions.some((opt) => opt.value.toLowerCase() === inputValue.toLowerCase());
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
);
// Get display value // Get display value
const displayValue = React.useMemo(() => { const displayValue = React.useMemo(() => {
@@ -129,17 +117,15 @@ export function Autocomplete({
aria-expanded={open} aria-expanded={open}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"w-full justify-between", 'w-full justify-between',
Icon && "font-mono text-sm", Icon && 'font-mono text-sm',
error && "border-destructive focus-visible:ring-destructive", error && 'border-destructive focus-visible:ring-destructive',
className className
)} )}
data-testid={testId} data-testid={testId}
> >
<span className="flex items-center gap-2 truncate"> <span className="flex items-center gap-2 truncate">
{Icon && ( {Icon && <Icon className="w-4 h-4 shrink-0 text-muted-foreground" />}
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{displayValue || placeholder} {displayValue || placeholder}
</span> </span>
<ChevronsUpDown className="opacity-50 shrink-0" /> <ChevronsUpDown className="opacity-50 shrink-0" />
@@ -163,8 +149,7 @@ export function Autocomplete({
<CommandEmpty> <CommandEmpty>
{isNewValue ? ( {isNewValue ? (
<div className="py-2 px-3 text-sm"> <div className="py-2 px-3 text-sm">
Press enter to create{" "} Press enter to create <code className="bg-muted px-1 rounded">{inputValue}</code>
<code className="bg-muted px-1 rounded">{inputValue}</code>
</div> </div>
) : ( ) : (
emptyMessage emptyMessage
@@ -177,7 +162,7 @@ export function Autocomplete({
value={inputValue} value={inputValue}
onSelect={() => { onSelect={() => {
onChange(inputValue); onChange(inputValue);
setInputValue(""); setInputValue('');
setOpen(false); setOpen(false);
}} }}
className="text-[var(--status-success)]" className="text-[var(--status-success)]"
@@ -185,9 +170,7 @@ export function Autocomplete({
> >
{Icon && <Icon className="w-4 h-4 mr-2" />} {Icon && <Icon className="w-4 h-4 mr-2" />}
{createLabel(inputValue)} {createLabel(inputValue)}
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">(new)</span>
(new)
</span>
</CommandItem> </CommandItem>
)} )}
{filteredOptions.map((option) => ( {filteredOptions.map((option) => (
@@ -195,24 +178,19 @@ export function Autocomplete({
key={option.value} key={option.value}
value={option.value} value={option.value}
onSelect={(currentValue) => { onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue); onChange(currentValue === value ? '' : currentValue);
setInputValue(""); setInputValue('');
setOpen(false); setOpen(false);
}} }}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`} data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, '-')}`}
> >
{Icon && <Icon className="w-4 h-4 mr-2" />} {Icon && <Icon className="w-4 h-4 mr-2" />}
{option.label} {option.label}
<Check <Check
className={cn( className={cn('ml-auto', value === option.value ? 'opacity-100' : 'opacity-0')}
"ml-auto",
value === option.value ? "opacity-100" : "opacity-0"
)}
/> />
{option.badge && ( {option.badge && (
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-xs text-muted-foreground">({option.badge})</span>
({option.badge})
</span>
)} )}
</CommandItem> </CommandItem>
))} ))}

Some files were not shown because too many files have changed in this diff Show More