feat: add standalone web dev command

This commit is contained in:
777genius 2026-04-14 17:23:29 +03:00
parent 36a79f2586
commit 89bd4b87e1
10 changed files with 195 additions and 28 deletions

View file

@ -19,6 +19,7 @@
"main": "dist-electron/main/index.cjs", "main": "dist-electron/main/index.cjs",
"scripts": { "scripts": {
"dev": "node ./scripts/dev-with-runtime.mjs", "dev": "node ./scripts/dev-with-runtime.mjs",
"dev:web": "node ./scripts/dev-web.mjs",
"dev:kill": "node bin/kill-dev.js", "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", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "electron-vite build", "build": "electron-vite build",

76
scripts/dev-web.mjs Normal file
View file

@ -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}`);

View file

@ -28,15 +28,15 @@ import { createLogger } from '@shared/utils/logger';
import { validateConfigUpdatePayload } from '../ipc/configValidation'; import { validateConfigUpdatePayload } from '../ipc/configValidation';
import { validateTriggerId } from '../ipc/guards'; import { validateTriggerId } from '../ipc/guards';
import { import { ConfigManager } from '../services/infrastructure/ConfigManager';
ConfigManager,
type NotificationTrigger,
type TriggerContentType,
type TriggerMatchField,
type TriggerMode,
type TriggerTokenType,
} from '../services';
import type {
NotificationTrigger,
TriggerContentType,
TriggerMatchField,
TriggerMode,
TriggerTokenType,
} from '../services/infrastructure/ConfigManager';
import type { TriggerColor } from '@shared/constants/triggerColors'; import type { TriggerColor } from '@shared/constants/triggerColors';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';

View file

@ -14,7 +14,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { coercePageLimit, validateNotificationId } from '../ipc/guards'; import { coercePageLimit, validateNotificationId } from '../ipc/guards';
import { NotificationManager } from '../services'; import { NotificationManager } from '../services/infrastructure/NotificationManager';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';

View file

@ -13,7 +13,7 @@
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; 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 { SessionsByIdsOptions, SessionsPaginationOptions } from '../types';
import type { HttpServices } from './index'; import type { HttpServices } from './index';

View file

@ -14,7 +14,7 @@
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { ConfigManager } from '../services'; import { ConfigManager } from '../services/infrastructure/ConfigManager';
import type { import type {
SshConnectionConfig, SshConnectionConfig,

View file

@ -14,12 +14,12 @@ import { createLogger } from '@shared/utils/logger';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { readAgentConfigs } from '../services/parsing/AgentConfigReader';
import { import {
type ClaudeMdFileInfo, type ClaudeMdFileInfo,
readAgentConfigs,
readAllClaudeMdFiles, readAllClaudeMdFiles,
readDirectoryClaudeMd, readDirectoryClaudeMd,
} from '../services'; } from '../services/parsing/ClaudeMdReader';
import { validateFilePath } from '../utils/pathValidation'; import { validateFilePath } from '../utils/pathValidation';
import { countTokens } from '../utils/tokenizer'; 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. */ /** Cached app version — read once from package.json, not every request. */
let cachedVersion: string | null = null; 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 { export function registerUtilityRoutes(app: FastifyInstance): void {
// App version (cached — no file I/O after first call) // App version (cached — no file I/O after first call)
app.get('/api/version', async () => { app.get('/api/version', async () => {
if (cachedVersion) return cachedVersion; if (cachedVersion) return cachedVersion;
try { try {
const pkgPath = path.resolve(__dirname, '../../../package.json'); const content = await fsp.readFile(resolvePackageJsonPath(), 'utf8');
const content = await fsp.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(content) as { version: string }; const pkg = JSON.parse(content) as { version: string };
cachedVersion = pkg.version; cachedVersion = pkg.version;
return cachedVersion; return cachedVersion;

View file

@ -25,16 +25,21 @@ const logger = createLogger('Service:HttpServer');
*/ */
function resolveRendererPath(): string | null { function resolveRendererPath(): string | null {
const candidates = [ 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) // Fallback: relative to cwd (dev mode, standalone)
join(process.cwd(), 'out/renderer'), 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 // Allow explicit override via env
if (process.env.RENDERER_PATH) { if (process.env.RENDERER_PATH) {
candidates.unshift(process.env.RENDERER_PATH); candidates.unshift(process.env.RENDERER_PATH);

View file

@ -21,14 +21,17 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { stripMarkdown } from '@main/utils/textFormatting'; import { stripMarkdown } from '@main/utils/textFormatting';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import { createRequire } from 'module';
import * as path from 'path'; import * as path from 'path';
import { type DetectedError } from '../error/ErrorMessageBuilder'; import { type DetectedError } from '../error/ErrorMessageBuilder';
import type { BrowserWindow, NotificationConstructorOptions } from 'electron';
const logger = createLogger('Service:NotificationManager'); const logger = createLogger('Service:NotificationManager');
const require = createRequire(import.meta.url);
import { import {
buildDetectedErrorFromTeam, buildDetectedErrorFromTeam,
type TeamNotificationPayload, type TeamNotificationPayload,
@ -93,6 +96,35 @@ const THROTTLE_MS = 5000;
/** Path to notifications storage file */ /** Path to notifications storage file */
const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'claude-devtools-notifications.json'); 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 // NotificationManager Class
// ============================================================================= // =============================================================================
@ -110,7 +142,7 @@ export class NotificationManager extends EventEmitter {
* and click handlers stop working after ~1-2 minutes. * and click handlers stop working after ~1-2 minutes.
* @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html
*/ */
private activeNotifications = new Set<Notification>(); private activeNotifications = new Set<NotificationInstance>();
/** Promise that resolves when async initialization is complete. /** Promise that resolves when async initialization is complete.
* Used by addError() to wait for notifications to be loaded from disk * Used by addError() to wait for notifications to be loaded from disk
* before writing, preventing a race where save overwrites unloaded data. */ * 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. * Closes over `stored` (StoredNotification) so click handler has full data.
*/ */
private showErrorNativeNotification(stored: StoredNotification): void { private showErrorNativeNotification(stored: StoredNotification): void {
if (!this.isNativeNotificationSupported()) return; const Notification = getNotificationClass();
if (!Notification || !this.isNativeNotificationSupported()) return;
const config = this.configManager.getConfig(); const config = this.configManager.getConfig();
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
@ -408,7 +441,7 @@ export class NotificationManager extends EventEmitter {
logger.debug(`[notification] shown: "Claude Code Error" — ${stored.context.projectName}`); logger.debug(`[notification] shown: "Claude Code Error" — ${stored.context.projectName}`);
}); });
notification.on('failed', (_, error) => { notification.on('failed', (_, error) => {
logger.warn(`[notification] failed: ${error}`); logger.warn(`[notification] failed: ${String(error)}`);
cleanup(); cleanup();
}); });
@ -423,7 +456,8 @@ export class NotificationManager extends EventEmitter {
stored: StoredNotification, stored: StoredNotification,
payload: TeamNotificationPayload payload: TeamNotificationPayload
): void { ): void {
if (!this.isNativeNotificationSupported()) { const Notification = getNotificationClass();
if (!Notification || !this.isNativeNotificationSupported()) {
logger.warn('[team-toast] native notifications not supported — skipping'); logger.warn('[team-toast] native notifications not supported — skipping');
return; return;
} }
@ -491,8 +525,9 @@ export class NotificationManager extends EventEmitter {
* Guard: checks if Electron's Notification API is available. * Guard: checks if Electron's Notification API is available.
*/ */
private isNativeNotificationSupported(): boolean { private isNativeNotificationSupported(): boolean {
const Notification = getNotificationClass();
if ( if (
typeof Notification === 'undefined' || !Notification ||
typeof Notification.isSupported !== 'function' || typeof Notification.isSupported !== 'function' ||
!Notification.isSupported() !Notification.isSupported()
) { ) {
@ -511,6 +546,7 @@ export class NotificationManager extends EventEmitter {
* Returns a result object indicating success or failure reason. * Returns a result object indicating success or failure reason.
*/ */
sendTestNotification(): { success: boolean; error?: string } { sendTestNotification(): { success: boolean; error?: string } {
const Notification = getNotificationClass();
if (!this.isNativeNotificationSupported()) { if (!this.isNativeNotificationSupported()) {
logger.warn('[test-notification] native notifications not supported'); logger.warn('[test-notification] native notifications not supported');
return { success: false, error: 'Native notifications are not supported on this platform' }; 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'); logger.debug('[notification] test notification shown successfully');
}); });
notification.on('failed', (_, error) => { notification.on('failed', (_, error) => {
logger.warn(`[notification] test notification failed: ${error}`); logger.warn(`[notification] test notification failed: ${String(error)}`);
cleanup(); cleanup();
}); });

42
vite.web.config.ts Normal file
View file

@ -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'),
},
},
});