mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Compare commits
17 Commits
v0.8.0
...
yumesha/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
329e429841 | ||
|
|
46636cf385 | ||
|
|
e24a6894a5 | ||
|
|
cf9289e21a | ||
|
|
fe7bc954ba | ||
|
|
8a6a83bf52 | ||
|
|
84b582ffa7 | ||
|
|
bd5176165d | ||
|
|
49f32c4d59 | ||
|
|
0af5bc86f4 | ||
|
|
bc5a36c5f4 | ||
|
|
2934d73db2 | ||
|
|
a4968f7235 | ||
|
|
b8e0c18c53 | ||
|
|
d0b3e0d9bb | ||
|
|
2a0719e00c | ||
|
|
af394183e6 |
71
Dockerfile
71
Dockerfile
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
82
apps/ui/src/hooks/use-project-settings-loader.ts
Normal file
82
apps/ui/src/hooks/use-project-settings-loader.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
BIN
apps/ui/tests/img/background.jpg
Normal file
BIN
apps/ui/tests/img/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
411
apps/ui/tests/projects/board-background-persistence.spec.ts
Normal file
411
apps/ui/tests/projects/board-background-persistence.spec.ts
Normal 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
50
dev.mjs
@@ -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');
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
7
package-lock.json
generated
@@ -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
34
scripts/get-claude-token.sh
Executable 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
69
scripts/get-cursor-token.sh
Executable 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"
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
41
start.mjs
41
start.mjs
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user