mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
20 Commits
v0.7.2
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79252be5c | ||
|
|
2bbc8113c0 | ||
|
|
7e03af2dc6 | ||
|
|
ab9ef0d560 | ||
|
|
844be657c8 | ||
|
|
90c89ef338 | ||
|
|
fb46c0c9ea | ||
|
|
81bd57cf6a | ||
|
|
59d47928a7 | ||
|
|
bd432b1da3 | ||
|
|
b51aed849c | ||
|
|
90e62b8add | ||
|
|
67c6c9a9e7 | ||
|
|
2d66e38fa7 | ||
|
|
50aac1c218 | ||
|
|
8c8a4875ca | ||
|
|
eec36268fe | ||
|
|
f6efbd1b26 | ||
|
|
e818922b0d | ||
|
|
495af733da |
@@ -34,10 +34,31 @@ This command accepts a version bump type as input:
|
|||||||
- Injects the version into the app via Vite's `__APP_VERSION__` constant
|
- Injects the version into the app via Vite's `__APP_VERSION__` constant
|
||||||
- Displays the version below the logo in the sidebar
|
- Displays the version below the logo in the sidebar
|
||||||
|
|
||||||
4. **Verify the release**
|
4. **Commit the version bump**
|
||||||
|
- Stage the updated package.json files:
|
||||||
|
```bash
|
||||||
|
git add apps/ui/package.json apps/server/package.json
|
||||||
|
```
|
||||||
|
- Commit with a release message:
|
||||||
|
```bash
|
||||||
|
git commit -m "chore: release v<version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create and push the git tag**
|
||||||
|
- Create an annotated tag for the release:
|
||||||
|
```bash
|
||||||
|
git tag -a v<version> -m "Release v<version>"
|
||||||
|
```
|
||||||
|
- Push the commit and tag to remote:
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Verify the release**
|
||||||
- Check that the build completed successfully
|
- Check that the build completed successfully
|
||||||
- Confirm the version appears correctly in the built artifacts
|
- Confirm the version appears correctly in the built artifacts
|
||||||
- The version will be displayed in the app UI below the logo
|
- The version will be displayed in the app UI below the logo
|
||||||
|
- Verify the tag is visible on the remote repository
|
||||||
|
|
||||||
## Version Centralization
|
## Version Centralization
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||||
@@ -47,6 +48,128 @@ export function validateWorkingDirectory(cwd: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known cloud storage path patterns where sandbox mode is incompatible.
|
||||||
|
*
|
||||||
|
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
|
||||||
|
* cloud storage providers' virtual filesystem implementations. This causes the
|
||||||
|
* Claude process to exit with code 1 when sandbox is enabled for these paths.
|
||||||
|
*
|
||||||
|
* Affected providers (macOS paths):
|
||||||
|
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
|
||||||
|
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
|
||||||
|
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
|
||||||
|
* - iCloud Drive: ~/Library/Mobile Documents/
|
||||||
|
* - Box: ~/Library/CloudStorage/Box-*
|
||||||
|
*
|
||||||
|
* @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS-specific cloud storage patterns that appear under ~/Library/
|
||||||
|
* These are specific enough to use with includes() safely.
|
||||||
|
*/
|
||||||
|
const MACOS_CLOUD_STORAGE_PATTERNS = [
|
||||||
|
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
|
||||||
|
'/Library/Mobile Documents/', // iCloud Drive on macOS
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic cloud storage folder names that need to be anchored to the home directory
|
||||||
|
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
|
||||||
|
*/
|
||||||
|
const HOME_ANCHORED_CLOUD_FOLDERS = [
|
||||||
|
'Google Drive', // Google Drive on some systems
|
||||||
|
'Dropbox', // Dropbox on Linux/alternative installs
|
||||||
|
'OneDrive', // OneDrive on Linux/alternative installs
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path is within a cloud storage location.
|
||||||
|
*
|
||||||
|
* Cloud storage providers use virtual filesystem implementations that are
|
||||||
|
* incompatible with the Claude CLI sandbox feature, causing process crashes.
|
||||||
|
*
|
||||||
|
* Uses two detection strategies:
|
||||||
|
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
|
||||||
|
* 2. Generic folder names - anchored to home directory to avoid false positives
|
||||||
|
*
|
||||||
|
* @param cwd - The working directory path to check
|
||||||
|
* @returns true if the path is in a cloud storage location
|
||||||
|
*/
|
||||||
|
export function isCloudStoragePath(cwd: string): boolean {
|
||||||
|
const resolvedPath = path.resolve(cwd);
|
||||||
|
|
||||||
|
// Check macOS-specific patterns (these are specific enough to use includes)
|
||||||
|
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => resolvedPath.includes(pattern))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check home-anchored patterns to avoid false positives
|
||||||
|
// e.g., /home/user/my-project-about-dropbox/ should NOT match
|
||||||
|
const home = os.homedir();
|
||||||
|
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
|
||||||
|
const cloudPath = path.join(home, folder);
|
||||||
|
// Check if resolved path starts with the cloud storage path followed by a separator
|
||||||
|
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
|
||||||
|
if (resolvedPath === cloudPath || resolvedPath.startsWith(cloudPath + path.sep)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of sandbox compatibility check
|
||||||
|
*/
|
||||||
|
export interface SandboxCheckResult {
|
||||||
|
/** Whether sandbox should be enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** If disabled, the reason why */
|
||||||
|
disabledReason?: 'cloud_storage' | 'user_setting';
|
||||||
|
/** Human-readable message for logging/UI */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if sandbox mode should be enabled for a given configuration.
|
||||||
|
*
|
||||||
|
* Sandbox mode is automatically disabled for cloud storage paths because the
|
||||||
|
* Claude CLI sandbox feature is incompatible with virtual filesystem
|
||||||
|
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
|
||||||
|
*
|
||||||
|
* @param cwd - The working directory
|
||||||
|
* @param enableSandboxMode - User's sandbox mode setting
|
||||||
|
* @returns SandboxCheckResult with enabled status and reason if disabled
|
||||||
|
*/
|
||||||
|
export function checkSandboxCompatibility(
|
||||||
|
cwd: string,
|
||||||
|
enableSandboxMode?: boolean
|
||||||
|
): SandboxCheckResult {
|
||||||
|
// User has explicitly disabled sandbox mode
|
||||||
|
if (enableSandboxMode === false) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
disabledReason: 'user_setting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cloud storage incompatibility (applies when enabled or undefined)
|
||||||
|
if (isCloudStoragePath(cwd)) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
disabledReason: 'cloud_storage',
|
||||||
|
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool presets for different use cases
|
* Tool presets for different use cases
|
||||||
*/
|
*/
|
||||||
@@ -381,7 +504,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
|||||||
* - Full tool access for code modification
|
* - Full tool access for code modification
|
||||||
* - Standard turns for interactive sessions
|
* - Standard turns for interactive sessions
|
||||||
* - Model priority: explicit model > session model > chat default
|
* - Model priority: explicit model > session model > chat default
|
||||||
* - Sandbox mode controlled by enableSandboxMode setting
|
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||||
*/
|
*/
|
||||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||||
@@ -397,6 +520,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build MCP-related options
|
// Build MCP-related options
|
||||||
const mcpOptions = buildMcpOptions(config);
|
const mcpOptions = buildMcpOptions(config);
|
||||||
|
|
||||||
|
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||||
|
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('chat', effectiveModel),
|
model: getModelForUseCase('chat', effectiveModel),
|
||||||
@@ -406,7 +532,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||||
// Apply MCP bypass options if configured
|
// Apply MCP bypass options if configured
|
||||||
...mcpOptions.bypassOptions,
|
...mcpOptions.bypassOptions,
|
||||||
...(config.enableSandboxMode && {
|
...(sandboxCheck.enabled && {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoAllowBashIfSandboxed: true,
|
autoAllowBashIfSandboxed: true,
|
||||||
@@ -425,7 +551,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
* - Full tool access for code modification and implementation
|
* - Full tool access for code modification and implementation
|
||||||
* - Extended turns for thorough feature implementation
|
* - Extended turns for thorough feature implementation
|
||||||
* - Uses default model (can be overridden)
|
* - Uses default model (can be overridden)
|
||||||
* - Sandbox mode controlled by enableSandboxMode setting
|
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||||
*/
|
*/
|
||||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||||
@@ -438,6 +564,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build MCP-related options
|
// Build MCP-related options
|
||||||
const mcpOptions = buildMcpOptions(config);
|
const mcpOptions = buildMcpOptions(config);
|
||||||
|
|
||||||
|
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||||
|
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('auto', config.model),
|
model: getModelForUseCase('auto', config.model),
|
||||||
@@ -447,7 +576,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||||
// Apply MCP bypass options if configured
|
// Apply MCP bypass options if configured
|
||||||
...mcpOptions.bypassOptions,
|
...mcpOptions.bypassOptions,
|
||||||
...(config.enableSandboxMode && {
|
...(sandboxCheck.enabled && {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoAllowBashIfSandboxed: true,
|
autoAllowBashIfSandboxed: true,
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||||
|
const IS_WINDOWS = process.platform === 'win32';
|
||||||
|
|
||||||
export interface MCPTestResult {
|
export interface MCPTestResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -41,6 +45,11 @@ export class MCPTestService {
|
|||||||
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let client: Client | null = null;
|
let client: Client | null = null;
|
||||||
|
let transport:
|
||||||
|
| StdioClientTransport
|
||||||
|
| SSEClientTransport
|
||||||
|
| StreamableHTTPClientTransport
|
||||||
|
| null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client = new Client({
|
client = new Client({
|
||||||
@@ -49,7 +58,7 @@ export class MCPTestService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create transport based on server type
|
// Create transport based on server type
|
||||||
const transport = await this.createTransport(serverConfig);
|
transport = await this.createTransport(serverConfig);
|
||||||
|
|
||||||
// Connect with timeout
|
// Connect with timeout
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
@@ -98,13 +107,47 @@ export class MCPTestService {
|
|||||||
connectionTime,
|
connectionTime,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up client connection
|
// Clean up client connection and ensure process termination
|
||||||
if (client) {
|
await this.cleanupConnection(client, transport);
|
||||||
try {
|
}
|
||||||
await client.close();
|
}
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
/**
|
||||||
}
|
* Clean up MCP client connection and terminate spawned processes
|
||||||
|
*
|
||||||
|
* On Windows, child processes spawned via 'cmd /c' don't get terminated when the
|
||||||
|
* parent process is killed. We use taskkill with /t flag to kill the entire process tree.
|
||||||
|
* This prevents orphaned MCP server processes that would spam logs with ping warnings.
|
||||||
|
*
|
||||||
|
* IMPORTANT: We must run taskkill BEFORE client.close() because:
|
||||||
|
* - client.close() kills only the parent cmd.exe process
|
||||||
|
* - This orphans the child node.exe processes before we can kill them
|
||||||
|
* - taskkill /t needs the parent PID to exist to traverse the process tree
|
||||||
|
*/
|
||||||
|
private async cleanupConnection(
|
||||||
|
client: Client | null,
|
||||||
|
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null
|
||||||
|
): Promise<void> {
|
||||||
|
// Get the PID before any cleanup (only available for stdio transports)
|
||||||
|
const pid = transport instanceof StdioClientTransport ? transport.pid : null;
|
||||||
|
|
||||||
|
// On Windows with stdio transport, kill the entire process tree FIRST
|
||||||
|
// This must happen before client.close() which would orphan child processes
|
||||||
|
if (IS_WINDOWS && pid) {
|
||||||
|
try {
|
||||||
|
// taskkill /f = force, /t = kill process tree, /pid = process ID
|
||||||
|
await execAsync(`taskkill /f /t /pid ${pid}`);
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited, which is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now do the standard close (may be a no-op if taskkill already killed everything)
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch {
|
||||||
|
// Expected if taskkill already terminated the process
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,161 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
describe('sdk-options.ts', () => {
|
describe('sdk-options.ts', () => {
|
||||||
let originalEnv: NodeJS.ProcessEnv;
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
|
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
originalEnv = { ...process.env };
|
originalEnv = { ...process.env };
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
// Spy on os.homedir and set default return value
|
||||||
|
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
|
homedirSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCloudStoragePath', () => {
|
||||||
|
it('should detect Dropbox paths on macOS', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Google Drive paths on macOS', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(
|
||||||
|
isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect OneDrive paths on macOS', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect iCloud Drive paths on macOS', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(
|
||||||
|
isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect home-anchored Dropbox paths', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true);
|
||||||
|
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true);
|
||||||
|
expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect home-anchored Google Drive paths', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true);
|
||||||
|
expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect home-anchored OneDrive paths', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true);
|
||||||
|
expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for local paths', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('/home/user/code/project')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('/var/www/app')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for relative paths not in cloud storage', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('./project')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('../other-project')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage
|
||||||
|
it('should NOT flag paths that merely contain "dropbox" in the name', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
// Projects with dropbox-like names
|
||||||
|
expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false);
|
||||||
|
// Dropbox folder that's NOT in the home directory
|
||||||
|
expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT flag paths that merely contain "Google Drive" in the name', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT flag paths that merely contain "OneDrive" in the name', async () => {
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false);
|
||||||
|
expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different home directories correctly', async () => {
|
||||||
|
// Change the mocked home directory
|
||||||
|
homedirSpy.mockReturnValue('/home/linuxuser');
|
||||||
|
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
|
// Should detect Dropbox under the Linux home directory
|
||||||
|
expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true);
|
||||||
|
// Should NOT detect Dropbox under the old home directory (since home changed)
|
||||||
|
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkSandboxCompatibility', () => {
|
||||||
|
it('should return enabled=false when user disables sandbox', async () => {
|
||||||
|
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||||
|
const result = checkSandboxCompatibility('/Users/test/project', false);
|
||||||
|
expect(result.enabled).toBe(false);
|
||||||
|
expect(result.disabledReason).toBe('user_setting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => {
|
||||||
|
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||||
|
const result = checkSandboxCompatibility(
|
||||||
|
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(result.enabled).toBe(false);
|
||||||
|
expect(result.disabledReason).toBe('cloud_storage');
|
||||||
|
expect(result.message).toContain('cloud storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enabled=true for local paths when sandbox enabled', async () => {
|
||||||
|
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||||
|
const result = checkSandboxCompatibility('/Users/test/projects/myapp', true);
|
||||||
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.disabledReason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enabled=true when enableSandboxMode is undefined for local paths', async () => {
|
||||||
|
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||||
|
const result = checkSandboxCompatibility('/Users/test/project', undefined);
|
||||||
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.disabledReason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => {
|
||||||
|
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||||
|
const result = checkSandboxCompatibility(
|
||||||
|
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(result.enabled).toBe(false);
|
||||||
|
expect(result.disabledReason).toBe('cloud_storage');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TOOL_PRESETS', () => {
|
describe('TOOL_PRESETS', () => {
|
||||||
@@ -224,13 +370,27 @@ describe('sdk-options.ts', () => {
|
|||||||
expect(options.sandbox).toBeUndefined();
|
expect(options.sandbox).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set sandbox when enableSandboxMode is not provided', async () => {
|
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
|
||||||
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
const options = createChatOptions({
|
const options = createChatOptions({
|
||||||
cwd: '/test/path',
|
cwd: '/test/path',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(options.sandbox).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-disable sandbox for cloud storage paths', async () => {
|
||||||
|
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
|
const options = createChatOptions({
|
||||||
|
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||||
|
enableSandboxMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(options.sandbox).toBeUndefined();
|
expect(options.sandbox).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -285,13 +445,48 @@ describe('sdk-options.ts', () => {
|
|||||||
expect(options.sandbox).toBeUndefined();
|
expect(options.sandbox).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set sandbox when enableSandboxMode is not provided', async () => {
|
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
|
||||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
const options = createAutoModeOptions({
|
const options = createAutoModeOptions({
|
||||||
cwd: '/test/path',
|
cwd: '/test/path',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(options.sandbox).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
autoAllowBashIfSandboxed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-disable sandbox for cloud storage paths', async () => {
|
||||||
|
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
|
const options = createAutoModeOptions({
|
||||||
|
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||||
|
enableSandboxMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(options.sandbox).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-disable sandbox for cloud storage paths even when enableSandboxMode is not provided', async () => {
|
||||||
|
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
|
const options = createAutoModeOptions({
|
||||||
|
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(options.sandbox).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-disable sandbox for iCloud paths', async () => {
|
||||||
|
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
|
const options = createAutoModeOptions({
|
||||||
|
cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project',
|
||||||
|
enableSandboxMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(options.sandbox).toBeUndefined();
|
expect(options.sandbox).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
interface SandboxRiskDialogProps {
|
interface SandboxRiskDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: (skipInFuture: boolean) => void;
|
||||||
onDeny: () => void;
|
onDeny: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +29,13 @@ const DOCKER_COMMAND = 'npm run dev:docker';
|
|||||||
|
|
||||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [skipInFuture, setSkipInFuture] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(skipInFuture);
|
||||||
|
// Reset checkbox state after confirmation
|
||||||
|
setSkipInFuture(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -93,18 +102,34 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
|
||||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
<div className="flex items-center space-x-2 self-start">
|
||||||
Deny & Exit
|
<Checkbox
|
||||||
</Button>
|
id="skip-sandbox-warning"
|
||||||
<Button
|
checked={skipInFuture}
|
||||||
variant="destructive"
|
onCheckedChange={(checked) => setSkipInFuture(checked === true)}
|
||||||
onClick={onConfirm}
|
data-testid="sandbox-skip-checkbox"
|
||||||
className="px-4"
|
/>
|
||||||
data-testid="sandbox-confirm"
|
<Label
|
||||||
>
|
htmlFor="skip-sandbox-warning"
|
||||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
className="text-sm text-muted-foreground cursor-pointer"
|
||||||
</Button>
|
>
|
||||||
|
Do not show this warning again
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 sm:gap-2 w-full sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||||
|
Deny & Exit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="px-4"
|
||||||
|
data-testid="sandbox-confirm"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -30,6 +30,29 @@ interface CardActionsProps {
|
|||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render contextual action buttons for a feature row based on the feature's status and whether it is the current automated task.
|
||||||
|
*
|
||||||
|
* Renders an appropriate set of buttons (Approve Plan, Logs, Force Stop, Verify, Resume, Complete, Edit, View Plan, Make, Refine, etc.) depending on
|
||||||
|
* feature properties (status, planSpec, skipTests, prUrl, id), the isCurrentAutoTask flag, and which callback props are provided.
|
||||||
|
*
|
||||||
|
* @param feature - The feature object whose status and metadata determine which actions are shown.
|
||||||
|
* @param isCurrentAutoTask - When true, renders actions relevant to the currently running automated task.
|
||||||
|
* @param hasContext - If true, indicates the feature has surrounding context (affects layout/availability in some states).
|
||||||
|
* @param shortcutKey - Optional keyboard shortcut label shown next to the Logs button when present.
|
||||||
|
* @param onEdit - Invoked when the Edit button is clicked.
|
||||||
|
* @param onViewOutput - Invoked when a Logs/View Output button is clicked.
|
||||||
|
* @param onVerify - Invoked when the Verify button (verification pathway) is clicked.
|
||||||
|
* @param onResume - Invoked when the Resume button is clicked.
|
||||||
|
* @param onForceStop - Invoked when the Force Stop button is clicked.
|
||||||
|
* @param onManualVerify - Invoked when a manual verification button is clicked.
|
||||||
|
* @param onFollowUp - Invoked when the Refine/Follow-up button is clicked.
|
||||||
|
* @param onImplement - Invoked when the Make/Implement button is clicked.
|
||||||
|
* @param onComplete - Invoked when the Complete button is clicked.
|
||||||
|
* @param onViewPlan - Invoked when the View Plan button is clicked.
|
||||||
|
* @param onApprovePlan - Invoked when the Approve Plan button is clicked.
|
||||||
|
* @returns The JSX element containing the action buttons for the feature row.
|
||||||
|
*/
|
||||||
export function CardActions({
|
export function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
@@ -109,73 +132,90 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
{!isCurrentAutoTask &&
|
||||||
<>
|
(feature.status === 'in_progress' ||
|
||||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
|
||||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
<>
|
||||||
<Button
|
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||||
variant="default"
|
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||||
size="sm"
|
<Button
|
||||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
variant="default"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
onApprovePlan();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onApprovePlan();
|
||||||
data-testid={`approve-plan-${feature.id}`}
|
}}
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
data-testid={`approve-plan-${feature.id}`}
|
||||||
Approve Plan
|
>
|
||||||
</Button>
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
)}
|
Approve Plan
|
||||||
{feature.skipTests && onManualVerify ? (
|
</Button>
|
||||||
<Button
|
)}
|
||||||
variant="default"
|
{feature.skipTests && onManualVerify ? (
|
||||||
size="sm"
|
<Button
|
||||||
className="flex-1 h-7 text-[11px]"
|
variant="default"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
className="flex-1 h-7 text-[11px]"
|
||||||
onManualVerify();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onManualVerify();
|
||||||
data-testid={`manual-verify-${feature.id}`}
|
}}
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
data-testid={`manual-verify-${feature.id}`}
|
||||||
Verify
|
>
|
||||||
</Button>
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
) : onResume ? (
|
Verify
|
||||||
<Button
|
</Button>
|
||||||
variant="default"
|
) : onResume ? (
|
||||||
size="sm"
|
<Button
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
variant="default"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
onResume();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onResume();
|
||||||
data-testid={`resume-feature-${feature.id}`}
|
}}
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
data-testid={`resume-feature-${feature.id}`}
|
||||||
Resume
|
>
|
||||||
</Button>
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
) : null}
|
Resume
|
||||||
{onViewOutput && !feature.skipTests && (
|
</Button>
|
||||||
<Button
|
) : onVerify ? (
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="default"
|
||||||
className="h-7 text-[11px] px-2"
|
size="sm"
|
||||||
onClick={(e) => {
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onViewOutput();
|
e.stopPropagation();
|
||||||
}}
|
onVerify();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
}}
|
||||||
data-testid={`view-output-inprogress-${feature.id}`}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
data-testid={`verify-feature-${feature.id}`}
|
||||||
<FileText className="w-3 h-3" />
|
>
|
||||||
</Button>
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
)}
|
Verify
|
||||||
</>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
|
{onViewOutput && !feature.skipTests && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px] px-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-inprogress-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === 'verified' && (
|
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||||
<>
|
<>
|
||||||
{/* Logs button */}
|
{/* Logs button */}
|
||||||
@@ -319,4 +359,4 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,23 @@ interface UseBoardEffectsProps {
|
|||||||
setFeaturesWithContext: (set: Set<string>) => void;
|
setFeaturesWithContext: (set: Set<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers and manages side effects for the board view (IPC/event listeners, global exposure, and context checks).
|
||||||
|
*
|
||||||
|
* Sets up event subscriptions to suggestions, spec regeneration, and auto-mode events; exposes the current project globally for modals; syncs running tasks from the backend; and maintains the set of feature IDs that have associated context files.
|
||||||
|
*
|
||||||
|
* @param currentProject - The active project object or `null`. Exposed globally for modal use and used when syncing backend state.
|
||||||
|
* @param specCreatingForProject - Project path currently undergoing spec regeneration, or `null`.
|
||||||
|
* @param setSpecCreatingForProject - Setter to clear or set the spec-regenerating project path.
|
||||||
|
* @param setSuggestionsCount - Setter for the persisted number of suggestion items.
|
||||||
|
* @param setFeatureSuggestions - Setter for the latest suggestion payload.
|
||||||
|
* @param setIsGeneratingSuggestions - Setter to mark whether suggestions are being generated.
|
||||||
|
* @param checkContextExists - Async function that returns whether a given feature ID has context files.
|
||||||
|
* @param features - Array of feature records to evaluate for potential context files.
|
||||||
|
* @param isLoading - Flag indicating whether features are still loading; context checks run only when loading is complete.
|
||||||
|
* @param featuresWithContext - Set of feature IDs currently known to have context files.
|
||||||
|
* @param setFeaturesWithContext - Setter that replaces the set of feature IDs that have context files.
|
||||||
|
*/
|
||||||
export function useBoardEffects({
|
export function useBoardEffects({
|
||||||
currentProject,
|
currentProject,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
@@ -130,7 +147,10 @@ export function useBoardEffects({
|
|||||||
const checkAllContexts = async () => {
|
const checkAllContexts = async () => {
|
||||||
const featuresWithPotentialContext = features.filter(
|
const featuresWithPotentialContext = features.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified'
|
f.status === 'in_progress' ||
|
||||||
|
f.status === 'waiting_approval' ||
|
||||||
|
f.status === 'verified' ||
|
||||||
|
(typeof f.status === 'string' && f.status.startsWith('pipeline_'))
|
||||||
);
|
);
|
||||||
const contextChecks = await Promise.all(
|
const contextChecks = await Promise.all(
|
||||||
featuresWithPotentialContext.map(async (f) => ({
|
featuresWithPotentialContext.map(async (f) => ({
|
||||||
@@ -179,4 +199,4 @@ export function useBoardEffects({
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [checkContextExists, setFeaturesWithContext]);
|
}, [checkContextExists, setFeaturesWithContext]);
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,13 @@ import { login } from '@/lib/http-api-client';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
|
||||||
export function LoginView() {
|
export function LoginView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setAuthState = useAuthStore((s) => s.setAuthState);
|
||||||
|
const setupComplete = useSetupStore((s) => s.setupComplete);
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -26,8 +30,11 @@ export function LoginView() {
|
|||||||
try {
|
try {
|
||||||
const result = await login(apiKey.trim());
|
const result = await login(apiKey.trim());
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Redirect to home/board on success
|
// Mark as authenticated for this session (cookie-based auth)
|
||||||
navigate({ to: '/' });
|
setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
|
|
||||||
|
// After auth, determine if setup is needed or go to app
|
||||||
|
navigate({ to: setupComplete ? '/' : '/setup' });
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Invalid API key');
|
setError(result.error || 'Invalid API key');
|
||||||
}
|
}
|
||||||
@@ -73,7 +80,7 @@ export function LoginView() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function SettingsView() {
|
|||||||
setAutoLoadClaudeMd,
|
setAutoLoadClaudeMd,
|
||||||
enableSandboxMode,
|
enableSandboxMode,
|
||||||
setEnableSandboxMode,
|
setEnableSandboxMode,
|
||||||
|
skipSandboxWarning,
|
||||||
|
setSkipSandboxWarning,
|
||||||
promptCustomization,
|
promptCustomization,
|
||||||
setPromptCustomization,
|
setPromptCustomization,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
@@ -184,6 +186,8 @@ export function SettingsView() {
|
|||||||
<DangerZoneSection
|
<DangerZoneSection
|
||||||
project={settingsProject}
|
project={settingsProject}
|
||||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||||
|
skipSandboxWarning={skipSandboxWarning}
|
||||||
|
onResetSandboxWarning={() => setSkipSandboxWarning(false)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
|
import { Trash2, Folder, AlertTriangle, Shield, RotateCcw } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Project } from '../shared/types';
|
import type { Project } from '../shared/types';
|
||||||
|
|
||||||
interface DangerZoneSectionProps {
|
interface DangerZoneSectionProps {
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
|
skipSandboxWarning: boolean;
|
||||||
|
onResetSandboxWarning: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
|
export function DangerZoneSection({
|
||||||
if (!project) return null;
|
project,
|
||||||
|
onDeleteClick,
|
||||||
|
skipSandboxWarning,
|
||||||
|
onResetSandboxWarning,
|
||||||
|
}: DangerZoneSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -28,35 +33,75 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
|
|||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
Permanently remove this project from Automaker.
|
Destructive actions and reset options.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
{/* Sandbox Warning Reset */}
|
||||||
<div className="flex items-center gap-3.5 min-w-0">
|
{skipSandboxWarning && (
|
||||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||||
<Folder className="w-5 h-5 text-brand-500" />
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
</div>
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-destructive/15 to-destructive/10 border border-destructive/20 flex items-center justify-center shrink-0">
|
||||||
<div className="min-w-0">
|
<Shield className="w-5 h-5 text-destructive" />
|
||||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">Sandbox Warning Disabled</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
||||||
|
The sandbox environment warning is hidden on startup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onResetSandboxWarning}
|
||||||
|
data-testid="reset-sandbox-warning-button"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 gap-2',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
'hover:scale-[1.02] active:scale-[0.98]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
)}
|
||||||
variant="destructive"
|
|
||||||
onClick={onDeleteClick}
|
{/* Project Delete */}
|
||||||
data-testid="delete-project-button"
|
{project && (
|
||||||
className={cn(
|
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||||
'shrink-0',
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
||||||
'transition-all duration-200 ease-out',
|
<Folder className="w-5 h-5 text-brand-500" />
|
||||||
'hover:scale-[1.02] active:scale-[0.98]'
|
</div>
|
||||||
)}
|
<div className="min-w-0">
|
||||||
>
|
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
|
||||||
Delete Project
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onDeleteClick}
|
||||||
|
data-testid="delete-project-button"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0',
|
||||||
|
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
'hover:scale-[1.02] active:scale-[0.98]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state when nothing to show */}
|
||||||
|
{!skipSandboxWarning && !project && (
|
||||||
|
<p className="text-sm text-muted-foreground/60 text-center py-4">
|
||||||
|
No danger zone actions available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
|||||||
validationModel: state.validationModel,
|
validationModel: state.validationModel,
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||||
enableSandboxMode: state.enableSandboxMode,
|
enableSandboxMode: state.enableSandboxMode,
|
||||||
|
skipSandboxWarning: state.skipSandboxWarning,
|
||||||
keyboardShortcuts: state.keyboardShortcuts,
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
mcpServers: state.mcpServers,
|
mcpServers: state.mcpServers,
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ let cachedServerUrl: string | null = null;
|
|||||||
* Must be called early in Electron mode before making API requests.
|
* Must be called early in Electron mode before making API requests.
|
||||||
*/
|
*/
|
||||||
export const initServerUrl = async (): Promise<void> => {
|
export const initServerUrl = async (): Promise<void> => {
|
||||||
if (typeof window !== 'undefined' && window.electronAPI?.getServerUrl) {
|
// window.electronAPI is typed as ElectronAPI, but some Electron-only helpers
|
||||||
|
// (like getServerUrl) are not part of the shared interface. Narrow via `any`.
|
||||||
|
const electron = typeof window !== 'undefined' ? (window.electronAPI as any) : null;
|
||||||
|
if (electron?.getServerUrl) {
|
||||||
try {
|
try {
|
||||||
cachedServerUrl = await window.electronAPI.getServerUrl();
|
cachedServerUrl = await electron.getServerUrl();
|
||||||
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
|
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
|
console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
|
||||||
@@ -109,7 +112,13 @@ export const clearSessionToken = (): void => {
|
|||||||
* Check if we're running in Electron mode
|
* Check if we're running in Electron mode
|
||||||
*/
|
*/
|
||||||
export const isElectronMode = (): boolean => {
|
export const isElectronMode = (): boolean => {
|
||||||
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey;
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
// Prefer a stable runtime marker from preload.
|
||||||
|
// In some dev/electron setups, method availability can be temporarily undefined
|
||||||
|
// during early startup, but `isElectron` remains reliable.
|
||||||
|
const api = window.electronAPI as any;
|
||||||
|
return api?.isElectron === true || !!api?.getApiKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,7 +316,9 @@ export const verifySession = async (): Promise<boolean> => {
|
|||||||
// Try to clear the cookie via logout (fire and forget)
|
// Try to clear the cookie via logout (fire and forget)
|
||||||
fetch(`${getServerUrl()}/api/auth/logout`, {
|
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: '{}',
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -356,7 +367,8 @@ type EventType =
|
|||||||
| 'auto-mode:event'
|
| 'auto-mode:event'
|
||||||
| 'suggestions:event'
|
| 'suggestions:event'
|
||||||
| 'spec-regeneration:event'
|
| 'spec-regeneration:event'
|
||||||
| 'issue-validation:event';
|
| 'issue-validation:event'
|
||||||
|
| 'backlog-plan:event';
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -378,17 +390,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.serverUrl = getServerUrl();
|
this.serverUrl = getServerUrl();
|
||||||
// Wait for API key initialization before connecting WebSocket
|
// Electron mode: connect WebSocket immediately once API key is ready.
|
||||||
// This prevents 401 errors on startup in Electron mode
|
// Web mode: defer WebSocket connection until a consumer subscribes to events,
|
||||||
waitForApiKeyInit()
|
// to avoid noisy 401s on first-load/login/setup routes.
|
||||||
.then(() => {
|
if (isElectronMode()) {
|
||||||
this.connectWebSocket();
|
waitForApiKeyInit()
|
||||||
})
|
.then(() => {
|
||||||
.catch((error) => {
|
this.connectWebSocket();
|
||||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
})
|
||||||
// Still attempt WebSocket connection - it may work with cookie auth
|
.catch((error) => {
|
||||||
this.connectWebSocket();
|
console.error('[HttpApiClient] API key initialization failed:', error);
|
||||||
});
|
// Still attempt WebSocket connection - it may work with cookie auth
|
||||||
|
this.connectWebSocket();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -436,9 +451,24 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
|
|
||||||
// In Electron mode, use API key directly
|
// Electron mode must authenticate with the injected API key.
|
||||||
const apiKey = getApiKey();
|
// If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow).
|
||||||
if (apiKey) {
|
if (isElectronMode()) {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn(
|
||||||
|
'[HttpApiClient] Electron mode: API key not ready, delaying WebSocket connect'
|
||||||
|
);
|
||||||
|
this.isConnecting = false;
|
||||||
|
if (!this.reconnectTimer) {
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||||
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
||||||
return;
|
return;
|
||||||
@@ -569,6 +599,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing JSON fails, use status text
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +623,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing JSON fails, use status text
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,6 +649,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing JSON fails, use status text
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,6 +674,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing JSON fails, use status text
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/contexts/file-browser-context';
|
} from '@/contexts/file-browser-context';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||||
import { isMac } from '@/lib/utils';
|
import { isMac } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -15,19 +16,22 @@ import {
|
|||||||
isElectronMode,
|
isElectronMode,
|
||||||
verifySession,
|
verifySession,
|
||||||
checkSandboxEnvironment,
|
checkSandboxEnvironment,
|
||||||
|
getServerUrlSync,
|
||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||||
|
|
||||||
// Session storage key for sandbox risk acknowledgment
|
|
||||||
const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged';
|
|
||||||
const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied';
|
|
||||||
|
|
||||||
function RootLayoutContent() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
|
const {
|
||||||
|
setIpcConnected,
|
||||||
|
currentProject,
|
||||||
|
getEffectiveTheme,
|
||||||
|
skipSandboxWarning,
|
||||||
|
setSkipSandboxWarning,
|
||||||
|
} = useAppStore();
|
||||||
const { setupComplete } = useSetupStore();
|
const { setupComplete } = useSetupStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
@@ -35,23 +39,18 @@ function RootLayoutContent() {
|
|||||||
const [setupHydrated, setSetupHydrated] = useState(
|
const [setupHydrated, setSetupHydrated] = useState(
|
||||||
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
||||||
);
|
);
|
||||||
const [authChecked, setAuthChecked] = useState(false);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
|
||||||
|
const isSetupRoute = location.pathname === '/setup';
|
||||||
|
const isLoginRoute = location.pathname === '/login';
|
||||||
|
|
||||||
// Sandbox environment check state
|
// Sandbox environment check state
|
||||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||||
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => {
|
// Always start from pending on a fresh page load so the user sees the prompt
|
||||||
// Check if user previously denied in this session
|
// each time the app is launched/refreshed (unless running in a container).
|
||||||
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) {
|
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>('pending');
|
||||||
return 'denied';
|
|
||||||
}
|
|
||||||
// Check if user previously acknowledged in this session
|
|
||||||
if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) {
|
|
||||||
return 'confirmed';
|
|
||||||
}
|
|
||||||
return 'pending';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hidden streamer panel - opens with "\" key
|
// Hidden streamer panel - opens with "\" key
|
||||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||||
@@ -113,6 +112,9 @@ function RootLayoutContent() {
|
|||||||
if (result.isContainerized) {
|
if (result.isContainerized) {
|
||||||
// Running in a container, no warning needed
|
// Running in a container, no warning needed
|
||||||
setSandboxStatus('containerized');
|
setSandboxStatus('containerized');
|
||||||
|
} else if (skipSandboxWarning) {
|
||||||
|
// User opted to skip the warning, auto-confirm
|
||||||
|
setSandboxStatus('confirmed');
|
||||||
} else {
|
} else {
|
||||||
// Not containerized, show warning dialog
|
// Not containerized, show warning dialog
|
||||||
setSandboxStatus('needs-confirmation');
|
setSandboxStatus('needs-confirmation');
|
||||||
@@ -120,23 +122,30 @@ function RootLayoutContent() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Sandbox] Failed to check environment:', error);
|
console.error('[Sandbox] Failed to check environment:', error);
|
||||||
// On error, assume not containerized and show warning
|
// On error, assume not containerized and show warning
|
||||||
setSandboxStatus('needs-confirmation');
|
if (skipSandboxWarning) {
|
||||||
|
setSandboxStatus('confirmed');
|
||||||
|
} else {
|
||||||
|
setSandboxStatus('needs-confirmation');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkSandbox();
|
checkSandbox();
|
||||||
}, [sandboxStatus]);
|
}, [sandboxStatus, skipSandboxWarning]);
|
||||||
|
|
||||||
// Handle sandbox risk confirmation
|
// Handle sandbox risk confirmation
|
||||||
const handleSandboxConfirm = useCallback(() => {
|
const handleSandboxConfirm = useCallback(
|
||||||
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
|
(skipInFuture: boolean) => {
|
||||||
setSandboxStatus('confirmed');
|
if (skipInFuture) {
|
||||||
}, []);
|
setSkipSandboxWarning(true);
|
||||||
|
}
|
||||||
|
setSandboxStatus('confirmed');
|
||||||
|
},
|
||||||
|
[setSkipSandboxWarning]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle sandbox risk denial
|
// Handle sandbox risk denial
|
||||||
const handleSandboxDeny = useCallback(async () => {
|
const handleSandboxDeny = useCallback(async () => {
|
||||||
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
|
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
// In Electron mode, quit the application
|
// In Electron mode, quit the application
|
||||||
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
|
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
|
||||||
@@ -156,19 +165,28 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Ref to prevent concurrent auth checks from running
|
||||||
|
const authCheckRunning = useRef(false);
|
||||||
|
|
||||||
// Initialize authentication
|
// Initialize authentication
|
||||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||||
// - Web mode: Uses HTTP-only session cookie
|
// - Web mode: Uses HTTP-only session cookie
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prevent concurrent auth checks
|
||||||
|
if (authCheckRunning.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
|
authCheckRunning.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize API key for Electron mode
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
await initApiKey();
|
||||||
|
|
||||||
// In Electron mode, we're always authenticated via header
|
// In Electron mode, we're always authenticated via header
|
||||||
if (isElectronMode()) {
|
if (isElectronMode()) {
|
||||||
setIsAuthenticated(true);
|
useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
setAuthChecked(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,31 +195,23 @@ function RootLayoutContent() {
|
|||||||
const isValid = await verifySession();
|
const isValid = await verifySession();
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
setIsAuthenticated(true);
|
useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
setAuthChecked(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is invalid or expired - redirect to login
|
// Session is invalid or expired - treat as not authenticated
|
||||||
console.log('Session invalid or expired - redirecting to login');
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
setIsAuthenticated(false);
|
|
||||||
setAuthChecked(true);
|
|
||||||
|
|
||||||
if (location.pathname !== '/login') {
|
|
||||||
navigate({ to: '/login' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize auth:', error);
|
console.error('Failed to initialize auth:', error);
|
||||||
setAuthChecked(true);
|
// On error, treat as not authenticated
|
||||||
// On error, redirect to login to be safe
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
if (location.pathname !== '/login') {
|
} finally {
|
||||||
navigate({ to: '/login' });
|
authCheckRunning.current = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initAuth();
|
initAuth();
|
||||||
}, [location.pathname, navigate]);
|
}, []); // Runs once per load; auth state drives routing rules
|
||||||
|
|
||||||
// Wait for setup store hydration before enforcing routing rules
|
// Wait for setup store hydration before enforcing routing rules
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -221,16 +231,34 @@ function RootLayoutContent() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Redirect first-run users (or anyone who reopened the wizard) to /setup
|
// Routing rules (web mode):
|
||||||
|
// - If not authenticated: force /login (even /setup is protected)
|
||||||
|
// - If authenticated but setup incomplete: force /setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setupHydrated) return;
|
if (!setupHydrated) return;
|
||||||
|
|
||||||
|
// Wait for auth check to complete before enforcing any redirects
|
||||||
|
if (!isElectronMode() && !authChecked) return;
|
||||||
|
|
||||||
|
// Unauthenticated -> force /login
|
||||||
|
if (!isElectronMode() && !isAuthenticated) {
|
||||||
|
if (location.pathname !== '/login') {
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated -> determine whether setup is required
|
||||||
if (!setupComplete && location.pathname !== '/setup') {
|
if (!setupComplete && location.pathname !== '/setup') {
|
||||||
navigate({ to: '/setup' });
|
navigate({ to: '/setup' });
|
||||||
} else if (setupComplete && location.pathname === '/setup') {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup complete but user is still on /setup -> go to app
|
||||||
|
if (setupComplete && location.pathname === '/setup') {
|
||||||
navigate({ to: '/' });
|
navigate({ to: '/' });
|
||||||
}
|
}
|
||||||
}, [setupComplete, setupHydrated, location.pathname, navigate]);
|
}, [authChecked, isAuthenticated, setupComplete, setupHydrated, location.pathname, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFileBrowser(openFileBrowser);
|
setGlobalFileBrowser(openFileBrowser);
|
||||||
@@ -240,9 +268,19 @@ function RootLayoutContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
if (isElectron()) {
|
||||||
const result = await api.ping();
|
const api = getElectronAPI();
|
||||||
setIpcConnected(result === 'pong');
|
const result = await api.ping();
|
||||||
|
setIpcConnected(result === 'pong');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web mode: check backend availability without instantiating the full HTTP client
|
||||||
|
const response = await fetch(`${getServerUrlSync()}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
setIpcConnected(response.ok);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('IPC connection failed:', error);
|
console.error('IPC connection failed:', error);
|
||||||
setIpcConnected(false);
|
setIpcConnected(false);
|
||||||
@@ -280,10 +318,6 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
}, [deferredTheme]);
|
}, [deferredTheme]);
|
||||||
|
|
||||||
// Login and setup views are full-screen without sidebar
|
|
||||||
const isSetupRoute = location.pathname === '/setup';
|
|
||||||
const isLoginRoute = location.pathname === '/login';
|
|
||||||
|
|
||||||
// Show rejection screen if user denied sandbox risk (web mode only)
|
// Show rejection screen if user denied sandbox risk (web mode only)
|
||||||
if (sandboxStatus === 'denied' && !isElectron()) {
|
if (sandboxStatus === 'denied' && !isElectron()) {
|
||||||
return <SandboxRejectionScreen />;
|
return <SandboxRejectionScreen />;
|
||||||
@@ -323,10 +357,16 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login if not authenticated (web mode)
|
// Redirect to login if not authenticated (web mode)
|
||||||
|
// Show loading state while navigation to login is in progress
|
||||||
if (!isElectronMode() && !isAuthenticated) {
|
if (!isElectronMode() && !isAuthenticated) {
|
||||||
return null; // Will redirect via useEffect
|
return (
|
||||||
|
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||||
|
<div className="text-muted-foreground">Redirecting to login...</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show setup page (full screen, no sidebar) - authenticated only
|
||||||
if (isSetupRoute) {
|
if (isSetupRoute) {
|
||||||
return (
|
return (
|
||||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ export interface AppState {
|
|||||||
// Claude Agent SDK Settings
|
// Claude Agent SDK Settings
|
||||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
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)
|
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
|
||||||
|
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
||||||
|
|
||||||
// MCP Servers
|
// MCP Servers
|
||||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||||
@@ -775,6 +776,7 @@ export interface AppActions {
|
|||||||
// Claude Agent SDK Settings actions
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||||
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
|
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
|
||||||
|
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||||
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
|
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
|
||||||
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
|
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
@@ -976,6 +978,7 @@ const initialState: AppState = {
|
|||||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
|
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
|
||||||
|
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||||
mcpServers: [], // No MCP servers configured by default
|
mcpServers: [], // No MCP servers configured by default
|
||||||
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
||||||
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
||||||
@@ -1623,6 +1626,12 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
|
setSkipSandboxWarning: async (skip) => {
|
||||||
|
set({ skipSandboxWarning: skip });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
await syncSettingsToServer();
|
||||||
|
},
|
||||||
setMcpAutoApproveTools: async (enabled) => {
|
setMcpAutoApproveTools: async (enabled) => {
|
||||||
set({ mcpAutoApproveTools: enabled });
|
set({ mcpAutoApproveTools: enabled });
|
||||||
// Sync to server settings file
|
// Sync to server settings file
|
||||||
@@ -2921,6 +2930,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
validationModel: state.validationModel,
|
validationModel: state.validationModel,
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||||
enableSandboxMode: state.enableSandboxMode,
|
enableSandboxMode: state.enableSandboxMode,
|
||||||
|
skipSandboxWarning: state.skipSandboxWarning,
|
||||||
// MCP settings
|
// MCP settings
|
||||||
mcpServers: state.mcpServers,
|
mcpServers: state.mcpServers,
|
||||||
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||||
|
|||||||
29
apps/ui/src/store/auth-store.ts
Normal file
29
apps/ui/src/store/auth-store.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
/** Whether we've attempted to determine auth status for this page load */
|
||||||
|
authChecked: boolean;
|
||||||
|
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthActions {
|
||||||
|
setAuthState: (state: Partial<AuthState>) => void;
|
||||||
|
resetAuth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
authChecked: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web authentication state.
|
||||||
|
*
|
||||||
|
* Intentionally NOT persisted: source of truth is the server session cookie.
|
||||||
|
*/
|
||||||
|
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
setAuthState: (state) => set(state),
|
||||||
|
resetAuth: () => set(initialState),
|
||||||
|
}));
|
||||||
22
init.mjs
22
init.mjs
@@ -268,6 +268,20 @@ function runNpm(args, options = {}) {
|
|||||||
return crossSpawn('npm', args, spawnOptions);
|
return crossSpawn('npm', args, spawnOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an npm command and wait for completion
|
||||||
|
*/
|
||||||
|
function runNpmAndWait(args, options = {}) {
|
||||||
|
const child = runNpm(args, options);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`));
|
||||||
|
});
|
||||||
|
child.on('error', (err) => reject(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run npx command using cross-spawn for Windows compatibility
|
* Run npx command using cross-spawn for Windows compatibility
|
||||||
*/
|
*/
|
||||||
@@ -525,6 +539,10 @@ async function main() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
log('Launching Web Application...', 'blue');
|
log('Launching Web Application...', 'blue');
|
||||||
|
|
||||||
|
// Build shared packages once (dev:server and dev:web both do this at the root level)
|
||||||
|
log('Building shared packages...', 'blue');
|
||||||
|
await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' });
|
||||||
|
|
||||||
// Start the backend server
|
// Start the backend server
|
||||||
log(`Starting backend server on port ${serverPort}...`, 'blue');
|
log(`Starting backend server on port ${serverPort}...`, 'blue');
|
||||||
|
|
||||||
@@ -535,7 +553,7 @@ async function main() {
|
|||||||
|
|
||||||
// Start server in background, showing output in console AND logging to file
|
// Start server in background, showing output in console AND logging to file
|
||||||
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
|
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
|
||||||
serverProcess = runNpm(['run', 'dev:server'], {
|
serverProcess = runNpm(['run', '_dev:server'], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
PORT: String(serverPort),
|
PORT: String(serverPort),
|
||||||
@@ -582,7 +600,7 @@ async function main() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Start web app
|
// Start web app
|
||||||
webProcess = runNpm(['run', 'dev:web'], {
|
webProcess = runNpm(['run', '_dev:web'], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: {
|
env: {
|
||||||
TEST_PORT: String(webPort),
|
TEST_PORT: String(webPort),
|
||||||
|
|||||||
@@ -353,6 +353,8 @@ export interface GlobalSettings {
|
|||||||
autoLoadClaudeMd?: boolean;
|
autoLoadClaudeMd?: boolean;
|
||||||
/** Enable sandbox mode for bash commands (default: false, enable for additional security) */
|
/** Enable sandbox mode for bash commands (default: false, enable for additional security) */
|
||||||
enableSandboxMode?: boolean;
|
enableSandboxMode?: boolean;
|
||||||
|
/** Skip showing the sandbox risk warning dialog */
|
||||||
|
skipSandboxWarning?: boolean;
|
||||||
|
|
||||||
// MCP Server Configuration
|
// MCP Server Configuration
|
||||||
/** List of configured MCP servers for agent use */
|
/** List of configured MCP servers for agent use */
|
||||||
@@ -531,6 +533,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
lastSelectedSessionByProject: {},
|
lastSelectedSessionByProject: {},
|
||||||
autoLoadClaudeMd: false,
|
autoLoadClaudeMd: false,
|
||||||
enableSandboxMode: false,
|
enableSandboxMode: false,
|
||||||
|
skipSandboxWarning: false,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||||
// via the security warning dialog that explains the risks.
|
// via the security warning dialog that explains the risks.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"test:server:coverage": "npm run test:cov --workspace=apps/server",
|
"test:server:coverage": "npm run test:cov --workspace=apps/server",
|
||||||
"test:packages": "npm run test -w @automaker/types -w @automaker/utils -w @automaker/prompts -w @automaker/platform -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils --if-present",
|
"test:packages": "npm run test -w @automaker/types -w @automaker/utils -w @automaker/prompts -w @automaker/platform -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils --if-present",
|
||||||
"test:all": "npm run test:packages && npm run test:server",
|
"test:all": "npm run test:packages && npm run test:server",
|
||||||
"lint:lockfile": "! grep -q 'git+ssh://' package-lock.json || (echo 'Error: package-lock.json contains git+ssh:// URLs. Run: git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"' && exit 1)",
|
"lint:lockfile": "node scripts/lint-lockfile.mjs",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"prepare": "husky && npm run build:packages"
|
"prepare": "husky && npm run build:packages"
|
||||||
|
|||||||
33
scripts/lint-lockfile.mjs
Normal file
33
scripts/lint-lockfile.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to check for git+ssh:// URLs in package-lock.json
|
||||||
|
* This ensures compatibility with CI/CD environments that don't support SSH.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const lockfilePath = join(process.cwd(), 'package-lock.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(lockfilePath, 'utf8');
|
||||||
|
|
||||||
|
// Check for git+ssh:// URLs
|
||||||
|
if (content.includes('git+ssh://')) {
|
||||||
|
console.error('Error: package-lock.json contains git+ssh:// URLs.');
|
||||||
|
console.error('Run: git config --global url."https://github.com/".insteadOf "git@github.com:"');
|
||||||
|
console.error('Or run: npm run fix:lockfile');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ No git+ssh:// URLs found in package-lock.json');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
console.error('Error: package-lock.json not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.error('Error checking package-lock.json:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user