Files
openchamber/packages/ui/src/lib/detectDevServer.ts
T
Bohdan Triapitsyn d4a8c4d2e1 feat(terminal): refactor runtime and add mobile workspace (#2280)
Replace the legacy terminal flow with a shared authenticated WebSocket
runtime used across web, desktop, relay, and mobile surfaces.

- introduce the v3 terminal protocol with scoped attachments, snapshots,
  ordered output, bounded replay history, reconnects, and explicit lifecycle
- harden PTY creation, restart, resize, close, force-kill, idle cleanup,
  shell selection, login mode, environment sanitization, and appearance sync
- add runtime-aware terminal APIs with relay authentication and Electron parity
- add a fullscreen mobile terminal workspace with touch scrolling,
  long-press selection, safe-area controls, quick keys, and Ctrl/Alt input
- add terminal selection attachments, preview detection, project actions,
  shell settings, and localized UI
- harden Ghostty rendering, resize recovery, Unicode handling, block
  characters, line height, and stale-row behavior
- remove the obsolete terminal SSE path and update reverse-proxy guidance
- expand terminal runtime, transport, input, selection, and store coverage
- avoid duplicate web builds when preparing mobile assets in root CI builds
2026-07-17 13:17:21 +03:00

223 lines
6.8 KiB
TypeScript

import type { OpenChamberProjectAction } from './openchamberConfig';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { runtimeFetch } from '@/lib/runtime-fetch';
type DevServerInfo = {
command: string;
label: string;
actionId?: string;
previewUrlHint?: string;
};
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
const DEV_COMMAND_PATTERNS = [
{ pattern: /^dev(:.*)?$/i },
{ pattern: /^start(:.*)?$/i },
{ pattern: /^preview(:.*)?$/i },
{ pattern: /^serve(:.*)?$/i },
{ pattern: /^develop(:.*)?$/i },
];
const COMMON_DEV_COMMANDS = [
'dev',
'start',
'preview',
'serve',
];
/**
* Detect the dev server command from project actions or package.json scripts
*/
export async function detectDevServerCommand(
directory: string,
projectActions: OpenChamberProjectAction[],
packageJsonScripts: Record<string, string> | null,
): Promise<DevServerInfo | null> {
if (!directory) return null;
// First, check if there's a project action that looks like a dev server
const devAction = findDevServerAction(projectActions);
if (devAction) {
return {
command: devAction.command,
label: devAction.name || 'Start Preview',
actionId: devAction.id,
previewUrlHint: devAction.openUrl,
};
}
// Then, check package.json scripts
if (packageJsonScripts) {
const devScript = findDevScript(packageJsonScripts);
if (devScript) {
// Determine the package manager command
const pm = await detectPackageManager(directory);
const pmCommand = pm === 'npm' ? 'npm run' : pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : pm === 'bun' ? 'bun run --shell=bun' : 'npm run';
return {
command: `${pmCommand} ${devScript}`,
label: `Start (${devScript})`,
};
}
}
// Fallback: static sites (no package.json) can be previewed via a simple file server.
// This keeps Start Preview usable for non-Node projects.
if (await hasStaticIndexHtml(directory)) {
const port = await allocatePreviewPort();
const resolvedPort = typeof port === 'number' && Number.isFinite(port) && port > 0 ? port : 8000;
return {
command: `python3 -m http.server ${resolvedPort}`,
label: 'Static preview',
previewUrlHint: `http://127.0.0.1:${resolvedPort}/`,
};
}
return null;
}
async function hasStaticIndexHtml(directory: string): Promise<boolean> {
const target = `${directory}/index.html`;
const content = await readOptionalTextFile(target, directory);
return typeof content === 'string' && content.trim().length > 0;
}
async function allocatePreviewPort(): Promise<number | null> {
try {
const response = await runtimeFetch('/api/system/free-port', { cache: 'no-store' });
if (!response.ok) return null;
const body = await response.json().catch(() => null) as { port?: unknown } | null;
const port = typeof body?.port === 'number' ? body.port : null;
return port && Number.isFinite(port) ? port : null;
} catch {
return null;
}
}
/**
* Find a project action that looks like a dev server
*/
function findDevServerAction(actions: OpenChamberProjectAction[]): OpenChamberProjectAction | null {
// Look for actions with "dev", "preview", "start" in the name or command
for (const action of actions) {
const nameAndCommand = `${action.name} ${action.command}`.toLowerCase();
// Check if it's likely a dev server action
const isDevAction = COMMON_DEV_COMMANDS.some(cmd =>
nameAndCommand.includes(cmd)
);
if (isDevAction) {
return action;
}
}
return null;
}
/**
* Find a dev script in package.json scripts
*/
function findDevScript(scripts: Record<string, string>): string | null {
for (const { pattern } of DEV_COMMAND_PATTERNS) {
for (const scriptName of Object.keys(scripts)) {
if (pattern.test(scriptName)) {
return scriptName;
}
}
}
return null;
}
/**
* Simple package manager detection based on lock files
* Note: This is intentionally a simple client-side check.
* For server-side operations, the server's package-manager.js is used.
*/
async function detectPackageManager(directory: string): Promise<PackageManager> {
const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`, directory);
if (packageJsonContent) {
try {
const pkg = JSON.parse(packageJsonContent) as { packageManager?: unknown };
const packageManager = typeof pkg.packageManager === 'string' ? pkg.packageManager.toLowerCase() : '';
if (packageManager.startsWith('bun@')) return 'bun';
if (packageManager.startsWith('pnpm@')) return 'pnpm';
if (packageManager.startsWith('yarn@')) return 'yarn';
if (packageManager.startsWith('npm@')) return 'npm';
} catch {
// Ignore malformed package.json here; readPackageJsonScripts handles it separately.
}
}
const lockfiles: Array<[string, PackageManager]> = [
['bun.lock', 'bun'],
['bun.lockb', 'bun'],
['pnpm-lock.yaml', 'pnpm'],
['yarn.lock', 'yarn'],
['package-lock.json', 'npm'],
];
for (const [fileName, packageManager] of lockfiles) {
const content = await readOptionalTextFile(`${directory}/${fileName}`, directory);
if (typeof content === 'string' && content.trim().length > 0) {
return packageManager;
}
}
return 'npm';
}
async function readOptionalTextFile(path: string, directory?: string): Promise<string | null> {
if (directory?.trim()) {
try {
const params = new URLSearchParams({ path, directory, optional: 'true' });
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
cache: 'no-store',
});
if (response.ok) {
return response.text();
}
} catch {
// Fall through to the registered files API for runtimes that do not expose HTTP fs routes.
}
}
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
if (runtimeFiles?.readFile) {
try {
const result = await runtimeFiles.readFile(path, { optional: true });
return typeof result?.content === 'string' ? result.content : null;
} catch {
return null;
}
}
try {
const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
cache: 'no-store',
});
if (!response.ok) return null;
return response.text();
} catch {
return null;
}
}
/**
* Read package.json scripts from a directory
*/
export async function readPackageJsonScripts(directory: string): Promise<Record<string, string> | null> {
try {
const content = await readOptionalTextFile(`${directory}/package.json`, directory);
if (content == null) return null;
const pkg = JSON.parse(content);
const scripts = (pkg as { scripts?: unknown }).scripts;
return scripts && typeof scripts === 'object' && !Array.isArray(scripts)
? scripts as Record<string, string>
: null;
} catch {
return null;
}
}