Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,8 @@ PROMPT.md

# E2E test secrets
.env.local

# i18n generated types (generated by i18next-cli)
src/i18n/i18next.d.ts
src/i18n/resources.d.ts
miniapps/*/src/i18n/i18next.d.ts
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"type": "module",
"packageManager": "pnpm@10.28.0",
"scripts": {
"dev": "vite",
"dev": "bun scripts/dev.ts",
"dev:vite": "vite",
"dev:mock": "SERVICE_IMPL=mock vite --port 5174",
"build": "vite build",
"build:web": "SERVICE_IMPL=web vite build",
Expand All @@ -25,7 +26,7 @@
"storybook": "bun scripts/storybook-clean.ts && storybook dev -p 6006",
"build-storybook": "storybook build",
"typecheck": "turbo run typecheck:run --",
"typecheck:run": "tsc --build --noEmit",
"typecheck:run": "bun scripts/i18n-types.ts && tsc --build --noEmit",
"e2e": "bun scripts/e2e.ts",
"e2e:runner": "bun scripts/e2e-runner.ts",
"e2e:all": "turbo run e2e:run e2e:mock:run --",
Expand Down Expand Up @@ -54,6 +55,8 @@
"i18n:check": "turbo run i18n:run --",
"i18n:run": "bun scripts/i18n-check.ts",
"i18n:validate": "bun scripts/i18n-validate.ts",
"i18n:types": "bun scripts/i18n-types.ts",
"i18n:types:watch": "bun scripts/i18n-types-watch.ts",
"theme:check": "turbo run theme:run --",
"theme:run": "bun scripts/theme-check.ts",
"agent": "deno run -A scripts/agent-flow/meta/entry.ts",
Expand Down Expand Up @@ -167,6 +170,7 @@
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-unused-imports": "^4.3.0",
"fake-indexeddb": "^6.2.5",
"i18next-cli": "^1.36.1",
"jsdom": "^27.2.0",
"jszip": "^3.10.1",
"oxlint": "^1.39.0",
Expand Down
567 changes: 451 additions & 116 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

176 changes: 176 additions & 0 deletions scripts/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env bun

import { commands } from './dev/commands';
import { ProcessManager } from './dev/process-manager';
import { ControlPanel } from './dev/control-panel';
import type { TabState } from './dev/types';

const processManager = new ProcessManager();
processManager.setCommands(commands);

const tabState: TabState = { activeTab: 0, selectedCommand: 0 };

const controlPanel = new ControlPanel(commands, (id) => processManager.getState(id));

function getRunningCommandAtTabIndex(tabIndex: number) {
const runningCommands = controlPanel.getRunningCommands();
return runningCommands[tabIndex - 1];
}

function getMaxTabIndex(): number {
return controlPanel.getRunningCommands().length;
}

function render() {
if (tabState.activeTab === 0) {
console.clear();
console.log(controlPanel.render(tabState));
}
}

function renderProcessOutput(commandId: string) {
const state = processManager.getState(commandId);
if (state) {
console.clear();
console.log(state.buffer.join(''));
}
}

processManager.onOutput = (commandId, text) => {
const runningCommands = controlPanel.getRunningCommands();
const cmdIndex = runningCommands.findIndex((c) => c.id === commandId);
if (tabState.activeTab === cmdIndex + 1) {
process.stdout.write(text);
}
};

async function handleKeypress(key: string): Promise<boolean> {
if (key === '\x1b[1;3D') {
tabState.activeTab = Math.max(0, tabState.activeTab - 1);
if (tabState.activeTab === 0) {
render();
} else {
const cmd = getRunningCommandAtTabIndex(tabState.activeTab);
if (cmd) renderProcessOutput(cmd.id);
}
return true;
}

if (key === '\x1b[1;3C') {
const maxTab = getMaxTabIndex();
tabState.activeTab = Math.min(maxTab, tabState.activeTab + 1);
if (tabState.activeTab === 0) {
render();
} else {
const cmd = getRunningCommandAtTabIndex(tabState.activeTab);
if (cmd) renderProcessOutput(cmd.id);
}
return true;
}

if (tabState.activeTab === 0) {
if (key === '\x1b[A') {
tabState.selectedCommand = Math.max(0, tabState.selectedCommand - 1);
render();
return true;
}

if (key === '\x1b[B') {
tabState.selectedCommand = Math.min(commands.length - 1, tabState.selectedCommand + 1);
render();
return true;
}

if (key === '\r') {
const cmd = commands[tabState.selectedCommand];
await processManager.start(cmd);
render();
return true;
}

if (key === 's' || key === 'S') {
const cmd = commands[tabState.selectedCommand];
await processManager.stop(cmd.id);
render();
return true;
}

if (key === 'r' || key === 'R') {
const cmd = commands[tabState.selectedCommand];
await processManager.restart(cmd);
render();
return true;
}

if (key === 'q' || key === 'Q' || key === '\x03') {
return false;
}
} else {
if (key === 'q' || key === 'Q') {
tabState.activeTab = 0;
render();
return true;
}

if (key === '\x03') {
return false;
}
}

return true;
}

async function cleanup() {
console.log('\n\x1b[33mStopping all processes...\x1b[0m');
await processManager.stopAll();
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
}

async function main() {
if (!process.stdin.isTTY) {
console.error('\x1b[31mError: Dev Runner requires a TTY. Run directly in terminal.\x1b[0m');
process.exit(1);
}

console.clear();
console.log('\x1b[36mStarting Dev Runner...\x1b[0m\n');

for (const cmd of commands) {
if (cmd.autoStart) {
console.log(`Starting ${cmd.name}...`);
await processManager.start(cmd);
}
}

process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');

render();

process.stdin.on('data', async (data: string) => {
const shouldContinue = await handleKeypress(data);
if (!shouldContinue) {
await cleanup();
process.exit(0);
}
});

process.on('SIGINT', async () => {
await cleanup();
process.exit(0);
});

process.on('SIGTERM', async () => {
await cleanup();
process.exit(0);
});
}

main().catch(async (err) => {
console.error('Fatal error:', err);
await cleanup();
process.exit(1);
});
38 changes: 38 additions & 0 deletions scripts/dev/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { DevCommand } from './types';

export const commands: DevCommand[] = [
{
id: 'vite',
name: 'vite',
description: 'Vite dev server',
cmd: ['bun', 'vite'],
color: '\x1b[36m',
autoStart: true,
port: 5173,
},
{
id: 'tsc',
name: 'tsc',
description: 'TypeScript watch',
cmd: ['bun', 'tsc', '--build', '--noEmit', '--watch', '--preserveWatchOutput'],
color: '\x1b[34m',
autoStart: true,
},
{
id: 'i18n',
name: 'i18n',
description: 'i18n types watcher',
cmd: ['bun', 'scripts/i18n-types-watch.ts'],
color: '\x1b[33m',
autoStart: true,
},
{
id: 'storybook',
name: 'storybook',
description: 'Storybook dev',
cmd: ['bun', 'run', 'storybook'],
color: '\x1b[35m',
autoStart: false,
port: 6006,
},
];
73 changes: 73 additions & 0 deletions scripts/dev/control-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { DevCommand, ProcessState, TabState } from './types';

type StateGetter = (id: string) => ProcessState | undefined;

export class ControlPanel {
private commands: DevCommand[];
private getState: StateGetter;

constructor(commands: DevCommand[], getState: StateGetter) {
this.commands = commands;
this.getState = getState;
}

render(tabState: TabState): string {
const lines: string[] = [];
const width = process.stdout.columns || 80;

lines.push('\x1b[90m' + '─'.repeat(width) + '\x1b[0m');
lines.push('\x1b[1m Dev Runner \x1b[0m');
lines.push('\x1b[90m Option+← / Option+→ 切换Tab │ ↑↓ 选择 │ Enter 启动 │ S 停止 │ R 重启 │ Q 退出\x1b[0m');
lines.push('\x1b[90m' + '─'.repeat(width) + '\x1b[0m');
lines.push('');

this.commands.forEach((cmd, index) => {
const state = this.getState(cmd.id);
const isSelected = index === tabState.selectedCommand;
const prefix = isSelected ? '\x1b[47m\x1b[30m > \x1b[0m' : ' ';

const statusIcon = this.getStatusIcon(state?.status);
const pidInfo = state?.pid ? `\x1b[90m${state.pid}:${cmd.name}\x1b[0m` : '\x1b[90m(stopped)\x1b[0m';

const line = `${prefix}${statusIcon} ${cmd.color}${cmd.name.padEnd(12)}\x1b[0m ${cmd.description.padEnd(25)} ${pidInfo}`;
lines.push(line);
});

lines.push('');
lines.push('\x1b[90m' + '─'.repeat(width) + '\x1b[0m');

const tabs = [tabState.activeTab === 0 ? '\x1b[47m\x1b[30m[0] Control\x1b[0m' : '[0] Control'];
let tabIndex = 1;
this.commands.forEach((cmd) => {
const state = this.getState(cmd.id);
if (state?.status === 'running' && state.pid) {
const tabLabel = `[${tabIndex}] ${state.pid}:${cmd.name}`;
tabs.push(tabState.activeTab === tabIndex ? `\x1b[47m\x1b[30m${tabLabel}\x1b[0m` : tabLabel);
tabIndex++;
}
});
lines.push(tabs.join(' '));

return lines.join('\n');
}

private getStatusIcon(status?: string): string {
switch (status) {
case 'running':
return '\x1b[32m●\x1b[0m';
case 'starting':
return '\x1b[33m◐\x1b[0m';
case 'stopping':
return '\x1b[31m◐\x1b[0m';
default:
return '\x1b[90m○\x1b[0m';
}
}

getRunningCommands(): DevCommand[] {
return this.commands.filter((cmd) => {
const state = this.getState(cmd.id);
return state?.status === 'running';
});
}
}
Loading