Compare commits

...

25 Commits

Author SHA1 Message Date
Kacper
0aef72540e fix(auth): Enhance credential detection logic for OAuth
- Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths.
- Improved error handling for settings file readability checks, providing clearer feedback on file access issues.
- Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid.

This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management.
2026-02-02 17:54:23 +01:00
Kacper
aad3ff2cdf fix(auth): Improve OAuth credential detection and startup warning
- Enhanced getClaudeAuthIndicators() to return detailed check information
  including file paths checked and specific error details for debugging
- Added debug logging to server startup credential detection for easier
  troubleshooting in Docker environments
- Show paths that were checked in the warning message to help users debug
  mount issues
- Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable
- Return authType in verify-claude-auth response to distinguish between
  OAuth and CLI authentication methods
- Updated UI to show specific success messages for Claude Code subscription
  vs generic CLI auth
- Added Docker troubleshooting tips to sandbox risk dialog
- Added comprehensive unit tests for OAuth credential detection scenarios

Closes #721

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:35:03 +01:00
Shirone
ebc7987988 Merge pull request #720 from noamloewenstern/fix/board-view-concurrency-null-worktree
fix(ui): handle null selectedWorktree in max concurrency handler
2026-02-02 15:31:44 +00:00
Shirone
29b3eef500 Merge pull request #744 from AutoMaker-Org/fix/git-project-initial-branch
fix(server): Use 'main' as default branch for new git projects
2026-02-02 14:20:03 +00:00
Kacper
010e516b0e fix(server): Use 'main' as default branch for new git projects
Git initialization now explicitly specifies --initial-branch=main to match
GitHub's default branch standard (since October 2020). This prevents the
branch name mismatch that caused features to disappear from the UI when
pushing to GitHub.

Fixes #734

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:07:43 +01:00
Shirone
00e4712ae7 Merge pull request #743 from AutoMaker-Org/fix/broken-syslinks-on-server
fix(electron): Fix broken symlinks in server bundle preventing app startup
2026-02-02 13:50:39 +00:00
Kacper
4b4ae04fbe refactor: Address PR review feedback for symlink and directory handling
- Use lstatSync with try/catch for robust broken symlink detection
- Remove redundant existsSync check before mkdirSync with recursive: true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:36:24 +01:00
Kacper
04775af561 fix(electron): Fix broken symlinks in server bundle preventing app startup
Fixes #742

This commit resolves two critical issues that prevented the Electron app from starting:

1. **Broken symlinks in server bundle**
   - After npm install, local @automaker/* packages were symlinked in node_modules
   - These symlinks broke after electron-builder packaging since relative paths no longer existed
   - Solution: Added Step 6b in prepare-server.mjs to replace symlinks with real directory copies
   - Added lstatSync and resolve imports to support symlink detection and replacement

2. **electronUserDataWriteFileSync fails on first launch**
   - The userData directory doesn't exist on first app launch
   - Writing .api-key file would fail with ENOENT error
   - Solution: Added directory existence check and creation with { recursive: true } before writing

Files modified:
- apps/ui/scripts/prepare-server.mjs: Added symlink replacement logic after npm install
- libs/platform/src/system-paths.ts: Added parent directory creation in electronUserDataWriteFileSync

Verification: After these fixes, npm run build:electron produces a working app that starts without ERR_MODULE_NOT_FOUND errors.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:26:59 +01:00
Shirone
b8fa7fc579 Merge pull request #732 from AutoMaker-Org/fix/icon-posiition-on-mac
fix(ui): adjust padding for logo for mac
2026-01-31 12:01:49 +00:00
Shirone
7fb0d0f2ca refactor(ui): Integrate macOS Electron padding logic into ProjectSwitcher
Updated the ProjectSwitcher component to conditionally apply top padding based on the operating system and Electron environment. This change utilizes the newly created MACOS_ELECTRON_TOP_PADDING_CLASS for improved maintainability and consistency across the UI.
2026-01-31 12:54:36 +01:00
Kacper
f15725f28a refactor(ui): Extract macOS Electron padding into shared constant
Extract the hardcoded 'pt-[38px]' magic number into a shared constant
MACOS_ELECTRON_TOP_PADDING_CLASS for better maintainability. This
addresses the PR #732 review feedback from Gemini Code Assist.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:43:28 +01:00
Kacper
7d7d152d4e fix(ui): Adjust sidebar padding for macOS Electron compatibility
Updated the sidebar header and navigation components to increase top padding for macOS Electron users from 10px to 38px, ensuring better layout and avoiding overlap with the traffic light controls. This change enhances the user experience on macOS platforms.
2026-01-30 20:36:33 +01:00
Noam Loewenstern
07f777da22 Update apps/ui/src/components/views/board-view.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-30 02:52:27 +02:00
Noam Loewenstern
b10501ea79 fix(ui): handle null selectedWorktree in max concurrency handler 2026-01-30 02:44:51 +02:00
DhanushSantosh
1a460c301a fix(test): Set HOSTNAME in dev server tests for consistent behavior
Dev server test was failing on non-localhost hostnames (e.g., 'fedora')
because it expected 'localhost' in the URL. Now sets HOSTNAME env var
in test setup and restores it in teardown for consistent test behavior
across all environments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:23 +05:30
DhanushSantosh
c1f480fe49 fix(ui): Make GitHub Copilot icon theme-aware for light mode visibility
The Copilot icon had a hardcoded white fill that made it invisible on
light theme backgrounds. Changed to use currentColor so it adapts to
theme and respects CSS text color classes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:08 +05:30
Shirone
ef3f8de33b Merge pull request #715 from OG-Ken/fix/opencode-dynamic-models-404-endpoint
fix: Correct OpenCode dynamic models API endpoint URL
2026-01-27 12:02:33 +00:00
Ken Lopez
d379bf412a fix: Correct OpenCode dynamic models API endpoint URL
The fetchOpencodeModels function was calling '/api/opencode/models' which
returns 404. Changed to '/api/setup/opencode/models' which correctly
returns the dynamic models.

This fixes an issue where enabled OpenCode dynamic models (e.g., local
Ollama models) were not appearing in the Model Defaults dropdown selectors
despite being visible and enabled in the OpenCode Settings page.
2026-01-27 03:06:28 -05:00
Shirone
cf35ca8650 Merge pull request #714 from AutoMaker-Org/feature/bug-request-changes-on-plan-mode-is-not-proceedin-8xpd
refactor(auto-mode): Enhance revision prompt customization
2026-01-26 23:36:30 +00:00
Shirone
4f1555f196 feat(event-history): Replace alert with toast notifications for event replay results
Update the EventHistoryView component to use toast notifications instead of alert dialogs for displaying event replay results, enhancing user experience and providing clearer feedback on success and failure states.
2026-01-27 00:29:34 +01:00
Shirone
5aace0ce0f fix(event-hook): Update featureName assignment to prioritize loaded feature title over payload 2026-01-27 00:25:36 +01:00
Shirone
e439d8a632 fix(routes): Update feature creation event to use title instead of name
Change the feature creation event to emit 'Untitled Feature' when the title is not provided, improving clarity in event handling.
2026-01-27 00:25:16 +01:00
Shirone
b7c6b8bfc6 feat(ui): Show project name in classic sidebar layout
Add project name display at the top of the navigation for the classic
(discord) sidebar style, which previously didn't show the project name
anywhere. Shows the project icon (custom or Lucide) and name with a
separator below.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:15:38 +01:00
Shirone
a60904bd51 fix(ui,server): Fix project icon updates and image upload issues
- Fix setProjectCustomIcon using wrong property name (customIcon -> customIconPath)
- Add currentProject state update to setProjectIcon and setProjectCustomIcon
- Fix data URL regex to handle all formats (e.g., charset=utf-8 in GIFs)
- Increase project icon size limit from 2MB to 5MB for animated GIFs
- Add toast notifications for upload validation errors
- Add image error fallback to folder icon in project switcher
- Make HttpApiClient get/put methods public for store access
- Fix TypeScript errors in app-store.ts (trashedAt type, font properties)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:09:55 +01:00
Kacper
d7c3337330 refactor(auto-mode): Enhance revision prompt customization and task format validation
- Updated the revision prompt generation to utilize a customizable template, allowing for dynamic insertion of plan version, previous plan content, user feedback, and task format examples.
- Added validation to ensure the presence of a tasks block in the revised specification, with clear instructions on the required format to prevent execution issues.
- Introduced logging for scenarios where no tasks are found in the revised plan, warning about potential fallback to single-agent execution.
2026-01-26 19:53:07 +01:00
34 changed files with 1416 additions and 222 deletions

View File

@@ -121,21 +121,89 @@ const BOX_CONTENT_WIDTH = 67;
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
logger.debug('[CREDENTIAL_CHECK] Starting credential detection...');
logger.debug('[CREDENTIAL_CHECK] Environment variables:', {
hasAnthropicKey,
hasEnvOAuthToken,
});
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
if (hasEnvOAuthToken) {
logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected');
return;
}
// Check for Claude Code CLI authentication
// Store indicators outside the try block so we can use them in the warning message
let cliAuthIndicators: Awaited<ReturnType<typeof getClaudeAuthIndicators>> | null = null;
try {
const indicators = await getClaudeAuthIndicators();
cliAuthIndicators = await getClaudeAuthIndicators();
const indicators = cliAuthIndicators;
// Log detailed credential detection results
logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', {
hasCredentialsFile: indicators.hasCredentialsFile,
hasSettingsFile: indicators.hasSettingsFile,
hasStatsCacheWithActivity: indicators.hasStatsCacheWithActivity,
hasProjectsSessions: indicators.hasProjectsSessions,
credentials: indicators.credentials,
});
logger.debug('[CREDENTIAL_CHECK] File check details:', {
settingsFile: {
path: indicators.checks.settingsFile.path,
exists: indicators.checks.settingsFile.exists,
readable: indicators.checks.settingsFile.readable,
error: indicators.checks.settingsFile.error,
},
statsCache: {
path: indicators.checks.statsCache.path,
exists: indicators.checks.statsCache.exists,
readable: indicators.checks.statsCache.readable,
hasDailyActivity: indicators.checks.statsCache.hasDailyActivity,
error: indicators.checks.statsCache.error,
},
projectsDir: {
path: indicators.checks.projectsDir.path,
exists: indicators.checks.projectsDir.exists,
readable: indicators.checks.projectsDir.readable,
entryCount: indicators.checks.projectsDir.entryCount,
error: indicators.checks.projectsDir.error,
},
credentialFiles: indicators.checks.credentialFiles.map((cf) => ({
path: cf.path,
exists: cf.exists,
readable: cf.readable,
error: cf.error,
})),
});
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
logger.debug('[CREDENTIAL_CHECK] Auth determination:', {
hasCliAuth,
reason: hasCliAuth
? indicators.hasStatsCacheWithActivity
? 'stats cache with activity'
: indicators.hasSettingsFile && indicators.hasProjectsSessions
? 'settings file + project sessions'
: indicators.credentials?.hasOAuthToken
? 'credentials file with OAuth token'
: 'credentials file with API key'
: 'no valid credentials found',
});
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
@@ -145,7 +213,7 @@ const BOX_CONTENT_WIDTH = 67;
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
// No authentication found - show warning with paths that were checked
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
@@ -158,6 +226,33 @@ const BOX_CONTENT_WIDTH = 67;
BOX_CONTENT_WIDTH
);
// Build paths checked summary from the indicators (if available)
let pathsCheckedInfo = '';
if (cliAuthIndicators) {
const pathsChecked: string[] = [];
// Collect paths that were checked
if (cliAuthIndicators.checks.settingsFile.path) {
pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`);
}
if (cliAuthIndicators.checks.statsCache.path) {
pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`);
}
if (cliAuthIndicators.checks.projectsDir.path) {
pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`);
}
for (const credFile of cliAuthIndicators.checks.credentialFiles) {
pathsChecked.push(`Credentials: ${credFile.path}`);
}
if (pathsChecked.length > 0) {
pathsCheckedInfo = `
║ ║
${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}
${pathsChecked.map((p) => `${p.substring(0, BOX_CONTENT_WIDTH - 2).padEnd(BOX_CONTENT_WIDTH - 2)}`).join('\n')}`;
}
}
logger.warn(`
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
@@ -169,7 +264,7 @@ const BOX_CONTENT_WIDTH = 67;
${w3}
${w4}
${w5}
${w6}
${w6}${pathsCheckedInfo}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);

View File

@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.name,
featureName: created.title || 'Untitled Feature',
projectPath,
});
}

View File

@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous)

View File

@@ -31,7 +31,9 @@ export function createSaveImageHandler() {
await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp

View File

@@ -320,9 +320,28 @@ export function createVerifyClaudeAuthHandler() {
authMethod,
});
// Determine specific auth type for success messages
let authType: 'oauth' | 'api_key' | 'cli' | undefined;
if (authenticated) {
if (authMethod === 'api_key') {
authType = 'api_key';
} else if (authMethod === 'cli') {
// Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI
// OAuth tokens are stored in the credentials file by the Claude CLI
const { getClaudeAuthIndicators } = await import('@automaker/platform');
const indicators = await getClaudeAuthIndicators();
if (indicators.credentials?.hasOAuthToken) {
authType = 'oauth';
} else {
authType = 'cli';
}
}
}
res.json({
success: true,
authenticated,
authType,
error: errorMessage || undefined,
});
} catch (error) {

View File

@@ -43,10 +43,14 @@ export function createInitGitHandler() {
// .git doesn't exist, continue with initialization
}
// Initialize git and create an initial empty commit
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
// and create an initial empty commit
await execAsync(
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath,
});
}
);
res.json({
success: true,

View File

@@ -4597,21 +4597,54 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
planVersion,
});
// Build revision prompt
let revisionPrompt = `The user has requested revisions to the plan/specification.
// Build revision prompt using customizable template
const revisionPrompts = await getPromptCustomization(
this.settingsService,
'[AutoMode]'
);
## Previous Plan (v${planVersion - 1})
${hasEdits ? approvalResult.editedPlan : currentPlanContent}
// Get task format example based on planning mode
const taskFormatExample =
planningMode === 'full'
? `\`\`\`tasks
## Phase 1: Foundation
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
## User Feedback
${approvalResult.feedback || 'Please revise the plan based on the edits above.'}
## Phase 2: Core Implementation
- [ ] T003: [Description] | File: [path/to/file]
- [ ] T004: [Description] | File: [path/to/file]
\`\`\``
: `\`\`\`tasks
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
- [ ] T003: [Description] | File: [path/to/file]
\`\`\``;
## Instructions
Please regenerate the specification incorporating the user's feedback.
Keep the same format with the \`\`\`tasks block for task definitions.
After generating the revised spec, output:
"[SPEC_GENERATED] Please review the revised specification above."
`;
let revisionPrompt = revisionPrompts.taskExecution.planRevisionTemplate;
revisionPrompt = revisionPrompt.replace(
/\{\{planVersion\}\}/g,
String(planVersion - 1)
);
revisionPrompt = revisionPrompt.replace(
/\{\{previousPlan\}\}/g,
hasEdits
? approvalResult.editedPlan || currentPlanContent
: currentPlanContent
);
revisionPrompt = revisionPrompt.replace(
/\{\{userFeedback\}\}/g,
approvalResult.feedback ||
'Please revise the plan based on the edits above.'
);
revisionPrompt = revisionPrompt.replace(
/\{\{planningMode\}\}/g,
planningMode
);
revisionPrompt = revisionPrompt.replace(
/\{\{taskFormatExample\}\}/g,
taskFormatExample
);
// Update status to regenerating
await this.updateFeaturePlanSpec(projectPath, featureId, {
@@ -4663,6 +4696,26 @@ After generating the revised spec, output:
const revisedTasks = parseTasksFromSpec(currentPlanContent);
logger.info(`Revised plan has ${revisedTasks.length} tasks`);
// Warn if no tasks found in spec/full mode - this may cause fallback to single-agent
if (
revisedTasks.length === 0 &&
(planningMode === 'spec' || planningMode === 'full')
) {
logger.warn(
`WARNING: Revised plan in ${planningMode} mode has no tasks! ` +
`This will cause fallback to single-agent execution. ` +
`The AI may have omitted the required \`\`\`tasks block.`
);
this.emitAutoModeEvent('plan_revision_warning', {
featureId,
projectPath,
branchName,
planningMode,
warning:
'Revised plan missing tasks block - will use single-agent execution',
});
}
// Update planSpec with revised content
await this.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generated',

View File

@@ -169,9 +169,10 @@ export class EventHookService {
}
// Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
featureName: featureName || payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,

View File

@@ -20,8 +20,8 @@ export interface TestRepo {
export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
// Initialize git repo
await execAsync('git init', { cwd: tmpDir });
// Initialize git repo with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init --initial-branch=main', { cwd: tmpDir });
// Use environment variables instead of git config to avoid affecting user's git config
// These env vars override git config without modifying it
@@ -38,9 +38,6 @@ export async function createTestGitRepo(): Promise<TestRepo> {
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir });
return {
path: tmpDir,
cleanup: async () => {

View File

@@ -14,7 +14,8 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
await execAsync('git init', { cwd: repoPath });
// Initialize with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init --initial-branch=main', { cwd: repoPath });
// Don't set git config - use environment variables in commit operations instead
// to avoid affecting user's git config
// Intentionally skip creating an initial commit

View File

@@ -30,11 +30,16 @@ import net from 'net';
describe('dev-server-service.ts', () => {
let testDir: string;
let originalHostname: string | undefined;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
// Store and set HOSTNAME for consistent test behavior
originalHostname = process.env.HOSTNAME;
process.env.HOSTNAME = 'localhost';
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
@@ -56,6 +61,13 @@ describe('dev-server-service.ts', () => {
});
afterEach(async () => {
// Restore original HOSTNAME
if (originalHostname === undefined) {
delete process.env.HOSTNAME;
} else {
process.env.HOSTNAME = originalHostname;
}
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {

View File

@@ -7,8 +7,8 @@
*/
import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -112,6 +112,29 @@ execSync('npm install --omit=dev', {
},
});
// Step 6b: Replace symlinks for local packages with real copies
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
console.log('🔗 Replacing symlinks with real directory copies...');
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
for (const pkgName of LOCAL_PACKAGES) {
const pkgDir = pkgName.replace('@automaker/', '');
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
try {
// lstatSync does not follow symlinks, allowing us to check for broken ones
if (lstatSync(nmPkgPath).isSymbolicLink()) {
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
rmSync(nmPkgPath);
cpSync(realPath, nmPkgPath, { recursive: true });
console.log(` ✓ Replaced symlink: ${pkgName}`);
}
} catch (error) {
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
if (error.code !== 'ENOENT') {
throw error;
}
}
}
// Step 7: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...');

View File

@@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
For safer operation, consider running Automaker in Docker. See the README for
instructions.
</p>
<div className="bg-muted/50 border border-border rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-foreground">
Already running in Docker? Try these troubleshooting steps:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
Ensure <code className="bg-muted px-1 rounded">IS_CONTAINERIZED=true</code> is
set in your docker-compose environment
</li>
<li>
Verify the server container has the environment variable:{' '}
<code className="bg-muted px-1 rounded">
docker exec automaker-server printenv IS_CONTAINERIZED
</code>
</li>
<li>Rebuild and restart containers if you recently changed the configuration</li>
<li>
Check the server logs for startup messages:{' '}
<code className="bg-muted px-1 rounded">docker-compose logs server</code>
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>

View File

@@ -15,6 +15,7 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker';
import { toast } from 'sonner';
interface EditProjectDialogProps {
project: Project;
@@ -52,11 +53,18 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error(
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
);
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
// Validate file size (max 5MB for icons - allows animated GIFs)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error(
`File too large (${(file.size / 1024 / 1024).toFixed(2)} MB). Maximum size is 5 MB.`
);
return;
}
@@ -72,15 +80,24 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
file.type,
project.path
);
if (result.success && result.path) {
setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set
setIcon(null);
toast.success('Icon uploaded successfully');
} else {
toast.error('Failed to upload icon');
}
setIsUploadingIcon(false);
};
reader.onerror = () => {
toast.error('Failed to read file');
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
toast.error('Failed to upload icon');
setIsUploadingIcon(false);
}
};
@@ -162,7 +179,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
PNG, JPG, GIF or WebP. Max 5MB.
</p>
</div>
</div>

View File

@@ -59,7 +59,7 @@ interface ThemeButtonProps {
/** Handler for pointer leave events (used to clear preview) */
onPointerLeave: (e: React.PointerEvent) => void;
/** Handler for click events (used to select theme) */
onClick: () => void;
onClick: (e: React.MouseEvent) => void;
}
/**
@@ -77,6 +77,7 @@ const ThemeButton = memo(function ThemeButton({
const Icon = option.icon;
return (
<button
type="button"
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
@@ -145,7 +146,10 @@ const ThemeColumn = memo(function ThemeColumn({
isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
onClick={() => onSelect(option.value)}
onClick={(e) => {
e.stopPropagation();
onSelect(option.value);
}}
/>
))}
</div>
@@ -193,7 +197,6 @@ export function ProjectContextMenu({
const {
moveProjectToTrash,
theme: globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
@@ -316,13 +319,24 @@ export function ProjectContextMenu({
const handleThemeSelect = useCallback(
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
// Clear any pending close timeout to prevent race conditions
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
// Close menu first
setShowThemeSubmenu(false);
onClose();
// Then apply theme changes
setPreviewTheme(null);
const isUsingGlobal = value === USE_GLOBAL_THEME;
setTheme(isUsingGlobal ? globalTheme : value);
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
setProjectTheme(project.id, isUsingGlobal ? null : value);
setShowThemeSubmenu(false);
},
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
[onClose, project.id, setPreviewTheme, setProjectTheme]
);
const handleConfirmRemove = useCallback(() => {
@@ -426,9 +440,13 @@ export function ProjectContextMenu({
<div className="p-2">
{/* Use Global Option */}
<button
type="button"
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
onClick={(e) => {
e.stopPropagation();
handleThemeSelect(USE_GLOBAL_THEME);
}}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, sanitizeForTestId } from '@/lib/utils';
@@ -19,6 +20,8 @@ export function ProjectSwitcherItem({
onClick,
onContextMenu,
}: ProjectSwitcherItemProps) {
const [imageError, setImageError] = useState(false);
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
@@ -35,7 +38,7 @@ export function ProjectSwitcherItem({
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
const hasCustomIcon = !!project.customIconPath && !imageError;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
@@ -74,6 +77,7 @@ export function ProjectSwitcherItem({
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
onError={() => setImageError(true)}
/>
) : (
<IconComponent

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { cn, isMac } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
@@ -11,9 +11,12 @@ import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
import {
MACOS_ELECTRON_TOP_PADDING_CLASS,
SIDEBAR_FEATURE_FLAGS,
} from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
@@ -279,7 +282,12 @@ export function ProjectSwitcher() {
data-testid="project-switcher"
>
{/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2">
<div
className={cn(
'flex flex-col items-center pb-2 px-2',
isMac && isElectron() ? MACOS_ELECTRON_TOP_PADDING_CLASS : 'pt-3'
)}
>
<button
onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5"

View File

@@ -100,14 +100,8 @@ export function ProjectSelectorWithOptions({
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
const {
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
handlePreviewEnter,
handlePreviewLeave,
} = useProjectTheme();
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme();
if (!sidebarOpen || projects.length === 0) {
return null;
@@ -281,11 +275,8 @@ export function ProjectSelectorWithOptions({
onValueChange={(value) => {
if (currentProject) {
setPreviewTheme(null);
if (value !== '') {
setTheme(value as ThemeMode);
} else {
setTheme(globalTheme);
}
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
setProjectTheme(
currentProject.id,
value === '' ? null : (value as ThemeMode)

View File

@@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { isElectron, type Project } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store';
import {
@@ -89,7 +90,7 @@ export function SidebarHeader({
<div
className={cn(
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]'
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
)}
>
<Tooltip>
@@ -240,7 +241,7 @@ export function SidebarHeader({
<div
className={cn(
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]'
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
)}
>
{/* Header with logo and project dropdown */}

View File

@@ -1,8 +1,13 @@
import { useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { isElectron } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { formatShortcut, useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import type { SidebarStyle } from '@automaker/types';
@@ -97,15 +102,52 @@ export function SidebarNavigation({
return !!currentProject;
});
// Get the icon component for the current project
const getProjectIcon = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
const ProjectIcon = getProjectIcon();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<nav
ref={navRef}
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
// Extra padding for macOS Electron to avoid traffic light overlap
sidebarStyle === 'discord'
? isMac && isElectron()
? MACOS_ELECTRON_TOP_PADDING_CLASS
: 'pt-3'
: 'mt-1'
)}
>
{/* Project name display for classic/discord mode */}
{sidebarStyle === 'discord' && currentProject && sidebarOpen && (
<div className="mb-3">
<div className="flex items-center gap-2.5 px-3 py-2">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-5 h-5 rounded object-cover"
/>
) : (
<ProjectIcon className="w-5 h-5 text-brand-500 shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">
{currentProject.name}
</span>
</div>
<div className="h-px bg-border/40 mx-1 mt-1" />
</div>
)}
{/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;

View File

@@ -9,19 +9,15 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
}: ThemeMenuItemProps) {
const Icon = option.icon;
return (
<div
key={option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
>
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
</div>
);
});

View File

@@ -1,5 +1,11 @@
import { darkThemes, lightThemes } from '@/config/theme-options';
/**
* Tailwind class for top padding on macOS Electron to avoid overlapping with traffic light window controls.
* This padding is applied conditionally when running on macOS in Electron.
*/
export const MACOS_ELECTRON_TOP_PADDING_CLASS = 'pt-[38px]';
/**
* Shared constants for theme submenu positioning and layout.
* Used across project-context-menu and project-selector-with-options components

View File

@@ -116,9 +116,8 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
},
copilot: {
viewBox: '0 0 98 96',
// Official GitHub Octocat logo mark
// Official GitHub Octocat logo mark (theme-aware via currentColor)
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
fill: '#ffffff',
},
};

View File

@@ -1275,8 +1275,10 @@ export function BoardView() {
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
if (currentProject) {
// If selectedWorktree is undefined or it's the main worktree, branchName will be null.
// Otherwise, use the branch name.
const branchName = selectedWorktree?.isMain === false ? selectedWorktree.branch : null;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Persist to server settings so capacity checks use the correct value

View File

@@ -68,10 +68,10 @@ export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps)
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
// Validate file size (max 5MB for icons - allows animated GIFs)
if (file.size > 5 * 1024 * 1024) {
toast.error('File too large', {
description: 'Please upload an image smaller than 2MB.',
description: 'Please upload an image smaller than 5MB.',
});
return;
}
@@ -208,7 +208,7 @@ export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps)
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
PNG, JPG, GIF or WebP. Max 5MB.
</p>
</div>
</div>

View File

@@ -19,6 +19,7 @@ import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automak
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
export function EventHistoryView() {
const currentProject = useAppStore((state) => state.currentProject);
@@ -85,16 +86,18 @@ export function EventHistoryView() {
const failCount = hookResults.filter((r) => !r.success).length;
if (hooksTriggered === 0) {
alert('No matching hooks found for this event trigger.');
toast.info('No matching hooks found for this event trigger.');
} else if (failCount === 0) {
alert(`Successfully ran ${successCount} hook(s).`);
toast.success(`Successfully ran ${successCount} hook(s).`);
} else {
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
toast.warning(
`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`
);
}
}
} catch (error) {
console.error('Failed to replay event:', error);
alert('Failed to replay event. Check console for details.');
toast.error('Failed to replay event. Check console for details.');
} finally {
setReplayingEvent(null);
}

View File

@@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
// CLI Verification state
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null);
// API Key Verification state
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
@@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
const verifyCliAuth = useCallback(async () => {
setCliVerificationStatus('verifying');
setCliVerificationError(null);
setCliAuthType(null);
try {
const api = getElectronAPI();
@@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
if (result.authenticated && !hasLimitReachedError) {
setCliVerificationStatus('verified');
// Store the auth type for displaying specific success message
const authType = result.authType === 'oauth' ? 'oauth' : 'cli';
setCliAuthType(authType);
setClaudeAuthStatus({
authenticated: true,
method: 'cli_authenticated',
method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
oauthTokenValid: authType === 'oauth',
});
// Show specific success message based on auth type
if (authType === 'oauth') {
toast.success('Claude Code subscription detected and verified!');
} else {
toast.success('Claude CLI authentication verified!');
}
} else {
setCliVerificationStatus('error');
setCliVerificationError(
@@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Authentication verified!</p>
<p className="font-medium text-foreground">
{cliAuthType === 'oauth'
? 'Claude Code subscription verified!'
: 'CLI Authentication verified!'}
</p>
<p className="text-sm text-muted-foreground">
Your Claude CLI is working correctly.
{cliAuthType === 'oauth'
? 'Your Claude Code subscription is active and ready to use.'
: 'Your Claude CLI is working correctly.'}
</p>
</div>
</div>

View File

@@ -1442,6 +1442,7 @@ interface SetupAPI {
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
success: boolean;
authenticated: boolean;
authType?: 'oauth' | 'api_key' | 'cli';
error?: string;
}>;
getGhStatus?: () => Promise<{

View File

@@ -946,7 +946,7 @@ export class HttpApiClient implements ElectronAPI {
return response.json();
}
private async get<T>(endpoint: string): Promise<T> {
async get<T>(endpoint: string): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
@@ -976,7 +976,7 @@ export class HttpApiClient implements ElectronAPI {
return response.json();
}
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
async put<T>(endpoint: string, body?: unknown): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
@@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{
success: boolean;
authenticated: boolean;
authType?: 'oauth' | 'api_key' | 'cli';
error?: string;
}> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }),

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { saveProjects, saveTrashedProjects } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
// Note: setItem/getItem moved to ./utils/theme-utils.ts
@@ -360,7 +360,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const trashedProject: TrashedProject = {
...project,
trashedAt: Date.now(),
trashedAt: new Date().toISOString(),
};
set((state) => ({
@@ -369,12 +369,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
// Persist to storage
saveProjects(get().projects);
saveTrashedProjects(get().trashedProjects);
},
restoreTrashedProject: (projectId: string) => {
@@ -390,12 +387,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
// Persist to storage
saveProjects(get().projects);
saveTrashedProjects(get().trashedProjects);
},
deleteTrashedProject: (projectId: string) => {
@@ -403,21 +397,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
// Persist to storage
saveTrashedProjects(get().trashedProjects);
},
emptyTrash: () => {
set({ trashedProjects: [] });
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setTrashedProjects([]);
}
// Persist to storage
saveTrashedProjects([]);
},
setCurrentProject: (project) => {
@@ -474,14 +462,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
get().addProject(newProject);
get().setCurrentProject(newProject);
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
// Small delay to ensure state is updated before persisting
// Persist to storage (small delay to ensure state is updated)
setTimeout(() => {
electronAPI.projects.setProjects(get().projects);
saveProjects(get().projects);
}, 0);
}
return newProject;
},
@@ -564,11 +548,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectIcon: (projectId: string, icon: string | null) => {
@@ -576,27 +557,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, icon: icon ?? undefined } : p
),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, icon: icon ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, customIcon: customIconPath ?? undefined } : p
p.id === projectId ? { ...p, customIconPath: customIconPath ?? undefined } : p
),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, customIconPath: customIconPath ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectName: (projectId: string, name: string) => {
@@ -609,11 +594,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// View actions
@@ -659,11 +641,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
);
}
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
getEffectiveTheme: () => {
const state = get();
@@ -696,11 +675,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectFontMono: (projectId: string, fontFamily: string | null) => {
set((state) => ({
@@ -714,20 +690,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
getEffectiveFontSans: () => {
const state = get();
const projectFont = state.currentProject?.fontSans;
const projectFont = state.currentProject?.fontFamilySans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: () => {
const state = get();
const projectFont = state.currentProject?.fontMono;
const projectFont = state.currentProject?.fontFamilyMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
@@ -744,11 +717,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// Project Phase Model Overrides
@@ -781,11 +751,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
clearAllProjectPhaseModelOverrides: (projectId: string) => {
@@ -804,11 +771,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// Project Default Feature Model Override
@@ -830,11 +794,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// Feature actions
@@ -845,7 +806,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})),
addFeature: (feature) => {
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newFeature: Feature = { ...feature, id };
const newFeature = { ...feature, id } as Feature;
set((state) => ({ features: [...state.features, newFeature] }));
return newFeature;
},
@@ -2471,8 +2432,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
try {
const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/codex/models');
const data = response.data as {
const data = await httpApi.get<{
success: boolean;
models?: Array<{
id: string;
@@ -2484,7 +2444,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
isDefault: boolean;
}>;
error?: string;
};
}>('/api/codex/models');
if (data.success && data.models) {
set({
@@ -2542,8 +2502,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
try {
const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/opencode/models');
const data = response.data as {
const data = await httpApi.get<{
success: boolean;
models?: ModelDefinition[];
providers?: Array<{
@@ -2553,7 +2512,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
authMethod?: string;
}>;
error?: string;
};
}>('/api/setup/opencode/models');
if (data.success && data.models) {
// Filter out Bedrock models

View File

@@ -134,6 +134,8 @@ export {
findClaudeCliPath,
getClaudeAuthIndicators,
type ClaudeAuthIndicators,
type FileCheckResult,
type DirectoryCheckResult,
findCodexCliPath,
getCodexAuthIndicators,
type CodexAuthIndicators,

View File

@@ -750,6 +750,9 @@ export function electronUserDataWriteFileSync(
throw new Error('[SystemPaths] Electron userData path not initialized');
}
const fullPath = path.join(electronUserDataPath, relativePath);
// Ensure parent directory exists (may not exist on first launch)
const dir = path.dirname(fullPath);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(fullPath, data, options);
}
@@ -973,6 +976,27 @@ export async function findGitBashPath(): Promise<string | null> {
return findFirstExistingPath(getGitBashPaths());
}
/**
* Details about a file check performed during auth detection
*/
export interface FileCheckResult {
path: string;
exists: boolean;
readable: boolean;
error?: string;
}
/**
* Details about a directory check performed during auth detection
*/
export interface DirectoryCheckResult {
path: string;
exists: boolean;
readable: boolean;
entryCount: number;
error?: string;
}
/**
* Get Claude authentication status by checking various indicators
*/
@@ -985,67 +1009,165 @@ export interface ClaudeAuthIndicators {
hasOAuthToken: boolean;
hasApiKey: boolean;
} | null;
/** Detailed information about what was checked */
checks: {
settingsFile: FileCheckResult;
statsCache: FileCheckResult & { hasDailyActivity?: boolean };
projectsDir: DirectoryCheckResult;
credentialFiles: FileCheckResult[];
};
}
export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const settingsPath = getClaudeSettingsPath();
const statsCachePath = getClaudeStatsCachePath();
const projectsDir = getClaudeProjectsDir();
const credentialPaths = getClaudeCredentialPaths();
// Initialize checks with paths
const settingsFileCheck: FileCheckResult = {
path: settingsPath,
exists: false,
readable: false,
};
const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = {
path: statsCachePath,
exists: false,
readable: false,
};
const projectsDirCheck: DirectoryCheckResult = {
path: projectsDir,
exists: false,
readable: false,
entryCount: 0,
};
const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({
path: p,
exists: false,
readable: false,
}));
const result: ClaudeAuthIndicators = {
hasCredentialsFile: false,
hasSettingsFile: false,
hasStatsCacheWithActivity: false,
hasProjectsSessions: false,
credentials: null,
checks: {
settingsFile: settingsFileCheck,
statsCache: statsCacheCheck,
projectsDir: projectsDirCheck,
credentialFiles: credentialFileChecks,
},
};
// Check settings file
// First check existence, then try to read to confirm it's actually readable
try {
if (await systemPathAccess(getClaudeSettingsPath())) {
if (await systemPathAccess(settingsPath)) {
settingsFileCheck.exists = true;
// Try to actually read the file to confirm read permissions
try {
await systemPathReadFile(settingsPath);
settingsFileCheck.readable = true;
result.hasSettingsFile = true;
} catch (readErr) {
// File exists but cannot be read (permission denied, etc.)
settingsFileCheck.readable = false;
settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`;
}
} catch {
// Ignore errors
}
} catch (err) {
settingsFileCheck.error = err instanceof Error ? err.message : String(err);
}
// Check stats cache for recent activity
try {
const statsContent = await systemPathReadFile(getClaudeStatsCachePath());
const statsContent = await systemPathReadFile(statsCachePath);
statsCacheCheck.exists = true;
statsCacheCheck.readable = true;
try {
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
statsCacheCheck.hasDailyActivity = true;
result.hasStatsCacheWithActivity = true;
} else {
statsCacheCheck.hasDailyActivity = false;
}
} catch (parseErr) {
statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
statsCacheCheck.exists = false;
} else {
statsCacheCheck.error = err instanceof Error ? err.message : String(err);
}
} catch {
// Ignore errors
}
// Check for sessions in projects directory
try {
const sessions = await systemPathReaddir(getClaudeProjectsDir());
const sessions = await systemPathReaddir(projectsDir);
projectsDirCheck.exists = true;
projectsDirCheck.readable = true;
projectsDirCheck.entryCount = sessions.length;
if (sessions.length > 0) {
result.hasProjectsSessions = true;
}
} catch {
// Ignore errors
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
projectsDirCheck.exists = false;
} else {
projectsDirCheck.error = err instanceof Error ? err.message : String(err);
}
}
// Check credentials files
const credentialPaths = getClaudeCredentialPaths();
for (const credPath of credentialPaths) {
// We iterate through all credential paths and only stop when we find a file
// that contains actual credentials (OAuth tokens or API keys). An empty or
// token-less file should not prevent checking subsequent credential paths.
for (let i = 0; i < credentialPaths.length; i++) {
const credPath = credentialPaths[i];
const credCheck = credentialFileChecks[i];
try {
const content = await systemPathReadFile(credPath);
credCheck.exists = true;
credCheck.readable = true;
try {
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);
const hasOAuthToken = hasClaudeOauth || hasLegacyOauth;
const hasApiKey = !!credentials.api_key;
// Only consider this a valid credentials file if it actually contains tokens
// An empty JSON file ({}) or file without tokens should not stop us from
// checking subsequent credential paths
if (hasOAuthToken || hasApiKey) {
result.hasCredentialsFile = true;
result.credentials = {
hasOAuthToken: hasClaudeOauth || hasLegacyOauth,
hasApiKey: !!credentials.api_key,
hasOAuthToken,
hasApiKey,
};
break;
} catch {
// Continue to next path
break; // Found valid credentials, stop searching
}
// File exists and is valid JSON but contains no tokens - continue checking other paths
} catch (parseErr) {
credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
credCheck.exists = false;
} else {
credCheck.error = err instanceof Error ? err.message : String(err);
}
}
}

View File

@@ -0,0 +1,761 @@
/**
* Unit tests for OAuth credential detection scenarios
*
* Tests the various Claude credential detection formats including:
* - Claude Code CLI OAuth format (claudeAiOauth)
* - Legacy OAuth token format (oauth_token, access_token)
* - API key format (api_key)
* - Invalid/malformed credential files
*
* These tests use real temp directories to avoid complex fs mocking issues.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
describe('OAuth Credential Detection', () => {
let tempDir: string;
let originalHomedir: () => string;
let mockClaudeDir: string;
let mockCodexDir: string;
let mockOpenCodeDir: string;
beforeEach(async () => {
// Reset modules to get fresh state
vi.resetModules();
// Create a temporary directory
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-'));
// Create mock home directory structure
mockClaudeDir = path.join(tempDir, '.claude');
mockCodexDir = path.join(tempDir, '.codex');
mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode');
await fs.mkdir(mockClaudeDir, { recursive: true });
await fs.mkdir(mockCodexDir, { recursive: true });
await fs.mkdir(mockOpenCodeDir, { recursive: true });
// Mock os.homedir to return our temp directory
originalHomedir = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
});
afterEach(async () => {
vi.restoreAllMocks();
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getClaudeAuthIndicators', () => {
it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => {
const credentialsContent = JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-access-token-12345',
refreshToken: 'oauth-refresh-token-67890',
expiresAt: Date.now() + 3600000,
},
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials).not.toBeNull();
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect legacy OAuth token format (oauth_token)', async () => {
const credentialsContent = JSON.stringify({
oauth_token: 'legacy-oauth-token-abcdef',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect legacy access_token format', async () => {
const credentialsContent = JSON.stringify({
access_token: 'legacy-access-token-xyz',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect API key format', async () => {
const credentialsContent = JSON.stringify({
api_key: 'sk-ant-api03-xxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(false);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should detect both OAuth and API key when present', async () => {
const credentialsContent = JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
},
api_key: 'sk-ant-api03-xxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should handle missing credentials file gracefully', async () => {
// No credentials file created
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
expect(indicators.checks.credentialFiles).toBeDefined();
expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0);
expect(indicators.checks.credentialFiles[0].exists).toBe(false);
});
it('should handle malformed JSON in credentials file', async () => {
const malformedContent = '{ invalid json }';
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File exists but parsing fails
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
expect(indicators.checks.credentialFiles[0].exists).toBe(true);
expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error');
});
it('should handle empty credentials file', async () => {
const emptyContent = JSON.stringify({});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Empty credentials file ({}) should NOT be treated as having credentials
// because it contains no actual tokens. This allows the system to continue
// checking subsequent credential paths that might have valid tokens.
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
// But the file should still show as existing and readable in the checks
expect(indicators.checks.credentialFiles[0].exists).toBe(true);
expect(indicators.checks.credentialFiles[0].readable).toBe(true);
});
it('should handle credentials file with null values', async () => {
const nullContent = JSON.stringify({
claudeAiOauth: null,
api_key: null,
oauth_token: null,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File with all null values should NOT be treated as having credentials
// because null values are not valid tokens
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle credentials with empty string values', async () => {
const emptyStrings = JSON.stringify({
claudeAiOauth: {
accessToken: '',
refreshToken: '',
},
api_key: '',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Empty strings should NOT be treated as having credentials
// This allows checking subsequent credential paths for valid tokens
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should detect settings file presence', async () => {
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.checks.settingsFile.exists).toBe(true);
expect(indicators.checks.settingsFile.readable).toBe(true);
});
it('should detect stats cache with activity', async () => {
const statsContent = JSON.stringify({
dailyActivity: [
{ date: '2025-01-15', messagesCount: 10 },
{ date: '2025-01-16', messagesCount: 5 },
],
});
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(true);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.hasDailyActivity).toBe(true);
});
it('should detect stats cache without activity', async () => {
const statsContent = JSON.stringify({
dailyActivity: [],
});
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(false);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.hasDailyActivity).toBe(false);
});
it('should detect project sessions', async () => {
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
await fs.mkdir(path.join(projectsDir, 'session-2'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasProjectsSessions).toBe(true);
expect(indicators.checks.projectsDir.exists).toBe(true);
expect(indicators.checks.projectsDir.entryCount).toBe(2);
});
it('should return comprehensive check details', async () => {
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Verify all check detail objects are present
expect(indicators.checks).toBeDefined();
expect(indicators.checks.settingsFile).toBeDefined();
expect(indicators.checks.settingsFile.path).toContain('settings.json');
expect(indicators.checks.statsCache).toBeDefined();
expect(indicators.checks.statsCache.path).toContain('stats-cache.json');
expect(indicators.checks.projectsDir).toBeDefined();
expect(indicators.checks.projectsDir.path).toContain('projects');
expect(indicators.checks.credentialFiles).toBeDefined();
expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true);
});
it('should try both .credentials.json and credentials.json paths', async () => {
// Write to credentials.json (without leading dot)
const credentialsContent = JSON.stringify({
api_key: 'sk-test-key',
});
await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should find credentials in the second path
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should prefer first credentials file if both exist', async () => {
// Write OAuth to .credentials.json (first path checked)
await fs.writeFile(
path.join(mockClaudeDir, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
},
})
);
// Write API key to credentials.json (second path)
await fs.writeFile(
path.join(mockClaudeDir, 'credentials.json'),
JSON.stringify({
api_key: 'sk-test-key',
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should use first file (.credentials.json) which has OAuth
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should check second credentials file if first file has no tokens', async () => {
// Write empty/token-less content to .credentials.json (first path checked)
// This tests the bug fix: previously, an empty JSON file would stop the search
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({}));
// Write actual credentials to credentials.json (second path)
await fs.writeFile(
path.join(mockClaudeDir, 'credentials.json'),
JSON.stringify({
api_key: 'sk-test-key-from-second-file',
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should find credentials in second file since first file has no tokens
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
});
describe('getCodexAuthIndicators', () => {
it('should detect OAuth token in Codex auth file', async () => {
const authContent = JSON.stringify({
access_token: 'codex-oauth-token-12345',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect API key in Codex auth file', async () => {
const authContent = JSON.stringify({
OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(true);
});
it('should detect nested tokens in Codex auth file', async () => {
const authContent = JSON.stringify({
tokens: {
oauth_token: 'nested-oauth-token',
},
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should handle missing Codex auth file', async () => {
// No auth file created
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(false);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect api_key field in Codex auth', async () => {
const authContent = JSON.stringify({
api_key: 'sk-api-key-value',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasApiKey).toBe(true);
});
});
describe('getOpenCodeAuthIndicators', () => {
it('should detect provider-specific OAuth credentials', async () => {
const authContent = JSON.stringify({
anthropic: {
type: 'oauth',
access: 'oauth-access-token',
refresh: 'oauth-refresh-token',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect GitHub Copilot refresh token as OAuth', async () => {
const authContent = JSON.stringify({
'github-copilot': {
type: 'oauth',
access: '', // Empty access token
refresh: 'gh-refresh-token', // But has refresh token
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should detect provider-specific API key credentials', async () => {
const authContent = JSON.stringify({
openai: {
type: 'api_key',
key: 'sk-xxxxxxxxxxxx',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(true);
});
it('should detect multiple providers', async () => {
const authContent = JSON.stringify({
anthropic: {
type: 'oauth',
access: 'anthropic-token',
refresh: 'refresh-token',
},
openai: {
type: 'api_key',
key: 'sk-xxxxxxxxxxxx',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(true);
});
it('should handle missing OpenCode auth file', async () => {
// No auth file created
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(false);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(false);
});
it('should handle legacy top-level OAuth keys', async () => {
const authContent = JSON.stringify({
access_token: 'legacy-access-token',
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should detect copilot provider OAuth', async () => {
const authContent = JSON.stringify({
copilot: {
type: 'oauth',
access: 'copilot-access-token',
refresh: 'copilot-refresh-token',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
});
describe('Credential path helpers', () => {
it('should return correct Claude credential paths', async () => {
const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths');
const configDir = getClaudeConfigDir();
expect(configDir).toContain('.claude');
const credPaths = getClaudeCredentialPaths();
expect(credPaths.length).toBeGreaterThan(0);
expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true);
expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true);
});
it('should return correct Codex auth path', async () => {
const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths');
const configDir = getCodexConfigDir();
expect(configDir).toContain('.codex');
const authPath = getCodexAuthPath();
expect(authPath).toContain('.codex');
expect(authPath).toContain('auth.json');
});
it('should return correct OpenCode auth path', async () => {
const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths');
const configDir = getOpenCodeConfigDir();
expect(configDir).toContain('opencode');
const authPath = getOpenCodeAuthPath();
expect(authPath).toContain('opencode');
expect(authPath).toContain('auth.json');
});
});
describe('Edge cases for credential detection', () => {
it('should handle credentials file with unexpected structure', async () => {
const unexpectedContent = JSON.stringify({
someUnexpectedKey: 'value',
nested: {
deeply: {
unexpected: true,
},
},
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File with unexpected structure but no valid tokens should NOT be treated as having credentials
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle array instead of object in credentials', async () => {
const arrayContent = JSON.stringify(['token1', 'token2']);
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle numeric values in credential fields', async () => {
const numericContent = JSON.stringify({
api_key: 12345,
oauth_token: 67890,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Note: Current implementation uses JavaScript truthiness which accepts numbers
// This documents the actual behavior - ideally would validate string type
expect(indicators.hasCredentialsFile).toBe(true);
// The implementation checks truthiness, not strict string type
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should handle boolean values in credential fields', async () => {
const booleanContent = JSON.stringify({
api_key: true,
oauth_token: false,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Note: Current implementation uses JavaScript truthiness
// api_key: true is truthy, oauth_token: false is falsy
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy
expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy
});
it('should handle malformed stats-cache.json gracefully', async () => {
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }');
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(false);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.error).toBeDefined();
});
it('should handle empty projects directory', async () => {
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasProjectsSessions).toBe(false);
expect(indicators.checks.projectsDir.exists).toBe(true);
expect(indicators.checks.projectsDir.entryCount).toBe(0);
});
});
describe('Combined authentication scenarios', () => {
it('should detect CLI authenticated state with settings + sessions', async () => {
// Create settings file
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
// Create projects directory with sessions
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.hasProjectsSessions).toBe(true);
});
it('should detect recent activity indicating working auth', async () => {
// Create stats cache with recent activity
await fs.writeFile(
path.join(mockClaudeDir, 'stats-cache.json'),
JSON.stringify({
dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }],
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(true);
});
it('should handle complete auth setup', async () => {
// Create all auth indicators
await fs.writeFile(
path.join(mockClaudeDir, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'token',
refreshToken: 'refresh',
},
})
);
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
await fs.writeFile(
path.join(mockClaudeDir, 'stats-cache.json'),
JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] })
);
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.hasStatsCacheWithActivity).toBe(true);
expect(indicators.hasProjectsSessions).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
});
});
});

View File

@@ -965,8 +965,20 @@ export const DEFAULT_PLAN_REVISION_TEMPLATE = `The user has requested revisions
## Instructions
Please regenerate the specification incorporating the user's feedback.
Keep the same format with the \`\`\`tasks block for task definitions.
After generating the revised spec, output:
**Current planning mode: {{planningMode}}**
**CRITICAL REQUIREMENT**: Your revised specification MUST include a \`\`\`tasks code block containing task definitions in the EXACT format shown below. This is MANDATORY - without the tasks block, the system cannot track or execute tasks properly.
### Required Task Format
{{taskFormatExample}}
**IMPORTANT**:
1. The \`\`\`tasks block must appear in your response
2. Each task MUST start with "- [ ] T###:" where ### is a sequential number (T001, T002, T003, etc.)
3. Each task MUST include "| File:" followed by the primary file path
4. Tasks should be ordered by dependencies (foundational tasks first)
After generating the revised spec with the tasks block, output:
"[SPEC_GENERATED] Please review the revised specification above."`;
export const DEFAULT_CONTINUATION_AFTER_APPROVAL_TEMPLATE = `The plan/specification has been approved. Now implement it.