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