/** * Cross-platform electron dev script * Replaces platform-specific npm scripts with a unified TypeScript solution */ import { spawn, type Subprocess } from 'bun'; import { existsSync, rmSync, cpSync, readFileSync, statSync, mkdirSync, } from 'fs'; import { join } from 'path'; import * as esbuild from 'esbuild'; import { downloadUv, type Platform, type Arch } from './build/common'; import { detectDevInstance, resolveDevPort } from './dev-instance'; const ROOT_DIR = join(import.meta.dir, '..'); const ELECTRON_DIR = join(ROOT_DIR, 'apps/electron'); const DIST_DIR = join(ELECTRON_DIR, 'dist'); // Replace grammY's bundled polyfills (node-fetch@2 + abort-controller@3) with // native Node globals. esbuild otherwise renames the polyfill's `class // AbortSignal` to `_AbortSignal` to dodge collision with the global, which // breaks node-fetch@2's `constructor.name === 'AbortSignal'` check and fails // every Telegram API call with a TypeError. Kept in sync with // `apps/electron/package.json` build:main and `scripts/electron-build-main.ts`. const MAIN_PROCESS_ALIAS: Record = { 'node-fetch': join(ROOT_DIR, 'apps/electron/src/main/shims/node-fetch.cjs'), 'abort-controller': join( ROOT_DIR, 'apps/electron/src/main/shims/abort-controller.cjs', ), }; // MCP server paths const SESSION_SERVER_DIR = join(ROOT_DIR, 'packages/session-mcp-server'); // Platform-specific binary paths (bun creates .exe on Windows, no extension on Unix) const IS_WINDOWS = process.platform === 'win32'; const BIN_EXT = IS_WINDOWS ? '.exe' : ''; const VITE_BIN = join(ROOT_DIR, `node_modules/.bin/vite${BIN_EXT}`); const ELECTRON_BIN = join(ROOT_DIR, `node_modules/.bin/electron${BIN_EXT}`); const ELECTRON_CLI_ARGS = process.argv.slice(2).filter((arg) => arg !== '--'); const QWEN_VENDOR_DIR = join(ELECTRON_DIR, 'vendor', 'qwen-code'); const QWEN_VENDOR_CLI_CANDIDATES = [ join(QWEN_VENDOR_DIR, 'dist', 'cli.js'), join(QWEN_VENDOR_DIR, 'cli.js'), join(QWEN_VENDOR_DIR, 'packages', 'cli', 'dist', 'index.js'), ]; interface DesktopPackageJson { qwenCodeRuntime?: { version?: string; }; } function firstExistingPath(candidates: string[]): string | undefined { return candidates.find((candidate) => existsSync(candidate)); } function readDefaultQwenCodeVersion(): string | undefined { try { const pkg = JSON.parse( readFileSync(join(ROOT_DIR, 'package.json'), 'utf-8'), ) as DesktopPackageJson; const version = pkg.qwenCodeRuntime?.version?.trim(); return version || undefined; } catch { return undefined; } } function isQwenSourceRoot(root: string): boolean { return ( existsSync(join(root, 'packages', 'cli', 'package.json')) && existsSync(join(root, 'package.json')) ); } async function ensureQwenRuntimeForDev(): Promise { if ( process.env.QWEN_CODE_CLI || process.env.QWEN_CODE_ROOT || process.env.QWEN_CODE_PATH ) { console.log('๐Ÿงญ Using Qwen Code runtime override from environment'); return; } const requestedExternalArtifact = Boolean( process.env.QWEN_CODE_TARBALL || process.env.QWEN_CODE_VERSION, ); const monorepoRoot = join(ROOT_DIR, '..', '..'); if (!requestedExternalArtifact && isQwenSourceRoot(monorepoRoot)) { console.log('๐Ÿงญ Using Qwen Code CLI from the local monorepo checkout'); return; } const vendoredCli = firstExistingPath(QWEN_VENDOR_CLI_CANDIDATES); if (vendoredCli && !requestedExternalArtifact) { process.env.QWEN_CODE_CLI = vendoredCli; console.log(`๐Ÿงญ Using vendored Qwen Code CLI: ${vendoredCli}`); return; } const env = { ...(process.env as Record) }; if (!env.QWEN_CODE_TARBALL && !env.QWEN_CODE_VERSION) { const defaultVersion = readDefaultQwenCodeVersion(); if (!defaultVersion) { throw new Error( 'No Qwen Code CLI runtime configured. Set QWEN_CODE_VERSION, QWEN_CODE_TARBALL, QWEN_CODE_ROOT, or qwenCodeRuntime.version in package.json.', ); } env.QWEN_CODE_VERSION = defaultVersion; } const sourceLabel = env.QWEN_CODE_TARBALL ? `tarball ${env.QWEN_CODE_TARBALL}` : `@qwen-code/qwen-code@${env.QWEN_CODE_VERSION}`; console.log(`๐Ÿ“ฆ Vendoring Qwen Code CLI from ${sourceLabel}...`); const proc = spawn({ cmd: ['bun', 'run', 'scripts/vendor-qwen-code.ts'], cwd: ROOT_DIR, stdout: 'inherit', stderr: 'inherit', env, }); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(`Failed to vendor Qwen Code CLI from ${sourceLabel}`); } const resolvedCli = firstExistingPath(QWEN_VENDOR_CLI_CANDIDATES); if (!resolvedCli) { throw new Error(`Vendored Qwen Code CLI not found in ${QWEN_VENDOR_DIR}`); } process.env.QWEN_CODE_CLI = resolvedCli; console.log(`๐Ÿงญ Using vendored Qwen Code CLI: ${resolvedCli}`); } function resolveBuildPlatform(): Platform { if (process.platform === 'darwin') return 'darwin'; if (process.platform === 'win32') return 'win32'; if (process.platform === 'linux') return 'linux'; throw new Error(`Unsupported platform for uv bootstrap: ${process.platform}`); } function resolveBuildArch(): Arch { if (process.arch === 'arm64') return 'arm64'; if (process.arch === 'x64') return 'x64'; throw new Error(`Unsupported architecture for uv bootstrap: ${process.arch}`); } async function ensureBundledUvForCurrentPlatform(): Promise { const platform = resolveBuildPlatform(); const arch = resolveBuildArch(); const platformKey = `${platform}-${arch}`; const uvBinary = platform === 'win32' ? 'uv.exe' : 'uv'; const uvPath = join(ELECTRON_DIR, 'resources', 'bin', platformKey, uvBinary); if (existsSync(uvPath)) { console.log(`โœ… Bundled uv present: ${uvPath}`); return; } console.log(`โฌ‡๏ธ Bundled uv missing, bootstrapping ${platformKey}...`); await downloadUv({ platform, arch, upload: false, uploadLatest: false, uploadScript: false, rootDir: ROOT_DIR, electronDir: ELECTRON_DIR, }); } // Multi-instance detection. // 1. Explicit numbered folders still map craft-agents-1 โ†’ ~/.craft-agent-1. // 2. Linked git worktrees get stable path-derived instances automatically. function detectInstance(): void { const instance = detectDevInstance(ROOT_DIR); if (!instance) return; process.env.CRAFT_INSTANCE_NUMBER ||= instance.instanceNumber; process.env.CRAFT_VITE_PORT ||= String(instance.resolvePort(5173)); process.env.CRAFT_APP_NAME ||= instance.appName; if (instance.configDir) { process.env.CRAFT_CONFIG_DIR ||= instance.configDir; } process.env.CRAFT_USER_DATA_DIR ||= instance.userDataDir; process.env.CRAFT_SERVER_LOCK_FILE ||= instance.serverLockFile; process.env.CRAFT_DEEPLINK_SCHEME ||= instance.deeplinkScheme; const configDir = process.env.CRAFT_CONFIG_DIR || '~/.craft-agent'; console.log( `๐Ÿ”ข ${instance.source} detected (${instance.label}): ` + `port=${process.env.CRAFT_VITE_PORT}, app="${process.env.CRAFT_APP_NAME}", ` + `config=${configDir}, userData=${process.env.CRAFT_USER_DATA_DIR}`, ); } // Load .env file if it exists function loadEnvFile(): void { const envPath = join(ROOT_DIR, '.env'); if (existsSync(envPath)) { const content = readFileSync(envPath, 'utf-8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const eqIndex = trimmed.indexOf('='); if (eqIndex > 0) { const key = trimmed.slice(0, eqIndex).trim(); let value = trimmed.slice(eqIndex + 1).trim(); // Remove surrounding quotes if present if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } process.env[key] = value; } } } console.log('๐Ÿ“„ Loaded .env file'); } } // Kill any process listening on the specified port. // `lsof -ti:` also returns clients connected to that port; in dev that // can include Electron renderer helpers connected to Vite's websocket. async function killProcessOnPort(port: string): Promise { const isWindows = process.platform === 'win32'; try { if (isWindows) { // Windows: use netstat to find listening PIDs, then taskkill const netstat = spawn({ cmd: ['cmd', '/c', `netstat -ano -p tcp | findstr :${port}`], stdout: 'pipe', stderr: 'pipe', }); const output = await new Response(netstat.stdout).text(); await netstat.exited; // Parse PIDs from netstat output (last column) const pids = new Set(); for (const line of output.split('\n')) { const parts = line.trim().split(/\s+/); if (parts.length >= 5) { const localAddress = parts[1]; const state = parts[3]?.toUpperCase(); const pid = parts[parts.length - 1]; const isListeningOnPort = localAddress?.endsWith(`:${port}`) && state === 'LISTENING'; if (isListeningOnPort && pid && /^\d+$/.test(pid) && pid !== '0') { pids.add(pid); } } } // Kill each PID for (const pid of pids) { const kill = spawn({ cmd: ['taskkill', '/PID', pid, '/F'], stdout: 'pipe', stderr: 'pipe', }); await kill.exited; } if (pids.size > 0) { console.log(`๐Ÿ”ช Killed ${pids.size} process(es) on port ${port}`); } } else { // Mac/Linux: use lsof and kill only listeners, not Vite websocket clients. const killListeningPidScript = [ 'pids=$(lsof -tiTCP:$1 -sTCP:LISTEN 2>/dev/null || true)', 'if [ -n "$pids" ]; then', 'kill -9 $pids 2>/dev/null || true', 'printf \'%s\\n\' "$pids"', 'fi', ].join('; '); const lsof = spawn({ cmd: ['sh', '-c', killListeningPidScript, 'sh', port], stdout: 'pipe', stderr: 'pipe', }); const output = await new Response(lsof.stdout).text(); await lsof.exited; if (output.trim()) { console.log(`๐Ÿ”ช Killed process(es) on port ${port}`); } } } catch { // Ignore errors - port may not be in use } } // Clean Vite cache directory function cleanViteCache(): void { const viteCacheDir = join(ELECTRON_DIR, 'node_modules/.vite'); if (existsSync(viteCacheDir)) { rmSync(viteCacheDir, { recursive: true, force: true }); console.log('๐Ÿงน Cleaned Vite cache'); } } // Copy resources to dist function copyResources(): void { const srcDir = join(ELECTRON_DIR, 'resources'); const destDir = join(ELECTRON_DIR, 'dist/resources'); if (existsSync(srcDir)) { cpSync(srcDir, destDir, { recursive: true, force: true }); console.log('๐Ÿ“ฆ Copied resources to dist'); } } // Build the WhatsApp worker bundle (dist/worker.cjs). Runs the canonical // `scripts/build-wa-worker.ts` as a subprocess so the dev path stays in // sync with the packaged/CI build. Cheap (~70ms) so we always rebuild. async function buildWaWorker(): Promise { console.log('๐Ÿ“จ Building WhatsApp worker...'); const proc = spawn({ cmd: ['bun', 'run', 'scripts/build-wa-worker.ts'], cwd: ROOT_DIR, stdout: 'inherit', stderr: 'inherit', }); const exitCode = await proc.exited; if (exitCode !== 0) { console.error('โŒ WhatsApp worker build failed'); process.exit(1); } } // Build MCP servers for agent sessions (one-time, no watch needed) async function buildMcpServers(): Promise { console.log('๐ŸŒ‰ Building MCP servers...'); // Ensure dist directories exist const sessionDistDir = join(SESSION_SERVER_DIR, 'dist'); if (!existsSync(sessionDistDir)) mkdirSync(sessionDistDir, { recursive: true }); // Build session MCP server (esbuild, packages external โ€” deps resolve from root node_modules) const sessionResult = await runEsbuild( 'packages/session-mcp-server/src/index.ts', 'packages/session-mcp-server/dist/index.js', {}, { packagesExternal: true }, ); if (!sessionResult.success) { console.error('โŒ Session MCP server build failed:', sessionResult.error); process.exit(1); } console.log('โœ… Session MCP server built'); } // Get OAuth defines for esbuild API function getOAuthDefines(): Record { const oauthVars = [ 'GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET', 'SLACK_OAUTH_CLIENT_ID', 'SLACK_OAUTH_CLIENT_SECRET', 'MICROSOFT_OAUTH_CLIENT_ID', 'MICROSOFT_OAUTH_CLIENT_SECRET', ]; const defines: Record = {}; for (const varName of oauthVars) { const value = process.env[varName] || ''; defines[`process.env.${varName}`] = JSON.stringify(value); } return defines; } // Get environment variables for electron process function getElectronEnv(): Record { const vitePort = String( resolveDevPort(ROOT_DIR, 5173, 'CRAFT_VITE_PORT').port, ); // Codex binary path is resolved at runtime by the binary-resolver module. // It checks: CODEX_PATH env var > bundled binary > local dev fork > system PATH. // You can override with CODEX_PATH env var if needed for debugging. return { ...(process.env as Record), VITE_DEV_SERVER_URL: `http://localhost:${vitePort}`, CRAFT_CONFIG_DIR: process.env.CRAFT_CONFIG_DIR || '', CRAFT_USER_DATA_DIR: process.env.CRAFT_USER_DATA_DIR || '', CRAFT_SERVER_LOCK_FILE: process.env.CRAFT_SERVER_LOCK_FILE || '', CRAFT_APP_NAME: process.env.CRAFT_APP_NAME || 'Qwen Code Desktop', CRAFT_DEEPLINK_SCHEME: process.env.CRAFT_DEEPLINK_SCHEME || 'craftagents', CRAFT_INSTANCE_NUMBER: process.env.CRAFT_INSTANCE_NUMBER || '', }; } // Run a one-shot esbuild using the JavaScript API async function runEsbuild( entryPoint: string, outfile: string, defines: Record = {}, options: { packagesExternal?: boolean; alias?: Record } = {}, ): Promise<{ success: boolean; error?: string }> { try { await esbuild.build({ entryPoints: [join(ROOT_DIR, entryPoint)], bundle: true, platform: 'node', format: 'cjs', outfile: join(ROOT_DIR, outfile), external: ['electron'], ...(options.packagesExternal ? { packages: 'external' as const } : {}), ...(options.alias ? { alias: options.alias } : {}), define: defines, logLevel: 'warning', }); return { success: true }; } catch (err) { return { success: false, error: String(err) }; } } // Verify a built JavaScript bundle is parseable. `node --check` performs // syntax-only validation โ€” it does NOT execute module-level code or resolve // `require()`, so Electron-specific top-level requires (e.g. @sentry/electron) // are safe. This catches truncated writes, FS corruption, and edge cases that // esbuild's build-success signal doesn't cover. async function verifyJsFile( filePath: string, ): Promise<{ valid: boolean; error?: string }> { if (!existsSync(filePath)) { return { valid: false, error: 'File does not exist' }; } const stats = statSync(filePath); if (stats.size === 0) { return { valid: false, error: 'File is empty' }; } try { const proc = spawn({ cmd: ['node', '--check', filePath], stdout: 'pipe', stderr: 'pipe', }); const stderr = await new Response(proc.stderr).text(); const exitCode = await proc.exited; if (exitCode !== 0) { return { valid: false, error: stderr.trim() || `node --check exited ${exitCode}`, }; } return { valid: true }; } catch (err) { return { valid: false, error: String(err) }; } } // Wait for file to stabilize (no size changes) async function waitForFileStable( filePath: string, timeoutMs = 10000, ): Promise { const startTime = Date.now(); let lastSize = -1; let stableCount = 0; while (Date.now() - startTime < timeoutMs) { if (!existsSync(filePath)) { await Bun.sleep(100); continue; } const stats = statSync(filePath); if (stats.size === lastSize) { stableCount++; // File size unchanged for 3 checks (300ms) - consider it stable if (stableCount >= 3) { return true; } } else { stableCount = 0; lastSize = stats.size; } await Bun.sleep(100); } return false; } async function main(): Promise { console.log('๐Ÿš€ Starting Electron dev environment...\n'); // Setup detectInstance(); loadEnvFile(); cleanViteCache(); // Ensure dist directory exists if (!existsSync(DIST_DIR)) { mkdirSync(DIST_DIR, { recursive: true }); } await ensureBundledUvForCurrentPlatform(); await ensureQwenRuntimeForDev(); copyResources(); // Build MCP servers for Codex sessions await buildMcpServers(); // Build WhatsApp worker bundle so the adapter can spawn it on demand await buildWaWorker(); const vitePort = String( resolveDevPort(ROOT_DIR, 5173, 'CRAFT_VITE_PORT').port, ); process.env.CRAFT_VITE_PORT ||= vitePort; const oauthDefines = getOAuthDefines(); // Kill any existing process on the Vite port await killProcessOnPort(vitePort); // ========================================================= // PHASE 1: Initial build (one-shot, wait for completion) // ========================================================= console.log('๐Ÿ”จ Building main process...'); const mainCjsPath = join(DIST_DIR, 'main.cjs'); const preloadCjsPath = join(DIST_DIR, 'bootstrap-preload.cjs'); const toolbarPreloadCjsPath = join(DIST_DIR, 'browser-toolbar-preload.cjs'); // Remove old build files to ensure fresh build if (existsSync(mainCjsPath)) rmSync(mainCjsPath); if (existsSync(preloadCjsPath)) rmSync(preloadCjsPath); if (existsSync(toolbarPreloadCjsPath)) rmSync(toolbarPreloadCjsPath); // Build main and preload entries in parallel const [mainResult, preloadResult, toolbarPreloadResult] = await Promise.all([ runEsbuild( 'apps/electron/src/main/index.ts', 'apps/electron/dist/main.cjs', oauthDefines, { alias: MAIN_PROCESS_ALIAS }, ), runEsbuild( 'apps/electron/src/preload/bootstrap.ts', 'apps/electron/dist/bootstrap-preload.cjs', ), runEsbuild( 'apps/electron/src/preload/browser-toolbar.ts', 'apps/electron/dist/browser-toolbar-preload.cjs', ), ]); if (!mainResult.success) { console.error('โŒ Main process build failed:', mainResult.error); process.exit(1); } if (!preloadResult.success) { console.error('โŒ Preload build failed:', preloadResult.error); process.exit(1); } if (!toolbarPreloadResult.success) { console.error( 'โŒ Browser toolbar preload build failed:', toolbarPreloadResult.error, ); process.exit(1); } // Wait for files to stabilize (filesystem flush) console.log('โณ Waiting for build files to stabilize...'); const [mainStable, preloadStable, toolbarPreloadStable] = await Promise.all([ waitForFileStable(mainCjsPath), waitForFileStable(preloadCjsPath), waitForFileStable(toolbarPreloadCjsPath), ]); if (!mainStable || !preloadStable || !toolbarPreloadStable) { console.error('โŒ Build files did not stabilize'); process.exit(1); } // Verify the built files are valid JavaScript console.log('๐Ÿ” Verifying build output...'); const [mainValid, preloadValid, toolbarPreloadValid] = await Promise.all([ verifyJsFile(mainCjsPath), verifyJsFile(preloadCjsPath), verifyJsFile(toolbarPreloadCjsPath), ]); if (!mainValid.valid) { console.error('โŒ main.cjs is invalid:', mainValid.error); process.exit(1); } if (!preloadValid.valid) { console.error('โŒ bootstrap-preload.cjs is invalid:', preloadValid.error); process.exit(1); } if (!toolbarPreloadValid.valid) { console.error( 'โŒ browser-toolbar-preload.cjs is invalid:', toolbarPreloadValid.error, ); process.exit(1); } console.log('โœ… Initial build complete and verified\n'); // ========================================================= // PHASE 2: Start dev servers with watch mode // ========================================================= console.log('๐Ÿ“ก Starting dev servers...\n'); const processes: Subprocess[] = []; const esbuildContexts: esbuild.BuildContext[] = []; // 1. Vite dev server (strictPort ensures we don't silently switch ports) const viteProc = spawn({ cmd: [ VITE_BIN, 'dev', '--config', 'apps/electron/vite.config.ts', '--port', vitePort, '--strictPort', ], cwd: ROOT_DIR, stdin: 'ignore', stdout: 'inherit', stderr: 'inherit', env: process.env as Record, }); processes.push(viteProc); // 2. Main process watcher (using esbuild watch API) const mainContext = await esbuild.context({ entryPoints: [join(ROOT_DIR, 'apps/electron/src/main/index.ts')], bundle: true, platform: 'node', format: 'cjs', outfile: join(ROOT_DIR, 'apps/electron/dist/main.cjs'), external: ['electron'], alias: MAIN_PROCESS_ALIAS, define: oauthDefines, logLevel: 'info', }); await mainContext.watch(); esbuildContexts.push(mainContext); console.log('๐Ÿ‘€ Watching main process...'); // 3. Preload watcher (using esbuild watch API) const preloadContext = await esbuild.context({ entryPoints: [join(ROOT_DIR, 'apps/electron/src/preload/bootstrap.ts')], bundle: true, platform: 'node', format: 'cjs', outfile: join(ROOT_DIR, 'apps/electron/dist/bootstrap-preload.cjs'), external: ['electron'], logLevel: 'info', }); await preloadContext.watch(); esbuildContexts.push(preloadContext); console.log('๐Ÿ‘€ Watching preload...'); // 4. Browser toolbar preload watcher (dedicated browser window bridge) const toolbarPreloadContext = await esbuild.context({ entryPoints: [ join(ROOT_DIR, 'apps/electron/src/preload/browser-toolbar.ts'), ], bundle: true, platform: 'node', format: 'cjs', outfile: join(ROOT_DIR, 'apps/electron/dist/browser-toolbar-preload.cjs'), external: ['electron'], logLevel: 'info', }); await toolbarPreloadContext.watch(); esbuildContexts.push(toolbarPreloadContext); console.log('๐Ÿ‘€ Watching browser toolbar preload...'); // 5. Start Electron (build already verified) if (ELECTRON_CLI_ARGS.length > 0) { console.log(`๐Ÿงญ Forwarding Electron args: ${ELECTRON_CLI_ARGS.join(' ')}`); } console.log('๐Ÿš€ Starting Electron...\n'); const electronProc = spawn({ cmd: [ELECTRON_BIN, ...ELECTRON_CLI_ARGS, 'apps/electron'], cwd: ROOT_DIR, stdin: 'ignore', stdout: 'inherit', stderr: 'inherit', env: getElectronEnv(), }); processes.push(electronProc); // Handle cleanup on exit const cleanup = async () => { console.log('\n๐Ÿ›‘ Shutting down...'); // Dispose esbuild contexts for (const ctx of esbuildContexts) { try { await ctx.dispose(); } catch { // Context may already be disposed } } // Kill subprocesses for (const proc of processes) { try { proc.kill(); } catch { // Process may already be dead } } process.exit(0); }; process.on('SIGINT', () => cleanup()); process.on('SIGTERM', () => cleanup()); // Windows doesn't have SIGINT/SIGTERM in the same way if (process.platform === 'win32') { process.on('SIGHUP', () => cleanup()); } // Wait for electron to exit (main process) await electronProc.exited; await cleanup(); } main().catch((err) => { console.error('โŒ Error:', err); process.exit(1); });