@@ -241,6 +241,10 @@ export class AgentProcessManager {
|
||||
const log = data.toString('utf8');
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
// Print to console when DEBUG is enabled (visible in pnpm dev terminal)
|
||||
if (['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '')) {
|
||||
console.log(`[Agent:${taskId}] ${log.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr - explicitly decode as UTF-8 for cross-platform Unicode support
|
||||
@@ -250,6 +254,10 @@ export class AgentProcessManager {
|
||||
// so we treat it as log, not error
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
// Print to console when DEBUG is enabled (visible in pnpm dev terminal)
|
||||
if (['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '')) {
|
||||
console.log(`[Agent:${taskId}] ${log.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
|
||||
@@ -27,7 +27,7 @@ export type {
|
||||
} from './updater/types';
|
||||
|
||||
// Export version management
|
||||
export { getBundledVersion } from './updater/version-manager';
|
||||
export { getBundledVersion, getEffectiveVersion } from './updater/version-manager';
|
||||
|
||||
// Export path resolution
|
||||
export {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { IPCResult } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
|
||||
/**
|
||||
@@ -21,10 +22,16 @@ export function registerAutobuildSourceHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK,
|
||||
async (): Promise<IPCResult<{ updateAvailable: boolean; currentVersion: string; latestVersion?: string; releaseNotes?: string; releaseUrl?: string; error?: string }>> => {
|
||||
console.log('[autobuild-source] Check for updates called');
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK called');
|
||||
try {
|
||||
const result = await checkSourceUpdates();
|
||||
console.log('[autobuild-source] Check result:', JSON.stringify(result, null, 2));
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK result:', result);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('[autobuild-source] Check error:', error);
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates'
|
||||
@@ -36,25 +43,33 @@ export function registerAutobuildSourceHandlers(
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD,
|
||||
() => {
|
||||
debugLog('[IPC] Autobuild source download requested');
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
if (!mainWindow) {
|
||||
debugLog('[IPC] No main window available, aborting update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start download in background
|
||||
downloadAndApplyUpdate((progress) => {
|
||||
debugLog('[IPC] Update progress:', progress.stage, progress.message);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
progress
|
||||
);
|
||||
}).then((result) => {
|
||||
if (result.success) {
|
||||
debugLog('[IPC] Update completed successfully, version:', result.version);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'complete',
|
||||
message: `Updated to version ${result.version}`
|
||||
message: `Updated to version ${result.version}`,
|
||||
newVersion: result.version // Include new version for UI refresh
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
} else {
|
||||
debugLog('[IPC] Update failed:', result.error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
@@ -64,6 +79,7 @@ export function registerAutobuildSourceHandlers(
|
||||
);
|
||||
}
|
||||
}).catch((error) => {
|
||||
debugLog('[IPC] Update error:', error instanceof Error ? error.message : error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
@@ -88,7 +104,9 @@ export function registerAutobuildSourceHandlers(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION,
|
||||
async (): Promise<IPCResult<string>> => {
|
||||
try {
|
||||
const version = getBundledVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
debugLog('[IPC] Returning effective version:', version);
|
||||
return { success: true, data: version };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -62,6 +62,10 @@ export function registerEnvHandlers(
|
||||
if (config.githubAutoSync !== undefined) {
|
||||
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
|
||||
}
|
||||
// Git/Worktree Settings
|
||||
if (config.defaultBranch !== undefined) {
|
||||
existingVars['DEFAULT_BRANCH'] = config.defaultBranch;
|
||||
}
|
||||
if (config.graphitiEnabled !== undefined) {
|
||||
existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false';
|
||||
}
|
||||
@@ -109,6 +113,13 @@ ${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}`
|
||||
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
|
||||
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Default base branch for worktree creation
|
||||
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch
|
||||
${existingVars['DEFAULT_BRANCH'] ? `DEFAULT_BRANCH=${existingVars['DEFAULT_BRANCH']}` : '# DEFAULT_BRANCH=main'}
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
@@ -216,6 +227,11 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
config.githubAutoSync = true;
|
||||
}
|
||||
|
||||
// Git/Worktree config
|
||||
if (vars['DEFAULT_BRANCH']) {
|
||||
config.defaultBranch = vars['DEFAULT_BRANCH'];
|
||||
}
|
||||
|
||||
if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') {
|
||||
config.graphitiEnabled = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../../shared/types';
|
||||
import { AgentManager } from '../agent';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { getEffectiveVersion } from '../auto-claude-updater';
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
@@ -264,7 +265,10 @@ export function registerSettingsHandlers(
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise<string> => {
|
||||
return app.getVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
console.log('[settings-handlers] APP_VERSION returning:', version);
|
||||
return version;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg } from '../../shared/utils/shell-escape';
|
||||
|
||||
|
||||
/**
|
||||
@@ -162,14 +164,108 @@ export function registerTerminalHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] ========== PROFILE SWITCH START ==========');
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Requested profile ID:', profileId);
|
||||
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const previousProfile = profileManager.getActiveProfile();
|
||||
const previousProfileId = previousProfile.id;
|
||||
const newProfile = profileManager.getProfile(profileId);
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Previous profile:', {
|
||||
id: previousProfile.id,
|
||||
name: previousProfile.name,
|
||||
hasOAuthToken: !!previousProfile.oauthToken,
|
||||
isDefault: previousProfile.isDefault
|
||||
});
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] New profile:', newProfile ? {
|
||||
id: newProfile.id,
|
||||
name: newProfile.name,
|
||||
hasOAuthToken: !!newProfile.oauthToken,
|
||||
isDefault: newProfile.isDefault
|
||||
} : 'NOT FOUND');
|
||||
|
||||
const success = profileManager.setActiveProfile(profileId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] setActiveProfile result:', success);
|
||||
|
||||
if (!success) {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Profile not found, aborting');
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
// If the profile actually changed, restart Claude in active terminals
|
||||
// This ensures existing Claude sessions use the new profile's OAuth token
|
||||
const profileChanged = previousProfileId !== profileId;
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Profile changed:', profileChanged, {
|
||||
previousProfileId,
|
||||
newProfileId: profileId
|
||||
});
|
||||
|
||||
if (profileChanged) {
|
||||
const activeTerminalIds = terminalManager.getActiveTerminalIds();
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Active terminal IDs:', activeTerminalIds);
|
||||
|
||||
const switchPromises: Promise<void>[] = [];
|
||||
const terminalsInClaudeMode: string[] = [];
|
||||
const terminalsNotInClaudeMode: string[] = [];
|
||||
|
||||
for (const terminalId of activeTerminalIds) {
|
||||
const isClaudeMode = terminalManager.isClaudeMode(terminalId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal check:', {
|
||||
terminalId,
|
||||
isClaudeMode
|
||||
});
|
||||
|
||||
if (isClaudeMode) {
|
||||
terminalsInClaudeMode.push(terminalId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Queuing terminal for profile switch:', terminalId);
|
||||
switchPromises.push(
|
||||
terminalManager.switchClaudeProfile(terminalId, profileId)
|
||||
.then(() => {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal profile switch SUCCESS:', terminalId);
|
||||
})
|
||||
.catch((err) => {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal profile switch FAILED:', terminalId, err);
|
||||
throw err; // Re-throw so Promise.allSettled correctly reports rejections
|
||||
})
|
||||
);
|
||||
} else {
|
||||
terminalsNotInClaudeMode.push(terminalId);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal summary:', {
|
||||
total: activeTerminalIds.length,
|
||||
inClaudeMode: terminalsInClaudeMode.length,
|
||||
notInClaudeMode: terminalsNotInClaudeMode.length,
|
||||
terminalsToSwitch: terminalsInClaudeMode,
|
||||
terminalsSkipped: terminalsNotInClaudeMode
|
||||
});
|
||||
|
||||
// Wait for all switches to complete (but don't fail the main operation if some fail)
|
||||
if (switchPromises.length > 0) {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Waiting for', switchPromises.length, 'terminal switches...');
|
||||
const results = await Promise.allSettled(switchPromises);
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled').length;
|
||||
const rejected = results.filter(r => r.status === 'rejected').length;
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Switch results:', {
|
||||
total: results.length,
|
||||
fulfilled,
|
||||
rejected
|
||||
});
|
||||
} else {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] No terminals in Claude mode to switch');
|
||||
}
|
||||
} else {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Same profile selected, no terminal switches needed');
|
||||
}
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] ========== PROFILE SWITCH COMPLETE ==========');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] EXCEPTION:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active Claude profile'
|
||||
@@ -208,7 +304,7 @@ export function registerTerminalHandlers(
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
console.warn('[IPC] Created config directory:', profile.configDir);
|
||||
debugLog('[IPC] Created config directory:', profile.configDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +313,7 @@ export function registerTerminalHandlers(
|
||||
const terminalId = `claude-login-${profileId}-${Date.now()}`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
console.warn('[IPC] Initializing Claude profile:', {
|
||||
debugLog('[IPC] Initializing Claude profile:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
configDir: profile.configDir,
|
||||
@@ -235,12 +331,14 @@ export function registerTerminalHandlers(
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
// SECURITY: Use escapeShellArg to prevent command injection via configDir
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
}
|
||||
|
||||
console.warn('[IPC] Sending login command to terminal:', loginCommand);
|
||||
debugLog('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
// Write the login command to the terminal
|
||||
terminalManager.write(terminalId, `${loginCommand}\r`);
|
||||
@@ -263,7 +361,7 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize Claude profile:', error);
|
||||
debugError('[IPC] Failed to initialize Claude profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize Claude profile'
|
||||
@@ -284,7 +382,7 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to set OAuth token:', error);
|
||||
debugError('[IPC] Failed to set OAuth token:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set OAuth token'
|
||||
@@ -569,5 +667,5 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi
|
||||
mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification);
|
||||
});
|
||||
|
||||
console.warn('[terminal-handlers] Usage monitor event forwarding initialized');
|
||||
debugLog('[terminal-handlers] Usage monitor event forwarding initialized');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as SessionHandler from './session-handler';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape';
|
||||
import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
@@ -92,9 +94,11 @@ export function handleOAuthToken(
|
||||
console.warn('[ClaudeIntegration] OAuth token detected, length:', token.length);
|
||||
|
||||
const email = OutputParser.extractEmail(terminal.outputBuffer);
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+)-/);
|
||||
// Match both custom profiles (profile-123456) and the default profile
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+|default)-/);
|
||||
|
||||
if (profileIdMatch) {
|
||||
// Save to specific profile (profile login terminal)
|
||||
const profileId = profileIdMatch[1];
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email || undefined);
|
||||
@@ -116,16 +120,56 @@ export function handleOAuthToken(
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token to profile:', profileId);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration] OAuth token detected but not in a profile login terminal');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Token detected but no profile associated with this terminal',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
// No profile-specific terminal, save to active profile (GitHub OAuth flow, etc.)
|
||||
console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, saving to active profile');
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
// Defensive null check for active profile
|
||||
if (!activeProfile) {
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token: no active profile found');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: undefined,
|
||||
email,
|
||||
success: false,
|
||||
message: 'No active profile found',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined);
|
||||
|
||||
if (success) {
|
||||
console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile.id,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
} else {
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token to active profile:', activeProfile.name);
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile?.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Failed to save token to active profile',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +205,11 @@ export function invokeClaude(
|
||||
getWindow: WindowGetter,
|
||||
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void
|
||||
): void {
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE START ==========');
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Terminal ID:', terminal.id);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Requested profile ID:', profileId);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd);
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
@@ -175,31 +224,70 @@ export function invokeClaude(
|
||||
const previousProfileId = terminal.claudeProfileId;
|
||||
terminal.claudeProfileId = activeProfile?.id;
|
||||
|
||||
const cwdCommand = cwd ? `cd "${cwd}" && ` : '';
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Profile resolution:', {
|
||||
previousProfileId,
|
||||
newProfileId: activeProfile?.id,
|
||||
profileName: activeProfile?.name,
|
||||
hasOAuthToken: !!activeProfile?.oauthToken,
|
||||
isDefault: activeProfile?.isDefault
|
||||
});
|
||||
|
||||
// Use safe shell escaping to prevent command injection
|
||||
const cwdCommand = buildCdCommand(cwd);
|
||||
const needsEnvOverride = profileId && profileId !== previousProfileId;
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', {
|
||||
profileIdProvided: !!profileId,
|
||||
previousProfileId,
|
||||
needsEnvOverride
|
||||
});
|
||||
|
||||
if (needsEnvOverride && activeProfile && !activeProfile.isDefault) {
|
||||
const token = profileManager.getProfileToken(activeProfile.id);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Token retrieval:', {
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile);
|
||||
fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 });
|
||||
|
||||
terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`);
|
||||
console.warn('[ClaudeIntegration] Switching to Claude profile:', activeProfile.name, '(via secure temp file)');
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// - HISTFILE= disables history file writing for the current command
|
||||
// - HISTCONTROL=ignorespace causes commands starting with space to be ignored
|
||||
// - Leading space ensures the command is ignored even if HISTCONTROL was already set
|
||||
// - Uses subshell (...) to isolate environment changes
|
||||
// This prevents temp file paths from appearing in shell history
|
||||
const command = `clear && ${cwdCommand} HISTFILE= HISTCONTROL=ignorespace bash -c 'source "${tempFile}" && rm -f "${tempFile}" && exec claude'\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) ==========');
|
||||
return;
|
||||
} else if (activeProfile.configDir) {
|
||||
terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`);
|
||||
console.warn('[ClaudeIntegration] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir);
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// Same history-disabling technique as temp file method above
|
||||
// SECURITY: Use escapeShellArg for configDir to prevent command injection
|
||||
// Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string
|
||||
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
|
||||
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} bash -c 'exec claude'\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) ==========');
|
||||
return;
|
||||
} else {
|
||||
debugLog('[ClaudeIntegration:invokeClaude] WARNING: No token or configDir available for non-default profile');
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProfile && !activeProfile.isDefault) {
|
||||
console.warn('[ClaudeIntegration] Using Claude profile:', activeProfile.name, '(from terminal environment)');
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name);
|
||||
}
|
||||
|
||||
terminal.pty.write(`${cwdCommand}claude\r`);
|
||||
const command = `${cwdCommand}claude\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command);
|
||||
terminal.pty.write(command);
|
||||
|
||||
if (activeProfile) {
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
@@ -220,6 +308,8 @@ export function invokeClaude(
|
||||
if (projectPath) {
|
||||
onSessionCapture(terminal.id, projectPath, startTime);
|
||||
}
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) ==========');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +324,8 @@ export function resumeClaude(
|
||||
|
||||
let command: string;
|
||||
if (sessionId) {
|
||||
command = `claude --resume "${sessionId}"`;
|
||||
// SECURITY: Escape sessionId to prevent command injection
|
||||
command = `claude --resume ${escapeShellArg(sessionId)}`;
|
||||
terminal.claudeSessionId = sessionId;
|
||||
} else {
|
||||
command = 'claude --continue';
|
||||
@@ -248,6 +339,103 @@ export function resumeClaude(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for waiting for Claude to exit
|
||||
*/
|
||||
interface WaitForExitConfig {
|
||||
/** Maximum time to wait for Claude to exit (ms) */
|
||||
timeout?: number;
|
||||
/** Interval between checks (ms) */
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of waiting for Claude to exit
|
||||
*/
|
||||
interface WaitForExitResult {
|
||||
/** Whether Claude exited successfully */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Whether the operation timed out */
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell prompt patterns that indicate Claude has exited and shell is ready
|
||||
* These patterns match common shell prompts across bash, zsh, fish, etc.
|
||||
*/
|
||||
const SHELL_PROMPT_PATTERNS = [
|
||||
/[$%#>❯]\s*$/m, // Common prompt endings: $, %, #, >, ❯
|
||||
/\w+@[\w.-]+[:\s]/, // user@hostname: format
|
||||
/^\s*\S+\s*[$%#>❯]\s*$/m, // hostname/path followed by prompt char
|
||||
/\(.*\)\s*[$%#>❯]\s*$/m, // (venv) or (branch) followed by prompt
|
||||
];
|
||||
|
||||
/**
|
||||
* Wait for Claude to exit by monitoring terminal output for shell prompt
|
||||
*
|
||||
* Instead of using fixed delays, this monitors the terminal's outputBuffer
|
||||
* for patterns indicating that Claude has exited and the shell prompt is visible.
|
||||
*/
|
||||
async function waitForClaudeExit(
|
||||
terminal: TerminalProcess,
|
||||
config: WaitForExitConfig = {}
|
||||
): Promise<WaitForExitResult> {
|
||||
const { timeout = 5000, pollInterval = 100 } = config;
|
||||
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Waiting for Claude to exit...');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Config:', { timeout, pollInterval });
|
||||
|
||||
// Capture current buffer length to detect new output
|
||||
const initialBufferLength = terminal.outputBuffer.length;
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkForPrompt = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Check for timeout
|
||||
if (elapsed >= timeout) {
|
||||
console.warn('[ClaudeIntegration:waitForClaudeExit] Timeout waiting for Claude to exit after', timeout, 'ms');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Timeout reached, Claude may not have exited cleanly');
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Timeout waiting for Claude to exit after ${timeout}ms`,
|
||||
timedOut: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get new output since we started waiting
|
||||
const newOutput = terminal.outputBuffer.slice(initialBufferLength);
|
||||
|
||||
// Check if we can see a shell prompt in the new output
|
||||
for (const pattern of SHELL_PROMPT_PATTERNS) {
|
||||
if (pattern.test(newOutput)) {
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Shell prompt detected after', elapsed, 'ms');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Matched pattern:', pattern.toString());
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if isClaudeMode was cleared (set by other handlers)
|
||||
if (!terminal.isClaudeMode) {
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] isClaudeMode flag cleared after', elapsed, 'ms');
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
setTimeout(checkForPrompt, pollInterval);
|
||||
};
|
||||
|
||||
// Start checking
|
||||
checkForPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch terminal to a different Claude profile
|
||||
*/
|
||||
@@ -258,27 +446,95 @@ export async function switchClaudeProfile(
|
||||
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => void,
|
||||
clearRateLimitCallback: (terminalId: string) => void
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Always-on tracing
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Called for terminal:', terminal.id, '| profileId:', profileId);
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Terminal state: isClaudeMode=', terminal.isClaudeMode);
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] ========== SWITCH PROFILE START ==========');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal ID:', terminal.id);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Target profile ID:', profileId);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal state:', {
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
currentProfileId: terminal.claudeProfileId,
|
||||
claudeSessionId: terminal.claudeSessionId,
|
||||
projectPath: terminal.projectPath,
|
||||
cwd: terminal.cwd
|
||||
});
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Profile found:', profile?.name || 'NOT FOUND');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Target profile:', profile ? {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
hasOAuthToken: !!profile.oauthToken,
|
||||
isDefault: profile.isDefault
|
||||
} : 'NOT FOUND');
|
||||
|
||||
if (!profile) {
|
||||
console.error('[ClaudeIntegration:switchClaudeProfile] Profile not found, aborting');
|
||||
debugError('[ClaudeIntegration:switchClaudeProfile] Profile not found, aborting');
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
console.warn('[ClaudeIntegration] Switching to Claude profile:', profile.name);
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Switching to profile:', profile.name);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Switching to Claude profile:', profile.name);
|
||||
|
||||
if (terminal.isClaudeMode) {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Sending exit commands (Ctrl+C, /exit)');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal is in Claude mode, sending exit commands');
|
||||
|
||||
// Send Ctrl+C to interrupt any ongoing operation
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Sending Ctrl+C (\\x03)');
|
||||
terminal.pty.write('\x03');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Wait briefly for Ctrl+C to take effect before sending /exit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Send /exit command
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Sending /exit command');
|
||||
terminal.pty.write('/exit\r');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Wait for Claude to actually exit by monitoring for shell prompt
|
||||
const exitResult = await waitForClaudeExit(terminal, { timeout: 5000, pollInterval: 100 });
|
||||
|
||||
if (exitResult.timedOut) {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Timed out waiting for Claude to exit, proceeding with caution');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Exit timeout - terminal may be in inconsistent state');
|
||||
|
||||
// Even on timeout, we'll try to proceed but log the warning
|
||||
// The alternative would be to abort, but that could leave users stuck
|
||||
// If this becomes a problem, we could add retry logic or abort option
|
||||
} else if (!exitResult.success) {
|
||||
console.error('[ClaudeIntegration:switchClaudeProfile] Failed to exit Claude:', exitResult.error);
|
||||
debugError('[ClaudeIntegration:switchClaudeProfile] Exit failed:', exitResult.error);
|
||||
// Continue anyway - the /exit command was sent
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Claude exited successfully');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Claude exited, ready to switch profile');
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] NOT in Claude mode, skipping exit commands');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal NOT in Claude mode, skipping exit commands');
|
||||
}
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Clearing rate limit state for terminal');
|
||||
clearRateLimitCallback(terminal.id);
|
||||
|
||||
const projectPath = terminal.projectPath || terminal.cwd;
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with new profile:', {
|
||||
terminalId: terminal.id,
|
||||
projectPath,
|
||||
profileId
|
||||
});
|
||||
invokeClaudeCallback(terminal.id, projectPath, profileId);
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager');
|
||||
profileManager.setActiveProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] COMPLETE');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] ========== SWITCH PROFILE COMPLETE ==========');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -74,9 +74,12 @@ export function downloadFile(
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(destPath);
|
||||
|
||||
// GitHub API URLs need the GitHub Accept header to get a redirect to the actual file
|
||||
// Non-API URLs (CDN, direct downloads) use octet-stream
|
||||
const isGitHubApi = url.includes('api.github.com');
|
||||
const headers = {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/octet-stream'
|
||||
'Accept': isGitHubApi ? 'application/vnd.github+json' : 'application/octet-stream'
|
||||
};
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
import { GITHUB_CONFIG } from './config';
|
||||
import { fetchJson } from './http-client';
|
||||
import { getBundledVersion, parseVersionFromTag, compareVersions } from './version-manager';
|
||||
import { getEffectiveVersion, parseVersionFromTag, compareVersions } from './version-manager';
|
||||
import { GitHubRelease, AutoBuildUpdateCheck } from './types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
// Cache for the latest release info (used by download)
|
||||
let cachedLatestRelease: GitHubRelease | null = null;
|
||||
@@ -35,7 +36,9 @@ export function clearCachedRelease(): void {
|
||||
* Check GitHub Releases for the latest version
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
const currentVersion = getBundledVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const currentVersion = getEffectiveVersion();
|
||||
debugLog('[UpdateCheck] Current effective version:', currentVersion);
|
||||
|
||||
try {
|
||||
// Fetch latest release from GitHub Releases API
|
||||
@@ -47,9 +50,11 @@ export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
|
||||
// Parse version from tag (e.g., "v1.2.0" -> "1.2.0")
|
||||
const latestVersion = parseVersionFromTag(release.tag_name);
|
||||
debugLog('[UpdateCheck] Latest version:', latestVersion);
|
||||
|
||||
// Compare versions
|
||||
const updateAvailable = compareVersions(latestVersion, currentVersion) > 0;
|
||||
debugLog('[UpdateCheck] Update available:', updateAvailable);
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
@@ -61,6 +66,7 @@ export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
} catch (error) {
|
||||
// Clear cache on error
|
||||
clearCachedRelease();
|
||||
debugLog('[UpdateCheck] Error:', error instanceof Error ? error.message : error);
|
||||
|
||||
return {
|
||||
updateAvailable: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getUpdateCachePath, getUpdateTargetPath } from './path-resolver';
|
||||
import { extractTarball, copyDirectoryRecursive, preserveFiles, restoreFiles, cleanTargetDirectory } from './file-operations';
|
||||
import { getCachedRelease, setCachedRelease, clearCachedRelease } from './update-checker';
|
||||
import { GitHubRelease, AutoBuildUpdateResult, UpdateProgressCallback, UpdateMetadata } from './types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Download and apply the latest auto-claude update from GitHub Releases
|
||||
@@ -25,6 +26,9 @@ export async function downloadAndApplyUpdate(
|
||||
): Promise<AutoBuildUpdateResult> {
|
||||
const cachePath = getUpdateCachePath();
|
||||
|
||||
debugLog('[Update] Starting update process...');
|
||||
debugLog('[Update] Cache path:', cachePath);
|
||||
|
||||
try {
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -34,19 +38,25 @@ export async function downloadAndApplyUpdate(
|
||||
// Ensure cache directory exists
|
||||
if (!existsSync(cachePath)) {
|
||||
mkdirSync(cachePath, { recursive: true });
|
||||
debugLog('[Update] Created cache directory');
|
||||
}
|
||||
|
||||
// Get release info (use cache or fetch fresh)
|
||||
let release = getCachedRelease();
|
||||
if (!release) {
|
||||
const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`;
|
||||
debugLog('[Update] Fetching release info from:', releaseUrl);
|
||||
release = await fetchJson<GitHubRelease>(releaseUrl);
|
||||
setCachedRelease(release);
|
||||
} else {
|
||||
debugLog('[Update] Using cached release info');
|
||||
}
|
||||
|
||||
// Use the release tarball URL
|
||||
const tarballUrl = release.tarball_url;
|
||||
const releaseVersion = parseVersionFromTag(release.tag_name);
|
||||
debugLog('[Update] Release version:', releaseVersion);
|
||||
debugLog('[Update] Tarball URL:', tarballUrl);
|
||||
|
||||
const tarballPath = path.join(cachePath, 'auto-claude-update.tar.gz');
|
||||
const extractPath = path.join(cachePath, 'extracted');
|
||||
@@ -63,6 +73,8 @@ export async function downloadAndApplyUpdate(
|
||||
message: 'Downloading update...'
|
||||
});
|
||||
|
||||
debugLog('[Update] Starting download to:', tarballPath);
|
||||
|
||||
// Download the tarball
|
||||
await downloadFile(tarballUrl, tarballPath, (percent) => {
|
||||
onProgress?.({
|
||||
@@ -72,14 +84,20 @@ export async function downloadAndApplyUpdate(
|
||||
});
|
||||
});
|
||||
|
||||
debugLog('[Update] Download complete');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'extracting',
|
||||
message: 'Extracting update...'
|
||||
});
|
||||
|
||||
debugLog('[Update] Extracting to:', extractPath);
|
||||
|
||||
// Extract the tarball
|
||||
await extractTarball(tarballPath, extractPath);
|
||||
|
||||
debugLog('[Update] Extraction complete');
|
||||
|
||||
// Find the auto-claude folder in extracted content
|
||||
// GitHub tarballs have a root folder like "owner-repo-hash/"
|
||||
const extractedDirs = readdirSync(extractPath);
|
||||
@@ -96,6 +114,7 @@ export async function downloadAndApplyUpdate(
|
||||
|
||||
// Determine where to install the update
|
||||
const targetPath = getUpdateTargetPath();
|
||||
debugLog('[Update] Target install path:', targetPath);
|
||||
|
||||
// Backup existing source (if in dev mode)
|
||||
const backupPath = path.join(cachePath, 'backup');
|
||||
@@ -104,11 +123,14 @@ export async function downloadAndApplyUpdate(
|
||||
rmSync(backupPath, { recursive: true, force: true });
|
||||
}
|
||||
// Simple copy for backup
|
||||
debugLog('[Update] Creating backup at:', backupPath);
|
||||
copyDirectoryRecursive(targetPath, backupPath);
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
debugLog('[Update] Applying update...');
|
||||
await applyUpdate(targetPath, autoBuildSource);
|
||||
debugLog('[Update] Update applied successfully');
|
||||
|
||||
// Write update metadata
|
||||
const metadata: UpdateMetadata = {
|
||||
@@ -132,14 +154,26 @@ export async function downloadAndApplyUpdate(
|
||||
message: `Updated to version ${releaseVersion}`
|
||||
});
|
||||
|
||||
debugLog('[Update] ============================================');
|
||||
debugLog('[Update] UPDATE SUCCESSFUL');
|
||||
debugLog('[Update] New version:', releaseVersion);
|
||||
debugLog('[Update] Target path:', targetPath);
|
||||
debugLog('[Update] ============================================');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: releaseVersion
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Update failed';
|
||||
debugLog('[Update] ============================================');
|
||||
debugLog('[Update] UPDATE FAILED');
|
||||
debugLog('[Update] Error:', errorMessage);
|
||||
debugLog('[Update] ============================================');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'error',
|
||||
message: error instanceof Error ? error.message : 'Update failed'
|
||||
message: errorMessage
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,17 +3,85 @@
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UpdateMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Get the current app/framework version
|
||||
* Get the current app/framework version from package.json
|
||||
*
|
||||
* Uses app.getVersion() (from package.json) as the single source of truth.
|
||||
* Both the Electron app and auto-claude framework share the same version.
|
||||
* Uses app.getVersion() (from package.json) as the base version.
|
||||
*/
|
||||
export function getBundledVersion(): string {
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective version - accounts for source updates
|
||||
*
|
||||
* Returns the updated source version if an update has been applied,
|
||||
* otherwise returns the bundled version.
|
||||
*/
|
||||
export function getEffectiveVersion(): string {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Build list of paths to check for update metadata
|
||||
const metadataPaths: string[] = [];
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Production: check userData override path
|
||||
metadataPaths.push(
|
||||
path.join(app.getPath('userData'), 'auto-claude-source', '.update-metadata.json')
|
||||
);
|
||||
} else {
|
||||
// Development: check the actual source paths where updates are written
|
||||
const possibleSourcePaths = [
|
||||
path.join(app.getAppPath(), '..', 'auto-claude'),
|
||||
path.join(app.getAppPath(), '..', '..', 'auto-claude'),
|
||||
path.join(process.cwd(), 'auto-claude'),
|
||||
path.join(process.cwd(), '..', 'auto-claude')
|
||||
];
|
||||
|
||||
for (const sourcePath of possibleSourcePaths) {
|
||||
metadataPaths.push(path.join(sourcePath, '.update-metadata.json'));
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.log('[Version] Checking metadata paths:', metadataPaths);
|
||||
}
|
||||
|
||||
// Check each path for metadata
|
||||
for (const metadataPath of metadataPaths) {
|
||||
const exists = existsSync(metadataPath);
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Checking ${metadataPath}: ${exists ? 'EXISTS' : 'not found'}`);
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as UpdateMetadata;
|
||||
if (metadata.version) {
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Found metadata version: ${metadata.version}`);
|
||||
}
|
||||
return metadata.version;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Error reading metadata: ${e}`);
|
||||
}
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bundledVersion = app.getVersion();
|
||||
if (isDebug) {
|
||||
console.log(`[Version] No metadata found, using bundled version: ${bundledVersion}`);
|
||||
}
|
||||
return bundledVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version from GitHub release tag
|
||||
* Handles tags like "v1.2.0", "1.2.0", "v1.2.0-beta"
|
||||
|
||||
@@ -301,10 +301,16 @@ export function App() {
|
||||
if (!gitHubSetupProject) return;
|
||||
|
||||
try {
|
||||
// NOTE: settings.githubToken is a GitHub access token (from gh CLI),
|
||||
// NOT a Claude Code OAuth token. They are different things:
|
||||
// - GitHub token: for GitHub API access (repo operations)
|
||||
// - Claude token: for Claude AI access (run.py, roadmap, etc.)
|
||||
// The user needs to separately authenticate with Claude using 'claude setup-token'
|
||||
|
||||
// Update project env config with GitHub settings
|
||||
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
|
||||
githubEnabled: true,
|
||||
githubToken: settings.githubToken,
|
||||
githubToken: settings.githubToken, // GitHub token for repo access
|
||||
githubRepo: settings.githubRepo
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useState, useRef, useEffect, type DragEvent } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, File, FileCode, FileJson, FileText, FileImage, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { FileNode } from '../../shared/types';
|
||||
@@ -70,15 +70,19 @@ export function FileTreeItem({
|
||||
isLoading,
|
||||
onToggle,
|
||||
}: FileTreeItemProps) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: node.path,
|
||||
data: {
|
||||
type: 'file',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
isDirectory: node.isDirectory
|
||||
}
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragImageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Cleanup drag image on unmount to prevent memory leaks
|
||||
// This handles cases where component unmounts mid-drag or dragend doesn't fire
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -94,15 +98,62 @@ export function FileTreeItem({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
|
||||
// Set the drag data as JSON
|
||||
const dragData = {
|
||||
type: 'file-reference',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
isDirectory: node.isDirectory
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
||||
e.dataTransfer.setData('text/plain', `@${node.name}`);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Create a custom drag image using safe DOM manipulation (no innerHTML)
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.className = 'flex items-center gap-2 bg-card border border-primary rounded-md px-3 py-2 shadow-lg text-sm';
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.textContent = node.isDirectory ? '📁' : '📄';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = node.name;
|
||||
|
||||
dragImage.appendChild(iconSpan);
|
||||
dragImage.appendChild(nameSpan);
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-1000px';
|
||||
dragImage.style.left = '-1000px';
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
|
||||
// Store reference for cleanup in dragend
|
||||
dragImageRef.current = dragImage;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
|
||||
// Clean up drag image element
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-1 px-2 rounded cursor-grab select-none',
|
||||
'hover:bg-accent/50 transition-colors',
|
||||
isDragging && 'opacity-50 bg-accent'
|
||||
isDragging && 'opacity-50 bg-accent ring-2 ring-primary'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Github,
|
||||
GitBranch,
|
||||
Key,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { GitHubOAuthFlow } from './project-settings/GitHubOAuthFlow';
|
||||
import { ClaudeOAuthFlow } from './project-settings/ClaudeOAuthFlow';
|
||||
import type { Project, ProjectSettings } from '../../shared/types';
|
||||
|
||||
interface GitHubSetupModalProps {
|
||||
@@ -36,15 +38,16 @@ interface GitHubSetupModalProps {
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'auth' | 'repo' | 'branch' | 'complete';
|
||||
type SetupStep = 'github-auth' | 'claude-auth' | 'repo' | 'branch' | 'complete';
|
||||
|
||||
/**
|
||||
* GitHub Setup Modal - Required setup flow after Auto Claude initialization
|
||||
* Setup Modal - Required setup flow after Auto Claude initialization
|
||||
*
|
||||
* Flow:
|
||||
* 1. Authenticate with GitHub (via gh CLI OAuth)
|
||||
* 2. Detect/confirm repository
|
||||
* 3. Select base branch for tasks (with recommended default)
|
||||
* 1. Authenticate with GitHub (via gh CLI OAuth) - for repo operations
|
||||
* 2. Authenticate with Claude (via claude CLI OAuth) - for AI features
|
||||
* 3. Detect/confirm repository
|
||||
* 4. Select base branch for tasks (with recommended default)
|
||||
*/
|
||||
export function GitHubSetupModal({
|
||||
open,
|
||||
@@ -53,7 +56,7 @@ export function GitHubSetupModal({
|
||||
onComplete,
|
||||
onSkip
|
||||
}: GitHubSetupModalProps) {
|
||||
const [step, setStep] = useState<SetupStep>('auth');
|
||||
const [step, setStep] = useState<SetupStep>('github-auth');
|
||||
const [githubToken, setGithubToken] = useState<string | null>(null);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [detectedRepo, setDetectedRepo] = useState<string | null>(null);
|
||||
@@ -67,7 +70,7 @@ export function GitHubSetupModal({
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('auth');
|
||||
setStep('github-auth');
|
||||
setGithubToken(null);
|
||||
setGithubRepo(null);
|
||||
setDetectedRepo(null);
|
||||
@@ -140,9 +143,16 @@ export function GitHubSetupModal({
|
||||
return branchList[0] || null;
|
||||
};
|
||||
|
||||
// Handle OAuth success
|
||||
const handleAuthSuccess = async (token: string) => {
|
||||
// Handle GitHub OAuth success
|
||||
const handleGitHubAuthSuccess = async (token: string) => {
|
||||
setGithubToken(token);
|
||||
// Move to Claude auth step
|
||||
setStep('claude-auth');
|
||||
};
|
||||
|
||||
// Handle Claude OAuth success
|
||||
const handleClaudeAuthSuccess = async () => {
|
||||
// Claude token is already saved to active profile by the OAuth flow
|
||||
// Move to repo detection
|
||||
await detectRepository();
|
||||
};
|
||||
@@ -161,7 +171,7 @@ export function GitHubSetupModal({
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'auth':
|
||||
case 'github-auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -176,7 +186,29 @@ export function GitHubSetupModal({
|
||||
|
||||
<div className="py-4">
|
||||
<GitHubOAuthFlow
|
||||
onSuccess={handleAuthSuccess}
|
||||
onSuccess={handleGitHubAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'claude-auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Connect to Claude AI
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auto Claude uses Claude AI for intelligent features like Roadmap generation, Task automation, and Ideation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<ClaudeOAuthFlow
|
||||
onSuccess={handleClaudeAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
@@ -372,20 +404,27 @@ export function GitHubSetupModal({
|
||||
|
||||
// Progress indicator
|
||||
const renderProgress = () => {
|
||||
const steps: { key: SetupStep; label: string }[] = [
|
||||
{ key: 'auth', label: 'Connect' },
|
||||
{ key: 'branch', label: 'Configure' },
|
||||
const steps: { label: string }[] = [
|
||||
{ label: 'Authenticate' },
|
||||
{ label: 'Configure' },
|
||||
];
|
||||
|
||||
// Don't show progress on complete step
|
||||
if (step === 'complete') return null;
|
||||
|
||||
const currentIndex = step === 'auth' ? 0 : step === 'repo' ? 0 : 1;
|
||||
// Map steps to progress indices
|
||||
// Auth steps (github-auth, claude-auth, repo) = 0
|
||||
// Config steps (branch) = 1
|
||||
const currentIndex =
|
||||
step === 'github-auth' ? 0 :
|
||||
step === 'claude-auth' ? 0 :
|
||||
step === 'repo' ? 0 :
|
||||
1;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{steps.map((s, index) => (
|
||||
<div key={s.key} className="flex items-center">
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
index < currentIndex
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, type ClipboardEvent, type DragEvent } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core';
|
||||
import { Loader2, ChevronDown, ChevronUp, Image as ImageIcon, X, RotateCcw, File, Folder, FolderTree, FileDown } from 'lucide-react';
|
||||
import { Loader2, ChevronDown, ChevronUp, Image as ImageIcon, X, RotateCcw, FolderTree, GitBranch } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -84,6 +74,15 @@ export function TaskCreationWizard({
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showImages, setShowImages] = useState(false);
|
||||
const [showFileExplorer, setShowFileExplorer] = useState(false);
|
||||
const [showGitOptions, setShowGitOptions] = useState(false);
|
||||
|
||||
// Git options state
|
||||
// Use a special value to represent "use project default" since Radix UI Select doesn't allow empty string values
|
||||
const PROJECT_DEFAULT_BRANCH = '__project_default__';
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
|
||||
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
|
||||
|
||||
// Get project path from project store
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
@@ -123,43 +122,12 @@ export function TaskCreationWizard({
|
||||
const [isDraftRestored, setIsDraftRestored] = useState(false);
|
||||
const [pasteSuccess, setPasteSuccess] = useState(false);
|
||||
|
||||
// Drag-and-drop state for file references
|
||||
const [activeDragData, setActiveDragData] = useState<{
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Ref for the textarea to handle paste events
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Drag-and-drop state for images over textarea
|
||||
const [isDragOverTextarea, setIsDragOverTextarea] = useState(false);
|
||||
|
||||
// Setup drag sensors with distance constraint to prevent accidental drags
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 8px movement required before drag starts
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Setup drop zone for file references (entire form)
|
||||
const { setNodeRef: setDropRef, isOver: isOverDropZone } = useDroppable({
|
||||
id: 'file-drop-zone',
|
||||
data: { type: 'file-drop-zone' }
|
||||
});
|
||||
|
||||
// Setup drop zone for description textarea (inline @mentions)
|
||||
const { setNodeRef: setTextareaDropRef, isOver: isOverTextarea } = useDroppable({
|
||||
id: 'description-drop-zone',
|
||||
data: { type: 'description-drop-zone' }
|
||||
});
|
||||
|
||||
// Determine if drop zone is at capacity
|
||||
const isAtMaxFiles = referencedFiles.length >= MAX_REFERENCED_FILES;
|
||||
|
||||
// Load draft when dialog opens, or initialize from selected profile
|
||||
useEffect(() => {
|
||||
if (open && projectId) {
|
||||
@@ -201,6 +169,51 @@ export function TaskCreationWizard({
|
||||
}
|
||||
}, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
|
||||
// Fetch branches and project default branch when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && projectPath) {
|
||||
fetchBranches();
|
||||
fetchProjectDefaultBranch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, projectPath]);
|
||||
|
||||
const fetchBranches = async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch branches:', err);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjectDefaultBranch = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
// Get env config to check if there's a configured default branch
|
||||
const result = await window.electronAPI.getProjectEnv(projectId);
|
||||
if (result.success && result.data?.defaultBranch) {
|
||||
setProjectDefaultBranch(result.data.defaultBranch);
|
||||
} else if (projectPath) {
|
||||
// Fall back to auto-detect
|
||||
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
if (detectResult.success && detectResult.data) {
|
||||
setProjectDefaultBranch(detectResult.data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project default branch:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current form state as a draft
|
||||
*/
|
||||
@@ -321,7 +334,7 @@ export function TaskCreationWizard({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drop on textarea for image files
|
||||
* Handle drop on textarea for file references and images
|
||||
*/
|
||||
const handleTextareaDrop = useCallback(
|
||||
async (e: DragEvent<HTMLTextAreaElement>) => {
|
||||
@@ -331,6 +344,40 @@ export function TaskCreationWizard({
|
||||
|
||||
if (isCreating) return;
|
||||
|
||||
// First, check for file reference drops (from the file explorer)
|
||||
const jsonData = e.dataTransfer?.getData('application/json');
|
||||
if (jsonData) {
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
if (data.type === 'file-reference' && data.name) {
|
||||
// Insert @mention at cursor position in the textarea
|
||||
const textarea = descriptionRef.current;
|
||||
if (textarea) {
|
||||
const cursorPos = textarea.selectionStart || 0;
|
||||
const textBefore = description.substring(0, cursorPos);
|
||||
const textAfter = description.substring(cursorPos);
|
||||
|
||||
// Insert @mention at cursor position
|
||||
const mention = `@${data.name}`;
|
||||
const newDescription = textBefore + mention + textAfter;
|
||||
setDescription(newDescription);
|
||||
|
||||
// Set cursor after the inserted mention
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = cursorPos + mention.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
|
||||
return; // Don't process as image
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, continue to image handling
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to image file handling
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
@@ -398,102 +445,9 @@ export function TaskCreationWizard({
|
||||
setTimeout(() => setPasteSuccess(false), 2000);
|
||||
}
|
||||
},
|
||||
[images, isCreating]
|
||||
[images, isCreating, description]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle drag start - capture file data for overlay
|
||||
*/
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as {
|
||||
type: string;
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
} | undefined;
|
||||
|
||||
if (data?.type === 'file') {
|
||||
setActiveDragData({
|
||||
path: data.path,
|
||||
name: data.name,
|
||||
isDirectory: data.isDirectory
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drag end - insert @mention in description or add to referencedFiles
|
||||
*/
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Clear drag state
|
||||
setActiveDragData(null);
|
||||
|
||||
// If not dropped on a valid target, do nothing
|
||||
if (!over) return;
|
||||
|
||||
const data = active.data.current as {
|
||||
type?: string;
|
||||
path?: string;
|
||||
name?: string;
|
||||
isDirectory?: boolean;
|
||||
} | undefined;
|
||||
|
||||
// Only process file drops
|
||||
if (data?.type !== 'file' || !data.path || !data.name) return;
|
||||
|
||||
// Handle drop on description textarea - insert inline @mention
|
||||
if (over.id === 'description-drop-zone') {
|
||||
const textarea = descriptionRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const cursorPos = textarea.selectionStart || 0;
|
||||
const textBefore = description.substring(0, cursorPos);
|
||||
const textAfter = description.substring(cursorPos);
|
||||
|
||||
// Insert @mention at cursor position
|
||||
const mention = `@${data.name}`;
|
||||
const newDescription = textBefore + mention + textAfter;
|
||||
setDescription(newDescription);
|
||||
|
||||
// Set cursor after the inserted mention
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = cursorPos + mention.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle drop on file-drop-zone - add to referenced files list
|
||||
if (over.id === 'file-drop-zone') {
|
||||
// Check if we're at the max limit
|
||||
if (referencedFiles.length >= MAX_REFERENCED_FILES) {
|
||||
setError(`Maximum of ${MAX_REFERENCED_FILES} referenced files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (referencedFiles.some(f => f.path === data.path)) {
|
||||
// Silently skip duplicates
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the file to referenced files
|
||||
const newFile: ReferencedFile = {
|
||||
id: crypto.randomUUID(),
|
||||
path: data.path,
|
||||
name: data.name,
|
||||
isDirectory: data.isDirectory ?? false,
|
||||
addedAt: new Date()
|
||||
};
|
||||
|
||||
setReferencedFiles(prev => [...prev, newFile]);
|
||||
}
|
||||
}, [referencedFiles, description]);
|
||||
|
||||
/**
|
||||
* Parse @mentions from description and create ReferencedFile entries
|
||||
* Merges with existing referencedFiles, avoiding duplicates
|
||||
@@ -560,6 +514,8 @@ export function TaskCreationWizard({
|
||||
if (images.length > 0) metadata.attachedImages = images;
|
||||
if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles;
|
||||
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
|
||||
// Only include baseBranch if it's not the project default placeholder
|
||||
if (baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH) metadata.baseBranch = baseBranch;
|
||||
|
||||
// Title is optional - if empty, it will be auto-generated by the backend
|
||||
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
|
||||
@@ -595,10 +551,12 @@ export function TaskCreationWizard({
|
||||
setImages([]);
|
||||
setReferencedFiles([]);
|
||||
setRequireReviewBeforeCoding(false);
|
||||
setBaseBranch(PROJECT_DEFAULT_BRANCH);
|
||||
setError(null);
|
||||
setShowAdvanced(false);
|
||||
setShowImages(false);
|
||||
setShowFileExplorer(false);
|
||||
setShowGitOptions(false);
|
||||
setIsDraftRestored(false);
|
||||
setPasteSuccess(false);
|
||||
};
|
||||
@@ -633,53 +591,17 @@ export function TaskCreationWizard({
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90vh] p-0 overflow-hidden transition-all duration-300 ease-out",
|
||||
showFileExplorer ? "sm:max-w-[900px]" : "sm:max-w-[550px]"
|
||||
)}
|
||||
hideCloseButton={showFileExplorer}
|
||||
>
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
{/* Form content - Drop zone wrapper */}
|
||||
<div
|
||||
ref={setDropRef}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col p-6 min-w-0 min-h-0 overflow-y-auto relative transition-all duration-150 ease-out",
|
||||
// Default state - no border
|
||||
!activeDragData && "",
|
||||
// Subtle visual feedback when dragging - border on the entire form
|
||||
activeDragData && !isOverDropZone && "border-2 border-dashed border-muted-foreground/40 rounded-lg",
|
||||
// Clear drop target feedback when over the form
|
||||
activeDragData && isOverDropZone && !isAtMaxFiles && "border-2 border-solid border-info rounded-lg bg-info/5",
|
||||
// Warning state when at capacity
|
||||
activeDragData && isOverDropZone && isAtMaxFiles && "border-2 border-solid border-warning rounded-lg bg-warning/5"
|
||||
)}
|
||||
>
|
||||
{/* Drop zone indicator overlay - shows when dragging over form */}
|
||||
{activeDragData && isOverDropZone && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-lg">
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium shadow-lg",
|
||||
isAtMaxFiles
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-info text-info-foreground"
|
||||
)}>
|
||||
<FileDown className="h-4 w-4" />
|
||||
<span>
|
||||
{isAtMaxFiles
|
||||
? `Maximum ${MAX_REFERENCED_FILES} files reached`
|
||||
: 'Drop file to add reference'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90vh] p-0 overflow-hidden transition-all duration-300 ease-out",
|
||||
showFileExplorer ? "sm:max-w-[900px]" : "sm:max-w-[550px]"
|
||||
)}
|
||||
hideCloseButton={showFileExplorer}
|
||||
>
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
{/* Form content */}
|
||||
<div className="flex-1 flex flex-col p-6 min-w-0 min-h-0 overflow-y-auto relative">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-foreground">Create New Task</DialogTitle>
|
||||
@@ -712,8 +634,8 @@ export function TaskCreationWizard({
|
||||
<Label htmlFor="description" className="text-sm font-medium text-foreground">
|
||||
Description <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{/* Wrap textarea in drop zone for file @mentions */}
|
||||
<div ref={setTextareaDropRef} className="relative">
|
||||
{/* Wrap textarea for file @mentions */}
|
||||
<div className="relative">
|
||||
{/* Syntax highlight overlay for @mentions */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none overflow-hidden rounded-md border border-transparent"
|
||||
@@ -756,20 +678,11 @@ export function TaskCreationWizard({
|
||||
disabled={isCreating}
|
||||
className={cn(
|
||||
"resize-y min-h-[120px] max-h-[400px] relative bg-transparent",
|
||||
// Image drop feedback (native drops)
|
||||
isDragOverTextarea && !isCreating && "border-primary bg-primary/5 ring-2 ring-primary/20",
|
||||
// File reference drop feedback (dnd-kit drops for @mentions)
|
||||
activeDragData && isOverTextarea && "border-info bg-info/5 ring-2 ring-info/20"
|
||||
// Visual feedback when dragging over textarea
|
||||
isDragOverTextarea && !isCreating && "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
)}
|
||||
style={{ caretColor: 'auto' }}
|
||||
/>
|
||||
{/* Drop indicator for file references */}
|
||||
{activeDragData && isOverTextarea && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 rounded-md bg-info text-info-foreground text-xs font-medium shadow-sm pointer-events-none z-10">
|
||||
<File className="h-3 w-3" />
|
||||
<span>Insert @{activeDragData.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tip: Drag files from the explorer to insert @references, or paste screenshots with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'}.
|
||||
@@ -1036,6 +949,65 @@ export function TaskCreationWizard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git Options Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGitOptions(!showGitOptions)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Git Options (optional)
|
||||
{baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{baseBranch}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showGitOptions ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Git Options */}
|
||||
{showGitOptions && (
|
||||
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base-branch" className="text-sm font-medium text-foreground">
|
||||
Base Branch (optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={baseBranch}
|
||||
onValueChange={setBaseBranch}
|
||||
disabled={isCreating || isLoadingBranches}
|
||||
>
|
||||
<SelectTrigger id="base-branch" className="h-9">
|
||||
<SelectValue placeholder={`Use project default${projectDefaultBranch ? ` (${projectDefaultBranch})` : ''}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROJECT_DEFAULT_BRANCH}>
|
||||
Use project default{projectDefaultBranch ? ` (${projectDefaultBranch})` : ''}
|
||||
</SelectItem>
|
||||
{branches.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override the branch this task's worktree will be created from. Leave empty to use the project's configured default branch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
@@ -1078,33 +1050,18 @@ export function TaskCreationWizard({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
{/* File Explorer Drawer */}
|
||||
{projectPath && (
|
||||
<TaskFileExplorerDrawer
|
||||
isOpen={showFileExplorer}
|
||||
onClose={() => setShowFileExplorer(false)}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Drag overlay - shows what's being dragged */}
|
||||
<DragOverlay>
|
||||
{activeDragData && (
|
||||
<div className="flex items-center gap-2 bg-card border border-border rounded-md px-3 py-2 shadow-lg">
|
||||
{activeDragData.isDirectory ? (
|
||||
<Folder className="h-4 w-4 text-warning" />
|
||||
) : (
|
||||
<File className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">{activeDragData.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{/* File Explorer Drawer */}
|
||||
{projectPath && (
|
||||
<TaskFileExplorerDrawer
|
||||
isOpen={showFileExplorer}
|
||||
onClose={() => setShowFileExplorer(false)}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
|
||||
interface ClaudeOAuthFlowProps {
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude OAuth flow component for setup wizard
|
||||
* Guides users through authenticating with Claude using claude setup-token
|
||||
*/
|
||||
export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
|
||||
const [status, setStatus] = useState<'ready' | 'authenticating' | 'success' | 'error'>('ready');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
// Track if we've already started auth to prevent double-execution
|
||||
const hasStartedRef = useRef(false);
|
||||
// Track the auto-advance timeout so we can cancel it on unmount/re-render
|
||||
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Listen for OAuth token detection
|
||||
useEffect(() => {
|
||||
// Clear any pending timeout from previous effect run
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
successTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onTerminalOAuthToken((info) => {
|
||||
console.warn('[ClaudeOAuth] Token event received:', {
|
||||
success: info.success,
|
||||
hasEmail: !!info.email,
|
||||
profileId: info.profileId
|
||||
});
|
||||
|
||||
if (info.success) {
|
||||
setEmail(info.email);
|
||||
setStatus('success');
|
||||
// Auto-advance after a short delay to show success message
|
||||
// Store the timeout ID so cleanup can cancel it if needed
|
||||
successTimeoutRef.current = setTimeout(() => {
|
||||
successTimeoutRef.current = null; // Clear ref since timeout fired
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(info.message || 'Failed to save OAuth token');
|
||||
setStatus('error');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
// Clear timeout on cleanup to prevent calling onSuccess after unmount
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
successTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onSuccess]);
|
||||
|
||||
const handleStartAuth = async () => {
|
||||
if (hasStartedRef.current) {
|
||||
console.warn('[ClaudeOAuth] Auth already started, ignoring duplicate call');
|
||||
return;
|
||||
}
|
||||
hasStartedRef.current = true;
|
||||
|
||||
console.warn('[ClaudeOAuth] Starting Claude authentication');
|
||||
setStatus('authenticating');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the active profile ID
|
||||
const profilesResult = await window.electronAPI.getClaudeProfiles();
|
||||
|
||||
if (!profilesResult.success || !profilesResult.data) {
|
||||
throw new Error('Failed to get Claude profiles');
|
||||
}
|
||||
|
||||
const activeProfileId = profilesResult.data.activeProfileId;
|
||||
console.warn('[ClaudeOAuth] Initializing profile:', activeProfileId);
|
||||
|
||||
// Initialize the profile - this opens a terminal and runs 'claude setup-token'
|
||||
const result = await window.electronAPI.initializeClaudeProfile(activeProfileId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to start authentication');
|
||||
}
|
||||
|
||||
console.warn('[ClaudeOAuth] Authentication started, waiting for token...');
|
||||
// Status will be updated by the event listener when token is detected
|
||||
} catch (err) {
|
||||
console.error('[ClaudeOAuth] Authentication failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
setStatus('error');
|
||||
hasStartedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
hasStartedRef.current = false;
|
||||
setStatus('ready');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Ready to authenticate */}
|
||||
{status === 'ready' && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Key className="h-6 w-6 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authenticate with Claude
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Auto Claude requires Claude AI authentication for AI-powered features like
|
||||
Roadmap generation, Task automation, and Ideation.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will open a browser window to authenticate with your Claude account.
|
||||
Your credentials are stored securely and are valid for 1 year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={handleStartAuth} size="lg" className="gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Authenticate with Claude
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authenticating */}
|
||||
{status === 'authenticating' && (
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authenticating...
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A terminal window has opened. Please complete the authentication in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-background/50 p-3 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">What's happening:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>A terminal opened and ran <code className="px-1 bg-muted rounded">claude setup-token</code></li>
|
||||
<li>Your browser should open to authenticate with Claude</li>
|
||||
<li>Complete the OAuth flow in your browser</li>
|
||||
<li>The terminal will display your token (starts with sk-ant-oat01-...)</li>
|
||||
<li>Auto Claude will automatically detect and save it</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{status === 'success' && (
|
||||
<Card className="border border-success/30 bg-success/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="h-6 w-6 text-success shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-success">
|
||||
Successfully Authenticated!
|
||||
</h3>
|
||||
<p className="text-sm text-success/80 mt-1">
|
||||
{email ? `Connected as ${email}` : 'Your Claude credentials have been saved'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3 text-xs text-success/70">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>You can now use all Auto Claude AI features</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{status === 'error' && error && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-destructive/30 bg-destructive/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-destructive">
|
||||
Authentication Failed
|
||||
</h3>
|
||||
<p className="text-sm text-destructive/80 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button onClick={handleRetry} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel button for ready/authenticating states */}
|
||||
{(status === 'ready' || status === 'authenticating') && onCancel && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button onClick={onCancel} variant="ghost" size="sm">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,8 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
const [isCheckingSourceUpdate, setIsCheckingSourceUpdate] = useState(false);
|
||||
const [isDownloadingUpdate, setIsDownloadingUpdate] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<AutoBuildSourceUpdateProgress | null>(null);
|
||||
// Local version state that can be updated after successful update
|
||||
const [displayVersion, setDisplayVersion] = useState<string>(version);
|
||||
|
||||
// Electron app update state
|
||||
const [appUpdateInfo, setAppUpdateInfo] = useState<AppUpdateAvailableEvent | null>(null);
|
||||
@@ -84,6 +86,11 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
const [appDownloadProgress, setAppDownloadProgress] = useState<AppUpdateProgress | null>(null);
|
||||
const [isAppUpdateDownloaded, setIsAppUpdateDownloaded] = useState(false);
|
||||
|
||||
// Sync displayVersion with prop when it changes
|
||||
useEffect(() => {
|
||||
setDisplayVersion(version);
|
||||
}, [version]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (section === 'updates') {
|
||||
@@ -98,6 +105,10 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
setDownloadProgress(progress);
|
||||
if (progress.stage === 'complete') {
|
||||
setIsDownloadingUpdate(false);
|
||||
// Update the displayed version if a new version was provided
|
||||
if (progress.newVersion) {
|
||||
setDisplayVersion(progress.newVersion);
|
||||
}
|
||||
checkForSourceUpdates();
|
||||
} else if (progress.stage === 'error') {
|
||||
setIsDownloadingUpdate(false);
|
||||
@@ -164,14 +175,20 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
};
|
||||
|
||||
const checkForSourceUpdates = async () => {
|
||||
console.log('[AdvancedSettings] Checking for source updates...');
|
||||
setIsCheckingSourceUpdate(true);
|
||||
try {
|
||||
const result = await window.electronAPI.checkAutoBuildSourceUpdate();
|
||||
console.log('[AdvancedSettings] Check result:', result);
|
||||
if (result.success && result.data) {
|
||||
setSourceUpdateCheck(result.data);
|
||||
// Update displayed version from the check result (most accurate)
|
||||
if (result.data.currentVersion) {
|
||||
setDisplayVersion(result.data.currentVersion);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silent fail - user can retry via button
|
||||
console.error('[AdvancedSettings] Check error:', err);
|
||||
} finally {
|
||||
setIsCheckingSourceUpdate(false);
|
||||
}
|
||||
@@ -287,7 +304,7 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Version</p>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{version || 'Loading...'}
|
||||
{displayVersion || 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
{isCheckingSourceUpdate ? (
|
||||
|
||||
@@ -404,8 +404,9 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
|
||||
</div>
|
||||
{editingProfileId !== profile.id && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Authenticate button - show if not authenticated */}
|
||||
{!profile.oauthToken && (
|
||||
{/* Authenticate button - show only if NOT authenticated */}
|
||||
{/* A profile is authenticated if: has OAuth token OR (is default AND has configDir) */}
|
||||
{!(profile.oauthToken || (profile.isDefault && profile.configDir)) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -420,6 +421,22 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
|
||||
)}
|
||||
Authenticate
|
||||
</Button>
|
||||
) : (
|
||||
/* Re-authenticate button for already authenticated profiles */
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleAuthenticateProfile(profile.id)}
|
||||
disabled={authenticatingProfileId === profile.id}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Re-authenticate profile"
|
||||
>
|
||||
{authenticatingProfileId === profile.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{profile.id !== activeProfileId && (
|
||||
<Button
|
||||
|
||||
+223
-3
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown } from 'lucide-react';
|
||||
import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch } from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Switch } from '../../ui/switch';
|
||||
@@ -34,6 +34,7 @@ interface GitHubIntegrationProps {
|
||||
setShowGitHubToken: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
gitHubConnectionStatus: GitHubSyncStatus | null;
|
||||
isCheckingGitHub: boolean;
|
||||
projectPath?: string; // Project path for fetching git branches
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,8 @@ export function GitHubIntegration({
|
||||
showGitHubToken: _showGitHubToken,
|
||||
setShowGitHubToken: _setShowGitHubToken,
|
||||
gitHubConnectionStatus,
|
||||
isCheckingGitHub
|
||||
isCheckingGitHub,
|
||||
projectPath
|
||||
}: GitHubIntegrationProps) {
|
||||
const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual');
|
||||
const [oauthUsername, setOauthUsername] = useState<string | null>(null);
|
||||
@@ -54,8 +56,14 @@ export function GitHubIntegration({
|
||||
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
|
||||
const [reposError, setReposError] = useState<string | null>(null);
|
||||
|
||||
// Branch selection state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
|
||||
debugLog('Render - authMode:', authMode);
|
||||
debugLog('Render - envConfig:', envConfig ? { githubEnabled: envConfig.githubEnabled, hasToken: !!envConfig.githubToken } : null);
|
||||
debugLog('Render - projectPath:', projectPath);
|
||||
debugLog('Render - envConfig:', envConfig ? { githubEnabled: envConfig.githubEnabled, hasToken: !!envConfig.githubToken, defaultBranch: envConfig.defaultBranch } : null);
|
||||
|
||||
// Fetch repos when entering oauth-success mode
|
||||
useEffect(() => {
|
||||
@@ -64,6 +72,60 @@ export function GitHubIntegration({
|
||||
}
|
||||
}, [authMode]);
|
||||
|
||||
// Fetch branches when GitHub is enabled and project path is available
|
||||
useEffect(() => {
|
||||
debugLog(`useEffect[branches] - githubEnabled: ${envConfig?.githubEnabled}, projectPath: ${projectPath}`);
|
||||
if (envConfig?.githubEnabled && projectPath) {
|
||||
debugLog('useEffect[branches] - Triggering fetchBranches');
|
||||
fetchBranches();
|
||||
} else {
|
||||
debugLog('useEffect[branches] - Skipping fetchBranches (conditions not met)');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig?.githubEnabled, projectPath]);
|
||||
|
||||
const fetchBranches = async () => {
|
||||
if (!projectPath) {
|
||||
debugLog('fetchBranches: No projectPath, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('fetchBranches: Starting with projectPath:', projectPath);
|
||||
setIsLoadingBranches(true);
|
||||
setBranchesError(null);
|
||||
|
||||
try {
|
||||
debugLog('fetchBranches: Calling getGitBranches...');
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
debugLog('fetchBranches: getGitBranches result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error });
|
||||
|
||||
// result.data is the array directly (not { branches: [] })
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
debugLog('fetchBranches: Loaded branches:', result.data.length);
|
||||
|
||||
// Auto-detect default branch if not set
|
||||
if (!envConfig?.defaultBranch) {
|
||||
debugLog('fetchBranches: No defaultBranch set, auto-detecting...');
|
||||
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
debugLog('fetchBranches: detectMainBranch result:', detectResult);
|
||||
if (detectResult.success && detectResult.data) {
|
||||
debugLog('fetchBranches: Auto-detected default branch:', detectResult.data);
|
||||
updateEnvConfig({ defaultBranch: detectResult.data });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugLog('fetchBranches: Failed -', result.error || 'No data returned');
|
||||
setBranchesError(result.error || 'Failed to load branches');
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog('fetchBranches: Exception:', err);
|
||||
setBranchesError(err instanceof Error ? err.message : 'Failed to load branches');
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserRepos = async () => {
|
||||
debugLog('Fetching user repositories...');
|
||||
setIsLoadingRepos(true);
|
||||
@@ -248,6 +310,20 @@ export function GitHubIntegration({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Default Branch Selector */}
|
||||
{projectPath && (
|
||||
<BranchSelector
|
||||
branches={branches}
|
||||
selectedBranch={envConfig.defaultBranch || ''}
|
||||
isLoading={isLoadingBranches}
|
||||
error={branchesError}
|
||||
onSelect={(branch) => updateEnvConfig({ defaultBranch: branch })}
|
||||
onRefresh={fetchBranches}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<AutoSyncToggle
|
||||
enabled={envConfig.githubAutoSync || false}
|
||||
onToggle={(checked) => updateEnvConfig({ githubAutoSync: checked })}
|
||||
@@ -500,3 +576,147 @@ function AutoSyncToggle({ enabled, onToggle }: AutoSyncToggleProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BranchSelectorProps {
|
||||
branches: string[];
|
||||
selectedBranch: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onSelect: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function BranchSelector({
|
||||
branches,
|
||||
selectedBranch,
|
||||
isLoading,
|
||||
error,
|
||||
onSelect,
|
||||
onRefresh
|
||||
}: BranchSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredBranches = branches.filter(branch =>
|
||||
branch.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-info" />
|
||||
<Label className="text-sm font-medium text-foreground">Default Branch</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
Base branch for creating task worktrees
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative pl-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading branches...
|
||||
</span>
|
||||
) : selectedBranch ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
{selectedBranch}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Auto-detect (main/master)</span>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && !isLoading && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
|
||||
{/* Search filter */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder="Search branches..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-detect option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect('');
|
||||
setIsOpen(false);
|
||||
setFilter('');
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
|
||||
!selectedBranch ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground italic">Auto-detect (main/master)</span>
|
||||
</button>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="max-h-40 overflow-y-auto border-t border-border">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
{filter ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(branch);
|
||||
setIsOpen(false);
|
||||
setFilter('');
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
|
||||
branch === selectedBranch ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{branch}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedBranch && (
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
All new tasks will branch from <code className="px-1 bg-muted rounded">{selectedBranch}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export function SectionRouter({
|
||||
setShowGitHubToken={setShowGitHubToken}
|
||||
gitHubConnectionStatus={gitHubConnectionStatus}
|
||||
isCheckingGitHub={isCheckingGitHub}
|
||||
projectPath={project.path}
|
||||
/>
|
||||
</InitializationGuard>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -212,3 +212,4 @@ export function canCreateRelease(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -284,6 +284,9 @@ export interface ProjectEnvConfig {
|
||||
githubRepo?: string; // Format: owner/repo
|
||||
githubAutoSync?: boolean; // Auto-sync issues on project load
|
||||
|
||||
// Git/Worktree Settings
|
||||
defaultBranch?: string; // Base branch for worktree creation (e.g., 'main', 'develop')
|
||||
|
||||
// Graphiti Memory Integration (V2 - Multi-provider support)
|
||||
graphitiEnabled: boolean;
|
||||
graphitiProviderConfig?: GraphitiProviderConfig; // New V2 provider configuration
|
||||
|
||||
@@ -107,4 +107,6 @@ export interface AutoBuildSourceUpdateProgress {
|
||||
stage: 'checking' | 'downloading' | 'extracting' | 'complete' | 'error';
|
||||
percent?: number;
|
||||
message: string;
|
||||
/** New version after successful update - used to refresh UI */
|
||||
newVersion?: string;
|
||||
}
|
||||
|
||||
@@ -220,6 +220,9 @@ export interface TaskMetadata {
|
||||
phaseModels?: PhaseModelConfig; // Per-phase model configuration
|
||||
phaseThinking?: PhaseThinkingConfig; // Per-phase thinking configuration
|
||||
|
||||
// Git/Worktree configuration
|
||||
baseBranch?: string; // Override base branch for this task's worktree
|
||||
|
||||
// Archive status
|
||||
archivedAt?: string; // ISO date when task was archived
|
||||
archivedInVersion?: string; // Version in which task was archived (from changelog)
|
||||
@@ -432,4 +435,5 @@ export interface TaskStartOptions {
|
||||
parallel?: boolean;
|
||||
workers?: number;
|
||||
model?: string;
|
||||
baseBranch?: string; // Override base branch for worktree creation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Shell Escape Utilities
|
||||
*
|
||||
* Provides safe escaping for shell command arguments to prevent command injection.
|
||||
* IMPORTANT: Always use these utilities when interpolating user-controlled values into shell commands.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a string for safe use as a shell argument.
|
||||
*
|
||||
* Uses single quotes which prevent all shell expansion (variables, command substitution, etc.)
|
||||
* except for single quotes themselves, which are escaped as '\''
|
||||
*
|
||||
* Examples:
|
||||
* - "hello" → 'hello'
|
||||
* - "hello world" → 'hello world'
|
||||
* - "it's" → 'it'\''s'
|
||||
* - "$(rm -rf /)" → '$(rm -rf /)'
|
||||
* - 'test"; rm -rf / #' → 'test"; rm -rf / #'
|
||||
*
|
||||
* @param arg - The argument to escape
|
||||
* @returns The escaped argument wrapped in single quotes
|
||||
*/
|
||||
export function escapeShellArg(arg: string): string {
|
||||
// Replace single quotes with: end quote, escaped quote, start quote
|
||||
// This is the standard POSIX-safe way to handle single quotes
|
||||
const escaped = arg.replace(/'/g, "'\\''");
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a path for use in a cd command.
|
||||
*
|
||||
* @param path - The path to escape
|
||||
* @returns The escaped path safe for use in shell commands
|
||||
*/
|
||||
export function escapeShellPath(path: string): string {
|
||||
return escapeShellArg(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a safe cd command from a path.
|
||||
*
|
||||
* @param path - The directory path
|
||||
* @returns A safe "cd '<path>' && " string, or empty string if path is undefined
|
||||
*/
|
||||
export function buildCdCommand(path: string | undefined): string {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
return `cd ${escapeShellPath(path)} && `;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path doesn't contain obviously malicious patterns.
|
||||
* This is a defense-in-depth measure - escaping should handle all cases,
|
||||
* but this can catch obvious attack attempts early.
|
||||
*
|
||||
* @param path - The path to validate
|
||||
* @returns true if the path appears safe, false if it contains suspicious patterns
|
||||
*/
|
||||
export function isPathSafe(path: string): boolean {
|
||||
// Check for obvious shell metacharacters that shouldn't appear in paths
|
||||
// Note: This is defense-in-depth; escaping handles these, but we can log/reject
|
||||
const suspiciousPatterns = [
|
||||
/\$\(/, // Command substitution $(...)
|
||||
/`/, // Backtick command substitution
|
||||
/\|/, // Pipe
|
||||
/;/, // Command separator
|
||||
/&&/, // AND operator
|
||||
/\|\|/, // OR operator
|
||||
/>/, // Output redirection
|
||||
/</, // Input redirection
|
||||
/\n/, // Newlines
|
||||
/\r/, // Carriage returns
|
||||
];
|
||||
|
||||
return !suspiciousPatterns.some(pattern => pattern.test(path));
|
||||
}
|
||||
@@ -144,24 +144,31 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
"""Fallback debug function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
"""Fallback debug_detailed function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
"""Fallback debug_verbose function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
"""Fallback debug_success function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
"""Fallback debug_error function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
"""Fallback debug_section function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
"""Fallback is_debug_enabled function when debug module is not available."""
|
||||
return False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user