mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
13 Commits
coderabbit
...
feature/mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1e855cc5 | ||
|
|
3c719f05a1 | ||
|
|
9cba2e509a | ||
|
|
c61eaff525 | ||
|
|
ef0a96182a | ||
|
|
a680f3a9c1 | ||
|
|
ea6a39c6ab | ||
|
|
f0c2860dec | ||
|
|
85dfabec0a | ||
|
|
15dca79fb7 | ||
|
|
e9b366fa18 | ||
|
|
145dcf4b97 | ||
|
|
5f328a4c13 |
172
CLAUDE.md
Normal file
172
CLAUDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Interactive launcher (choose web or electron)
|
||||
npm run dev:web # Web browser mode (localhost:3007)
|
||||
npm run dev:electron # Desktop app mode
|
||||
npm run dev:electron:debug # Desktop with DevTools open
|
||||
|
||||
# Building
|
||||
npm run build # Build web application
|
||||
npm run build:packages # Build all shared packages (required before other builds)
|
||||
npm run build:electron # Build desktop app for current platform
|
||||
npm run build:server # Build server only
|
||||
|
||||
# Testing
|
||||
npm run test # E2E tests (Playwright, headless)
|
||||
npm run test:headed # E2E tests with browser visible
|
||||
npm run test:server # Server unit tests (Vitest)
|
||||
npm run test:packages # All shared package tests
|
||||
npm run test:all # All tests (packages + server)
|
||||
|
||||
# Single test file
|
||||
npm run test:server -- tests/unit/specific.test.ts
|
||||
|
||||
# Linting and formatting
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier write
|
||||
npm run format:check # Prettier check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend (port 3007)
|
||||
│ └── server/ # Express + WebSocket backend (port 3008)
|
||||
└── libs/ # Shared packages (@automaker/*)
|
||||
├── types/ # Core TypeScript definitions (no dependencies)
|
||||
├── utils/ # Logging, errors, image processing, context loading
|
||||
├── prompts/ # AI prompt templates
|
||||
├── platform/ # Path management, security, process spawning
|
||||
├── model-resolver/ # Claude model alias resolution
|
||||
├── dependency-resolver/ # Feature dependency ordering
|
||||
└── git-utils/ # Git operations & worktree management
|
||||
```
|
||||
|
||||
### Package Dependency Chain
|
||||
|
||||
Packages can only depend on packages above them:
|
||||
|
||||
```
|
||||
@automaker/types (no dependencies)
|
||||
↓
|
||||
@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
|
||||
↓
|
||||
@automaker/git-utils
|
||||
↓
|
||||
@automaker/server, @automaker/ui
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
|
||||
- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
|
||||
- **Testing**: Playwright (E2E), Vitest (unit)
|
||||
|
||||
### Server Architecture
|
||||
|
||||
The server (`apps/server/src/`) follows a modular pattern:
|
||||
|
||||
- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
|
||||
- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
|
||||
- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
|
||||
- `lib/` - Utilities (events, auth, worktree metadata)
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
The UI (`apps/ui/src/`) uses:
|
||||
|
||||
- `routes/` - TanStack Router file-based routing
|
||||
- `components/views/` - Main view components (board, settings, terminal, etc.)
|
||||
- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
|
||||
- `hooks/` - Custom React hooks
|
||||
- `lib/` - Utilities and API client
|
||||
|
||||
## Data Storage
|
||||
|
||||
### Per-Project Data (`.automaker/`)
|
||||
|
||||
```
|
||||
.automaker/
|
||||
├── features/ # Feature JSON files and images
|
||||
│ └── {featureId}/
|
||||
│ ├── feature.json
|
||||
│ ├── agent-output.md
|
||||
│ └── images/
|
||||
├── context/ # Context files for AI agents (CLAUDE.md, etc.)
|
||||
├── settings.json # Project-specific settings
|
||||
├── spec.md # Project specification
|
||||
└── analysis.json # Project structure analysis
|
||||
```
|
||||
|
||||
### Global Data (`DATA_DIR`, default `./data`)
|
||||
|
||||
```
|
||||
data/
|
||||
├── settings.json # Global settings, profiles, shortcuts
|
||||
├── credentials.json # API keys
|
||||
├── sessions-metadata.json # Chat session metadata
|
||||
└── agent-sessions/ # Conversation histories
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Always import from shared packages, never from old paths:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import type { Feature, ExecuteOptions } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
import { getEnhancementPrompt } from '@automaker/prompts';
|
||||
import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { resolveDependencies } from '@automaker/dependency-resolver';
|
||||
import { getGitRepositoryDiffs } from '@automaker/git-utils';
|
||||
|
||||
// ❌ Never import from old paths
|
||||
import { Feature } from '../services/feature-loader'; // Wrong
|
||||
import { createLogger } from '../lib/logger'; // Wrong
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
|
||||
|
||||
### Git Worktree Isolation
|
||||
|
||||
Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
|
||||
|
||||
### Context Files
|
||||
|
||||
Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
|
||||
|
||||
### Model Resolution
|
||||
|
||||
Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
@@ -28,6 +28,7 @@
|
||||
"@automaker/prompts": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
|
||||
@@ -50,6 +50,10 @@ import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||
import { createMCPRoutes } from './routes/mcp/index.js';
|
||||
import { MCPTestService } from './services/mcp-test-service.js';
|
||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -101,9 +105,13 @@ if (ENABLE_REQUEST_LOGGING) {
|
||||
})
|
||||
);
|
||||
}
|
||||
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
|
||||
// from malicious websites. MCP server endpoints can execute arbitrary commands,
|
||||
// so allowing any origin would enable RCE from any website visited while Automaker runs.
|
||||
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
@@ -119,6 +127,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -162,6 +171,8 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
@@ -136,6 +136,53 @@ function getBaseOptions(): Partial<Options> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP permission options result
|
||||
*/
|
||||
interface McpPermissionOptions {
|
||||
/** Whether tools should be restricted to a preset */
|
||||
shouldRestrictTools: boolean;
|
||||
/** Options to spread when MCP bypass is enabled */
|
||||
bypassOptions: Partial<Options>;
|
||||
/** Options to spread for MCP servers */
|
||||
mcpServerOptions: Partial<Options>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build MCP-related options based on configuration.
|
||||
* Centralizes the logic for determining permission modes and tool restrictions
|
||||
* when MCP servers are configured.
|
||||
*
|
||||
* @param config - The SDK options config
|
||||
* @returns Object with MCP permission settings to spread into final options
|
||||
*/
|
||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
|
||||
|
||||
// Determine if we should bypass permissions based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
return {
|
||||
shouldRestrictTools,
|
||||
// Only include bypass options when MCP is configured and auto-approve is enabled
|
||||
bypassOptions: shouldBypassPermissions
|
||||
? {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
// Required flag when using bypassPermissions mode
|
||||
allowDangerouslySkipPermissions: true,
|
||||
}
|
||||
: {},
|
||||
// Include MCP servers if configured
|
||||
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||
* When autoLoadClaudeMd is true:
|
||||
@@ -219,8 +266,25 @@ export interface CreateSdkOptionsConfig {
|
||||
|
||||
/** Enable sandbox mode for bash command isolation */
|
||||
enableSandboxMode?: boolean;
|
||||
|
||||
/** MCP servers to make available to the agent */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
|
||||
/** Auto-approve MCP tool calls without permission prompts */
|
||||
mcpAutoApproveTools?: boolean;
|
||||
|
||||
/** Allow unrestricted tools when MCP servers are enabled */
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
}
|
||||
|
||||
// Re-export MCP types from @automaker/types for convenience
|
||||
export type {
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create SDK options for spec generation
|
||||
*
|
||||
@@ -330,12 +394,18 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -344,6 +414,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,12 +435,18 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -378,6 +455,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,14 +478,27 @@ export function createCustomOptions(
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||
const effectiveAllowedTools = config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
: mcpOptions.shouldRestrictTools
|
||||
? [...TOOL_PRESETS.readOnly]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('default', config.model),
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
||||
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||
@@ -136,3 +137,121 @@ function formatContextFileEntry(file: ContextFileInfo): string {
|
||||
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
|
||||
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled MCP servers from global settings, converted to SDK format.
|
||||
* Returns an empty object if settings service is not available or no servers are configured.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP servers in SDK format (keyed by name)
|
||||
*/
|
||||
export async function getMCPServersFromSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<Record<string, McpServerConfig>> {
|
||||
if (!settingsService) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const mcpServers = globalSettings.mcpServers || [];
|
||||
|
||||
// Filter to only enabled servers and convert to SDK format
|
||||
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convert settings format to SDK format (keyed by name)
|
||||
const sdkServers: Record<string, McpServerConfig> = {};
|
||||
for (const server of enabledServers) {
|
||||
sdkServers[server.name] = convertToSdkFormat(server);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
|
||||
);
|
||||
|
||||
return sdkServers;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load MCP servers setting:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP permission settings from global settings.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP permission settings
|
||||
*/
|
||||
export async function getMCPPermissionSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
|
||||
|
||||
if (!settingsService) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = {
|
||||
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||
};
|
||||
console.log(
|
||||
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
|
||||
* Validates required fields and throws informative errors if missing.
|
||||
*/
|
||||
function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
|
||||
if (server.type === 'sse') {
|
||||
if (!server.url) {
|
||||
throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
|
||||
}
|
||||
return {
|
||||
type: 'sse',
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
};
|
||||
}
|
||||
|
||||
if (server.type === 'http') {
|
||||
if (!server.url) {
|
||||
throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
|
||||
}
|
||||
return {
|
||||
type: 'http',
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to stdio
|
||||
if (!server.command) {
|
||||
throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
|
||||
}
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,16 +36,33 @@ export class ClaudeProvider extends BaseProvider {
|
||||
} = options;
|
||||
|
||||
// Build Claude SDK options
|
||||
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
|
||||
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
|
||||
// the provider is the final point where SDK options are constructed.
|
||||
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
const toolsToUse = allowedTools || defaultTools;
|
||||
|
||||
// Determine permission mode based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: toolsToUse,
|
||||
permissionMode: 'default',
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||
// Required when using bypassPermissions mode
|
||||
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
@@ -55,6 +72,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
...(options.settingSources && { settingSources: options.settingSources }),
|
||||
// Forward sandbox configuration
|
||||
...(options.sandbox && { sandbox: options.sandbox }),
|
||||
// Forward MCP servers configuration
|
||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
/**
|
||||
* Shared types for AI model providers
|
||||
*
|
||||
* Re-exports types from @automaker/types for consistency across the codebase.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for a provider instance
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
apiKey?: string;
|
||||
cliPath?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a query via a provider
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
||||
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
|
||||
}
|
||||
// Re-export all provider types from @automaker/types
|
||||
export type {
|
||||
ProviderConfig,
|
||||
ConversationMessage,
|
||||
ExecuteOptions,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
|
||||
20
apps/server/src/routes/mcp/common.ts
Normal file
20
apps/server/src/routes/mcp/common.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Common utilities for MCP routes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with prefix
|
||||
*/
|
||||
export function logError(error: unknown, message: string): void {
|
||||
console.error(`[MCP] ${message}:`, error);
|
||||
}
|
||||
36
apps/server/src/routes/mcp/index.ts
Normal file
36
apps/server/src/routes/mcp/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* MCP routes - HTTP API for testing MCP servers
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Testing MCP server connections
|
||||
* - Listing available tools from MCP servers
|
||||
*
|
||||
* Mounted at /api/mcp in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { MCPTestService } from '../../services/mcp-test-service.js';
|
||||
import { createTestServerHandler } from './routes/test-server.js';
|
||||
import { createListToolsHandler } from './routes/list-tools.js';
|
||||
|
||||
/**
|
||||
* Create MCP router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /test - Test MCP server connection
|
||||
* - POST /tools - List tools from MCP server
|
||||
*
|
||||
* @param mcpTestService - Instance of MCPTestService for testing connections
|
||||
* @returns Express Router configured with all MCP endpoints
|
||||
*/
|
||||
export function createMCPRoutes(mcpTestService: MCPTestService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Test MCP server connection
|
||||
router.post('/test', createTestServerHandler(mcpTestService));
|
||||
|
||||
// List tools from MCP server
|
||||
router.post('/tools', createListToolsHandler(mcpTestService));
|
||||
|
||||
return router;
|
||||
}
|
||||
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* POST /api/mcp/tools - List tools for an MCP server
|
||||
*
|
||||
* Lists available tools for an MCP server.
|
||||
* Similar to test but focused on tool discovery.
|
||||
*
|
||||
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||
* Users must explicitly save a server config through the UI before testing.
|
||||
*
|
||||
* Request body:
|
||||
* { serverId: string } - Get tools by server ID from settings
|
||||
*
|
||||
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ListToolsRequest {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/mcp/tools
|
||||
*/
|
||||
export function createListToolsHandler(mcpTestService: MCPTestService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as ListToolsRequest;
|
||||
|
||||
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'serverId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mcpTestService.testServerById(body.serverId);
|
||||
|
||||
// Return only tool-related information
|
||||
res.json({
|
||||
success: result.success,
|
||||
tools: result.tools,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List tools failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/mcp/test - Test MCP server connection and list tools
|
||||
*
|
||||
* Tests connection to an MCP server and returns available tools.
|
||||
*
|
||||
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||
* Users must explicitly save a server config through the UI before testing.
|
||||
*
|
||||
* Request body:
|
||||
* { serverId: string } - Test server by ID from settings
|
||||
*
|
||||
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface TestServerRequest {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/mcp/test
|
||||
*/
|
||||
export function createTestServerHandler(mcpTestService: MCPTestService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as TestServerRequest;
|
||||
|
||||
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'serverId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mcpTestService.testServerById(body.serverId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Test server failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
21
apps/server/src/routes/pipeline/common.ts
Normal file
21
apps/server/src/routes/pipeline/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Common utilities for pipeline routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all pipeline endpoints.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for pipeline-related operations */
|
||||
export const logger = createLogger('Pipeline');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
77
apps/server/src/routes/pipeline/index.ts
Normal file
77
apps/server/src/routes/pipeline/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pipeline routes - HTTP API for pipeline configuration management
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Getting pipeline configuration
|
||||
* - Saving pipeline configuration
|
||||
* - Adding, updating, deleting, and reordering pipeline steps
|
||||
*
|
||||
* All endpoints use handler factories that receive the PipelineService instance.
|
||||
* Mounted at /api/pipeline in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { PipelineService } from '../../services/pipeline-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGetConfigHandler } from './routes/get-config.js';
|
||||
import { createSaveConfigHandler } from './routes/save-config.js';
|
||||
import { createAddStepHandler } from './routes/add-step.js';
|
||||
import { createUpdateStepHandler } from './routes/update-step.js';
|
||||
import { createDeleteStepHandler } from './routes/delete-step.js';
|
||||
import { createReorderStepsHandler } from './routes/reorder-steps.js';
|
||||
|
||||
/**
|
||||
* Create pipeline router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /config - Get pipeline configuration
|
||||
* - POST /config/save - Save entire pipeline configuration
|
||||
* - POST /steps/add - Add a new pipeline step
|
||||
* - POST /steps/update - Update an existing pipeline step
|
||||
* - POST /steps/delete - Delete a pipeline step
|
||||
* - POST /steps/reorder - Reorder pipeline steps
|
||||
*
|
||||
* @param pipelineService - Instance of PipelineService for file I/O
|
||||
* @returns Express Router configured with all pipeline endpoints
|
||||
*/
|
||||
export function createPipelineRoutes(pipelineService: PipelineService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get pipeline configuration
|
||||
router.post(
|
||||
'/config',
|
||||
validatePathParams('projectPath'),
|
||||
createGetConfigHandler(pipelineService)
|
||||
);
|
||||
|
||||
// Save entire pipeline configuration
|
||||
router.post(
|
||||
'/config/save',
|
||||
validatePathParams('projectPath'),
|
||||
createSaveConfigHandler(pipelineService)
|
||||
);
|
||||
|
||||
// Pipeline step operations
|
||||
router.post(
|
||||
'/steps/add',
|
||||
validatePathParams('projectPath'),
|
||||
createAddStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/update',
|
||||
validatePathParams('projectPath'),
|
||||
createUpdateStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/reorder',
|
||||
validatePathParams('projectPath'),
|
||||
createReorderStepsHandler(pipelineService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/add - Add a new pipeline step
|
||||
*
|
||||
* Adds a new step to the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, step: { name, order, instructions, colorClass } }
|
||||
* Response: { success: true, step: PipelineStep }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAddStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, step } = req.body as {
|
||||
projectPath: string;
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step) {
|
||||
res.status(400).json({ success: false, error: 'step is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step.name) {
|
||||
res.status(400).json({ success: false, error: 'step.name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.instructions === undefined) {
|
||||
res.status(400).json({ success: false, error: 'step.instructions is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = await pipelineService.addStep(projectPath, step);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
step: newStep,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Add pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/delete - Delete a pipeline step
|
||||
*
|
||||
* Removes a step from the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepId: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepId } = req.body as {
|
||||
projectPath: string;
|
||||
stepId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepId) {
|
||||
res.status(400).json({ success: false, error: 'stepId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.deleteStep(projectPath, stepId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Delete pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* POST /api/pipeline/config - Get pipeline configuration
|
||||
*
|
||||
* Returns the pipeline configuration for a project.
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, config: PipelineConfig }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetConfigHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get pipeline config failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/reorder - Reorder pipeline steps
|
||||
*
|
||||
* Reorders the steps in the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepIds: string[] }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createReorderStepsHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepIds } = req.body as {
|
||||
projectPath: string;
|
||||
stepIds: string[];
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepIds || !Array.isArray(stepIds)) {
|
||||
res.status(400).json({ success: false, error: 'stepIds array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.reorderSteps(projectPath, stepIds);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Reorder pipeline steps failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/pipeline/config/save - Save entire pipeline configuration
|
||||
*
|
||||
* Saves the complete pipeline configuration for a project.
|
||||
*
|
||||
* Request body: { projectPath: string, config: PipelineConfig }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSaveConfigHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, config } = req.body as {
|
||||
projectPath: string;
|
||||
config: PipelineConfig;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
res.status(400).json({ success: false, error: 'config is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.savePipelineConfig(projectPath, config);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Save pipeline config failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/update - Update an existing pipeline step
|
||||
*
|
||||
* Updates a step in the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepId: string, updates: Partial<PipelineStep> }
|
||||
* Response: { success: true, step: PipelineStep }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createUpdateStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
stepId: string;
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepId) {
|
||||
res.status(400).json({ success: false, error: 'stepId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
res.status(400).json({ success: false, error: 'updates is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
step: updatedStep,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Update pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Common utilities for update routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Updates');
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
// ============================================================================
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env['ProgramFiles(x86)']) {
|
||||
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
||||
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
||||
'/home/linuxbrew/.linuxbrew/bin' // Linuxbrew
|
||||
);
|
||||
// pipx, other user installs - only add if HOME is defined
|
||||
if (process.env.HOME) {
|
||||
additionalPaths.push(`${process.env.HOME}/.local/bin`);
|
||||
}
|
||||
}
|
||||
|
||||
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
||||
.filter(Boolean)
|
||||
.join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Automaker installation path
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Locate the Automaker monorepo root directory.
|
||||
*
|
||||
* @returns Absolute path to the monorepo root directory (the directory containing the top-level `package.json`)
|
||||
*/
|
||||
export function getAutomakerRoot(): string {
|
||||
// In ESM, we use import.meta.url to get the current file path
|
||||
// This file is at: apps/server/src/routes/updates/common.ts
|
||||
// So we need to go up 5 levels to get to the monorepo root
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Go up from: updates -> routes -> src -> server -> apps -> root
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether Git is available on the system.
|
||||
*
|
||||
* @returns `true` if the `git` command is executable in the current environment, `false` otherwise.
|
||||
*/
|
||||
export async function isGitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git --version', { env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the given filesystem path is a Git repository.
|
||||
*
|
||||
* @param repoPath - Filesystem path to check
|
||||
* @returns `true` if the path is inside a Git working tree, `false` otherwise.
|
||||
*/
|
||||
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath, env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the full commit hash pointed to by HEAD in the given repository.
|
||||
*
|
||||
* @param repoPath - Filesystem path of the Git repository to query
|
||||
* @returns The full commit hash for HEAD as a trimmed string
|
||||
*/
|
||||
export async function getCurrentCommit(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the short commit hash of HEAD for the repository at the given path.
|
||||
*
|
||||
* @param repoPath - Filesystem path to the git repository
|
||||
* @returns The short commit hash for `HEAD`
|
||||
*/
|
||||
export async function getShortCommit(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the repository contains uncommitted local changes.
|
||||
*
|
||||
* @param repoPath - Filesystem path to the Git repository to check
|
||||
* @returns `true` if the repository has any uncommitted changes, `false` otherwise
|
||||
*/
|
||||
export async function hasLocalChanges(repoPath: string): Promise<boolean> {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a string is a well-formed git remote URL and contains no shell metacharacters.
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @returns `true` if `url` starts with a common git protocol (`https://`, `git@`, `git://`, `ssh://`) and does not contain shell metacharacters, `false` otherwise.
|
||||
*/
|
||||
export function isValidGitUrl(url: string): boolean {
|
||||
// Allow HTTPS, SSH, and git protocols
|
||||
const startsWithValidProtocol =
|
||||
url.startsWith('https://') ||
|
||||
url.startsWith('git@') ||
|
||||
url.startsWith('git://') ||
|
||||
url.startsWith('ssh://');
|
||||
|
||||
// Block shell metacharacters to prevent command injection
|
||||
const hasShellChars = /[;`|&<>()$!\\[\] ]/.test(url);
|
||||
|
||||
return startsWithValidProtocol && !hasShellChars;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Update routes - HTTP API for checking and applying updates
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Checking if updates are available from upstream
|
||||
* - Pulling updates from upstream
|
||||
* - Getting current installation info
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { createCheckHandler } from './routes/check.js';
|
||||
import { createPullHandler } from './routes/pull.js';
|
||||
import { createInfoHandler } from './routes/info.js';
|
||||
|
||||
/**
|
||||
* Create an Express Router that exposes API endpoints for update operations.
|
||||
*
|
||||
* @returns An Express Router with the routes:
|
||||
* - GET `/check` — checks for available updates
|
||||
* - POST `/pull` — pulls updates from upstream
|
||||
* - GET `/info` — returns current installation info
|
||||
*/
|
||||
export function createUpdatesRoutes(settingsService: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/updates/check - Check if updates are available
|
||||
router.get('/check', createCheckHandler(settingsService));
|
||||
|
||||
// POST /api/updates/pull - Pull updates from upstream
|
||||
router.post('/pull', createPullHandler(settingsService));
|
||||
|
||||
// GET /api/updates/info - Get current installation info
|
||||
router.get('/info', createInfoHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* GET /check endpoint - Check if updates are available
|
||||
*
|
||||
* Compares local version with the remote upstream version.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { UpdateCheckResult } from '@automaker/types';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
isValidGitUrl,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Create an Express handler for the update check endpoint that compares the local Git commit
|
||||
* against a configured upstream to determine whether an update is available.
|
||||
*
|
||||
* The handler validates Git availability and repository state, reads the upstream URL from
|
||||
* global settings (with a default), attempts to fetch the upstream main branch using a
|
||||
* temporary remote, and returns a structured result describing local and remote commits and
|
||||
* whether the remote is ahead.
|
||||
*
|
||||
* @param settingsService - Service used to read global settings (used to obtain `autoUpdate.upstreamUrl`)
|
||||
* @returns An Express request handler that responds with JSON. On success the response is
|
||||
* `{ success: true, result }` where `result` is an `UpdateCheckResult`. On error the response
|
||||
* is `{ success: false, error }`. If fetching the upstream fails the handler still responds
|
||||
* with `{ success: true, result }` where `result` indicates no update and includes an `error` message.
|
||||
*/
|
||||
export function createCheckHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Check if git is available
|
||||
if (!(await isGitAvailable())) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Git is not installed or not available in PATH',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if automaker directory is a git repo
|
||||
if (!(await isGitRepo(installPath))) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Automaker installation is not a git repository',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for upstream URL
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const sourceUrl =
|
||||
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
|
||||
|
||||
// Validate URL to prevent command injection
|
||||
if (!isValidGitUrl(sourceUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid upstream URL format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get local version
|
||||
const localVersion = await getCurrentCommit(installPath);
|
||||
const localVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Use a random remote name to avoid conflicts with concurrent checks
|
||||
const tempRemoteName = `automaker-update-check-${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
try {
|
||||
// Add temporary remote
|
||||
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Fetch from the temporary remote
|
||||
await execAsync(`git fetch ${tempRemoteName} main`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Get remote version
|
||||
const { stdout: remoteVersionOutput } = await execAsync(
|
||||
`git rev-parse ${tempRemoteName}/main`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
const remoteVersion = remoteVersionOutput.trim();
|
||||
|
||||
// Get short remote version
|
||||
const { stdout: remoteVersionShortOutput } = await execAsync(
|
||||
`git rev-parse --short ${tempRemoteName}/main`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
const remoteVersionShort = remoteVersionShortOutput.trim();
|
||||
|
||||
// Check if remote is ahead of local (update available)
|
||||
// git merge-base --is-ancestor <commit1> <commit2> returns 0 if commit1 is ancestor of commit2
|
||||
let updateAvailable = false;
|
||||
if (localVersion !== remoteVersion) {
|
||||
try {
|
||||
// Check if local is already an ancestor of remote (remote is ahead)
|
||||
await execAsync(`git merge-base --is-ancestor ${localVersion} ${remoteVersion}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
// If we get here (exit code 0), local is ancestor of remote, so update is available
|
||||
updateAvailable = true;
|
||||
} catch {
|
||||
// Exit code 1 means local is NOT an ancestor of remote
|
||||
// This means either local is ahead, or branches have diverged
|
||||
// In either case, we don't show "update available"
|
||||
updateAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
const result: UpdateCheckResult = {
|
||||
updateAvailable,
|
||||
localVersion,
|
||||
localVersionShort,
|
||||
remoteVersion,
|
||||
remoteVersionShort,
|
||||
sourceUrl,
|
||||
installPath,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (fetchError) {
|
||||
const errorMsg = getErrorMessage(fetchError);
|
||||
logError(fetchError, 'Failed to fetch from upstream');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
updateAvailable: false,
|
||||
localVersion,
|
||||
localVersionShort,
|
||||
remoteVersion: null,
|
||||
remoteVersionShort: null,
|
||||
sourceUrl,
|
||||
installPath,
|
||||
error: `Could not fetch from upstream: ${errorMsg}`,
|
||||
} satisfies UpdateCheckResult,
|
||||
});
|
||||
} finally {
|
||||
// Always clean up temp remote
|
||||
try {
|
||||
await execAsync(`git remote remove ${tempRemoteName}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Update check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* GET /info endpoint - Get current installation info
|
||||
*
|
||||
* Returns current version, branch, and configuration info.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { DEFAULT_AUTO_UPDATE_SETTINGS, type UpdateInfo } from '@automaker/types';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
hasLocalChanges,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Creates an Express handler that returns update information for the application installation.
|
||||
*
|
||||
* The produced handler responds with a JSON payload containing an UpdateInfo result describing
|
||||
* installation path, git-based version and branch data (when available), local change status,
|
||||
* and configured auto-update settings. On failure the handler responds with HTTP 500 and a JSON
|
||||
* error message.
|
||||
*
|
||||
* @returns An Express request handler that sends `{ success: true, result: UpdateInfo }` on success
|
||||
* or `{ success: false, error: string }` with HTTP 500 on error.
|
||||
*/
|
||||
export function createInfoHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Get settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const autoUpdateSettings = settings.autoUpdate || DEFAULT_AUTO_UPDATE_SETTINGS;
|
||||
|
||||
// Check if git is available
|
||||
const gitAvailable = await isGitAvailable();
|
||||
|
||||
if (!gitAvailable) {
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion: null,
|
||||
currentVersionShort: null,
|
||||
currentBranch: null,
|
||||
hasLocalChanges: false,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: false,
|
||||
gitAvailable: false,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a git repo
|
||||
const isRepo = await isGitRepo(installPath);
|
||||
|
||||
if (!isRepo) {
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion: null,
|
||||
currentVersionShort: null,
|
||||
currentBranch: null,
|
||||
hasLocalChanges: false,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: false,
|
||||
gitAvailable: true,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get git info
|
||||
const currentVersion = await getCurrentCommit(installPath);
|
||||
const currentVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
const currentBranch = branchOutput.trim();
|
||||
|
||||
// Check for local changes
|
||||
const localChanges = await hasLocalChanges(installPath);
|
||||
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion,
|
||||
currentVersionShort,
|
||||
currentBranch,
|
||||
hasLocalChanges: localChanges,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: true,
|
||||
gitAvailable: true,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Failed to get update info');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* POST /pull endpoint - Pull updates from upstream
|
||||
*
|
||||
* Executes git pull from the configured upstream repository.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { UpdatePullResult } from '@automaker/types';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
isValidGitUrl,
|
||||
hasLocalChanges,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Create an Express handler for POST /pull that updates the local Automaker installation by pulling from the configured upstream Git repository.
|
||||
*
|
||||
* The handler validates Git availability and that the install directory is a git repository, ensures there are no local uncommitted changes, validates the upstream URL from global settings, and performs a fast-forward-only pull using a temporary remote. It returns a JSON UpdatePullResult on success, or an error JSON with appropriate HTTP status codes for invalid input, merge conflicts, non-fast-forward divergence, or unexpected failures.
|
||||
*
|
||||
* @param settingsService - Service used to read global settings (used to obtain the upstream URL)
|
||||
* @returns An Express request handler that performs the safe fast-forward pull and sends a JSON response describing the result or error
|
||||
*/
|
||||
export function createPullHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Check if git is available
|
||||
if (!(await isGitAvailable())) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Git is not installed or not available in PATH',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if automaker directory is a git repo
|
||||
if (!(await isGitRepo(installPath))) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Automaker installation is not a git repository',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
if (await hasLocalChanges(installPath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'You have local uncommitted changes. Please commit or stash them before updating.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for upstream URL
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const sourceUrl =
|
||||
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
|
||||
|
||||
// Validate URL to prevent command injection
|
||||
if (!isValidGitUrl(sourceUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid upstream URL format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current version before pull
|
||||
const previousVersion = await getCurrentCommit(installPath);
|
||||
const previousVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Use a random remote name to avoid conflicts with concurrent pulls
|
||||
const tempRemoteName = `automaker-update-pull-${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
try {
|
||||
// Add temporary remote
|
||||
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Fetch first
|
||||
await execAsync(`git fetch ${tempRemoteName} main`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Merge the fetched changes
|
||||
const { stdout: mergeOutput } = await execAsync(
|
||||
`git merge ${tempRemoteName}/main --ff-only`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
|
||||
// Get new version after merge
|
||||
const newVersion = await getCurrentCommit(installPath);
|
||||
const newVersionShort = await getShortCommit(installPath);
|
||||
|
||||
const alreadyUpToDate =
|
||||
mergeOutput.includes('Already up to date') || previousVersion === newVersion;
|
||||
|
||||
const result: UpdatePullResult = {
|
||||
success: true,
|
||||
previousVersion,
|
||||
previousVersionShort,
|
||||
newVersion,
|
||||
newVersionShort,
|
||||
alreadyUpToDate,
|
||||
message: alreadyUpToDate
|
||||
? 'Already up to date'
|
||||
: `Updated from ${previousVersionShort} to ${newVersionShort}`,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (pullError) {
|
||||
const errorMsg = getErrorMessage(pullError);
|
||||
logError(pullError, 'Failed to pull updates');
|
||||
|
||||
// Check for common errors
|
||||
if (errorMsg.includes('not possible to fast-forward')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Cannot fast-forward merge. Your local branch has diverged from upstream. Please resolve manually.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorMsg.includes('CONFLICT')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Merge conflict detected. Please resolve conflicts manually.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Failed to pull updates: ${errorMsg}`,
|
||||
});
|
||||
} finally {
|
||||
// Always clean up temp remote
|
||||
try {
|
||||
await execAsync(`git remote remove ${tempRemoteName}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Update pull failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -227,6 +229,12 @@ export class AgentService {
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
@@ -252,6 +260,9 @@ export class AgentService {
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -275,6 +286,9 @@ export class AgentService {
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { ExecuteOptions, Feature } from '@automaker/types';
|
||||
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
@@ -32,10 +32,13 @@ import {
|
||||
} from '../lib/sdk-options.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { pipelineService, PipelineService } from './pipeline-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -631,6 +634,23 @@ export class AutoModeService {
|
||||
}
|
||||
);
|
||||
|
||||
// Check for pipeline steps and execute them
|
||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
if (sortedSteps.length > 0) {
|
||||
// Execute pipeline steps sequentially
|
||||
await this.executePipelineSteps(
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
sortedSteps,
|
||||
workDir,
|
||||
abortController,
|
||||
autoLoadClaudeMd
|
||||
);
|
||||
}
|
||||
|
||||
// Determine final status based on testing mode:
|
||||
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
||||
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
||||
@@ -674,6 +694,143 @@ export class AutoModeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute pipeline steps sequentially after initial feature implementation
|
||||
*/
|
||||
private async executePipelineSteps(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
feature: Feature,
|
||||
steps: PipelineStep[],
|
||||
workDir: string,
|
||||
abortController: AbortController,
|
||||
autoLoadClaudeMd: boolean
|
||||
): Promise<void> {
|
||||
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||
|
||||
// Load context files once
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
});
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
// Load previous agent output for context continuity
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
let previousContext = '';
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
// No previous context
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const pipelineStatus = `pipeline_${step.id}`;
|
||||
|
||||
// Update feature status to current pipeline step
|
||||
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
this.emitAutoModeEvent('pipeline_step_started', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Build prompt for this pipeline step
|
||||
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
|
||||
|
||||
// Get model from feature
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
|
||||
// Run the agent for this pipeline step
|
||||
await this.runAgent(
|
||||
workDir,
|
||||
featureId,
|
||||
prompt,
|
||||
abortController,
|
||||
projectPath,
|
||||
undefined, // no images for pipeline steps
|
||||
model,
|
||||
{
|
||||
projectPath,
|
||||
planningMode: 'skip', // Pipeline steps don't need planning
|
||||
requirePlanApproval: false,
|
||||
previousContent: previousContext,
|
||||
systemPrompt: contextFilesPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
}
|
||||
);
|
||||
|
||||
// Load updated context for next step
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
// No context update
|
||||
}
|
||||
|
||||
this.emitAutoModeEvent('pipeline_step_complete', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for a pipeline step
|
||||
*/
|
||||
private buildPipelineStepPrompt(
|
||||
step: PipelineStep,
|
||||
feature: Feature,
|
||||
previousContext: string
|
||||
): string {
|
||||
let prompt = `## Pipeline Step: ${step.name}
|
||||
|
||||
This is an automated pipeline step following the initial feature implementation.
|
||||
|
||||
### Feature Context
|
||||
${this.buildFeaturePrompt(feature)}
|
||||
|
||||
`;
|
||||
|
||||
if (previousContext) {
|
||||
prompt += `### Previous Work
|
||||
The following is the output from the previous work on this feature:
|
||||
|
||||
${previousContext}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `### Pipeline Step Instructions
|
||||
${step.instructions}
|
||||
|
||||
### Task
|
||||
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific feature
|
||||
*/
|
||||
@@ -1841,6 +1998,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
// Load enableSandboxMode setting (global setting only)
|
||||
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Build SDK options using centralized configuration for feature implementation
|
||||
const sdkOptions = createAutoModeOptions({
|
||||
cwd: workDir,
|
||||
@@ -1848,6 +2011,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -1889,6 +2055,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Execute via provider
|
||||
@@ -2116,6 +2285,9 @@ After generating the revised spec, output:
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
let revisionText = '';
|
||||
@@ -2253,6 +2425,9 @@ After generating the revised spec, output:
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
let taskOutput = '';
|
||||
@@ -2342,6 +2517,9 @@ Implement all the changes described in the plan above.`;
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
for await (const msg of continuationStream) {
|
||||
|
||||
208
apps/server/src/services/mcp-test-service.ts
Normal file
208
apps/server/src/services/mcp-test-service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* MCP Test Service
|
||||
*
|
||||
* Provides functionality to test MCP server connections and list available tools.
|
||||
* Supports stdio, SSE, and HTTP transport types.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean;
|
||||
tools?: MCPToolInfo[];
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Test Service for testing server connections and listing tools
|
||||
*/
|
||||
export class MCPTestService {
|
||||
private settingsService: SettingsService;
|
||||
|
||||
constructor(settingsService: SettingsService) {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to an MCP server and list its tools
|
||||
*/
|
||||
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
||||
const startTime = Date.now();
|
||||
let client: Client | null = null;
|
||||
|
||||
try {
|
||||
client = new Client({
|
||||
name: 'automaker-mcp-test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Create transport based on server type
|
||||
const transport = await this.createTransport(serverConfig);
|
||||
|
||||
// Connect with timeout
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'),
|
||||
]);
|
||||
|
||||
// List tools with timeout
|
||||
const toolsResult = await Promise.race([
|
||||
client.listTools(),
|
||||
this.timeout<{
|
||||
tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}>;
|
||||
}>(DEFAULT_TIMEOUT, 'List tools timeout'),
|
||||
]);
|
||||
|
||||
const connectionTime = Date.now() - startTime;
|
||||
|
||||
// Convert tools to MCPToolInfo format
|
||||
const tools: MCPToolInfo[] = (toolsResult.tools || []).map(
|
||||
(tool: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tools,
|
||||
connectionTime,
|
||||
serverInfo: {
|
||||
name: serverConfig.name,
|
||||
version: undefined, // Could be extracted from server info if available
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const connectionTime = Date.now() - startTime;
|
||||
return {
|
||||
success: false,
|
||||
error: this.getErrorMessage(error),
|
||||
connectionTime,
|
||||
};
|
||||
} finally {
|
||||
// Clean up client connection
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test server by ID (looks up config from settings)
|
||||
*/
|
||||
async testServerById(serverId: string): Promise<MCPTestResult> {
|
||||
try {
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId);
|
||||
|
||||
if (!serverConfig) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Server with ID "${serverId}" not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return this.testServer(serverConfig);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appropriate transport based on server type
|
||||
*/
|
||||
private async createTransport(
|
||||
config: MCPServerConfig
|
||||
): Promise<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
|
||||
if (config.type === 'sse') {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required for SSE transport');
|
||||
}
|
||||
// Use eventSourceInit workaround for SSE headers (SDK bug workaround)
|
||||
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436
|
||||
const headers = config.headers;
|
||||
return new SSEClientTransport(new URL(config.url), {
|
||||
requestInit: headers ? { headers } : undefined,
|
||||
eventSourceInit: headers
|
||||
? {
|
||||
fetch: (url: string | URL | Request, init?: RequestInit) => {
|
||||
const fetchHeaders = new Headers(init?.headers || {});
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
fetchHeaders.set(key, value);
|
||||
}
|
||||
return fetch(url, { ...init, headers: fetchHeaders });
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.type === 'http') {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required for HTTP transport');
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers
|
||||
? {
|
||||
headers: config.headers,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Default to stdio
|
||||
if (!config.command) {
|
||||
throw new Error('Command is required for stdio transport');
|
||||
}
|
||||
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout promise
|
||||
*/
|
||||
private timeout<T>(ms: number, message: string): Promise<T> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
private getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
320
apps/server/src/services/pipeline-service.ts
Normal file
320
apps/server/src/services/pipeline-service.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Pipeline Service - Handles reading/writing pipeline configuration
|
||||
*
|
||||
* Provides persistent storage for:
|
||||
* - Pipeline configuration ({projectPath}/.automaker/pipeline.json)
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('PipelineService');
|
||||
|
||||
// Default empty pipeline config
|
||||
const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for pipeline steps
|
||||
*/
|
||||
function generateStepId(): string {
|
||||
return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pipeline config file path for a project
|
||||
*/
|
||||
function getPipelineConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'pipeline.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* PipelineService - Manages pipeline configuration for workflow automation
|
||||
*
|
||||
* Handles reading and writing pipeline config to JSON files with atomic operations.
|
||||
* Pipeline steps define custom columns that appear between "in_progress" and
|
||||
* "waiting_approval/verified" columns in the kanban board.
|
||||
*/
|
||||
export class PipelineService {
|
||||
/**
|
||||
* Get pipeline configuration for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @returns Promise resolving to PipelineConfig (empty steps array if no config exists)
|
||||
*/
|
||||
async getPipelineConfig(projectPath: string): Promise<PipelineConfig> {
|
||||
const configPath = getPipelineConfigPath(projectPath);
|
||||
const config = await readJsonFile<PipelineConfig>(configPath, DEFAULT_PIPELINE_CONFIG);
|
||||
|
||||
// Ensure version is set
|
||||
return {
|
||||
...DEFAULT_PIPELINE_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save entire pipeline configuration
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param config - Complete PipelineConfig to save
|
||||
*/
|
||||
async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise<void> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const configPath = getPipelineConfigPath(projectPath);
|
||||
await atomicWriteJson(configPath, config);
|
||||
logger.info(`Pipeline config saved for project: ${projectPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param step - Step data (without id, createdAt, updatedAt)
|
||||
* @returns Promise resolving to the created PipelineStep
|
||||
*/
|
||||
async addStep(
|
||||
projectPath: string,
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<PipelineStep> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newStep: PipelineStep = {
|
||||
...step,
|
||||
id: generateStepId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
config.steps.push(newStep);
|
||||
|
||||
// Normalize order values
|
||||
config.steps.sort((a, b) => a.order - b.order);
|
||||
config.steps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`);
|
||||
|
||||
return newStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to update
|
||||
* @param updates - Partial step data to merge
|
||||
*/
|
||||
async updateStep(
|
||||
projectPath: string,
|
||||
stepId: string,
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
|
||||
): Promise<PipelineStep> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Pipeline step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
config.steps[stepIndex] = {
|
||||
...config.steps[stepIndex],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step updated: ${stepId}`);
|
||||
|
||||
return config.steps[stepIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to delete
|
||||
*/
|
||||
async deleteStep(projectPath: string, stepId: string): Promise<void> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Pipeline step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
config.steps.splice(stepIndex, 1);
|
||||
|
||||
// Normalize order values after deletion
|
||||
config.steps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step deleted: ${stepId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder pipeline steps
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepIds - Array of step IDs in the desired order
|
||||
*/
|
||||
async reorderSteps(projectPath: string, stepIds: string[]): Promise<void> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
|
||||
// Validate all step IDs exist
|
||||
const existingIds = new Set(config.steps.map((s) => s.id));
|
||||
for (const id of stepIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new Error(`Pipeline step not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
|
||||
|
||||
// Reorder steps based on stepIds array
|
||||
config.steps = stepIds.map((id, index) => {
|
||||
const step = stepMap.get(id)!;
|
||||
return { ...step, order: index, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline steps reordered`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next status in the pipeline flow
|
||||
*
|
||||
* Determines what status a feature should transition to based on current status.
|
||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||
*
|
||||
* @param currentStatus - Current feature status
|
||||
* @param config - Pipeline configuration (or null if no pipeline)
|
||||
* @param skipTests - Whether to skip tests (affects final status)
|
||||
* @returns The next status in the pipeline flow
|
||||
*/
|
||||
getNextStatus(
|
||||
currentStatus: FeatureStatusWithPipeline,
|
||||
config: PipelineConfig | null,
|
||||
skipTests: boolean
|
||||
): FeatureStatusWithPipeline {
|
||||
const steps = config?.steps || [];
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
// If no pipeline steps, use original logic
|
||||
if (sortedSteps.length === 0) {
|
||||
if (currentStatus === 'in_progress') {
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// Coming from in_progress -> go to first pipeline step
|
||||
if (currentStatus === 'in_progress') {
|
||||
return `pipeline_${sortedSteps[0].id}`;
|
||||
}
|
||||
|
||||
// Coming from a pipeline step -> go to next step or final status
|
||||
if (currentStatus.startsWith('pipeline_')) {
|
||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Step not found, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
if (currentIndex < sortedSteps.length - 1) {
|
||||
// Go to next step
|
||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||
}
|
||||
|
||||
// Last step completed, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
// For other statuses, don't change
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific pipeline step by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to retrieve
|
||||
* @returns The pipeline step or null if not found
|
||||
*/
|
||||
async getStep(projectPath: string, stepId: string): Promise<PipelineStep | null> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
return config.steps.find((s) => s.id === stepId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status is a pipeline status
|
||||
*/
|
||||
isPipelineStatus(status: FeatureStatusWithPipeline): boolean {
|
||||
return status.startsWith('pipeline_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract step ID from a pipeline status
|
||||
*/
|
||||
getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null {
|
||||
if (!this.isPipelineStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
return status.replace('pipeline_', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pipelineService = new PipelineService();
|
||||
365
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
365
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
|
||||
describe('settings-helpers.ts', () => {
|
||||
describe('getMCPServersFromSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is null', async () => {
|
||||
const result = await getMCPServersFromSettings(null);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is undefined', async () => {
|
||||
const result = await getMCPServersFromSettings(undefined);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when no MCP servers configured', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when mcpServers is undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert enabled stdio server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'test-server',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { NODE_ENV: 'test' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'test-server': {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { NODE_ENV: 'test' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert enabled SSE server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'sse-server',
|
||||
type: 'sse',
|
||||
url: 'http://localhost:3000/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'sse-server': {
|
||||
type: 'sse',
|
||||
url: 'http://localhost:3000/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert enabled HTTP server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'http-server',
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/api',
|
||||
headers: { 'X-API-Key': 'secret' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'http-server': {
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/api',
|
||||
headers: { 'X-API-Key': 'secret' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out disabled servers', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'enabled-server',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'disabled-server',
|
||||
type: 'stdio',
|
||||
command: 'python',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result['enabled-server']).toBeDefined();
|
||||
expect(result['disabled-server']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat servers without enabled field as enabled', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'implicit-enabled',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
// enabled field not set
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result['implicit-enabled']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple enabled servers', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{ id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true },
|
||||
{ id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true },
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result['server1']).toBeDefined();
|
||||
expect(result['server2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty object and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({});
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for SSE server without URL', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-sse',
|
||||
type: 'sse',
|
||||
enabled: true,
|
||||
// url missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// The error is caught and logged, returns empty
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error for HTTP server without URL', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-http',
|
||||
type: 'http',
|
||||
enabled: true,
|
||||
// url missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error for stdio server without command', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-stdio',
|
||||
type: 'stdio',
|
||||
enabled: true,
|
||||
// command missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should default to stdio type when type is not specified', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'no-type',
|
||||
command: 'node',
|
||||
enabled: true,
|
||||
// type not specified, should default to stdio
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result['no-type']).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMCPPermissionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is null', async () => {
|
||||
const result = await getMCPPermissionSettings(null);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is undefined', async () => {
|
||||
const result = await getMCPPermissionSettings(undefined);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return settings from service', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to true when settings are undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed settings', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom log prefix', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
|
||||
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[CustomPrefix]'));
|
||||
});
|
||||
});
|
||||
});
|
||||
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js';
|
||||
import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js';
|
||||
import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js';
|
||||
import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js';
|
||||
import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js';
|
||||
import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js';
|
||||
import type { PipelineService } from '@/services/pipeline-service.js';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
|
||||
describe('pipeline routes', () => {
|
||||
let mockPipelineService: PipelineService;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPipelineService = {
|
||||
getPipelineConfig: vi.fn(),
|
||||
savePipelineConfig: vi.fn(),
|
||||
addStep: vi.fn(),
|
||||
updateStep: vi.fn(),
|
||||
deleteStep: vi.fn(),
|
||||
reorderSteps: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('get-config', () => {
|
||||
it('should return pipeline config successfully', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config);
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project');
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = {};
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Read failed');
|
||||
vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Read failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('save-config', () => {
|
||||
it('should save pipeline config successfully', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', config };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { config: { version: 1, steps: [] } };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if config is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'config is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Save failed');
|
||||
vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
config: { version: 1, steps: [] },
|
||||
};
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Save failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add-step', () => {
|
||||
it('should add step successfully', async () => {
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
};
|
||||
|
||||
const newStep: PipelineStep = {
|
||||
...stepData,
|
||||
id: 'step1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep);
|
||||
req.body = { projectPath: '/test/project', step: stepData };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
step: newStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step.name is missing', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { order: 0, instructions: 'Do', colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step.name is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step.instructions is missing', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { name: 'Step', order: 0, colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step.instructions is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Add failed');
|
||||
vi.mocked(mockPipelineService.addStep).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Add failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-step', () => {
|
||||
it('should update step successfully', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Name',
|
||||
instructions: 'Updated instructions',
|
||||
};
|
||||
|
||||
const updatedStep: PipelineStep = {
|
||||
id: 'step1',
|
||||
name: 'Updated Name',
|
||||
order: 0,
|
||||
instructions: 'Updated instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1', updates };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.updateStep).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'step1',
|
||||
updates
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
step: updatedStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepId: 'step1', updates: { name: 'New' } };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepId is missing', async () => {
|
||||
req.body = { projectPath: '/test/project', updates: { name: 'New' } };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepId is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if updates is missing', async () => {
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'updates is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if updates is empty object', async () => {
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'updates is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Update failed');
|
||||
vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
stepId: 'step1',
|
||||
updates: { name: 'New' },
|
||||
};
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Update failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete-step', () => {
|
||||
it('should delete step successfully', async () => {
|
||||
vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1');
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepId is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepId is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Delete failed');
|
||||
vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Delete failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder-steps', () => {
|
||||
it('should reorder steps successfully', async () => {
|
||||
vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [
|
||||
'step2',
|
||||
'step1',
|
||||
'step3',
|
||||
]);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepIds: ['step1', 'step2'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepIds is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepIds array is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepIds is not an array', async () => {
|
||||
req.body = { projectPath: '/test/project', stepIds: 'not-an-array' };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepIds array is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Reorder failed');
|
||||
vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Reorder failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
@@ -0,0 +1,860 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PipelineService } from '@/services/pipeline-service.js';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ensureAutomakerDir
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
ensureAutomakerDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { ensureAutomakerDir } from '@automaker/platform';
|
||||
|
||||
describe('pipeline-service.ts', () => {
|
||||
let testProjectDir: string;
|
||||
let pipelineService: PipelineService;
|
||||
|
||||
beforeEach(async () => {
|
||||
testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`);
|
||||
await fs.mkdir(testProjectDir, { recursive: true });
|
||||
pipelineService = new PipelineService();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testProjectDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('getPipelineConfig', () => {
|
||||
it('should return default config when file does not exist', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(config).toEqual({
|
||||
version: 1,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should read and return existing config', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8');
|
||||
expect(config).toEqual(existingConfig);
|
||||
});
|
||||
|
||||
it('should merge with defaults for missing properties', async () => {
|
||||
const partialConfig = {
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(config.version).toBe(1);
|
||||
expect(config.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle read errors gracefully', async () => {
|
||||
const error = new Error('Read error');
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
// Should return default config on error
|
||||
expect(config).toEqual({
|
||||
version: 1,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePipelineConfig', () => {
|
||||
it('should save config to file', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.savePipelineConfig(testProjectDir, config);
|
||||
|
||||
expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir);
|
||||
expect(secureFs.writeFile).toHaveBeenCalled();
|
||||
expect(secureFs.rename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use atomic write pattern', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.savePipelineConfig(testProjectDir, config);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const tempPath = writeCall[0] as string;
|
||||
expect(tempPath).toContain('.tmp.');
|
||||
expect(tempPath).toContain('pipeline.json');
|
||||
});
|
||||
|
||||
it('should clean up temp file on write error', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
vi.mocked(secureFs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow(
|
||||
'Write failed'
|
||||
);
|
||||
|
||||
expect(secureFs.unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addStep', () => {
|
||||
it('should add a new step to config', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
};
|
||||
|
||||
const newStep = await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
expect(newStep.name).toBe('New Step');
|
||||
expect(newStep.id).toMatch(/^step_/);
|
||||
expect(newStep.createdAt).toBeDefined();
|
||||
expect(newStep.updatedAt).toBeDefined();
|
||||
expect(newStep.createdAt).toBe(newStep.updatedAt);
|
||||
});
|
||||
|
||||
it('should normalize order values after adding step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 5, // Out of order
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 10, // Out of order
|
||||
instructions: 'Do something',
|
||||
colorClass: 'red',
|
||||
};
|
||||
|
||||
await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
});
|
||||
|
||||
it('should sort steps by order before normalizing', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 2,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 0,
|
||||
instructions: 'Do something else',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 1,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'red',
|
||||
};
|
||||
|
||||
await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
// Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2)
|
||||
expect(savedConfig.steps[0].id).toBe('step2');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
expect(savedConfig.steps[2].id).toBe('step1');
|
||||
expect(savedConfig.steps[2].order).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStep', () => {
|
||||
it('should update an existing step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Old Name',
|
||||
order: 0,
|
||||
instructions: 'Old instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const updates = {
|
||||
name: 'New Name',
|
||||
instructions: 'New instructions',
|
||||
};
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates);
|
||||
|
||||
expect(updatedStep.name).toBe('New Name');
|
||||
expect(updatedStep.instructions).toBe('New instructions');
|
||||
expect(updatedStep.id).toBe('step1');
|
||||
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(
|
||||
pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' })
|
||||
).rejects.toThrow('Pipeline step not found: nonexistent');
|
||||
});
|
||||
|
||||
it('should preserve createdAt when updating', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', {
|
||||
name: 'Updated',
|
||||
});
|
||||
|
||||
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteStep', () => {
|
||||
it('should delete an existing step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.deleteStep(testProjectDir, 'step1');
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps).toHaveLength(1);
|
||||
expect(savedConfig.steps[0].id).toBe('step2');
|
||||
expect(savedConfig.steps[0].order).toBe(0); // Normalized
|
||||
});
|
||||
|
||||
it('should throw error if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow(
|
||||
'Pipeline step not found: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize order values after deletion', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 5, // Out of order
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 10, // Out of order
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.deleteStep(testProjectDir, 'step2');
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps).toHaveLength(2);
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderSteps', () => {
|
||||
it('should reorder steps according to stepIds array', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].id).toBe('step3');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].id).toBe('step1');
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
expect(savedConfig.steps[2].id).toBe('step2');
|
||||
expect(savedConfig.steps[2].order).toBe(2);
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp for reordered steps', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error if step ID not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(
|
||||
pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent'])
|
||||
).rejects.toThrow('Pipeline step not found: nonexistent');
|
||||
});
|
||||
|
||||
it('should allow partial reordering (filtering steps)', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step1']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
// Should only keep step1, effectively filtering out step2
|
||||
expect(savedConfig.steps).toHaveLength(1);
|
||||
expect(savedConfig.steps[0].id).toBe('step1');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextStatus', () => {
|
||||
it('should return waiting_approval when no pipeline and skipTests is true', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', null, true);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should return verified when no pipeline and skipTests is false', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', null, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should return first pipeline step when coming from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should go to next pipeline step when in middle of pipeline', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step2');
|
||||
});
|
||||
|
||||
it('should go to final status when completing last pipeline step', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should go to waiting_approval when completing last step with skipTests', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should handle invalid pipeline step ID gracefully', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should preserve other statuses unchanged', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog');
|
||||
expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe(
|
||||
'waiting_approval'
|
||||
);
|
||||
expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified');
|
||||
expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed');
|
||||
});
|
||||
|
||||
it('should sort steps by order when determining next status', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStep', () => {
|
||||
it('should return step by ID', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const step = await pipelineService.getStep(testProjectDir, 'step1');
|
||||
|
||||
expect(step).not.toBeNull();
|
||||
expect(step?.id).toBe('step1');
|
||||
expect(step?.name).toBe('Step 1');
|
||||
});
|
||||
|
||||
it('should return null if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const step = await pipelineService.getStep(testProjectDir, 'nonexistent');
|
||||
|
||||
expect(step).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPipelineStatus', () => {
|
||||
it('should return true for pipeline statuses', () => {
|
||||
expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true);
|
||||
expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-pipeline statuses', () => {
|
||||
expect(pipelineService.isPipelineStatus('in_progress')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('verified')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('backlog')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepIdFromStatus', () => {
|
||||
it('should extract step ID from pipeline status', () => {
|
||||
expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1');
|
||||
expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should return null for non-pipeline statuses', () => {
|
||||
expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull();
|
||||
expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull();
|
||||
expect(pipelineService.getStepIdFromStatus('verified')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Update Notifier Component
|
||||
*
|
||||
* Responsible for displaying toast notifications related to updates.
|
||||
* Subscribes to the updates store and reacts to state changes.
|
||||
*
|
||||
* This component handles the UI notifications, keeping them separate
|
||||
* from the business logic in the store.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useUpdatesStore } from '@/store/updates-store';
|
||||
import { useUpdatePolling } from '@/hooks/use-update-polling';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getRepoDisplayName } from '@/lib/utils';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UpdateNotifierProps {
|
||||
/** Custom handler for update available (for testing/DI) */
|
||||
onUpdateAvailable?: (remoteVersion: string) => void;
|
||||
|
||||
/** Custom handler for update installed (for testing/DI) */
|
||||
onUpdateInstalled?: (newVersion: string, alreadyUpToDate: boolean) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Displays persistent toasts for available and installed application updates.
|
||||
*
|
||||
* Shows a persistent "Update Available" toast when a new remote version is detected and,
|
||||
* after initiating an update, shows success toasts for either "Already up to date!" or
|
||||
* "Update installed!" with actions to restart now or later.
|
||||
*
|
||||
* @param onUpdateAvailable - Optional callback invoked with `remoteVersion` when an update is detected; providing this prevents the default availability toast.
|
||||
* @param onUpdateInstalled - Optional callback invoked with `(newVersion, alreadyUpToDate)` after attempting to install updates; providing this prevents the default installation toasts.
|
||||
* @returns Null (this component renders no visible UI; it manages global toast notifications).
|
||||
*/
|
||||
export function UpdateNotifier({ onUpdateAvailable, onUpdateInstalled }: UpdateNotifierProps = {}) {
|
||||
// Store state
|
||||
const { updateAvailable, remoteVersionShort, pullUpdates, isPulling } = useUpdatesStore();
|
||||
|
||||
const { autoUpdate } = useAppStore();
|
||||
|
||||
// Start polling
|
||||
useUpdatePolling();
|
||||
|
||||
// Track shown toasts to avoid duplicates
|
||||
const shownToastForCommitRef = useRef<string | null>(null);
|
||||
const toastIdRef = useRef<string | number | null>(null);
|
||||
|
||||
// Handle "Update Now" click
|
||||
const handleUpdateNow = useCallback(async () => {
|
||||
const result = await pullUpdates();
|
||||
|
||||
if (result) {
|
||||
// Dismiss the "update available" toast
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
toastIdRef.current = null;
|
||||
}
|
||||
|
||||
// Call custom handler if provided
|
||||
if (onUpdateInstalled) {
|
||||
onUpdateInstalled(result.newVersionShort, result.alreadyUpToDate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show appropriate toast based on result
|
||||
if (result.alreadyUpToDate) {
|
||||
toast.success('Already up to date!');
|
||||
} else {
|
||||
toast.success('Update installed!', {
|
||||
description: result.message,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Restart Now',
|
||||
onClick: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: 'Later',
|
||||
onClick: () => {
|
||||
// Just dismiss - user will restart manually later
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [pullUpdates, onUpdateInstalled]);
|
||||
|
||||
// Show toast when update becomes available
|
||||
useEffect(() => {
|
||||
if (!updateAvailable || !remoteVersionShort) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show toast if we've already shown it for this version
|
||||
if (shownToastForCommitRef.current === remoteVersionShort) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownToastForCommitRef.current = remoteVersionShort;
|
||||
|
||||
// Call custom handler if provided
|
||||
if (onUpdateAvailable) {
|
||||
onUpdateAvailable(remoteVersionShort);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss any existing toast
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
}
|
||||
|
||||
// Extract repo name for display
|
||||
const repoName = getRepoDisplayName(autoUpdate.upstreamUrl);
|
||||
|
||||
// Show persistent toast with update button
|
||||
toastIdRef.current = toast.info('Update Available', {
|
||||
description: `New version (${remoteVersionShort}) available from ${repoName}`,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: isPulling ? 'Updating...' : 'Update Now',
|
||||
onClick: handleUpdateNow,
|
||||
},
|
||||
cancel: {
|
||||
label: 'Later',
|
||||
onClick: () => {
|
||||
// Dismiss toast - won't show again for this version until a new version appears
|
||||
shownToastForCommitRef.current = remoteVersionShort;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [
|
||||
updateAvailable,
|
||||
remoteVersionShort,
|
||||
autoUpdate.upstreamUrl,
|
||||
isPulling,
|
||||
handleUpdateNow,
|
||||
onUpdateAvailable,
|
||||
]);
|
||||
|
||||
// Clean up toast on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset shown toast when update is no longer available
|
||||
useEffect(() => {
|
||||
if (!updateAvailable) {
|
||||
shownToastForCommitRef.current = null;
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
toastIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [updateAvailable]);
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@dnd-kit/core';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
} from './board-view/dialogs';
|
||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
||||
@@ -85,7 +87,10 @@ export function BoardView() {
|
||||
enableDependencyBlocking,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
setPipelineConfig,
|
||||
} = useAppStore();
|
||||
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
|
||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
features: hookFeatures,
|
||||
@@ -130,6 +135,9 @@ export function BoardView() {
|
||||
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
|
||||
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
|
||||
|
||||
// Pipeline settings dialog state
|
||||
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
|
||||
|
||||
// Follow-up state hook
|
||||
const {
|
||||
showFollowUpDialog,
|
||||
@@ -201,6 +209,25 @@ export function BoardView() {
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
// Load pipeline config when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
const loadPipelineConfig = async () => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.pipeline.getConfig(currentProject.path);
|
||||
if (result.success && result.config) {
|
||||
setPipelineConfig(currentProject.path, result.config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Board] Failed to load pipeline config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadPipelineConfig();
|
||||
}, [currentProject?.path, setPipelineConfig]);
|
||||
|
||||
// Auto mode hook
|
||||
const autoMode = useAutoMode();
|
||||
// Get runningTasks from the hook (scoped to current project)
|
||||
@@ -1094,6 +1121,10 @@ export function BoardView() {
|
||||
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
||||
suggestionsCount={suggestionsCount}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
pipelineConfig={
|
||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||
}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
/>
|
||||
) : (
|
||||
<GraphView
|
||||
@@ -1206,6 +1237,22 @@ export function BoardView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pipeline Settings Dialog */}
|
||||
<PipelineSettingsDialog
|
||||
open={showPipelineSettings}
|
||||
onClose={() => setShowPipelineSettings(false)}
|
||||
projectPath={currentProject.path}
|
||||
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
|
||||
onSave={async (config) => {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save pipeline config');
|
||||
}
|
||||
setPipelineConfig(currentProject.path, config);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Follow-Up Prompt Dialog */}
|
||||
<FollowUpDialog
|
||||
open={showFollowUpDialog}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { Feature } from '@/store/app-store';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
export type ColumnId = Feature['status'];
|
||||
|
||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||
export interface Column {
|
||||
id: FeatureStatusWithPipeline;
|
||||
title: string;
|
||||
colorClass: string;
|
||||
isPipelineStep?: boolean;
|
||||
pipelineStepId?: string;
|
||||
}
|
||||
|
||||
// Base columns (start)
|
||||
const BASE_COLUMNS: Column[] = [
|
||||
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
|
||||
{
|
||||
id: 'in_progress',
|
||||
title: 'In Progress',
|
||||
colorClass: 'bg-[var(--status-in-progress)]',
|
||||
},
|
||||
];
|
||||
|
||||
// End columns (after pipeline)
|
||||
const END_COLUMNS: Column[] = [
|
||||
{
|
||||
id: 'waiting_approval',
|
||||
title: 'Waiting Approval',
|
||||
@@ -20,3 +34,58 @@ export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||
colorClass: 'bg-[var(--status-success)]',
|
||||
},
|
||||
];
|
||||
|
||||
// Static COLUMNS for backwards compatibility (no pipeline)
|
||||
export const COLUMNS: Column[] = [...BASE_COLUMNS, ...END_COLUMNS];
|
||||
|
||||
/**
|
||||
* Generate columns including pipeline steps
|
||||
*/
|
||||
export function getColumnsWithPipeline(pipelineConfig: PipelineConfig | null): Column[] {
|
||||
const pipelineSteps = pipelineConfig?.steps || [];
|
||||
|
||||
if (pipelineSteps.length === 0) {
|
||||
return COLUMNS;
|
||||
}
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...pipelineSteps].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Convert pipeline steps to columns (filter out invalid steps)
|
||||
const pipelineColumns: Column[] = sortedSteps
|
||||
.filter((step) => step && step.id) // Only include valid steps with an id
|
||||
.map((step) => ({
|
||||
id: `pipeline_${step.id}` as FeatureStatusWithPipeline,
|
||||
title: step.name || 'Pipeline Step',
|
||||
colorClass: step.colorClass || 'bg-[var(--status-in-progress)]',
|
||||
isPipelineStep: true,
|
||||
pipelineStepId: step.id,
|
||||
}));
|
||||
|
||||
return [...BASE_COLUMNS, ...pipelineColumns, ...END_COLUMNS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index where pipeline columns should be inserted
|
||||
* (after in_progress, before waiting_approval)
|
||||
*/
|
||||
export function getPipelineInsertIndex(): number {
|
||||
return BASE_COLUMNS.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status is a pipeline status
|
||||
*/
|
||||
export function isPipelineStatus(status: string): boolean {
|
||||
return status.startsWith('pipeline_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract step ID from a pipeline status
|
||||
*/
|
||||
export function getStepIdFromStatus(status: string): string | null {
|
||||
if (!isPipelineStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
return status.replace('pipeline_', '');
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{dep.status.replace(/_/g, ' ')}
|
||||
{(dep.status || 'backlog').replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ export function DependencyTreeDialog({
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{dependent.status.replace(/_/g, ' ')}
|
||||
{(dependent.status || 'backlog').replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Color options for pipeline columns
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
|
||||
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
|
||||
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
|
||||
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
|
||||
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
|
||||
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
|
||||
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
|
||||
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
|
||||
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
|
||||
];
|
||||
|
||||
// Pre-built step templates with well-designed prompts
|
||||
const STEP_TEMPLATES = [
|
||||
{
|
||||
id: 'code-review',
|
||||
name: 'Code Review',
|
||||
colorClass: 'bg-blue-500/20',
|
||||
instructions: `## Code Review
|
||||
|
||||
Please perform a thorough code review of the changes made in this feature. Focus on:
|
||||
|
||||
### Code Quality
|
||||
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
|
||||
- **Maintainability**: Will this code be easy to modify in the future?
|
||||
- **DRY Principle**: Is there any duplicated code that should be abstracted?
|
||||
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
|
||||
|
||||
### Best Practices
|
||||
- Follow established patterns and conventions used in the codebase
|
||||
- Ensure proper error handling is in place
|
||||
- Check for appropriate logging where needed
|
||||
- Verify that magic numbers/strings are replaced with named constants
|
||||
|
||||
### Performance
|
||||
- Identify any potential performance bottlenecks
|
||||
- Check for unnecessary re-renders (React) or redundant computations
|
||||
- Ensure efficient data structures are used
|
||||
|
||||
### Testing
|
||||
- Verify that new code has appropriate test coverage
|
||||
- Check that edge cases are handled
|
||||
|
||||
### Action Required
|
||||
After reviewing, make any necessary improvements directly. If you find issues:
|
||||
1. Fix them immediately if they are straightforward
|
||||
2. For complex issues, document them clearly with suggested solutions
|
||||
|
||||
Provide a brief summary of changes made or issues found.`,
|
||||
},
|
||||
{
|
||||
id: 'security-review',
|
||||
name: 'Security Review',
|
||||
colorClass: 'bg-red-500/20',
|
||||
instructions: `## Security Review
|
||||
|
||||
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
|
||||
|
||||
### Input Validation & Sanitization
|
||||
- Verify all user inputs are properly validated and sanitized
|
||||
- Check for SQL injection vulnerabilities
|
||||
- Check for XSS (Cross-Site Scripting) vulnerabilities
|
||||
- Ensure proper encoding of output data
|
||||
|
||||
### Authentication & Authorization
|
||||
- Verify authentication checks are in place where needed
|
||||
- Ensure authorization logic correctly restricts access
|
||||
- Check for privilege escalation vulnerabilities
|
||||
- Verify session management is secure
|
||||
|
||||
### Data Protection
|
||||
- Ensure sensitive data is not logged or exposed
|
||||
- Check that secrets/credentials are not hardcoded
|
||||
- Verify proper encryption is used for sensitive data
|
||||
- Check for secure transmission of data (HTTPS, etc.)
|
||||
|
||||
### Common Vulnerabilities (OWASP Top 10)
|
||||
- Injection flaws
|
||||
- Broken authentication
|
||||
- Sensitive data exposure
|
||||
- XML External Entities (XXE)
|
||||
- Broken access control
|
||||
- Security misconfiguration
|
||||
- Cross-Site Scripting (XSS)
|
||||
- Insecure deserialization
|
||||
- Using components with known vulnerabilities
|
||||
- Insufficient logging & monitoring
|
||||
|
||||
### Action Required
|
||||
1. Fix any security vulnerabilities immediately
|
||||
2. For complex security issues, document them with severity levels
|
||||
3. Add security-related comments where appropriate
|
||||
|
||||
Provide a security assessment summary with any issues found and fixes applied.`,
|
||||
},
|
||||
{
|
||||
id: 'testing',
|
||||
name: 'Testing',
|
||||
colorClass: 'bg-green-500/20',
|
||||
instructions: `## Testing Step
|
||||
|
||||
Please ensure comprehensive test coverage for the changes made in this feature.
|
||||
|
||||
### Unit Tests
|
||||
- Write unit tests for all new functions and methods
|
||||
- Ensure edge cases are covered
|
||||
- Test error handling paths
|
||||
- Aim for high code coverage on new code
|
||||
|
||||
### Integration Tests
|
||||
- Test interactions between components/modules
|
||||
- Verify API endpoints work correctly
|
||||
- Test database operations if applicable
|
||||
|
||||
### Test Quality
|
||||
- Tests should be readable and well-documented
|
||||
- Each test should have a clear purpose
|
||||
- Use descriptive test names that explain the scenario
|
||||
- Follow the Arrange-Act-Assert pattern
|
||||
|
||||
### Run Tests
|
||||
After writing tests, run the full test suite and ensure:
|
||||
1. All new tests pass
|
||||
2. No existing tests are broken
|
||||
3. Test coverage meets project standards
|
||||
|
||||
Provide a summary of tests added and any issues found during testing.`,
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
name: 'Documentation',
|
||||
colorClass: 'bg-amber-500/20',
|
||||
instructions: `## Documentation Step
|
||||
|
||||
Please ensure all changes are properly documented.
|
||||
|
||||
### Code Documentation
|
||||
- Add/update JSDoc or docstrings for new functions and classes
|
||||
- Document complex algorithms or business logic
|
||||
- Add inline comments for non-obvious code
|
||||
|
||||
### API Documentation
|
||||
- Document any new or modified API endpoints
|
||||
- Include request/response examples
|
||||
- Document error responses
|
||||
|
||||
### README Updates
|
||||
- Update README if new setup steps are required
|
||||
- Document any new environment variables
|
||||
- Update architecture diagrams if applicable
|
||||
|
||||
### Changelog
|
||||
- Document notable changes for the changelog
|
||||
- Include breaking changes if any
|
||||
|
||||
Provide a summary of documentation added or updated.`,
|
||||
},
|
||||
{
|
||||
id: 'optimization',
|
||||
name: 'Performance Optimization',
|
||||
colorClass: 'bg-cyan-500/20',
|
||||
instructions: `## Performance Optimization Step
|
||||
|
||||
Review and optimize the performance of the changes made in this feature.
|
||||
|
||||
### Code Performance
|
||||
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
|
||||
- Remove unnecessary computations or redundant operations
|
||||
- Optimize loops and iterations
|
||||
- Use appropriate data structures
|
||||
|
||||
### Memory Usage
|
||||
- Check for memory leaks
|
||||
- Optimize memory-intensive operations
|
||||
- Ensure proper cleanup of resources
|
||||
|
||||
### Database/API
|
||||
- Optimize database queries (add indexes, reduce N+1 queries)
|
||||
- Implement caching where appropriate
|
||||
- Batch API calls when possible
|
||||
|
||||
### Frontend (if applicable)
|
||||
- Minimize bundle size
|
||||
- Optimize render performance
|
||||
- Implement lazy loading where appropriate
|
||||
- Use memoization for expensive computations
|
||||
|
||||
### Action Required
|
||||
1. Profile the code to identify bottlenecks
|
||||
2. Apply optimizations
|
||||
3. Measure improvements
|
||||
|
||||
Provide a summary of optimizations applied and performance improvements achieved.`,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper to get template color class
|
||||
const getTemplateColorClass = (templateId: string): string => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
return template?.colorClass || COLOR_OPTIONS[0].value;
|
||||
};
|
||||
|
||||
interface PipelineSettingsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string;
|
||||
pipelineConfig: PipelineConfig | null;
|
||||
onSave: (config: PipelineConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EditingStep {
|
||||
id?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function PipelineSettingsDialog({
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
pipelineConfig,
|
||||
onSave,
|
||||
}: PipelineSettingsDialogProps) {
|
||||
// Filter and validate steps to ensure all required properties exist
|
||||
const validateSteps = (steps: PipelineStep[] | undefined): PipelineStep[] => {
|
||||
if (!Array.isArray(steps)) return [];
|
||||
return steps.filter(
|
||||
(step): step is PipelineStep =>
|
||||
step != null &&
|
||||
typeof step.id === 'string' &&
|
||||
typeof step.name === 'string' &&
|
||||
typeof step.instructions === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
|
||||
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync steps when dialog opens or pipelineConfig changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSteps(validateSteps(pipelineConfig?.steps));
|
||||
}
|
||||
}, [open, pipelineConfig]);
|
||||
|
||||
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const handleAddStep = () => {
|
||||
setEditingStep({
|
||||
name: '',
|
||||
instructions: '',
|
||||
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
|
||||
order: steps.length,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditStep = (step: PipelineStep) => {
|
||||
setEditingStep({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
instructions: step.instructions,
|
||||
colorClass: step.colorClass,
|
||||
order: step.order,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteStep = (stepId: string) => {
|
||||
const newSteps = steps.filter((s) => s.id !== stepId);
|
||||
// Reorder remaining steps
|
||||
newSteps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
const handleMoveStep = (stepId: string, direction: 'up' | 'down') => {
|
||||
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
|
||||
if (
|
||||
(direction === 'up' && stepIndex === 0) ||
|
||||
(direction === 'down' && stepIndex === sortedSteps.length - 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSteps = [...sortedSteps];
|
||||
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
|
||||
|
||||
// Swap orders
|
||||
const temp = newSteps[stepIndex].order;
|
||||
newSteps[stepIndex].order = newSteps[targetIndex].order;
|
||||
newSteps[targetIndex].order = temp;
|
||||
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
const handleFileUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
|
||||
toast.success('Instructions loaded from file');
|
||||
} catch (error) {
|
||||
toast.error('Failed to load file');
|
||||
}
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStep = () => {
|
||||
if (!editingStep) return;
|
||||
|
||||
if (!editingStep.name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingStep.instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (editingStep.id) {
|
||||
// Update existing step
|
||||
setSteps((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === editingStep.id
|
||||
? {
|
||||
...s,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Add new step
|
||||
const newStep: PipelineStep = {
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: steps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSteps((prev) => [...prev, newStep]);
|
||||
}
|
||||
|
||||
setEditingStep(null);
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// If the user is currently editing a step and clicks "Save Configuration",
|
||||
// include that step in the config (common expectation) instead of silently dropping it.
|
||||
let effectiveSteps = steps;
|
||||
if (editingStep) {
|
||||
if (!editingStep.name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingStep.instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (editingStep.id) {
|
||||
// Update existing (or add if missing for some reason)
|
||||
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
|
||||
if (existingIdx >= 0) {
|
||||
effectiveSteps = effectiveSteps.map((s) =>
|
||||
s.id === editingStep.id
|
||||
? {
|
||||
...s,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
);
|
||||
} else {
|
||||
effectiveSteps = [
|
||||
...effectiveSteps,
|
||||
{
|
||||
id: editingStep.id,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: effectiveSteps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Add new step
|
||||
effectiveSteps = [
|
||||
...effectiveSteps,
|
||||
{
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: effectiveSteps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Keep local UI state consistent with what we are saving.
|
||||
setSteps(effectiveSteps);
|
||||
setEditingStep(null);
|
||||
}
|
||||
|
||||
const sortedEffectiveSteps = [...effectiveSteps].sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0)
|
||||
);
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
|
||||
};
|
||||
await onSave(config);
|
||||
toast.success('Pipeline configuration saved');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error('Failed to save pipeline configuration');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Hidden file input for loading instructions from .md files */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pipeline Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
||||
step will automatically prompt the agent with its instructions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||
{/* Steps List */}
|
||||
{sortedSteps.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'down')}
|
||||
disabled={index === sortedSteps.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{(step.instructions || '').substring(0, 100)}
|
||||
{(step.instructions || '').length > 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEditStep(step)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No pipeline steps configured.</p>
|
||||
<p className="text-sm">
|
||||
Add steps to create a custom workflow after features complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Step Button */}
|
||||
{!editingStep && (
|
||||
<Button variant="outline" className="w-full" onClick={handleAddStep}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Pipeline Step
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit/Add Step Form */}
|
||||
{editingStep && (
|
||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingStep(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Template Selector - only show for new steps */}
|
||||
{!editingStep.id && (
|
||||
<div className="space-y-2">
|
||||
<Label>Start from Template</Label>
|
||||
<Select
|
||||
onValueChange={(templateId) => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setEditingStep((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: template.name,
|
||||
instructions: template.instructions,
|
||||
colorClass: template.colorClass,
|
||||
}
|
||||
: null
|
||||
);
|
||||
toast.success(`Loaded "${template.name}" template`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose a template (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TEMPLATES.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
template.colorClass.replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
{template.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a pre-built template to populate the form, or create your own from
|
||||
scratch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-name">Step Name</Label>
|
||||
<Input
|
||||
id="step-name"
|
||||
placeholder="e.g., Code Review, Testing, Documentation"
|
||||
value={editingStep.name}
|
||||
onChange={(e) =>
|
||||
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
color.preview,
|
||||
editingStep.colorClass === color.value
|
||||
? 'ring-2 ring-offset-2 ring-primary'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
onClick={() =>
|
||||
setEditingStep((prev) =>
|
||||
prev ? { ...prev, colorClass: color.value } : null
|
||||
)
|
||||
}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="step-instructions">Agent Instructions</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleFileUpload}
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
Load from .md file
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="step-instructions"
|
||||
placeholder="Instructions for the agent to follow during this pipeline step..."
|
||||
value={editingStep.instructions}
|
||||
onChange={(e) =>
|
||||
setEditingStep((prev) =>
|
||||
prev ? { ...prev, instructions: e.target.value } : null
|
||||
)
|
||||
}
|
||||
rows={6}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setEditingStep(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveStep}>
|
||||
{editingStep.id ? 'Update Step' : 'Add Step'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? 'Saving...'
|
||||
: editingStep
|
||||
? 'Save Step & Configuration'
|
||||
: 'Save Configuration'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export function useBoardColumnFeatures({
|
||||
}: UseBoardColumnFeaturesProps) {
|
||||
// Memoize column features to prevent unnecessary re-renders
|
||||
const columnFeaturesMap = useMemo(() => {
|
||||
const map: Record<ColumnId, Feature[]> = {
|
||||
// Use a more flexible type to support dynamic pipeline statuses
|
||||
const map: Record<string, Feature[]> = {
|
||||
backlog: [],
|
||||
in_progress: [],
|
||||
waiting_approval: [],
|
||||
@@ -76,31 +77,56 @@ export function useBoardColumnFeatures({
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
}
|
||||
|
||||
// Use the feature's status (fallback to backlog for unknown statuses)
|
||||
const status = f.status || 'backlog';
|
||||
|
||||
// IMPORTANT:
|
||||
// Historically, we forced "running" features into in_progress so they never disappeared
|
||||
// during stale reload windows. With pipelines, a feature can legitimately be running while
|
||||
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
|
||||
if (isRunning) {
|
||||
// Only show running tasks if they match the current worktree
|
||||
if (matchesWorktree) {
|
||||
if (!matchesWorktree) return;
|
||||
|
||||
if (status.startsWith('pipeline_')) {
|
||||
if (!map[status]) map[status] = [];
|
||||
map[status].push(f);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's running and has a known non-backlog status, keep it in that status.
|
||||
// Otherwise, fallback to in_progress as the "active work" column.
|
||||
if (status !== 'backlog' && map[status]) {
|
||||
map[status].push(f);
|
||||
} else {
|
||||
map.in_progress.push(f);
|
||||
}
|
||||
} else {
|
||||
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
||||
const status = f.status as ColumnId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter all items by worktree, including backlog
|
||||
// This ensures backlog items with a branch assigned only show in that branch
|
||||
if (status === 'backlog') {
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
} else if (map[status]) {
|
||||
// Only show if matches current worktree or has no worktree assigned
|
||||
if (matchesWorktree) {
|
||||
map[status].push(f);
|
||||
}
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
// Not running: place by status (and worktree filter)
|
||||
// Filter all items by worktree, including backlog
|
||||
// This ensures backlog items with a branch assigned only show in that branch
|
||||
if (status === 'backlog') {
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
} else if (map[status]) {
|
||||
// Only show if matches current worktree or has no worktree assigned
|
||||
if (matchesWorktree) {
|
||||
map[status].push(f);
|
||||
}
|
||||
} else if (status.startsWith('pipeline_')) {
|
||||
// Handle pipeline statuses - initialize array if needed
|
||||
if (matchesWorktree) {
|
||||
if (!map[status]) {
|
||||
map[status] = [];
|
||||
}
|
||||
map[status].push(f);
|
||||
}
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -147,7 +173,7 @@ export function useBoardColumnFeatures({
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
(columnId: ColumnId) => {
|
||||
return columnFeaturesMap[columnId];
|
||||
return columnFeaturesMap[columnId] || [];
|
||||
},
|
||||
[columnFeaturesMap]
|
||||
);
|
||||
|
||||
@@ -203,6 +203,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
// This ensures the feature card shows the "Approve Plan" button
|
||||
console.log('[Board] Plan approval required, reloading features...');
|
||||
loadFeatures();
|
||||
} else if (event.type === 'pipeline_step_started') {
|
||||
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
|
||||
// Reload so the card moves into the correct pipeline column immediately.
|
||||
console.log('[Board] Pipeline step started, reloading features...');
|
||||
loadFeatures();
|
||||
} else if (event.type === 'auto_mode_error') {
|
||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
||||
console.log('[Board] Feature error, reloading features...', event.error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
@@ -5,10 +6,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { KanbanColumn, KanbanCard } from './components';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { FastForward, Lightbulb, Archive } from 'lucide-react';
|
||||
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
import { COLUMNS, ColumnId } from './constants';
|
||||
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
@@ -49,6 +51,8 @@ interface KanbanBoardProps {
|
||||
onShowSuggestions: () => void;
|
||||
suggestionsCount: number;
|
||||
onArchiveAllVerified: () => void;
|
||||
pipelineConfig: PipelineConfig | null;
|
||||
onOpenPipelineSettings?: () => void;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -82,13 +86,18 @@ export function KanbanBoard({
|
||||
onShowSuggestions,
|
||||
suggestionsCount,
|
||||
onArchiveAllVerified,
|
||||
pipelineConfig,
|
||||
onOpenPipelineSettings,
|
||||
}: KanbanBoardProps) {
|
||||
// Generate columns including pipeline steps
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
|
||||
// Use responsive column widths based on window size
|
||||
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
@@ -96,8 +105,8 @@ export function KanbanBoard({
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
@@ -156,6 +165,28 @@ export function KanbanBoard({
|
||||
</HotkeyButton>
|
||||
)}
|
||||
</div>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Pipeline Settings"
|
||||
data-testid="pipeline-settings-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : column.isPipelineStep ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Edit Pipeline Step"
|
||||
data-testid="edit-pipeline-step-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -178,6 +178,13 @@ export function ContextView() {
|
||||
// Ensure context directory exists
|
||||
await api.mkdir(contextPath);
|
||||
|
||||
// Ensure metadata file exists (create empty one if not)
|
||||
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||
const metadataExists = await api.exists(metadataPath);
|
||||
if (!metadataExists) {
|
||||
await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2));
|
||||
}
|
||||
|
||||
// Load metadata for descriptions
|
||||
const metadata = await loadMetadata();
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ const priorityConfig = {
|
||||
};
|
||||
|
||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||
const config = statusConfig[data.status] || statusConfig.backlog;
|
||||
// Handle pipeline statuses by treating them like in_progress
|
||||
const status = data.status || 'backlog';
|
||||
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
|
||||
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
|
||||
const StatusIcon = config.icon;
|
||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||
|
||||
|
||||
@@ -17,18 +17,11 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
|
||||
import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { UpdatesSection } from './settings-view/updates/updates-section';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
|
||||
/**
|
||||
* Render the application settings view, including navigation, section panels, theme and project preferences, CLI status, and global dialogs.
|
||||
*
|
||||
* Renders the appropriate settings subsection based on the active view, wires UI controls to the app store for reading and updating settings (theme, defaults, features, updates, audio, keyboard, API keys, Claude/CLI settings, etc.), and manages visibility of the keyboard map and delete-project dialogs.
|
||||
*
|
||||
* @returns The settings view JSX element
|
||||
*/
|
||||
export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
@@ -60,8 +53,6 @@ export function SettingsView() {
|
||||
setAutoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
setEnableSandboxMode,
|
||||
autoUpdate,
|
||||
setAutoUpdate,
|
||||
} = useAppStore();
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
@@ -126,6 +117,8 @@ export function SettingsView() {
|
||||
{showUsageTracking && <ClaudeUsageSection />}
|
||||
</div>
|
||||
);
|
||||
case 'mcp-servers':
|
||||
return <MCPServersSection />;
|
||||
case 'ai-enhancement':
|
||||
return <AIEnhancementSection />;
|
||||
case 'appearance':
|
||||
@@ -168,8 +161,6 @@ export function SettingsView() {
|
||||
onValidationModelChange={setValidationModel}
|
||||
/>
|
||||
);
|
||||
case 'updates':
|
||||
return <UpdatesSection autoUpdate={autoUpdate} onAutoUpdateChange={setAutoUpdate} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
@@ -215,4 +206,4 @@ export function SettingsView() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Plug,
|
||||
} from 'lucide-react';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
|
||||
@@ -22,6 +23,7 @@ export interface NavigationItem {
|
||||
export const NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||
{ id: 'claude', label: 'Claude', icon: Terminal },
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useCallback } from 'react';
|
||||
export type SettingsViewId =
|
||||
| 'api-keys'
|
||||
| 'claude'
|
||||
| 'mcp-servers'
|
||||
| 'ai-enhancement'
|
||||
| 'appearance'
|
||||
| 'terminal'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MCPServerHeader } from './mcp-server-header';
|
||||
export { MCPPermissionSettings } from './mcp-permission-settings';
|
||||
export { MCPToolsWarning } from './mcp-tools-warning';
|
||||
export { MCPServerCard } from './mcp-server-card';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { syncSettingsToServer } from '@/hooks/use-settings-migration';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MCPPermissionSettingsProps {
|
||||
mcpAutoApproveTools: boolean;
|
||||
mcpUnrestrictedTools: boolean;
|
||||
onAutoApproveChange: (checked: boolean) => void;
|
||||
onUnrestrictedChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function MCPPermissionSettings({
|
||||
mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools,
|
||||
onAutoApproveChange,
|
||||
onUnrestrictedChange,
|
||||
}: MCPPermissionSettingsProps) {
|
||||
const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
id="mcp-auto-approve"
|
||||
checked={mcpAutoApproveTools}
|
||||
onCheckedChange={async (checked) => {
|
||||
onAutoApproveChange(checked);
|
||||
await syncSettingsToServer();
|
||||
}}
|
||||
data-testid="mcp-auto-approve-toggle"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
|
||||
Auto-approve MCP tool calls
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the AI agent can use MCP tools without permission prompts.
|
||||
</p>
|
||||
{mcpAutoApproveTools && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Bypasses normal permission checks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
id="mcp-unrestricted"
|
||||
checked={mcpUnrestrictedTools}
|
||||
onCheckedChange={async (checked) => {
|
||||
onUnrestrictedChange(checked);
|
||||
await syncSettingsToServer();
|
||||
}}
|
||||
data-testid="mcp-unrestricted-toggle"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
|
||||
Unrestricted tool access
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the AI agent can use any tool, not just the default set.
|
||||
</p>
|
||||
{mcpUnrestrictedTools && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Agent has full tool access including file writes and bash
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasAnyEnabled && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
|
||||
'text-xs text-amber-700 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
<p className="font-medium mb-1">Security Note</p>
|
||||
<p>
|
||||
These settings reduce security restrictions for MCP tool usage. Only enable if you
|
||||
trust all configured MCP servers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MCPServerConfig } from '@automaker/types';
|
||||
import type { ServerTestState } from '../types';
|
||||
import { getServerIcon, getTestStatusIcon, maskSensitiveUrl } from '../utils';
|
||||
import { MCPToolsList } from '../mcp-tools-list';
|
||||
|
||||
interface MCPServerCardProps {
|
||||
server: MCPServerConfig;
|
||||
testState?: ServerTestState;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onTest: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onEditJson: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function MCPServerCard({
|
||||
server,
|
||||
testState,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
onTest,
|
||||
onToggleEnabled,
|
||||
onEditJson,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: MCPServerCardProps) {
|
||||
const Icon = getServerIcon(server.type);
|
||||
const hasTools = testState?.tools && testState.tools.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border',
|
||||
server.enabled !== false
|
||||
? 'border-border/50 bg-accent/20'
|
||||
: 'border-border/30 bg-muted/30 opacity-60'
|
||||
)}
|
||||
data-testid={`mcp-server-${server.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-3 text-left min-w-0 flex-1',
|
||||
hasTools && 'cursor-pointer hover:opacity-80'
|
||||
)}
|
||||
disabled={!hasTools}
|
||||
>
|
||||
{hasTools ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">{server.name}</span>
|
||||
{testState && getTestStatusIcon(testState.status)}
|
||||
{testState?.status === 'success' && testState.tools && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{testState.tools.length} tool{testState.tools.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{server.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{server.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground/60 mt-0.5 truncate">
|
||||
{server.type === 'stdio'
|
||||
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
|
||||
: maskSensitiveUrl(server.url || '')}
|
||||
</div>
|
||||
{testState?.status === 'error' && testState.error && (
|
||||
<div className="text-xs text-destructive mt-1 line-clamp-2 break-words">
|
||||
{testState.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={testState?.status === 'testing' || server.enabled === false}
|
||||
data-testid={`mcp-server-test-${server.id}`}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
{testState?.status === 'testing' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1.5 text-xs">Test</span>
|
||||
</Button>
|
||||
<Switch
|
||||
checked={server.enabled !== false}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
data-testid={`mcp-server-toggle-${server.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onEditJson}
|
||||
title="Edit JSON"
|
||||
data-testid={`mcp-server-json-${server.id}`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onEdit}
|
||||
data-testid={`mcp-server-edit-${server.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
data-testid={`mcp-server-delete-${server.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{hasTools && (
|
||||
<CollapsibleContent>
|
||||
<div className="px-4 pb-4 pt-0 ml-7 overflow-hidden">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Available Tools</div>
|
||||
<MCPToolsList
|
||||
tools={testState.tools!}
|
||||
isLoading={testState.status === 'testing'}
|
||||
error={testState.error}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MCPServerHeaderProps {
|
||||
isRefreshing: boolean;
|
||||
hasServers: boolean;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onEditAllJson: () => void;
|
||||
onImport: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function MCPServerHeader({
|
||||
isRefreshing,
|
||||
hasServers,
|
||||
onRefresh,
|
||||
onExport,
|
||||
onEditAllJson,
|
||||
onImport,
|
||||
onAdd,
|
||||
}: MCPServerHeaderProps) {
|
||||
return (
|
||||
<div className="p-6 border-b border-border/50 bg-linear-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Plug className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">MCP Servers</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure Model Context Protocol servers to extend agent capabilities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
data-testid="refresh-mcp-servers-button"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
{hasServers && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onExport}
|
||||
data-testid="export-mcp-servers-button"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEditAllJson}
|
||||
data-testid="edit-all-json-button"
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Edit JSON
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onImport}
|
||||
data-testid="import-mcp-servers-button"
|
||||
>
|
||||
<FileJson className="w-4 h-4 mr-2" />
|
||||
Import JSON
|
||||
</Button>
|
||||
<Button size="sm" onClick={onAdd} data-testid="add-mcp-server-button">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||
|
||||
interface MCPToolsWarningProps {
|
||||
totalTools: number;
|
||||
}
|
||||
|
||||
export function MCPToolsWarning({ totalTools }: MCPToolsWarningProps) {
|
||||
return (
|
||||
<div className="mx-6 mt-4 p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
High tool count detected ({totalTools} tools)
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Having more than {MAX_RECOMMENDED_TOOLS} MCP tools may degrade AI model performance.
|
||||
Consider disabling unused servers or removing unnecessary tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Patterns that indicate sensitive values in URLs or config
|
||||
export const SENSITIVE_PARAM_PATTERNS = [
|
||||
/api[-_]?key/i,
|
||||
/api[-_]?token/i,
|
||||
/auth/i,
|
||||
/token/i,
|
||||
/secret/i,
|
||||
/password/i,
|
||||
/credential/i,
|
||||
/bearer/i,
|
||||
];
|
||||
|
||||
// Maximum recommended MCP tools before performance degradation
|
||||
export const MAX_RECOMMENDED_TOOLS = 80;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { MCPServerConfig } from '@automaker/types';
|
||||
import type { ServerFormData, ServerType } from '../types';
|
||||
|
||||
interface AddEditServerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingServer: MCPServerConfig | null;
|
||||
formData: ServerFormData;
|
||||
onFormDataChange: (data: ServerFormData) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AddEditServerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingServer,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: AddEditServerDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="mcp-server-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure an MCP server to extend agent capabilities with custom tools.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
|
||||
placeholder="my-mcp-server"
|
||||
data-testid="mcp-server-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-description">Description (optional)</Label>
|
||||
<Input
|
||||
id="server-description"
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange({ ...formData, description: e.target.value })}
|
||||
placeholder="What this server provides..."
|
||||
data-testid="mcp-server-description-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-type">Transport Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value: ServerType) => onFormDataChange({ ...formData, type: value })}
|
||||
>
|
||||
<SelectTrigger id="server-type" data-testid="mcp-server-type-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">Stdio (subprocess)</SelectItem>
|
||||
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{formData.type === 'stdio' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-command">Command</Label>
|
||||
<Input
|
||||
id="server-command"
|
||||
value={formData.command}
|
||||
onChange={(e) => onFormDataChange({ ...formData, command: e.target.value })}
|
||||
placeholder="npx, node, python, etc."
|
||||
data-testid="mcp-server-command-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-args">Arguments (space-separated)</Label>
|
||||
<Input
|
||||
id="server-args"
|
||||
value={formData.args}
|
||||
onChange={(e) => onFormDataChange({ ...formData, args: e.target.value })}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem"
|
||||
data-testid="mcp-server-args-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-env">Environment Variables (JSON, optional)</Label>
|
||||
<Textarea
|
||||
id="server-env"
|
||||
value={formData.env}
|
||||
onChange={(e) => onFormDataChange({ ...formData, env: e.target.value })}
|
||||
placeholder={'{\n "API_KEY": "your-key"\n}'}
|
||||
className="font-mono text-sm h-24"
|
||||
data-testid="mcp-server-env-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
value={formData.url}
|
||||
onChange={(e) => onFormDataChange({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com/mcp"
|
||||
data-testid="mcp-server-url-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-headers">Headers (JSON, optional)</Label>
|
||||
<Textarea
|
||||
id="server-headers"
|
||||
value={formData.headers}
|
||||
onChange={(e) => onFormDataChange({ ...formData, headers: e.target.value })}
|
||||
placeholder={
|
||||
'{\n "x-api-key": "your-api-key",\n "Authorization": "Bearer token"\n}'
|
||||
}
|
||||
className="font-mono text-sm h-24"
|
||||
data-testid="mcp-server-headers-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSave} data-testid="mcp-server-save-button">
|
||||
{editingServer ? 'Save Changes' : 'Add Server'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface DeleteServerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteServerDialog({ open, onOpenChange, onConfirm }: DeleteServerDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="mcp-server-delete-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this MCP server? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
data-testid="mcp-server-confirm-delete-button"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Code } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface GlobalJsonEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
jsonValue: string;
|
||||
onJsonValueChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function GlobalJsonEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
jsonValue,
|
||||
onJsonValueChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: GlobalJsonEditDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onCancel();
|
||||
} else {
|
||||
onOpenChange(open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh]" data-testid="mcp-global-json-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit All MCP Servers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit the full MCP servers configuration. Add, modify, or remove servers directly in
|
||||
JSON. Servers removed from JSON will be deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
value={jsonValue}
|
||||
onChange={(e) => onJsonValueChange(e.target.value)}
|
||||
placeholder={`{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-name"]
|
||||
}
|
||||
}
|
||||
}`}
|
||||
className="font-mono text-sm h-[50vh] min-h-[300px]"
|
||||
data-testid="mcp-global-json-edit-textarea"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={!jsonValue.trim()}
|
||||
data-testid="mcp-global-json-edit-save-button"
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Save All
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { FileJson } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface ImportJsonDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
importJson: string;
|
||||
onImportJsonChange: (value: string) => void;
|
||||
onImport: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportJsonDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
importJson,
|
||||
onImportJsonChange,
|
||||
onImport,
|
||||
onCancel,
|
||||
}: ImportJsonDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl" data-testid="mcp-import-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import MCP Servers</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste JSON configuration in Claude Code format. Servers with duplicate names will be
|
||||
skipped.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
value={importJson}
|
||||
onChange={(e) => onImportJsonChange(e.target.value)}
|
||||
placeholder={`{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-name"],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
className="font-mono text-sm h-64"
|
||||
data-testid="mcp-import-textarea"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onImport} disabled={!importJson.trim()} data-testid="mcp-import-button">
|
||||
<FileJson className="w-4 h-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { AddEditServerDialog } from './add-edit-server-dialog';
|
||||
export { DeleteServerDialog } from './delete-server-dialog';
|
||||
export { ImportJsonDialog } from './import-json-dialog';
|
||||
export { JsonEditDialog } from './json-edit-dialog';
|
||||
export { GlobalJsonEditDialog } from './global-json-edit-dialog';
|
||||
export { SecurityWarningDialog } from './security-warning-dialog';
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Code } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { MCPServerConfig } from '@automaker/types';
|
||||
|
||||
interface JsonEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
server: MCPServerConfig | null;
|
||||
jsonValue: string;
|
||||
onJsonValueChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function JsonEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
server,
|
||||
jsonValue,
|
||||
onJsonValueChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: JsonEditDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onCancel();
|
||||
} else {
|
||||
onOpenChange(open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl" data-testid="mcp-json-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Server Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit the raw JSON configuration for "{server?.name}". Changes will be validated before
|
||||
saving.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
value={jsonValue}
|
||||
onChange={(e) => onJsonValueChange(e.target.value)}
|
||||
placeholder="Server configuration JSON..."
|
||||
className="font-mono text-sm h-80"
|
||||
data-testid="mcp-json-edit-textarea"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={!jsonValue.trim()}
|
||||
data-testid="mcp-json-edit-save-button"
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Save JSON
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ShieldAlert, Terminal, Globe } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface SecurityWarningDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
serverType: 'stdio' | 'sse' | 'http';
|
||||
serverName: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
/** Number of servers being imported (for import dialog) */
|
||||
importCount?: number;
|
||||
}
|
||||
|
||||
export function SecurityWarningDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
serverType,
|
||||
serverName,
|
||||
command,
|
||||
args,
|
||||
url,
|
||||
importCount,
|
||||
}: SecurityWarningDialogProps) {
|
||||
const isImport = importCount !== undefined && importCount > 0;
|
||||
const isStdio = serverType === 'stdio';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg" data-testid="mcp-security-warning-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||
Security Warning
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
{isImport
|
||||
? `You are about to import ${importCount} MCP server${importCount > 1 ? 's' : ''}.`
|
||||
: 'MCP servers can execute code on your machine.'}
|
||||
</p>
|
||||
|
||||
{!isImport && isStdio && command && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Terminal className="h-4 w-4 text-destructive" />
|
||||
This server will run:
|
||||
</div>
|
||||
<code className="mt-1 block break-all text-sm text-muted-foreground">
|
||||
{command} {args?.join(' ')}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isImport && !isStdio && url && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Globe className="h-4 w-4 text-amber-500" />
|
||||
This server will connect to:
|
||||
</div>
|
||||
<code className="mt-1 block break-all text-sm text-muted-foreground">{url}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImport && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-sm text-foreground">
|
||||
Each imported server can execute arbitrary commands or connect to external
|
||||
services. Review the JSON carefully before importing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
|
||||
<li>Only add servers from sources you trust</li>
|
||||
{isStdio && <li>Stdio servers run with your user privileges</li>}
|
||||
{!isStdio && <li>HTTP/SSE servers can access network resources</li>}
|
||||
<li>Review the {isStdio ? 'command' : 'URL'} before confirming</li>
|
||||
</ul>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} data-testid="mcp-security-confirm-button">
|
||||
I understand, {isImport ? 'import' : 'add'} server
|
||||
{isImport && importCount! > 1 ? 's' : ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useMCPServers } from './use-mcp-servers';
|
||||
@@ -0,0 +1,759 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import type { MCPServerConfig } from '@automaker/types';
|
||||
import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { ServerFormData, ServerTestState } from '../types';
|
||||
import { defaultFormData } from '../types';
|
||||
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||
import type { ServerType } from '../types';
|
||||
|
||||
/** Pending server data waiting for security confirmation */
|
||||
interface PendingServerData {
|
||||
type: 'add' | 'import';
|
||||
serverData?: Omit<MCPServerConfig, 'id'>;
|
||||
importServers?: Array<Omit<MCPServerConfig, 'id'>>;
|
||||
serverType: ServerType;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function useMCPServers() {
|
||||
const {
|
||||
mcpServers,
|
||||
addMCPServer,
|
||||
updateMCPServer,
|
||||
removeMCPServer,
|
||||
mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools,
|
||||
setMcpAutoApproveTools,
|
||||
setMcpUnrestrictedTools,
|
||||
} = useAppStore();
|
||||
|
||||
// State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(null);
|
||||
const [formData, setFormData] = useState<ServerFormData>(defaultFormData);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importJson, setImportJson] = useState('');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [serverTestStates, setServerTestStates] = useState<Record<string, ServerTestState>>({});
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
const [jsonEditServer, setJsonEditServer] = useState<MCPServerConfig | null>(null);
|
||||
const [jsonEditValue, setJsonEditValue] = useState('');
|
||||
const [isGlobalJsonEditOpen, setIsGlobalJsonEditOpen] = useState(false);
|
||||
const [globalJsonValue, setGlobalJsonValue] = useState('');
|
||||
const autoTestedServersRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Security warning dialog state
|
||||
const [isSecurityWarningOpen, setIsSecurityWarningOpen] = useState(false);
|
||||
const [pendingServerData, setPendingServerData] = useState<PendingServerData | null>(null);
|
||||
|
||||
// Computed values
|
||||
const totalToolsCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const server of mcpServers) {
|
||||
if (server.enabled !== false) {
|
||||
const testState = serverTestStates[server.id];
|
||||
if (testState?.status === 'success' && testState.tools) {
|
||||
count += testState.tools.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [mcpServers, serverTestStates]);
|
||||
|
||||
const showToolsWarning = totalToolsCount > MAX_RECOMMENDED_TOOLS;
|
||||
|
||||
// Auto-load MCP servers from settings file on mount
|
||||
useEffect(() => {
|
||||
loadMCPServersFromServer().catch((error) => {
|
||||
console.error('Failed to load MCP servers on mount:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Test a single server (extracted for reuse)
|
||||
const testServer = useCallback(async (server: MCPServerConfig, silent = false) => {
|
||||
setServerTestStates((prev) => ({
|
||||
...prev,
|
||||
[server.id]: { status: 'testing' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.mcp.testServer(server.id);
|
||||
|
||||
if (result.success) {
|
||||
setServerTestStates((prev) => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
status: 'success',
|
||||
tools: result.tools,
|
||||
connectionTime: result.connectionTime,
|
||||
},
|
||||
}));
|
||||
// Only auto-expand on manual test, not on auto-test (silent)
|
||||
if (!silent) {
|
||||
setExpandedServers((prev) => new Set([...prev, server.id]));
|
||||
toast.success(
|
||||
`Connected to ${server.name} (${result.tools?.length || 0} tools, ${result.connectionTime}ms)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setServerTestStates((prev) => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
status: 'error',
|
||||
error: result.error,
|
||||
connectionTime: result.connectionTime,
|
||||
},
|
||||
}));
|
||||
if (!silent) {
|
||||
toast.error(`Failed to connect: ${result.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setServerTestStates((prev) => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
},
|
||||
}));
|
||||
if (!silent) {
|
||||
toast.error(`Test failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-test all enabled servers on mount
|
||||
useEffect(() => {
|
||||
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||
const serversToTest = enabledServers.filter((s) => !autoTestedServersRef.current.has(s.id));
|
||||
|
||||
if (serversToTest.length > 0) {
|
||||
// Mark all as being tested
|
||||
serversToTest.forEach((s) => autoTestedServersRef.current.add(s.id));
|
||||
|
||||
// Test all servers in parallel (silently - no toast spam)
|
||||
serversToTest.forEach((server) => {
|
||||
testServer(server, true);
|
||||
});
|
||||
}
|
||||
}, [mcpServers, testServer]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const success = await loadMCPServersFromServer();
|
||||
if (success) {
|
||||
toast.success('MCP servers refreshed from settings');
|
||||
} else {
|
||||
toast.error('Failed to refresh MCP servers');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error refreshing MCP servers');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestServer = (server: MCPServerConfig) => {
|
||||
testServer(server, false); // false = show toast notifications
|
||||
};
|
||||
|
||||
const toggleServerExpanded = (serverId: string) => {
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serverId)) {
|
||||
next.delete(serverId);
|
||||
} else {
|
||||
next.add(serverId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
setFormData(defaultFormData);
|
||||
setEditingServer(null);
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditDialog = (server: MCPServerConfig) => {
|
||||
setFormData({
|
||||
name: server.name,
|
||||
description: server.description || '',
|
||||
type: server.type || 'stdio',
|
||||
command: server.command || '',
|
||||
args: server.args?.join(' ') || '',
|
||||
url: server.url || '',
|
||||
headers: server.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||
env: server.env ? JSON.stringify(server.env, null, 2) : '',
|
||||
});
|
||||
setEditingServer(server);
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setEditingServer(null);
|
||||
setFormData(defaultFormData);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Server name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.type === 'stdio' && !formData.command.trim()) {
|
||||
toast.error('Command is required for stdio servers');
|
||||
return;
|
||||
}
|
||||
|
||||
if ((formData.type === 'sse' || formData.type === 'http') && !formData.url.trim()) {
|
||||
toast.error('URL is required for SSE/HTTP servers');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse headers if provided
|
||||
let parsedHeaders: Record<string, string> | undefined;
|
||||
if (formData.headers.trim()) {
|
||||
try {
|
||||
parsedHeaders = JSON.parse(formData.headers.trim());
|
||||
if (typeof parsedHeaders !== 'object' || Array.isArray(parsedHeaders)) {
|
||||
toast.error('Headers must be a JSON object');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
toast.error('Invalid JSON for headers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse env if provided
|
||||
let parsedEnv: Record<string, string> | undefined;
|
||||
if (formData.env.trim()) {
|
||||
try {
|
||||
parsedEnv = JSON.parse(formData.env.trim());
|
||||
if (typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) {
|
||||
toast.error('Environment variables must be a JSON object');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
toast.error('Invalid JSON for environment variables');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
type: formData.type,
|
||||
enabled: editingServer?.enabled ?? true,
|
||||
};
|
||||
|
||||
if (formData.type === 'stdio') {
|
||||
serverData.command = formData.command.trim();
|
||||
if (formData.args.trim()) {
|
||||
serverData.args = formData.args.trim().split(/\s+/);
|
||||
}
|
||||
if (parsedEnv) {
|
||||
serverData.env = parsedEnv;
|
||||
}
|
||||
} else {
|
||||
serverData.url = formData.url.trim();
|
||||
if (parsedHeaders) {
|
||||
serverData.headers = parsedHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
// If editing an existing server, save directly (user already approved it)
|
||||
if (editingServer) {
|
||||
updateMCPServer(editingServer.id, serverData);
|
||||
toast.success('MCP server updated');
|
||||
await syncSettingsToServer();
|
||||
handleCloseDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// For new servers, show security warning first
|
||||
setPendingServerData({
|
||||
type: 'add',
|
||||
serverData,
|
||||
serverType: formData.type,
|
||||
command: formData.type === 'stdio' ? formData.command.trim() : undefined,
|
||||
args:
|
||||
formData.type === 'stdio' && formData.args.trim()
|
||||
? formData.args.trim().split(/\s+/)
|
||||
: undefined,
|
||||
url: formData.type !== 'stdio' ? formData.url.trim() : undefined,
|
||||
});
|
||||
setIsSecurityWarningOpen(true);
|
||||
};
|
||||
|
||||
/** Called when user confirms the security warning for adding a server */
|
||||
const handleSecurityWarningConfirm = async () => {
|
||||
if (!pendingServerData) return;
|
||||
|
||||
if (pendingServerData.type === 'add' && pendingServerData.serverData) {
|
||||
addMCPServer(pendingServerData.serverData);
|
||||
toast.success('MCP server added');
|
||||
await syncSettingsToServer();
|
||||
handleCloseDialog();
|
||||
} else if (pendingServerData.type === 'import' && pendingServerData.importServers) {
|
||||
for (const serverData of pendingServerData.importServers) {
|
||||
addMCPServer(serverData);
|
||||
}
|
||||
await syncSettingsToServer();
|
||||
const count = pendingServerData.importServers.length;
|
||||
toast.success(`Imported ${count} MCP server${count > 1 ? 's' : ''}`);
|
||||
setIsImportDialogOpen(false);
|
||||
setImportJson('');
|
||||
}
|
||||
|
||||
setIsSecurityWarningOpen(false);
|
||||
setPendingServerData(null);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (server: MCPServerConfig) => {
|
||||
updateMCPServer(server.id, { enabled: !server.enabled });
|
||||
await syncSettingsToServer();
|
||||
toast.success(server.enabled ? 'Server disabled' : 'Server enabled');
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
removeMCPServer(id);
|
||||
await syncSettingsToServer();
|
||||
setDeleteConfirmId(null);
|
||||
toast.success('MCP server removed');
|
||||
};
|
||||
|
||||
const handleImportJson = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(importJson);
|
||||
|
||||
// Support both formats:
|
||||
// 1. Claude Code format: { "mcpServers": { "name": { command, args, ... } } }
|
||||
// 2. Direct format: { "name": { command, args, ... } }
|
||||
const servers = parsed.mcpServers || parsed;
|
||||
|
||||
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||
toast.error('Invalid format: expected object with server configurations');
|
||||
return;
|
||||
}
|
||||
|
||||
const serversToImport: Array<Omit<MCPServerConfig, 'id'>> = [];
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (typeof config !== 'object' || config === null) continue;
|
||||
|
||||
const serverConfig = config as Record<string, unknown>;
|
||||
|
||||
// Check if server with this name already exists
|
||||
if (mcpServers.some((s) => s.name === name)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name,
|
||||
type: (serverConfig.type as ServerType) || 'stdio',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
if (serverData.type === 'stdio') {
|
||||
if (!serverConfig.command) {
|
||||
console.warn(`Skipping ${name}: no command specified`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawCommand = serverConfig.command as string;
|
||||
|
||||
// Support both formats:
|
||||
// 1. Separate command/args: { "command": "npx", "args": ["-y", "package"] }
|
||||
// 2. Inline args (Claude Desktop format): { "command": "npx -y package" }
|
||||
if (Array.isArray(serverConfig.args) && serverConfig.args.length > 0) {
|
||||
// Args provided separately
|
||||
serverData.command = rawCommand;
|
||||
serverData.args = serverConfig.args as string[];
|
||||
} else if (rawCommand.includes(' ')) {
|
||||
// Parse inline command string - split on spaces but preserve quoted strings
|
||||
const parts = rawCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [rawCommand];
|
||||
serverData.command = parts[0];
|
||||
if (parts.length > 1) {
|
||||
// Remove quotes from args
|
||||
serverData.args = parts.slice(1).map((arg) => arg.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
} else {
|
||||
serverData.command = rawCommand;
|
||||
}
|
||||
|
||||
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||
serverData.env = serverConfig.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
if (!serverConfig.url) {
|
||||
console.warn(`Skipping ${name}: no url specified`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
serverData.url = serverConfig.url as string;
|
||||
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
serversToImport.push(serverData);
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
toast.info(
|
||||
`Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)`
|
||||
);
|
||||
}
|
||||
|
||||
if (serversToImport.length === 0) {
|
||||
toast.warning('No new servers to import');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show security warning before importing
|
||||
// Use the first server's type for the warning (most imports are stdio)
|
||||
const firstServer = serversToImport[0];
|
||||
setPendingServerData({
|
||||
type: 'import',
|
||||
importServers: serversToImport,
|
||||
serverType: firstServer.type || 'stdio',
|
||||
command: firstServer.type === 'stdio' ? firstServer.command : undefined,
|
||||
args: firstServer.type === 'stdio' ? firstServer.args : undefined,
|
||||
url: firstServer.type !== 'stdio' ? firstServer.url : undefined,
|
||||
});
|
||||
setIsSecurityWarningOpen(true);
|
||||
} catch (error) {
|
||||
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
const exportData: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const server of mcpServers) {
|
||||
const serverConfig: Record<string, unknown> = {
|
||||
type: server.type || 'stdio',
|
||||
};
|
||||
|
||||
if (server.type === 'stdio' || !server.type) {
|
||||
serverConfig.command = server.command;
|
||||
if (server.args?.length) serverConfig.args = server.args;
|
||||
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
||||
} else {
|
||||
serverConfig.url = server.url;
|
||||
if (server.headers && Object.keys(server.headers).length > 0)
|
||||
serverConfig.headers = server.headers;
|
||||
}
|
||||
|
||||
exportData[server.name] = serverConfig;
|
||||
}
|
||||
|
||||
const json = JSON.stringify({ mcpServers: exportData }, null, 2);
|
||||
navigator.clipboard.writeText(json);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const handleOpenJsonEdit = (server: MCPServerConfig) => {
|
||||
// Build a clean config object for editing (excluding internal fields like id)
|
||||
const editableConfig: Record<string, unknown> = {
|
||||
name: server.name,
|
||||
type: server.type || 'stdio',
|
||||
};
|
||||
|
||||
if (server.description) {
|
||||
editableConfig.description = server.description;
|
||||
}
|
||||
|
||||
if (server.type === 'stdio' || !server.type) {
|
||||
if (server.command) editableConfig.command = server.command;
|
||||
if (server.args?.length) editableConfig.args = server.args;
|
||||
if (server.env && Object.keys(server.env).length > 0) editableConfig.env = server.env;
|
||||
} else {
|
||||
if (server.url) editableConfig.url = server.url;
|
||||
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||
editableConfig.headers = server.headers;
|
||||
}
|
||||
}
|
||||
|
||||
if (server.enabled === false) {
|
||||
editableConfig.enabled = false;
|
||||
}
|
||||
|
||||
setJsonEditValue(JSON.stringify(editableConfig, null, 2));
|
||||
setJsonEditServer(server);
|
||||
};
|
||||
|
||||
const handleSaveJsonEdit = async () => {
|
||||
if (!jsonEditServer) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonEditValue);
|
||||
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
toast.error('Config must be a JSON object');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields based on type
|
||||
const serverType = parsed.type || 'stdio';
|
||||
|
||||
if (!parsed.name || typeof parsed.name !== 'string') {
|
||||
toast.error('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
if (!parsed.command || typeof parsed.command !== 'string') {
|
||||
toast.error('Command is required for stdio servers');
|
||||
return;
|
||||
}
|
||||
} else if (serverType === 'sse' || serverType === 'http') {
|
||||
if (!parsed.url || typeof parsed.url !== 'string') {
|
||||
toast.error('URL is required for SSE/HTTP servers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Partial<MCPServerConfig> = {
|
||||
name: parsed.name,
|
||||
type: serverType,
|
||||
description: parsed.description || undefined,
|
||||
enabled: parsed.enabled !== false,
|
||||
};
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
updateData.command = parsed.command;
|
||||
updateData.args = Array.isArray(parsed.args) ? parsed.args : undefined;
|
||||
updateData.env =
|
||||
typeof parsed.env === 'object' && !Array.isArray(parsed.env) ? parsed.env : undefined;
|
||||
// Clear HTTP fields
|
||||
updateData.url = undefined;
|
||||
updateData.headers = undefined;
|
||||
} else {
|
||||
updateData.url = parsed.url;
|
||||
updateData.headers =
|
||||
typeof parsed.headers === 'object' && !Array.isArray(parsed.headers)
|
||||
? parsed.headers
|
||||
: undefined;
|
||||
// Clear stdio fields
|
||||
updateData.command = undefined;
|
||||
updateData.args = undefined;
|
||||
updateData.env = undefined;
|
||||
}
|
||||
|
||||
updateMCPServer(jsonEditServer.id, updateData);
|
||||
await syncSettingsToServer();
|
||||
|
||||
toast.success('Server configuration updated');
|
||||
setJsonEditServer(null);
|
||||
setJsonEditValue('');
|
||||
} catch (error) {
|
||||
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenGlobalJsonEdit = () => {
|
||||
// Build the full mcpServers config object
|
||||
const exportData: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const server of mcpServers) {
|
||||
const serverConfig: Record<string, unknown> = {
|
||||
type: server.type || 'stdio',
|
||||
};
|
||||
|
||||
if (server.description) {
|
||||
serverConfig.description = server.description;
|
||||
}
|
||||
|
||||
if (server.enabled === false) {
|
||||
serverConfig.enabled = false;
|
||||
}
|
||||
|
||||
if (server.type === 'stdio' || !server.type) {
|
||||
serverConfig.command = server.command;
|
||||
if (server.args?.length) serverConfig.args = server.args;
|
||||
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
||||
} else {
|
||||
serverConfig.url = server.url;
|
||||
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||
serverConfig.headers = server.headers;
|
||||
}
|
||||
}
|
||||
|
||||
exportData[server.name] = serverConfig;
|
||||
}
|
||||
|
||||
setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2));
|
||||
setIsGlobalJsonEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveGlobalJsonEdit = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(globalJsonValue);
|
||||
|
||||
// Support both formats
|
||||
const servers = parsed.mcpServers || parsed;
|
||||
|
||||
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||
toast.error('Invalid format: expected object with server configurations');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all servers first
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
toast.error(`Invalid config for "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = config as Record<string, unknown>;
|
||||
const serverType = (serverConfig.type as string) || 'stdio';
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
|
||||
toast.error(`Command is required for "${name}" (stdio)`);
|
||||
return;
|
||||
}
|
||||
} else if (serverType === 'sse' || serverType === 'http') {
|
||||
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
|
||||
toast.error(`URL is required for "${name}" (${serverType})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of existing servers by name for updating
|
||||
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
||||
const processedNames = new Set<string>();
|
||||
|
||||
// Update or add servers
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
const serverConfig = config as Record<string, unknown>;
|
||||
const serverType = (serverConfig.type as ServerType) || 'stdio';
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name,
|
||||
type: serverType,
|
||||
description: (serverConfig.description as string) || undefined,
|
||||
enabled: serverConfig.enabled !== false,
|
||||
};
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
serverData.command = serverConfig.command as string;
|
||||
if (Array.isArray(serverConfig.args)) {
|
||||
serverData.args = serverConfig.args as string[];
|
||||
}
|
||||
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||
serverData.env = serverConfig.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
serverData.url = serverConfig.url as string;
|
||||
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = existingByName.get(name);
|
||||
if (existing) {
|
||||
updateMCPServer(existing.id, serverData);
|
||||
} else {
|
||||
addMCPServer(serverData);
|
||||
}
|
||||
processedNames.add(name);
|
||||
}
|
||||
|
||||
// Remove servers that are no longer in the JSON
|
||||
for (const server of mcpServers) {
|
||||
if (!processedNames.has(server.name)) {
|
||||
removeMCPServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
await syncSettingsToServer();
|
||||
|
||||
toast.success('MCP servers configuration updated');
|
||||
setIsGlobalJsonEditOpen(false);
|
||||
setGlobalJsonValue('');
|
||||
} catch (error) {
|
||||
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Store state
|
||||
mcpServers,
|
||||
mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools,
|
||||
setMcpAutoApproveTools,
|
||||
setMcpUnrestrictedTools,
|
||||
|
||||
// Dialog state
|
||||
isAddDialogOpen,
|
||||
setIsAddDialogOpen,
|
||||
editingServer,
|
||||
formData,
|
||||
setFormData,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
isImportDialogOpen,
|
||||
setIsImportDialogOpen,
|
||||
importJson,
|
||||
setImportJson,
|
||||
jsonEditServer,
|
||||
setJsonEditServer,
|
||||
jsonEditValue,
|
||||
setJsonEditValue,
|
||||
isGlobalJsonEditOpen,
|
||||
setIsGlobalJsonEditOpen,
|
||||
globalJsonValue,
|
||||
setGlobalJsonValue,
|
||||
|
||||
// Security warning dialog state
|
||||
isSecurityWarningOpen,
|
||||
setIsSecurityWarningOpen,
|
||||
pendingServerData,
|
||||
|
||||
// UI state
|
||||
isRefreshing,
|
||||
serverTestStates,
|
||||
expandedServers,
|
||||
|
||||
// Computed
|
||||
totalToolsCount,
|
||||
showToolsWarning,
|
||||
|
||||
// Handlers
|
||||
handleRefresh,
|
||||
handleTestServer,
|
||||
toggleServerExpanded,
|
||||
handleOpenAddDialog,
|
||||
handleOpenEditDialog,
|
||||
handleCloseDialog,
|
||||
handleSave,
|
||||
handleToggleEnabled,
|
||||
handleDelete,
|
||||
handleImportJson,
|
||||
handleExportJson,
|
||||
handleOpenJsonEdit,
|
||||
handleSaveJsonEdit,
|
||||
handleOpenGlobalJsonEdit,
|
||||
handleSaveGlobalJsonEdit,
|
||||
handleSecurityWarningConfirm,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MCPServersSection } from './mcp-servers-section';
|
||||
export { MCPToolsList, type MCPToolDisplay } from './mcp-tools-list';
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Plug } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMCPServers } from './hooks';
|
||||
import {
|
||||
MCPServerHeader,
|
||||
MCPPermissionSettings,
|
||||
MCPToolsWarning,
|
||||
MCPServerCard,
|
||||
} from './components';
|
||||
import {
|
||||
AddEditServerDialog,
|
||||
DeleteServerDialog,
|
||||
ImportJsonDialog,
|
||||
JsonEditDialog,
|
||||
GlobalJsonEditDialog,
|
||||
SecurityWarningDialog,
|
||||
} from './dialogs';
|
||||
|
||||
export function MCPServersSection() {
|
||||
const {
|
||||
// Store state
|
||||
mcpServers,
|
||||
mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools,
|
||||
setMcpAutoApproveTools,
|
||||
setMcpUnrestrictedTools,
|
||||
|
||||
// Dialog state
|
||||
isAddDialogOpen,
|
||||
setIsAddDialogOpen,
|
||||
editingServer,
|
||||
formData,
|
||||
setFormData,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
isImportDialogOpen,
|
||||
setIsImportDialogOpen,
|
||||
importJson,
|
||||
setImportJson,
|
||||
jsonEditServer,
|
||||
setJsonEditServer,
|
||||
jsonEditValue,
|
||||
setJsonEditValue,
|
||||
isGlobalJsonEditOpen,
|
||||
setIsGlobalJsonEditOpen,
|
||||
globalJsonValue,
|
||||
setGlobalJsonValue,
|
||||
|
||||
// Security warning dialog state
|
||||
isSecurityWarningOpen,
|
||||
setIsSecurityWarningOpen,
|
||||
pendingServerData,
|
||||
|
||||
// UI state
|
||||
isRefreshing,
|
||||
serverTestStates,
|
||||
expandedServers,
|
||||
|
||||
// Computed
|
||||
totalToolsCount,
|
||||
showToolsWarning,
|
||||
|
||||
// Handlers
|
||||
handleRefresh,
|
||||
handleTestServer,
|
||||
toggleServerExpanded,
|
||||
handleOpenAddDialog,
|
||||
handleOpenEditDialog,
|
||||
handleCloseDialog,
|
||||
handleSave,
|
||||
handleToggleEnabled,
|
||||
handleDelete,
|
||||
handleImportJson,
|
||||
handleExportJson,
|
||||
handleOpenJsonEdit,
|
||||
handleSaveJsonEdit,
|
||||
handleOpenGlobalJsonEdit,
|
||||
handleSaveGlobalJsonEdit,
|
||||
handleSecurityWarningConfirm,
|
||||
} = useMCPServers();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<MCPServerHeader
|
||||
isRefreshing={isRefreshing}
|
||||
hasServers={mcpServers.length > 0}
|
||||
onRefresh={handleRefresh}
|
||||
onExport={handleExportJson}
|
||||
onEditAllJson={handleOpenGlobalJsonEdit}
|
||||
onImport={() => setIsImportDialogOpen(true)}
|
||||
onAdd={handleOpenAddDialog}
|
||||
/>
|
||||
|
||||
{mcpServers.length > 0 && (
|
||||
<MCPPermissionSettings
|
||||
mcpAutoApproveTools={mcpAutoApproveTools}
|
||||
mcpUnrestrictedTools={mcpUnrestrictedTools}
|
||||
onAutoApproveChange={setMcpAutoApproveTools}
|
||||
onUnrestrictedChange={setMcpUnrestrictedTools}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showToolsWarning && <MCPToolsWarning totalTools={totalToolsCount} />}
|
||||
|
||||
<div className="p-6">
|
||||
{mcpServers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Plug className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No MCP servers configured</p>
|
||||
<p className="text-xs mt-1">Add a server to extend agent capabilities</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mcpServers.map((server) => (
|
||||
<MCPServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
testState={serverTestStates[server.id]}
|
||||
isExpanded={expandedServers.has(server.id)}
|
||||
onToggleExpanded={() => toggleServerExpanded(server.id)}
|
||||
onTest={() => handleTestServer(server)}
|
||||
onToggleEnabled={() => handleToggleEnabled(server)}
|
||||
onEditJson={() => handleOpenJsonEdit(server)}
|
||||
onEdit={() => handleOpenEditDialog(server)}
|
||||
onDelete={() => setDeleteConfirmId(server.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AddEditServerDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
editingServer={editingServer}
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCloseDialog}
|
||||
/>
|
||||
|
||||
<DeleteServerDialog
|
||||
open={!!deleteConfirmId}
|
||||
onOpenChange={(open) => setDeleteConfirmId(open ? deleteConfirmId : null)}
|
||||
onConfirm={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||
/>
|
||||
|
||||
<ImportJsonDialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
importJson={importJson}
|
||||
onImportJsonChange={setImportJson}
|
||||
onImport={handleImportJson}
|
||||
onCancel={() => {
|
||||
setIsImportDialogOpen(false);
|
||||
setImportJson('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<JsonEditDialog
|
||||
open={!!jsonEditServer}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setJsonEditServer(null);
|
||||
setJsonEditValue('');
|
||||
}
|
||||
}}
|
||||
server={jsonEditServer}
|
||||
jsonValue={jsonEditValue}
|
||||
onJsonValueChange={setJsonEditValue}
|
||||
onSave={handleSaveJsonEdit}
|
||||
onCancel={() => {
|
||||
setJsonEditServer(null);
|
||||
setJsonEditValue('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<GlobalJsonEditDialog
|
||||
open={isGlobalJsonEditOpen}
|
||||
onOpenChange={setIsGlobalJsonEditOpen}
|
||||
jsonValue={globalJsonValue}
|
||||
onJsonValueChange={setGlobalJsonValue}
|
||||
onSave={handleSaveGlobalJsonEdit}
|
||||
onCancel={() => {
|
||||
setIsGlobalJsonEditOpen(false);
|
||||
setGlobalJsonValue('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<SecurityWarningDialog
|
||||
open={isSecurityWarningOpen}
|
||||
onOpenChange={setIsSecurityWarningOpen}
|
||||
onConfirm={handleSecurityWarningConfirm}
|
||||
serverType={pendingServerData?.serverType || 'stdio'}
|
||||
serverName={pendingServerData?.serverData?.name || ''}
|
||||
command={pendingServerData?.command}
|
||||
args={pendingServerData?.args}
|
||||
url={pendingServerData?.url}
|
||||
importCount={
|
||||
pendingServerData?.type === 'import' ? pendingServerData.importServers?.length : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
export interface MCPToolDisplay {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MCPToolsListProps {
|
||||
tools: MCPToolDisplay[];
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MCPToolsList({ tools, isLoading, error, className }: MCPToolsListProps) {
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleTool = (toolName: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolName)) {
|
||||
next.delete(toolName);
|
||||
} else {
|
||||
next.add(toolName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground animate-pulse', className)}>
|
||||
Loading tools...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={cn('text-sm text-destructive wrap-break-word', className)}>{error}</div>;
|
||||
}
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground italic', className)}>
|
||||
No tools available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1 overflow-hidden', className)}>
|
||||
{tools.map((tool) => {
|
||||
const isExpanded = expandedTools.has(tool.name);
|
||||
const hasSchema = tool.inputSchema && Object.keys(tool.inputSchema).length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible key={tool.name} open={isExpanded} onOpenChange={() => toggleTool(tool.name)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/30 bg-background/50 overflow-hidden',
|
||||
'hover:border-border/50 transition-colors'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start h-auto py-2 px-3 font-normal"
|
||||
>
|
||||
<div className="flex items-start gap-2 w-full min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
||||
{hasSchema ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-3.5" />
|
||||
)}
|
||||
<Wrench className="w-3.5 h-3.5 text-brand-500" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start text-left min-w-0 overflow-hidden flex-1">
|
||||
<span className="font-medium text-xs truncate max-w-full">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2 wrap-break-word w-full">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{hasSchema && (
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-2 pt-0 overflow-hidden">
|
||||
<div className="bg-muted/50 rounded p-2 text-xs font-mono overflow-x-auto max-h-48">
|
||||
<pre className="whitespace-pre-wrap break-all text-[10px] leading-relaxed">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MCPToolDisplay } from './mcp-tools-list';
|
||||
|
||||
export type ServerType = 'stdio' | 'sse' | 'http';
|
||||
|
||||
export interface ServerFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
type: ServerType;
|
||||
command: string;
|
||||
args: string;
|
||||
url: string;
|
||||
headers: string; // JSON string for headers
|
||||
env: string; // JSON string for env vars
|
||||
}
|
||||
|
||||
export const defaultFormData: ServerFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: '',
|
||||
url: '',
|
||||
headers: '',
|
||||
env: '',
|
||||
};
|
||||
|
||||
export interface ServerTestState {
|
||||
status: 'idle' | 'testing' | 'success' | 'error';
|
||||
tools?: MCPToolDisplay[];
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import type { ServerType, ServerTestState } from './types';
|
||||
import { SENSITIVE_PARAM_PATTERNS } from './constants';
|
||||
|
||||
/**
|
||||
* Mask sensitive values in URLs (query params with key-like names)
|
||||
*/
|
||||
export function maskSensitiveUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const params = new URLSearchParams(urlObj.search);
|
||||
let hasSensitive = false;
|
||||
|
||||
for (const [key] of params.entries()) {
|
||||
if (SENSITIVE_PARAM_PATTERNS.some((pattern) => pattern.test(key))) {
|
||||
params.set(key, '***');
|
||||
hasSensitive = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSensitive) {
|
||||
urlObj.search = params.toString();
|
||||
return urlObj.toString();
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
// If URL parsing fails, try simple regex replacement for common patterns
|
||||
return url.replace(
|
||||
/([?&])(api[-_]?key|auth|token|secret|password|credential)=([^&]*)/gi,
|
||||
'$1$2=***'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerIcon(type: ServerType = 'stdio') {
|
||||
if (type === 'stdio') return Terminal;
|
||||
return Globe;
|
||||
}
|
||||
|
||||
export function getTestStatusIcon(status: ServerTestState['status']) {
|
||||
switch (status) {
|
||||
case 'testing':
|
||||
return <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* Updates Section Component
|
||||
*
|
||||
* Settings panel for configuring and managing auto-updates.
|
||||
* Uses the centralized updates-store for state and actions.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn, getRepoDisplayName } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useUpdatesStore } from '@/store/updates-store';
|
||||
import type { AutoUpdateSettings } from '@automaker/types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface UpdatesSectionProps {
|
||||
autoUpdate: AutoUpdateSettings;
|
||||
onAutoUpdateChange: (settings: Partial<AutoUpdateSettings>) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
/**
|
||||
* Renders the Updates settings panel and manages update-related actions and UI.
|
||||
*
|
||||
* Fetches current update info on mount, exposes controls for auto-update settings
|
||||
* (enabled, check interval, upstream URL), and provides actions to check for and
|
||||
* pull updates with user-facing notifications.
|
||||
*
|
||||
* @param autoUpdate - Current auto-update configuration (enabled, checkIntervalMinutes, upstreamUrl).
|
||||
* @param onAutoUpdateChange - Callback invoked with partial updates to apply to the auto-update configuration.
|
||||
* @returns The Updates settings React element.
|
||||
*/
|
||||
|
||||
export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectionProps) {
|
||||
// Use centralized store
|
||||
const {
|
||||
info,
|
||||
updateAvailable,
|
||||
remoteVersionShort,
|
||||
isChecking,
|
||||
isPulling,
|
||||
isLoadingInfo,
|
||||
error,
|
||||
fetchInfo,
|
||||
checkForUpdates,
|
||||
pullUpdates,
|
||||
clearError,
|
||||
} = useUpdatesStore();
|
||||
|
||||
// Fetch info on mount
|
||||
useEffect(() => {
|
||||
fetchInfo();
|
||||
}, [fetchInfo]);
|
||||
|
||||
// Handle check for updates with toast notifications
|
||||
const handleCheckForUpdates = async () => {
|
||||
clearError();
|
||||
const hasUpdate = await checkForUpdates();
|
||||
|
||||
if (hasUpdate) {
|
||||
toast.success('Update available!', {
|
||||
description: `New version: ${useUpdatesStore.getState().remoteVersionShort}`,
|
||||
});
|
||||
} else if (!useUpdatesStore.getState().error) {
|
||||
toast.success('You are up to date!');
|
||||
} else {
|
||||
toast.error(useUpdatesStore.getState().error || 'Failed to check for updates');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle pull updates with toast notifications
|
||||
const handlePullUpdates = async () => {
|
||||
clearError();
|
||||
const result = await pullUpdates();
|
||||
|
||||
if (result) {
|
||||
if (result.alreadyUpToDate) {
|
||||
toast.success('Already up to date!');
|
||||
} else {
|
||||
toast.success('Update installed!', {
|
||||
description: result.message,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Restart Now',
|
||||
onClick: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: 'Later',
|
||||
onClick: () => {
|
||||
// Just dismiss - user will restart manually later
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (useUpdatesStore.getState().error) {
|
||||
toast.error(useUpdatesStore.getState().error || 'Failed to pull updates');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isChecking || isPulling || isLoadingInfo;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<RefreshCw className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Updates</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Check for and install updates from the upstream repository.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Current Version Info */}
|
||||
{info && (
|
||||
<div className="p-4 rounded-xl bg-accent/20 border border-border/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
<span className="text-sm font-medium">Current Installation</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<code className="font-mono text-foreground">
|
||||
{info.currentVersionShort || 'Unknown'}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Branch:</span>
|
||||
<span className="text-foreground">{info.currentBranch || 'Unknown'}</span>
|
||||
</div>
|
||||
{info.hasLocalChanges && (
|
||||
<div className="flex items-center gap-2 text-yellow-500">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Local changes detected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update Status */}
|
||||
{updateAvailable && remoteVersionShort && (
|
||||
<div className="p-4 rounded-xl bg-green-500/10 border border-green-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="font-medium text-green-500">Update Available</span>
|
||||
</div>
|
||||
<code className="font-mono text-sm text-green-500">{remoteVersionShort}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-500">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-Update Toggle */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="auto-update-enabled"
|
||||
checked={autoUpdate.enabled}
|
||||
onCheckedChange={(checked) => onAutoUpdateChange({ enabled: !!checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="auto-update-enabled"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-brand-500" />
|
||||
Enable automatic update checks
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Periodically check for new updates from the upstream repository.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Interval */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="check-interval" className="text-sm font-medium">
|
||||
Check interval (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="check-interval"
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={autoUpdate.checkIntervalMinutes}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 60) {
|
||||
onAutoUpdateChange({ checkIntervalMinutes: value });
|
||||
}
|
||||
}}
|
||||
className="w-32"
|
||||
disabled={!autoUpdate.enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How often to check for updates (1-60 minutes).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upstream URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upstream-url" className="text-sm font-medium">
|
||||
Upstream repository URL
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="upstream-url"
|
||||
type="url"
|
||||
value={autoUpdate.upstreamUrl}
|
||||
onChange={(e) => onAutoUpdateChange({ upstreamUrl: e.target.value })}
|
||||
placeholder="https://github.com/AutoMaker-Org/automaker.git"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const url = autoUpdate.upstreamUrl.replace(/\.git$/, '');
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
title="Open repository"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repository to check for updates. Default: {getRepoDisplayName(autoUpdate.upstreamUrl)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border/30">
|
||||
<Button onClick={handleCheckForUpdates} disabled={isLoading} variant="outline">
|
||||
{isChecking ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Check for Updates
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{updateAvailable && (
|
||||
<Button onClick={handlePullUpdates} disabled={isLoading}>
|
||||
{isPulling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Update Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -174,14 +174,12 @@ export function useResponsiveKanban(
|
||||
// Calculate total board width for container sizing
|
||||
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||
|
||||
// Container style to center content
|
||||
// Use flex layout with justify-center to naturally center columns
|
||||
// The parent container has px-4 padding which provides equal left/right margins
|
||||
// Container style for horizontal scrolling support
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: `${gap}px`,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
width: 'max-content', // Expand to fit all columns, enabling horizontal scroll when needed
|
||||
minHeight: '100%', // Ensure full height
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { isElectron } from '@/lib/electron';
|
||||
import { getItem, removeItem } from '@/lib/storage';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
/**
|
||||
* State returned by useSettingsMigration hook
|
||||
@@ -180,12 +181,17 @@ export function useSettingsMigration(): MigrationState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist selected global settings from localStorage to the file-based server storage.
|
||||
* Sync current global settings to file-based server storage
|
||||
*
|
||||
* Reads the `automaker-storage` entry (Zustand state) and sends the relevant global settings
|
||||
* to the server so they are written to the application's settings file.
|
||||
* Reads the current Zustand state from localStorage and sends all global settings
|
||||
* to the server to be written to {dataDir}/settings.json.
|
||||
*
|
||||
* @returns `true` if the server acknowledged the update, `false` otherwise.
|
||||
* Call this when important global settings change (theme, UI preferences, profiles, etc.)
|
||||
* Safe to call from store subscribers or change handlers.
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncSettingsToServer(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
@@ -222,12 +228,14 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
enableSandboxMode: state.enableSandboxMode,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
mcpServers: state.mcpServers,
|
||||
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
projectHistory: state.projectHistory,
|
||||
projectHistoryIndex: state.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
autoUpdate: state.autoUpdate,
|
||||
};
|
||||
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
@@ -311,4 +319,43 @@ export async function syncProjectSettingsToServer(
|
||||
console.error('[Settings Sync] Failed to sync project settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load MCP servers from server settings file into the store
|
||||
*
|
||||
* Fetches the global settings from the server and updates the store's
|
||||
* mcpServers state. Useful when settings were modified externally
|
||||
* (e.g., by editing the settings.json file directly).
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @returns Promise resolving to true if load succeeded, false otherwise
|
||||
*/
|
||||
export async function loadMCPServersFromServer(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.getGlobal();
|
||||
|
||||
if (!result.success || !result.settings) {
|
||||
console.error('[Settings Load] Failed to load settings:', result.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const mcpServers = result.settings.mcpServers || [];
|
||||
const mcpAutoApproveTools = result.settings.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestrictedTools = result.settings.mcpUnrestrictedTools ?? true;
|
||||
|
||||
// Clear existing and add all from server
|
||||
// We need to update the store directly since we can't use hooks here
|
||||
useAppStore.setState({ mcpServers, mcpAutoApproveTools, mcpUnrestrictedTools });
|
||||
|
||||
console.log(`[Settings Load] Loaded ${mcpServers.length} MCP servers from server`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Settings Load] Failed to load MCP servers:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Update Polling Hook
|
||||
*
|
||||
* Handles the background polling logic for checking updates.
|
||||
* Separated from the store to follow single responsibility principle.
|
||||
*
|
||||
* This hook only manages WHEN to check, not HOW to check.
|
||||
* The actual check logic lives in the updates-store.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useUpdatesStore } from '@/store/updates-store';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UseUpdatePollingOptions {
|
||||
/** Override the check function (for testing/DI) */
|
||||
onCheck?: () => Promise<boolean>;
|
||||
|
||||
/** Override enabled state (for testing) */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Override interval in minutes (for testing) */
|
||||
intervalMinutes?: number;
|
||||
}
|
||||
|
||||
export interface UseUpdatePollingResult {
|
||||
/** Whether polling is currently active */
|
||||
isPollingActive: boolean;
|
||||
|
||||
/** Manually trigger a check */
|
||||
checkNow: () => Promise<boolean>;
|
||||
|
||||
/** Last check timestamp */
|
||||
lastChecked: Date | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manages periodic background checks for updates and exposes controls and status.
|
||||
*
|
||||
* @param options - Optional overrides for testing or dependency injection:
|
||||
* - `onCheck`: override the function used to perform an update check
|
||||
* - `enabled`: force polling enabled or disabled
|
||||
* - `intervalMinutes`: override the polling interval in minutes
|
||||
* @returns An object with polling status and controls:
|
||||
* - `isPollingActive`: `true` when polling is enabled, `false` otherwise
|
||||
* - `checkNow`: a function that triggers an immediate update check and returns `true` if an update was found, `false` otherwise
|
||||
* - `lastChecked`: timestamp of the last performed check, or `null` if never checked
|
||||
*/
|
||||
export function useUpdatePolling(options: UseUpdatePollingOptions = {}): UseUpdatePollingResult {
|
||||
const { autoUpdate } = useAppStore();
|
||||
const { checkForUpdates, lastChecked } = useUpdatesStore();
|
||||
|
||||
// Allow overrides for testing
|
||||
const isEnabled = options.enabled ?? autoUpdate.enabled;
|
||||
const intervalMinutes = options.intervalMinutes ?? autoUpdate.checkIntervalMinutes;
|
||||
|
||||
// Stabilize the check function reference to prevent interval resets
|
||||
const onCheckRef = useRef(options.onCheck ?? checkForUpdates);
|
||||
onCheckRef.current = options.onCheck ?? checkForUpdates;
|
||||
|
||||
const stableOnCheck = useCallback(() => onCheckRef.current(), []);
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Don't set up polling if disabled
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check immediately on enable
|
||||
stableOnCheck();
|
||||
|
||||
// Set up interval
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
intervalRef.current = setInterval(stableOnCheck, intervalMs);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isEnabled, intervalMinutes, stableOnCheck]);
|
||||
|
||||
return {
|
||||
isPollingActive: isEnabled,
|
||||
checkNow: stableOnCheck,
|
||||
lastChecked,
|
||||
};
|
||||
}
|
||||
@@ -926,6 +926,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
recentFolders: string[];
|
||||
worktreePanelCollapsed: boolean;
|
||||
lastSelectedSessionByProject: Record<string, string>;
|
||||
mcpServers?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: 'stdio' | 'sse' | 'http';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
mcpAutoApproveTools?: boolean;
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get('/api/settings/global'),
|
||||
@@ -1125,6 +1139,138 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// MCP API - Test MCP server connections and list tools
|
||||
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
|
||||
// drive-by command execution attacks. Servers must be saved first.
|
||||
mcp = {
|
||||
testServer: (
|
||||
serverId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}> => this.post('/api/mcp/test', { serverId }),
|
||||
|
||||
listTools: (
|
||||
serverId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
}> => this.post('/api/mcp/tools', { serverId }),
|
||||
};
|
||||
|
||||
// Pipeline API - custom workflow pipeline steps
|
||||
pipeline = {
|
||||
getConfig: (
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
config?: {
|
||||
version: 1;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post('/api/pipeline/config', { projectPath }),
|
||||
|
||||
saveConfig: (
|
||||
projectPath: string,
|
||||
config: {
|
||||
version: 1;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/pipeline/config/save', { projectPath, config }),
|
||||
|
||||
addStep: (
|
||||
projectPath: string,
|
||||
step: {
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
step?: {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post('/api/pipeline/steps/add', { projectPath, step }),
|
||||
|
||||
updateStep: (
|
||||
projectPath: string,
|
||||
stepId: string,
|
||||
updates: Partial<{
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
}>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
step?: {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post('/api/pipeline/steps/update', { projectPath, stepId, updates }),
|
||||
|
||||
deleteStep: (
|
||||
projectPath: string,
|
||||
stepId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/pipeline/steps/delete', { projectPath, stepId }),
|
||||
|
||||
reorderSteps: (
|
||||
projectPath: string,
|
||||
stepIds: string[]
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/pipeline/steps/reorder', { projectPath, stepIds }),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -63,14 +63,3 @@ export const isMac =
|
||||
: typeof navigator !== 'undefined' &&
|
||||
(/Mac/.test(navigator.userAgent) ||
|
||||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));
|
||||
|
||||
/**
|
||||
* Get the owner/repo display name from a GitHub repository URL.
|
||||
*
|
||||
* @param url - A repository URL (common GitHub formats like `https://github.com/owner/repo`, `git@github.com:owner/repo.git`, or `github.com/owner/repo`)
|
||||
* @returns The `owner/repo` string when the URL is a GitHub repository, otherwise `'upstream'`
|
||||
*/
|
||||
export function getRepoDisplayName(url: string): string {
|
||||
const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/);
|
||||
return match ? match[1] : 'upstream';
|
||||
}
|
||||
@@ -37,19 +37,13 @@ const STATIC_PORT = 3007;
|
||||
// ============================================
|
||||
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||
// With sidebar collapsed (64px): 1220 + 64 = 1284px
|
||||
const COLUMN_MIN_WIDTH = 280;
|
||||
const COLUMN_COUNT = 4;
|
||||
const GAP_SIZE = 20;
|
||||
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
|
||||
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
|
||||
const SIDEBAR_EXPANDED = 288;
|
||||
const SIDEBAR_COLLAPSED = 64;
|
||||
|
||||
const BOARD_CONTENT_MIN =
|
||||
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
|
||||
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
|
||||
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
|
||||
const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling
|
||||
const MIN_WIDTH_EXPANDED = 800; // Reduced - horizontal scrolling handles overflow
|
||||
const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
|
||||
const MIN_HEIGHT = 500; // Reduced to allow more flexibility
|
||||
const DEFAULT_WIDTH = 1600;
|
||||
const DEFAULT_HEIGHT = 950;
|
||||
|
||||
@@ -199,7 +193,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
// Ensure minimum dimensions
|
||||
return {
|
||||
...bounds,
|
||||
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
|
||||
width: Math.max(bounds.width, MIN_WIDTH_COLLAPSED),
|
||||
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
@@ -420,7 +414,7 @@ function createWindow(): void {
|
||||
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||
x: validBounds?.x,
|
||||
y: validBounds?.y,
|
||||
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
|
||||
minWidth: MIN_WIDTH_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
|
||||
minHeight: MIN_HEIGHT,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
@@ -673,15 +667,10 @@ ipcMain.handle('server:getUrl', async () => {
|
||||
});
|
||||
|
||||
// Window management - update minimum width based on sidebar state
|
||||
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
|
||||
// Now uses a fixed small minimum since horizontal scrolling handles overflow
|
||||
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
|
||||
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
|
||||
|
||||
// If current width is below new minimum, resize window
|
||||
const currentBounds = mainWindow.getBounds();
|
||||
if (currentBounds.width < minWidth) {
|
||||
mainWindow.setSize(minWidth, currentBounds.height);
|
||||
}
|
||||
// Always use the smaller minimum width - horizontal scrolling handles any overflow
|
||||
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
|
||||
});
|
||||
|
||||
@@ -11,17 +11,7 @@ import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||
import { UpdateNotifier } from '@/components/updates';
|
||||
|
||||
/**
|
||||
* Render the application's root layout and manage global UI state, routing, and integrations.
|
||||
*
|
||||
* This component provides the main application shell: sidebar, route Outlet, a hidden streamer panel,
|
||||
* theme application, first-run/setup routing handling, file browser binding, IPC connection testing,
|
||||
* global keyboard shortcut handling, and global UI utilities like the Toaster and UpdateNotifier.
|
||||
*
|
||||
* @returns The root layout element containing the sidebar, main content Outlet, hidden streamer panel, Toaster, and UpdateNotifier.
|
||||
*/
|
||||
function RootLayoutContent() {
|
||||
const location = useLocation();
|
||||
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
|
||||
@@ -185,7 +175,6 @@ function RootLayoutContent() {
|
||||
}`}
|
||||
/>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
<UpdateNotifier />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -200,4 +189,4 @@ function RootLayout() {
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
MCPServerConfig,
|
||||
FeatureStatusWithPipeline,
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
@@ -263,7 +267,7 @@ export interface Feature extends Omit<
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[]; // Required in UI (not optional)
|
||||
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
||||
status: FeatureStatusWithPipeline;
|
||||
images?: FeatureImage[]; // UI-specific base64 images
|
||||
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||
@@ -483,6 +487,11 @@ export interface AppState {
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
|
||||
|
||||
// MCP Servers
|
||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||
mcpAutoApproveTools: boolean; // Auto-approve MCP tool calls without permission prompts
|
||||
mcpUnrestrictedTools: boolean; // Allow unrestricted tools when MCP servers are enabled
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
@@ -534,6 +543,9 @@ export interface AppState {
|
||||
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
|
||||
claudeUsage: ClaudeUsage | null;
|
||||
claudeUsageLastUpdated: number | null;
|
||||
|
||||
// Pipeline Configuration (per-project, keyed by project path)
|
||||
pipelineConfigByProject: Record<string, PipelineConfig>;
|
||||
}
|
||||
|
||||
// Claude Usage interface matching the server response
|
||||
@@ -759,6 +771,8 @@ export interface AppActions {
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
|
||||
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
|
||||
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
||||
@@ -767,6 +781,12 @@ export interface AppActions {
|
||||
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
||||
resetAIProfiles: () => void;
|
||||
|
||||
// MCP Server actions
|
||||
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
|
||||
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
|
||||
removeMCPServer: (id: string) => void;
|
||||
reorderMCPServers: (oldIndex: number, newIndex: number) => void;
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||
setIsAnalyzing: (analyzing: boolean) => void;
|
||||
@@ -857,6 +877,21 @@ export interface AppActions {
|
||||
} | null
|
||||
) => void;
|
||||
|
||||
// Pipeline actions
|
||||
setPipelineConfig: (projectPath: string, config: PipelineConfig) => void;
|
||||
getPipelineConfig: (projectPath: string) => PipelineConfig | null;
|
||||
addPipelineStep: (
|
||||
projectPath: string,
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) => PipelineStep;
|
||||
updatePipelineStep: (
|
||||
projectPath: string,
|
||||
stepId: string,
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
|
||||
) => void;
|
||||
deletePipelineStep: (projectPath: string, stepId: string) => void;
|
||||
reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -934,6 +969,9 @@ const initialState: AppState = {
|
||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
||||
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
||||
aiProfiles: DEFAULT_AI_PROFILES,
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
@@ -961,6 +999,10 @@ const initialState: AppState = {
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultAIProfileId: null,
|
||||
pendingPlanApproval: null,
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
claudeUsageLastUpdated: null,
|
||||
pipelineConfigByProject: {},
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
@@ -1573,6 +1615,18 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
setMcpAutoApproveTools: async (enabled) => {
|
||||
set({ mcpAutoApproveTools: enabled });
|
||||
// Sync to server settings file
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
setMcpUnrestrictedTools: async (enabled) => {
|
||||
set({ mcpUnrestrictedTools: enabled });
|
||||
// Sync to server settings file
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile) => {
|
||||
@@ -1614,6 +1668,29 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
|
||||
},
|
||||
|
||||
// MCP Server actions
|
||||
addMCPServer: (server) => {
|
||||
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
set({ mcpServers: [...get().mcpServers, { ...server, id, enabled: true }] });
|
||||
},
|
||||
|
||||
updateMCPServer: (id, updates) => {
|
||||
set({
|
||||
mcpServers: get().mcpServers.map((s) => (s.id === id ? { ...s, ...updates } : s)),
|
||||
});
|
||||
},
|
||||
|
||||
removeMCPServer: (id) => {
|
||||
set({ mcpServers: get().mcpServers.filter((s) => s.id !== id) });
|
||||
},
|
||||
|
||||
reorderMCPServers: (oldIndex, newIndex) => {
|
||||
const servers = [...get().mcpServers];
|
||||
const [movedServer] = servers.splice(oldIndex, 1);
|
||||
servers.splice(newIndex, 0, movedServer);
|
||||
set({ mcpServers: servers });
|
||||
},
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
||||
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
||||
@@ -2597,6 +2674,105 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
claudeUsageLastUpdated: usage ? Date.now() : null,
|
||||
}),
|
||||
|
||||
// Pipeline actions
|
||||
setPipelineConfig: (projectPath, config) => {
|
||||
set({
|
||||
pipelineConfigByProject: {
|
||||
...get().pipelineConfigByProject,
|
||||
[projectPath]: config,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getPipelineConfig: (projectPath) => {
|
||||
return get().pipelineConfigByProject[projectPath] || null;
|
||||
},
|
||||
|
||||
addPipelineStep: (projectPath, step) => {
|
||||
const config = get().pipelineConfigByProject[projectPath] || { version: 1, steps: [] };
|
||||
const now = new Date().toISOString();
|
||||
const newStep: PipelineStep = {
|
||||
...step,
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const newSteps = [...config.steps, newStep].sort((a, b) => a.order - b.order);
|
||||
newSteps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
set({
|
||||
pipelineConfigByProject: {
|
||||
...get().pipelineConfigByProject,
|
||||
[projectPath]: { ...config, steps: newSteps },
|
||||
},
|
||||
});
|
||||
|
||||
return newStep;
|
||||
},
|
||||
|
||||
updatePipelineStep: (projectPath, stepId, updates) => {
|
||||
const config = get().pipelineConfigByProject[projectPath];
|
||||
if (!config) return;
|
||||
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
if (stepIndex === -1) return;
|
||||
|
||||
const updatedSteps = [...config.steps];
|
||||
updatedSteps[stepIndex] = {
|
||||
...updatedSteps[stepIndex],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
set({
|
||||
pipelineConfigByProject: {
|
||||
...get().pipelineConfigByProject,
|
||||
[projectPath]: { ...config, steps: updatedSteps },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
deletePipelineStep: (projectPath, stepId) => {
|
||||
const config = get().pipelineConfigByProject[projectPath];
|
||||
if (!config) return;
|
||||
|
||||
const newSteps = config.steps.filter((s) => s.id !== stepId);
|
||||
newSteps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
set({
|
||||
pipelineConfigByProject: {
|
||||
...get().pipelineConfigByProject,
|
||||
[projectPath]: { ...config, steps: newSteps },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
reorderPipelineSteps: (projectPath, stepIds) => {
|
||||
const config = get().pipelineConfigByProject[projectPath];
|
||||
if (!config) return;
|
||||
|
||||
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
|
||||
const reorderedSteps = stepIds
|
||||
.map((id, index) => {
|
||||
const step = stepMap.get(id);
|
||||
if (!step) return null;
|
||||
return { ...step, order: index, updatedAt: new Date().toISOString() };
|
||||
})
|
||||
.filter((s): s is PipelineStep => s !== null);
|
||||
|
||||
set({
|
||||
pipelineConfigByProject: {
|
||||
...get().pipelineConfigByProject,
|
||||
[projectPath]: { ...config, steps: reorderedSteps },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
@@ -2729,6 +2905,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
validationModel: state.validationModel,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
enableSandboxMode: state.enableSandboxMode,
|
||||
// MCP settings
|
||||
mcpServers: state.mcpServers,
|
||||
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
|
||||
// Profiles and sessions
|
||||
aiProfiles: state.aiProfiles,
|
||||
chatSessions: state.chatSessions,
|
||||
|
||||
18
apps/ui/src/types/electron.d.ts
vendored
18
apps/ui/src/types/electron.d.ts
vendored
@@ -190,6 +190,24 @@ export type AutoModeEvent =
|
||||
passes: boolean;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'pipeline_step_started';
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
stepId: string;
|
||||
stepName: string;
|
||||
stepIndex: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
| {
|
||||
type: 'pipeline_step_complete';
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
stepId: string;
|
||||
stepName: string;
|
||||
stepIndex: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
| {
|
||||
type: 'auto_mode_error';
|
||||
error: string;
|
||||
|
||||
156
docs/pipeline-feature.md
Normal file
156
docs/pipeline-feature.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Pipeline Feature
|
||||
|
||||
Custom pipeline steps that run automatically after a feature completes "In Progress", creating a sequential workflow for code review, security audits, testing, and more.
|
||||
|
||||
## Overview
|
||||
|
||||
The pipeline feature allows users to define custom workflow steps that execute automatically after the main implementation phase. Each step prompts the agent with specific instructions while maintaining the full conversation context.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Feature completes "In Progress"** - When the agent finishes implementing a feature
|
||||
2. **Pipeline steps execute sequentially** - Each configured step runs in order
|
||||
3. **Agent receives instructions** - The step's instructions are sent to the agent
|
||||
4. **Context preserved** - Full chat history is maintained between steps
|
||||
5. **Final status** - After all steps complete, the feature moves to "Waiting Approval" or "Verified"
|
||||
|
||||
## Configuration
|
||||
|
||||
### Accessing Pipeline Settings
|
||||
|
||||
- Click the **gear icon** on the "In Progress" column header
|
||||
- Or click the gear icon on any pipeline step column
|
||||
|
||||
### Adding Pipeline Steps
|
||||
|
||||
1. Click **"Add Pipeline Step"**
|
||||
2. Optionally select a **pre-built template** from the dropdown:
|
||||
- Code Review
|
||||
- Security Review
|
||||
- Testing
|
||||
- Documentation
|
||||
- Performance Optimization
|
||||
3. Customize the **Step Name**
|
||||
4. Choose a **Color** for the column
|
||||
5. Write or modify the **Agent Instructions**
|
||||
6. Click **"Add Step"**
|
||||
|
||||
### Managing Steps
|
||||
|
||||
- **Reorder**: Use the up/down arrows to change step order
|
||||
- **Edit**: Click the pencil icon to modify a step
|
||||
- **Delete**: Click the trash icon to remove a step
|
||||
- **Load from file**: Upload a `.md` or `.txt` file for instructions
|
||||
|
||||
## Storage
|
||||
|
||||
Pipeline configuration is stored per-project at:
|
||||
|
||||
```
|
||||
{project}/.automaker/pipeline.json
|
||||
```
|
||||
|
||||
## Pre-built Templates
|
||||
|
||||
### Code Review
|
||||
|
||||
Comprehensive code quality review covering:
|
||||
|
||||
- Readability and maintainability
|
||||
- DRY principle and single responsibility
|
||||
- Best practices and conventions
|
||||
- Performance considerations
|
||||
- Test coverage
|
||||
|
||||
### Security Review
|
||||
|
||||
OWASP-focused security audit including:
|
||||
|
||||
- Input validation and sanitization
|
||||
- SQL injection and XSS prevention
|
||||
- Authentication and authorization
|
||||
- Data protection
|
||||
- Common vulnerability checks (OWASP Top 10)
|
||||
|
||||
### Testing
|
||||
|
||||
Test coverage verification:
|
||||
|
||||
- Unit test requirements
|
||||
- Integration testing
|
||||
- Test quality standards
|
||||
- Running and validating tests
|
||||
|
||||
### Documentation
|
||||
|
||||
Documentation requirements:
|
||||
|
||||
- Code documentation (JSDoc/docstrings)
|
||||
- API documentation
|
||||
- README updates
|
||||
- Changelog entries
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
Performance review covering:
|
||||
|
||||
- Algorithm optimization
|
||||
- Memory usage
|
||||
- Database/API optimization
|
||||
- Frontend performance (if applicable)
|
||||
|
||||
## UI Changes
|
||||
|
||||
### Kanban Board
|
||||
|
||||
- Pipeline columns appear between "In Progress" and "Waiting Approval"
|
||||
- Each pipeline column shows features currently in that step
|
||||
- Gear icon on columns opens pipeline settings
|
||||
|
||||
### Horizontal Scrolling
|
||||
|
||||
- Board supports horizontal scrolling when many columns exist
|
||||
- Minimum window width reduced to 600px to accommodate various screen sizes
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Files Modified
|
||||
|
||||
**Types:**
|
||||
|
||||
- `libs/types/src/pipeline.ts` - PipelineStep, PipelineConfig types
|
||||
- `libs/types/src/index.ts` - Export pipeline types
|
||||
|
||||
**Server:**
|
||||
|
||||
- `apps/server/src/services/pipeline-service.ts` - CRUD operations, status transitions
|
||||
- `apps/server/src/routes/pipeline/` - API endpoints
|
||||
- `apps/server/src/services/auto-mode-service.ts` - Pipeline execution integration
|
||||
|
||||
**UI:**
|
||||
|
||||
- `apps/ui/src/store/app-store.ts` - Pipeline state management
|
||||
- `apps/ui/src/lib/http-api-client.ts` - Pipeline API client
|
||||
- `apps/ui/src/components/views/board-view/constants.ts` - Dynamic column generation
|
||||
- `apps/ui/src/components/views/board-view/kanban-board.tsx` - Pipeline props, scrolling
|
||||
- `apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx` - Settings UI
|
||||
- `apps/ui/src/hooks/use-responsive-kanban.ts` - Scroll support
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```
|
||||
POST /api/pipeline/config - Get pipeline config
|
||||
POST /api/pipeline/config/save - Save pipeline config
|
||||
POST /api/pipeline/steps/add - Add a step
|
||||
POST /api/pipeline/steps/update - Update a step
|
||||
POST /api/pipeline/steps/delete - Delete a step
|
||||
POST /api/pipeline/steps/reorder - Reorder steps
|
||||
```
|
||||
|
||||
### Status Flow
|
||||
|
||||
```
|
||||
backlog → in_progress → pipeline_step1 → pipeline_step2 → ... → verified/waiting_approval
|
||||
```
|
||||
|
||||
Pipeline statuses use the format `pipeline_{stepId}` to support unlimited dynamic steps.
|
||||
@@ -13,6 +13,10 @@ export type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from './provider.js';
|
||||
|
||||
// Feature types
|
||||
@@ -54,6 +58,8 @@ export type {
|
||||
ModelProvider,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
MCPToolInfo,
|
||||
MCPServerConfig,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
ChatSessionRef,
|
||||
@@ -105,3 +111,11 @@ export type {
|
||||
BacklogPlanRequest,
|
||||
BacklogPlanApplyResult,
|
||||
} from './backlog-plan.js';
|
||||
|
||||
// Pipeline types
|
||||
export type {
|
||||
PipelineStep,
|
||||
PipelineConfig,
|
||||
PipelineStatus,
|
||||
FeatureStatusWithPipeline,
|
||||
} from './pipeline.js';
|
||||
|
||||
28
libs/types/src/pipeline.ts
Normal file
28
libs/types/src/pipeline.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Pipeline types for AutoMaker custom workflow steps
|
||||
*/
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PipelineConfig {
|
||||
version: 1;
|
||||
steps: PipelineStep[];
|
||||
}
|
||||
|
||||
export type PipelineStatus = `pipeline_${string}`;
|
||||
|
||||
export type FeatureStatusWithPipeline =
|
||||
| 'backlog'
|
||||
| 'in_progress'
|
||||
| 'waiting_approval'
|
||||
| 'verified'
|
||||
| 'completed'
|
||||
| PipelineStatus;
|
||||
@@ -28,6 +28,38 @@ export interface SystemPromptPreset {
|
||||
append?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP server configuration types for SDK options
|
||||
* Matches the Claude Agent SDK's McpServerConfig types
|
||||
*/
|
||||
export type McpServerConfig = McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig;
|
||||
|
||||
/**
|
||||
* Stdio-based MCP server (subprocess)
|
||||
* Note: `type` is optional and defaults to 'stdio' to match SDK behavior
|
||||
* and allow simpler configs like { command: "node", args: ["server.js"] }
|
||||
*/
|
||||
export interface McpStdioServerConfig {
|
||||
type?: 'stdio';
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** SSE-based MCP server */
|
||||
export interface McpSSEServerConfig {
|
||||
type: 'sse';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** HTTP-based MCP server */
|
||||
export interface McpHttpServerConfig {
|
||||
type: 'http';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a query via a provider
|
||||
*/
|
||||
@@ -38,7 +70,9 @@ export interface ExecuteOptions {
|
||||
systemPrompt?: string | SystemPromptPreset;
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
mcpAutoApproveTools?: boolean; // Auto-approve MCP tool calls without permission prompts
|
||||
mcpUnrestrictedTools?: boolean; // Allow unrestricted tools when MCP servers are enabled
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
||||
|
||||
@@ -163,6 +163,55 @@ export interface AIProfile {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCPToolInfo - Information about a tool provided by an MCP server
|
||||
*
|
||||
* Contains the tool's name, description, and whether it's enabled for use.
|
||||
*/
|
||||
export interface MCPToolInfo {
|
||||
/** Tool name as exposed by the MCP server */
|
||||
name: string;
|
||||
/** Description of what the tool does */
|
||||
description?: string;
|
||||
/** JSON Schema for the tool's input parameters */
|
||||
inputSchema?: Record<string, unknown>;
|
||||
/** Whether this tool is enabled for use (defaults to true) */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCPServerConfig - Configuration for an MCP (Model Context Protocol) server
|
||||
*
|
||||
* MCP servers provide additional tools and capabilities to AI agents.
|
||||
* Supports stdio (subprocess), SSE, and HTTP transport types.
|
||||
*/
|
||||
export interface MCPServerConfig {
|
||||
/** Unique identifier for the server config */
|
||||
id: string;
|
||||
/** Display name for the server */
|
||||
name: string;
|
||||
/** User-friendly description of what this server provides */
|
||||
description?: string;
|
||||
/** Transport type: stdio (default), sse, or http */
|
||||
type?: 'stdio' | 'sse' | 'http';
|
||||
/** For stdio: command to execute (e.g., 'node', 'python', 'npx') */
|
||||
command?: string;
|
||||
/** For stdio: arguments to pass to the command */
|
||||
args?: string[];
|
||||
/** For stdio: environment variables to set */
|
||||
env?: Record<string, string>;
|
||||
/** For sse/http: URL endpoint */
|
||||
url?: string;
|
||||
/** For sse/http: headers to include in requests */
|
||||
headers?: Record<string, string>;
|
||||
/** Whether this server is enabled */
|
||||
enabled?: boolean;
|
||||
/** Tools discovered from this server with their enabled states */
|
||||
tools?: MCPToolInfo[];
|
||||
/** Timestamp when tools were last fetched */
|
||||
toolsLastFetched?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectRef - Minimal reference to a project stored in global settings
|
||||
*
|
||||
@@ -303,6 +352,14 @@ export interface GlobalSettings {
|
||||
autoLoadClaudeMd?: boolean;
|
||||
/** Enable sandbox mode for bash commands (default: true, disable if issues occur) */
|
||||
enableSandboxMode?: boolean;
|
||||
|
||||
// MCP Server Configuration
|
||||
/** List of configured MCP servers for agent use */
|
||||
mcpServers: MCPServerConfig[];
|
||||
/** Auto-approve MCP tool calls without permission prompts (uses bypassPermissions mode) */
|
||||
mcpAutoApproveTools?: boolean;
|
||||
/** Allow unrestricted tools when MCP servers are enabled (don't filter allowedTools) */
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,6 +519,11 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
lastSelectedSessionByProject: {},
|
||||
autoLoadClaudeMd: false,
|
||||
enableSandboxMode: true,
|
||||
mcpServers: [],
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
};
|
||||
|
||||
/** Default credentials (empty strings - user must provide API keys) */
|
||||
|
||||
219
package-lock.json
generated
219
package-lock.json
generated
@@ -36,6 +36,7 @@
|
||||
"@automaker/prompts": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
@@ -2647,6 +2648,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -3473,6 +3486,67 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"jose": "^6.1.1",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
||||
@@ -6802,6 +6876,45 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
@@ -9436,6 +9549,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -9458,6 +9592,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9496,6 +9631,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -9538,7 +9688,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
@@ -9555,6 +9704,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
@@ -10320,6 +10485,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.3",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz",
|
||||
"integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -10911,6 +11086,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -10958,6 +11142,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
@@ -13468,6 +13658,15 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
@@ -14034,6 +14233,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
@@ -16549,6 +16757,15 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
|
||||
Reference in New Issue
Block a user