feat: add standalone web dev command
This commit is contained in:
parent
36a79f2586
commit
89bd4b87e1
10 changed files with 195 additions and 28 deletions
|
|
@ -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
76
scripts/dev-web.mjs
Normal 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}`);
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { ConfigManager } from '../services';
|
||||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||
|
||||
import type {
|
||||
SshConnectionConfig,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
42
vite.web.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue