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",
"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",

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 { 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';

View file

@ -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';

View file

@ -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';

View file

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

View file

@ -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;

View file

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

View file

@ -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<Notification>();
private activeNotifications = new Set<NotificationInstance>();
/** 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();
});

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