From 89bd4b87e1dc810797711274e58aa682b37eeeeb Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 17:23:29 +0300 Subject: [PATCH] feat: add standalone web dev command --- package.json | 1 + scripts/dev-web.mjs | 76 +++++++++++++++++++ src/main/http/config.ts | 16 ++-- src/main/http/notifications.ts | 2 +- src/main/http/sessions.ts | 2 +- src/main/http/ssh.ts | 2 +- src/main/http/utility.ts | 15 +++- .../services/infrastructure/HttpServer.ts | 17 +++-- .../infrastructure/NotificationManager.ts | 50 ++++++++++-- vite.web.config.ts | 42 ++++++++++ 10 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 scripts/dev-web.mjs create mode 100644 vite.web.config.ts diff --git a/package.json b/package.json index 9c9c5cdc..27370fa7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "main": "dist-electron/main/index.cjs", "scripts": { "dev": "node ./scripts/dev-with-runtime.mjs", + "dev:web": "node ./scripts/dev-web.mjs", "dev:kill": "node bin/kill-dev.js", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "build": "electron-vite build", diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs new file mode 100644 index 00000000..159415ef --- /dev/null +++ b/scripts/dev-web.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const standalonePort = process.env.STANDALONE_PORT?.trim() || '3456'; +const webPort = process.env.WEB_PORT?.trim() || '5174'; +const corsOrigin = + process.env.CORS_ORIGIN?.trim() || + `http://127.0.0.1:${webPort},http://localhost:${webPort}`; + +const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); + +function shouldUseWindowsShell(cmd) { + if (process.platform !== 'win32') { + return false; + } + + return WINDOWS_SHELL_COMMANDS.has(path.basename(cmd).toLowerCase()); +} + +function spawnProcess(cmd, args, env) { + return spawn(cmd, args, { + cwd: repoRoot, + env: { ...process.env, ...env }, + stdio: 'inherit', + shell: shouldUseWindowsShell(cmd), + }); +} + +const backend = spawnProcess('pnpm', ['exec', 'tsx', 'src/main/standalone.ts'], { + HOST: process.env.HOST?.trim() || '127.0.0.1', + PORT: standalonePort, + CORS_ORIGIN: corsOrigin, +}); + +const frontend = spawnProcess( + 'pnpm', + ['exec', 'vite', '--config', 'vite.web.config.ts', '--host', '127.0.0.1', '--port', webPort], + { + VITE_STANDALONE_PORT: standalonePort, + VITE_WEB_PORT: webPort, + } +); + +let shuttingDown = false; + +function terminateChildren(signal = 'SIGTERM') { + if (shuttingDown) { + return; + } + + shuttingDown = true; + backend.kill(signal); + frontend.kill(signal); +} + +backend.on('exit', (code, signal) => { + terminateChildren(signal ?? undefined); + process.exitCode = code ?? (signal ? 1 : 0); +}); + +frontend.on('exit', (code, signal) => { + terminateChildren(signal ?? undefined); + process.exitCode = code ?? (signal ? 1 : 0); +}); + +process.on('SIGINT', () => terminateChildren('SIGINT')); +process.on('SIGTERM', () => terminateChildren('SIGTERM')); + +console.log(`Starting standalone backend on http://127.0.0.1:${standalonePort}`); +console.log(`Starting browser dev server on http://127.0.0.1:${webPort}`); diff --git a/src/main/http/config.ts b/src/main/http/config.ts index c5acd2fc..09220eb6 100644 --- a/src/main/http/config.ts +++ b/src/main/http/config.ts @@ -28,15 +28,15 @@ import { createLogger } from '@shared/utils/logger'; import { validateConfigUpdatePayload } from '../ipc/configValidation'; import { validateTriggerId } from '../ipc/guards'; -import { - ConfigManager, - type NotificationTrigger, - type TriggerContentType, - type TriggerMatchField, - type TriggerMode, - type TriggerTokenType, -} from '../services'; +import { ConfigManager } from '../services/infrastructure/ConfigManager'; +import type { + NotificationTrigger, + TriggerContentType, + TriggerMatchField, + TriggerMode, + TriggerTokenType, +} from '../services/infrastructure/ConfigManager'; import type { TriggerColor } from '@shared/constants/triggerColors'; import type { FastifyInstance } from 'fastify'; diff --git a/src/main/http/notifications.ts b/src/main/http/notifications.ts index 535ca925..c48646e0 100644 --- a/src/main/http/notifications.ts +++ b/src/main/http/notifications.ts @@ -14,7 +14,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateNotificationId } from '../ipc/guards'; -import { NotificationManager } from '../services'; +import { NotificationManager } from '../services/infrastructure/NotificationManager'; import type { FastifyInstance } from 'fastify'; diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index a3bc267c..c0244274 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -13,7 +13,7 @@ import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; -import { DataCache } from '../services'; +import { DataCache } from '../services/infrastructure/DataCache'; import type { SessionsByIdsOptions, SessionsPaginationOptions } from '../types'; import type { HttpServices } from './index'; diff --git a/src/main/http/ssh.ts b/src/main/http/ssh.ts index 8e3a304c..60f71734 100644 --- a/src/main/http/ssh.ts +++ b/src/main/http/ssh.ts @@ -14,7 +14,7 @@ import { createLogger } from '@shared/utils/logger'; -import { ConfigManager } from '../services'; +import { ConfigManager } from '../services/infrastructure/ConfigManager'; import type { SshConnectionConfig, diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index e3839e71..d076b558 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -14,12 +14,12 @@ import { createLogger } from '@shared/utils/logger'; import * as fsp from 'fs/promises'; import * as path from 'path'; +import { readAgentConfigs } from '../services/parsing/AgentConfigReader'; import { type ClaudeMdFileInfo, - readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd, -} from '../services'; +} from '../services/parsing/ClaudeMdReader'; import { validateFilePath } from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; @@ -30,13 +30,20 @@ const logger = createLogger('HTTP:utility'); /** Cached app version — read once from package.json, not every request. */ let cachedVersion: string | null = null; +function resolvePackageJsonPath(): string { + if (typeof __dirname === 'string' && __dirname.length > 0) { + return path.resolve(__dirname, '../../../package.json'); + } + + return path.resolve(process.cwd(), 'package.json'); +} + export function registerUtilityRoutes(app: FastifyInstance): void { // App version (cached — no file I/O after first call) app.get('/api/version', async () => { if (cachedVersion) return cachedVersion; try { - const pkgPath = path.resolve(__dirname, '../../../package.json'); - const content = await fsp.readFile(pkgPath, 'utf8'); + const content = await fsp.readFile(resolvePackageJsonPath(), 'utf8'); const pkg = JSON.parse(content) as { version: string }; cachedVersion = pkg.version; return cachedVersion; diff --git a/src/main/services/infrastructure/HttpServer.ts b/src/main/services/infrastructure/HttpServer.ts index 81ad929d..8fb9f9d6 100644 --- a/src/main/services/infrastructure/HttpServer.ts +++ b/src/main/services/infrastructure/HttpServer.ts @@ -25,16 +25,21 @@ const logger = createLogger('Service:HttpServer'); */ function resolveRendererPath(): string | null { const candidates = [ - // Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem) - join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked'), - // Electron production (asar fallback): app.asar/out/renderer - join(__dirname, '../../out/renderer'), - // Standalone: dist-standalone/index.cjs → ../out/renderer - join(__dirname, '../out/renderer'), // Fallback: relative to cwd (dev mode, standalone) join(process.cwd(), 'out/renderer'), ]; + if (typeof __dirname === 'string') { + candidates.unshift( + // Standalone: dist-standalone/index.cjs → ../out/renderer + join(__dirname, '../out/renderer'), + // Electron production (asar fallback): app.asar/out/renderer + join(__dirname, '../../out/renderer'), + // Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem) + join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked') + ); + } + // Allow explicit override via env if (process.env.RENDERER_PATH) { candidates.unshift(process.env.RENDERER_PATH); diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 4e3c1db7..9a35044b 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -21,14 +21,17 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; -import { type BrowserWindow, Notification } from 'electron'; import { EventEmitter } from 'events'; import * as fsp from 'fs/promises'; +import { createRequire } from 'module'; import * as path from 'path'; import { type DetectedError } from '../error/ErrorMessageBuilder'; +import type { BrowserWindow, NotificationConstructorOptions } from 'electron'; + const logger = createLogger('Service:NotificationManager'); +const require = createRequire(import.meta.url); import { buildDetectedErrorFromTeam, type TeamNotificationPayload, @@ -93,6 +96,35 @@ const THROTTLE_MS = 5000; /** Path to notifications storage file */ const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'claude-devtools-notifications.json'); +type NotificationEventName = 'click' | 'close' | 'show' | 'failed'; + +interface NotificationInstance { + on(event: NotificationEventName, listener: (...args: unknown[]) => void): void; + show(): void; +} + +interface NotificationClass { + new (options: NotificationConstructorOptions): NotificationInstance; + isSupported(): boolean; +} + +let cachedNotificationClass: NotificationClass | null | undefined; + +function getNotificationClass(): NotificationClass | null { + if (cachedNotificationClass !== undefined) { + return cachedNotificationClass; + } + + try { + const electronModule = require('electron') as { Notification?: NotificationClass }; + cachedNotificationClass = electronModule.Notification ?? null; + } catch { + cachedNotificationClass = null; + } + + return cachedNotificationClass; +} + // ============================================================================= // NotificationManager Class // ============================================================================= @@ -110,7 +142,7 @@ export class NotificationManager extends EventEmitter { * and click handlers stop working after ~1-2 minutes. * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html */ - private activeNotifications = new Set(); + private activeNotifications = new Set(); /** Promise that resolves when async initialization is complete. * Used by addError() to wait for notifications to be loaded from disk * before writing, preventing a race where save overwrites unloaded data. */ @@ -378,7 +410,8 @@ export class NotificationManager extends EventEmitter { * Closes over `stored` (StoredNotification) so click handler has full data. */ private showErrorNativeNotification(stored: StoredNotification): void { - if (!this.isNativeNotificationSupported()) return; + const Notification = getNotificationClass(); + if (!Notification || !this.isNativeNotificationSupported()) return; const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; @@ -408,7 +441,7 @@ export class NotificationManager extends EventEmitter { logger.debug(`[notification] shown: "Claude Code Error" — ${stored.context.projectName}`); }); notification.on('failed', (_, error) => { - logger.warn(`[notification] failed: ${error}`); + logger.warn(`[notification] failed: ${String(error)}`); cleanup(); }); @@ -423,7 +456,8 @@ export class NotificationManager extends EventEmitter { stored: StoredNotification, payload: TeamNotificationPayload ): void { - if (!this.isNativeNotificationSupported()) { + const Notification = getNotificationClass(); + if (!Notification || !this.isNativeNotificationSupported()) { logger.warn('[team-toast] native notifications not supported — skipping'); return; } @@ -491,8 +525,9 @@ export class NotificationManager extends EventEmitter { * Guard: checks if Electron's Notification API is available. */ private isNativeNotificationSupported(): boolean { + const Notification = getNotificationClass(); if ( - typeof Notification === 'undefined' || + !Notification || typeof Notification.isSupported !== 'function' || !Notification.isSupported() ) { @@ -511,6 +546,7 @@ export class NotificationManager extends EventEmitter { * Returns a result object indicating success or failure reason. */ sendTestNotification(): { success: boolean; error?: string } { + const Notification = getNotificationClass(); if (!this.isNativeNotificationSupported()) { logger.warn('[test-notification] native notifications not supported'); return { success: false, error: 'Native notifications are not supported on this platform' }; @@ -541,7 +577,7 @@ export class NotificationManager extends EventEmitter { logger.debug('[notification] test notification shown successfully'); }); notification.on('failed', (_, error) => { - logger.warn(`[notification] test notification failed: ${error}`); + logger.warn(`[notification] test notification failed: ${String(error)}`); cleanup(); }); diff --git a/vite.web.config.ts b/vite.web.config.ts new file mode 100644 index 00000000..39c5778d --- /dev/null +++ b/vite.web.config.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +const ROOT = __dirname; +const standalonePort = process.env.VITE_STANDALONE_PORT?.trim() || '3456'; +const webPort = Number.parseInt(process.env.VITE_WEB_PORT?.trim() || '5174', 10); +const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) as { version: string }; + +export default defineConfig({ + root: resolve(ROOT, 'src/renderer'), + plugins: [react()], + server: { + host: '127.0.0.1', + port: webPort, + proxy: { + '/api': { + target: `http://127.0.0.1:${standalonePort}`, + changeOrigin: false, + }, + }, + }, + optimizeDeps: { + include: ['@codemirror/language-data'], + exclude: ['@claude-teams/agent-graph'], + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + 'import.meta.env.VITE_SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''), + }, + resolve: { + alias: { + '@features': resolve(ROOT, 'src/features'), + '@renderer': resolve(ROOT, 'src/renderer'), + '@shared': resolve(ROOT, 'src/shared'), + '@main': resolve(ROOT, 'src/main'), + '@claude-teams/agent-graph': resolve(ROOT, 'packages/agent-graph/src/index.ts'), + }, + }, +});