mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge branch: resolve conflict in worktree-actions-dropdown.tsx
This commit is contained in:
343
libs/platform/src/editor.ts
Normal file
343
libs/platform/src/editor.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Cross-platform editor detection and launching utilities
|
||||
*
|
||||
* Handles:
|
||||
* - Detecting available code editors on the system
|
||||
* - Cross-platform editor launching (handles Windows .cmd files)
|
||||
* - Caching of detected editors for performance
|
||||
*/
|
||||
|
||||
import { execFile, spawn, type ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { access } from 'fs/promises';
|
||||
import type { EditorInfo } from '@automaker/types';
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Platform detection
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
// Cache with TTL for editor detection
|
||||
let cachedEditors: EditorInfo[] | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Check if the editor cache is still valid
|
||||
*/
|
||||
function isCacheValid(): boolean {
|
||||
return cachedEditors !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the editor detection cache
|
||||
* Useful when editors may have been installed/uninstalled
|
||||
*/
|
||||
export function clearEditorCache(): void {
|
||||
cachedEditors = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CLI command exists in PATH
|
||||
* Uses platform-specific command lookup (where on Windows, which on Unix)
|
||||
*/
|
||||
export async function commandExists(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
const whichCmd = isWindows ? 'where' : 'which';
|
||||
await execFileAsync(whichCmd, [cmd]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a macOS app bundle exists and return the path if found
|
||||
* Checks both /Applications and ~/Applications
|
||||
*/
|
||||
async function findMacApp(appName: string): Promise<string | null> {
|
||||
if (!isMac) return null;
|
||||
|
||||
// Check /Applications first
|
||||
const systemAppPath = join('/Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(systemAppPath);
|
||||
return systemAppPath;
|
||||
} catch {
|
||||
// Not in /Applications
|
||||
}
|
||||
|
||||
// Check ~/Applications (used by JetBrains Toolbox and others)
|
||||
const userAppPath = join(homedir(), 'Applications', `${appName}.app`);
|
||||
try {
|
||||
await access(userAppPath);
|
||||
return userAppPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor definition with CLI command and macOS app bundle name
|
||||
*/
|
||||
interface EditorDefinition {
|
||||
name: string;
|
||||
cliCommand: string;
|
||||
cliAliases?: readonly string[];
|
||||
macAppName: string;
|
||||
/** If true, only available on macOS */
|
||||
macOnly?: boolean;
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_CLI_COMMANDS = ['antigravity', 'agy'] as const;
|
||||
const [PRIMARY_ANTIGRAVITY_COMMAND, ...LEGACY_ANTIGRAVITY_COMMANDS] = ANTIGRAVITY_CLI_COMMANDS;
|
||||
|
||||
/**
|
||||
* List of supported editors in priority order
|
||||
*/
|
||||
const SUPPORTED_EDITORS: EditorDefinition[] = [
|
||||
{ name: 'Cursor', cliCommand: 'cursor', macAppName: 'Cursor' },
|
||||
{ name: 'VS Code', cliCommand: 'code', macAppName: 'Visual Studio Code' },
|
||||
{
|
||||
name: 'VS Code Insiders',
|
||||
cliCommand: 'code-insiders',
|
||||
macAppName: 'Visual Studio Code - Insiders',
|
||||
},
|
||||
{ name: 'Kiro', cliCommand: 'kiro', macAppName: 'Kiro' },
|
||||
{ name: 'Zed', cliCommand: 'zed', macAppName: 'Zed' },
|
||||
{ name: 'Sublime Text', cliCommand: 'subl', macAppName: 'Sublime Text' },
|
||||
{ name: 'Windsurf', cliCommand: 'windsurf', macAppName: 'Windsurf' },
|
||||
{ name: 'Trae', cliCommand: 'trae', macAppName: 'Trae' },
|
||||
{ name: 'Rider', cliCommand: 'rider', macAppName: 'Rider' },
|
||||
{ name: 'WebStorm', cliCommand: 'webstorm', macAppName: 'WebStorm' },
|
||||
{ name: 'Xcode', cliCommand: 'xed', macAppName: 'Xcode', macOnly: true },
|
||||
{ name: 'Android Studio', cliCommand: 'studio', macAppName: 'Android Studio' },
|
||||
{
|
||||
name: 'Antigravity',
|
||||
cliCommand: PRIMARY_ANTIGRAVITY_COMMAND,
|
||||
cliAliases: LEGACY_ANTIGRAVITY_COMMANDS,
|
||||
macAppName: 'Antigravity',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if Xcode is fully installed (not just Command Line Tools)
|
||||
* xed command requires full Xcode.app, not just CLT
|
||||
*/
|
||||
async function isXcodeFullyInstalled(): Promise<boolean> {
|
||||
if (!isMac) return false;
|
||||
|
||||
try {
|
||||
// Check if xcode-select points to full Xcode, not just CommandLineTools
|
||||
const { stdout } = await execFileAsync('xcode-select', ['-p']);
|
||||
const devPath = stdout.trim();
|
||||
|
||||
// Full Xcode path: /Applications/Xcode.app/Contents/Developer
|
||||
// Command Line Tools: /Library/Developer/CommandLineTools
|
||||
const isPointingToXcode = devPath.includes('Xcode.app');
|
||||
|
||||
if (!isPointingToXcode && devPath.includes('CommandLineTools')) {
|
||||
// Check if xed command exists (indicates CLT are installed)
|
||||
const xedExists = await commandExists('xed');
|
||||
|
||||
// Check if Xcode.app actually exists
|
||||
const xcodeAppPath = await findMacApp('Xcode');
|
||||
|
||||
if (xedExists && xcodeAppPath) {
|
||||
console.warn(
|
||||
'Xcode is installed but xcode-select is pointing to Command Line Tools. ' +
|
||||
'To use Xcode as an editor, run: sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return isPointingToXcode;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an editor - checks CLI first, then macOS app bundle
|
||||
* Returns EditorInfo if found, null otherwise
|
||||
*/
|
||||
async function findEditor(definition: EditorDefinition): Promise<EditorInfo | null> {
|
||||
// Skip macOS-only editors on other platforms
|
||||
if (definition.macOnly && !isMac) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Special handling for Xcode: verify full installation, not just xed command
|
||||
if (definition.name === 'Xcode') {
|
||||
if (!(await isXcodeFullyInstalled())) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try CLI command first (works on all platforms)
|
||||
const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])];
|
||||
for (const cliCommand of cliCandidates) {
|
||||
if (await commandExists(cliCommand)) {
|
||||
return { name: definition.name, command: cliCommand };
|
||||
}
|
||||
}
|
||||
|
||||
// Try macOS app bundle (checks /Applications and ~/Applications)
|
||||
if (isMac) {
|
||||
const appPath = await findMacApp(definition.macAppName);
|
||||
if (appPath) {
|
||||
// Use 'open -a' with full path for apps not in /Applications
|
||||
return { name: definition.name, command: `open -a "${appPath}"` };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific file manager
|
||||
*/
|
||||
function getFileManagerInfo(): EditorInfo {
|
||||
if (isMac) {
|
||||
return { name: 'Finder', command: 'open' };
|
||||
} else if (isWindows) {
|
||||
return { name: 'Explorer', command: 'explorer' };
|
||||
} else {
|
||||
return { name: 'File Manager', command: 'xdg-open' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all available code editors on the system
|
||||
* Results are cached for 5 minutes for performance
|
||||
*/
|
||||
export async function detectAllEditors(): Promise<EditorInfo[]> {
|
||||
// Return cached result if still valid
|
||||
if (isCacheValid() && cachedEditors) {
|
||||
return cachedEditors;
|
||||
}
|
||||
|
||||
// Check all editors in parallel for better performance
|
||||
const editorChecks = SUPPORTED_EDITORS.map((def) => findEditor(def));
|
||||
const results = await Promise.all(editorChecks);
|
||||
|
||||
// Filter out null results (editors not found)
|
||||
const editors = results.filter((e): e is EditorInfo => e !== null);
|
||||
|
||||
// Always add file manager as fallback
|
||||
editors.push(getFileManagerInfo());
|
||||
|
||||
// Update cache
|
||||
cachedEditors = editors;
|
||||
cacheTimestamp = Date.now();
|
||||
|
||||
return editors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the default (first available) code editor on the system
|
||||
* Returns the highest priority editor that is installed
|
||||
*/
|
||||
export async function detectDefaultEditor(): Promise<EditorInfo> {
|
||||
const editors = await detectAllEditors();
|
||||
// Return first editor (highest priority) - always exists due to file manager fallback
|
||||
return editors[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific editor by command
|
||||
* Returns the editor info if available, null otherwise
|
||||
*/
|
||||
export async function findEditorByCommand(command: string): Promise<EditorInfo | null> {
|
||||
const editors = await detectAllEditors();
|
||||
return editors.find((e) => e.command === command) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path in the specified editor
|
||||
*
|
||||
* Handles cross-platform differences:
|
||||
* - On Windows, uses spawn with shell:true to handle .cmd batch scripts
|
||||
* - On macOS, handles 'open -a' style commands for app bundles
|
||||
* - On Linux, uses direct execution
|
||||
*
|
||||
* @param targetPath - The file or directory path to open
|
||||
* @param editorCommand - The editor command to use (optional, uses default if not specified)
|
||||
* @returns Promise that resolves with editor info when launched, rejects on error
|
||||
*/
|
||||
export async function openInEditor(
|
||||
targetPath: string,
|
||||
editorCommand?: string
|
||||
): Promise<{ editorName: string }> {
|
||||
// Determine which editor to use
|
||||
let editor: EditorInfo;
|
||||
|
||||
if (editorCommand) {
|
||||
const found = await findEditorByCommand(editorCommand);
|
||||
if (found) {
|
||||
editor = found;
|
||||
} else {
|
||||
// Fall back to default if specified editor not found
|
||||
editor = await detectDefaultEditor();
|
||||
}
|
||||
} else {
|
||||
editor = await detectDefaultEditor();
|
||||
}
|
||||
|
||||
// Execute the editor
|
||||
await executeEditorCommand(editor.command, targetPath);
|
||||
|
||||
return { editorName: editor.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an editor command with a path argument
|
||||
* Handles platform-specific differences in command execution
|
||||
*/
|
||||
async function executeEditorCommand(command: string, targetPath: string): Promise<void> {
|
||||
// Handle 'open -a "AppPath"' style commands (macOS app bundles)
|
||||
if (command.startsWith('open -a ')) {
|
||||
const appPath = command.replace('open -a ', '').replace(/"/g, '');
|
||||
await execFileAsync('open', ['-a', appPath, targetPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
// On Windows, editor CLI commands are typically .cmd batch scripts
|
||||
// spawn with shell:true is required to execute them properly
|
||||
if (isWindows) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child: ChildProcess = spawn(command, [targetPath], {
|
||||
shell: true,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// Unref to allow the parent process to exit independently
|
||||
child.unref();
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Resolve after a small delay to catch immediate spawn errors
|
||||
// Editors run in background, so we don't wait for them to exit
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Unix/macOS: use execFile for direct execution
|
||||
await execFileAsync(command, [targetPath]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path in the platform's default file manager
|
||||
* Always available as a fallback option
|
||||
*/
|
||||
export async function openInFileManager(targetPath: string): Promise<{ editorName: string }> {
|
||||
const fileManager = getFileManagerInfo();
|
||||
await execFileAsync(fileManager.command, [targetPath]);
|
||||
return { editorName: fileManager.name };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
getValidationPath,
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
getExecutionStatePath,
|
||||
ensureAutomakerDir,
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
@@ -158,3 +159,14 @@ export {
|
||||
|
||||
// Port configuration
|
||||
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js';
|
||||
|
||||
// Editor detection and launching (cross-platform)
|
||||
export {
|
||||
commandExists,
|
||||
clearEditorCache,
|
||||
detectAllEditors,
|
||||
detectDefaultEditor,
|
||||
findEditorByCommand,
|
||||
openInEditor,
|
||||
openInFileManager,
|
||||
} from './editor.js';
|
||||
|
||||
@@ -173,6 +173,19 @@ export function getBranchTrackingPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), 'active-branches.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the execution state file path for a project
|
||||
*
|
||||
* Stores JSON metadata about auto-mode execution state for recovery on restart.
|
||||
* Tracks which features were running and auto-loop configuration.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Absolute path to {projectPath}/.automaker/execution-state.json
|
||||
*/
|
||||
export function getExecutionStatePath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), 'execution-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the automaker directory structure for a project if it doesn't exist
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user