Compare commits

...

17 Commits

Author SHA1 Message Date
webdevcody
329e429841 fix: downgrade @modelcontextprotocol/sdk to version 1.25.0 in package.json 2026-01-06 21:34:42 -05:00
antdev
46636cf385 fix background image and create test in case background image failed again 2026-01-07 02:04:22 +08:00
antdev
e24a6894a5 Merge remote-tracking branch 'refs/remotes/origin/main'
# Conflicts:
#	apps/ui/src/routes/__root.tsx
2026-01-07 01:48:34 +08:00
antdev
cf9289e21a fix background image and create test incase background image failed again 2026-01-07 01:21:46 +08:00
webdevcody
fe7bc954ba chore: add OpenSSH client to Dockerfile for enhanced SSH capabilities
- Updated the Dockerfile to include the OpenSSH client, improving the container's ability to handle SSH connections and operations.
2026-01-06 00:36:45 -05:00
Web Dev Cody
8a6a83bf52 Merge pull request #366 from AutoMaker-Org/cursor-docker-oauth
feat: add Cursor CLI installation attempts documentation and enhance …
2026-01-05 21:53:31 -05:00
webdevcody
84b582ffa7 refactor: streamline Docker container management and enhance utility functions
- Removed redundant Docker image rebuilding logic from `dev.mjs` and `start.mjs`, centralizing it in the new `launchDockerContainers` function within `launcher-utils.mjs`.
- Introduced `sanitizeProjectName` and `shouldRebuildDockerImages` functions to improve project name handling and Docker image management.
- Updated the Docker launch process to provide clearer logging and ensure proper handling of environment variables, enhancing the overall development experience.
2026-01-05 21:50:12 -05:00
webdevcody
bd5176165d refactor: remove duplicate logger initialization in useCliStatus hook
- Eliminated redundant logger declaration within the useCliStatus hook to improve code clarity and prevent potential performance issues.
- This change enhances the maintainability of the code by ensuring the logger is created only once outside the hook.
2026-01-05 21:38:18 -05:00
webdevcody
49f32c4d59 Merge branch 'main' of github.com:AutoMaker-Org/automaker into cursor-docker-oauth 2026-01-05 21:29:20 -05:00
webdevcody
0af5bc86f4 Merge branch 'cursor-docker-oauth' of github.com:AutoMaker-Org/automaker into cursor-docker-oauth 2026-01-05 21:29:01 -05:00
webdevcody
bc5a36c5f4 feat: enhance project name sanitization and improve Docker image naming
- Added a `sanitizeProjectName` function to ensure project names are safe for shell commands and Docker image names by converting them to lowercase and removing non-alphanumeric characters.
- Updated `dev.mjs` and `start.mjs` to utilize the new sanitization function when determining Docker image names, enhancing security and consistency.
- Refactored the Docker entrypoint script to ensure proper permissions for the Cursor CLI config directory, improving setup reliability.
- Clarified documentation regarding the storage location of OAuth tokens for the Cursor CLI on Linux.

These changes improve the robustness of the Docker setup and enhance the overall development workflow.
2026-01-05 21:28:42 -05:00
Web Dev Cody
2934d73db2 Merge pull request #368 from AutoMaker-Org/fix/small-bugs
fix: small bugs
2026-01-05 20:23:42 -05:00
Shirone
a4968f7235 fix: show success toast only during project creation flow
- Updated the useSpecRegeneration hook to conditionally display the success toast message only when the user is in the active project creation flow, preventing unnecessary notifications during regular spec regeneration.
2026-01-06 02:04:08 +01:00
Shirone
b8e0c18c53 fix: theme switch bug
- when user had set up theme on the project lvl i and went trought the setup wizard again and changed theme its was not updating because its was only updating global theme and app was reverting back to show current project theme
2026-01-06 02:00:41 +01:00
Shirone
d0b3e0d9bb refactor: move logger initialization outside of useCliStatus function
- Moved the logger initialization to the top of the file for better readability and to avoid re-initialization on each function call.
- This change enhances the performance and clarity of the code in the useCliStatus hook.
- fix infinite loop calling caused by rerender because of logger
2026-01-06 01:53:08 +01:00
Kacper
2a0719e00c refactor: move logger initialization outside of useCliStatus hook
- Moved the logger creation outside the hook to prevent infinite re-renders.
- Updated dependencies in the checkStatus function to remove logger from the dependency array.

These changes enhance performance and maintainability of the useCliStatus hook.
2026-01-06 00:58:31 +01:00
webdevcody
af394183e6 feat: add Cursor CLI installation attempts documentation and enhance Docker setup
- Introduced a new markdown file summarizing various attempts to install the Cursor CLI in Docker, detailing approaches, results, and key learnings.
- Updated Dockerfile to ensure proper installation of Cursor CLI for the non-root user, including necessary PATH adjustments for interactive shells.
- Enhanced entrypoint script to manage OAuth tokens for both Claude and Cursor CLIs, ensuring correct permissions and directory setups.
- Added scripts for extracting OAuth tokens from macOS Keychain and Linux JSON files for seamless integration with Docker.
- Updated docker-compose files to support persistent storage for CLI configurations and authentication tokens.

These changes improve the development workflow and provide clear guidance on CLI installation and authentication processes.
2026-01-05 18:13:14 -05:00
20 changed files with 1017 additions and 173 deletions

View File

@@ -8,10 +8,12 @@
# =============================================================================
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
# =============================================================================
FROM node:22-alpine AS base
FROM node:22-slim AS base
# Install build dependencies for native modules (node-pty)
RUN apk add --no-cache python3 make g++
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -51,32 +53,59 @@ RUN npm run build:packages && npm run build --workspace=apps/server
# =============================================================================
# SERVER PRODUCTION STAGE
# =============================================================================
FROM node:22-alpine AS server
FROM node:22-slim AS server
# Install git, curl, bash (for terminal), su-exec (for user switching), and GitHub CLI (pinned version, multi-arch)
RUN apk add --no-cache git curl bash su-exec && \
GH_VERSION="2.63.2" && \
ARCH=$(uname -m) && \
case "$ARCH" in \
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl bash gosu ca-certificates openssh-client \
&& GH_VERSION="2.63.2" \
&& ARCH=$(uname -m) \
&& case "$ARCH" in \
x86_64) GH_ARCH="amd64" ;; \
aarch64|arm64) GH_ARCH="arm64" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
esac \
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
&& tar -xzf gh.tar.gz \
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
&& rm -rf /var/lib/apt/lists/*
# Install Claude CLI globally
# Install Claude CLI globally (available to all users via npm global bin)
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
# Create non-root user with home directory BEFORE installing Cursor CLI
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
mkdir -p /home/automaker/.local/bin && \
mkdir -p /home/automaker/.cursor && \
chown -R automaker:automaker /home/automaker && \
chmod 700 /home/automaker/.cursor
# Create non-root user with home directory
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001 -h /home/automaker && \
mkdir -p /home/automaker && \
chown automaker:automaker /home/automaker
# Install Cursor CLI as the automaker user
# Set HOME explicitly and install to /home/automaker/.local/bin/
USER automaker
ENV HOME=/home/automaker
RUN curl https://cursor.com/install -fsS | bash && \
echo "=== Checking Cursor CLI installation ===" && \
ls -la /home/automaker/.local/bin/ && \
echo "=== PATH is: $PATH ===" && \
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
USER root
# Add PATH to profile so it's available in all interactive shells (for login shells)
RUN mkdir -p /etc/profile.d && \
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
chmod +x /etc/profile.d/cursor-cli.sh
# Add to automaker's .bashrc for bash interactive shells
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
chown automaker:automaker /home/automaker/.bashrc
# Also add to root's .bashrc since docker exec defaults to root
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
WORKDIR /app
# Copy root package.json (needed for workspace resolution)
COPY --from=server-builder /app/package*.json ./
@@ -111,6 +140,8 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV PORT=3008
ENV DATA_DIR=/data
ENV HOME=/home/automaker
# Add user's local bin to PATH for cursor-agent
ENV PATH="/home/automaker/.local/bin:${PATH}"
# Expose port
EXPOSE 3008

View File

@@ -42,6 +42,9 @@ export function useSpecRegeneration({
}
if (event.type === 'spec_regeneration_complete') {
// Only show toast if we're in active creation flow (not regular regeneration)
const isCreationFlow = creatingSpecProjectPath !== null;
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview('');
@@ -49,9 +52,12 @@ export function useSpecRegeneration({
// Clear onboarding state if we came from onboarding
setNewProjectName('');
setNewProjectPath('');
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
if (isCreationFlow) {
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
}
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {

View File

@@ -32,6 +32,53 @@ export function useCliStatus() {
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
// Refresh Claude auth status from the server
const refreshAuthStatus = useCallback(async () => {
const api = getElectronAPI();
if (!api?.setup?.getClaudeStatus) return;
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to refresh Claude auth status:', error);
}
}, [setClaudeAuthStatus]);
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
@@ -48,54 +95,13 @@ export function useCliStatus() {
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to check Claude auth status:', error);
}
}
await refreshAuthStatus();
};
checkCliStatus();
}, [setClaudeAuthStatus]);
}, [refreshAuthStatus]);
// Refresh Claude CLI status
// Refresh Claude CLI status and auth status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
@@ -104,12 +110,14 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
// Also refresh auth status
await refreshAuthStatus();
} catch (error) {
logger.error('Failed to refresh Claude CLI status:', error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
}, [refreshAuthStatus]);
return {
claudeCliStatus,

View File

@@ -8,6 +8,9 @@ interface UseCliStatusOptions {
setAuthStatus: (status: any) => void;
}
// Create logger once outside the hook to prevent infinite re-renders
const logger = createLogger('CliStatus');
export function useCliStatus({
cliType,
statusApi,
@@ -15,7 +18,6 @@ export function useCliStatus({
setAuthStatus,
}: UseCliStatusOptions) {
const [isChecking, setIsChecking] = useState(false);
const logger = createLogger('CliStatus');
const checkStatus = useCallback(async () => {
logger.info(`Starting status check for ${cliType}...`);
@@ -66,7 +68,7 @@ export function useCliStatus({
} finally {
setIsChecking(false);
}
}, [cliType, statusApi, setCliStatus, setAuthStatus, logger]);
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
return { isChecking, checkStatus };
}

View File

@@ -11,7 +11,7 @@ interface ThemeStepProps {
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const { theme, setTheme, setPreviewTheme, currentProject, setProjectTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const handleThemeHover = (themeValue: string) => {
@@ -24,6 +24,11 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
// Also update the current project's theme if one exists
// This ensures the selected theme is visible since getEffectiveTheme() prioritizes project theme
if (currentProject) {
setProjectTheme(currentProject.id, themeValue as typeof theme);
}
setPreviewTheme(null);
};

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
/**
* Hook that loads project settings from the server when the current project changes.
* This ensures that settings like board backgrounds are properly restored when
* switching between projects or restarting the app.
*/
export function useProjectSettingsLoader() {
const currentProject = useAppStore((state) => state.currentProject);
const setBoardBackground = useAppStore((state) => state.setBoardBackground);
const setCardOpacity = useAppStore((state) => state.setCardOpacity);
const setColumnOpacity = useAppStore((state) => state.setColumnOpacity);
const setColumnBorderEnabled = useAppStore((state) => state.setColumnBorderEnabled);
const setCardGlassmorphism = useAppStore((state) => state.setCardGlassmorphism);
const setCardBorderEnabled = useAppStore((state) => state.setCardBorderEnabled);
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
const loadingRef = useRef<string | null>(null);
useEffect(() => {
if (!currentProject?.path) {
return;
}
// Prevent loading the same project multiple times
if (loadingRef.current === currentProject.path) {
return;
}
loadingRef.current = currentProject.path;
const loadProjectSettings = async () => {
try {
const httpClient = getHttpApiClient();
const result = await httpClient.settings.getProject(currentProject.path);
if (result.success && result.settings?.boardBackground) {
const bg = result.settings.boardBackground;
const projectPath = currentProject.path;
// Update store with loaded settings (without triggering server save)
setBoardBackground(projectPath, bg.imagePath);
const settingsMap = {
cardOpacity: setCardOpacity,
columnOpacity: setColumnOpacity,
columnBorderEnabled: setColumnBorderEnabled,
cardGlassmorphism: setCardGlassmorphism,
cardBorderEnabled: setCardBorderEnabled,
cardBorderOpacity: setCardBorderOpacity,
hideScrollbar: setHideScrollbar,
} as const;
for (const [key, setter] of Object.entries(settingsMap)) {
const value = bg[key as keyof typeof bg];
if (value !== undefined) {
(setter as (path: string, val: typeof value) => void)(projectPath, value);
}
}
}
} catch (error) {
console.error('Failed to load project settings:', error);
// Don't show error toast - just log it
}
};
loadProjectSettings();
}, [
currentProject?.path,
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
]);
}

View File

@@ -24,6 +24,7 @@ import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
const logger = createLogger('RootLayout');
@@ -47,6 +48,9 @@ function RootLayoutContent() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { openFileBrowser } = useFileBrowser();
// Load project settings when switching projects
useProjectSettingsLoader();
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,411 @@
/**
* Board Background Persistence End-to-End Test
*
* Tests that board background settings are properly saved and loaded when switching projects.
* This verifies that:
* 1. Background settings are saved to .automaker-local/settings.json
* 2. Settings are loaded when switching back to a project
* 3. Background image, opacity, and other settings are correctly restored
* 4. Settings persist across app restarts (new page loads)
*
* This test prevents regression of the board background loading bug where
* settings were saved but never loaded when switching projects.
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
authenticateForTests,
handleLoginScreenIfPresent,
} from '../utils';
// Create unique temp dirs for this test run
const TEST_TEMP_DIR = createTempDirPath('board-bg-test');
test.describe('Board Background Persistence', () => {
test.beforeAll(async () => {
// Create test temp directory
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
// Cleanup temp directory
cleanupTempDir(TEST_TEMP_DIR);
});
test('should load board background settings when switching projects', async ({ page }) => {
const projectAName = `project-a-${Date.now()}`;
const projectBName = `project-b-${Date.now()}`;
const projectAPath = path.join(TEST_TEMP_DIR, projectAName);
const projectBPath = path.join(TEST_TEMP_DIR, projectBName);
const projectAId = `project-a-${Date.now()}`;
const projectBId = `project-b-${Date.now()}`;
// Create both project directories
fs.mkdirSync(projectAPath, { recursive: true });
fs.mkdirSync(projectBPath, { recursive: true });
// Create basic files for both projects
for (const [name, projectPath] of [
[projectAName, projectAPath],
[projectBName, projectBPath],
]) {
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name, version: '1.0.0' }, null, 2)
);
fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${name}\n`);
}
// Create .automaker-local directory for project A with background settings
const automakerDirA = path.join(projectAPath, '.automaker-local');
fs.mkdirSync(automakerDirA, { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'board'), { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'context'), { recursive: true });
// Copy actual background image from test fixtures
const backgroundPath = path.join(automakerDirA, 'board', 'background.jpg');
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
fs.copyFileSync(testImagePath, backgroundPath);
// Create settings.json with board background configuration
const settingsPath = path.join(automakerDirA, 'settings.json');
const backgroundSettings = {
version: 1,
boardBackground: {
imagePath: backgroundPath,
cardOpacity: 85,
columnOpacity: 60,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: false,
cardBorderOpacity: 50,
hideScrollbar: true,
imageVersion: Date.now(),
},
};
fs.writeFileSync(settingsPath, JSON.stringify(backgroundSettings, null, 2));
// Create minimal automaker-local directory for project B (no background)
const automakerDirB = path.join(projectBPath, '.automaker-local');
fs.mkdirSync(automakerDirB, { recursive: true });
fs.mkdirSync(path.join(automakerDirB, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDirB, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDirB, 'settings.json'),
JSON.stringify({ version: 1 }, null, 2)
);
// Set up app state with both projects in the list (not recent, but in projects list)
await page.addInitScript(
({ projects }: { projects: string[] }) => {
const appState = {
state: {
projects: [
{
id: projects[0],
name: projects[1],
path: projects[2],
lastOpened: new Date(Date.now() - 86400000).toISOString(),
theme: 'red',
},
{
id: projects[3],
name: projects[4],
path: projects[5],
lastOpened: new Date(Date.now() - 172800000).toISOString(),
theme: 'red',
},
],
currentProject: null,
currentView: 'welcome',
theme: 'red',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
boardBackgroundByProject: {},
},
version: 2,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Setup complete
const setupState = {
state: {
setupComplete: true,
workspaceDir: '/tmp',
},
version: 0,
};
localStorage.setItem('setup-storage', JSON.stringify(setupState));
},
{ projects: [projectAId, projectAName, projectAPath, projectBId, projectBName, projectBPath] }
);
// Track API calls to /api/settings/project to verify settings are being loaded
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
page.on('request', (request) => {
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
settingsApiCalls.push({
url: request.url(),
method: request.method(),
body: request.postData() || '',
});
}
});
// Navigate to the app
await authenticateForTests(page);
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
// Wait for welcome view
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Open project A (has background settings)
const projectACard = page.locator(`[data-testid="recent-project-${projectAId}"]`);
await expect(projectACard).toBeVisible();
await projectACard.click();
// Wait for board view
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Verify project A is current
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectAName)
).toBeVisible({ timeout: 5000 });
// CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook)
// This ensures the background settings are fetched from the server
await page.waitForResponse(
(resp) =>
resp.url().includes('/api/settings/project') &&
resp.request().postData()?.includes(projectAPath) === true
);
// Check if background settings were applied by checking the store
// We can't directly access React state, so we'll verify via DOM/CSS
const boardView = page.locator('[data-testid="board-view"]');
await expect(boardView).toBeVisible();
// Wait for initial project load to stabilize
await page.waitForTimeout(500);
// Switch to project B (no background)
const projectSelector = page.locator('[data-testid="project-selector"]');
await projectSelector.click();
// Wait for dropdown to be visible
await expect(page.locator('[data-testid="project-picker-dropdown"]')).toBeVisible({
timeout: 5000,
});
const projectPickerB = page.locator(`[data-testid="project-option-${projectBId}"]`);
await expect(projectPickerB).toBeVisible({ timeout: 5000 });
await projectPickerB.click();
// Wait for project B to load
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectBName)
).toBeVisible({ timeout: 5000 });
// Wait a bit for project B to fully load before switching
await page.waitForTimeout(500);
// Switch back to project A
await projectSelector.click();
// Wait for dropdown to be visible
await expect(page.locator('[data-testid="project-picker-dropdown"]')).toBeVisible({
timeout: 5000,
});
const projectPickerA = page.locator(`[data-testid="project-option-${projectAId}"]`);
await expect(projectPickerA).toBeVisible({ timeout: 5000 });
await projectPickerA.click();
// Verify we're back on project A
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectAName)
).toBeVisible({ timeout: 5000 });
// CRITICAL: Wait for settings to be loaded again
await page.waitForResponse(
(resp) =>
resp.url().includes('/api/settings/project') &&
resp.request().postData()?.includes(projectAPath) === true
);
// Verify that the settings API was called for project A (at least twice - initial load and switch back)
const projectASettingsCalls = settingsApiCalls.filter((call) =>
call.body.includes(projectAPath)
);
// Debug: log all API calls if test fails
if (projectASettingsCalls.length < 2) {
console.log('Total settings API calls:', settingsApiCalls.length);
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
console.log('Looking for path:', projectAPath);
}
expect(projectASettingsCalls.length).toBeGreaterThanOrEqual(2);
// Verify settings file still exists with correct data
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(loadedSettings.boardBackground).toBeDefined();
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
expect(loadedSettings.boardBackground.cardOpacity).toBe(85);
expect(loadedSettings.boardBackground.columnOpacity).toBe(60);
expect(loadedSettings.boardBackground.hideScrollbar).toBe(true);
// The test passing means:
// 1. The useProjectSettingsLoader hook is working
// 2. Settings are loaded when switching projects
// 3. The API call to /api/settings/project is made correctly
});
test('should load background settings on app restart', async ({ page }) => {
const projectName = `restart-test-${Date.now()}`;
const projectPath = path.join(TEST_TEMP_DIR, projectName);
const projectId = `project-${Date.now()}`;
// Create project directory
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
// Create .automaker-local with background settings
const automakerDir = path.join(projectPath, '.automaker-local');
fs.mkdirSync(automakerDir, { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'board'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
// Copy actual background image from test fixtures
const backgroundPath = path.join(automakerDir, 'board', 'background.jpg');
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
fs.copyFileSync(testImagePath, backgroundPath);
const settingsPath = path.join(automakerDir, 'settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
version: 1,
boardBackground: {
imagePath: backgroundPath,
cardOpacity: 90,
columnOpacity: 70,
imageVersion: Date.now(),
},
},
null,
2
)
);
// Set up with project as current using direct localStorage
await page.addInitScript(
({ project }: { project: string[] }) => {
const projectObj = {
id: project[0],
name: project[1],
path: project[2],
lastOpened: new Date().toISOString(),
theme: 'red',
};
const appState = {
state: {
projects: [projectObj],
currentProject: projectObj,
currentView: 'board',
theme: 'red',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
boardBackgroundByProject: {},
},
version: 2,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Setup complete
const setupState = {
state: {
setupComplete: true,
workspaceDir: '/tmp',
},
version: 0,
};
localStorage.setItem('setup-storage', JSON.stringify(setupState));
},
{ project: [projectId, projectName, projectPath] }
);
// Track API calls to /api/settings/project to verify settings are being loaded
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
page.on('request', (request) => {
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
settingsApiCalls.push({
url: request.url(),
method: request.method(),
body: request.postData() || '',
});
}
});
// Navigate and authenticate
await authenticateForTests(page);
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
// Should go straight to board view (not welcome) since we have currentProject
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Wait for settings to load
await page.waitForResponse(
(resp) =>
resp.url().includes('/api/settings/project') &&
resp.request().postData()?.includes(projectPath) === true
);
// Verify that the settings API was called for this project
const projectSettingsCalls = settingsApiCalls.filter((call) => call.body.includes(projectPath));
// Debug: log all API calls if test fails
if (projectSettingsCalls.length < 1) {
console.log('Total settings API calls:', settingsApiCalls.length);
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
console.log('Looking for path:', projectPath);
}
expect(projectSettingsCalls.length).toBeGreaterThanOrEqual(1);
// Verify settings file exists with correct data
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(loadedSettings.boardBackground).toBeDefined();
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
expect(loadedSettings.boardBackground.cardOpacity).toBe(90);
expect(loadedSettings.boardBackground.columnOpacity).toBe(70);
// The test passing means:
// 1. The useProjectSettingsLoader hook is working
// 2. Settings are loaded when app starts with a currentProject
// 3. The API call to /api/settings/project is made correctly
});
});

50
dev.mjs
View File

@@ -11,13 +11,13 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
createRestrictedFs,
log,
runNpm,
runNpmAndWait,
runNpx,
printHeader,
printModeMenu,
resolvePortConfiguration,
@@ -26,11 +26,9 @@ import {
startServerAndWait,
ensureDependencies,
prompt,
launchDockerContainers,
} from './scripts/launcher-utils.mjs';
const require = createRequire(import.meta.url);
const crossSpawn = require('cross-spawn');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -52,10 +50,11 @@ async function installPlaywrightBrowsers() {
log('Checking Playwright browsers...', 'yellow');
try {
const exitCode = await new Promise((resolve) => {
const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], {
stdio: 'inherit',
cwd: path.join(__dirname, 'apps', 'ui'),
});
const playwright = runNpx(
['playwright', 'install', 'chromium'],
{ stdio: 'inherit' },
path.join(__dirname, 'apps', 'ui')
);
playwright.on('close', (code) => resolve(code));
playwright.on('error', () => resolve(1));
});
@@ -171,40 +170,7 @@ async function main() {
break;
} else if (choice === '3') {
console.log('');
log('Launching Docker Container (Isolated Mode)...', 'blue');
log('Starting Docker containers...', 'yellow');
log('Note: Containers will only rebuild if images are missing.', 'yellow');
log('To force a rebuild, run: docker compose up --build', 'yellow');
console.log('');
// Check if ANTHROPIC_API_KEY is set
if (!process.env.ANTHROPIC_API_KEY) {
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
log('The server will require an API key to function.', 'yellow');
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
console.log('');
}
// Start containers with docker-compose (without --build to preserve volumes)
// Images will only be built if they don't exist
processes.docker = crossSpawn('docker', ['compose', 'up'], {
stdio: 'inherit',
cwd: __dirname,
env: {
...process.env,
},
});
log('Docker containers starting...', 'blue');
log('UI will be available at: http://localhost:3007', 'green');
log('API will be available at: http://localhost:3008', 'green');
console.log('');
log('Press Ctrl+C to stop the containers.', 'yellow');
await new Promise((resolve) => {
processes.docker.on('close', resolve);
});
await launchDockerContainers({ baseDir: __dirname, processes });
break;
} else {
log('Invalid choice. Please enter 1, 2, or 3.', 'red');

View File

@@ -4,8 +4,26 @@ services:
# Mount your workspace directory to /projects inside the container
# Example: mount your local /workspace to /projects inside the container
- /Users/webdevcody/Workspace/automaker-workspace:/projects:rw
# ===== CLI Authentication (Optional) =====
# Mount host CLI credentials to avoid re-authenticating in container
# Claude CLI - mount your ~/.claude directory (Linux/Windows)
# This shares your 'claude login' OAuth session with the container
# - ~/.claude:/home/automaker/.claude
# Cursor CLI - mount your ~/.cursor directory (Linux/Windows)
# This shares your 'cursor-agent login' OAuth session with the container
# - ~/.cursor:/home/automaker/.cursor
environment:
# Set root directory for all projects and file operations
# Users can only create/open projects within this directory
- ALLOWED_ROOT_DIRECTORY=/projects
- NODE_ENV=development
# ===== macOS Users =====
# On macOS, OAuth tokens are stored in SQLite databases, not plain files.
# Extract your Cursor token with: ./scripts/get-cursor-token.sh
# Then set it here or in your .env file:
# - CURSOR_API_KEY=${CURSOR_API_KEY:-}

View File

@@ -36,6 +36,17 @@ services:
# Required
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
# Optional - Claude CLI OAuth credentials (for macOS users)
# Extract with: ./scripts/get-claude-token.sh
# This writes the OAuth tokens to ~/.claude/.credentials.json in the container
- CLAUDE_OAUTH_CREDENTIALS=${CLAUDE_OAUTH_CREDENTIALS:-}
# Optional - Cursor CLI OAuth token (extract from host with the command shown below)
# macOS: ./scripts/get-cursor-token.sh (extracts from Keychain)
# Linux: jq -r '.accessToken' ~/.config/cursor/auth.json
# Note: cursor-agent stores its OAuth tokens separately from Cursor IDE
- CURSOR_AUTH_TOKEN=${CURSOR_AUTH_TOKEN:-}
# Optional - authentication, one will generate if left blank
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
@@ -63,6 +74,10 @@ services:
# This allows 'claude login' authentication to persist between restarts
- automaker-claude-config:/home/automaker/.claude
# Persist Cursor CLI configuration and authentication across container restarts
# This allows 'cursor-agent login' authentication to persist between restarts
- automaker-cursor-config:/home/automaker/.cursor
# NO host directory mounts - container cannot access your laptop files
# If you need to work on a project, create it INSIDE the container
# or use a separate docker-compose override file
@@ -81,3 +96,8 @@ volumes:
name: automaker-claude-config
# Named volume for Claude CLI OAuth session keys and configuration
# Persists authentication across container restarts
automaker-cursor-config:
name: automaker-cursor-config
# Named volume for Cursor CLI configuration and authentication
# Persists cursor-agent login authentication across container restarts

View File

@@ -1,19 +1,45 @@
#!/bin/sh
set -e
# Fix permissions on Claude CLI config directory if it exists
# This handles the case where a volume is mounted and owned by root
if [ -d "/home/automaker/.claude" ]; then
chown -R automaker:automaker /home/automaker/.claude
chmod -R 755 /home/automaker/.claude
fi
# Ensure the directory exists with correct permissions if volume is empty
# Ensure Claude CLI config directory exists with correct permissions
if [ ! -d "/home/automaker/.claude" ]; then
mkdir -p /home/automaker/.claude
chown automaker:automaker /home/automaker/.claude
chmod 755 /home/automaker/.claude
fi
# If CLAUDE_OAUTH_CREDENTIALS is set, write it to the credentials file
# This allows passing OAuth tokens from host (especially macOS where they're in Keychain)
if [ -n "$CLAUDE_OAUTH_CREDENTIALS" ]; then
echo "$CLAUDE_OAUTH_CREDENTIALS" > /home/automaker/.claude/.credentials.json
chmod 600 /home/automaker/.claude/.credentials.json
fi
# Fix permissions on Claude CLI config directory
chown -R automaker:automaker /home/automaker/.claude
chmod 700 /home/automaker/.claude
# Ensure Cursor CLI config directory exists with correct permissions
# This handles both: mounted volumes (owned by root) and empty directories
if [ ! -d "/home/automaker/.cursor" ]; then
mkdir -p /home/automaker/.cursor
fi
chown -R automaker:automaker /home/automaker/.cursor
chmod -R 700 /home/automaker/.cursor
# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file
# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage
# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent
if [ -n "$CURSOR_AUTH_TOKEN" ]; then
CURSOR_CONFIG_DIR="/home/automaker/.config/cursor"
mkdir -p "$CURSOR_CONFIG_DIR"
# Write auth.json with the access token
cat > "$CURSOR_CONFIG_DIR/auth.json" << EOF
{
"accessToken": "$CURSOR_AUTH_TOKEN"
}
EOF
chmod 600 "$CURSOR_CONFIG_DIR/auth.json"
chown -R automaker:automaker /home/automaker/.config
fi
# Switch to automaker user and execute the command
exec su-exec automaker "$@"
exec gosu automaker "$@"

View File

@@ -57,10 +57,63 @@ docker-compose -f docker-compose.yml -f docker-compose.project.yml up -d
**Tip**: Use `:ro` (read-only) when possible for extra safety.
## CLI Authentication (macOS)
On macOS, OAuth tokens are stored in Keychain (Claude) and SQLite (Cursor). Use these scripts to extract and pass them to the container:
### Claude CLI
```bash
# Extract and add to .env
echo "CLAUDE_OAUTH_CREDENTIALS=$(./scripts/get-claude-token.sh)" >> .env
```
### Cursor CLI
```bash
# Extract and add to .env (extracts from macOS Keychain)
echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env
```
**Note**: The cursor-agent CLI stores its OAuth tokens separately from the Cursor IDE:
- **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`)
- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` (not `~/.cursor`)
### Apply to container
```bash
# Restart with new credentials
docker-compose down && docker-compose up -d
```
**Note**: Tokens expire periodically. If you get authentication errors, re-run the extraction scripts.
## CLI Authentication (Linux/Windows)
On Linux/Windows, cursor-agent stores credentials in files, so you can either:
**Option 1: Extract tokens to environment variables (recommended)**
```bash
# Linux: Extract tokens to .env
echo "CURSOR_AUTH_TOKEN=$(jq -r '.accessToken' ~/.config/cursor/auth.json)" >> .env
```
**Option 2: Bind mount credential directories directly**
```yaml
# In docker-compose.override.yml
volumes:
- ~/.claude:/home/automaker/.claude
- ~/.config/cursor:/home/automaker/.config/cursor
```
## Troubleshooting
| Problem | Solution |
| --------------------- | -------------------------------------------------------------------------------------------- |
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
| Problem | Solution |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. |

View File

@@ -800,8 +800,14 @@ export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const content = await systemPathReadFile(credPath);
const credentials = JSON.parse(content);
result.hasCredentialsFile = true;
// Support multiple credential formats:
// 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } }
// 2. Legacy format: { oauth_token } or { access_token }
// 3. API key format: { api_key }
const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken;
const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token);
result.credentials = {
hasOAuthToken: !!(credentials.oauth_token || credentials.access_token),
hasOAuthToken: hasClaudeOauth || hasLegacyOauth,
hasApiKey: !!credentials.api_key,
};
break;

7
package-lock.json generated
View File

@@ -20,7 +20,8 @@
"devDependencies": {
"husky": "9.1.7",
"lint-staged": "16.2.7",
"prettier": "3.7.4"
"prettier": "3.7.4",
"vitest": "4.0.16"
},
"engines": {
"node": ">=22.0.0 <23.0.0"
@@ -28,7 +29,7 @@
},
"apps/server": {
"name": "@automaker/server",
"version": "0.7.3",
"version": "0.8.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.76",
@@ -78,7 +79,7 @@
},
"apps/ui": {
"name": "@automaker/ui",
"version": "0.7.3",
"version": "0.8.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

34
scripts/get-claude-token.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Extract Claude OAuth token from macOS Keychain for use in Docker container
# Usage: ./scripts/get-claude-token.sh
# or: export CLAUDE_OAUTH_TOKEN=$(./scripts/get-claude-token.sh)
set -e
# Only works on macOS (uses security command for Keychain access)
if [[ "$OSTYPE" != "darwin"* ]]; then
echo "Error: This script only works on macOS." >&2
echo "On Linux, mount ~/.claude directory directly instead." >&2
exit 1
fi
# Check if security command exists
if ! command -v security &> /dev/null; then
echo "Error: 'security' command not found." >&2
exit 1
fi
# Get the current username
USERNAME=$(whoami)
# Extract credentials from Keychain
CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$USERNAME" -w 2>/dev/null)
if [ -z "$CREDS" ]; then
echo "Error: No Claude credentials found in Keychain." >&2
echo "Make sure you've logged in with 'claude login' first." >&2
exit 1
fi
# Output the full credentials JSON (contains accessToken and refreshToken)
echo "$CREDS"

69
scripts/get-cursor-token.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# Extract Cursor CLI OAuth token from host machine for use in Docker container
#
# IMPORTANT: This extracts the cursor-agent CLI OAuth token, NOT the Cursor IDE token.
# cursor-agent stores tokens in macOS Keychain (not SQLite like the IDE).
#
# Usage: ./scripts/get-cursor-token.sh
# or: export CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)
#
# For Docker: echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env
set -e
# Determine platform and extract token accordingly
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS: cursor-agent stores OAuth tokens in Keychain
# Service: cursor-access-token, Account: cursor-user
if ! command -v security &> /dev/null; then
echo "Error: 'security' command not found." >&2
exit 1
fi
# Extract access token from Keychain
TOKEN=$(security find-generic-password -a "cursor-user" -s "cursor-access-token" -w 2>/dev/null)
if [ -z "$TOKEN" ]; then
echo "Error: No Cursor CLI token found in Keychain." >&2
echo "Make sure you've logged in with 'cursor-agent login' first." >&2
exit 1
fi
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux: cursor-agent stores OAuth tokens in a JSON file
# Default location: ~/.config/cursor/auth.json
# Or: $XDG_CONFIG_HOME/cursor/auth.json
if [ -n "$XDG_CONFIG_HOME" ]; then
AUTH_FILE="$XDG_CONFIG_HOME/cursor/auth.json"
else
AUTH_FILE="$HOME/.config/cursor/auth.json"
fi
if [ ! -f "$AUTH_FILE" ]; then
echo "Error: Cursor auth file not found at: $AUTH_FILE" >&2
echo "Make sure you've logged in with 'cursor-agent login' first." >&2
exit 1
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed." >&2
echo "Install it with: apt install jq" >&2
exit 1
fi
TOKEN=$(jq -r '.accessToken // empty' "$AUTH_FILE" 2>/dev/null)
if [ -z "$TOKEN" ]; then
echo "Error: No access token found in $AUTH_FILE" >&2
exit 1
fi
else
echo "Error: Unsupported platform: $OSTYPE" >&2
exit 1
fi
# Output the token
echo "$TOKEN"

View File

@@ -13,7 +13,7 @@
*/
import { execSync } from 'child_process';
import fsNative from 'fs';
import fsNative, { statSync } from 'fs';
import http from 'http';
import path from 'path';
import readline from 'readline';
@@ -662,3 +662,142 @@ export async function ensureDependencies(fs, baseDir) {
});
}
}
// =============================================================================
// Docker Utilities
// =============================================================================
/**
* Sanitize a project name to be safe for use in shell commands and Docker image names.
* Converts to lowercase and removes any characters that aren't alphanumeric.
* @param {string} name - Project name to sanitize
* @returns {string} - Sanitized project name
*/
export function sanitizeProjectName(name) {
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
}
/**
* Check if Docker images need to be rebuilt based on Dockerfile or package.json changes
* @param {string} baseDir - Base directory containing Dockerfile and package.json
* @returns {boolean} - Whether images need to be rebuilt
*/
export function shouldRebuildDockerImages(baseDir) {
try {
const dockerfilePath = path.join(baseDir, 'Dockerfile');
const packageJsonPath = path.join(baseDir, 'package.json');
// Get modification times of source files
const dockerfileMtime = statSync(dockerfilePath).mtimeMs;
const packageJsonMtime = statSync(packageJsonPath).mtimeMs;
const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime);
// Get project name from docker-compose config, falling back to directory name
let projectName;
try {
const composeConfig = execSync('docker compose config --format json', {
encoding: 'utf-8',
cwd: baseDir,
});
const config = JSON.parse(composeConfig);
projectName = config.name;
} catch (error) {
// Fallback handled below
}
// Sanitize project name (whether from config or fallback)
// This prevents command injection and ensures valid Docker image names
const sanitizedProjectName = sanitizeProjectName(projectName || path.basename(baseDir));
const serverImageName = `${sanitizedProjectName}_server`;
const uiImageName = `${sanitizedProjectName}_ui`;
// Check if images exist and get their creation times
let needsRebuild = false;
try {
// Check server image
const serverImageInfo = execSync(
`docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`,
{ encoding: 'utf-8', cwd: baseDir }
).trim();
// Check UI image
const uiImageInfo = execSync(
`docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`,
{ encoding: 'utf-8', cwd: baseDir }
).trim();
// If either image doesn't exist, we need to rebuild
if (!serverImageInfo || !uiImageInfo) {
return true;
}
// Parse image creation times (ISO 8601 format)
const serverCreated = new Date(serverImageInfo).getTime();
const uiCreated = new Date(uiImageInfo).getTime();
const oldestImageTime = Math.min(serverCreated, uiCreated);
// If source files are newer than images, rebuild
needsRebuild = latestSourceMtime > oldestImageTime;
} catch (error) {
// If images don't exist or inspect fails, rebuild
needsRebuild = true;
}
return needsRebuild;
} catch (error) {
// If we can't check, err on the side of rebuilding
log('Could not check Docker image status, will rebuild to be safe', 'yellow');
return true;
}
}
/**
* Launch Docker containers with docker-compose
* @param {object} options - Configuration options
* @param {string} options.baseDir - Base directory containing docker-compose.yml
* @param {object} options.processes - Processes object to track docker process
* @returns {Promise<void>}
*/
export async function launchDockerContainers({ baseDir, processes }) {
log('Launching Docker Container (Isolated Mode)...', 'blue');
// Check if Dockerfile or package.json changed and rebuild if needed
const needsRebuild = shouldRebuildDockerImages(baseDir);
const buildFlag = needsRebuild ? ['--build'] : [];
if (needsRebuild) {
log('Dockerfile or package.json changed - rebuilding images...', 'yellow');
} else {
log('Starting Docker containers...', 'yellow');
}
console.log('');
// Check if ANTHROPIC_API_KEY is set
if (!process.env.ANTHROPIC_API_KEY) {
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
log('The server will require an API key to function.', 'yellow');
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
console.log('');
}
// Start containers with docker-compose
// Will rebuild if Dockerfile or package.json changed
processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], {
stdio: 'inherit',
cwd: baseDir,
env: {
...process.env,
},
});
log('Docker containers starting...', 'blue');
log('UI will be available at: http://localhost:3007', 'green');
log('API will be available at: http://localhost:3008', 'green');
console.log('');
log('Press Ctrl+C to stop the containers.', 'yellow');
await new Promise((resolve) => {
processes.docker.on('close', resolve);
});
}

View File

@@ -18,11 +18,9 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
createRestrictedFs,
log,
runNpm,
runNpmAndWait,
runNpx,
printHeader,
@@ -35,11 +33,9 @@ import {
prompt,
killProcessTree,
sleep,
launchDockerContainers,
} from './scripts/launcher-utils.mjs';
const require = createRequire(import.meta.url);
const crossSpawn = require('cross-spawn');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -230,40 +226,7 @@ async function main() {
break;
} else if (choice === '3') {
console.log('');
log('Launching Docker Container (Isolated Mode)...', 'blue');
log('Starting Docker containers...', 'yellow');
log('Note: Containers will only rebuild if images are missing.', 'yellow');
log('To force a rebuild, run: docker compose up --build', 'yellow');
console.log('');
// Check if ANTHROPIC_API_KEY is set
if (!process.env.ANTHROPIC_API_KEY) {
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
log('The server will require an API key to function.', 'yellow');
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
console.log('');
}
// Start containers with docker-compose (without --build to preserve volumes)
// Images will only be built if they don't exist
processes.docker = crossSpawn('docker', ['compose', 'up'], {
stdio: 'inherit',
cwd: __dirname,
env: {
...process.env,
},
});
log('Docker containers starting...', 'blue');
log('UI will be available at: http://localhost:3007', 'green');
log('API will be available at: http://localhost:3008', 'green');
console.log('');
log('Press Ctrl+C to stop the containers.', 'yellow');
await new Promise((resolve) => {
processes.docker.on('close', resolve);
});
await launchDockerContainers({ baseDir: __dirname, processes });
break;
} else {
log('Invalid choice. Please enter 1, 2, or 3.', 'red');