{article.title}
-
-
- {t('publishAt')}
-
- 快速、流畅、轻松 — 约一分钟即可上线的全栈发布平台。
- 一条 CLI · 全栈 CMS · Headless 主题 · 面向生产环境部署
+ ReactPress 4.0 — 快速、流畅、轻松,约一分钟即可上线的全栈发布平台。
+ 一条 CLI · 插件系统 · 桌面客户端 · Headless 主题 · MySQL & SQLite · 面向生产环境部署
- Fast, smooth, and effortless publishing — live in about a minute.
- One CLI · Full-stack CMS · Headless themes · Built for production deployment.
+ ReactPress 4.0 — fast, smooth, effortless publishing, live in about a minute.
+ One CLI · Plugins · Desktop app · Headless themes · MySQL & SQLite · Built for production.
', 'npm 2FA one-time password')
+ .action(async (options) => {
+ try {
+ const publish = require('../lib/publish');
+ if (options.build) {
+ await publish.buildPackages();
+ return;
+ }
+ if (options.publish) {
+ await publish.publishPackages({
+ tag: options.tag,
+ version: options.version,
+ yes: Boolean(options.yes),
+ noBuild: Boolean(options.noBuild),
+ otp: options.otp || process.env.NPM_OTP,
+ });
+ return;
+ }
+ await publish.main();
+ } catch (err) {
+ console.error(chalk.red('[reactpress]'), err.message || err);
+ process.exit(1);
+ }
+ });
+
+const themeCmd = program.command('theme').description(t('cli.theme.description'));
+
+themeCmd
+ .command('add')
+ .description(t('cli.theme.add.description'))
+ .argument('[spec]', t('cli.theme.add.spec'))
+ .option('--catalog ', t('cli.theme.add.catalog'))
+ .option('--skip-deps', t('cli.theme.add.skipDeps'))
+ .action(async (spec, options) => {
+ try {
+ const projectRoot = ensureOriginalCwd();
+ const targetSpec = options.catalog || spec;
+ if (!targetSpec) {
+ throw new Error(t('themeInstall.specRequired'));
+ }
+ await require('../lib/theme-cli').runThemeAdd(projectRoot, targetSpec, {
+ skipDependencies: !!options.skipDeps,
+ });
+ } catch (err) {
+ console.error(chalk.red('[reactpress]'), err.message || err);
+ process.exit(1);
+ }
+ });
+
+themeCmd.command('list').description(t('cli.theme.list.description')).action(() => {
+ require('../lib/theme-cli').runThemeList(ensureOriginalCwd());
+});
+
+const pluginCmd = program.command('plugin').description(t('cli.plugin.description'));
+
+pluginCmd
+ .command('install')
+ .description(t('cli.plugin.install.description'))
+ .argument('', t('cli.plugin.install.id'))
+ .action((id) => {
+ try {
+ require('../lib/plugin-cli').runPluginInstall(ensureOriginalCwd(), id);
+ } catch (err) {
+ console.error(chalk.red('[reactpress]'), err.message || err);
+ process.exit(1);
+ }
+ });
+
+pluginCmd.command('list').description(t('cli.plugin.list.description')).action(() => {
+ require('../lib/plugin-cli').runPluginList(ensureOriginalCwd());
+});
+
+program
+ .command('start')
+ .description(t('cli.start.description'))
+ .action(async () => {
+ const projectRoot = ensureOriginalCwd();
+ const code = await runLifecycleCommand('start', projectRoot);
+ if (code !== 0) process.exit(code);
+
+ const { resolveThemeDirectory, readActiveThemeManifest } = require('../lib/theme-runtime');
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const themeDir = resolveThemeDirectory(projectRoot, activeTheme);
+ if (!themeDir) {
+ console.log(t('dev.standaloneHint'));
+ return;
+ }
+ const { spawn } = require('child_process');
+ const child = spawn('pnpm', ['run', 'start'], {
+ stdio: 'inherit',
+ shell: true,
+ cwd: themeDir,
+ });
+ child.on('close', (c) => process.exit(c ?? 0));
+ });
+
+program.on('--help', () => {
+ console.log('');
+ console.log(brand.bold(t('cli.help.examples')));
+ console.log(divider(40));
+ const lines = [
+ t('cli.help.interactive'),
+ t('cli.help.dev'),
+ t('cli.help.devLocal'),
+ t('cli.help.initLocal'),
+ t('cli.help.desktop'),
+ t('cli.help.server'),
+ t('cli.help.status'),
+ t('cli.help.doctor'),
+ t('cli.help.docker'),
+ t('cli.help.nginx'),
+ t('cli.help.build'),
+ t('cli.help.theme'),
+ t('cli.help.themeList'),
+ t('cli.help.plugin'),
+ t('cli.help.dbBackup'),
+ t('cli.help.publish'),
+ ];
+ for (const line of lines) {
+ console.log(brand.dim(line));
+ }
+ console.log('');
+});
+
+async function main() {
+ const argv = process.argv.slice(2);
+ if (argv.length === 0) {
+ await runInteractiveLoop();
+ return;
+ }
+ program.parse(process.argv);
+}
+
+main().catch((err) => {
+ console.error(chalk.red('[reactpress]'), err.message || err);
+ process.exit(1);
+});
diff --git a/cli/src/core/services/config.ts b/cli/src/core/services/config.ts
new file mode 100644
index 00000000..bc705ff7
--- /dev/null
+++ b/cli/src/core/services/config.ts
@@ -0,0 +1,140 @@
+import fs from 'fs-extra';
+
+import type { EnvMap, ReactPressConfig } from '../../types/config';
+import { getProjectPaths } from '../utils/paths';
+
+export async function loadConfig(projectRoot: string): Promise {
+ const { configPath } = getProjectPaths(projectRoot);
+ if (!(await fs.pathExists(configPath))) {
+ throw new Error('未找到 ReactPress 项目。请先运行 reactpress init 初始化。');
+ }
+ return fs.readJson(configPath) as Promise;
+}
+
+export async function saveConfig(projectRoot: string, config: ReactPressConfig): Promise {
+ const { configPath, reactpressDir } = getProjectPaths(projectRoot);
+ await fs.ensureDir(reactpressDir);
+ await fs.writeJson(configPath, config, { spaces: 2 });
+}
+
+export async function loadEnvFile(envPath: string): Promise {
+ const content = await fs.readFile(envPath, 'utf8');
+ const map: EnvMap = {};
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const eq = trimmed.indexOf('=');
+ if (eq === -1) continue;
+ map[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
+ }
+ return map;
+}
+
+export async function writeEnvFile(envPath: string, map: EnvMap): Promise {
+ const lines = [
+ '# ReactPress — managed by reactpress-cli',
+ ...Object.entries(map).map(([k, v]) => `${k}=${v}`),
+ '',
+ ];
+ await fs.writeFile(envPath, lines.join('\n'), 'utf8');
+}
+
+export function isSqliteMode(config: ReactPressConfig): boolean {
+ return config.database.mode === 'embedded-sqlite';
+}
+
+export async function syncEnvFromConfig(
+ projectRoot: string,
+ config: ReactPressConfig,
+): Promise {
+ const { envPath, sqlitePath, uploadsDir } = getProjectPaths(projectRoot);
+ const existing = (await fs.pathExists(envPath)) ? await loadEnvFile(envPath) : {};
+
+ if (isSqliteMode(config)) {
+ const dbFile = config.database.sqlitePath ?? sqlitePath;
+ const port = config.server.port;
+ const siteUrl =
+ config.server.serverUrl ?? config.server.siteUrl ?? `http://127.0.0.1:${port}`;
+ const clientUrl = config.server.clientUrl ?? 'http://localhost:3001';
+ const merged: EnvMap = {
+ ...existing,
+ DB_TYPE: 'sqlite',
+ DB_DATABASE: dbFile,
+ SERVER_PORT: String(port),
+ CLIENT_SITE_URL: clientUrl,
+ SERVER_SITE_URL: siteUrl,
+ SERVER_API_PREFIX: config.server.apiPrefix ?? existing.SERVER_API_PREFIX ?? '/api',
+ REACTPRESS_UPLOAD_DIR: existing.REACTPRESS_UPLOAD_DIR ?? uploadsDir,
+ };
+ await writeEnvFile(envPath, merged);
+ return;
+ }
+
+ const merged: EnvMap = {
+ ...existing,
+ DB_TYPE: 'mysql',
+ DB_HOST: config.database.host ?? existing.DB_HOST ?? '127.0.0.1',
+ DB_PORT: String(config.database.port ?? existing.DB_PORT ?? 3306),
+ DB_USER: config.database.user ?? existing.DB_USER ?? 'reactpress',
+ DB_PASSWD: config.database.password ?? existing.DB_PASSWD ?? 'reactpress',
+ DB_DATABASE: config.database.database ?? existing.DB_DATABASE ?? 'reactpress',
+ SERVER_PORT: String(config.server.port),
+ CLIENT_SITE_URL: config.server.clientUrl ?? existing.CLIENT_SITE_URL ?? 'http://localhost:3001',
+ SERVER_SITE_URL: config.server.serverUrl ?? existing.SERVER_SITE_URL ?? 'http://localhost:3002',
+ };
+ await writeEnvFile(envPath, merged);
+}
+
+export function setConfigValue(
+ config: ReactPressConfig,
+ keyPath: string,
+ value: string,
+): ReactPressConfig {
+ const parts = keyPath.split('.');
+ const clone = structuredClone(config) as unknown as Record;
+ let cursor: Record = clone;
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i];
+ if (typeof cursor[part] !== 'object' || cursor[part] === null) {
+ cursor[part] = {};
+ }
+ cursor = cursor[part] as Record;
+ }
+ const last = parts[parts.length - 1];
+ const numericKeys = new Set(['port', 'version']);
+ cursor[last] = numericKeys.has(last) ? Number(value) : value;
+ return clone as unknown as ReactPressConfig;
+}
+
+export function getConfigValue(config: ReactPressConfig, keyPath: string): string {
+ const parts = keyPath.split('.');
+ let cursor: unknown = config;
+ for (const part of parts) {
+ if (typeof cursor !== 'object' || cursor === null) {
+ throw new Error(`配置项不存在: ${keyPath}`);
+ }
+ cursor = (cursor as Record)[part];
+ }
+ if (cursor === undefined) {
+ throw new Error(`配置项不存在: ${keyPath}`);
+ }
+ return String(cursor);
+}
+
+export function listConfigKeys(obj: Record, prefix = ''): string[] {
+ const keys: string[] = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const fullPath = prefix ? `${prefix}.${key}` : key;
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ keys.push(...listConfigKeys(value as Record, fullPath));
+ } else {
+ keys.push(fullPath);
+ }
+ }
+ return keys;
+}
+
+export async function isReactPressProject(projectRoot: string): Promise {
+ const { configPath } = getProjectPaths(projectRoot);
+ return fs.pathExists(configPath);
+}
diff --git a/cli/src/core/services/database/index.ts b/cli/src/core/services/database/index.ts
new file mode 100644
index 00000000..d64da310
--- /dev/null
+++ b/cli/src/core/services/database/index.ts
@@ -0,0 +1,293 @@
+import fs from 'fs-extra';
+
+import type {
+ DatabaseEnsureResult,
+ MysqlCredentials,
+ ReactPressConfig,
+} from '../../../types/config';
+import { getDockerComposeCommand } from '../../utils/platform';
+import { getProjectPaths } from '../../utils/paths';
+import { findAvailablePort, isDockerPortBindError, isPortAvailable } from '../../utils/port';
+import { isDockerAvailable, runSync, sleep } from '../exec';
+import { isSqliteMode, loadConfig, loadEnvFile, saveConfig, syncEnvFromConfig } from '../config';
+import {
+ getDatabaseCredentials,
+ testMysqlConnection,
+ waitForMysql,
+} from './mysql';
+import { ensureSqliteDatabase, isSqliteReady } from './sqlite';
+
+export { getDatabaseCredentials, testMysqlConnection as testDatabaseConnection } from './mysql';
+export { ensureSqliteDatabase, probeSqliteDatabase, isSqliteReady } from './sqlite';
+export { resolveDatabaseProfile, requiresDocker, isLocalDatabaseMode } from './profile';
+
+const DEFAULT_DB_HOST_PORT = 3306;
+const EMBEDDED_DB_ROOT_PASSWORD = 'reactpress_root';
+
+export async function waitForDatabase(
+ creds: MysqlCredentials,
+ maxAttempts = 40,
+ intervalMs = 1000,
+): Promise {
+ return waitForMysql(creds, maxAttempts, intervalMs);
+}
+
+export function parseDockerPublishedPort(output: string): number | null {
+ for (const line of output.split('\n')) {
+ const match = line.trim().match(/:(\d+)\s*$/);
+ if (match) return Number(match[1]);
+ }
+ return null;
+}
+
+export async function getContainerPublishedHostPort(
+ containerName: string,
+ containerPort = 3306,
+): Promise {
+ if (!isDockerAvailable()) return null;
+ const result = runSync('docker', ['port', containerName, `${containerPort}/tcp`], {
+ silent: true,
+ });
+ if (!result.ok || !result.stdout.trim()) return null;
+ return parseDockerPublishedPort(result.stdout);
+}
+
+async function persistDatabaseHostPort(
+ projectRoot: string,
+ config: ReactPressConfig,
+ port: number,
+): Promise {
+ const { configPath, envPath } = getProjectPaths(projectRoot);
+ config.database.port = port;
+ if (await fs.pathExists(configPath)) {
+ await saveConfig(projectRoot, config);
+ }
+ if (await fs.pathExists(envPath)) {
+ await syncEnvFromConfig(projectRoot, config);
+ }
+}
+
+async function isContainerHealthy(containerName: string): Promise {
+ const result = runSync(
+ 'docker',
+ [
+ 'inspect',
+ '-f',
+ '{{if .State.Health}}{{.State.Health.Status}}{{else}}healthy{{end}}',
+ containerName,
+ ],
+ { silent: true },
+ );
+ if (!result.ok) return false;
+ return result.stdout.trim() === 'healthy';
+}
+
+async function buildConnectionFailureMessage(
+ projectRoot: string,
+ config: ReactPressConfig,
+ creds: MysqlCredentials,
+): Promise {
+ const containerName = config.database.containerName ?? 'reactpress_cli_db';
+ const published = await getContainerPublishedHostPort(containerName);
+ const port = published ?? creds.port;
+ const rootReachable = await testMysqlConnection({
+ host: creds.host,
+ port,
+ user: 'root',
+ password: EMBEDDED_DB_ROOT_PASSWORD,
+ database: creds.database,
+ });
+ const healthy = await isContainerHealthy(containerName);
+ if (rootReachable && healthy) {
+ return (
+ `数据库容器已在端口 ${port} 运行,但账号「${creds.user}」无法连接(数据卷中的凭证与 .env 不一致)。` +
+ ` 请在项目目录执行: cd .reactpress && docker compose down -v && cd .. && reactpress start`
+ );
+ }
+ return `数据库容器已启动,但连接超时。请执行 docker logs ${containerName} 查看详情。`;
+}
+
+export async function ensureDatabaseHostPort(
+ projectRoot: string,
+ forcePort?: number,
+ configOverride?: ReactPressConfig,
+): Promise<{ port: number; changed: boolean; previousPort: number }> {
+ const config = configOverride ?? (await loadConfig(projectRoot));
+ if (isSqliteMode(config)) {
+ const port = config.server.port ?? DEFAULT_DB_HOST_PORT;
+ return { port, changed: false, previousPort: port };
+ }
+ if (config.database.mode !== 'embedded-docker') {
+ const port = config.database.port ?? DEFAULT_DB_HOST_PORT;
+ return { port, changed: false, previousPort: port };
+ }
+
+ const { envPath } = getProjectPaths(projectRoot);
+ const existing = (await fs.pathExists(envPath)) ? await loadEnvFile(envPath) : {};
+ const currentPort = Number(existing.DB_PORT ?? config.database.port ?? DEFAULT_DB_HOST_PORT);
+ const containerName = config.database.containerName ?? 'reactpress_cli_db';
+ const containerPort = await getContainerPublishedHostPort(containerName);
+
+ if (containerPort !== null && !forcePort) {
+ if (containerPort !== currentPort) {
+ await persistDatabaseHostPort(projectRoot, config, containerPort);
+ return { port: containerPort, changed: true, previousPort: currentPort };
+ }
+ return { port: containerPort, changed: false, previousPort: currentPort };
+ }
+
+ if (!forcePort && (await isPortAvailable(currentPort))) {
+ return { port: currentPort, changed: false, previousPort: currentPort };
+ }
+
+ if (!forcePort && !(await isPortAvailable(currentPort))) {
+ const creds = await getDatabaseCredentials(projectRoot);
+ if (await testMysqlConnection({ ...creds, port: currentPort })) {
+ return { port: currentPort, changed: false, previousPort: currentPort };
+ }
+ }
+
+ let port: number;
+ if (forcePort && (await isPortAvailable(forcePort))) {
+ port = forcePort;
+ } else {
+ const start =
+ forcePort ??
+ (currentPort === DEFAULT_DB_HOST_PORT ? DEFAULT_DB_HOST_PORT + 1 : currentPort + 1);
+ port = await findAvailablePort(start);
+ }
+
+ if (currentPort === port && config.database.port === port) {
+ return { port, changed: false, previousPort: currentPort };
+ }
+
+ await persistDatabaseHostPort(projectRoot, config, port);
+ return { port, changed: true, previousPort: currentPort };
+}
+
+async function getDockerComposeEnv(projectRoot: string): Promise> {
+ const creds = await getDatabaseCredentials(projectRoot);
+ return {
+ DB_PORT: String(creds.port),
+ DB_USER: creds.user,
+ DB_PASSWD: creds.password,
+ DB_DATABASE: creds.database,
+ MYSQL_ROOT_PASSWORD: EMBEDDED_DB_ROOT_PASSWORD,
+ };
+}
+
+function runDockerCompose(
+ composeFile: string,
+ cwd: string,
+ subcommand: string[],
+ env: Record,
+) {
+ const composeV2 = runSync('docker', ['compose', 'version'], { silent: true });
+ if (composeV2.ok) {
+ return runSync('docker', ['compose', '-f', composeFile, ...subcommand], {
+ cwd,
+ silent: true,
+ env,
+ });
+ }
+ return runSync(getDockerComposeCommand(), ['-f', composeFile, ...subcommand], {
+ cwd,
+ silent: true,
+ env,
+ });
+}
+
+export async function startEmbeddedDatabase(
+ projectRoot: string,
+ config: ReactPressConfig,
+): Promise {
+ if (isSqliteMode(config)) {
+ return ensureSqliteDatabase(projectRoot);
+ }
+ if (config.database.mode !== 'embedded-docker') {
+ return { ok: true };
+ }
+ if (!isDockerAvailable()) {
+ return {
+ ok: false,
+ message:
+ '未检测到 Docker。请安装并启动 Docker,或将 database.mode 设为 external / embedded-sqlite。',
+ };
+ }
+
+ const { dockerComposePath, reactpressDir } = getProjectPaths(projectRoot);
+ const maxPortRetries = 5;
+
+ for (let attempt = 0; attempt < maxPortRetries; attempt++) {
+ const { port, changed, previousPort } = await ensureDatabaseHostPort(
+ projectRoot,
+ undefined,
+ config,
+ );
+ if (changed) {
+ console.warn(`[reactpress] 宿主机端口 ${previousPort} 已被占用,已改用 ${port}(已更新 .env)`);
+ }
+ const composeEnv = await getDockerComposeEnv(projectRoot);
+ const result = runDockerCompose(dockerComposePath, reactpressDir, ['up', '-d'], composeEnv);
+ if (result.ok) break;
+
+ const output = `${result.stderr}\n${result.stdout}`;
+ if (isDockerPortBindError(output) && attempt < maxPortRetries - 1) {
+ await ensureDatabaseHostPort(projectRoot, port + 1, config);
+ console.warn(`[reactpress] 端口 ${port} 绑定失败,正在尝试其他端口…`);
+ continue;
+ }
+ return { ok: false, message: `启动数据库容器失败: ${result.stderr || result.stdout}` };
+ }
+
+ const creds = await getDatabaseCredentials(projectRoot);
+ const ready = await waitForDatabase(creds);
+ if (!ready) {
+ return {
+ ok: false,
+ message: await buildConnectionFailureMessage(projectRoot, config, creds),
+ };
+ }
+ return { ok: true };
+}
+
+export async function stopEmbeddedDatabase(
+ projectRoot: string,
+ config: ReactPressConfig,
+): Promise {
+ if (config.database.mode !== 'embedded-docker') return;
+ if (!isDockerAvailable()) return;
+ const { dockerComposePath, reactpressDir } = getProjectPaths(projectRoot);
+ const composeEnv = await getDockerComposeEnv(projectRoot);
+ runDockerCompose(dockerComposePath, reactpressDir, ['down'], composeEnv);
+}
+
+export async function ensureDatabase(
+ projectRoot: string,
+ config: ReactPressConfig,
+): Promise {
+ if (isSqliteMode(config)) {
+ return ensureSqliteDatabase(projectRoot);
+ }
+
+ const creds = await getDatabaseCredentials(projectRoot);
+ if (await testMysqlConnection(creds)) {
+ return { ok: true };
+ }
+ if (config.database.mode === 'embedded-docker') {
+ return startEmbeddedDatabase(projectRoot, config);
+ }
+ return {
+ ok: false,
+ message: `无法连接数据库 ${creds.host}:${creds.port},请检查 .env 中的 DB_* 配置。`,
+ };
+}
+
+export async function isDatabaseReady(projectRoot: string): Promise {
+ const config = await loadConfig(projectRoot);
+ if (isSqliteMode(config)) {
+ return isSqliteReady(projectRoot);
+ }
+ const creds = await getDatabaseCredentials(projectRoot);
+ return testMysqlConnection(creds);
+}
diff --git a/cli/src/core/services/database/mysql.ts b/cli/src/core/services/database/mysql.ts
new file mode 100644
index 00000000..179b7cd7
--- /dev/null
+++ b/cli/src/core/services/database/mysql.ts
@@ -0,0 +1,92 @@
+import fs from 'fs-extra';
+import path from 'node:path';
+
+import type { DatabaseEnsureResult, MysqlCredentials } from '../../../types/config';
+
+export type ConnectionTester = (creds: MysqlCredentials) => Promise;
+
+async function defaultConnectionTester(creds: MysqlCredentials): Promise {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const mysql = require('mysql2/promise') as typeof import('mysql2/promise');
+ const connection = await mysql.createConnection({
+ host: creds.host,
+ port: creds.port,
+ user: creds.user,
+ password: creds.password,
+ database: creds.database,
+ connectTimeout: 5000,
+ });
+ await connection.query('SELECT 1');
+ await connection.end();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+let connectionTester: ConnectionTester = defaultConnectionTester;
+
+/** @internal test hook */
+export function setConnectionTesterForTests(tester: ConnectionTester | null): void {
+ connectionTester = tester ?? defaultConnectionTester;
+}
+
+export async function testMysqlConnection(creds: MysqlCredentials): Promise {
+ return connectionTester(creds);
+}
+
+export async function waitForMysql(
+ creds: MysqlCredentials,
+ maxAttempts = 40,
+ intervalMs = 1000,
+): Promise {
+ for (let i = 0; i < maxAttempts; i++) {
+ if (await testMysqlConnection(creds)) return true;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ return false;
+}
+
+import { loadEnvFile } from '../config';
+import { getProjectPaths } from '../../utils/paths';
+
+export async function getDatabaseCredentials(projectRoot: string): Promise {
+ const { envPath } = getProjectPaths(projectRoot);
+ if (!(await fs.pathExists(envPath))) {
+ return { ...DEFAULT_CREDS };
+ }
+ const env = await loadEnvFile(envPath);
+ return {
+ host: env.DB_HOST ?? DEFAULT_CREDS.host,
+ port: Number(env.DB_PORT ?? DEFAULT_CREDS.port),
+ user: env.DB_USER ?? DEFAULT_CREDS.user,
+ password: env.DB_PASSWD ?? DEFAULT_CREDS.password,
+ database: env.DB_DATABASE ?? DEFAULT_CREDS.database,
+ };
+}
+
+const DEFAULT_CREDS: MysqlCredentials = {
+ host: '127.0.0.1',
+ port: 3306,
+ user: 'reactpress',
+ password: 'reactpress',
+ database: 'reactpress',
+};
+
+export async function probeMysqlHost(
+ host: string,
+ port: number,
+ user: string,
+ password: string,
+ database: string,
+): Promise<{ ok: boolean; error?: string }> {
+ try {
+ const ok = await testMysqlConnection({ host, port, user, password, database });
+ return ok ? { ok: true } : { ok: false, error: 'connection refused' };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+export { DEFAULT_CREDS };
diff --git a/cli/src/core/services/database/profile.ts b/cli/src/core/services/database/profile.ts
new file mode 100644
index 00000000..6e3bcc5b
--- /dev/null
+++ b/cli/src/core/services/database/profile.ts
@@ -0,0 +1,58 @@
+import fs from 'fs-extra';
+import path from 'node:path';
+
+import type { DatabaseProfile, EnvMap, ReactPressConfig } from '../../../types/config';
+import { getProjectPaths } from '../../utils/paths';
+import { isSqliteMode, loadConfig, loadEnvFile } from '../config';
+
+const DEFAULT_MYSQL = {
+ host: '127.0.0.1',
+ port: 3306,
+ user: 'reactpress',
+ password: 'reactpress',
+ database: 'reactpress',
+} as const;
+
+export function resolveDatabaseType(config: ReactPressConfig, env: EnvMap): 'mysql' | 'sqlite' {
+ const envType = String(env.DB_TYPE || '').toLowerCase();
+ if (envType === 'sqlite') return 'sqlite';
+ if (isSqliteMode(config)) return 'sqlite';
+ return 'mysql';
+}
+
+export async function resolveDatabaseProfile(projectRoot: string): Promise {
+ const config = await loadConfig(projectRoot);
+ const { envPath, sqlitePath } = getProjectPaths(projectRoot);
+ const env = (await fs.pathExists(envPath)) ? await loadEnvFile(envPath) : {};
+ const type = resolveDatabaseType(config, env);
+
+ if (type === 'sqlite') {
+ const database =
+ env.DB_DATABASE ?? config.database.sqlitePath ?? sqlitePath;
+ return {
+ type: 'sqlite',
+ mode: config.database.mode,
+ sqlite: { database: path.resolve(projectRoot, database) },
+ };
+ }
+
+ return {
+ type: 'mysql',
+ mode: config.database.mode,
+ mysql: {
+ host: env.DB_HOST ?? config.database.host ?? DEFAULT_MYSQL.host,
+ port: Number(env.DB_PORT ?? config.database.port ?? DEFAULT_MYSQL.port),
+ user: env.DB_USER ?? config.database.user ?? DEFAULT_MYSQL.user,
+ password: env.DB_PASSWD ?? config.database.password ?? DEFAULT_MYSQL.password,
+ database: env.DB_DATABASE ?? config.database.database ?? DEFAULT_MYSQL.database,
+ },
+ };
+}
+
+export function isLocalDatabaseMode(config: ReactPressConfig): boolean {
+ return config.database.mode === 'embedded-sqlite';
+}
+
+export function requiresDocker(config: ReactPressConfig): boolean {
+ return config.database.mode === 'embedded-docker';
+}
diff --git a/cli/src/core/services/database/sqlite.ts b/cli/src/core/services/database/sqlite.ts
new file mode 100644
index 00000000..d091386c
--- /dev/null
+++ b/cli/src/core/services/database/sqlite.ts
@@ -0,0 +1,77 @@
+import fs from 'fs-extra';
+import path from 'node:path';
+
+import type { DatabaseEnsureResult, SqliteCredentials } from '../../../types/config';
+import { getProjectPaths } from '../../utils/paths';
+import { loadEnvFile } from '../config';
+
+export async function resolveSqlitePath(
+ projectRoot: string,
+ override?: string,
+): Promise {
+ const paths = getProjectPaths(projectRoot);
+ if (override) return path.resolve(projectRoot, override);
+ if (await fs.pathExists(paths.envPath)) {
+ const env = await loadEnvFile(paths.envPath);
+ if (env.DB_DATABASE) return path.resolve(projectRoot, env.DB_DATABASE);
+ }
+ return paths.sqlitePath;
+}
+
+export async function getSqliteCredentials(projectRoot: string): Promise {
+ const database = await resolveSqlitePath(projectRoot);
+ return { database };
+}
+
+export async function ensureSqliteDatabase(projectRoot: string): Promise {
+ const database = await resolveSqlitePath(projectRoot);
+ const dir = path.dirname(database);
+ await fs.ensureDir(dir);
+
+ try {
+ await fs.access(dir, fs.constants.W_OK);
+ } catch {
+ return { ok: false, message: `SQLite 数据目录不可写: ${dir}` };
+ }
+
+ if (!(await fs.pathExists(database))) {
+ await fs.writeFile(database, Buffer.alloc(0));
+ }
+
+ return { ok: true };
+}
+
+export async function probeSqliteDatabase(
+ projectRoot: string,
+): Promise<{ ok: boolean; message?: string }> {
+ const database = await resolveSqlitePath(projectRoot);
+ const dir = path.dirname(database);
+
+ if (!(await fs.pathExists(dir))) {
+ return { ok: false, message: `SQLite 目录不存在: ${dir}` };
+ }
+
+ try {
+ await fs.access(dir, fs.constants.W_OK);
+ } catch {
+ return { ok: false, message: `SQLite 目录不可写: ${dir}` };
+ }
+
+ if (await fs.pathExists(database)) {
+ const stat = await fs.stat(database);
+ return {
+ ok: true,
+ message: `SQLite ${database} (${stat.size} bytes)`,
+ };
+ }
+
+ return {
+ ok: true,
+ message: `SQLite 将在启动时创建: ${database}`,
+ };
+}
+
+export async function isSqliteReady(projectRoot: string): Promise {
+ const result = await ensureSqliteDatabase(projectRoot);
+ return result.ok;
+}
diff --git a/cli/src/core/services/exec.ts b/cli/src/core/services/exec.ts
new file mode 100644
index 00000000..deb69a54
--- /dev/null
+++ b/cli/src/core/services/exec.ts
@@ -0,0 +1,70 @@
+import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
+import crossSpawn from 'cross-spawn';
+
+import { isWindows } from '../utils/platform';
+
+export interface RunSyncResult {
+ ok: boolean;
+ stdout: string;
+ stderr: string;
+ code: number | null;
+}
+
+export function runSync(
+ command: string,
+ args: string[],
+ options: {
+ cwd?: string;
+ env?: NodeJS.ProcessEnv;
+ silent?: boolean;
+ } = {},
+): RunSyncResult {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd,
+ env: { ...process.env, ...options.env },
+ encoding: 'utf8',
+ shell: isWindows(),
+ stdio: options.silent ? 'pipe' : 'inherit',
+ });
+ return {
+ ok: result.status === 0,
+ stdout: (result.stdout ?? '').toString(),
+ stderr: (result.stderr ?? '').toString(),
+ code: result.status,
+ };
+}
+
+export function spawnDetached(
+ command: string,
+ args: string[],
+ options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
+): ChildProcess {
+ return crossSpawn(command, args, {
+ cwd: options.cwd,
+ env: { ...process.env, ...options.env },
+ detached: !isWindows(),
+ stdio: 'ignore',
+ shell: isWindows(),
+ });
+}
+
+export function isCommandAvailable(command: string): boolean {
+ const checkCmd = isWindows() ? 'where' : 'which';
+ const result = spawnSync(checkCmd, [command], {
+ encoding: 'utf8',
+ shell: isWindows(),
+ stdio: 'pipe',
+ });
+ return result.status === 0;
+}
+
+export function isDockerAvailable(): boolean {
+ const result = runSync('docker', ['info'], { silent: true });
+ return result.ok;
+}
+
+export async function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export let spawnFn = spawn;
diff --git a/cli/src/core/services/init.ts b/cli/src/core/services/init.ts
new file mode 100644
index 00000000..79a373d0
--- /dev/null
+++ b/cli/src/core/services/init.ts
@@ -0,0 +1,76 @@
+import fs from 'fs-extra';
+import { join } from 'node:path';
+
+import type { ReactPressConfig } from '../../types/config';
+import { getProjectPaths, getTemplatesDir } from '../utils/paths';
+import { saveConfig, syncEnvFromConfig } from './config';
+import { ensureDatabase, ensureDatabaseHostPort } from './database';
+import { initLocalProject } from './local-site';
+
+export interface InitProjectOptions {
+ directory: string;
+ force?: boolean;
+ local?: boolean;
+}
+
+export async function initProject(
+ options: InitProjectOptions,
+): Promise<{ ok: boolean; projectRoot: string; message: string }> {
+ if (options.local) {
+ return initLocalProject(options.directory, { force: options.force });
+ }
+
+ const projectRoot = options.directory;
+ const paths = getProjectPaths(projectRoot);
+
+ if ((await fs.pathExists(paths.configPath)) && !options.force) {
+ return {
+ ok: false,
+ projectRoot,
+ message: '目录已是 ReactPress 项目。使用 --force 覆盖配置。',
+ };
+ }
+
+ await fs.ensureDir(projectRoot);
+ await fs.ensureDir(paths.reactpressDir);
+ const templatesDir = getTemplatesDir();
+
+ await copyTemplate(join(templatesDir, 'docker-compose.yml'), paths.dockerComposePath);
+ await copyTemplate(join(templatesDir, 'package.json'), join(projectRoot, 'package.json'));
+
+ const config = (await fs.readJson(
+ join(templatesDir, 'config.default.json'),
+ )) as ReactPressConfig;
+ await saveConfig(projectRoot, config);
+ await syncEnvFromConfig(projectRoot, config);
+
+ if (!(await fs.pathExists(paths.envPath)) || options.force) {
+ const envTemplate = await fs.readFile(join(templatesDir, 'env.default'), 'utf8');
+ await fs.writeFile(paths.envPath, envTemplate, 'utf8');
+ await syncEnvFromConfig(projectRoot, config);
+ }
+
+ await ensureDatabaseHostPort(projectRoot, undefined, config);
+ const dbResult = await ensureDatabase(projectRoot, config);
+
+ if (!dbResult.ok) {
+ return {
+ ok: true,
+ projectRoot,
+ message: `项目已创建,但数据库未就绪: ${dbResult.message}。可稍后运行 reactpress dev。`,
+ };
+ }
+
+ return {
+ ok: true,
+ projectRoot,
+ message: 'ReactPress 项目初始化完成。运行 reactpress dev 启动服务。',
+ };
+}
+
+async function copyTemplate(src: string, dest: string): Promise {
+ if (!(await fs.pathExists(src))) {
+ throw new Error(`模板文件缺失: ${src}`);
+ }
+ await fs.copy(src, dest, { overwrite: true });
+}
diff --git a/cli/src/core/services/local-site.ts b/cli/src/core/services/local-site.ts
new file mode 100644
index 00000000..c3084300
--- /dev/null
+++ b/cli/src/core/services/local-site.ts
@@ -0,0 +1,181 @@
+import fs from 'fs-extra';
+import path from 'node:path';
+
+import type { ReactPressConfig } from '../../types/config';
+import { getProjectPaths } from '../utils/paths';
+import { saveConfig, syncEnvFromConfig } from '../services/config';
+
+export interface LocalSitePaths {
+ siteRoot: string;
+ dataDir: string;
+ uploadsDir: string;
+ dbPath: string;
+ envPath: string;
+ reactpressDir: string;
+}
+
+export function getLocalSitePaths(siteRoot: string): LocalSitePaths {
+ const dataDir = path.join(siteRoot, 'data');
+ return {
+ siteRoot,
+ dataDir,
+ uploadsDir: path.join(siteRoot, 'uploads'),
+ dbPath: path.join(dataDir, 'reactpress.db'),
+ envPath: path.join(siteRoot, '.env'),
+ reactpressDir: path.join(siteRoot, '.reactpress'),
+ };
+}
+
+export interface EnsureLocalSiteOptions {
+ monorepoRoot?: string;
+ adminUser?: string;
+ adminPassword?: string;
+}
+
+export function ensureLocalSite(
+ siteRoot: string,
+ port: number,
+ options: EnsureLocalSiteOptions = {},
+): LocalSitePaths {
+ const paths = getLocalSitePaths(siteRoot);
+ fs.mkdirSync(paths.dataDir, { recursive: true });
+ fs.mkdirSync(paths.uploadsDir, { recursive: true });
+ fs.mkdirSync(paths.reactpressDir, { recursive: true });
+
+ const siteUrl = `http://127.0.0.1:${port}`;
+ const clientSiteUrl = 'http://localhost:3001';
+ const envLines = [
+ 'DB_TYPE=sqlite',
+ `DB_DATABASE=${paths.dbPath}`,
+ `SERVER_PORT=${port}`,
+ `SERVER_SITE_URL=${siteUrl}`,
+ `CLIENT_SITE_URL=${clientSiteUrl}`,
+ 'SERVER_API_PREFIX=/api',
+ `REACTPRESS_UPLOAD_DIR=${paths.uploadsDir}`,
+ `ADMIN_USER=${options.adminUser ?? 'admin'}`,
+ `ADMIN_PASSWD=${options.adminPassword ?? 'admin'}`,
+ `REACTPRESS_LANG=${process.env.REACTPRESS_LANG ?? 'zh'}`,
+ '',
+ ];
+ fs.writeFileSync(paths.envPath, envLines.join('\n'), 'utf8');
+
+ if (options.monorepoRoot) {
+ seedBundledAssets(siteRoot, options.monorepoRoot);
+ }
+
+ const configPath = path.join(paths.reactpressDir, 'config.json');
+ if (!fs.existsSync(configPath)) {
+ const config: ReactPressConfig = {
+ version: 1,
+ database: { mode: 'embedded-sqlite', sqlitePath: paths.dbPath },
+ server: {
+ port,
+ apiPrefix: '/api',
+ siteUrl,
+ clientUrl: clientSiteUrl,
+ serverUrl: siteUrl,
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
+ }
+
+ return paths;
+}
+
+function seedBundledAssets(siteRoot: string, monorepoRoot: string): void {
+ seedSymlinkRegistry(
+ path.join(monorepoRoot, 'plugins'),
+ path.join(siteRoot, 'plugins'),
+ 'local',
+ );
+ seedSymlinkRegistry(
+ path.join(monorepoRoot, 'themes'),
+ path.join(siteRoot, 'themes'),
+ 'local',
+ 'npm',
+ );
+ seedRuntimeThemes(siteRoot, monorepoRoot);
+}
+
+function seedSymlinkRegistry(
+ sourceDir: string,
+ targetDir: string,
+ ...registryKeys: string[]
+): void {
+ const sourcePackageJson = path.join(sourceDir, 'package.json');
+ const targetPackageJson = path.join(targetDir, 'package.json');
+ if (!fs.existsSync(sourcePackageJson)) return;
+
+ fs.mkdirSync(targetDir, { recursive: true });
+ if (!fs.existsSync(targetPackageJson)) {
+ fs.copyFileSync(sourcePackageJson, targetPackageJson);
+ }
+
+ let meta: { reactpress?: Record } = {};
+ try {
+ meta = JSON.parse(fs.readFileSync(targetPackageJson, 'utf8')) as typeof meta;
+ } catch {
+ return;
+ }
+
+ for (const key of registryKeys) {
+ const ids = Array.isArray(meta.reactpress?.[key]) ? meta.reactpress[key] : [];
+ for (const id of ids) {
+ if (typeof id !== 'string' || !id.trim()) continue;
+ const sourcePath = path.join(sourceDir, id.trim());
+ const targetPath = path.join(targetDir, id.trim());
+ if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) continue;
+ fs.symlinkSync(sourcePath, targetPath, 'dir');
+ }
+ }
+}
+
+function seedRuntimeThemes(siteRoot: string, monorepoRoot: string): void {
+ const sourceRuntime = path.join(monorepoRoot, '.reactpress', 'runtime');
+ const targetRuntime = path.join(siteRoot, '.reactpress', 'runtime');
+ if (!fs.existsSync(sourceRuntime)) return;
+
+ fs.mkdirSync(path.join(siteRoot, '.reactpress'), { recursive: true });
+
+ for (const entry of fs.readdirSync(sourceRuntime, { withFileTypes: true })) {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
+ const sourcePath = path.join(sourceRuntime, entry.name);
+ const targetPath = path.join(targetRuntime, entry.name);
+ if (fs.existsSync(targetPath)) continue;
+ try {
+ if (!fs.statSync(sourcePath).isDirectory()) continue;
+ fs.mkdirSync(targetRuntime, { recursive: true });
+ fs.symlinkSync(sourcePath, targetPath, 'dir');
+ } catch {
+ // skip broken entries
+ }
+ }
+}
+
+export async function initLocalProject(
+ projectRoot: string,
+ options: { force?: boolean; port?: number } = {},
+): Promise<{ ok: boolean; projectRoot: string; message: string }> {
+ const paths = getProjectPaths(projectRoot);
+ const port = options.port ?? 3002;
+
+ if ((await fs.pathExists(paths.configPath)) && !options.force) {
+ return {
+ ok: false,
+ projectRoot,
+ message: '目录已是 ReactPress 项目。使用 --force 覆盖配置,或 --local 初始化 SQLite 本地模式。',
+ };
+ }
+
+ await fs.ensureDir(projectRoot);
+ ensureLocalSite(projectRoot, port);
+ const config = (await fs.readJson(paths.configPath)) as ReactPressConfig;
+ await saveConfig(projectRoot, config);
+ await syncEnvFromConfig(projectRoot, config);
+
+ return {
+ ok: true,
+ projectRoot,
+ message: 'ReactPress 本地项目(SQLite)初始化完成。运行 reactpress dev --local 启动。',
+ };
+}
diff --git a/cli/src/core/utils/cli-context.ts b/cli/src/core/utils/cli-context.ts
new file mode 100644
index 00000000..1f171268
--- /dev/null
+++ b/cli/src/core/utils/cli-context.ts
@@ -0,0 +1,9 @@
+let projectCwd: string | undefined;
+
+export function setProjectCwd(cwd?: string): void {
+ projectCwd = cwd ? cwd : undefined;
+}
+
+export function getProjectCwd(): string {
+ return projectCwd ?? process.cwd();
+}
diff --git a/cli/src/core/utils/paths.ts b/cli/src/core/utils/paths.ts
new file mode 100644
index 00000000..3796e1e8
--- /dev/null
+++ b/cli/src/core/utils/paths.ts
@@ -0,0 +1,43 @@
+import { join } from 'node:path';
+
+export const CONFIG_DIR = '.reactpress';
+export const CONFIG_FILE = 'config.json';
+export const PID_FILE = 'server.pid';
+export const ENV_FILE = '.env';
+
+/** CLI 包根目录(编译后位于 out/core/utils → ../../../) */
+export function getPackageRoot(): string {
+ return join(__dirname, '..', '..', '..');
+}
+
+export function getTemplatesDir(): string {
+ return join(getPackageRoot(), 'templates');
+}
+
+export interface ProjectPaths {
+ projectRoot: string;
+ reactpressDir: string;
+ configPath: string;
+ pidPath: string;
+ envPath: string;
+ dockerComposePath: string;
+ dbDataDir: string;
+ sqlitePath: string;
+ uploadsDir: string;
+}
+
+export function getProjectPaths(projectRoot: string): ProjectPaths {
+ const reactpressDir = join(projectRoot, CONFIG_DIR);
+ const dataDir = join(projectRoot, 'data');
+ return {
+ projectRoot,
+ reactpressDir,
+ configPath: join(reactpressDir, CONFIG_FILE),
+ pidPath: join(reactpressDir, PID_FILE),
+ envPath: join(projectRoot, ENV_FILE),
+ dockerComposePath: join(reactpressDir, 'docker-compose.yml'),
+ dbDataDir: join(reactpressDir, 'data'),
+ sqlitePath: join(dataDir, 'reactpress.db'),
+ uploadsDir: join(projectRoot, 'uploads'),
+ };
+}
diff --git a/cli/src/core/utils/platform.ts b/cli/src/core/utils/platform.ts
new file mode 100644
index 00000000..e044bfd9
--- /dev/null
+++ b/cli/src/core/utils/platform.ts
@@ -0,0 +1,25 @@
+import { platform } from 'node:os';
+
+export function isWindows(): boolean {
+ return platform() === 'win32';
+}
+
+export function isMac(): boolean {
+ return platform() === 'darwin';
+}
+
+export function getDockerComposeCommand(): string {
+ return isWindows() ? 'docker-compose.exe' : 'docker-compose';
+}
+
+export function getNpmCommand(): string {
+ return isWindows() ? 'npm.cmd' : 'npm';
+}
+
+export function getNpxCommand(): string {
+ return isWindows() ? 'npx.cmd' : 'npx';
+}
+
+export function getNodeCommand(): string {
+ return process.execPath;
+}
diff --git a/cli/src/core/utils/port.ts b/cli/src/core/utils/port.ts
new file mode 100644
index 00000000..ed26b07c
--- /dev/null
+++ b/cli/src/core/utils/port.ts
@@ -0,0 +1,30 @@
+import net from 'node:net';
+
+const DEFAULT_MAX_ATTEMPTS = 100;
+
+export function isPortAvailable(port: number, host = '0.0.0.0'): Promise {
+ return new Promise((resolve) => {
+ const server = net.createServer();
+ server.once('error', () => resolve(false));
+ server.once('listening', () => {
+ server.close(() => resolve(true));
+ });
+ server.listen(port, host);
+ });
+}
+
+export async function findAvailablePort(
+ startPort: number,
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
+): Promise {
+ for (let port = startPort; port < startPort + maxAttempts; port++) {
+ if (await isPortAvailable(port)) {
+ return port;
+ }
+ }
+ throw new Error(`在 ${startPort}-${startPort + maxAttempts - 1} 范围内未找到可用端口`);
+}
+
+export function isDockerPortBindError(output: string): boolean {
+ return /port is already allocated|address already in use/i.test(output);
+}
diff --git a/cli/src/lib/api-dev-runner.ts b/cli/src/lib/api-dev-runner.ts
new file mode 100644
index 00000000..4caed570
--- /dev/null
+++ b/cli/src/lib/api-dev-runner.ts
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+// @ts-nocheck
+const { runApiDev } = require('./api-dev');
+const { ensureOriginalCwd } = require('./root');
+
+ensureOriginalCwd();
+runApiDev();
diff --git a/cli/src/lib/api-dev.ts b/cli/src/lib/api-dev.ts
new file mode 100644
index 00000000..479ed367
--- /dev/null
+++ b/cli/src/lib/api-dev.ts
@@ -0,0 +1,111 @@
+// @ts-nocheck
+const { spawn } = require('child_process');
+const path = require('path');
+const { ensureProjectEnvironment } = require('./bootstrap');
+const {
+ getServerBin,
+ getServerDir,
+ isUsingMonorepoServer,
+ canStartLocalApi,
+} = require('./paths');
+const { stopApi } = require('./lifecycle');
+const { ensureOriginalCwd } = require('./root');
+const { t } = require('./i18n');
+const { ensureApiPortFree } = require('./ports');
+const { ensureDevDatabase } = require('./docker');
+
+let apiChild;
+
+function stopApiDev(projectRoot) {
+ if (apiChild && !apiChild.killed) {
+ apiChild.kill('SIGTERM');
+ }
+ if (!isUsingMonorepoServer(projectRoot)) {
+ stopApi(projectRoot);
+ }
+}
+
+function startApiDev(projectRoot) {
+ if (isUsingMonorepoServer(projectRoot)) {
+ console.log(t('apiDev.modeServer'));
+ apiChild = spawn('pnpm', ['run', '--dir', './server', 'dev'], {
+ cwd: projectRoot,
+ stdio: 'inherit',
+ shell: true,
+ env: {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ },
+ });
+ } else if (canStartLocalApi(projectRoot)) {
+ console.log(t('apiDev.modeBundled'));
+ apiChild = spawn(process.execPath, [getServerBin(projectRoot)], {
+ cwd: getServerDir(projectRoot),
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ },
+ });
+ } else {
+ console.error(t('lifecycle.noServerAvailable'));
+ process.exit(1);
+ }
+
+ if (apiChild) {
+ apiChild.on('close', (code) => {
+ process.exit(code ?? 0);
+ });
+ console.log(t('apiDev.ctrlCHint'));
+ console.log(t('apiDev.stopHint'));
+ }
+}
+
+async function runApiDev(projectRoot = ensureOriginalCwd()) {
+ const skipEnvBootstrap = process.env.REACTPRESS_DEV_DB_READY === '1';
+
+ if (!skipEnvBootstrap) {
+ try {
+ await ensureProjectEnvironment(projectRoot);
+ } catch (err) {
+ console.error(t('dev.envFailed'), err.message || err);
+ process.exit(1);
+ }
+
+ try {
+ await ensureDevDatabase(projectRoot);
+ } catch (err) {
+ console.error(t('dev.dbEnsureFailed', { message: err.message || err }));
+ process.exit(1);
+ }
+ }
+
+ process.on('SIGINT', () => {
+ stopApiDev(projectRoot);
+ process.exit(0);
+ });
+ process.on('SIGTERM', () => {
+ stopApiDev(projectRoot);
+ process.exit(0);
+ });
+
+ if (process.env.REACTPRESS_DEV_PORTS_READY !== '1') {
+ const { reused, port: apiPort } = await ensureApiPortFree(projectRoot);
+ if (reused) {
+ console.log(t('dev.apiReusing', { port: apiPort }));
+ return;
+ }
+ }
+
+ startApiDev(projectRoot);
+}
+
+function getApiDevScriptPath() {
+ return path.join(__dirname, 'api-dev-runner.js');
+}
+
+module.exports = {
+ runApiDev,
+ stopApiDev,
+ getApiDevScriptPath,
+};
diff --git a/cli/src/lib/bootstrap.ts b/cli/src/lib/bootstrap.ts
new file mode 100644
index 00000000..6daabc6c
--- /dev/null
+++ b/cli/src/lib/bootstrap.ts
@@ -0,0 +1,115 @@
+// @ts-nocheck
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { setProjectCwd } from '../core/utils/cli-context';
+import { saveConfig, syncEnvFromConfig, loadConfig, isReactPressProject } from '../core/services/config';
+import { ensureDatabase, ensureDatabaseHostPort } from '../core/services/database';
+import { initProject } from '../core/services/init';
+import { getProjectPaths, getTemplatesDir } from '../core/utils/paths';
+import { ensureOriginalCwd, isMonorepoCheckout } from './root';
+import { t } from './i18n';
+
+async function copyTemplateFile(src: string, dest: string): Promise {
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+ await fs.promises.copyFile(src, dest);
+}
+
+export async function initMonorepoProject(
+ projectRoot: string,
+ { force = false, local = false }: { force?: boolean; local?: boolean } = {},
+): Promise<{ ok: boolean; projectRoot: string; message: string }> {
+ if (local) {
+ return initProject({ directory: projectRoot, force, local: true });
+ }
+
+ const paths = getProjectPaths(projectRoot);
+ const templatesDir = getTemplatesDir();
+
+ if (fs.existsSync(paths.configPath) && !force) {
+ const config = await loadConfig(projectRoot);
+ await ensureDatabaseHostPort(projectRoot, undefined, config);
+ const dbResult = await ensureDatabase(projectRoot, config);
+ if (!dbResult.ok) {
+ return { ok: false, projectRoot, message: dbResult.message ?? t('bootstrap.dbPendingShort') };
+ }
+ return { ok: true, projectRoot, message: t('bootstrap.configReady') };
+ }
+
+ await fs.promises.mkdir(paths.reactpressDir, { recursive: true });
+ await copyTemplateFile(
+ path.join(templatesDir, 'docker-compose.yml'),
+ paths.dockerComposePath,
+ );
+
+ const config = JSON.parse(
+ await fs.promises.readFile(path.join(templatesDir, 'config.default.json'), 'utf8'),
+ );
+ await saveConfig(projectRoot, config);
+ await syncEnvFromConfig(projectRoot, config);
+
+ if (!fs.existsSync(paths.envPath) || force) {
+ await copyTemplateFile(path.join(templatesDir, 'env.default'), paths.envPath);
+ await syncEnvFromConfig(projectRoot, config);
+ }
+
+ await ensureDatabaseHostPort(projectRoot, undefined, config);
+ const dbResult = await ensureDatabase(projectRoot, config);
+
+ if (!dbResult.ok) {
+ return {
+ ok: true,
+ projectRoot,
+ message: t('bootstrap.projectDbPending', { message: dbResult.message ?? '' }),
+ };
+ }
+
+ return {
+ ok: true,
+ projectRoot,
+ message: t('bootstrap.ready'),
+ };
+}
+
+export async function ensureProjectEnvironment(
+ projectRoot = ensureOriginalCwd(),
+ options: { skipDatabase?: boolean } = {},
+): Promise<{ ok: boolean; projectRoot: string; message: string | null }> {
+ const root = path.resolve(projectRoot);
+ setProjectCwd(root);
+
+ if (!(await isReactPressProject(root))) {
+ if (isMonorepoCheckout(root)) {
+ const result = await initMonorepoProject(root);
+ if (!result.ok) {
+ throw new Error(result.message || t('bootstrap.initFailed'));
+ }
+ return result;
+ }
+
+ const result = await initProject({ directory: root, force: false });
+ if (!result.ok) {
+ throw new Error(result.message || t('bootstrap.cliInitFailed'));
+ }
+ return result;
+ }
+
+ const config = await loadConfig(root);
+ if (options.skipDatabase) {
+ return { ok: true, projectRoot: root, message: null };
+ }
+
+ await ensureDatabaseHostPort(root, undefined, config);
+ const dbResult = await ensureDatabase(root, config);
+ if (!dbResult.ok) {
+ throw new Error(
+ t('bootstrap.dbNotReady', {
+ message: dbResult.message || t('bootstrap.dbPendingShort'),
+ }),
+ );
+ }
+
+ return { ok: true, projectRoot: root, message: t('bootstrap.dbReady') };
+}
+
+export { isMonorepoCheckout };
diff --git a/cli/src/lib/build.ts b/cli/src/lib/build.ts
new file mode 100644
index 00000000..3042ebb7
--- /dev/null
+++ b/cli/src/lib/build.ts
@@ -0,0 +1,225 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const ora = require('ora');
+const { brand, icon, ok, warn, label, chip } = require('../ui/theme');
+const { runSync } = require('./spawn');
+const { ensureOriginalCwd } = require('./root');
+const { hasWeb } = require('./project-type');
+const { t } = require('./i18n');
+const { shouldBuildToolkit } = require('./toolkit-build');
+const { hasUsableProductionBuild, readActiveThemeBuildState } = require('./theme-prod');
+const { resolveBuildNodeEnv } = require('./prod-memory');
+
+const FORBIDDEN_SCRIPTS = new Set(['build']);
+
+/** @type {Record} */
+const BUILD_STEPS = {
+ toolkit: [{ script: 'build:toolkit', labelKey: 'build.label.toolkit' }],
+ plugins: [{ script: 'build:plugins', labelKey: 'build.label.plugins' }],
+ server: [{ script: 'build:server', labelKey: 'build.label.server' }],
+ web: [{ script: 'build:web', labelKey: 'build.label.web' }],
+ theme: [{ script: 'build:theme', labelKey: 'build.label.theme' }],
+ docs: [{ script: 'build:docs', labelKey: 'build.label.docs' }],
+};
+
+const TARGETS = [...Object.keys(BUILD_STEPS), 'all'];
+
+function getBuildSteps(target, projectRoot) {
+ if (target !== 'all') {
+ return BUILD_STEPS[target];
+ }
+
+ const steps = [
+ { script: 'build:toolkit', labelKey: 'build.label.toolkit' },
+ { script: 'build:plugins', labelKey: 'build.label.plugins' },
+ { script: 'build:server', labelKey: 'build.label.server' },
+ ];
+ if (hasWeb(projectRoot)) {
+ steps.push({ script: 'build:web', labelKey: 'build.label.web' });
+ }
+ steps.push({ script: 'build:theme', labelKey: 'build.label.theme' });
+ return steps;
+}
+
+const buildChildEnv = resolveBuildNodeEnv({ REACTPRESS_BUILD_ACTIVE: '1' });
+
+function readPackageScripts(packageJsonPath) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+ return pkg.scripts || {};
+ } catch {
+ return {};
+ }
+}
+
+/** Prefer workspace package scripts over root package.json aliases. */
+function resolveBuildInvocation(script, projectRoot) {
+ const root = path.resolve(projectRoot);
+
+ if (script === 'build:toolkit') {
+ const toolkitDir = path.join(root, 'toolkit');
+ if (fs.existsSync(path.join(toolkitDir, 'package.json'))) {
+ return { command: 'pnpm', args: ['run', 'build'], cwd: toolkitDir };
+ }
+ const rootScripts = readPackageScripts(path.join(root, 'package.json'));
+ if (rootScripts['build:toolkit']) {
+ return { command: 'pnpm', args: ['run', 'build:toolkit'], cwd: root };
+ }
+ return null;
+ }
+
+ if (script === 'build:server') {
+ const serverDir = path.join(root, 'server');
+ if (fs.existsSync(path.join(serverDir, 'package.json'))) {
+ return { command: 'pnpm', args: ['run', 'build'], cwd: serverDir };
+ }
+ }
+
+ if (script === 'build:plugins') {
+ const pluginsDir = path.join(root, 'plugins');
+ if (fs.existsSync(path.join(pluginsDir, 'package.json'))) {
+ return { command: 'pnpm', args: ['run', 'build'], cwd: pluginsDir };
+ }
+ const rootScripts = readPackageScripts(path.join(root, 'package.json'));
+ if (rootScripts['build:plugins']) {
+ return { command: 'pnpm', args: ['run', 'build:plugins'], cwd: root };
+ }
+ }
+
+ if (script === 'build:web') {
+ const webDir = path.join(root, 'web');
+ if (fs.existsSync(path.join(webDir, 'package.json'))) {
+ return { command: 'pnpm', args: ['run', 'build'], cwd: webDir };
+ }
+ const rootScripts = readPackageScripts(path.join(root, 'package.json'));
+ if (rootScripts['build:web']) {
+ return { command: 'pnpm', args: ['run', 'build:web'], cwd: root };
+ }
+ return null;
+ }
+
+ if (script === 'build:theme') {
+ const { readActiveThemeManifest, resolveThemeDirectory } = require('./theme-runtime');
+ const { activeTheme } = readActiveThemeManifest(root);
+ const themeDir = resolveThemeDirectory(root, activeTheme);
+ if (themeDir && fs.existsSync(path.join(themeDir, 'package.json'))) {
+ return { command: 'pnpm', args: ['run', 'build'], cwd: themeDir };
+ }
+ }
+
+ if (script === 'build:docs') {
+ const docsDir = path.join(root, 'docs');
+ if (fs.existsSync(path.join(docsDir, 'package.json'))) {
+ return { command: 'pnpm', args: ['run', 'build'], cwd: docsDir };
+ }
+ }
+
+ const rootScripts = readPackageScripts(path.join(root, 'package.json'));
+ if (rootScripts[script]) {
+ return { command: 'pnpm', args: ['run', script], cwd: root };
+ }
+
+ return null;
+}
+
+function stepBadge(current, total) {
+ return chip(`${current}/${total}`, brand.primary);
+}
+
+async function runBuild(target = 'all', projectRoot = ensureOriginalCwd()) {
+ if (process.env.REACTPRESS_BUILD_ACTIVE === '1') {
+ throw new Error(t('build.recursive'));
+ }
+
+ const steps = getBuildSteps(target, projectRoot);
+ if (!steps) {
+ throw new Error(
+ t('build.unknownTarget', {
+ target,
+ available: TARGETS.join(', '),
+ })
+ );
+ }
+
+ const total = steps.length;
+ const buildStarted = Date.now();
+
+ console.log('');
+ if (total > 1) {
+ console.log(label(t('build.plan', { total })));
+ console.log('');
+ }
+
+ for (let i = 0; i < steps.length; i++) {
+ const { script, labelKey } = steps[i];
+ if (FORBIDDEN_SCRIPTS.has(script)) {
+ throw new Error(t('build.forbiddenScript', { script }));
+ }
+
+ const current = i + 1;
+ const stepLabel = t(labelKey);
+ const stepStarted = Date.now();
+ const badge = stepBadge(current, total);
+
+ if (script === 'build:toolkit' && !shouldBuildToolkit(projectRoot)) {
+ console.log(` ${badge} ${ok(t('build.stepSkippedFresh', { label: stepLabel }))}`);
+ continue;
+ }
+
+ if (script === 'build:theme') {
+ const themeState = readActiveThemeBuildState(projectRoot);
+ if (
+ themeState &&
+ hasUsableProductionBuild(themeState.themeDir, themeState.activeTheme)
+ ) {
+ console.log(
+ ` ${badge} ${ok(t('build.stepSkippedReuse', { label: stepLabel, id: themeState.activeTheme }))}`,
+ );
+ continue;
+ }
+ }
+
+ const invocation = resolveBuildInvocation(script, projectRoot);
+ if (!invocation) {
+ console.log(` ${badge} ${warn(t('build.stepSkipped', { label: stepLabel }))}`);
+ continue;
+ }
+
+ const spinner = ora({
+ text: `${badge} ${t('build.step', { current, total, label: stepLabel })}`,
+ color: 'magenta',
+ spinner: 'dots',
+ }).start();
+
+ try {
+ runSync(invocation.command, invocation.args, {
+ cwd: invocation.cwd,
+ env: buildChildEnv,
+ });
+ } catch (err) {
+ spinner.fail(`${badge} ${t('build.stepFailed', { current, total, label: stepLabel })}`);
+ throw err;
+ }
+
+ const seconds = ((Date.now() - stepStarted) / 1000).toFixed(1);
+ spinner.succeed(
+ `${badge} ${ok(t('build.stepDone', { current, total, label: stepLabel, seconds }))}`
+ );
+ }
+
+ if (total > 1) {
+ const totalSeconds = ((Date.now() - buildStarted) / 1000).toFixed(1);
+ console.log('');
+ console.log(` ${icon.spark} ${ok(t('build.done', { seconds: totalSeconds }))}`);
+ }
+ console.log('');
+}
+
+module.exports = {
+ runBuild,
+ TARGETS,
+ BUILD_STEPS,
+ getBuildSteps,
+ resolveBuildInvocation,
+};
diff --git a/cli/src/lib/db-backup.ts b/cli/src/lib/db-backup.ts
new file mode 100644
index 00000000..89433e2a
--- /dev/null
+++ b/cli/src/lib/db-backup.ts
@@ -0,0 +1,68 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+const chalk = require('chalk');
+const { t } = require('./i18n');
+const { mysqldumpFromDbContainer } = require('./docker');
+
+function isLocalDbHost(host) {
+ const h = String(host || '').toLowerCase();
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '';
+}
+
+function isMysqldumpNotFoundError(err) {
+ const msg = `${err && err.message ? err.message : ''}\n${err && err.stderr ? err.stderr : ''}`;
+ if (err && err.status === 127) return true;
+ return /command not found|not recognized as an internal or external command/i.test(msg);
+}
+
+function parseEnv(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ const out = {};
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ for (const line of content.split('\n')) {
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
+ if (m) out[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return out;
+}
+
+async function runDbBackup(projectRoot, outputPath) {
+ const env = parseEnv(projectRoot);
+ const host = env.DB_HOST || '127.0.0.1';
+ const port = env.DB_PORT || '3306';
+ const user = env.DB_USER || 'root';
+ const password = env.DB_PASSWD || env.DB_PASSWORD || 'root';
+ const database = env.DB_DATABASE || 'reactpress';
+ const out =
+ outputPath ||
+ path.join(projectRoot, `reactpress-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.sql`);
+
+ const cmd = `mysqldump -h ${host} -P ${port} -u ${user} -p${password} ${database}`;
+ console.log(chalk.cyan('[reactpress]'), t('db.backup.to', { path: out }));
+ try {
+ const dump = execSync(cmd, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
+ fs.writeFileSync(out, dump, 'utf8');
+ console.log(chalk.green('[reactpress]'), t('db.backup.done'));
+ return out;
+ } catch (err) {
+ if (isMysqldumpNotFoundError(err) && isLocalDbHost(host)) {
+ const via = mysqldumpFromDbContainer(projectRoot, { user, password, database });
+ if (via.ok) {
+ console.log(chalk.cyan('[reactpress]'), t('db.backup.viaDocker'));
+ fs.writeFileSync(out, via.stdout, 'utf8');
+ console.log(chalk.green('[reactpress]'), t('db.backup.done'));
+ return out;
+ }
+ }
+ console.error(chalk.red('[reactpress]'), t('db.backup.fail'));
+ throw err;
+ }
+}
+
+module.exports = { runDbBackup };
diff --git a/cli/src/lib/dev-banner.ts b/cli/src/lib/dev-banner.ts
new file mode 100644
index 00000000..dc4583a5
--- /dev/null
+++ b/cli/src/lib/dev-banner.ts
@@ -0,0 +1,153 @@
+// @ts-nocheck
+const {
+ brand,
+ icon,
+ ok,
+ divider,
+ padRight,
+ terminalWidth,
+ gradientText,
+ palette,
+ pulseBar,
+ statusLights,
+} = require('../ui/theme');
+const {
+ loadClientSiteUrl,
+ loadWebAdminUrl,
+ loadServerSiteUrl,
+ getApiPrefix,
+ getHealthUrl,
+} = require('./http');
+const { hasWeb } = require('./project-type');
+const { nginxEntryUrl } = require('./nginx');
+const { t } = require('./i18n');
+
+function getDevUrls(projectRoot) {
+ const client = loadClientSiteUrl(projectRoot).replace(/\/$/, '');
+ const server = loadServerSiteUrl(projectRoot).replace(/\/$/, '');
+ const prefix = getApiPrefix(projectRoot).replace(/\/$/, '') || '/api';
+ const admin = hasWeb(projectRoot)
+ ? loadWebAdminUrl(projectRoot).replace(/\/$/, '')
+ : `${client}/admin`;
+ return {
+ site: client,
+ admin,
+ api: `${server}${prefix}`,
+ swagger: `${server}${prefix}`,
+ health: getHealthUrl(projectRoot),
+ };
+}
+
+function urlLine(key, url, { underline = true } = {}) {
+ const keyCol = brand.muted(padRight(key, 10));
+ const value = underline ? brand.accent.underline(url) : brand.dim(url);
+ return ` ${brand.accent('▸ ')}${keyCol} ${value}`;
+}
+
+function printDevReadyBanner(
+ projectRoot,
+ {
+ apiOnly = false,
+ webOnly = false,
+ desktop = false,
+ localWeb = false,
+ nginx = false,
+ hasThemeSite = false,
+ dbOk = true,
+ adminApiOrigin = null,
+ clientApiOrigin = null,
+ localApiUrl = null,
+ dbType = null,
+ } = {}
+) {
+ const urls = getDevUrls(projectRoot);
+ const w = Math.min(terminalWidth() - 4, 56);
+ const readyKey = apiOnly
+ ? 'devBanner.readyApi'
+ : desktop
+ ? 'devBanner.readyDesktop'
+ : localWeb
+ ? 'devBanner.readyLocalWeb'
+ : webOnly
+ ? 'devBanner.readyWeb'
+ : 'devBanner.ready';
+
+ const useLocalDesktopApi = Boolean((desktop || localWeb) && localApiUrl);
+ const lights = useLocalDesktopApi || dbOk ? 'online' : 'degraded';
+ const readyGradient =
+ useLocalDesktopApi || dbOk ? [palette.green, palette.accent] : [palette.amber, palette.muted];
+
+ console.log('');
+ console.log(
+ ` ${useLocalDesktopApi || dbOk ? icon.ok : icon.warn} ${gradientText(t(readyKey), readyGradient, { bold: true })} ${statusLights(lights)}`
+ );
+ console.log(` ${brand.primary('╔' + '═'.repeat(w) + '╗')}`);
+
+ if (useLocalDesktopApi) {
+ const dbLabel =
+ dbType === 'sqlite' ? t('devBanner.sqliteEmbedded') : t('devBanner.mysqlDocker');
+ console.log(urlLine(t('devBanner.database'), dbLabel, { underline: false }));
+ console.log(urlLine(t('devBanner.api'), localApiUrl));
+ console.log(urlLine(t('devBanner.admin'), urls.admin));
+ if (hasThemeSite) {
+ console.log(urlLine(t('devBanner.site'), urls.site));
+ }
+ const healthUrl = localApiUrl.replace(/\/api\/?$/, '') + '/api/health';
+ console.log(urlLine(t('devBanner.health'), healthUrl, { underline: false }));
+ console.log(
+ ` ${brand.muted(' ')}${brand.dim(t(localWeb ? 'devBanner.localWebHint' : 'devBanner.desktopLocalHint'))}`
+ );
+ } else if (nginx) {
+ const entry = nginxEntryUrl(projectRoot);
+ if (!apiOnly && (hasThemeSite || !webOnly)) {
+ console.log(urlLine(t('devBanner.site'), entry));
+ }
+ if (!apiOnly && hasWeb(projectRoot)) {
+ console.log(urlLine(t('devBanner.admin'), `${entry}/admin/`));
+ }
+ console.log(urlLine(t('devBanner.api'), `${entry}/api`, { underline: false }));
+ if (clientApiOrigin) {
+ console.log(
+ ` ${brand.muted(' ')}${brand.dim(t('devBanner.nginxRemoteHint', { url: clientApiOrigin }))}`
+ );
+ } else {
+ console.log(` ${brand.muted(' ')}${brand.dim(t('devBanner.nginxHint'))}`);
+ }
+ if (adminApiOrigin) {
+ console.log(
+ ` ${brand.muted(' ')}${brand.dim(t('devBanner.adminRemoteHint', { url: adminApiOrigin }))}`
+ );
+ }
+ } else {
+ if (!apiOnly) {
+ if (!webOnly) {
+ console.log(urlLine(t('devBanner.site'), urls.site));
+ }
+ console.log(urlLine(t('devBanner.admin'), urls.admin));
+ }
+ console.log(urlLine(t('devBanner.api'), urls.api));
+ console.log(urlLine(t('devBanner.swagger'), urls.swagger));
+ console.log(urlLine(t('devBanner.health'), urls.health, { underline: false }));
+ }
+
+ const pulseWidth = Math.min(20, w - 4);
+ if (pulseWidth > 6) {
+ console.log(
+ ` ${brand.muted(' ')}${pulseBar(pulseWidth, pulseWidth)} ${
+ useLocalDesktopApi
+ ? brand.success(t('devBanner.localModeGo'))
+ : dbOk
+ ? brand.success(t('devBanner.allSystemsGo'))
+ : brand.warn(t('devBanner.dbDegraded'))
+ }`
+ );
+ }
+
+ console.log(` ${brand.primary('╚' + '═'.repeat(w) + '╝')}`);
+ console.log(
+ ` ${brand.dim(t('devBanner.hint'))} ${brand.muted('·')} ${brand.dim(t('devBanner.shortcuts'))}`
+ );
+ console.log('');
+}
+
+module.exports = { getDevUrls, printDevReadyBanner };
diff --git a/cli/src/lib/dev-child-io.ts b/cli/src/lib/dev-child-io.ts
new file mode 100644
index 00000000..ffa2c983
--- /dev/null
+++ b/cli/src/lib/dev-child-io.ts
@@ -0,0 +1,83 @@
+// @ts-nocheck
+const { spawn } = require('child_process');
+
+const DEV_OUTPUT_NOISE = [
+ /^>/,
+ /^◇ injected env/,
+ /^◈ /,
+ /^warn\s+- Invalid next\.config/,
+ /^Browserslist:/,
+ /^event - /,
+ /^wait - /,
+ /^Warning: \[antd/,
+ /^Warning: Route file/,
+ /^If this file is not intended/,
+ /^Current configuration:/,
+ /^ \d\. Rename/,
+ /^ routeFileIgnore/,
+ /^See more info here/,
+ /^\s+- The root value/,
+ /^\s+- The value at/,
+ /^\(node:\d+\) MaxListenersExceededWarning/,
+ /^\(Use `node --trace-warnings/,
+ /^ready - started server/,
+ /^vite:react-swc\]/,
+ /^\s*VITE\+/,
+ /^\s*➜\s+Network:/,
+ /^\s*➜\s+Local:/,
+ /^\s*➜\s+press h \+/,
+];
+
+function isDevOutputQuiet() {
+ return process.env.REACTPRESS_DEV_VERBOSE !== '1';
+}
+
+function isNoiseLine(line) {
+ const trimmed = line.trim();
+ if (!trimmed) return true;
+ return DEV_OUTPUT_NOISE.some((re) => re.test(trimmed));
+}
+
+function pipeFiltered(stream, target) {
+ let buffer = '';
+ stream.on('data', (chunk) => {
+ buffer += chunk.toString();
+ let idx;
+ while ((idx = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ if (!isNoiseLine(line)) {
+ target.write(`${line}\n`);
+ }
+ }
+ });
+ stream.on('end', () => {
+ if (buffer.trim() && !isNoiseLine(buffer)) {
+ target.write(`${buffer}\n`);
+ }
+ });
+}
+
+/**
+ * Spawn with filtered stdout/stderr unless REACTPRESS_DEV_VERBOSE=1.
+ */
+function spawnDevChild(command, args, options = {}) {
+ if (!isDevOutputQuiet()) {
+ return spawn(command, args, { ...options, stdio: options.stdio ?? 'inherit' });
+ }
+
+ const child = spawn(command, args, {
+ ...options,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ if (child.stdout) pipeFiltered(child.stdout, process.stdout);
+ if (child.stderr) pipeFiltered(child.stderr, process.stderr);
+
+ return child;
+}
+
+module.exports = {
+ isDevOutputQuiet,
+ spawnDevChild,
+};
diff --git a/cli/src/lib/dev-log.ts b/cli/src/lib/dev-log.ts
new file mode 100644
index 00000000..c811d22d
--- /dev/null
+++ b/cli/src/lib/dev-log.ts
@@ -0,0 +1,68 @@
+// @ts-nocheck
+const { t } = require('./i18n');
+
+let startedAt = 0;
+/** @type {Map} */
+const marks = new Map();
+
+function startDevTimer() {
+ startedAt = Date.now();
+ marks.clear();
+ marks.set('start', 0);
+}
+
+function markDevPhase(name) {
+ if (!startedAt) startDevTimer();
+ marks.set(name, Date.now() - startedAt);
+}
+
+function isDevVerbose() {
+ return process.env.REACTPRESS_DEV_VERBOSE === '1';
+}
+
+function formatDuration(ms) {
+ if (ms < 1000) return `${ms}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+function logDevSection(messageKey, vars = {}) {
+ console.log(`\n${t(messageKey, vars)}`);
+}
+
+function logDevStatus(messageKey, vars = {}) {
+ console.log(` ${t(messageKey, vars)}`);
+}
+
+function logDevLine(messageKey, vars = {}) {
+ const msg = t(messageKey, vars);
+ console.log(msg.startsWith('[reactpress]') ? msg : `[reactpress] ${msg}`);
+}
+
+function logDevDetail(messageKey, vars = {}) {
+ if (isDevVerbose()) logDevStatus(messageKey, vars);
+}
+
+function logDevTimingSummary(extra = {}) {
+ markDevPhase('ready');
+ const readyMs = marks.get('ready') ?? Date.now() - startedAt;
+ const infraMs = marks.has('infra') ? marks.get('infra') - (marks.get('start') || 0) : null;
+ const servicesMs = marks.has('services') ? marks.get('services') - (marks.get('infra') || 0) : null;
+
+ const parts = [`${(readyMs / 1000).toFixed(1)}s`];
+ if (infraMs != null) parts.push(`${t('dev.timingInfra')} ${formatDuration(infraMs)}`);
+ if (servicesMs != null) parts.push(`${t('dev.timingServices')} ${formatDuration(servicesMs)}`);
+ if (extra.apiReused) parts.push(t('dev.timingApiReused'));
+
+ logDevStatus('dev.timingReady', { summary: parts.join(' · ') });
+}
+
+module.exports = {
+ startDevTimer,
+ markDevPhase,
+ isDevVerbose,
+ logDevSection,
+ logDevStatus,
+ logDevLine,
+ logDevDetail,
+ logDevTimingSummary,
+};
diff --git a/cli/src/lib/dev-session.ts b/cli/src/lib/dev-session.ts
new file mode 100644
index 00000000..b92bef40
--- /dev/null
+++ b/cli/src/lib/dev-session.ts
@@ -0,0 +1,106 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+function lockFilePath(projectRoot) {
+ return path.join(projectRoot, '.reactpress', 'dev-session.json');
+}
+
+function isPidAlive(pid) {
+ const n = parseInt(pid, 10);
+ if (!Number.isFinite(n) || n <= 0) return false;
+ try {
+ process.kill(n, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function readDevSession(projectRoot) {
+ try {
+ return JSON.parse(fs.readFileSync(lockFilePath(projectRoot), 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Ensure a single `reactpress dev` owner per project directory.
+ * Stops a stale lock holder from a crashed prior run when its PID is gone,
+ * or signals a still-running prior session before taking over ports.
+ */
+async function acquireDevSession(projectRoot) {
+ const resolvedRoot = path.resolve(projectRoot);
+ const lockPath = lockFilePath(resolvedRoot);
+ const existing = readDevSession(resolvedRoot);
+
+ if (existing?.pid && existing.pid !== process.pid) {
+ if (isPidAlive(existing.pid)) {
+ console.warn(
+ `[reactpress] Replacing dev session pid ${existing.pid} (started ${existing.startedAt || 'unknown'})`,
+ );
+ try {
+ process.kill(existing.pid, 'SIGTERM');
+ } catch {
+ // prior session may have exited during signal
+ }
+ await sleep(400);
+ if (isPidAlive(existing.pid)) {
+ try {
+ process.kill(existing.pid, 'SIGKILL');
+ } catch {
+ // ignore
+ }
+ await sleep(200);
+ }
+ // Do not run `docker compose down` here — DB/nginx containers must survive dev restarts.
+ }
+ }
+
+ const { releaseStaleDevStackPorts } = require('./ports');
+ await releaseStaleDevStackPorts(resolvedRoot);
+
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
+ fs.writeFileSync(
+ lockPath,
+ `${JSON.stringify(
+ {
+ pid: process.pid,
+ ppid: process.ppid,
+ startedAt: new Date().toISOString(),
+ projectRoot: resolvedRoot,
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+}
+
+function releaseDevSession(projectRoot) {
+ try {
+ const existing = readDevSession(projectRoot);
+ if (existing?.pid === process.pid) {
+ fs.unlinkSync(lockFilePath(projectRoot));
+ }
+ } catch {
+ // ignore
+ }
+}
+
+function isDevSessionOwner(projectRoot) {
+ const existing = readDevSession(projectRoot);
+ return !existing?.pid || existing.pid === process.pid;
+}
+
+module.exports = {
+ acquireDevSession,
+ releaseDevSession,
+ readDevSession,
+ isDevSessionOwner,
+ isPidAlive,
+};
diff --git a/cli/src/lib/dev.ts b/cli/src/lib/dev.ts
new file mode 100644
index 00000000..94d7704d
--- /dev/null
+++ b/cli/src/lib/dev.ts
@@ -0,0 +1,1062 @@
+// @ts-nocheck
+const { spawn, spawnSync } = require('child_process');
+const { spawnDevChild } = require('./dev-child-io');
+const fs = require('fs');
+const path = require('path');
+const ora = require('ora');
+const { runBuild } = require('./build');
+const { ensureProjectEnvironment } = require('./bootstrap');
+const {
+ loadWebAdminUrl,
+ loadClientSiteUrl,
+ loadServerSiteUrl,
+ getHealthUrl,
+ checkHealth,
+ waitForHttp,
+} = require('./http');
+const { printDevReadyBanner } = require('./dev-banner');
+const { startDevNginx, stopDevNginx, nginxEntryUrl } = require('./nginx');
+const { ensureOriginalCwd, isMonorepoCheckout } = require('./root');
+const { detectProjectType, hasWeb, hasToolkit } = require('./project-type');
+const {
+ hasResolvableActiveTheme,
+ hasThemePackages,
+ readActiveThemeManifest,
+ resolveThemeDirectory,
+} = require('./theme-runtime');
+const { shouldBuildToolkit } = require('./toolkit-build');
+const { buildLocalPlugins } = require('./plugin-build');
+const { startThemeSiteWithWatch, stopThemeSite } = require('./theme-dev');
+const { scheduleBackgroundThemeBuilds } = require('./theme-prod');
+const {
+ shouldBlockOnThemeWarmup,
+ warmupThemeDevRoutes,
+ warmupThemeDevRoutesInBackground,
+} = require('./theme-warmup');
+const { DEV_PORTS, ensureApiPortFree, ensurePortFree, readEnvPort, isPortListening } =
+ require('./ports');
+const { ensureDevDatabase, probeMysqlHost } = require('./docker');
+const { acquireDevSession, releaseDevSession } = require('./dev-session');
+const { checkNodeVersion, checkDocker } = require('./doctor');
+const {
+ startDevTimer,
+ markDevPhase,
+ isDevVerbose,
+ logDevLine,
+ logDevDetail,
+ logDevStatus,
+ logDevTimingSummary,
+} = require('./dev-log');
+const { t } = require('./i18n');
+const {
+ resolveRemoteThemeApiBase,
+ readDevClientApiOrigin,
+ normalizeRemoteOrigin,
+} = require('./remote-dev');
+
+const CLIENT_READY_TIMEOUT_MS = 120_000;
+const API_READY_TIMEOUT_MS = 180_000;
+const DEV_POLL_MS = 250;
+const DEV_POLL_FAST_MS = 150;
+
+function shouldWaitForThemeInForeground() {
+ if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') return true;
+ return process.env.REACTPRESS_DEV_WAIT_THEME === '1';
+}
+
+function logDevPhase(step, total, messageKey, vars = {}) {
+ console.log('');
+ console.log(`[reactpress] [${step}/${total}] ${t(messageKey, vars)}`);
+}
+
+function isDesktopLocalMode() {
+ return process.env.REACTPRESS_DESKTOP_LOCAL === '1';
+}
+
+function isLocalSqliteMode() {
+ return process.env.REACTPRESS_LOCAL_MODE === '1' || isDesktopLocalMode();
+}
+
+function desktopPhaseKey(defaultKey) {
+ if (isLocalSqliteMode()) {
+ const localMap = {
+ 'dev.phasePrerequisites': 'dev.phasePrerequisitesDesktop',
+ 'dev.phaseInfra': 'dev.phaseInfraDesktop',
+ 'dev.phaseServices': 'dev.phaseServicesLocalWeb',
+ };
+ if (localMap[defaultKey]) return localMap[defaultKey];
+ }
+ if (!isDesktopLocalMode()) return defaultKey;
+ const map = {
+ 'dev.phasePrerequisites': 'dev.phasePrerequisitesDesktop',
+ 'dev.phaseInfra': 'dev.phaseInfraDesktop',
+ 'dev.phaseServices': 'dev.phaseServicesDesktop',
+ };
+ return map[defaultKey] || defaultKey;
+}
+
+function formatDevFailureHint() {
+ return [
+ t('dev.nextSteps'),
+ t('dev.nextDoctor'),
+ t('dev.nextDocker'),
+ t('dev.nextEnv'),
+ ].join('\n');
+}
+
+let apiChild;
+let webChild;
+let desktopChild;
+let shuttingDown = false;
+let nginxEnabled = false;
+/** When false, admin/API child exit during startup must not tear down the stack. */
+let devServicesReady = false;
+
+function shutdown(signal = 'SIGINT') {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ stopThemeSite();
+ if (nginxEnabled) stopDevNginx(ensureOriginalCwd());
+ const stopEmbeddedApi =
+ signal === 'SIGINT' || devServicesReady || !isDesktopLocalMode();
+ if (stopEmbeddedApi) {
+ try {
+ const { stopLocalServer } = require(path.join(ensureOriginalCwd(), 'desktop/out/main/local-server.js'));
+ stopLocalServer();
+ } catch {
+ // desktop local API not running
+ }
+ }
+ if (desktopChild && !desktopChild.killed) desktopChild.kill(signal);
+ if (webChild && !webChild.killed) webChild.kill(signal);
+ if (apiChild && !apiChild.killed) apiChild.kill(signal);
+ try {
+ releaseDevSession(ensureOriginalCwd());
+ } catch {
+ // ignore
+ }
+}
+
+async function buildToolkit(projectRoot) {
+ if (!hasToolkit(projectRoot)) return;
+ if (!shouldBuildToolkit(projectRoot)) {
+ logDevDetail('dev.toolkitUpToDate');
+ } else {
+ await runBuild('toolkit', projectRoot);
+ }
+ try {
+ buildLocalPlugins(projectRoot);
+ } catch (err) {
+ console.error(`[reactpress] ${err.message || err}`);
+ throw err;
+ }
+}
+
+async function spawnApi(projectRoot) {
+ const { reused, port: apiPort } = await ensureApiPortFree(projectRoot, { allowReuse: true });
+ if (reused) {
+ logDevStatus('dev.apiReusing', { port: apiPort });
+ return { reused: true, port: apiPort };
+ }
+
+ const apiDevRunner = path.join(__dirname, 'api-dev-runner.js');
+ logDevStatus('dev.startingApi');
+ apiChild = spawn(process.execPath, [apiDevRunner], {
+ stdio: 'inherit',
+ cwd: projectRoot,
+ env: {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ REACTPRESS_DEV_SESSION_PID: String(process.pid),
+ REACTPRESS_DEV_DB_READY: '1',
+ REACTPRESS_DEV_PORTS_READY: '1',
+ },
+ });
+
+ apiChild.on('close', (code) => {
+ if (shuttingDown) {
+ process.exit(code ?? 0);
+ return;
+ }
+ if (webChild && !webChild.killed) webChild.kill('SIGINT');
+ process.exit(code ?? 1);
+ });
+ return { reused: false, port: apiPort };
+}
+
+async function waitForApiReady(
+ projectRoot,
+ { readyMessageKey = 'dev.apiReady', alreadyHealthy = false } = {},
+) {
+ const healthUrl = getHealthUrl(projectRoot);
+ const apiPort = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API);
+
+ if (alreadyHealthy) {
+ const health = await checkHealth(healthUrl, 2000);
+ if (health.ok) {
+ logDevStatus(readyMessageKey);
+ return;
+ }
+ }
+
+ const useSpinner = isDevVerbose() && process.stdout.isTTY;
+ const spinner = useSpinner
+ ? ora({
+ text: t('dev.waitingApi', { url: healthUrl }),
+ color: 'magenta',
+ spinner: 'dots',
+ }).start()
+ : null;
+ if (!useSpinner) logDevStatus('dev.waitingApiQuiet');
+
+ const deadline = Date.now() + API_READY_TIMEOUT_MS;
+ let lastHint = '';
+
+ while (Date.now() < deadline) {
+ if (!isPortListening(apiPort)) {
+ lastHint = t('dev.waitingApiCompile', { port: apiPort });
+ if (spinner) {
+ spinner.text = `${t('dev.waitingApi', { url: healthUrl })} — ${lastHint}`;
+ }
+ await new Promise((r) => setTimeout(r, DEV_POLL_MS));
+ continue;
+ }
+
+ const health = await checkHealth(healthUrl, 2500);
+ if (health.ok) {
+ if (spinner) spinner.succeed(t(readyMessageKey));
+ else logDevStatus(readyMessageKey);
+ return;
+ }
+
+ if (health.data?.database === 'down') {
+ lastHint = t('dev.healthDbDown');
+ } else if (health.statusCode === 200 && health.data?.status === 'degraded') {
+ lastHint = t('dev.healthDegraded');
+ } else if (health.statusCode === 0) {
+ lastHint = t('dev.waitingApiStarting');
+ } else if (health.statusCode > 0) {
+ lastHint = `HTTP ${health.statusCode}`;
+ }
+
+ if (spinner && lastHint) {
+ spinner.text = `${t('dev.waitingApi', { url: healthUrl })} — ${lastHint}`;
+ }
+ await new Promise((r) => setTimeout(r, DEV_POLL_FAST_MS));
+ }
+
+ if (spinner) spinner.fail(t('dev.apiTimeout', { seconds: API_READY_TIMEOUT_MS / 1000 }));
+ else console.error(t('dev.apiTimeout', { seconds: API_READY_TIMEOUT_MS / 1000 }));
+ shutdown('SIGINT');
+ process.exit(1);
+}
+
+function handlePrimaryDevChildClose(code, label = 'dev', projectRoot = ensureOriginalCwd()) {
+ if (!devServicesReady) {
+ console.warn(
+ `[reactpress] ${label} process exited during startup (code ${code ?? 'unknown'}) — waiting for services…`,
+ );
+ return;
+ }
+ const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB);
+ if (isDesktopLocalMode() && label === 'Admin dev') {
+ return;
+ }
+ if (label === 'Admin dev' && isPortListening(adminPort)) {
+ return;
+ }
+ if (!shuttingDown) shutdown('SIGINT');
+ process.exit(code ?? 0);
+}
+
+async function spawnAdminWeb(
+ projectRoot,
+ {
+ behindNginx = false,
+ integratedStack = false,
+ adminApiOrigin = null,
+ waitForReady = true,
+ } = {},
+) {
+ const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB);
+ await ensurePortFree(adminPort, { label: 'admin' });
+
+ logDevDetail('dev.startingAdmin', { url: loadWebAdminUrl(projectRoot) });
+ const adminEnv = {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ WEB_ADMIN_PORT: String(adminPort),
+ };
+ if (nginxEnabled && process.env.REACTPRESS_NGINX_ENTRY_URL) {
+ adminEnv.REACTPRESS_NGINX_ENTRY_URL = process.env.REACTPRESS_NGINX_ENTRY_URL;
+ } else {
+ adminEnv.REACTPRESS_SKIP_DEV_PORT_REDIRECT = '1';
+ }
+ if (behindNginx) {
+ adminEnv.VITE_ADMIN_BASE = '/admin/';
+ process.env.REACTPRESS_BEHIND_NGINX = '1';
+ }
+ // Full stack (API + theme site): theme install/activate must hit Nest, not MSW.
+ if (integratedStack || adminApiOrigin) {
+ adminEnv.VITE_ENABLE_MOCK = 'false';
+ adminEnv.VITE_AUTH_MODE = 'server';
+ if (adminApiOrigin) {
+ // Vite proxies `/api` → `${target}/api/...`; target is host-only origin.
+ adminEnv.VITE_DEV_API_PROXY_TARGET = normalizeRemoteOrigin(adminApiOrigin) || adminApiOrigin;
+ } else if (isDesktopLocalMode() && process.env.REACTPRESS_DESKTOP_LOCAL_API) {
+ // Desktop dev embeds SQLite API on a dedicated port (default :13102), not :3002.
+ adminEnv.VITE_DEV_API_PROXY_TARGET = process.env.REACTPRESS_DESKTOP_LOCAL_API.replace(
+ /\/api\/?$/,
+ '',
+ );
+ } else {
+ adminEnv.VITE_DEV_API_PROXY_TARGET = loadServerSiteUrl(projectRoot).replace(/\/$/, '');
+ }
+ }
+
+ webChild = isDesktopLocalMode()
+ ? spawn(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', ['exec', 'vp', 'dev'], {
+ cwd: path.join(projectRoot, 'web'),
+ env: adminEnv,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ })
+ : spawnDevChild('pnpm', ['run', '--dir', './web', 'dev'], {
+ shell: true,
+ cwd: projectRoot,
+ env: adminEnv,
+ });
+
+ webChild.on('close', (code) => {
+ handlePrimaryDevChildClose(code, 'Admin dev', projectRoot);
+ });
+
+ if (!waitForReady) return Promise.resolve(true);
+
+ const readyUrl = loadWebAdminUrl(projectRoot);
+ return waitForHttp(readyUrl, CLIENT_READY_TIMEOUT_MS, DEV_POLL_MS).then((ready) => {
+ if (!ready) {
+ console.warn(
+ t('dev.adminSlow', {
+ seconds: CLIENT_READY_TIMEOUT_MS / 1000,
+ url: readyUrl,
+ }),
+ );
+ }
+ return ready;
+ });
+}
+
+function assertDevPrerequisites() {
+ const node = checkNodeVersion();
+ if (!node.ok) {
+ console.error(`[reactpress] ${node.message}`);
+ if (node.fix) console.error(` → ${node.fix}`);
+ console.error(formatDevFailureHint());
+ process.exit(1);
+ }
+ if (!isLocalSqliteMode()) {
+ const docker = checkDocker();
+ if (!docker.ok) {
+ console.error(`[reactpress] ${docker.message}`);
+ if (docker.fix) console.error(` → ${docker.fix}`);
+ console.error(formatDevFailureHint());
+ process.exit(1);
+ }
+ }
+ logDevStatus(
+ isDesktopLocalMode() || process.env.REACTPRESS_LOCAL_MODE === '1'
+ ? 'dev.prerequisitesOkDesktop'
+ : 'dev.prerequisitesOk',
+ { version: process.version },
+ );
+}
+
+async function prepareDevInfrastructure(projectRoot, { needsLocalApi = true } = {}) {
+ await acquireDevSession(projectRoot);
+ if (isLocalSqliteMode()) {
+ if (process.env.REACTPRESS_LOCAL_MODE === '1') {
+ const { ensureLocalSite } = require('../core/services/local-site');
+ const { ensureSqliteDatabase } = require('../core/services/database/sqlite');
+ const { getMonorepoRoot } = require('./root');
+ const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API);
+ ensureLocalSite(projectRoot, port, { monorepoRoot: getMonorepoRoot() });
+ const sqliteResult = await ensureSqliteDatabase(projectRoot);
+ if (!sqliteResult.ok) {
+ throw new Error(sqliteResult.message || 'SQLite 数据库未就绪');
+ }
+ }
+ nginxEnabled = false;
+ delete process.env.REACTPRESS_NGINX_ENTRY_URL;
+ process.env.REACTPRESS_SKIP_DEV_PORT_REDIRECT = '1';
+ return;
+ }
+ const planNginx = process.env.REACTPRESS_SKIP_NGINX !== '1';
+ const clientApiOrigin = readDevClientApiOrigin(projectRoot);
+
+ const [, nginxResult] = await Promise.all([
+ needsLocalApi ? ensureDevDatabase(projectRoot, { quiet: true }) : Promise.resolve(true),
+ planNginx ? startDevNginx(projectRoot) : Promise.resolve(false),
+ ]);
+
+ nginxEnabled = nginxResult;
+ if (nginxEnabled) {
+ process.env.REACTPRESS_NGINX_ENTRY_URL = nginxEntryUrl(projectRoot);
+ delete process.env.REACTPRESS_SKIP_DEV_PORT_REDIRECT;
+ if (clientApiOrigin) {
+ logDevStatus('dev.nginxReadyRemote', {
+ url: nginxEntryUrl(projectRoot),
+ api: clientApiOrigin,
+ });
+ } else {
+ logDevStatus('dev.nginxReady', { url: nginxEntryUrl(projectRoot) });
+ }
+ } else {
+ delete process.env.REACTPRESS_NGINX_ENTRY_URL;
+ process.env.REACTPRESS_SKIP_DEV_PORT_REDIRECT = '1';
+ }
+}
+
+async function startDevStack(
+ projectRoot,
+ {
+ webOnly = false,
+ themeOnly = false,
+ desktopMode = false,
+ localWebMode = false,
+ infraDone = false,
+ apiOrigins = { admin: null, client: null, needsLocalApi: true },
+ } = {},
+) {
+ const { admin: adminApiOrigin, client: clientApiOrigin, needsLocalApi } = apiOrigins;
+ if (!infraDone) {
+ logDevPhase(1, 3, desktopPhaseKey('dev.phasePrerequisites'));
+ assertDevPrerequisites();
+ logDevPhase(2, 3, desktopPhaseKey('dev.phaseInfra'));
+ try {
+ await prepareDevInfrastructure(projectRoot, { needsLocalApi });
+ } catch (err) {
+ console.error(t('dev.dbEnsureFailed', { message: err.message || err }));
+ console.error(formatDevFailureHint());
+ process.exit(1);
+ }
+ } else if (needsLocalApi && !isLocalSqliteMode() && !(await probeMysqlHost(projectRoot))) {
+ console.error(
+ t('dev.dbEnsureFailed', {
+ message: t('docker.devStartBlocked', {
+ port: readEnvPort(projectRoot, 'DB_PORT', DEV_PORTS.MYSQL),
+ }),
+ }),
+ );
+ console.error(formatDevFailureHint());
+ process.exit(1);
+ }
+
+ const includeAdmin = webOnly && hasWeb(projectRoot);
+ // Always run theme watchers when packages exist — admin activate/preview writes manifests
+ // and relies on fs.watch even if the current active theme is not yet resolvable.
+ const includeThemeSite =
+ themeOnly ||
+ (desktopMode && hasThemePackages(projectRoot)) ||
+ (!webOnly && hasThemePackages(projectRoot));
+ if (!webOnly && !themeOnly && includeThemeSite && !hasResolvableActiveTheme(projectRoot)) {
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ console.warn(
+ `[reactpress] ${t('themeDev.notFound', { id: activeTheme })} — ${t('dev.themeSiteSkipped')}`,
+ );
+ }
+ if (includeThemeSite) {
+ const { validateBundledThemes, validateCatalogThemes } = require('./theme-registry');
+ const bundled = validateBundledThemes(projectRoot);
+ if (bundled.missing.length) {
+ console.warn(
+ `[reactpress] themes/package.json lists bundled theme(s) without theme.json: ${bundled.missing.join(', ')}`,
+ );
+ }
+ const catalog = validateCatalogThemes(projectRoot);
+ if (catalog.missing.length) {
+ console.warn(
+ `[reactpress] themes/package.json lists catalog dir(s) without reactpress.theme in package.json: ${catalog.missing.join(', ')}`,
+ );
+ }
+ }
+ const planNginx = process.env.REACTPRESS_SKIP_NGINX !== '1';
+ const includeWebInStack = hasWeb(projectRoot) && !webOnly && !themeOnly;
+
+ if (infraDone) {
+ logDevPhase(
+ 3,
+ 3,
+ localWebMode ? 'dev.phaseServicesLocalWeb' : desktopPhaseKey('dev.phaseServices'),
+ );
+ }
+
+ markDevPhase('services');
+ const readinessWaits = [];
+ let apiSpawn = null;
+
+ const prewarmPreviewBuilds =
+ includeThemeSite &&
+ isDesktopLocalMode() &&
+ process.env.REACTPRESS_SKIP_PREVIEW_BUILD !== '1';
+ if (prewarmPreviewBuilds) {
+ const { warmupAllPreviewThemeBuilds } = require('./theme-prod');
+ logDevStatus('dev.previewPrewarmStarting');
+ readinessWaits.push(warmupAllPreviewThemeBuilds(projectRoot));
+ }
+
+ if (adminApiOrigin || clientApiOrigin) {
+ if (adminApiOrigin) {
+ logDevStatus('dev.adminApiRemote', { url: adminApiOrigin });
+ }
+ if (clientApiOrigin) {
+ logDevStatus('dev.clientApiRemote', { url: clientApiOrigin });
+ }
+ }
+ if (needsLocalApi) {
+ logDevStatus('dev.phaseApi');
+ apiSpawn = await spawnApi(projectRoot);
+ const readyMessageKey = webOnly || hasWeb(projectRoot) ? 'dev.apiReadyAdmin' : 'dev.apiReady';
+ readinessWaits.push(
+ waitForApiReady(projectRoot, {
+ readyMessageKey,
+ alreadyHealthy: Boolean(apiSpawn?.reused),
+ }),
+ );
+ }
+
+ if (!includeAdmin && !includeThemeSite && !includeWebInStack) {
+ await Promise.all(readinessWaits);
+ printDevReadyBanner(projectRoot, { apiOnly: true, nginx: nginxEnabled });
+ console.log(t('dev.standaloneHint'));
+ return;
+ }
+
+ const waitThemeInForeground = includeThemeSite && shouldWaitForThemeInForeground();
+ let themeSiteStarted = false;
+
+ if (includeWebInStack) {
+ logDevDetail('dev.phaseAdmin');
+ if (planNginx) process.env.REACTPRESS_BEHIND_NGINX = '1';
+ logDevDetail('dev.startingAdmin', { url: loadWebAdminUrl(projectRoot) });
+ spawnAdminWeb(projectRoot, {
+ behindNginx: planNginx,
+ integratedStack: true,
+ adminApiOrigin,
+ waitForReady: false,
+ });
+ const adminBase = loadWebAdminUrl(projectRoot).replace(/\/$/, '');
+ const adminProbe = planNginx ? `${adminBase}/admin/` : `${adminBase}/`;
+ readinessWaits.push(waitForHttp(adminProbe, 120_000, DEV_POLL_MS));
+ } else if (includeAdmin) {
+ logDevDetail('dev.phaseAdmin');
+ spawnAdminWeb(projectRoot, {
+ behindNginx: desktopMode || localWebMode ? false : planNginx,
+ integratedStack: desktopMode || localWebMode || isLocalSqliteMode(),
+ adminApiOrigin,
+ waitForReady: false,
+ });
+ const adminBase = loadWebAdminUrl(projectRoot).replace(/\/$/, '');
+ const adminProbe = planNginx ? `${adminBase}/admin/` : `${adminBase}/`;
+ readinessWaits.push(waitForHttp(adminProbe, 120_000, DEV_POLL_MS));
+ }
+
+ if (includeThemeSite) {
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ logDevStatus('dev.themeStarting', {
+ id: activeTheme,
+ port: readEnvPort(projectRoot, 'CLIENT_PORT', DEV_PORTS.VISITOR),
+ });
+ if (planNginx) process.env.REACTPRESS_BEHIND_NGINX = '1';
+ if (clientApiOrigin) {
+ delete process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API;
+ const themeApiBase = resolveRemoteThemeApiBase(clientApiOrigin);
+ process.env.REACTPRESS_THEME_API_URL = themeApiBase;
+ process.env.REACTPRESS_THEME_PUBLIC_API_URL = planNginx
+ ? `${nginxEntryUrl(projectRoot).replace(/\/$/, '')}/api`
+ : themeApiBase;
+ const themeDir = resolveThemeDirectory(projectRoot, activeTheme);
+ if (themeDir) {
+ const nextCache = path.join(themeDir, '.next');
+ if (fs.existsSync(nextCache)) {
+ fs.rmSync(nextCache, { recursive: true, force: true });
+ logDevDetail('dev.themeCacheClearedForRemote');
+ }
+ }
+ } else {
+ process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API = '1';
+ }
+ const themeBoot = startThemeSiteWithWatch(projectRoot);
+ const themeWait = themeBoot.then(async (started) => {
+ themeSiteStarted = started;
+ if (!started) return false;
+ const clientUrl = loadClientSiteUrl(projectRoot);
+ const port = readEnvPort(projectRoot, 'CLIENT_PORT', DEV_PORTS.VISITOR);
+ const portOpen = await (async () => {
+ const deadline = Date.now() + 120_000;
+ while (Date.now() < deadline) {
+ if (isPortListening(port)) return true;
+ await new Promise((r) => setTimeout(r, DEV_POLL_MS));
+ }
+ return false;
+ })();
+ if (portOpen) {
+ if (isDesktopLocalMode()) {
+ const { warmupThemeHomepage } = require('./theme-warmup');
+ await warmupThemeHomepage(projectRoot, clientUrl);
+ } else if (shouldBlockOnThemeWarmup()) {
+ await warmupThemeDevRoutes(projectRoot);
+ } else {
+ warmupThemeDevRoutesInBackground(projectRoot);
+ }
+ }
+ return portOpen;
+ });
+ if (waitThemeInForeground) {
+ readinessWaits.push(themeWait);
+ } else {
+ themeWait.then((ready) => {
+ if (ready) logDevDetail('dev.themeReadyQuiet', { url: loadClientSiteUrl(projectRoot) });
+ });
+ }
+ }
+
+ if (readinessWaits.length > 1) {
+ logDevStatus('dev.waitingProxies');
+ }
+ await Promise.all(readinessWaits);
+ if (readinessWaits.length > 1) {
+ logDevStatus('dev.proxiesReady');
+ }
+
+ if (includeWebInStack && planNginx && nginxEnabled) {
+ const adminViaNginx = `${nginxEntryUrl(projectRoot).replace(/\/$/, '')}/admin/`;
+ waitForHttp(adminViaNginx, 15_000, DEV_POLL_MS).then((adminOk) => {
+ if (!adminOk) {
+ console.warn(t('dev.adminNginxSlow', { url: adminViaNginx }));
+ }
+ });
+ } else if (planNginx && !nginxEnabled) {
+ startDevNginx(projectRoot).then((enabled) => {
+ nginxEnabled = enabled;
+ if (enabled) {
+ console.log(t('dev.nginxReady', { url: nginxEntryUrl(projectRoot) }));
+ }
+ });
+ }
+
+ const dbOk = isDesktopLocalMode()
+ ? true
+ : needsLocalApi
+ ? await probeMysqlHost(projectRoot)
+ : true;
+ if (needsLocalApi && !dbOk && !isDesktopLocalMode()) {
+ console.warn(t('dev.mysqlUnreachable'));
+ }
+
+ if (includeThemeSite && process.env.REACTPRESS_SKIP_PREVIEW_BUILD !== '1' && !prewarmPreviewBuilds) {
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ scheduleBackgroundThemeBuilds(projectRoot, { excludeThemeId: activeTheme });
+ }
+
+ printDevReadyBanner(projectRoot, {
+ webOnly: (includeAdmin && !includeThemeSite) || localWebMode,
+ desktop: desktopMode && !localWebMode,
+ localWeb: localWebMode,
+ nginx: nginxEnabled,
+ hasThemeSite: includeThemeSite,
+ dbOk,
+ adminApiOrigin,
+ clientApiOrigin,
+ localApiUrl: isDesktopLocalMode() ? process.env.REACTPRESS_DESKTOP_LOCAL_API || null : null,
+ dbType: isDesktopLocalMode() ? 'sqlite' : needsLocalApi ? 'mysql' : null,
+ });
+
+ logDevTimingSummary({
+ apiReused: Boolean(apiSpawn?.reused),
+ });
+
+ devServicesReady = true;
+}
+
+async function runDevStartup(
+ projectRoot,
+ {
+ webOnly = false,
+ themeOnly = false,
+ desktopMode = false,
+ localWebMode = false,
+ skipPrepareInfra = false,
+ apiOrigins = { admin: null, client: null, needsLocalApi: true },
+ } = {},
+) {
+ startDevTimer();
+ try {
+ const result = await ensureProjectEnvironment(projectRoot, { skipDatabase: true });
+ if (result.message && isDevVerbose()) console.log(`[reactpress] ${result.message}`);
+ } catch (err) {
+ console.error(t('dev.envFailed'), err.message || err);
+ console.error(formatDevFailureHint());
+ process.exit(1);
+ }
+
+ logDevPhase(1, 3, desktopPhaseKey('dev.phasePrerequisites'));
+ assertDevPrerequisites();
+
+ logDevPhase(2, 3, desktopPhaseKey('dev.phaseInfra'));
+ try {
+ const infraTasks = [buildToolkit(projectRoot)];
+ if (!skipPrepareInfra) {
+ infraTasks.unshift(
+ prepareDevInfrastructure(projectRoot, { needsLocalApi: apiOrigins.needsLocalApi }),
+ );
+ }
+ await Promise.all(infraTasks);
+ markDevPhase('infra');
+ } catch (err) {
+ console.error(t('dev.dbEnsureFailed', { message: err.message || err }));
+ console.error(formatDevFailureHint());
+ process.exit(1);
+ }
+
+ await startDevStack(projectRoot, {
+ webOnly,
+ themeOnly,
+ desktopMode,
+ localWebMode,
+ infraDone: true,
+ apiOrigins,
+ });
+}
+
+function loadDesktopBootstrap(projectRoot) {
+ return require(path.join(projectRoot, 'desktop/scripts/bootstrap-local-api.cjs'));
+}
+
+async function startDesktopLocalApi(projectRoot, { forceRestart = false } = {}) {
+ const desktopDir = path.join(projectRoot, 'desktop');
+ if (!fs.existsSync(path.join(desktopDir, 'package.json'))) {
+ console.error(`[reactpress] ${t('dev.desktopMissing')}`);
+ process.exit(1);
+ }
+
+ const boot = await loadDesktopBootstrap(projectRoot);
+ console.log('');
+ logDevStatus('dev.desktopLocalApiStarting');
+ const { siteRoot, localApiBase } = await boot.startDesktopLocalApi(projectRoot, { forceRestart });
+ process.env.REACTPRESS_DESKTOP_LOCAL_API = localApiBase;
+ process.env.REACTPRESS_DESKTOP_SITE_ROOT = siteRoot;
+ process.env.REACTPRESS_THEME_API_URL = localApiBase;
+ process.env.REACTPRESS_THEME_PUBLIC_API_URL = localApiBase;
+ const localApiOrigin = localApiBase.replace(/\/api\/?$/, '');
+ process.env.VITE_DEV_API_PROXY_TARGET = localApiOrigin;
+ logDevStatus('dev.desktopLocalApiReady', { url: localApiBase, db: t('dev.dbTypeSqlite') });
+ return { siteRoot, localApiBase };
+}
+
+async function ensureDesktopLocalApiHealthy(projectRoot, { forceRestart = false } = {}) {
+ if (!isDesktopLocalMode()) return true;
+ const base = process.env.REACTPRESS_DESKTOP_LOCAL_API?.trim();
+ if (!base) return false;
+ const healthUrl = `${base.replace(/\/api\/?$/, '')}/api/health`;
+ const health = await checkHealth(healthUrl, 2500);
+ if (health.ok && !forceRestart) return true;
+ console.warn('[reactpress] Local API unreachable — restarting embedded SQLite API…');
+ await startDesktopLocalApi(projectRoot, { forceRestart: true });
+ const retry = await checkHealth(healthUrl, 5000);
+ if (!retry.ok) {
+ console.warn(`[reactpress] Local API still unhealthy after restart (${healthUrl})`);
+ }
+ return retry.ok;
+}
+
+function registerDevShutdownHandlers(projectRoot) {
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => {
+ // Stray SIGTERM during local web boot must not tear down the embedded API (Vite still starting).
+ if (!devServicesReady && isDesktopLocalMode()) return;
+ shutdown('SIGTERM');
+ });
+ process.on('exit', () => {
+ try {
+ releaseDevSession(projectRoot);
+ } catch {
+ // ignore
+ }
+ });
+}
+
+/** Block until the primary dev child exits (admin Vite, Electron shell, or Nest API). */
+function waitUntilDevChildExit(projectRoot = ensureOriginalCwd()) {
+ return new Promise((resolve) => {
+ const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB);
+
+ const waitForShutdownSignal = () => {
+ const onSignal = () => resolve(0);
+ process.once('SIGINT', onSignal);
+ process.once('SIGTERM', onSignal);
+ };
+
+ const child = webChild || desktopChild || apiChild;
+ if (!child) {
+ waitForShutdownSignal();
+ return;
+ }
+
+ const finish = (code) => {
+ if (child === webChild && isPortListening(adminPort)) {
+ waitForShutdownSignal();
+ return;
+ }
+ resolve(code ?? 0);
+ };
+
+ if (child.exitCode != null) {
+ finish(child.exitCode);
+ return;
+ }
+ if (child.killed) {
+ finish(1);
+ return;
+ }
+ child.once('close', (code) => finish(code));
+ });
+}
+
+function canUseDesktopLocalStack(projectRoot) {
+ return (
+ isMonorepoCheckout(projectRoot) &&
+ fs.existsSync(path.join(projectRoot, 'desktop', 'package.json'))
+ );
+}
+
+async function spawnDesktopApp(projectRoot) {
+ const desktopDir = path.join(projectRoot, 'desktop');
+ if (!fs.existsSync(path.join(desktopDir, 'package.json'))) {
+ console.error(`[reactpress] ${t('dev.desktopMissing')}`);
+ shutdown('SIGINT');
+ process.exit(1);
+ }
+
+ const adminUrl = loadWebAdminUrl(projectRoot).replace(/\/$/, '');
+ logDevStatus('dev.desktopStarting', { url: adminUrl });
+
+ const boot = await loadDesktopBootstrap(projectRoot);
+ boot.ensureDesktopBuilt(projectRoot);
+
+ desktopChild = spawnDevChild(
+ 'pnpm',
+ ['exec', 'cross-env', `VITE_DEV_SERVER_URL=${adminUrl}`, 'ELECTRON_IS_DEV=1', 'pnpm', 'run', 'dev:shell'],
+ {
+ shell: true,
+ cwd: desktopDir,
+ env: {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ VITE_DEV_SERVER_URL: adminUrl,
+ ELECTRON_IS_DEV: '1',
+ },
+ },
+ );
+
+ desktopChild.on('close', (code) => {
+ handlePrimaryDevChildClose(code, 'Desktop shell');
+ });
+}
+
+async function runLocalMonorepoDev(projectRoot = ensureOriginalCwd()) {
+ if (!hasWeb(projectRoot)) {
+ console.error(t('dev.noWeb'));
+ process.exit(1);
+ }
+
+ process.env.REACTPRESS_SKIP_NGINX = '1';
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+ registerDevShutdownHandlers(projectRoot);
+
+ console.log('');
+ logDevLine('dev.localFullIntro');
+
+ await prepareDevInfrastructure(projectRoot, { needsLocalApi: false });
+ await startDesktopLocalApi(projectRoot, { forceRestart: true });
+
+ await runDevStartup(projectRoot, {
+ skipPrepareInfra: true,
+ apiOrigins: { admin: null, client: null, needsLocalApi: false },
+ });
+ await ensureDesktopLocalApiHealthy(projectRoot);
+ await new Promise((resolve) => {
+ const interval = setInterval(() => {
+ void ensureDesktopLocalApiHealthy(projectRoot).catch(() => {});
+ }, 20_000);
+ const onStop = (signal) => {
+ clearInterval(interval);
+ shutdown(signal);
+ resolve(0);
+ };
+ process.once('SIGINT', () => onStop('SIGINT'));
+ process.once('SIGTERM', () => onStop('SIGTERM'));
+ });
+ process.exit(0);
+}
+
+async function runDesktopDev(projectRoot = ensureOriginalCwd()) {
+ if (!hasWeb(projectRoot)) {
+ console.error(t('dev.noWeb'));
+ process.exit(1);
+ }
+
+ process.env.REACTPRESS_SKIP_NGINX = '1';
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+ registerDevShutdownHandlers(projectRoot);
+
+ console.log('');
+ logDevLine('dev.desktopIntro');
+
+ await prepareDevInfrastructure(projectRoot, { needsLocalApi: false });
+ await startDesktopLocalApi(projectRoot, { forceRestart: true });
+
+ await runDevStartup(projectRoot, {
+ webOnly: true,
+ desktopMode: true,
+ skipPrepareInfra: true,
+ apiOrigins: { admin: null, client: null, needsLocalApi: false },
+ });
+ await spawnDesktopApp(projectRoot);
+ const exitCode = await waitUntilDevChildExit(projectRoot);
+ process.exit(exitCode);
+}
+
+async function runLocalWebDev(
+ projectRoot = ensureOriginalCwd(),
+ { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {},
+) {
+ if (!hasWeb(projectRoot)) {
+ console.error(t('dev.noWeb'));
+ process.exit(1);
+ }
+
+ registerDevShutdownHandlers(projectRoot);
+
+ if (canUseDesktopLocalStack(projectRoot)) {
+ delete process.env.REACTPRESS_LOCAL_MODE;
+ process.env.REACTPRESS_SKIP_NGINX = '1';
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+
+ console.log('');
+ logDevLine('dev.localWebIntro');
+
+ await prepareDevInfrastructure(projectRoot, { needsLocalApi: false });
+ await startDesktopLocalApi(projectRoot, { forceRestart: true });
+
+ await runDevStartup(projectRoot, {
+ webOnly: true,
+ desktopMode: true,
+ localWebMode: true,
+ skipPrepareInfra: true,
+ apiOrigins: { admin: null, client: null, needsLocalApi: false },
+ });
+ await ensureDesktopLocalApiHealthy(projectRoot);
+ await new Promise((resolve) => {
+ const interval = setInterval(() => {
+ void ensureDesktopLocalApiHealthy(projectRoot).catch(() => {});
+ }, 20_000);
+ const onStop = (signal) => {
+ clearInterval(interval);
+ shutdown(signal);
+ resolve(0);
+ };
+ process.once('SIGINT', () => onStop('SIGINT'));
+ process.once('SIGTERM', () => onStop('SIGTERM'));
+ });
+ process.exit(0);
+ return;
+ }
+
+ process.env.REACTPRESS_LOCAL_MODE = '1';
+ process.env.REACTPRESS_SKIP_NGINX = '1';
+
+ console.log('');
+ logDevLine('dev.localWebIntro');
+
+ await runDevStartup(projectRoot, { webOnly: true, apiOrigins });
+}
+
+async function runThemeDev(
+ projectRoot = ensureOriginalCwd(),
+ { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {},
+) {
+ if (!hasResolvableActiveTheme(projectRoot)) {
+ console.error(t('dev.themeSiteSkipped'));
+ process.exit(1);
+ }
+
+ process.env.REACTPRESS_THEME_DEV_ONLY = '1';
+
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('exit', () => {
+ try {
+ releaseDevSession(projectRoot);
+ } catch {
+ // ignore
+ }
+ });
+
+ await runDevStartup(projectRoot, { themeOnly: true, apiOrigins });
+}
+
+async function runWebDev(
+ projectRoot = ensureOriginalCwd(),
+ { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {},
+) {
+ if (!hasWeb(projectRoot)) {
+ console.error(t('dev.noWeb'));
+ process.exit(1);
+ }
+
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('exit', () => {
+ try {
+ releaseDevSession(projectRoot);
+ } catch {
+ // ignore
+ }
+ });
+
+ await runDevStartup(projectRoot, { webOnly: true, apiOrigins });
+}
+
+async function runDev(
+ projectRoot = ensureOriginalCwd(),
+ { apiOrigins = { admin: null, client: null, needsLocalApi: true } } = {},
+) {
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('exit', () => {
+ try {
+ releaseDevSession(projectRoot);
+ } catch {
+ // ignore
+ }
+ });
+
+ await runDevStartup(projectRoot, { apiOrigins });
+}
+
+module.exports = {
+ runDev,
+ runWebDev,
+ runLocalWebDev,
+ runLocalMonorepoDev,
+ runThemeDev,
+ runDesktopDev,
+ runDevStartup,
+ buildToolkit,
+ assertDevPrerequisites,
+ prepareDevInfrastructure,
+ startDevStack,
+ detectProjectType,
+ nginxEntryUrl,
+};
diff --git a/cli/src/lib/docker.ts b/cli/src/lib/docker.ts
new file mode 100644
index 00000000..ba31a786
--- /dev/null
+++ b/cli/src/lib/docker.ts
@@ -0,0 +1,421 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { spawn, execSync, spawnSync } = require('child_process');
+const ora = require('ora');
+const { ensureOriginalCwd } = require('./root');
+const { detectProjectType } = require('./project-type');
+const { t } = require('./i18n');
+
+function isDockerRunning() {
+ try {
+ execSync('docker info', { stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function pickDockerComposeCommand() {
+ const v2 = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' });
+ if (v2.status === 0) return { command: 'docker', baseArgs: ['compose'] };
+
+ const v1 = spawnSync('docker-compose', ['version'], { stdio: 'ignore' });
+ if (v1.status === 0) return { command: 'docker-compose', baseArgs: [] };
+
+ return { command: 'docker', baseArgs: ['compose'] };
+}
+
+/**
+ * Resolve which docker-compose file to use for the current project.
+ *
+ * - Monorepo checkouts use `docker-compose.dev.yml` at the repo root.
+ * - Standalone projects use `.reactpress/docker-compose.yml` (managed by init).
+ *
+ * @returns {{ composeFile: string, cwd: string, type: 'monorepo' | 'standalone' }}
+ */
+function resolveComposeContext(projectRoot) {
+ const type = detectProjectType(projectRoot);
+ if (type === 'monorepo') {
+ const composeFile = path.join(projectRoot, 'docker-compose.dev.yml');
+ if (fs.existsSync(composeFile)) {
+ return { composeFile, cwd: projectRoot, type };
+ }
+ }
+ const standaloneCompose = path.join(projectRoot, '.reactpress', 'docker-compose.yml');
+ if (fs.existsSync(standaloneCompose)) {
+ return { composeFile: standaloneCompose, cwd: path.dirname(standaloneCompose), type: 'standalone' };
+ }
+ const fallback = path.join(projectRoot, 'docker-compose.dev.yml');
+ return { composeFile: fallback, cwd: projectRoot, type };
+}
+
+function runCompose(args, ctx, options = {}) {
+ const { command, baseArgs } = pickDockerComposeCommand();
+ return spawnSync(
+ command,
+ [...baseArgs, '-f', ctx.composeFile, ...args],
+ { stdio: options.stdio ?? 'inherit', cwd: ctx.cwd, ...options }
+ );
+}
+
+function stopDockerServices(projectRoot) {
+ console.log(t('docker.stopping'));
+ const ctx = resolveComposeContext(projectRoot);
+ const result = runCompose(['down'], ctx);
+ if (result.status !== 0) {
+ console.error(t('docker.stopFailed'));
+ throw new Error(t('docker.stopFailed'));
+ }
+ console.log(t('docker.stopped'));
+}
+
+function parseEnvValue(projectRoot, key, fallback) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));
+ if (match) return match[1].trim().replace(/^['"]|['"]$/g, '');
+ } catch {
+ // ignore
+ }
+ return fallback;
+}
+
+function parseDbPort(projectRoot) {
+ const raw = parseEnvValue(projectRoot, 'DB_PORT', '3306');
+ const port = parseInt(raw, 10);
+ return Number.isFinite(port) && port > 0 ? port : 3306;
+}
+
+function isPortListening(port, host = '127.0.0.1') {
+ const byLsof = spawnSync('lsof', [`-iTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
+ if (byLsof.status === 0 && byLsof.stdout.trim()) return true;
+ const byNc = spawnSync('nc', ['-z', host, String(port)], { stdio: 'ignore' });
+ return byNc.status === 0;
+}
+
+function isDbContainerRunning(container) {
+ const res = spawnSync('docker', ['inspect', '-f', '{{.State.Running}}', container], {
+ encoding: 'utf8',
+ });
+ return res.status === 0 && res.stdout.trim() === 'true';
+}
+
+async function startDockerServices(projectRoot) {
+ console.log(t('docker.starting'));
+ if (!isDockerRunning()) {
+ throw new Error(t('docker.notRunning'));
+ }
+ try {
+ const { ensureNginxConfig } = require('./nginx');
+ const { configPath, created } = ensureNginxConfig(projectRoot, { mode: 'dev' });
+ if (created) {
+ console.log(t('nginx.configCreated', { path: configPath }));
+ }
+ } catch (err) {
+ console.warn(t('nginx.ensureWarn', { message: err.message || err }));
+ }
+ const ctx = resolveComposeContext(projectRoot);
+ const dbPort = parseDbPort(projectRoot);
+
+ if (isPortListening(dbPort)) {
+ if (await probeMysqlHost(projectRoot)) {
+ console.log(t('docker.dbReuseExisting', { port: dbPort }));
+ const nginxOnly = runCompose(['up', '-d', '--no-deps', 'nginx'], ctx);
+ if (nginxOnly.status !== 0) {
+ throw new Error(t('docker.notRunning'));
+ }
+ console.log(t('docker.started'));
+ return;
+ }
+ console.warn(t('docker.dbPortInUseRecycle', { port: dbPort }));
+ spawnSync('docker', ['rm', '-f', 'reactpress_db'], { stdio: 'ignore' });
+ const result = runCompose(['up', '-d', '--no-deps', 'nginx'], ctx);
+ if (result.status !== 0) {
+ throw new Error(t('docker.notRunning'));
+ }
+ console.log(t('docker.started'));
+ return;
+ }
+
+ const dbResult = runCompose(['up', '-d', 'db'], ctx);
+ if (dbResult.status !== 0) {
+ throw new Error(t('docker.notRunning'));
+ }
+ const nginxResult = runCompose(['up', '-d', '--no-deps', 'nginx'], ctx);
+ if (nginxResult.status !== 0) {
+ throw new Error(t('docker.notRunning'));
+ }
+ console.log(t('docker.started'));
+}
+
+async function ensureDevDatabase(projectRoot, { quiet = false } = {}) {
+ const dbPort = parseDbPort(projectRoot);
+ const ctx = resolveComposeContext(projectRoot);
+ const container = resolveDbContainerName(ctx, projectRoot);
+
+ const finishWhenReady = async (maxAttempts = 60) => {
+ if (await waitForMysql(projectRoot, maxAttempts, { quiet })) return true;
+ return false;
+ };
+
+ if (await probeMysqlHost(projectRoot) && (await finishWhenReady(4))) {
+ return;
+ }
+
+ if (!quiet) console.log(t('docker.ensureDevDb'));
+ if (!isDockerRunning()) {
+ throw new Error(t('docker.devStartBlocked', { port: dbPort }));
+ }
+
+ for (const name of new Set([container, 'reactpress_db', 'reactpress_cli_db'])) {
+ spawnSync('docker', ['start', name], { stdio: 'ignore' });
+ }
+ await new Promise((r) => setTimeout(r, 1000));
+ if (await finishWhenReady(45)) return;
+
+ const composeStdio = quiet ? 'ignore' : 'inherit';
+
+ if (!isPortListening(dbPort)) {
+ const dbResult = runCompose(['up', '-d', '--remove-orphans', 'db'], ctx, { stdio: composeStdio });
+ if (dbResult.status !== 0) {
+ throw new Error(t('docker.mysqlNotReady'));
+ }
+ } else if (await probeMysqlHost(projectRoot)) {
+ if (!quiet) console.log(t('docker.dbReuseExisting', { port: dbPort }));
+ if (await finishWhenReady(30)) return;
+ } else {
+ if (!quiet) console.warn(t('docker.dbPortInUseRecycle', { port: dbPort }));
+ spawnSync('docker', ['rm', '-f', 'reactpress_db'], { stdio: 'ignore' });
+ await new Promise((r) => setTimeout(r, 500));
+ const recreate = runCompose(['up', '-d', '--remove-orphans', 'db'], ctx, { stdio: composeStdio });
+ if (recreate.status !== 0) {
+ throw new Error(t('docker.mysqlNotReady'));
+ }
+ }
+
+ if (await finishWhenReady(60)) return;
+
+ throw new Error(
+ t('docker.dbPortConflict', {
+ port: dbPort,
+ }),
+ );
+}
+
+/** Gate API boot — MySQL must accept connections on DB_PORT (avoids Nest retrying on :3307). */
+async function requireMysqlBeforeApi(projectRoot) {
+ const dbPort = parseDbPort(projectRoot);
+ if (await probeMysqlHost(projectRoot) && (await waitForMysql(projectRoot, 5))) {
+ return;
+ }
+ await ensureDevDatabase(projectRoot);
+ if (!(await probeMysqlHost(projectRoot))) {
+ throw new Error(
+ t('docker.dbPortConflict', {
+ port: dbPort,
+ }),
+ );
+ }
+}
+
+function resolveDbContainerName(ctx, projectRoot) {
+ if (ctx.type === 'standalone') return 'reactpress_cli_db';
+ return 'reactpress_db';
+}
+
+function resolveDbCredentialsFromEnv(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ let user = 'reactpress';
+ let password = 'reactpress';
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const u = content.match(/^DB_USER=(.+)$/m);
+ const p = content.match(/^(DB_PASSWD|DB_PASSWORD)=(.+)$/m);
+ if (u) user = u[1].trim().replace(/^['"]|['"]$/g, '');
+ if (p) password = p[2].trim().replace(/^['"]|['"]$/g, '');
+ } catch {
+ // ignore
+ }
+ return { user, password };
+}
+
+async function probeMysqlHost(projectRoot) {
+ const host = parseEnvValue(projectRoot, 'DB_HOST', '127.0.0.1');
+ const port = parseDbPort(projectRoot);
+ const user = parseEnvValue(projectRoot, 'DB_USER', 'reactpress');
+ const password =
+ parseEnvValue(projectRoot, 'DB_PASSWD', '') ||
+ parseEnvValue(projectRoot, 'DB_PASSWORD', 'reactpress');
+ const database = parseEnvValue(projectRoot, 'DB_DATABASE', 'reactpress');
+
+ let mysql;
+ try {
+ mysql = require('mysql2/promise');
+ } catch {
+ try {
+ mysql = require(path.join(projectRoot, 'server/node_modules/mysql2/promise'));
+ } catch {
+ return false;
+ }
+ }
+
+ try {
+ const conn = await mysql.createConnection({
+ host,
+ port,
+ user,
+ password,
+ database,
+ connectTimeout: 3000,
+ });
+ await conn.ping();
+ await conn.end();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function waitForMysql(projectRoot, maxAttempts = 30, { quiet = false } = {}) {
+ const ctx = resolveComposeContext(projectRoot);
+ const container = resolveDbContainerName(ctx, projectRoot);
+ const { user, password } = resolveDbCredentialsFromEnv(projectRoot);
+ const dbPort = parseDbPort(projectRoot);
+
+ if (!isDbContainerRunning(container) && isPortListening(dbPort)) {
+ const ready = await probeMysqlHost(projectRoot);
+ if (ready) {
+ if (!quiet) console.log(t('docker.mysqlExternalReady', { port: dbPort }));
+ return true;
+ }
+ }
+
+ const useSpinner = !quiet && process.stdout.isTTY;
+ const spinner = useSpinner
+ ? ora({
+ text: t('docker.waitingMysql'),
+ color: 'magenta',
+ spinner: 'dots',
+ }).start()
+ : null;
+
+ let attempts = 0;
+ while (attempts < maxAttempts) {
+ if (isDbContainerRunning(container)) {
+ const probe = spawnSync(
+ 'docker',
+ ['exec', container, 'mysql', `-u${user}`, `-p${password}`, '-e', 'SELECT 1'],
+ { stdio: 'ignore' }
+ );
+ if (probe.status === 0) {
+ if (spinner) spinner.succeed(t('docker.mysqlReady'));
+ return true;
+ }
+ } else if (await probeMysqlHost(projectRoot)) {
+ if (spinner) spinner.succeed(t('docker.mysqlExternalReady', { port: dbPort }));
+ return true;
+ }
+ attempts += 1;
+ if (spinner) {
+ spinner.text = t('docker.waitingMysqlProgress', { attempts, max: maxAttempts });
+ }
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+ if (spinner) spinner.fail(t('docker.mysqlTimeout'));
+ return false;
+}
+
+async function dockerStartWithDev(projectRoot) {
+ await startDockerServices(projectRoot);
+ const ready = await waitForMysql(projectRoot);
+ if (!ready) {
+ throw new Error(t('docker.mysqlNotReady'));
+ }
+
+ const { runDev } = require('./dev');
+ await runDev(projectRoot);
+}
+
+/**
+ * Run mysqldump inside the compose `db` container (MySQL image ships mysqldump).
+ * Used when the host has no `mysqldump` binary but Docker DB is running.
+ *
+ * @returns {{ ok: true, stdout: string } | { ok: false, stderr: string }}
+ */
+function mysqldumpFromDbContainer(projectRoot, { user, password, database }) {
+ const ctx = resolveComposeContext(projectRoot);
+ if (!fs.existsSync(ctx.composeFile)) {
+ return { ok: false, stderr: 'compose file missing' };
+ }
+ if (!isDockerRunning()) {
+ return { ok: false, stderr: 'docker not running' };
+ }
+ const container = resolveDbContainerName(ctx, projectRoot);
+ const res = spawnSync(
+ 'docker',
+ ['exec', container, 'mysqldump', `-u${user}`, `-p${password}`, database],
+ { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 }
+ );
+ if (res.error) {
+ return { ok: false, stderr: res.error.message };
+ }
+ if (res.status !== 0) {
+ return { ok: false, stderr: res.stderr || res.stdout || `exit ${res.status}` };
+ }
+ return { ok: true, stdout: res.stdout };
+}
+
+async function runDockerCommand(command, projectRoot = ensureOriginalCwd(), extraArgs = []) {
+ const ctx = resolveComposeContext(projectRoot);
+ switch (command) {
+ case 'up':
+ await startDockerServices(projectRoot);
+ await waitForMysql(projectRoot);
+ return;
+ case 'down':
+ case 'stop':
+ stopDockerServices(projectRoot);
+ return;
+ case 'start':
+ await dockerStartWithDev(projectRoot);
+ return;
+ case 'restart':
+ stopDockerServices(projectRoot);
+ await new Promise((r) => setTimeout(r, 2000));
+ await startDockerServices(projectRoot);
+ await waitForMysql(projectRoot);
+ return;
+ case 'status': {
+ const res = runCompose(['ps'], ctx);
+ if (res.status !== 0) {
+ throw new Error(t('docker.unknownCommand', { command: 'ps' }));
+ }
+ return;
+ }
+ case 'logs': {
+ const service = extraArgs[0];
+ const args = ['logs', '-f'];
+ if (service) args.push(service);
+ runCompose(args, ctx);
+ return;
+ }
+ default:
+ throw new Error(t('docker.unknownCommand', { command }));
+ }
+}
+
+module.exports = {
+ runDockerCommand,
+ startDockerServices,
+ stopDockerServices,
+ waitForMysql,
+ ensureDevDatabase,
+ requireMysqlBeforeApi,
+ probeMysqlHost,
+ isDockerRunning,
+ resolveComposeContext,
+ pickDockerComposeCommand,
+ mysqldumpFromDbContainer,
+};
diff --git a/cli/src/lib/doctor.ts b/cli/src/lib/doctor.ts
new file mode 100644
index 00000000..3d17f764
--- /dev/null
+++ b/cli/src/lib/doctor.ts
@@ -0,0 +1,285 @@
+// @ts-nocheck
+const fs = require('fs');
+const net = require('net');
+const path = require('path');
+const { execSync } = require('child_process');
+const ora = require('ora');
+const {
+ brand,
+ icon,
+ ok,
+ warn,
+ divider,
+ sectionHeader,
+ terminalWidth,
+ gradientText,
+ palette,
+} = require('../ui/theme');
+const { getHealthUrl, checkHealth } = require('./http');
+const { isDockerRunning } = require('./docker');
+const { checkNginx } = require('./nginx');
+const { envFileStatus } = require('./status');
+const { t } = require('./i18n');
+
+function checkNodeVersion() {
+ const major = parseInt(process.versions.node.split('.')[0], 10);
+ if (major >= 18) {
+ return { ok: true, message: `Node.js ${process.version}` };
+ }
+ return {
+ ok: false,
+ message: t('doctor.nodeBad', { version: process.version }),
+ fix: t('doctor.nodeFix'),
+ };
+}
+
+function checkDocker() {
+ if (isDockerRunning()) {
+ return { ok: true, message: t('doctor.dockerOk') };
+ }
+ return {
+ ok: false,
+ message: t('doctor.dockerBad'),
+ fix: t('doctor.dockerFix'),
+ };
+}
+
+function parseEnv(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ const out = {};
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ for (const line of content.split('\n')) {
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
+ if (m) out[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return out;
+}
+
+function checkPort(port, host = '127.0.0.1') {
+ return new Promise((resolve) => {
+ const socket = net.createConnection({ port, host }, () => {
+ socket.destroy();
+ resolve(true);
+ });
+ socket.on('error', () => resolve(false));
+ socket.setTimeout(1000, () => {
+ socket.destroy();
+ resolve(false);
+ });
+ });
+}
+
+async function checkPorts(projectRoot) {
+ const env = parseEnv(projectRoot);
+ const apiPort = parseInt(env.SERVER_PORT || '3002', 10);
+ const clientPort = parseInt(env.CLIENT_PORT || '3001', 10);
+
+ const healthUrl = getHealthUrl(projectRoot);
+ const apiHealth = await checkHealth(healthUrl);
+ if (apiHealth.ok) {
+ return {
+ ok: true,
+ message: t('doctor.portOk', { apiPort, clientPort }),
+ };
+ }
+
+ const [apiBusy, clientBusy] = await Promise.all([checkPort(apiPort), checkPort(clientPort)]);
+ const issues = [];
+ if (apiBusy) issues.push(t('doctor.portApiBusy', { port: apiPort }));
+ if (clientBusy) issues.push(t('doctor.portClientBusy', { port: clientPort }));
+ if (issues.length) {
+ return {
+ ok: false,
+ message: issues.join('; '),
+ fix: t('doctor.portFix'),
+ };
+ }
+ return {
+ ok: true,
+ message: t('doctor.portOk', { apiPort, clientPort }),
+ };
+}
+
+async function checkDatabase(projectRoot) {
+ const env = parseEnv(projectRoot);
+ const dbType = String(env.DB_TYPE || 'mysql').toLowerCase();
+
+ if (dbType === 'sqlite') {
+ const { probeSqliteDatabase } = require('../core/services/database/sqlite');
+ const result = await probeSqliteDatabase(projectRoot);
+ return {
+ ok: result.ok,
+ message: result.ok
+ ? t('doctor.dbSqliteOk', { detail: result.message ?? '' })
+ : t('doctor.dbSqliteBad', { error: result.message ?? '' }),
+ fix: result.ok ? undefined : t('doctor.dbSqliteFix'),
+ };
+ }
+
+ const host = env.DB_HOST || '127.0.0.1';
+ const port = parseInt(env.DB_PORT || '3306', 10);
+ const user = env.DB_USER || 'root';
+ const password = env.DB_PASSWD || env.DB_PASSWORD || 'root';
+ const database = env.DB_DATABASE || 'reactpress';
+
+ return new Promise((resolve) => {
+ let mysql;
+ try {
+ mysql = require('mysql2/promise');
+ } catch {
+ try {
+ mysql = require(path.join(projectRoot, 'server/node_modules/mysql2/promise'));
+ } catch {
+ resolve({
+ ok: false,
+ message: t('doctor.dbNoMysql2'),
+ fix: t('doctor.dbMysql2Fix'),
+ });
+ return;
+ }
+ }
+
+ mysql
+ .createConnection({ host, port, user, password, database, connectTimeout: 5000 })
+ .then(async (conn) => {
+ await conn.ping();
+ await conn.end();
+ resolve({
+ ok: true,
+ message: t('doctor.dbOk', { host, port, database }),
+ });
+ })
+ .catch((err) => {
+ resolve({
+ ok: false,
+ message: t('doctor.dbBad', { error: err.message }),
+ fix: t('doctor.dbFix'),
+ });
+ });
+ });
+}
+
+async function checkApiHealth(projectRoot) {
+ const healthUrl = getHealthUrl(projectRoot);
+ const result = await checkHealth(healthUrl);
+ if (result.ok) {
+ return { ok: true, message: t('doctor.apiOk', { url: healthUrl }) };
+ }
+ return {
+ ok: false,
+ message: t('doctor.apiBad', { url: healthUrl }),
+ fix: t('doctor.apiFix'),
+ };
+}
+
+function checkPnpm() {
+ try {
+ const v = execSync('pnpm -v', { encoding: 'utf8' }).trim();
+ return { ok: true, message: `pnpm ${v}` };
+ } catch {
+ return {
+ ok: false,
+ message: t('doctor.pnpmBad'),
+ fix: t('doctor.pnpmFix'),
+ };
+ }
+}
+
+async function runCheckWithSpinner(name, run) {
+ const spinner = ora({
+ text: t('doctor.checking', { name }),
+ color: 'magenta',
+ spinner: 'dots',
+ }).start();
+ const result = await run();
+ if (result.ok) {
+ spinner.stop();
+ } else {
+ spinner.stop();
+ }
+ return result;
+}
+
+async function runDoctor(projectRoot) {
+ const env = envFileStatus(projectRoot);
+ const checks = [
+ { name: 'Node.js', run: () => checkNodeVersion() },
+ { name: 'pnpm', run: () => checkPnpm() },
+ {
+ name: t('doctor.check.config'),
+ run: () => ({
+ ok: env.config,
+ message: env.config ? t('doctor.configOk') : t('doctor.configBad'),
+ fix: t('doctor.configFix'),
+ }),
+ },
+ {
+ name: t('doctor.check.env'),
+ run: () => ({
+ ok: env.env,
+ message: env.env ? t('doctor.envOk') : t('doctor.envBad'),
+ fix: t('doctor.envFix'),
+ }),
+ },
+ { name: 'Docker', run: () => checkDocker() },
+ { name: t('doctor.check.nginx'), run: () => checkNginx(projectRoot) },
+ { name: t('doctor.check.ports'), run: () => checkPorts(projectRoot) },
+ { name: t('doctor.check.database'), run: () => checkDatabase(projectRoot) },
+ { name: t('doctor.check.api'), run: () => checkApiHealth(projectRoot) },
+ ];
+
+ const w = Math.min(terminalWidth() - 4, 52);
+ const results = [];
+ const fixes = [];
+
+ console.log('');
+ console.log(
+ ` ${gradientText(t('doctor.title'), [palette.primary, palette.accent], { bold: true })} ${brand.dim(t('doctor.subtitle'))}`
+ );
+ console.log(` ${brand.dim(t('doctor.project', { path: projectRoot }))}`);
+ console.log(` ${divider(w)}`);
+
+ for (const { name, run } of checks) {
+ const result = await runCheckWithSpinner(name, run);
+ results.push({ name, ...result });
+ const mark = result.ok ? icon.ok : icon.fail;
+ const msgColor = result.ok ? brand.dim : brand.warn;
+ console.log(` ${mark} ${brand.bold(name)} ${msgColor(result.message)}`);
+ if (!result.ok && result.fix) {
+ fixes.push({ name, fix: result.fix });
+ }
+ }
+
+ const passed = results.filter((r) => r.ok).length;
+ const failed = results.length - passed;
+
+ console.log(` ${divider(w)}`);
+ console.log(
+ ` ${brand.dim(t('doctor.summary', { passed, failed, total: results.length }))}`
+ );
+
+ if (failed === 0) {
+ console.log(` ${ok(t('doctor.allPass'))}`);
+ } else {
+ console.log(` ${warn(t('doctor.failed', { count: failed }))}`);
+ if (fixes.length) {
+ console.log('');
+ console.log(sectionHeader(t('doctor.fixesHeader')));
+ for (const { name, fix } of fixes) {
+ console.log(` ${brand.primary('→')} ${brand.dim(name)} ${brand.warn(fix)}`);
+ }
+ }
+ }
+ console.log('');
+ return failed === 0 ? 0 : 1;
+}
+
+module.exports = {
+ runDoctor,
+ checkNodeVersion,
+ checkDocker,
+};
diff --git a/cli/src/lib/health-parse.test.ts b/cli/src/lib/health-parse.test.ts
new file mode 100644
index 00000000..30ee285d
--- /dev/null
+++ b/cli/src/lib/health-parse.test.ts
@@ -0,0 +1,37 @@
+// @ts-nocheck
+const assert = require('assert');
+const {
+ normalizeHealthPayload,
+ isHealthPayloadReady,
+ isHealthHttpReady,
+} = require('./health-parse');
+
+assert.strictEqual(
+ isHealthHttpReady(200, {
+ statusCode: 200,
+ success: true,
+ data: { status: 'ok', database: 'up', version: '3.0.0' },
+ }),
+ true,
+);
+
+assert.strictEqual(
+ isHealthHttpReady(200, { status: 'ok', database: 'up' }),
+ true,
+);
+
+assert.strictEqual(
+ isHealthHttpReady(200, {
+ statusCode: 200,
+ success: true,
+ data: { status: 'degraded', database: 'down' },
+ }),
+ false,
+);
+
+assert.strictEqual(
+ isHealthPayloadReady(normalizeHealthPayload({ status: 'ok', database: 'up' })),
+ true,
+);
+
+console.log('health-parse.test.js: ok');
diff --git a/cli/src/lib/health-parse.ts b/cli/src/lib/health-parse.ts
new file mode 100644
index 00000000..cef94280
--- /dev/null
+++ b/cli/src/lib/health-parse.ts
@@ -0,0 +1,36 @@
+// @ts-nocheck
+/**
+ * Parse `/api/health` JSON — supports raw Nest payload and TransformInterceptor wrapper.
+ */
+
+function normalizeHealthPayload(body) {
+ if (!body || typeof body !== 'object') return null;
+ if (
+ body.data &&
+ typeof body.data === 'object' &&
+ (Object.prototype.hasOwnProperty.call(body.data, 'status') ||
+ Object.prototype.hasOwnProperty.call(body.data, 'database'))
+ ) {
+ return body.data;
+ }
+ return body;
+}
+
+function isHealthPayloadReady(payload) {
+ if (!payload || typeof payload !== 'object') return false;
+ if (Object.prototype.hasOwnProperty.call(payload, 'database')) {
+ return payload.status === 'ok' && payload.database === 'up';
+ }
+ return payload.status === 'ok' || payload.status === 'OK';
+}
+
+function isHealthHttpReady(statusCode, body) {
+ if (statusCode !== 200) return false;
+ return isHealthPayloadReady(normalizeHealthPayload(body));
+}
+
+module.exports = {
+ normalizeHealthPayload,
+ isHealthPayloadReady,
+ isHealthHttpReady,
+};
diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts
new file mode 100644
index 00000000..79cabe77
--- /dev/null
+++ b/cli/src/lib/http.ts
@@ -0,0 +1,249 @@
+// @ts-nocheck
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { DEV_PORTS } = require('./ports');
+const {
+ normalizeHealthPayload,
+ isHealthPayloadReady,
+ isHealthHttpReady,
+} = require('./health-parse');
+
+function loadServerSiteUrl(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const match = content.match(/^SERVER_SITE_URL=(.+)$/m);
+ if (match) {
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return 'http://localhost:3002';
+}
+
+function loadWebAdminUrl(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const urlMatch = content.match(/^WEB_ADMIN_URL=(.+)$/m);
+ if (urlMatch) {
+ return urlMatch[1].trim().replace(/^['"]|['"]$/g, '');
+ }
+ const portMatch = content.match(/^WEB_ADMIN_PORT=(.+)$/m);
+ if (portMatch) {
+ const port = parseInt(portMatch[1].trim(), 10);
+ if (Number.isInteger(port) && port > 0) {
+ return `http://localhost:${port}`;
+ }
+ }
+ } catch {
+ // ignore
+ }
+ return `http://localhost:${DEV_PORTS.ADMIN_WEB}`;
+}
+
+function loadClientSiteUrl(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const match = content.match(/^CLIENT_SITE_URL=(.+)$/m);
+ if (match) {
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return 'http://localhost:3001';
+}
+
+function getApiPrefix(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const match = content.match(/^SERVER_API_PREFIX=(.+)$/m);
+ if (match) {
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return '/api';
+}
+
+function getHealthUrl(projectRoot) {
+ const base = loadServerSiteUrl(projectRoot).replace(/\/$/, '');
+ const prefix = getApiPrefix(projectRoot).replace(/\/$/, '');
+ return `${base}${prefix}/health`;
+}
+
+/** Use IPv4 loopback for probes — `localhost` often resolves to `::1` while Nest binds IPv4. */
+function parseProbeTarget(urlString) {
+ const parsed = new URL(urlString);
+ const host = parsed.hostname;
+ if (host === 'localhost' || host === '::1' || host === '[::1]') {
+ parsed.hostname = '127.0.0.1';
+ }
+ return parsed;
+}
+
+function normalizeProbeUrl(urlString) {
+ return parseProbeTarget(urlString).toString();
+}
+
+function probeHttp(url, timeoutMs = 3000) {
+ return new Promise((resolve) => {
+ let parsed;
+ try {
+ parsed = parseProbeTarget(url);
+ } catch {
+ resolve({ ok: false, statusCode: 0, data: null });
+ return;
+ }
+ const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80);
+ const req = http.request(
+ {
+ hostname: parsed.hostname,
+ port,
+ family: 4,
+ path: parsed.pathname + (parsed.search || ''),
+ method: 'GET',
+ timeout: timeoutMs,
+ },
+ (res) => {
+ let body = '';
+ res.on('data', (chunk) => {
+ body += chunk;
+ });
+ res.on('end', () => {
+ const ok = res.statusCode === 200;
+ let data = null;
+ try {
+ data = JSON.parse(body);
+ } catch {
+ // ignore
+ }
+ resolve({ ok, statusCode: res.statusCode, data });
+ });
+ }
+ );
+ req.on('timeout', () => {
+ req.destroy();
+ resolve({ ok: false, statusCode: 0, data: null });
+ });
+ req.on('error', () => resolve({ ok: false, statusCode: 0, data: null }));
+ req.end();
+ });
+}
+
+/**
+ * Health probe: prefers `/api/health` JSON; falls back to API prefix (e.g. Swagger)
+ * for older bundled servers that omit the health route.
+ */
+async function checkHealth(url, timeoutMs = 3000) {
+ const primary = await probeHttp(normalizeProbeUrl(url), timeoutMs);
+ const payload = normalizeHealthPayload(primary.data);
+ if (isHealthHttpReady(primary.statusCode, primary.data)) {
+ return { ok: true, statusCode: primary.statusCode, data: payload };
+ }
+ if (primary.ok) {
+ return { ok: false, statusCode: primary.statusCode, data: payload ?? primary.data };
+ }
+
+ if (primary.statusCode === 404 || primary.statusCode === 0) {
+ try {
+ const parsed = parseProbeTarget(url);
+ const prefix = parsed.pathname.replace(/\/health\/?$/, '') || '/api';
+ const candidates = [
+ `${parsed.origin}${prefix}/`,
+ `${parsed.origin}${prefix}`,
+ parsed.origin,
+ ];
+ for (const fallback of candidates) {
+ const alt = await probeHttp(fallback, timeoutMs);
+ if (alt.statusCode === 200) {
+ return {
+ ok: false,
+ statusCode: 200,
+ data: { status: 'degraded', database: 'unknown' },
+ };
+ }
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ return primary;
+}
+
+function isHttpResponding(url, timeoutMs = 2000) {
+ return new Promise((resolve) => {
+ let parsed;
+ try {
+ parsed = parseProbeTarget(url);
+ } catch {
+ resolve(false);
+ return;
+ }
+
+ const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80);
+ const req = http.request(
+ {
+ hostname: parsed.hostname,
+ port,
+ family: 4,
+ path: parsed.pathname || '/',
+ method: 'GET',
+ timeout: timeoutMs,
+ },
+ (res) => resolve(res.statusCode > 0)
+ );
+
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ req.on('error', () => resolve(false));
+ req.end();
+ });
+}
+
+async function waitForHttp(url, timeoutMs = 120_000, intervalMs = 500) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await isHttpResponding(url)) {
+ return true;
+ }
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ return false;
+}
+
+/** Poll until HTTP 200 — used for theme dev homepage compile readiness. */
+async function waitForHttpOk(url, timeoutMs = 120_000, intervalMs = 500) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const result = await probeHttp(normalizeProbeUrl(url), Math.min(intervalMs + 500, 3000));
+ if (result.ok) return true;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ return false;
+}
+
+module.exports = {
+ isHealthPayloadReady,
+ loadServerSiteUrl,
+ loadWebAdminUrl,
+ loadClientSiteUrl,
+ getApiPrefix,
+ getHealthUrl,
+ normalizeProbeUrl,
+ checkHealth,
+ isHttpResponding,
+ waitForHttp,
+ waitForHttpOk,
+ probeHttp,
+ normalizeProbeUrl,
+};
diff --git a/cli/lib/i18n/index.js b/cli/src/lib/i18n/index.ts
similarity index 100%
rename from cli/lib/i18n/index.js
rename to cli/src/lib/i18n/index.ts
diff --git a/cli/src/lib/i18n/strings.ts b/cli/src/lib/i18n/strings.ts
new file mode 100644
index 00000000..5b21c4ca
--- /dev/null
+++ b/cli/src/lib/i18n/strings.ts
@@ -0,0 +1,1211 @@
+/** CLI user-facing strings — English first, Chinese via REACTPRESS_LANG=zh or zh LANG */
+const STRINGS = {
+ en: {
+ 'cli.description':
+ 'ReactPress 4.0 CLI — init, dev, plugins, desktop, themes, build, and publish',
+ 'cli.init.description': 'Initialize project (.reactpress/config.json + .env + Docker MySQL)',
+ 'cli.init.directory': 'Project directory',
+ 'cli.init.force': 'Overwrite existing config',
+ 'cli.init.local': 'Initialize with embedded SQLite (no Docker)',
+ 'cli.dev.description': 'Zero-config dev: env check + toolkit build + API + frontend',
+ 'cli.dev.apiOnly': 'API only (watch)',
+ 'cli.dev.local': 'Local SQLite mode (no Docker/nginx)',
+ 'cli.dev.clientOnly': 'Frontend only',
+ 'cli.dev.webOnly': 'Admin SPA + API (web/)',
+ 'cli.dev.remoteOrigin': 'Default remote API URL; with no admin/client flags, both use remote',
+ 'cli.dev.adminOrigin': 'Admin API: local | remote | URL (remote uses --remote-origin default)',
+ 'cli.dev.clientOrigin': 'Client/theme API (nginx /api): local | remote | URL',
+ 'cli.dev.remoteOriginRequired': '--remote-origin requires a URL (e.g. api.gaoredu.com)',
+ 'cli.dev.remoteDefaultRequired': 'Use remote with a URL: --remote-origin or pass a URL to admin/client-origin',
+ 'cli.dev.invalidOrigin': 'Invalid origin; use local, remote, or a host/URL',
+ 'cli.dev.remoteOriginIncompatibleApiOnly': 'Remote API flags cannot be used with --api-only',
+ 'cli.desktopDev.description': 'Desktop dev: embedded SQLite API + admin SPA + Electron (no Docker/MySQL)',
+ 'cli.server.description': 'Manage API service',
+ 'cli.server.start.description': 'Start API (wait until HTTP ready)',
+ 'cli.server.start.pm2': 'Start with PM2 (production)',
+ 'cli.server.start.bg': 'Start in background without waiting for HTTP',
+ 'cli.server.stop': 'Stop API',
+ 'cli.server.restart': 'Restart API',
+ 'cli.server.status': 'API status',
+ 'cli.client.description': 'Manage frontend',
+ 'cli.client.start': 'Start Next.js client',
+ 'cli.client.start.pm2': 'Start with PM2',
+ 'cli.client.restart': 'Rebuild active theme and restart visitor client',
+ 'themeProd.building': 'Building active theme "{id}"…',
+ 'themeProd.installingDeps': 'Installing dependencies for theme "{id}"…',
+ 'themeProd.reusingBuild': 'Reusing existing build for theme "{id}" (skip rebuild)',
+ 'themeProd.restarting': 'Restarting visitor client for theme "{id}"…',
+ 'themeProd.restarted': 'Visitor client is running theme "{id}".',
+ 'themePreview.backgroundBuildScheduled':
+ 'Scheduling background production builds for {count} preview theme(s)…',
+ 'themePreview.warmingAll': 'Pre-building {count} theme preview(s) for fast switching…',
+ 'themePreview.warmingAllSkipped': '{count} preview build(s) already up to date',
+ 'themePreview.installingDeps': 'Installing dependencies for preview theme "{id}"…',
+ 'themePreview.building': 'Building preview theme "{id}"…',
+ 'themePreview.reusingBuild': 'Reusing existing build for preview theme "{id}" (skip rebuild)',
+ 'themePreview.buildDone': 'Preview theme "{id}" build finished.',
+ 'themePreview.buildFailed': 'Preview theme "{id}" build failed: {message}',
+ 'themePreview.starting':
+ 'Preview theme "{id}" → {url} (port {port}, {dir}, {mode})',
+ 'themePreview.ready': 'Preview ready: {url} (theme: {id})',
+ 'cli.build.description': 'Build production artifacts',
+ 'cli.docker.description': 'Docker dev environment (MySQL + nginx)',
+ 'cli.docker.up': 'Start Docker services and wait for MySQL',
+ 'cli.docker.down': 'Stop Docker services',
+ 'cli.docker.start': 'Start Docker + full-stack dev (API + frontend)',
+ 'cli.docker.restart': 'Restart Docker services',
+ 'cli.docker.status': 'Docker container status',
+ 'cli.docker.logs': 'Docker logs (db | nginx)',
+ 'cli.nginx.description': 'Nginx reverse proxy (unified entry on :80)',
+ 'cli.nginx.ensure': 'Write default nginx config if missing',
+ 'cli.nginx.up': 'Start nginx container',
+ 'cli.nginx.down': 'Stop nginx container',
+ 'cli.nginx.restart': 'Restart nginx container',
+ 'cli.nginx.status': 'Show nginx container and config status',
+ 'cli.nginx.logs': 'Follow nginx container logs',
+ 'cli.nginx.test': 'Validate nginx config inside container (nginx -t)',
+ 'cli.nginx.reload': 'Reload nginx after config change',
+ 'cli.nginx.open': 'Open nginx entry URL in browser',
+ 'cli.nginx.prod': 'Use production compose + nginx.conf (monorepo only)',
+ 'cli.nginx.force': 'Overwrite existing nginx config from template',
+ 'cli.help.nginx': ' reactpress nginx up Start reverse proxy (:80)',
+ 'cli.status.description': 'Project, API, frontend, and Docker status',
+ 'cli.doctor.description': 'Diagnose Node, Docker, ports, database, and API health',
+ 'cli.db.description': 'Database operations',
+ 'cli.db.backup': 'Backup current project database with mysqldump',
+ 'cli.db.backup.output': 'Output SQL file path',
+ 'cli.publish.description': 'Build and publish npm packages',
+ 'cli.publish.build': 'Build publish artifacts only',
+ 'cli.publish.publish': 'Publish core packages to npm',
+ 'cli.start.description': 'Production: start API + frontend',
+ 'cli.help.examples': 'Examples:',
+ 'cli.help.interactive': ' reactpress Interactive menu',
+ 'cli.help.dev': ' reactpress dev Zero-config full-stack dev',
+ 'cli.help.init': ' reactpress init --force Re-initialize config',
+ 'cli.help.server': ' reactpress server start Start API',
+ 'cli.help.status': ' reactpress status Combined status',
+ 'cli.help.doctor': ' reactpress doctor Environment diagnostics',
+ 'cli.help.docker': ' reactpress docker start Docker + full stack',
+ 'cli.help.build':
+ ' reactpress build -t all Build (toolkit|plugins|server|web|theme|docs|all)',
+ 'cli.help.publish': ' reactpress publish Publish npm packages (maintainers)',
+ 'cli.help.theme': ' reactpress theme add Install theme from npm',
+ 'cli.help.themeList': ' reactpress theme list List available themes',
+ 'cli.help.initLocal': ' reactpress init --local Initialize with SQLite (no Docker)',
+ 'cli.help.devLocal': ' reactpress dev --local SQLite dev (no Docker/nginx)',
+ 'cli.help.desktop': ' reactpress desktop dev Desktop (Electron + SQLite)',
+ 'cli.help.plugin': ' reactpress plugin install seo Install a plugin',
+ 'cli.help.dbBackup': ' reactpress db backup Backup MySQL database',
+ 'cli.plugin.description': 'Manage ReactPress plugins',
+ 'cli.plugin.install.description': 'Install a local plugin into .reactpress/plugins',
+ 'cli.plugin.install.id': 'Plugin id from plugins/ registry',
+ 'cli.plugin.list.description': 'List registered plugins',
+ 'cli.theme.description': 'Install and manage themes',
+ 'cli.theme.add.description': 'Install a theme from an npm package spec or .tgz file',
+ 'cli.theme.add.spec': 'npm package spec (e.g. @fecommunity/reactpress-theme-starter@1.0.0-beta.0)',
+ 'cli.theme.add.catalog': 'Install from themes/{dir}/package.json by theme id',
+ 'cli.theme.add.skipDeps': 'Skip pnpm/npm install in the theme directory',
+ 'cli.theme.list.description': 'List available theme packages',
+ 'themeInstall.specRequired': 'Theme npm spec is required',
+ 'themeInstall.installing': 'Installing theme from npm: {spec}',
+ 'themeInstall.success': 'Theme "{name}" installed as "{id}" → {dir}',
+ 'themeInstall.nextActivate': 'Enable it in Admin → Appearance → Themes, or: POST /extension/themes/{id}/activate',
+ 'themeInstall.listHeading': 'Available themes:',
+ 'themeInstall.listEmpty': 'No theme packages found under themes/ or .reactpress/runtime/',
+ 'cli.build.target': 'Build target: toolkit | plugins | server | web | theme | docs | all',
+ 'cli.build.lowMem': 'Cap Node heap for builds and skip unchanged steps (2GB VPS)',
+ 'banner.subtitle': ' · Full-stack publishing CLI ',
+ /** Left label for the decorative pulse bar (not a URL — the repo link
+ * lives directly under the title bar at the top of the card). */
+ 'banner.pulseLabel': 'Setup',
+ 'banner.pulseReady': 'READY',
+ 'banner.pulsePending': 'INIT',
+ 'banner.label.mode': 'MODE',
+ 'banner.label.path': 'PATH',
+ 'banner.mode.standalone': 'STANDALONE',
+ 'banner.mode.monorepo': 'MONOREPO',
+ 'banner.mode.uninitialized': 'UNINITIALIZED',
+ 'banner.systemLabel': 'SYSTEM',
+ 'banner.systemOnline': 'ONLINE',
+ 'banner.systemPending': 'PENDING',
+ 'menu.dev': 'Zero-config dev (env + DB + API + frontend)',
+ 'menu.init': 'Initialize project (.reactpress + .env + database)',
+ 'menu.status': 'View project status',
+ 'menu.doctor': 'Environment diagnostics (doctor)',
+ 'menu.devApi': 'API only (dev watch)',
+ 'menu.devClient': 'Frontend only',
+ 'menu.serverStart': 'Start API (production background)',
+ 'menu.serverStop': 'Stop API',
+ 'menu.serverRestart': 'Restart API',
+ 'menu.build': 'Build (toolkit → server → client)',
+ 'menu.buildTarget': 'What do you want to build?',
+ 'menu.buildAll': 'All (toolkit → server → client)',
+ 'menu.dockerStart': 'Docker dev (DB + nginx + full stack)',
+ 'menu.dockerUp': 'Docker: database only',
+ 'menu.dockerStop': 'Stop Docker services',
+ 'menu.openAdmin': 'Open admin in browser',
+ 'menu.publish': 'Publish npm packages (interactive)',
+ 'menu.exit': 'Exit',
+ 'menu.prompt': 'Choose an action',
+ 'menu.back': 'Return to main menu?',
+ 'menu.retry': 'Return to main menu and retry?',
+ 'menu.startingDev': 'Starting full-stack dev…',
+ 'menu.initProject': 'Initializing project…',
+ 'menu.done': 'Done',
+ 'menu.opening': 'Opening {url}',
+ 'menu.goodbye': ' Goodbye.',
+ 'menu.section.run': 'Run',
+ 'menu.section.extend': '4.0 Extend',
+ 'menu.section.lifecycle': 'Lifecycle',
+ 'menu.section.build': 'Build & Deploy',
+ 'menu.section.tools': 'Tools',
+ 'menu.devDesktop': 'Desktop dev (SQLite + Electron)',
+ 'menu.hint.devDesktop': 'no Docker',
+ 'menu.devWeb': 'Admin SPA + API only',
+ 'menu.hint.devWeb': 'web/ dev',
+ 'menu.devLocalWeb': 'Local web (SQLite in browser)',
+ 'menu.hint.devLocalWeb': 'no Docker/Electron',
+ 'menu.initLocal': 'Initialize with SQLite (no Docker)',
+ 'menu.hint.initLocal': 'init --local',
+ 'menu.themeList': 'List available themes',
+ 'menu.hint.themeList': 'theme list',
+ 'menu.pluginList': 'List available plugins',
+ 'menu.hint.pluginList': 'plugin list',
+ 'menu.dbBackup': 'Backup database',
+ 'menu.hint.dbBackup': 'mysqldump',
+ 'menu.tip': 'Tip: arrow keys to navigate, Enter to select, Ctrl+C to quit.',
+ 'menu.shortcuts': '↑/↓ navigate · enter select · esc back · ctrl+c quit',
+ 'menu.statusHeader': 'Status',
+ 'menu.contextStandalone': 'Project mode · standalone (using bundled API)',
+ 'menu.contextMonorepo': 'Project mode · monorepo (server/src + client/)',
+ 'menu.contextUnknown': 'Project mode · not initialized (run `init`)',
+ 'menu.statusApi': 'API {status}',
+ 'menu.statusDb': 'DB {status}',
+ 'menu.statusDocker': 'Docker {status}',
+ 'menu.statusLabelApi': 'API',
+ 'menu.statusLabelDb': 'DB',
+ 'menu.statusLabelDocker': 'Docker',
+ 'menu.statusChecking': 'checking…',
+ 'menu.startingApi': 'Starting API…',
+ 'menu.stoppingApi': 'Stopping API…',
+ 'menu.restartingApi': 'Restarting API…',
+ 'menu.statusOn': 'online',
+ 'menu.statusOff': 'offline',
+ 'menu.statusReady': 'ready',
+ 'menu.statusNotReady': 'not ready',
+ 'menu.statusYes': 'available',
+ 'menu.statusNo': 'unavailable',
+ 'menu.hint.dev': 'API + DB + frontend',
+ 'menu.hint.init': '.reactpress + .env',
+ 'menu.hint.status': 'all services overview',
+ 'menu.hint.doctor': 'environment diagnostics',
+ 'menu.hint.devApi': 'watch mode',
+ 'menu.hint.devClient': 'Next.js dev',
+ 'menu.hint.serverStart': 'production background',
+ 'menu.hint.serverStop': '',
+ 'menu.hint.serverRestart': '',
+ 'menu.hint.build': 'production output',
+ 'menu.hint.dockerStart': 'DB + nginx + dev stack',
+ 'menu.hint.dockerUp': 'database only',
+ 'menu.hint.dockerStop': '',
+ 'menu.nginxUp': 'Start nginx reverse proxy (:80)',
+ 'menu.nginxOpen': 'Open nginx entry in browser',
+ 'menu.nginxReload': 'Reload nginx after editing config',
+ 'menu.hint.nginxUp': 'unified entry :80',
+ 'menu.hint.nginxOpen': 'http://localhost',
+ 'menu.hint.nginxReload': 'nginx -t && reload',
+ 'menu.hint.openAdmin': 'opens in browser',
+ 'menu.hint.publish': 'maintainers only',
+ 'menu.hint.exit': '',
+ 'menu.actionPrefix': 'action',
+ 'dev.phaseApi': 'API → :3002',
+ 'dev.phasePrerequisites': 'Checking Node.js and Docker…',
+ 'dev.phaseInfra': 'Starting MySQL and nginx…',
+ 'dev.phaseServices': 'Starting API, admin SPA, and theme…',
+ 'dev.phasePrerequisitesDesktop': 'Checking Node.js…',
+ 'dev.phaseInfraDesktop': 'Building toolkit & local workspace…',
+ 'dev.phaseServicesDesktop': 'Starting admin SPA and Electron…',
+ 'dev.phaseServicesLocalWeb': 'Starting admin SPA (browser preview)…',
+ 'dev.previewPrewarmStarting': 'Pre-building theme previews for fast switching…',
+ 'dev.remoteApiUsing': 'Using remote API (nginx /api → {url})',
+ 'dev.adminApiRemote': 'Admin API → {url}',
+ 'dev.clientApiRemote': 'Client API (nginx /api) → {url}',
+ 'dev.nginxReadyRemote': 'nginx ready at {url} (client API → {api})',
+ 'dev.checkNodeOk': 'Node.js {version}',
+ 'dev.checkDockerOk': 'Docker is running',
+ 'dev.prerequisitesOk': '✓ Node {version} · ✓ Docker',
+ 'dev.prerequisitesOkDesktop': '✓ Node {version} · SQLite (no Docker)',
+ 'dev.apiKept': 'Keeping healthy API on port {port}',
+ 'dev.timingReady': 'Ready in {summary}',
+ 'dev.timingInfra': 'infra',
+ 'dev.timingServices': 'services',
+ 'dev.timingApiReused': 'API reused',
+ 'dev.waitingApiQuiet': 'Waiting for API…',
+ 'dev.mysqlReadyQuiet': 'MySQL ready',
+ 'dev.themeStarting': 'Theme "{id}" → :{port}',
+ 'dev.themeCacheClearedForRemote': 'Cleared theme .next (remote API — stale SERVER_API_URL)',
+ 'dev.themeReadyQuiet': 'Visitor site ready → {url}',
+ 'dev.phaseAdmin': 'Starting admin SPA (internal :3000/admin/)…',
+ 'dev.phaseTheme': 'Starting active theme site (internal :3001)…',
+ 'dev.phaseClient': 'Starting legacy client…',
+ 'dev.phaseNginx': 'Starting nginx unified entry (:80)…',
+ 'dev.phaseNginxWait': 'Waiting for admin & theme, then nginx…',
+ 'dev.waitingProxies': 'Waiting for admin & theme…',
+ 'dev.startingAdmin': 'Admin dev server → {url}',
+ 'dev.adminNginxSlow': '[reactpress] Admin via nginx not ready: {url} — ensure Vite base is /admin/ (restart pnpm dev)',
+ 'dev.nginxReady': 'Nginx → {url}',
+ 'dev.proxiesReady': 'Admin & theme dev servers listening',
+ 'dev.portApiBusy': '[reactpress] Port {port} is already in use (API not healthy). Stop the other process or run: reactpress doctor',
+ 'dev.portApiBusyHint': '[reactpress] Tip: lsof -i :3002 · avoid running pnpm dev in two terminals',
+ 'dev.startingApi': 'Starting API (port 3002)…',
+ 'dev.waitingApi': 'Waiting for API: {url}',
+ 'dev.waitingApiCompile': 'compiling (port {port} not listening yet)',
+ 'dev.waitingApiStarting': 'port open, waiting for health',
+ 'dev.healthDbDown': 'database down',
+ 'dev.healthDegraded': 'API degraded',
+ 'dev.apiTimeout': '[reactpress] API not ready within {seconds}s.\n → Run reactpress doctor\n → Embedded MySQL: reactpress docker up\n → Check DB_* and SERVER_SITE_URL in .env',
+ 'dev.apiReusing': 'API on :{port} (healthy, skipped restart)',
+ 'dev.apiReady': 'API ready',
+ 'dev.toolkitUpToDate': 'Toolkit build skipped (dist is up to date; set REACTPRESS_FORCE_TOOLKIT_BUILD=1 to rebuild)',
+ 'dev.themeSiteSkipped': 'visitor site (:3001) will not start until the theme package exists',
+ 'dev.themeBackground': 'Visitor site (:3001) compiling in background — banner shows when API & admin are ready',
+ 'dev.themeBackgroundReady': 'Visitor site ready: {url}',
+ 'themeDev.starting': '[reactpress] Active theme "{id}" → {url} (port {port}, {dir})',
+ 'themeDev.startingShort': 'Theme "{id}" on :{port} ({dir})',
+ 'themeDev.cacheCleared': 'Cleared theme .next (REACTPRESS_CLEAR_THEME_CACHE=1)',
+ 'themeDev.cacheStaleCleared': 'Cleared theme .next (missing {marker})',
+ 'themeDev.apiSplit': '[reactpress] Theme API — SSR: {ssr} · browser: {browser}',
+ 'themeDev.ready': '[reactpress] Public site ready: {url} (theme: {id})',
+ 'themeDev.slow': '[reactpress] Theme site slow to start: {url}',
+ 'themeDev.notFound': 'Theme package "{id}" not found',
+ 'themeDev.invalidManifest':
+ 'Ignored invalid active-theme.json (only themes/ or .reactpress/runtime/ packages apply)',
+ 'themeDev.unavailable': 'will not listen',
+ 'themeDev.restart': 'active-theme.json changed — restarting theme on :3001…',
+ 'themeDev.restartFailed': 'theme restart failed: {message}',
+ 'themeDev.portBusy': 'Port {port} still in use after stopping the previous theme — skip restart (retry activate or restart pnpm dev)',
+ 'themeDev.portBusyHint': 'Or free the port manually: {cmd}',
+ 'dev.apiReadyAdmin': 'API ready · starting admin',
+ 'dev.clientSlow': '[reactpress] Frontend not responding within {seconds}s; it may still be compiling. Visit {url} later',
+ 'dev.adminSlow': '[reactpress] Admin SPA not responding within {seconds}s; it may still be compiling. Visit {url} later',
+ 'dev.noWeb': '[reactpress] web/ directory not found; cannot start admin dev stack.',
+ 'dev.envFailed': '[reactpress] Environment setup failed:',
+ 'dev.toolkitFailed': 'toolkit build failed with exit code: {code}',
+ 'dev.nextSteps': 'Suggested next steps:',
+ 'dev.nextDoctor': ' → reactpress doctor Diagnostics',
+ 'dev.nextDocker': ' → reactpress docker up Start embedded MySQL',
+ 'dev.nextEnv': ' → Check DB_* and SERVER_SITE_URL in .env',
+ 'dev.standaloneHint': '[reactpress] Standalone project: only API is started here; build your own frontend separately.',
+ 'dev.desktopStarting': 'Starting Electron desktop → {url}',
+ 'dev.desktopMissing': 'desktop/ package not found — run from monorepo root',
+ 'dev.desktopIntro': 'Desktop dev — local-first mode (SQLite embedded, no Docker/MySQL)',
+ 'dev.localWebIntro': 'Local web dev — SQLite API + admin SPA in browser (no Docker/Electron)',
+ 'dev.localFullIntro': 'Local full-stack dev — SQLite API + admin + theme (no Docker/MySQL)',
+ 'dev.desktopLocalApiStarting': 'Starting embedded SQLite API…',
+ 'dev.desktopLocalApiReady': '✓ Local API ({db}) → {url}',
+ 'dev.desktopLocalApi': 'Local SQLite API → {url}',
+ 'dev.dbTypeSqlite': 'SQLite',
+ 'devBanner.ready': 'ReactPress dev environment is ready',
+ 'devBanner.readyWeb': 'ReactPress admin dev environment is ready',
+ 'devBanner.readyLocalWeb': 'ReactPress local web dev environment is ready',
+ 'devBanner.readyDesktop': 'ReactPress desktop dev environment is ready',
+ 'devBanner.readyApi': 'ReactPress API is ready',
+ 'devBanner.site': 'Site',
+ 'devBanner.admin': 'Admin',
+ 'devBanner.api': 'API',
+ 'devBanner.database': 'Database',
+ 'devBanner.sqliteEmbedded': 'SQLite (embedded, no Docker)',
+ 'devBanner.mysqlDocker': 'MySQL (Docker)',
+ 'devBanner.swagger': 'Swagger',
+ 'devBanner.health': 'Health',
+ 'devBanner.desktopLocalHint': 'Default login admin/admin · switch to remote API in Settings',
+ 'devBanner.localWebHint': 'Open the admin URL in your browser · default login admin/admin',
+ 'devBanner.localModeGo': 'LOCAL MODE READY',
+ 'devBanner.hint': 'Diagnostics: reactpress doctor · Status: reactpress status',
+ 'devBanner.nginxHint': 'Internal ports 3001 (site) / 3002 (API) / 3003 (preview) / 3000 (admin) — use URLs above only',
+ 'devBanner.nginxRemoteHint': 'Client /api proxied to {url}',
+ 'devBanner.adminRemoteHint': 'Admin /api proxied to {url}',
+ 'devBanner.shortcuts': 'Ctrl+C stop',
+ 'devBanner.allSystemsGo': 'ALL SYSTEMS GO',
+ 'devBanner.dbDegraded': 'DB OFFLINE — run reactpress docker up',
+ 'dev.nginxSkippedDocker': '[reactpress] Docker not running — skipping nginx (use direct ports or start Docker)',
+ 'dev.nginxStartFailed': '[reactpress] nginx failed to start: {message}',
+ 'dev.dbEnsureFailed': '[reactpress] Database not ready: {message}',
+ 'dev.mysqlUnreachable':
+ '[reactpress] MySQL is not reachable — theme/admin API calls will fail. Start Docker, then: reactpress docker up',
+ 'dev.nginxSlow': '[reactpress] nginx entry slow to respond: {url}',
+ 'doctor.nodeBad': 'Node.js {version} (requires ≥ 18)',
+ 'doctor.nodeFix': 'Install Node.js 18+: https://nodejs.org/',
+ 'doctor.dockerOk': 'Docker engine is available',
+ 'doctor.dockerBad': 'Docker is not running or unavailable',
+ 'doctor.dockerFix': 'Install and start Docker: https://docs.docker.com/get-docker/ , then run reactpress docker up; or use external MySQL in config.json',
+ 'doctor.portApiBusy': 'API port {port} is in use',
+ 'doctor.portClientBusy': 'Frontend port {port} is in use',
+ 'doctor.portFix': 'Change SERVER_PORT / CLIENT_PORT in .env, or stop the blocking process',
+ 'doctor.portOk': 'Ports {apiPort} (API) and {clientPort} (frontend) are available',
+ 'doctor.dbNoMysql2': 'mysql2 not installed; cannot check database',
+ 'doctor.dbMysql2Fix': 'Run pnpm install at monorepo root',
+ 'doctor.dbOk': 'MySQL {host}:{port}/{database} connected',
+ 'doctor.dbBad': 'Database connection failed: {error}',
+ 'doctor.dbFix': 'Run reactpress docker up or check DB_* in .env',
+ 'doctor.dbSqliteOk': 'SQLite ready ({detail})',
+ 'doctor.dbSqliteBad': 'SQLite check failed: {error}',
+ 'doctor.dbSqliteFix': 'Run reactpress init --local or check DB_DATABASE in .env',
+ 'doctor.apiOk': 'API health check passed ({url})',
+ 'doctor.apiBad': 'API health check failed ({url})',
+ 'doctor.apiFix': 'Run reactpress server start or reactpress dev',
+ 'doctor.pnpmBad': 'pnpm not found',
+ 'doctor.pnpmFix': 'npm i -g pnpm, or enable corepack at monorepo root',
+ 'doctor.check.config': 'Config file',
+ 'doctor.check.env': 'Environment',
+ 'doctor.check.ports': 'Ports',
+ 'doctor.check.database': 'Database',
+ 'doctor.check.api': 'API health',
+ 'doctor.configOk': '.reactpress/config.json exists',
+ 'doctor.configBad': 'Missing .reactpress/config.json',
+ 'doctor.configFix': 'Run reactpress init',
+ 'doctor.envOk': '.env exists',
+ 'doctor.envBad': 'Missing .env',
+ 'doctor.envFix': 'Run reactpress init or reactpress config --apply',
+ 'doctor.project': 'Project {path}',
+ 'doctor.allPass': 'All checks passed. You can start developing.',
+ 'doctor.failed': '{count} item(s) need attention.',
+ 'doctor.title': 'ReactPress Doctor',
+ 'doctor.subtitle': 'environment diagnostics',
+ 'doctor.checking': 'Checking {name}…',
+ 'doctor.summary': '{passed} passed · {failed} failed · {total} total',
+ 'doctor.fixesHeader': 'Suggested fixes',
+ 'status.title': 'ReactPress project status',
+ 'status.dir': 'Project {path}',
+ 'status.apiSource': 'API source {source}',
+ 'status.apiSource.monorepo': 'monorepo server/',
+ 'status.apiSource.bundle': '@fecommunity/reactpress',
+ 'status.configOk': '.reactpress/config.json',
+ 'status.configBad': 'not initialized',
+ 'status.envOk': '.env',
+ 'status.envBad': 'missing .env',
+ 'status.apiOnline': 'online',
+ 'status.apiOffline': 'offline',
+ 'status.apiUnreachable': '{url} (offline or not started)',
+ 'status.dbUp': 'connected',
+ 'status.dbDown': 'unavailable',
+ 'status.pidRunning': '(running)',
+ 'status.frontend': 'Frontend',
+ 'status.docker': 'Docker',
+ 'status.dockerUp': 'available',
+ 'status.dockerDown': 'not running',
+ 'status.section.project': 'Project',
+ 'status.section.api': 'API service',
+ 'status.section.frontend': 'Frontend',
+ 'status.section.docker': 'Docker',
+ 'status.field.url': 'URL',
+ 'status.field.http': 'HTTP',
+ 'status.field.health': 'Health',
+ 'status.field.database': 'Database',
+ 'status.field.pid': 'PID',
+ 'status.field.engine': 'Engine',
+ 'status.field.config': 'Config',
+ 'status.field.env': '.env',
+ 'status.field.source': 'Source',
+ 'status.field.dir': 'Directory',
+ 'bootstrap.configReady': 'Config exists and database is ready.',
+ 'bootstrap.projectDbPending': 'Project created, but database is not ready: {message}. Start Docker and run reactpress dev again.',
+ 'bootstrap.ready': 'ReactPress dev environment is ready (config + database).',
+ 'bootstrap.initFailed': 'Initialization failed',
+ 'bootstrap.cliInitFailed': 'reactpress-cli init failed',
+ 'bootstrap.dbPendingShort': 'Database not ready',
+ 'bootstrap.dbNotReady': '{message}. Tip: start Docker and run reactpress docker up, or run reactpress doctor',
+ 'bootstrap.dbReady': 'Database is ready',
+ 'db.backup.to': 'Backing up database to {path}',
+ 'db.backup.done': 'Backup complete',
+ 'db.backup.viaDocker': 'mysqldump not on PATH; using mysqldump inside the db container…',
+ 'db.backup.fail':
+ 'mysqldump failed; install a MySQL client (e.g. brew install mysql-client), or ensure Docker db is running for automatic container backup',
+ 'common.done': 'Done',
+ 'common.yes': 'yes',
+ 'common.no': 'no',
+ 'common.none': '(none)',
+ 'common.unknownError': 'Unknown error',
+ 'lifecycle.apiStopped': '[reactpress] API process stopped (pid {pid})',
+ 'lifecycle.stopPidFailed': '[reactpress] Failed to stop pid {pid}:',
+ 'lifecycle.apiAlreadyRunning': '[reactpress] API already running (pid {pid})',
+ 'lifecycle.noServerAvailable': '[reactpress] No API runtime found. Reinstall @fecommunity/reactpress or run from a project with server/src.',
+ 'lifecycle.startingLocalApi': '[reactpress] Starting local API (server/)…',
+ 'lifecycle.startingBundledApi': '[reactpress] Starting bundled API…',
+ 'lifecycle.apiStartedBg': '[reactpress] API started in background (pid {pid})',
+ 'lifecycle.apiTimeout120': '[reactpress] API not ready within 120s: {url}',
+ 'lifecycle.apiReady': '[reactpress] API ready: {url}',
+ 'lifecycle.apiStatusTitle': '[reactpress] API status',
+ 'lifecycle.source': ' Source: {source}',
+ 'lifecycle.source.monorepo': 'monorepo server/',
+ 'lifecycle.source.bundle': 'bundled API (@fecommunity/reactpress)',
+ 'lifecycle.pidFile': ' PID file: {path}',
+ 'lifecycle.recordedPid': ' Recorded PID: {pid}',
+ 'lifecycle.processAlive': ' Process alive: {alive}',
+ 'lifecycle.httpStatus': ' HTTP ({url}): {status}',
+ 'lifecycle.httpReachable': 'reachable',
+ 'lifecycle.httpUnreachable': 'unreachable',
+ 'lifecycle.unknownCommand': 'Unknown lifecycle command: {command}',
+ 'docker.stopping': '[reactpress] Stopping Docker services…',
+ 'docker.stopped': '[reactpress] Docker services stopped.',
+ 'docker.stopFailed': '[reactpress] Failed to stop Docker:',
+ 'docker.starting': '[reactpress] Starting Docker services…',
+ 'docker.notRunning': 'Docker is not running. Please start Docker Desktop first.',
+ 'docker.devStartBlocked':
+ 'MySQL is not reachable on 127.0.0.1:{port} and Docker is not running. Start Docker Desktop, then run: reactpress docker up — or point DB_* in .env to an external MySQL instance.',
+ 'docker.started': '[reactpress] Docker services started.',
+ 'docker.waitingMysql': '[reactpress] Waiting for MySQL…',
+ 'docker.mysqlReady': '[reactpress] MySQL is ready.',
+ 'docker.mysqlExternalReady': '[reactpress] Using existing MySQL on port {port}.',
+ 'docker.dbPortInUse':
+ '[reactpress] Port {port} is already in use — skipping reactpress_db, using existing MySQL on that port.',
+ 'docker.dbReuseExisting':
+ '[reactpress] MySQL already reachable on port {port} — keeping Docker DB container',
+ 'docker.dbPortInUseRecycle':
+ '[reactpress] Port {port} is in use but MySQL is not reachable — recreating reactpress_db container…',
+ 'docker.dbPortConflict':
+ '[reactpress] MySQL on port {port} is unreachable. Run: docker start reactpress_cli_db reactpress_db OR docker compose -f docker-compose.dev.yml up -d db',
+ 'docker.ensureDevDb': '[reactpress] MySQL not reachable — starting Docker database…',
+ 'docker.waitingMysqlProgress': '[reactpress] Waiting for MySQL… ({attempts}/{max})',
+ 'docker.mysqlTimeout': '[reactpress] MySQL not ready within timeout.',
+ 'docker.mysqlNotReady': 'MySQL is not ready',
+ 'docker.startDevStack': '[reactpress] Starting API + frontend (Docker MySQL)…',
+ 'docker.visitUrls': '[reactpress] Visit: http://localhost (nginx) / http://localhost:3001 (client)',
+ 'docker.devProcessExit': 'Dev process exited with code {code}',
+ 'docker.unknownCommand': 'Unknown docker command: {command}',
+ 'nginx.configCreated': '[reactpress] Created nginx config: {path}',
+ 'nginx.configExists': '[reactpress] Nginx config already exists: {path}',
+ 'nginx.ensureWarn': '[reactpress] Could not ensure nginx config: {message}',
+ 'nginx.started': '[reactpress] Nginx started — {url}',
+ 'nginx.configPath': '[reactpress] Config: {path}',
+ 'nginx.stopped': '[reactpress] Nginx stopped.',
+ 'nginx.startFailed': 'Failed to start nginx container',
+ 'nginx.prodMonorepoOnly': 'Production nginx (--prod) requires monorepo with docker-compose.prod.yml',
+ 'nginx.statusTitle': '[reactpress] Nginx status',
+ 'nginx.statusContainer': ' Container {name}: {running}',
+ 'nginx.statusConfig': ' Config {path}: {exists}',
+ 'nginx.statusUrl': ' Entry {url} (port {port})',
+ 'nginx.statusMode': ' Mode: {mode}',
+ 'nginx.notRunning': 'Nginx container is not running. Run: reactpress nginx up',
+ 'nginx.testOk': '[reactpress] Nginx config test passed.',
+ 'nginx.testFailed': 'Nginx config test failed',
+ 'nginx.reloadOk': '[reactpress] Nginx reloaded.',
+ 'nginx.reloadFailed': 'Nginx reload failed',
+ 'nginx.opening': '[reactpress] Opening {url}',
+ 'nginx.unknownCommand': 'Unknown nginx command: {command}',
+ 'nginx.templateMissing': 'Bundled nginx template missing: {path}',
+ 'nginx.doctorSkippedDocker': 'Skipped (Docker not running)',
+ 'nginx.doctorSkippedNotRunning': 'Not started (optional: reactpress nginx up)',
+ 'nginx.doctorNotRunningFix': 'reactpress nginx up (or reactpress docker up)',
+ 'nginx.doctorOk': 'Nginx healthy ({url}/health)',
+ 'nginx.doctorUnhealthy': 'Nginx running but /health failed ({url})',
+ 'nginx.doctorUnhealthyFix': 'Ensure client (:3001) and API (:3002) are running; reactpress nginx reload',
+ 'doctor.check.nginx': 'Nginx proxy',
+ 'apiDev.modeServer': '[reactpress] Dev mode: server/ (nest start --watch)',
+ 'apiDev.modeBundled': '[reactpress] Dev mode: bundled API (built-in server)',
+ 'apiDev.ctrlCHint': '[reactpress] Press Ctrl+C to stop API.',
+ 'apiDev.stopHint': '[reactpress] Stop separately: reactpress server stop',
+ 'build.unknownTarget': 'Unknown build target: {target}. Available: {available}',
+ 'build.recursive': 'Build recursion detected (pnpm run build must not call itself). Use build:toolkit, build:server, or build:client.',
+ 'build.forbiddenScript': 'Invalid build script "{script}"; use granular scripts like build:toolkit.',
+ 'build.stepFailed': '[{current}/{total}] {label} failed',
+ 'build.plan': 'Production build — {total} step(s): toolkit → server → client',
+ 'build.step': '[{current}/{total}] {label}',
+ 'build.stepDone': '[{current}/{total}] {label} ({seconds}s)',
+ 'build.stepSkipped': 'skipped {label} (not part of this project layout)',
+ 'build.stepSkippedFresh': 'skipped {label} (dist up to date)',
+ 'build.stepSkippedReuse': 'skipped {label} — reusing build for "{id}"',
+ 'build.done': 'Build finished in {seconds}s',
+ 'build.label.toolkit': 'Toolkit',
+ 'build.label.plugins': 'Plugins',
+ 'build.label.server': 'API (server)',
+ 'build.label.web': 'Admin (web)',
+ 'build.label.theme': 'Visitor theme',
+ 'build.label.docs': 'Documentation (docs)',
+ 'pm2.startFailed': '[reactpress] PM2 failed to start API:',
+ 'pm2.exitCode': 'PM2 exited with code {code}',
+ 'spawn.commandFailed': 'Command failed ({command}): exit code {code}',
+ 'spawn.exitCode': 'Exit code {code}',
+ 'shim.deprecated': '\n[deprecated] reactpress-cli will be removed in 3.1. Use instead:\n npm i -g @fecommunity/reactpress\n reactpress init · reactpress dev · reactpress doctor\n',
+ 'server.help.invokedBy': ' (usually invoked by reactpress server start)',
+ 'publish.pkg.main': 'ReactPress 3.0 main package — single entry (init / dev / doctor / publish)',
+ 'publish.pkg.server': 'NestJS backend API (deprecated — use bundled API in reactpress-cli)',
+ 'bundle.cli.description': 'Zero-config init and manage ReactPress CMS & blog server',
+ 'bundle.cli.cwd': 'ReactPress project directory (default: current working directory)',
+ 'bundle.cli.init.description': 'One-click init ReactPress CMS & blog (zero config)',
+ 'bundle.cli.init.directory': 'Project directory',
+ 'bundle.cli.init.force': 'Overwrite existing config',
+ 'bundle.cli.start.description': 'Start server (prepares database automatically)',
+ 'bundle.cli.stop.description': 'Stop server',
+ 'bundle.cli.stop.database': 'Also stop embedded database container',
+ 'bundle.cli.restart.description': 'Restart server',
+ 'bundle.cli.status.description': 'Server and database status',
+ 'bundle.cli.config.description': 'View or update config (--apply to restart)',
+ 'bundle.cli.config.key': 'Config key, e.g. server.port',
+ 'bundle.cli.config.value': 'New value',
+ 'bundle.cli.config.list': 'List all config keys',
+ 'bundle.cli.config.apply': 'Restart automatically after update',
+ 'bundle.cli.unknownCommand': 'Unknown command: {command}',
+ 'bundle.cmd.init.spinner': 'Initializing ReactPress project…',
+ 'bundle.cmd.init.succeed': 'Initialization complete',
+ 'bundle.cmd.init.fail': 'Initialization failed',
+ 'bundle.cmd.init.projectDir': 'Project directory: {path}',
+ 'bundle.cmd.init.nextStep': 'Next: reactpress-cli start',
+ 'bundle.cmd.notProject': 'This directory is not a ReactPress project.',
+ 'bundle.cmd.notProjectInit': 'This directory is not a ReactPress project. Run reactpress-cli init first.',
+ 'bundle.cmd.start.spinner': 'Preparing database and server…',
+ 'bundle.cmd.start.succeed': 'Server started',
+ 'bundle.cmd.start.fail': 'Start failed',
+ 'bundle.cmd.stop.spinner': 'Stopping server…',
+ 'bundle.cmd.stop.succeed': 'Stopped',
+ 'bundle.cmd.stop.fail': 'Stop failed',
+ 'bundle.cmd.restart.spinner': 'Restarting server…',
+ 'bundle.cmd.restart.succeed': 'Restart complete',
+ 'bundle.cmd.restart.fail': 'Restart failed',
+ 'bundle.cmd.status.title': 'Service status',
+ 'bundle.cmd.status.project': 'Project: {path}',
+ 'bundle.cmd.status.service': 'Service: {status}{pid}',
+ 'bundle.cmd.status.running': 'running',
+ 'bundle.cmd.status.stopped': 'stopped',
+ 'bundle.cmd.status.url': 'URL: {url}',
+ 'bundle.cmd.status.database': 'Database: {status} ({mode})',
+ 'bundle.cmd.status.dbReady': 'ready',
+ 'bundle.cmd.status.dbNotReady': 'not ready',
+ 'bundle.cmd.config.title': 'Configuration',
+ 'bundle.cmd.config.keyRequired': 'Specify a config key, e.g.: reactpress-cli config server.port 3003',
+ 'bundle.cmd.config.listHint': 'Use --list to show all keys',
+ 'bundle.cmd.config.updated': 'Updated {key} = {value}',
+ 'bundle.cmd.config.restartSpinner': 'Restarting to apply config…',
+ 'bundle.cmd.config.applied': 'Config applied',
+ 'bundle.cmd.config.restartFail': 'Restart failed',
+ 'bundle.cmd.config.restartManual': 'Run reactpress-cli restart manually',
+ 'bundle.cmd.config.applyHint': 'Run reactpress-cli restart or config --apply to apply changes',
+ 'bundle.service.init.alreadyProject': 'Directory is already a ReactPress project. Use --force to overwrite config.',
+ 'bundle.service.init.dbPending': 'Project created, but database is not ready: {message}. Run reactpress-cli start later.',
+ 'bundle.service.init.complete': 'ReactPress project initialized. Run reactpress-cli start to launch the server.',
+ 'bundle.service.init.templateMissing': 'Template file missing: {path}',
+ 'bundle.service.config.notFound': 'ReactPress project not found. Run reactpress-cli init first.',
+ 'bundle.service.config.keyMissing': 'Config key does not exist: {key}',
+ 'bundle.service.server.alreadyRunning': 'Server already running (PID {pid}), visit {url}',
+ 'bundle.service.server.portBusy': 'Port {port} is in use; ReactPress cannot bind. If you started via Docker Compose, run: docker stop reactpress_server. Or change server.port in .reactpress/config.json.',
+ 'bundle.service.server.cannotStart': 'Could not start ReactPress server process.',
+ 'bundle.service.server.noHttp': 'Server process started (PID {pid}) but no HTTP response at {url}. Check port conflicts or DB_* in .env.',
+ 'bundle.service.server.started': 'ReactPress server started at {url}',
+ 'bundle.service.server.stopped': 'ReactPress server stopped.',
+ 'bundle.service.server.cannotStopPid': 'Could not stop process PID {pid}',
+ 'bundle.service.database.dockerMissing': 'Docker not detected. Install and start Docker, or set database.mode to external with an existing MySQL.',
+ 'bundle.service.database.portSwitched': 'Host port {previous} was in use; switched to {port} (.env updated)',
+ 'bundle.service.database.portBindRetry': 'Port {port} bind failed, trying another port…',
+ 'bundle.service.database.containerStartFailed': 'Failed to start database container: {detail}',
+ 'bundle.service.database.credsMismatch': 'Database container is on port {port} but user "{user}" cannot connect (volume credentials differ from .env). Run: cd .reactpress && docker compose down -v && cd .. && reactpress-cli start',
+ 'bundle.service.database.connectionTimeout': 'Database container started but connection timed out. Run: docker logs {container}',
+ 'bundle.service.database.cannotConnect': 'Cannot connect to database {host}:{port}. Check DB_* in .env.',
+ 'bundle.serverBundle.missing': 'Bundled server is missing. Reinstall reactpress-cli.',
+ 'bundle.serverBundle.installFailed': 'Bundled server dependency install failed. In the reactpress-cli install dir run: cd server && npm install --omit=dev --no-bin-links',
+ 'bundle.serverBundle.notBuilt': 'Bundled server is not built or dependencies are incomplete. Run npm run build:server (dev) or reinstall reactpress-cli.',
+ 'bundle.port.notFound': 'No available port in range {start}-{end}',
+ },
+ zh: {
+ 'cli.description': 'ReactPress 4.0 CLI — 初始化、开发、插件、桌面、主题、构建与发布',
+ 'cli.init.description': '初始化项目 (.reactpress/config.json + .env + Docker MySQL)',
+ 'cli.init.directory': '项目目录',
+ 'cli.init.force': '覆盖已有配置',
+ 'cli.init.local': '使用嵌入式 SQLite 初始化(无需 Docker)',
+ 'cli.dev.description': '零配置开发: 环境检查 + toolkit 构建 + API + 前端',
+ 'cli.dev.apiOnly': '仅启动 API (watch)',
+ 'cli.dev.local': '本地 SQLite 模式(无需 Docker/nginx)',
+ 'cli.dev.clientOnly': '仅启动前端',
+ 'cli.dev.webOnly': '管理后台 + API (web/)',
+ 'cli.dev.remoteOrigin': '默认远程 API;未指定 admin/client 时两者均走远程',
+ 'cli.dev.adminOrigin': '管理后台 API:local | remote | URL(remote 用 --remote-origin 默认值)',
+ 'cli.dev.clientOrigin': '访客站 API(nginx /api):local | remote | URL',
+ 'cli.dev.remoteOriginRequired': '--remote-origin 需要填写地址(如 api.gaoredu.com)',
+ 'cli.dev.remoteDefaultRequired': 'remote 需配合 URL:使用 --remote-origin 或为 admin/client-origin 填写地址',
+ 'cli.dev.invalidOrigin': '无效的 origin;请使用 local、remote 或主机/URL',
+ 'cli.dev.remoteOriginIncompatibleApiOnly': '远程 API 参数不能与 --api-only 同时使用',
+ 'cli.desktopDev.description': '桌面开发:内嵌 SQLite API + 管理后台 + Electron(无需 Docker/MySQL)',
+ 'cli.server.description': '管理 API 服务',
+ 'cli.server.start.description': '启动 API(等待 HTTP 就绪)',
+ 'cli.server.start.pm2': '使用 PM2 启动(生产)',
+ 'cli.server.start.bg': '后台启动,不等待 HTTP',
+ 'cli.server.stop': '停止 API',
+ 'cli.server.restart': '重启 API',
+ 'cli.server.status': '查看 API 状态',
+ 'cli.client.description': '管理前端',
+ 'cli.client.start': '启动 Next.js 客户端',
+ 'cli.client.start.pm2': '使用 PM2 启动',
+ 'cli.client.restart': '重新构建当前主题并重启访客端',
+ 'themeProd.building': '正在构建当前主题「{id}」…',
+ 'themeProd.installingDeps': '正在为主题「{id}」安装依赖…',
+ 'themeProd.reusingBuild': '复用主题「{id}」已有构建,跳过重新构建',
+ 'themeProd.restarting': '正在为主题「{id}」重启访客端…',
+ 'themeProd.restarted': '访客端已切换为主题「{id}」。',
+ 'themePreview.backgroundBuildScheduled': '正在后台构建 {count} 个预览主题(生产模式)…',
+ 'themePreview.warmingAll': '正在预构建 {count} 个主题预览(加速切换)…',
+ 'themePreview.warmingAllSkipped': '{count} 个预览构建已是最新,已跳过',
+ 'themePreview.installingDeps': '正在为预览主题「{id}」安装依赖…',
+ 'themePreview.building': '正在构建预览主题「{id}」…',
+ 'themePreview.reusingBuild': '复用预览主题「{id}」已有构建,跳过重新构建',
+ 'themePreview.buildDone': '预览主题「{id}」构建完成。',
+ 'themePreview.buildFailed': '预览主题「{id}」构建失败:{message}',
+ 'themePreview.starting': '预览主题「{id}」→ {url}(端口 {port},{dir},{mode})',
+ 'themePreview.ready': '预览已就绪:{url}(主题:{id})',
+ 'cli.build.description': '构建生产产物',
+ 'cli.docker.description': 'Docker 开发环境 (MySQL + nginx)',
+ 'cli.docker.up': '仅启动 Docker 服务并等待 MySQL',
+ 'cli.docker.down': '停止 Docker 服务',
+ 'cli.docker.start': '启动 Docker + 全栈开发 (API + 前端)',
+ 'cli.docker.restart': '重启 Docker 服务',
+ 'cli.docker.status': '查看 Docker 容器状态',
+ 'cli.docker.logs': '查看 Docker 日志 (db | nginx)',
+ 'cli.nginx.description': 'Nginx 反向代理(统一入口 :80)',
+ 'cli.nginx.ensure': '若缺失则生成默认 nginx 配置',
+ 'cli.nginx.up': '启动 nginx 容器',
+ 'cli.nginx.down': '停止 nginx 容器',
+ 'cli.nginx.restart': '重启 nginx 容器',
+ 'cli.nginx.status': '查看 nginx 容器与配置状态',
+ 'cli.nginx.logs': '跟踪 nginx 容器日志',
+ 'cli.nginx.test': '在容器内校验配置 (nginx -t)',
+ 'cli.nginx.reload': '修改配置后热加载 nginx',
+ 'cli.nginx.open': '在浏览器打开 nginx 入口',
+ 'cli.nginx.prod': '使用生产 compose + nginx.conf(仅 monorepo)',
+ 'cli.nginx.force': '用模板覆盖已有 nginx 配置',
+ 'cli.help.nginx': ' reactpress nginx up 启动反向代理 (:80)',
+ 'cli.status.description': '查看项目、API、前端、Docker 综合状态',
+ 'cli.doctor.description': '诊断环境:Node、Docker、端口、数据库、API 健康',
+ 'cli.db.description': '数据库运维',
+ 'cli.db.backup': '使用 mysqldump 备份当前项目数据库',
+ 'cli.db.backup.output': '输出 SQL 文件路径',
+ 'cli.publish.description': '构建并发布 npm 包',
+ 'cli.publish.build': '仅构建发布产物',
+ 'cli.publish.publish': '发布核心 npm 包',
+ 'cli.start.description': '生产模式: 启动 API + 前端',
+ 'cli.help.examples': '示例:',
+ 'cli.help.interactive': ' reactpress 交互式菜单',
+ 'cli.help.dev': ' reactpress dev 零配置全栈开发',
+ 'cli.help.init': ' reactpress init --force 重新初始化配置',
+ 'cli.help.server': ' reactpress server start 启动 API',
+ 'cli.help.status': ' reactpress status 综合状态',
+ 'cli.help.doctor': ' reactpress doctor 环境诊断',
+ 'cli.help.docker': ' reactpress docker start Docker + 全栈',
+ 'cli.help.build':
+ ' reactpress build -t all 构建 (toolkit|plugins|server|web|theme|docs|all)',
+ 'cli.help.publish': ' reactpress publish 发布 npm 包(维护者)',
+ 'cli.help.theme': ' reactpress theme add 从 npm 安装主题',
+ 'cli.help.themeList': ' reactpress theme list 列出可用主题',
+ 'cli.help.initLocal': ' reactpress init --local SQLite 初始化(无需 Docker)',
+ 'cli.help.devLocal': ' reactpress dev --local SQLite 开发(无需 Docker/nginx)',
+ 'cli.help.desktop': ' reactpress desktop dev 桌面客户端(Electron + SQLite)',
+ 'cli.help.plugin': ' reactpress plugin install seo 安装插件',
+ 'cli.help.dbBackup': ' reactpress db backup 备份 MySQL 数据库',
+ 'cli.plugin.description': '管理 ReactPress 插件',
+ 'cli.plugin.install.description': '安装本地插件到 .reactpress/plugins',
+ 'cli.plugin.install.id': 'plugins/ 注册表中的插件 id',
+ 'cli.plugin.list.description': '列出已注册插件',
+ 'cli.theme.description': '安装与管理主题',
+ 'cli.theme.add.description': '从 npm 包 spec 或 .tgz 文件安装主题',
+ 'cli.theme.add.spec': 'npm 包 spec(如 @fecommunity/reactpress-theme-starter@1.0.0-beta.0)',
+ 'cli.theme.add.catalog': '按 themes/{dir}/package.json 中的主题 id 安装',
+ 'cli.theme.add.skipDeps': '跳过主题目录内的 pnpm/npm install',
+ 'cli.theme.list.description': '列出可用主题包',
+ 'themeInstall.specRequired': '请提供 npm 主题包 spec',
+ 'themeInstall.installing': '正在从 npm 安装主题: {spec}',
+ 'themeInstall.success': '主题「{name}」已安装为「{id}」→ {dir}',
+ 'themeInstall.nextActivate': '请在管理后台「外观 → 主题」中启用,或调用 POST /extension/themes/{id}/activate',
+ 'themeInstall.listHeading': '可用主题:',
+ 'themeInstall.listEmpty': '在 themes/ 或 .reactpress/runtime/ 下未找到主题包',
+ 'cli.build.target': '构建目标: toolkit | plugins | server | web | theme | docs | all',
+ 'cli.build.lowMem': '低内存模式:限制构建堆内存并跳过未变化步骤(2G 小机)',
+ 'banner.subtitle': ' · 全栈发布平台 CLI ',
+ /** 左侧装饰性进度条标签(不是网址;仓库地址放在卡片顶部 Title 正下方)。 */
+ 'banner.pulseLabel': '准备',
+ 'banner.pulseReady': '就绪',
+ 'banner.pulsePending': '初始化',
+ 'banner.label.mode': '模式',
+ 'banner.label.path': '路径',
+ 'banner.mode.standalone': '独立项目',
+ 'banner.mode.monorepo': 'MONOREPO',
+ 'banner.mode.uninitialized': '未初始化',
+ 'banner.systemLabel': '系统',
+ 'banner.systemOnline': '在线',
+ 'banner.systemPending': '准备中',
+ 'menu.dev': '零配置开发 (env + DB + API + 前端)',
+ 'menu.init': '初始化项目 (.reactpress + .env + 数据库)',
+ 'menu.status': '查看项目状态',
+ 'menu.doctor': '环境诊断 (doctor)',
+ 'menu.devApi': '仅启动 API (开发 watch)',
+ 'menu.devClient': '仅启动前端',
+ 'menu.serverStart': '启动 API (后台生产)',
+ 'menu.serverStop': '停止 API',
+ 'menu.serverRestart': '重启 API',
+ 'menu.build': '构建 (toolkit → server → client)',
+ 'menu.buildTarget': '选择要构建的目标',
+ 'menu.buildAll': '全部 (toolkit → server → client)',
+ 'menu.dockerStart': 'Docker 开发环境 (DB + nginx + 全栈)',
+ 'menu.dockerUp': 'Docker 仅启动数据库',
+ 'menu.dockerStop': '停止 Docker 服务',
+ 'menu.openAdmin': '在浏览器打开管理后台',
+ 'menu.publish': '发布 npm 包 (交互式)',
+ 'menu.exit': '退出',
+ 'menu.prompt': '选择操作',
+ 'menu.back': '返回主菜单?',
+ 'menu.retry': '返回主菜单重试?',
+ 'menu.startingDev': '启动全栈开发…',
+ 'menu.initProject': '初始化项目…',
+ 'menu.done': '完成',
+ 'menu.opening': '打开 {url}',
+ 'menu.goodbye': ' 再见。',
+ 'menu.section.run': '运行',
+ 'menu.section.extend': '4.0 扩展',
+ 'menu.section.lifecycle': '生命周期',
+ 'menu.section.build': '构建与部署',
+ 'menu.section.tools': '工具',
+ 'menu.devDesktop': '桌面开发(SQLite + Electron)',
+ 'menu.hint.devDesktop': '无需 Docker',
+ 'menu.devWeb': '仅管理后台 + API',
+ 'menu.hint.devWeb': 'web/ 开发',
+ 'menu.devLocalWeb': '本地 Web(浏览器 + SQLite)',
+ 'menu.hint.devLocalWeb': '无需 Docker/Electron',
+ 'menu.initLocal': 'SQLite 初始化(无需 Docker)',
+ 'menu.hint.initLocal': 'init --local',
+ 'menu.themeList': '列出可用主题',
+ 'menu.hint.themeList': 'theme list',
+ 'menu.pluginList': '列出可用插件',
+ 'menu.hint.pluginList': 'plugin list',
+ 'menu.dbBackup': '备份数据库',
+ 'menu.hint.dbBackup': 'mysqldump',
+ 'menu.tip': '提示:上下方向键选择,回车确认,Ctrl+C 退出。',
+ 'menu.shortcuts': '↑/↓ 选择 · 回车 确认 · esc 返回 · Ctrl+C 退出',
+ 'menu.statusHeader': '当前状态',
+ 'menu.contextStandalone': '项目类型 · 独立项目(使用内置 API)',
+ 'menu.contextMonorepo': '项目类型 · monorepo(server/src + client/)',
+ 'menu.contextUnknown': '项目类型 · 未初始化(请先运行 init)',
+ 'menu.statusApi': 'API {status}',
+ 'menu.statusDb': '数据库 {status}',
+ 'menu.statusDocker': 'Docker {status}',
+ 'menu.statusLabelApi': 'API',
+ 'menu.statusLabelDb': '数据库',
+ 'menu.statusLabelDocker': 'Docker',
+ 'menu.statusChecking': '检测中…',
+ 'menu.startingApi': '正在启动 API…',
+ 'menu.stoppingApi': '正在停止 API…',
+ 'menu.restartingApi': '正在重启 API…',
+ 'menu.statusOn': '在线',
+ 'menu.statusOff': '离线',
+ 'menu.statusReady': '就绪',
+ 'menu.statusNotReady': '未就绪',
+ 'menu.statusYes': '可用',
+ 'menu.statusNo': '不可用',
+ 'menu.hint.dev': 'API + 数据库 + 前端',
+ 'menu.hint.init': '生成 .reactpress + .env',
+ 'menu.hint.status': '所有服务概览',
+ 'menu.hint.doctor': '环境健康检查',
+ 'menu.hint.devApi': 'watch 模式',
+ 'menu.hint.devClient': 'Next.js 开发',
+ 'menu.hint.serverStart': '后台生产模式',
+ 'menu.hint.serverStop': '',
+ 'menu.hint.serverRestart': '',
+ 'menu.hint.build': '生产构建产物',
+ 'menu.hint.dockerStart': '数据库 + nginx + 全栈',
+ 'menu.hint.dockerUp': '仅数据库',
+ 'menu.hint.dockerStop': '',
+ 'menu.nginxUp': '启动 nginx 反向代理 (:80)',
+ 'menu.nginxOpen': '在浏览器打开 nginx 入口',
+ 'menu.nginxReload': '修改配置后重载 nginx',
+ 'menu.hint.nginxUp': '统一入口 :80',
+ 'menu.hint.nginxOpen': 'http://localhost',
+ 'menu.hint.nginxReload': 'nginx -t 后 reload',
+ 'menu.hint.openAdmin': '在浏览器打开',
+ 'menu.hint.publish': '仅维护者使用',
+ 'menu.hint.exit': '',
+ 'menu.actionPrefix': '操作',
+ 'dev.phaseApi': 'API → :3002',
+ 'dev.phasePrerequisites': '检查 Node.js 与 Docker…',
+ 'dev.phaseInfra': '启动 MySQL 与 nginx…',
+ 'dev.phaseServices': '启动 API、管理后台与主题…',
+ 'dev.phasePrerequisitesDesktop': '检查 Node.js…',
+ 'dev.phaseInfraDesktop': '构建 toolkit 与本地工作区…',
+ 'dev.phaseServicesDesktop': '启动管理后台与 Electron…',
+ 'dev.phaseServicesLocalWeb': '启动管理后台(浏览器预览)…',
+ 'dev.previewPrewarmStarting': '预构建主题预览以加速切换…',
+ 'dev.remoteApiUsing': '使用远程 API(nginx /api → {url})',
+ 'dev.adminApiRemote': '管理后台 API → {url}',
+ 'dev.clientApiRemote': '访客站 API(nginx /api)→ {url}',
+ 'dev.nginxReadyRemote': 'nginx 已就绪:{url}(访客站 API → {api})',
+ 'dev.checkNodeOk': 'Node.js {version}',
+ 'dev.checkDockerOk': 'Docker 已运行',
+ 'dev.prerequisitesOk': '✓ Node {version} · ✓ Docker',
+ 'dev.prerequisitesOkDesktop': '✓ Node {version} · SQLite(无需 Docker)',
+ 'dev.apiKept': '复用端口 {port} 上已健康的 API',
+ 'dev.timingReady': '启动完成 · {summary}',
+ 'dev.timingInfra': '基础设施',
+ 'dev.timingServices': '服务',
+ 'dev.timingApiReused': 'API 已复用',
+ 'dev.waitingApiQuiet': '等待 API…',
+ 'dev.mysqlReadyQuiet': 'MySQL 已就绪',
+ 'dev.themeStarting': '主题「{id}」→ :{port}',
+ 'dev.themeCacheClearedForRemote': '已清除主题 .next(远程 API,避免陈旧 SERVER_API_URL)',
+ 'dev.themeReadyQuiet': '访客站已就绪 → {url}',
+ 'dev.phaseAdmin': '启动管理后台(内部 :3000/admin/)…',
+ 'dev.phaseTheme': '启动当前启用主题(内部 :3001)…',
+ 'dev.phaseClient': '启动旧版 client 前端…',
+ 'dev.phaseNginx': '启动 nginx 统一入口(:80)…',
+ 'dev.phaseNginxWait': '等待管理端与主题就绪后启动 nginx…',
+ 'dev.waitingProxies': '等待管理后台与主题…',
+ 'dev.startingAdmin': '管理后台 → {url}',
+ 'dev.adminNginxSlow': '[reactpress] 经 nginx 访问管理端未就绪: {url} — 请确认 Vite base 为 /admin/(重新执行 pnpm dev)',
+ 'dev.nginxReady': 'Nginx → {url}',
+ 'dev.proxiesReady': '管理后台与主题开发服务已监听',
+ 'dev.portApiBusy': '[reactpress] 端口 {port} 已被占用(且 API 健康检查未通过)。请结束占用进程或运行: reactpress doctor',
+ 'dev.portApiBusyHint': '[reactpress] 提示: lsof -i :3002 · 请勿在两个终端同时运行 pnpm dev',
+ 'dev.startingApi': '启动 API(端口 3002)…',
+ 'dev.waitingApi': '等待 API: {url}',
+ 'dev.waitingApiCompile': '编译中(端口 {port} 尚未监听)',
+ 'dev.waitingApiStarting': '端口已开,等待健康检查',
+ 'dev.healthDbDown': '数据库未连接',
+ 'dev.healthDegraded': 'API 降级',
+ 'dev.apiTimeout': '[reactpress] API 在 {seconds}s 内未就绪。\n → 运行 reactpress doctor 查看详情\n → 嵌入式 MySQL:reactpress docker up\n → 检查 .env 中 DB_* 与 SERVER_SITE_URL',
+ 'dev.apiReusing': 'API :{port} 已健康,跳过重启',
+ 'dev.apiReady': 'API 已就绪',
+ 'dev.toolkitUpToDate': '已跳过 toolkit 构建(dist 为最新;设置 REACTPRESS_FORCE_TOOLKIT_BUILD=1 可强制重建)',
+ 'dev.themeSiteSkipped': '访客站(:3001)需在主题包就绪后才会启动',
+ 'dev.themeBackground': '访客站(:3001)后台编译中 — API 与管理端就绪后将显示启动横幅',
+ 'dev.themeBackgroundReady': '访客站已就绪: {url}',
+ 'themeDev.starting': '[reactpress] 已启用主题「{id}」→ {url}(端口 {port},{dir})',
+ 'themeDev.startingShort': '主题「{id}」→ :{port}({dir})',
+ 'themeDev.cacheCleared': '已清理主题 .next(REACTPRESS_CLEAR_THEME_CACHE=1)',
+ 'themeDev.cacheStaleCleared': '已清理主题 .next(缺少 {marker})',
+ 'themeDev.apiSplit': '[reactpress] 主题 API — 服务端渲染: {ssr} · 浏览器: {browser}',
+ 'themeDev.ready': '[reactpress] 访客站已就绪: {url}(主题: {id})',
+ 'themeDev.slow': '[reactpress] 主题站点启动较慢: {url}',
+ 'themeDev.notFound': '未找到主题包「{id}」',
+ 'themeDev.invalidManifest':
+ '已忽略无效的 active-theme.json(仅 themes/ 或 .reactpress/runtime/ 下的主题包会生效)',
+ 'themeDev.unavailable': '无法监听',
+ 'themeDev.restart': 'active-theme.json 已更新,正在重启 :3001 主题进程…',
+ 'themeDev.restartFailed': '主题重启失败: {message}',
+ 'themeDev.portBusy': '端口 {port} 仍被占用,已跳过本次主题重启(请再次启用主题或重启 pnpm dev)',
+ 'themeDev.portBusyHint': '也可手动释放端口: {cmd}',
+ 'dev.apiReadyAdmin': 'API 已就绪 · 启动管理后台',
+ 'dev.clientSlow': '[reactpress] 前端在 {seconds}s 内未响应,可能仍在编译。稍后访问 {url}',
+ 'dev.adminSlow': '[reactpress] 管理后台在 {seconds}s 内未响应,可能仍在编译。稍后访问 {url}',
+ 'dev.noWeb': '[reactpress] 未找到 web/ 目录,无法启动管理后台开发栈。',
+ 'dev.envFailed': '[reactpress] 环境准备失败:',
+ 'dev.toolkitFailed': 'toolkit 构建失败,退出码: {code}',
+ 'dev.nextSteps': '下一步建议:',
+ 'dev.nextDoctor': ' → reactpress doctor 环境诊断',
+ 'dev.nextDocker': ' → reactpress docker up 启动嵌入式 MySQL',
+ 'dev.nextEnv': ' → 检查 .env 中 DB_* 与 SERVER_SITE_URL',
+ 'dev.standaloneHint': '[reactpress] 独立项目:当前目录仅启动 API,前端请单独构建。',
+ 'dev.desktopStarting': '正在启动 Electron 桌面客户端 → {url}',
+ 'dev.desktopMissing': '未找到 desktop/ 包 — 请在 monorepo 根目录运行',
+ 'dev.desktopIntro': '桌面开发 — 本地优先模式(内嵌 SQLite,无需 Docker/MySQL)',
+ 'dev.localWebIntro': '本地 Web 开发 — SQLite API + 浏览器管理后台(无需 Docker/Electron)',
+ 'dev.localFullIntro': '本地全栈开发 — SQLite API + 管理后台 + 访客主题(无需 Docker/MySQL)',
+ 'dev.desktopLocalApiStarting': '正在启动内嵌 SQLite API…',
+ 'dev.desktopLocalApiReady': '✓ 本地 API({db})→ {url}',
+ 'dev.desktopLocalApi': '本地 SQLite API → {url}',
+ 'dev.dbTypeSqlite': 'SQLite',
+ 'devBanner.ready': 'ReactPress 开发环境已就绪',
+ 'devBanner.readyWeb': 'ReactPress 管理后台开发环境已就绪',
+ 'devBanner.readyLocalWeb': 'ReactPress 本地 Web 开发环境已就绪',
+ 'devBanner.readyDesktop': 'ReactPress 桌面开发环境已就绪',
+ 'devBanner.readyApi': 'ReactPress API 已就绪',
+ 'devBanner.site': '前台',
+ 'devBanner.admin': '管理端',
+ 'devBanner.api': 'API',
+ 'devBanner.database': '数据库',
+ 'devBanner.sqliteEmbedded': 'SQLite(内嵌,无需 Docker)',
+ 'devBanner.mysqlDocker': 'MySQL(Docker)',
+ 'devBanner.swagger': 'Swagger',
+ 'devBanner.health': '健康检查',
+ 'devBanner.desktopLocalHint': '默认账号 admin/admin · 可在设置中切换远程 API 或同步',
+ 'devBanner.localWebHint': '在浏览器打开管理端地址 · 默认账号 admin/admin',
+ 'devBanner.localModeGo': '本地模式就绪',
+ 'devBanner.hint': '诊断: reactpress doctor · 状态: reactpress status',
+ 'devBanner.nginxHint': '内部端口:3001 访客站 / 3002 API / 3003 主题预览 / 3000 管理后台 — 请只使用上方地址',
+ 'devBanner.nginxRemoteHint': '访客站 /api 已代理至 {url}',
+ 'devBanner.adminRemoteHint': '管理后台 /api 已代理至 {url}',
+ 'devBanner.shortcuts': 'Ctrl+C 停止',
+ 'devBanner.allSystemsGo': '一切就绪',
+ 'devBanner.dbDegraded': '数据库未连接 — 请执行 reactpress docker up',
+ 'dev.nginxSkippedDocker': '[reactpress] Docker 未运行,已跳过 nginx(请启动 Docker 或直连各端口)',
+ 'dev.nginxStartFailed': '[reactpress] nginx 启动失败: {message}',
+ 'dev.dbEnsureFailed': '[reactpress] 数据库未就绪: {message}',
+ 'dev.mysqlUnreachable':
+ '[reactpress] MySQL 不可达,主题/后台接口会报错。请先启动 Docker,再执行: reactpress docker up',
+ 'dev.nginxSlow': '[reactpress] nginx 入口响应较慢: {url}',
+ 'doctor.nodeBad': 'Node.js {version}(需要 ≥ 18)',
+ 'doctor.nodeFix': '请安装 Node.js 18+:https://nodejs.org/',
+ 'doctor.dockerOk': 'Docker 引擎可用',
+ 'doctor.dockerBad': 'Docker 未运行或不可用',
+ 'doctor.dockerFix': '安装并启动 Docker:https://docs.docker.com/get-docker/ ,然后运行 reactpress docker up;或改 config.json 使用外部 MySQL',
+ 'doctor.portApiBusy': 'API 端口 {port} 已被占用',
+ 'doctor.portClientBusy': '前端端口 {port} 已被占用',
+ 'doctor.portFix': '修改 .env 中 SERVER_PORT / CLIENT_PORT,或停止占用进程',
+ 'doctor.portOk': '端口 {apiPort}(API)、{clientPort}(前端)可用',
+ 'doctor.dbNoMysql2': '未安装 mysql2,无法检测数据库',
+ 'doctor.dbMysql2Fix': '在 monorepo 根目录执行 pnpm install',
+ 'doctor.dbOk': 'MySQL {host}:{port}/{database} 连通',
+ 'doctor.dbBad': '数据库连接失败: {error}',
+ 'doctor.dbFix': '运行 reactpress docker up 或检查 .env 中 DB_* 配置',
+ 'doctor.dbSqliteOk': 'SQLite 就绪 ({detail})',
+ 'doctor.dbSqliteBad': 'SQLite 检测失败: {error}',
+ 'doctor.dbSqliteFix': '运行 reactpress init --local 或检查 .env 中 DB_DATABASE',
+ 'doctor.apiOk': 'API 健康检查通过 ({url})',
+ 'doctor.apiBad': 'API 未响应健康检查 ({url})',
+ 'doctor.apiFix': '运行 reactpress server start 或 reactpress dev',
+ 'doctor.pnpmBad': '未检测到 pnpm',
+ 'doctor.pnpmFix': 'npm i -g pnpm,或在 monorepo 根目录使用 corepack enable',
+ 'doctor.check.config': '配置文件',
+ 'doctor.check.env': '环境变量',
+ 'doctor.check.ports': '端口',
+ 'doctor.check.database': '数据库',
+ 'doctor.check.api': 'API 健康',
+ 'doctor.configOk': '.reactpress/config.json 存在',
+ 'doctor.configBad': '缺少 .reactpress/config.json',
+ 'doctor.configFix': '运行 reactpress init',
+ 'doctor.envOk': '.env 存在',
+ 'doctor.envBad': '缺少 .env',
+ 'doctor.envFix': '运行 reactpress init 或 reactpress config --apply',
+ 'doctor.project': '项目目录 {path}',
+ 'doctor.allPass': '全部检查通过,可以开始开发。',
+ 'doctor.failed': '{count} 项需要处理。',
+ 'doctor.title': 'ReactPress Doctor',
+ 'doctor.subtitle': '环境健康检查',
+ 'doctor.checking': '正在检查 {name}…',
+ 'doctor.summary': '通过 {passed} · 失败 {failed} · 共 {total} 项',
+ 'doctor.fixesHeader': '修复建议',
+ 'status.title': 'ReactPress 项目状态',
+ 'status.dir': '项目目录 {path}',
+ 'status.apiSource': 'API 来源 {source}',
+ 'status.apiSource.monorepo': 'monorepo server/',
+ 'status.apiSource.bundle': '@fecommunity/reactpress',
+ 'status.configOk': '.reactpress/config.json',
+ 'status.configBad': '未初始化',
+ 'status.envOk': '.env',
+ 'status.envBad': '缺少 .env',
+ 'status.apiOnline': '在线',
+ 'status.apiOffline': '离线',
+ 'status.apiUnreachable': '{url} (离线或未启动)',
+ 'status.dbUp': '连通',
+ 'status.dbDown': '不可用',
+ 'status.pidRunning': '(运行中)',
+ 'status.frontend': '前端',
+ 'status.docker': 'Docker',
+ 'status.dockerUp': '可用',
+ 'status.dockerDown': '未运行',
+ 'status.section.project': '项目信息',
+ 'status.section.api': 'API 服务',
+ 'status.section.frontend': '前端',
+ 'status.section.docker': 'Docker',
+ 'status.field.url': 'URL',
+ 'status.field.http': 'HTTP',
+ 'status.field.health': '健康',
+ 'status.field.database': '数据库',
+ 'status.field.pid': 'PID',
+ 'status.field.engine': '引擎',
+ 'status.field.config': '配置',
+ 'status.field.env': '环境',
+ 'status.field.source': '来源',
+ 'status.field.dir': '目录',
+ 'bootstrap.configReady': '配置已存在,数据库已就绪。',
+ 'bootstrap.projectDbPending': '项目已创建,但数据库未就绪: {message}。请确认 Docker 已启动后重试 reactpress dev。',
+ 'bootstrap.ready': 'ReactPress 开发环境已就绪(配置 + 数据库)。',
+ 'bootstrap.initFailed': '初始化失败',
+ 'bootstrap.cliInitFailed': 'reactpress-cli init 失败',
+ 'bootstrap.dbPendingShort': '数据库未就绪',
+ 'bootstrap.dbNotReady': '{message}。建议:启动 Docker 后运行 reactpress docker up,或执行 reactpress doctor',
+ 'bootstrap.dbReady': '数据库已就绪',
+ 'db.backup.to': '备份数据库到 {path}',
+ 'db.backup.done': '备份完成',
+ 'db.backup.viaDocker': '本机未找到 mysqldump,改用 Docker 内 db 容器的 mysqldump…',
+ 'db.backup.fail':
+ 'mysqldump 失败:请安装 MySQL 客户端(如 brew install mysql-client),或确保 Docker 数据库已运行以便自动在容器内备份',
+ 'common.done': '完成',
+ 'common.yes': '是',
+ 'common.no': '否',
+ 'common.none': '(无)',
+ 'common.unknownError': '未知错误',
+ 'lifecycle.apiStopped': '[reactpress] 已停止 API 进程 (pid {pid})',
+ 'lifecycle.stopPidFailed': '[reactpress] 停止 pid {pid} 失败:',
+ 'lifecycle.apiAlreadyRunning': '[reactpress] API 已在运行 (pid {pid})',
+ 'lifecycle.noServerAvailable': '[reactpress] 未找到可用的 API 运行时。请重新安装 @fecommunity/reactpress,或在含 server/src 的项目目录中运行。',
+ 'lifecycle.startingLocalApi': '[reactpress] 正在启动本地 API (server/)…',
+ 'lifecycle.startingBundledApi': '[reactpress] 正在启动内置 API…',
+ 'lifecycle.apiStartedBg': '[reactpress] API 已后台启动 (pid {pid})',
+ 'lifecycle.apiTimeout120': '[reactpress] API 在 120s 内未就绪: {url}',
+ 'lifecycle.apiReady': '[reactpress] API 已就绪: {url}',
+ 'lifecycle.apiStatusTitle': '[reactpress] API 状态',
+ 'lifecycle.source': ' 来源: {source}',
+ 'lifecycle.source.monorepo': 'monorepo server/',
+ 'lifecycle.source.bundle': '内置 API (@fecommunity/reactpress)',
+ 'lifecycle.pidFile': ' PID 文件: {path}',
+ 'lifecycle.recordedPid': ' 记录 PID: {pid}',
+ 'lifecycle.processAlive': ' 进程存活: {alive}',
+ 'lifecycle.httpStatus': ' HTTP ({url}): {status}',
+ 'lifecycle.httpReachable': '可访问',
+ 'lifecycle.httpUnreachable': '不可访问',
+ 'lifecycle.unknownCommand': '未知 lifecycle 命令: {command}',
+ 'docker.stopping': '[reactpress] 正在停止 Docker 服务…',
+ 'docker.stopped': '[reactpress] Docker 服务已停止。',
+ 'docker.stopFailed': '[reactpress] 停止 Docker 失败:',
+ 'docker.starting': '[reactpress] 正在启动 Docker 服务…',
+ 'docker.notRunning': 'Docker 未运行,请先启动 Docker Desktop。',
+ 'docker.devStartBlocked':
+ '无法连接 127.0.0.1:{port} 上的 MySQL,且 Docker 未运行。请先启动 Docker Desktop,再执行:reactpress docker up — 或在 .env 中将 DB_* 指向已有 MySQL 实例。',
+ 'docker.started': '[reactpress] Docker 服务已启动。',
+ 'docker.waitingMysql': '[reactpress] 等待 MySQL 就绪…',
+ 'docker.mysqlReady': '[reactpress] MySQL 已就绪。',
+ 'docker.mysqlExternalReady': '[reactpress] 已使用端口 {port} 上的现有 MySQL。',
+ 'docker.dbPortInUse':
+ '[reactpress] 端口 {port} 已被占用 — 跳过 reactpress_db,改用该端口上的现有 MySQL。',
+ 'docker.dbReuseExisting':
+ '[reactpress] 端口 {port} 上 MySQL 已可用 — 保留 Docker 数据库容器',
+ 'docker.dbPortInUseRecycle':
+ '[reactpress] 端口 {port} 被占用且 MySQL 不可达 — 正在重建 reactpress_db 容器…',
+ 'docker.dbPortConflict':
+ '[reactpress] 端口 {port} 上的 MySQL 不可达。请执行:docker start reactpress_cli_db reactpress_db 或 docker compose -f docker-compose.dev.yml up -d db',
+ 'docker.ensureDevDb': '[reactpress] MySQL 不可达 — 正在启动 Docker 数据库…',
+ 'docker.waitingMysqlProgress': '[reactpress] 等待 MySQL… ({attempts}/{max})',
+ 'docker.mysqlTimeout': '[reactpress] MySQL 在超时时间内未就绪。',
+ 'docker.mysqlNotReady': 'MySQL 未就绪',
+ 'docker.startDevStack': '[reactpress] 启动 API + 前端 (Docker MySQL)…',
+ 'docker.visitUrls': '[reactpress] 访问: http://localhost (nginx) / http://localhost:3001 (client)',
+ 'docker.devProcessExit': '开发进程退出: {code}',
+ 'docker.unknownCommand': '未知 docker 命令: {command}',
+ 'nginx.configCreated': '[reactpress] 已生成 nginx 配置: {path}',
+ 'nginx.configExists': '[reactpress] nginx 配置已存在: {path}',
+ 'nginx.ensureWarn': '[reactpress] 无法确保 nginx 配置: {message}',
+ 'nginx.started': '[reactpress] Nginx 已启动 — {url}',
+ 'nginx.configPath': '[reactpress] 配置: {path}',
+ 'nginx.stopped': '[reactpress] Nginx 已停止。',
+ 'nginx.startFailed': '启动 nginx 容器失败',
+ 'nginx.prodMonorepoOnly': '生产 nginx(--prod)需要 monorepo 且存在 docker-compose.prod.yml',
+ 'nginx.statusTitle': '[reactpress] Nginx 状态',
+ 'nginx.statusContainer': ' 容器 {name}: {running}',
+ 'nginx.statusConfig': ' 配置 {path}: {exists}',
+ 'nginx.statusUrl': ' 入口 {url} (端口 {port})',
+ 'nginx.statusMode': ' 模式: {mode}',
+ 'nginx.notRunning': 'Nginx 容器未运行。请执行: reactpress nginx up',
+ 'nginx.testOk': '[reactpress] Nginx 配置校验通过。',
+ 'nginx.testFailed': 'Nginx 配置校验失败',
+ 'nginx.reloadOk': '[reactpress] Nginx 已重载。',
+ 'nginx.reloadFailed': 'Nginx 重载失败',
+ 'nginx.opening': '[reactpress] 正在打开 {url}',
+ 'nginx.unknownCommand': '未知 nginx 命令: {command}',
+ 'nginx.templateMissing': '内置 nginx 模板缺失: {path}',
+ 'nginx.doctorSkippedDocker': '已跳过(Docker 未运行)',
+ 'nginx.doctorSkippedNotRunning': '未启动(可选: reactpress nginx up)',
+ 'nginx.doctorNotRunningFix': 'reactpress nginx up(或 reactpress docker up)',
+ 'nginx.doctorOk': 'Nginx 健康 ({url}/health)',
+ 'nginx.doctorUnhealthy': 'Nginx 在运行但 /health 失败 ({url})',
+ 'nginx.doctorUnhealthyFix': '确认前端 (:3001) 与 API (:3002) 已启动;可执行 reactpress nginx reload',
+ 'doctor.check.nginx': 'Nginx 代理',
+ 'apiDev.modeServer': '[reactpress] 开发模式: server/ (nest start --watch)',
+ 'apiDev.modeBundled': '[reactpress] 开发模式: 内置 API(随包附带)',
+ 'apiDev.ctrlCHint': '[reactpress] 按 Ctrl+C 停止 API。',
+ 'apiDev.stopHint': '[reactpress] 单独停止: reactpress server stop',
+ 'build.unknownTarget': '未知构建目标: {target},可选: {available}',
+ 'build.recursive': '检测到构建递归(pnpm run build 不能再次调用自身)。请使用 build:toolkit、build:server 或 build:client。',
+ 'build.forbiddenScript': '无效的构建脚本 "{script}",请使用 build:toolkit 等细分脚本。',
+ 'build.stepFailed': '[{current}/{total}] {label} 失败',
+ 'build.plan': '生产构建 — 共 {total} 步:toolkit → server → client',
+ 'build.step': '[{current}/{total}] {label}',
+ 'build.stepDone': '[{current}/{total}] {label} ({seconds}s)',
+ 'build.stepSkipped': '已跳过 {label}(当前项目无对应源码包)',
+ 'build.stepSkippedFresh': '已跳过 {label}(dist 已是最新)',
+ 'build.stepSkippedReuse': '已跳过 {label} — 复用主题「{id}」已有构建',
+ 'build.done': '构建完成,耗时 {seconds}s',
+ 'build.label.toolkit': 'Toolkit',
+ 'build.label.plugins': '插件 (plugins)',
+ 'build.label.server': 'API (server)',
+ 'build.label.web': '管理后台 (web)',
+ 'build.label.theme': '访客主题 (theme)',
+ 'build.label.docs': '文档 (docs)',
+ 'pm2.startFailed': '[reactpress] PM2 启动 API 失败:',
+ 'pm2.exitCode': 'PM2 退出码 {code}',
+ 'spawn.commandFailed': '命令失败 ({command}): 退出码 {code}',
+ 'spawn.exitCode': '退出码 {code}',
+ 'shim.deprecated': '\n[deprecated] reactpress-cli 将在 3.1 移除。请改用:\n npm i -g @fecommunity/reactpress\n reactpress init · reactpress dev · reactpress doctor\n',
+ 'server.help.invokedBy': ' (通常由 reactpress server start 调用)',
+ 'publish.pkg.main': 'ReactPress 3.0 主包 — 唯一入口 (init / dev / doctor / publish)',
+ 'publish.pkg.server': 'NestJS 后端 API (deprecated — 使用 reactpress-cli 内置 API)',
+ 'bundle.cli.description': '零配置初始化与管理 ReactPress CMS & 博客服务器',
+ 'bundle.cli.cwd': 'ReactPress 项目目录(默认:当前工作目录)',
+ 'bundle.cli.init.description': '一键初始化 ReactPress CMS & 博客服务器(零配置)',
+ 'bundle.cli.init.directory': '项目目录',
+ 'bundle.cli.init.force': '覆盖已有配置',
+ 'bundle.cli.start.description': '启动服务器(自动准备数据库)',
+ 'bundle.cli.stop.description': '停止服务器',
+ 'bundle.cli.stop.database': '同时停止嵌入式数据库容器',
+ 'bundle.cli.restart.description': '重启服务器',
+ 'bundle.cli.status.description': '查看服务与数据库状态',
+ 'bundle.cli.config.description': '查看或更新配置(更新后可用 --apply 重启生效)',
+ 'bundle.cli.config.key': '配置键,如 server.port',
+ 'bundle.cli.config.value': '新值',
+ 'bundle.cli.config.list': '列出所有配置',
+ 'bundle.cli.config.apply': '更新后自动重启服务',
+ 'bundle.cli.unknownCommand': '未知命令: {command}',
+ 'bundle.cmd.init.spinner': '正在初始化 ReactPress 项目…',
+ 'bundle.cmd.init.succeed': '初始化完成',
+ 'bundle.cmd.init.fail': '初始化失败',
+ 'bundle.cmd.init.projectDir': '项目目录: {path}',
+ 'bundle.cmd.init.nextStep': '下一步: reactpress-cli start',
+ 'bundle.cmd.notProject': '当前目录不是 ReactPress 项目。',
+ 'bundle.cmd.notProjectInit': '当前目录不是 ReactPress 项目。请先运行 reactpress-cli init。',
+ 'bundle.cmd.start.spinner': '正在准备数据库与服务…',
+ 'bundle.cmd.start.succeed': '服务已启动',
+ 'bundle.cmd.start.fail': '启动失败',
+ 'bundle.cmd.stop.spinner': '正在停止服务…',
+ 'bundle.cmd.stop.succeed': '已停止',
+ 'bundle.cmd.stop.fail': '停止失败',
+ 'bundle.cmd.restart.spinner': '正在重启服务…',
+ 'bundle.cmd.restart.succeed': '重启完成',
+ 'bundle.cmd.restart.fail': '重启失败',
+ 'bundle.cmd.status.title': '服务状态',
+ 'bundle.cmd.status.project': '项目: {path}',
+ 'bundle.cmd.status.service': '服务: {status}{pid}',
+ 'bundle.cmd.status.running': '运行中',
+ 'bundle.cmd.status.stopped': '已停止',
+ 'bundle.cmd.status.url': '地址: {url}',
+ 'bundle.cmd.status.database': '数据库: {status} ({mode})',
+ 'bundle.cmd.status.dbReady': '就绪',
+ 'bundle.cmd.status.dbNotReady': '未就绪',
+ 'bundle.cmd.config.title': '配置项',
+ 'bundle.cmd.config.keyRequired': '请指定配置键,例如: reactpress-cli config server.port 3003',
+ 'bundle.cmd.config.listHint': '使用 --list 查看所有配置项',
+ 'bundle.cmd.config.updated': '已更新 {key} = {value}',
+ 'bundle.cmd.config.restartSpinner': '正在重启以使配置生效…',
+ 'bundle.cmd.config.applied': '配置已应用',
+ 'bundle.cmd.config.restartFail': '重启失败',
+ 'bundle.cmd.config.restartManual': '请手动运行 reactpress-cli restart',
+ 'bundle.cmd.config.applyHint': '运行 reactpress-cli restart 或 config --apply 使配置生效',
+ 'bundle.service.init.alreadyProject': '目录已是 ReactPress 项目。使用 --force 覆盖配置。',
+ 'bundle.service.init.dbPending': '项目已创建,但数据库未就绪: {message}。可稍后运行 reactpress-cli start。',
+ 'bundle.service.init.complete': 'ReactPress 项目初始化完成。运行 reactpress-cli start 启动服务。',
+ 'bundle.service.init.templateMissing': '模板文件缺失: {path}',
+ 'bundle.service.config.notFound': '未找到 ReactPress 项目。请先运行 reactpress-cli init 初始化。',
+ 'bundle.service.config.keyMissing': '配置项不存在: {key}',
+ 'bundle.service.server.alreadyRunning': '服务已在运行 (PID {pid}),访问 {url}',
+ 'bundle.service.server.portBusy': '端口 {port} 已被占用,ReactPress 无法绑定。若曾用 Docker Compose 启动过 ReactPress,请执行: docker stop reactpress_server。也可在 .reactpress/config.json 中修改 server.port。',
+ 'bundle.service.server.cannotStart': '无法启动 ReactPress 服务进程。',
+ 'bundle.service.server.noHttp': '服务进程已启动 (PID {pid}),但 {url} 无 HTTP 响应。请检查端口占用或 .env 数据库配置。',
+ 'bundle.service.server.started': 'ReactPress 服务已启动,访问 {url}',
+ 'bundle.service.server.stopped': 'ReactPress 服务已停止。',
+ 'bundle.service.server.cannotStopPid': '无法停止进程 PID {pid}',
+ 'bundle.service.database.dockerMissing': '未检测到 Docker。请安装并启动 Docker,或将 database.mode 设为 external 并使用已有 MySQL。',
+ 'bundle.service.database.portSwitched': '宿主机端口 {previous} 已被占用,已改用 {port}(已更新 .env)',
+ 'bundle.service.database.portBindRetry': '端口 {port} 绑定失败,正在尝试其他端口…',
+ 'bundle.service.database.containerStartFailed': '启动数据库容器失败: {detail}',
+ 'bundle.service.database.credsMismatch': '数据库容器已在端口 {port} 运行,但账号「{user}」无法连接(数据卷中的凭证与 .env 不一致)。请在项目目录执行: cd .reactpress && docker compose down -v && cd .. && reactpress-cli start',
+ 'bundle.service.database.connectionTimeout': '数据库容器已启动,但连接超时。请执行 docker logs {container} 查看详情。',
+ 'bundle.service.database.cannotConnect': '无法连接数据库 {host}:{port},请检查 .env 中的 DB_* 配置。',
+ 'bundle.serverBundle.missing': '内置服务端缺失,请重新安装 reactpress-cli。',
+ 'bundle.serverBundle.installFailed': '内置服务端依赖安装失败。请在 reactpress-cli 安装目录下手动执行: cd server && npm install --omit=dev --no-bin-links',
+ 'bundle.serverBundle.notBuilt': '内置服务端未构建或依赖不完整。请运行 npm run build:server(开发)或重新安装 reactpress-cli。',
+ 'bundle.port.notFound': '在 {start}-{end} 范围内未找到可用端口',
+ },
+};
+
+module.exports = { STRINGS };
diff --git a/cli/src/lib/lifecycle.ts b/cli/src/lib/lifecycle.ts
new file mode 100644
index 00000000..03137b08
--- /dev/null
+++ b/cli/src/lib/lifecycle.ts
@@ -0,0 +1,191 @@
+// @ts-nocheck
+const { spawn } = require('child_process');
+const ora = require('ora');
+const { ensureProjectEnvironment } = require('./bootstrap');
+const { loadServerSiteUrl, waitForHttp } = require('./http');
+const {
+ getServerBin,
+ getServerDir,
+ isUsingMonorepoServer,
+ canStartLocalApi,
+ getPidFile,
+} = require('./paths');
+const net = require('net');
+const { readPid, isProcessRunning, clearPidFile, writePid } = require('./process');
+const { ensureOriginalCwd } = require('./root');
+const { t } = require('./i18n');
+
+function parseServerPort(projectRoot) {
+ try {
+ const url = new URL(loadServerSiteUrl(projectRoot));
+ return Number(url.port) || 3002;
+ } catch {
+ return 3002;
+ }
+}
+
+function isPortBusy(port, host = '127.0.0.1') {
+ return new Promise((resolve) => {
+ const socket = net.createConnection({ port, host }, () => {
+ socket.destroy();
+ resolve(true);
+ });
+ socket.on('error', () => resolve(false));
+ socket.setTimeout(800, () => {
+ socket.destroy();
+ resolve(false);
+ });
+ });
+}
+
+async function waitForPortFree(port, timeoutMs = 8000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (!(await isPortBusy(port))) return true;
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ return false;
+}
+
+async function ensureConfig(projectRoot) {
+ try {
+ await ensureProjectEnvironment(projectRoot);
+ return true;
+ } catch (err) {
+ console.error(t('dev.envFailed'), err.message || err);
+ return false;
+ }
+}
+
+function stopApi(projectRoot) {
+ const pid = readPid(projectRoot);
+ if (pid && isProcessRunning(pid)) {
+ try {
+ process.kill(pid, 'SIGTERM');
+ console.log(t('lifecycle.apiStopped', { pid }));
+ } catch (err) {
+ console.warn(t('lifecycle.stopPidFailed', { pid }), err.message);
+ }
+ }
+ clearPidFile(projectRoot);
+}
+
+async function startApi(projectRoot, { wait = true } = {}) {
+ if (!(await ensureConfig(projectRoot))) {
+ return 1;
+ }
+
+ const existing = readPid(projectRoot);
+ if (existing && isProcessRunning(existing)) {
+ console.log(t('lifecycle.apiAlreadyRunning', { pid: existing }));
+ return 0;
+ }
+ clearPidFile(projectRoot);
+
+ if (!canStartLocalApi(projectRoot)) {
+ console.error(t('lifecycle.noServerAvailable'));
+ return 1;
+ }
+
+ if (isUsingMonorepoServer(projectRoot)) {
+ console.log(t('lifecycle.startingLocalApi'));
+ } else {
+ console.log(t('lifecycle.startingBundledApi'));
+ }
+
+ const child = spawn(process.execPath, [getServerBin(projectRoot)], {
+ cwd: getServerDir(projectRoot),
+ detached: true,
+ stdio: 'ignore',
+ env: {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ },
+ });
+
+ child.unref();
+ writePid(projectRoot, child.pid);
+ console.log(t('lifecycle.apiStartedBg', { pid: child.pid }));
+
+ if (!wait) {
+ return 0;
+ }
+
+ const serverUrl = loadServerSiteUrl(projectRoot);
+ const spinner = ora({
+ text: t('dev.waitingApi', { url: serverUrl }),
+ color: 'magenta',
+ spinner: 'dots',
+ }).start();
+ const ready = await waitForHttp(serverUrl);
+ if (!ready) {
+ spinner.fail(t('lifecycle.apiTimeout120', { url: serverUrl }));
+ return 1;
+ }
+ spinner.succeed(t('lifecycle.apiReady', { url: serverUrl }));
+ return 0;
+}
+
+async function statusApi(projectRoot) {
+ const pid = readPid(projectRoot);
+ const serverUrl = loadServerSiteUrl(projectRoot);
+ const { isHttpResponding } = require('./http');
+ const httpOk = await isHttpResponding(serverUrl);
+
+ const source = isUsingMonorepoServer(projectRoot)
+ ? t('lifecycle.source.monorepo')
+ : t('lifecycle.source.bundle');
+
+ console.log(t('lifecycle.apiStatusTitle'));
+ console.log(t('lifecycle.source', { source }));
+ console.log(t('lifecycle.pidFile', { path: getPidFile(projectRoot) }));
+ console.log(
+ t('lifecycle.recordedPid', {
+ pid: pid ?? t('common.none'),
+ })
+ );
+ console.log(
+ t('lifecycle.processAlive', {
+ alive: pid
+ ? isProcessRunning(pid)
+ ? t('common.yes')
+ : t('common.no')
+ : '—',
+ })
+ );
+ console.log(
+ t('lifecycle.httpStatus', {
+ url: serverUrl,
+ status: httpOk ? t('lifecycle.httpReachable') : t('lifecycle.httpUnreachable'),
+ })
+ );
+}
+
+async function runLifecycleCommand(command, projectRoot = ensureOriginalCwd()) {
+ switch (command) {
+ case 'start':
+ return startApi(projectRoot, { wait: true });
+ case 'start:bg':
+ return startApi(projectRoot, { wait: false });
+ case 'stop':
+ stopApi(projectRoot);
+ return 0;
+ case 'restart':
+ stopApi(projectRoot);
+ await waitForPortFree(parseServerPort(projectRoot));
+ await new Promise((r) => setTimeout(r, 400));
+ return startApi(projectRoot, { wait: true });
+ case 'status':
+ await statusApi(projectRoot);
+ return 0;
+ default:
+ throw new Error(t('lifecycle.unknownCommand', { command }));
+ }
+}
+
+module.exports = {
+ startApi,
+ stopApi,
+ statusApi,
+ runLifecycleCommand,
+};
diff --git a/cli/src/lib/nginx.ts b/cli/src/lib/nginx.ts
new file mode 100644
index 00000000..ae110acb
--- /dev/null
+++ b/cli/src/lib/nginx.ts
@@ -0,0 +1,659 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const { spawnSync } = require('child_process');
+const open = require('open');
+const { detectProjectType } = require('./project-type');
+const { isDockerRunning, pickDockerComposeCommand } = require('./docker');
+const { t } = require('./i18n');
+const { readDevClientApiOrigin } = require('./remote-dev');
+
+const NGINX_CONTAINER = 'reactpress_nginx';
+const DEFAULT_NGINX_PORT = 80;
+
+function resolveNginxMode(options = {}) {
+ return options.prod ? 'prod' : 'dev';
+}
+
+function resolveNginxConfigBasename(mode) {
+ return mode === 'prod' ? 'nginx.conf' : 'nginx.dev.conf';
+}
+
+function resolveNginxConfigPath(projectRoot, mode = 'dev') {
+ const basename = resolveNginxConfigBasename(mode);
+ const type = detectProjectType(projectRoot);
+ if (type === 'monorepo') {
+ return path.join(projectRoot, basename);
+ }
+ return path.join(projectRoot, '.reactpress', basename);
+}
+
+function bundledTemplatePath(mode) {
+ const file = mode === 'prod' ? 'nginx.prod.conf' : 'nginx.dev.conf';
+ return path.join(__dirname, '..', '..', 'templates', file);
+}
+
+function resolveNginxPort(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const m = content.match(/^NGINX_PORT=(.+)$/m);
+ if (m) {
+ const port = parseInt(m[1].trim().replace(/^['"]|['"]$/g, ''), 10);
+ if (port > 0) return port;
+ }
+ } catch {
+ // ignore
+ }
+ return DEFAULT_NGINX_PORT;
+}
+
+function nginxEntryUrl(projectRoot) {
+ const port = resolveNginxPort(projectRoot);
+ return port === 80 ? 'http://localhost' : `http://localhost:${port}`;
+}
+
+function readDevNginxPorts(projectRoot) {
+ const { DEV_PORTS, readEnvPort, readVisitorPort } = require('./ports');
+ return {
+ adminPort: readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB),
+ visitorPort: readVisitorPort(projectRoot),
+ apiPort: readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API),
+ };
+}
+
+function resolveRemoteUpstreamHost(remoteApiOrigin) {
+ try {
+ return new URL(remoteApiOrigin).host;
+ } catch {
+ return remoteApiOrigin.replace(/^https?:\/\//i, '').split('/')[0];
+ }
+}
+
+function renderApiProxyBlock(remoteApiOrigin, apiPort) {
+ if (remoteApiOrigin) {
+ const upstreamHost = resolveRemoteUpstreamHost(remoteApiOrigin);
+ return ` # REST API (remote upstream)
+ location /api {
+ proxy_pass ${remoteApiOrigin};
+ proxy_ssl_server_name on;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host ${upstreamHost};
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
+ proxy_redirect off;
+ }`;
+ }
+
+ return ` # REST API (Nest on host :${apiPort}, keep /api prefix)
+ location /api {
+ proxy_pass http://host.docker.internal:${apiPort};
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
+ proxy_redirect off;
+ }`;
+}
+
+function renderPublicUploadsProxyBlock(remoteApiOrigin, apiPort) {
+ const proxyTarget = remoteApiOrigin
+ ? remoteApiOrigin.replace(/\/api\/?$/, '')
+ : `http://host.docker.internal:${apiPort}`;
+
+ return ` # Uploaded media (API static /public)
+ location /public/ {
+ proxy_pass ${proxyTarget};
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ expires 30d;
+ add_header Cache-Control "public, max-age=2592000";
+ access_log off;
+ }`;
+}
+
+function renderDevNginxConfig({ adminPort, visitorPort, apiPort, clientApiOrigin = null }) {
+ const apiBlock = renderApiProxyBlock(clientApiOrigin, apiPort);
+ const publicBlock = renderPublicUploadsProxyBlock(clientApiOrigin, apiPort);
+ return `server {
+ listen 80;
+ server_name localhost;
+ charset utf-8;
+
+ # Visitor site (active theme Next.js on host :${visitorPort})
+ location / {
+ proxy_pass http://host.docker.internal:${visitorPort};
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
+ proxy_redirect off;
+ }
+
+ # Admin SPA (Vite base /admin/, host :${adminPort})
+ location /admin/ {
+ proxy_pass http://host.docker.internal:${adminPort}/admin/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ }
+
+ location = /admin {
+ return 301 /admin/;
+ }
+
+${publicBlock}
+
+${apiBlock}
+
+ # Next.js dev/HMR rewrites chunks frequently — never cache /_next (prod nginx keeps long cache).
+ location /_next/ {
+ proxy_pass http://host.docker.internal:${visitorPort};
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
+ }
+
+ location /health {
+ access_log off;
+ return 200 "healthy\\n";
+ add_header Content-Type text/plain;
+ }
+
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
+`;
+}
+
+function isDevNginxConfigStale(projectRoot, configPath) {
+ const { adminPort, visitorPort, apiPort } = readDevNginxPorts(projectRoot);
+ const clientApiOrigin = readDevClientApiOrigin(projectRoot);
+ let content;
+ try {
+ content = fs.readFileSync(configPath, 'utf8');
+ } catch {
+ return true;
+ }
+ if (content.includes(':5173')) return true;
+ if (!content.includes(`host.docker.internal:${adminPort}/admin/`)) return true;
+ if (!content.includes(`host.docker.internal:${visitorPort}`)) return true;
+ if (clientApiOrigin) {
+ if (!content.includes(`proxy_pass ${clientApiOrigin}`)) return true;
+ if (content.includes(`host.docker.internal:${apiPort}`)) return true;
+ } else if (!content.includes(`host.docker.internal:${apiPort}`)) {
+ return true;
+ }
+ // Dev must not long-cache Next chunks (breaks client-side nav after on-demand compile).
+ if (content.includes('expires 1y') && content.includes('/_next/')) return true;
+ if (!content.includes('location /public/')) return true;
+ return false;
+}
+
+function isProdNginxConfigStale(projectRoot, configPath) {
+ const { visitorPort, apiPort } = readDevNginxPorts(projectRoot);
+ let content = '';
+ try {
+ content = fs.readFileSync(configPath, 'utf8');
+ } catch {
+ return true;
+ }
+ if (content.includes('host.docker.internal:13001') || content.includes('host.docker.internal:13002')) {
+ return true;
+ }
+ if (!content.includes(`host.docker.internal:${visitorPort}`)) return true;
+ if (!content.includes(`host.docker.internal:${apiPort}`)) return true;
+ if (!content.includes('location /public/')) return true;
+ return false;
+}
+
+function renderProdNginxConfig(projectRoot) {
+ const templatePath = bundledTemplatePath('prod');
+ const { visitorPort, apiPort } = readDevNginxPorts(projectRoot);
+ let content = fs.readFileSync(templatePath, 'utf8');
+ content = content.replace(/host\.docker\.internal:3001/g, `host.docker.internal:${visitorPort}`);
+ content = content.replace(/host\.docker\.internal:3002/g, `host.docker.internal:${apiPort}`);
+ return content;
+}
+
+function writeProdNginxConfig(projectRoot) {
+ const configPath = resolveNginxConfigPath(projectRoot, 'prod');
+ const content = renderProdNginxConfig(projectRoot);
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
+ const existed = fs.existsSync(configPath);
+ const previous = existed ? fs.readFileSync(configPath, 'utf8') : '';
+ fs.writeFileSync(configPath, content, 'utf8');
+ return {
+ configPath,
+ changed: content !== previous,
+ created: !existed,
+ mode: 'prod',
+ };
+}
+
+function writeDevNginxConfig(projectRoot) {
+ const configPath = resolveNginxConfigPath(projectRoot, 'dev');
+ const ports = readDevNginxPorts(projectRoot);
+ const clientApiOrigin = readDevClientApiOrigin(projectRoot);
+ const content = renderDevNginxConfig({ ...ports, clientApiOrigin });
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
+ const existed = fs.existsSync(configPath);
+ const previous = existed ? fs.readFileSync(configPath, 'utf8') : '';
+ fs.writeFileSync(configPath, content, 'utf8');
+ return {
+ configPath,
+ changed: content !== previous,
+ created: !existed,
+ mode: 'dev',
+ };
+}
+
+/**
+ * Write default nginx config from CLI templates when missing (or when force).
+ *
+ * @returns {{ configPath: string, created: boolean, mode: 'dev' | 'prod', changed?: boolean }}
+ */
+function ensureNginxConfig(projectRoot, options = {}) {
+ const mode = resolveNginxMode(options);
+ const configPath = resolveNginxConfigPath(projectRoot, mode);
+
+ if (mode === 'dev') {
+ if (options.force || !fs.existsSync(configPath) || isDevNginxConfigStale(projectRoot, configPath)) {
+ const result = writeDevNginxConfig(projectRoot);
+ return { configPath: result.configPath, created: result.created || result.changed, changed: result.changed, mode };
+ }
+ return { configPath, created: false, changed: false, mode };
+ }
+
+ if (mode === 'prod') {
+ if (options.force || !fs.existsSync(configPath) || isProdNginxConfigStale(projectRoot, configPath)) {
+ const result = writeProdNginxConfig(projectRoot);
+ return {
+ configPath: result.configPath,
+ created: result.created || result.changed,
+ changed: result.changed,
+ mode,
+ };
+ }
+ return { configPath, created: false, changed: false, mode };
+ }
+
+ const templatePath = bundledTemplatePath(mode);
+ if (!fs.existsSync(templatePath)) {
+ throw new Error(t('nginx.templateMissing', { path: templatePath }));
+ }
+
+ const exists = fs.existsSync(configPath);
+ if (exists && !options.force) {
+ return { configPath, created: false, mode };
+ }
+
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
+ fs.copyFileSync(templatePath, configPath);
+ return { configPath, created: !exists || !!options.force, mode };
+}
+
+function resolveNginxComposeContext(projectRoot, mode = 'dev') {
+ const type = detectProjectType(projectRoot);
+ if (mode === 'prod' && type === 'monorepo') {
+ return {
+ composeFile: path.join(projectRoot, 'docker-compose.prod.yml'),
+ cwd: projectRoot,
+ service: 'nginx',
+ };
+ }
+ if (type === 'monorepo') {
+ return {
+ composeFile: path.join(projectRoot, 'docker-compose.dev.yml'),
+ cwd: projectRoot,
+ service: 'nginx',
+ };
+ }
+ return {
+ composeFile: path.join(projectRoot, '.reactpress', 'docker-compose.yml'),
+ cwd: path.join(projectRoot, '.reactpress'),
+ service: 'nginx',
+ };
+}
+
+function composeDefinesNginxService(composeFile) {
+ try {
+ const content = fs.readFileSync(composeFile, 'utf8');
+ return /^\s*nginx:\s*$/m.test(content);
+ } catch {
+ return false;
+ }
+}
+
+function runComposeOnContext(ctx, args, options = {}) {
+ const { command, baseArgs } = pickDockerComposeCommand();
+ return spawnSync(command, [...baseArgs, '-f', ctx.composeFile, ...args], {
+ stdio: options.stdio ?? 'inherit',
+ cwd: ctx.cwd,
+ ...options,
+ });
+}
+
+function isNginxContainerRunning() {
+ const res = spawnSync(
+ 'docker',
+ ['inspect', '-f', '{{.State.Running}}', NGINX_CONTAINER],
+ { encoding: 'utf8' }
+ );
+ return res.status === 0 && res.stdout.trim() === 'true';
+}
+
+function startNginxContainer(configPath, port) {
+ spawnSync('docker', ['rm', '-f', NGINX_CONTAINER], { stdio: 'ignore' });
+ const absConfig = path.resolve(configPath);
+ const res = spawnSync(
+ 'docker',
+ [
+ 'run',
+ '-d',
+ '--name',
+ NGINX_CONTAINER,
+ '-p',
+ `${port}:80`,
+ '-v',
+ `${absConfig}:/etc/nginx/conf.d/default.conf:ro`,
+ '--add-host',
+ 'host.docker.internal:host-gateway',
+ 'nginx:alpine',
+ ],
+ { encoding: 'utf8' }
+ );
+ if (res.status !== 0) {
+ throw new Error(res.stderr?.trim() || t('nginx.startFailed'));
+ }
+}
+
+function stopNginxContainer() {
+ spawnSync('docker', ['rm', '-sf', NGINX_CONTAINER], { stdio: 'ignore' });
+}
+
+function nginxUp(projectRoot, options = {}) {
+ if (!isDockerRunning()) {
+ throw new Error(t('docker.notRunning'));
+ }
+
+ const mode = resolveNginxMode(options);
+ const type = detectProjectType(projectRoot);
+
+ if (mode === 'prod' && type !== 'monorepo') {
+ throw new Error(t('nginx.prodMonorepoOnly'));
+ }
+
+ const { configPath } = ensureNginxConfig(projectRoot, { mode, force: options.force });
+ const port = resolveNginxPort(projectRoot);
+ const ctx = resolveNginxComposeContext(projectRoot, mode);
+
+ if (fs.existsSync(ctx.composeFile) && composeDefinesNginxService(ctx.composeFile)) {
+ const composeArgs = ['up', '-d', '--no-deps', '--remove-orphans', ctx.service];
+ const result = runComposeOnContext(ctx, composeArgs, {
+ stdio: options.quiet ? 'ignore' : 'inherit',
+ });
+ if (result.status !== 0) {
+ throw new Error(t('nginx.startFailed'));
+ }
+ } else {
+ startNginxContainer(configPath, port);
+ }
+
+ if (!options.quiet) {
+ console.log(t('nginx.started', { url: nginxEntryUrl(projectRoot) }));
+ console.log(t('nginx.configPath', { path: configPath }));
+ }
+}
+
+function nginxDown(projectRoot, options = {}) {
+ const mode = resolveNginxMode(options);
+ const ctx = resolveNginxComposeContext(projectRoot, mode);
+ if (fs.existsSync(ctx.composeFile) && composeDefinesNginxService(ctx.composeFile)) {
+ runComposeOnContext(ctx, ['stop', ctx.service], { stdio: 'ignore' });
+ }
+ stopNginxContainer();
+ console.log(t('nginx.stopped'));
+}
+
+function nginxRestart(projectRoot, options = {}) {
+ nginxDown(projectRoot, options);
+ nginxUp(projectRoot, options);
+}
+
+function nginxStatus(projectRoot, options = {}) {
+ const mode = resolveNginxMode(options);
+ const configPath = resolveNginxConfigPath(projectRoot, mode);
+ const port = resolveNginxPort(projectRoot);
+ const running = isNginxContainerRunning();
+ const configExists = fs.existsSync(configPath);
+
+ console.log(t('nginx.statusTitle'));
+ console.log(t('nginx.statusContainer', { name: NGINX_CONTAINER, running: running ? t('common.yes') : t('common.no') }));
+ console.log(t('nginx.statusConfig', { path: configPath, exists: configExists ? t('common.yes') : t('common.no') }));
+ console.log(t('nginx.statusUrl', { url: nginxEntryUrl(projectRoot), port }));
+ console.log(t('nginx.statusMode', { mode }));
+}
+
+function nginxLogs(extraArgs = []) {
+ const args = ['logs', '-f', NGINX_CONTAINER, ...extraArgs];
+ spawnSync('docker', args, { stdio: 'inherit' });
+}
+
+function dockerExecNginx(args) {
+ return spawnSync('docker', ['exec', NGINX_CONTAINER, 'nginx', ...args], {
+ encoding: 'utf8',
+ });
+}
+
+function nginxTest() {
+ if (!isNginxContainerRunning()) {
+ throw new Error(t('nginx.notRunning'));
+ }
+ const res = dockerExecNginx(['-t']);
+ process.stdout.write(res.stdout || '');
+ process.stderr.write(res.stderr || '');
+ if (res.status !== 0) {
+ throw new Error(t('nginx.testFailed'));
+ }
+ console.log(t('nginx.testOk'));
+}
+
+function nginxReload() {
+ nginxTest();
+ const res = dockerExecNginx(['-s', 'reload']);
+ if (res.status !== 0) {
+ throw new Error(res.stderr?.trim() || t('nginx.reloadFailed'));
+ }
+ console.log(t('nginx.reloadOk'));
+}
+
+async function nginxOpen(projectRoot) {
+ const url = nginxEntryUrl(projectRoot);
+ console.log(t('nginx.opening', { url }));
+ await open(url);
+}
+
+function probeNginxHealth(projectRoot, timeoutMs = 2000) {
+ const url = new URL('/health', nginxEntryUrl(projectRoot));
+ return new Promise((resolve) => {
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
+ res.resume();
+ resolve(res.statusCode === 200);
+ });
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ });
+}
+
+async function checkNginx(projectRoot) {
+ if (!isDockerRunning()) {
+ return { ok: true, message: t('nginx.doctorSkippedDocker') };
+ }
+ if (!isNginxContainerRunning()) {
+ return { ok: true, message: t('nginx.doctorSkippedNotRunning') };
+ }
+ const healthy = await probeNginxHealth(projectRoot);
+ if (healthy) {
+ return {
+ ok: true,
+ message: t('nginx.doctorOk', { url: nginxEntryUrl(projectRoot) }),
+ };
+ }
+ return {
+ ok: false,
+ message: t('nginx.doctorUnhealthy', { url: nginxEntryUrl(projectRoot) }),
+ fix: t('nginx.doctorUnhealthyFix'),
+ };
+}
+
+/**
+ * Start dev reverse proxy (Docker). Returns false when skipped or failed (non-fatal).
+ * @returns {Promise}
+ */
+async function startDevNginx(projectRoot) {
+ if (process.env.REACTPRESS_SKIP_NGINX === '1') {
+ return false;
+ }
+ if (!isDockerRunning()) {
+ console.warn(t('dev.nginxSkippedDocker'));
+ return false;
+ }
+ try {
+ const { changed } = writeDevNginxConfig(projectRoot);
+ nginxUp(projectRoot, { quiet: true });
+ if (changed && isNginxContainerRunning()) {
+ try {
+ nginxReload();
+ } catch {
+ nginxRestart(projectRoot, { quiet: true });
+ }
+ }
+ const probeMs = Math.max(
+ 1000,
+ parseInt(process.env.REACTPRESS_NGINX_PROBE_MS || '4000', 10) || 4000,
+ );
+ const healthy = await probeNginxHealth(projectRoot, probeMs);
+ if (!healthy) {
+ console.warn(t('dev.nginxSlow', { url: nginxEntryUrl(projectRoot) }));
+ }
+ return true;
+ } catch (err) {
+ console.warn(t('dev.nginxStartFailed', { message: err.message || String(err) }));
+ return false;
+ }
+}
+
+function stopDevNginx(projectRoot) {
+ try {
+ nginxDown(projectRoot);
+ } catch {
+ stopNginxContainer();
+ }
+}
+
+async function runNginxCommand(command, projectRoot, extraArgs = [], options = {}) {
+ switch (command) {
+ case 'ensure': {
+ const { configPath, created } = ensureNginxConfig(projectRoot, options);
+ console.log(
+ created ? t('nginx.configCreated', { path: configPath }) : t('nginx.configExists', { path: configPath })
+ );
+ return;
+ }
+ case 'up':
+ nginxUp(projectRoot, options);
+ return;
+ case 'down':
+ case 'stop':
+ nginxDown(projectRoot, options);
+ return;
+ case 'restart':
+ nginxRestart(projectRoot, options);
+ return;
+ case 'status':
+ nginxStatus(projectRoot, options);
+ return;
+ case 'logs':
+ nginxLogs(extraArgs);
+ return;
+ case 'test':
+ nginxTest();
+ return;
+ case 'reload':
+ nginxReload();
+ return;
+ case 'open':
+ await nginxOpen(projectRoot);
+ return;
+ default:
+ throw new Error(t('nginx.unknownCommand', { command }));
+ }
+}
+
+module.exports = {
+ NGINX_CONTAINER,
+ DEFAULT_NGINX_PORT,
+ resolveNginxMode,
+ resolveNginxConfigPath,
+ resolveNginxComposeContext,
+ ensureNginxConfig,
+ renderDevNginxConfig,
+ renderProdNginxConfig,
+ nginxEntryUrl,
+ resolveNginxPort,
+ isNginxContainerRunning,
+ probeNginxHealth,
+ checkNginx,
+ runNginxCommand,
+ startDevNginx,
+ stopDevNginx,
+};
diff --git a/cli/src/lib/paths.ts b/cli/src/lib/paths.ts
new file mode 100644
index 00000000..7132547b
--- /dev/null
+++ b/cli/src/lib/paths.ts
@@ -0,0 +1,128 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { ensureOriginalCwd, getMonorepoRoot } = require('./root');
+
+function resolveProjectRoot(projectRoot) {
+ return path.resolve(projectRoot || ensureOriginalCwd());
+}
+
+function getMonorepoServerDir(projectRoot) {
+ return path.join(resolveProjectRoot(projectRoot), 'server');
+}
+
+function hasMonorepoServerSource(projectRoot) {
+ return fs.existsSync(
+ path.join(getMonorepoServerDir(projectRoot), 'src', 'main.ts')
+ );
+}
+
+function getCliPackageRoot() {
+ const ownRoot = path.join(__dirname, '..', '..');
+ if (fs.existsSync(path.join(ownRoot, 'out', 'bin', 'reactpress.js'))) {
+ return ownRoot;
+ }
+ if (fs.existsSync(path.join(ownRoot, 'dist', 'index.js'))) {
+ return ownRoot;
+ }
+ try {
+ return path.dirname(
+ require.resolve('@fecommunity/reactpress-cli-core/package.json')
+ );
+ } catch {
+ return path.dirname(require.resolve('@fecommunity/reactpress-cli/package.json'));
+ }
+}
+
+function getBundledServerDir() {
+ return path.join(getCliPackageRoot(), 'server');
+}
+
+function hasBundledServerBuild() {
+ return fs.existsSync(path.join(getBundledServerDir(), 'dist', 'main.js'));
+}
+
+function getServerDir(projectRoot) {
+ if (hasMonorepoServerSource(projectRoot)) {
+ return getMonorepoServerDir(projectRoot);
+ }
+ return getBundledServerDir();
+}
+
+function getServerBin(projectRoot) {
+ return path.join(getServerDir(projectRoot), 'bin', 'reactpress-server.js');
+}
+
+function getSwaggerPath(projectRoot) {
+ return path.join(getServerDir(projectRoot), 'public', 'swagger.json');
+}
+
+function getServerMain(projectRoot) {
+ return path.join(getServerDir(projectRoot), 'dist', 'main.js');
+}
+
+function isUsingMonorepoServer(projectRoot) {
+ return hasMonorepoServerSource(projectRoot);
+}
+
+function canStartLocalApi(projectRoot) {
+ return (
+ isUsingMonorepoServer(projectRoot) ||
+ hasBundledServerBuild()
+ );
+}
+
+function getThemeBin(projectRoot) {
+ const root = resolveProjectRoot(projectRoot);
+ const { readActiveThemeManifest, resolveThemeDirectory } = require('./theme-runtime');
+ const { activeTheme } = readActiveThemeManifest(root);
+ const themeDir = resolveThemeDirectory(root, activeTheme);
+ if (!themeDir) {
+ const err = new Error(
+ `Active theme not found: ${activeTheme}. Activate a theme in Admin → Appearance.`
+ );
+ err.code = 'REACTPRESS_THEME_NOT_FOUND';
+ throw err;
+ }
+ const binPath = path.join(themeDir, 'bin', 'reactpress-client.js');
+ if (fs.existsSync(binPath)) {
+ return binPath;
+ }
+ const genericBin = path.join(getCliPackageRoot(), 'bin', 'reactpress-theme-client.js');
+ if (fs.existsSync(genericBin)) {
+ return genericBin;
+ }
+ const err = new Error(
+ `Theme entry not found: ${binPath}. Run from a ReactPress project with an installed theme.`
+ );
+ err.code = 'REACTPRESS_THEME_BIN_NOT_FOUND';
+ throw err;
+}
+
+/** @deprecated Use getThemeBin */
+function getClientBin(projectRoot) {
+ return getThemeBin(projectRoot);
+}
+
+function getPidFile(projectRoot) {
+ return path.join(resolveProjectRoot(projectRoot), '.reactpress', 'server.pid');
+}
+
+module.exports = {
+ getMonorepoRoot,
+ resolveProjectRoot,
+ getMonorepoServerDir,
+ hasMonorepoServerSource,
+ hasBundledServerBuild,
+ isUsingMonorepoServer,
+ canStartLocalApi,
+ getCliPackageRoot,
+ getBundledServerDir,
+ getServerDir,
+ getServerBin,
+ getSwaggerPath,
+ getServerMain,
+ getThemeBin,
+ getClientBin,
+ getPidFile,
+};
diff --git a/cli/src/lib/plugin-build.ts b/cli/src/lib/plugin-build.ts
new file mode 100644
index 00000000..322b53a4
--- /dev/null
+++ b/cli/src/lib/plugin-build.ts
@@ -0,0 +1,97 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+function readLocalPluginIds(projectRoot) {
+ const pkgPath = path.join(projectRoot, 'plugins', 'package.json');
+ if (!fs.existsSync(pkgPath)) return [];
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ const local = pkg?.reactpress?.local;
+ return Array.isArray(local) ? local.filter((id) => typeof id === 'string') : [];
+ } catch {
+ return [];
+ }
+}
+
+function readPluginManifest(pluginDir) {
+ const manifestPath = path.join(pluginDir, 'plugin.json');
+ if (!fs.existsSync(manifestPath)) return null;
+ try {
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function newestMtime(root, relDir) {
+ const dir = path.join(root, relDir);
+ if (!fs.existsSync(dir)) return 0;
+ let max = 0;
+ const walk = (current) => {
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) walk(full);
+ else if (entry.isFile()) max = Math.max(max, fs.statSync(full).mtimeMs);
+ }
+ };
+ walk(dir);
+ return max;
+}
+
+function shouldBuildPlugin(pluginDir) {
+ const pkgPath = path.join(pluginDir, 'package.json');
+ if (!fs.existsSync(pkgPath)) return false;
+ let pkg;
+ try {
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ } catch {
+ return false;
+ }
+ if (!pkg.scripts?.build) return false;
+
+ const manifest = readPluginManifest(pluginDir);
+ const moduleRel = manifest?.server?.module;
+ if (!moduleRel || typeof moduleRel !== 'string') return false;
+
+ const entry = path.join(pluginDir, moduleRel.replace(/^\.\//, ''));
+ if (!fs.existsSync(entry)) return true;
+
+ const srcMtime = newestMtime(pluginDir, 'src');
+ const entryMtime = fs.statSync(entry).mtimeMs;
+ return srcMtime > entryMtime;
+}
+
+function buildPlugin(pluginDir, { quiet = false } = {}) {
+ const name = path.basename(pluginDir);
+ if (!quiet) {
+ console.log(`[reactpress] Building plugin "${name}"…`);
+ }
+ const result = spawnSync('pnpm', ['run', 'build'], {
+ cwd: pluginDir,
+ stdio: quiet ? 'pipe' : 'inherit',
+ env: process.env,
+ });
+ if (result.status !== 0) {
+ const stderr = result.stderr?.toString?.() ?? '';
+ throw new Error(`Plugin "${name}" build failed${stderr ? `: ${stderr.trim()}` : ''}`);
+ }
+}
+
+function buildLocalPlugins(projectRoot, options = {}) {
+ const ids = readLocalPluginIds(projectRoot);
+ for (const id of ids) {
+ const pluginDir = path.join(projectRoot, 'plugins', id);
+ if (!fs.existsSync(pluginDir)) continue;
+ if (!shouldBuildPlugin(pluginDir)) continue;
+ buildPlugin(pluginDir, options);
+ }
+}
+
+module.exports = {
+ readLocalPluginIds,
+ shouldBuildPlugin,
+ buildPlugin,
+ buildLocalPlugins,
+};
diff --git a/cli/src/lib/plugin-cli.ts b/cli/src/lib/plugin-cli.ts
new file mode 100644
index 00000000..9ed8d4d2
--- /dev/null
+++ b/cli/src/lib/plugin-cli.ts
@@ -0,0 +1,144 @@
+// @ts-nocheck
+const chalk = require('chalk');
+const fs = require('fs');
+const path = require('path');
+
+const PLUGIN_RUNTIME_REL = path.join('.reactpress', 'plugins');
+const PLUGIN_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const COPY_SKIP_NAMES = new Set([
+ 'node_modules',
+ '.git',
+ 'dist',
+ '.turbo',
+ 'coverage',
+ '.reactpress',
+ '.cache',
+ 'package-lock.json',
+]);
+
+function isValidPluginId(id) {
+ return typeof id === 'string' && PLUGIN_ID_RE.test(id) && id.length <= 64;
+}
+
+function readPluginsPackageMeta(projectRoot) {
+ const pkgPath = path.join(projectRoot, 'plugins', 'package.json');
+ if (!fs.existsSync(pkgPath)) return { local: [] };
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ const local = Array.isArray(pkg.reactpress?.local)
+ ? pkg.reactpress.local.filter((id) => typeof id === 'string')
+ : [];
+ return { local };
+ } catch {
+ return { local: [] };
+ }
+}
+
+function readPluginManifest(pluginDir) {
+ const manifestPath = path.join(pluginDir, 'plugin.json');
+ if (!fs.existsSync(manifestPath)) return null;
+ try {
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function copyDir(src, dest) {
+ fs.mkdirSync(dest, { recursive: true });
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+ if (COPY_SKIP_NAMES.has(entry.name)) continue;
+ const from = path.join(src, entry.name);
+ const to = path.join(dest, entry.name);
+ if (entry.isSymbolicLink()) {
+ continue;
+ } else if (entry.isDirectory()) {
+ copyDir(from, to);
+ } else if (entry.isFile()) {
+ fs.copyFileSync(from, to);
+ }
+ }
+}
+
+function removeDir(dir) {
+ if (!fs.existsSync(dir)) return;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) removeDir(full);
+ else fs.unlinkSync(full);
+ }
+ fs.rmdirSync(dir);
+}
+
+function materializeRuntimePlugin(projectRoot, templatePath, targetDir) {
+ const forceCopy =
+ process.env.REACTPRESS_PLUGIN_RUNTIME_COPY === '1' || process.env.NODE_ENV === 'production';
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
+ if (fs.existsSync(targetDir)) removeDir(targetDir);
+ if (!forceCopy) {
+ const linkTarget = path.relative(path.dirname(targetDir), templatePath);
+ fs.symlinkSync(linkTarget, targetDir, 'dir');
+ return;
+ }
+ copyDir(templatePath, targetDir);
+}
+
+function installLocalPlugin(projectRoot, id) {
+ if (!isValidPluginId(id)) {
+ throw new Error(`Invalid plugin id "${id}"`);
+ }
+ const templatePath = path.join(projectRoot, 'plugins', id);
+ if (!fs.existsSync(templatePath)) {
+ throw new Error(`Plugin template "${id}" not found under plugins/`);
+ }
+ const manifest = readPluginManifest(templatePath);
+ if (!manifest?.id) {
+ throw new Error(`Plugin "${id}" has invalid plugin.json`);
+ }
+ const targetDir = path.join(projectRoot, PLUGIN_RUNTIME_REL, id);
+ materializeRuntimePlugin(projectRoot, templatePath, targetDir);
+ return { pluginId: manifest.id, name: manifest.name, pluginDirRel: PLUGIN_RUNTIME_REL };
+}
+
+function listAvailablePluginIds(projectRoot) {
+ const { local } = readPluginsPackageMeta(projectRoot);
+ const runtimeDir = path.join(projectRoot, PLUGIN_RUNTIME_REL);
+ const installed = fs.existsSync(runtimeDir)
+ ? fs
+ .readdirSync(runtimeDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ : [];
+ return [...new Set([...local, ...installed])];
+}
+
+function runPluginInstall(projectRoot, id) {
+ const result = installLocalPlugin(projectRoot, id);
+ console.log(
+ chalk.green('[reactpress]'),
+ `Installed plugin "${result.name}" (${result.pluginId}) → ${result.pluginDirRel}/${result.pluginId}/`,
+ );
+ console.log(chalk.gray(`Activate via admin /plugins or: reactpress plugin activate ${result.pluginId}`));
+ return result;
+}
+
+function runPluginList(projectRoot) {
+ const ids = listAvailablePluginIds(projectRoot);
+ if (!ids.length) {
+ console.log('No plugins registered.');
+ return;
+ }
+ console.log('Available plugins:');
+ for (const id of ids.sort()) {
+ const runtime = path.join(projectRoot, PLUGIN_RUNTIME_REL, id);
+ const installed = fs.existsSync(runtime);
+ console.log(` - ${id}${installed ? ' (installed)' : ''}`);
+ }
+}
+
+module.exports = {
+ installLocalPlugin,
+ listAvailablePluginIds,
+ runPluginInstall,
+ runPluginList,
+};
diff --git a/cli/src/lib/pm2.ts b/cli/src/lib/pm2.ts
new file mode 100644
index 00000000..01e8c7d9
--- /dev/null
+++ b/cli/src/lib/pm2.ts
@@ -0,0 +1,33 @@
+// @ts-nocheck
+const { spawn } = require('child_process');
+const { getServerBin, getServerDir } = require('./paths');
+const { ensureOriginalCwd } = require('./root');
+const { t } = require('./i18n');
+
+function startApiWithPm2(projectRoot = ensureOriginalCwd()) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [getServerBin(projectRoot), '--pm2'], {
+ stdio: 'inherit',
+ cwd: getServerDir(projectRoot),
+ env: {
+ ...process.env,
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ },
+ });
+
+ child.on('error', (error) => {
+ console.error(t('pm2.startFailed'), error);
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ if (code !== 0) {
+ reject(Object.assign(new Error(t('pm2.exitCode', { code })), { exitCode: code }));
+ return;
+ }
+ resolve();
+ });
+ });
+}
+
+module.exports = { startApiWithPm2 };
diff --git a/cli/src/lib/ports.ts b/cli/src/lib/ports.ts
new file mode 100644
index 00000000..c75f08fe
--- /dev/null
+++ b/cli/src/lib/ports.ts
@@ -0,0 +1,430 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+/**
+ * Local dev port map (keep in sync with `.env`, README, and `theme.service.ts` defaults).
+ *
+ * | Port | Service |
+ * |------|---------|
+ * | 3000 | Admin SPA — Vite (`web/`, `WEB_ADMIN_URL`) |
+ * | 3001 | Visitor site — active theme Next.js (`CLIENT_SITE_URL`) |
+ * | 3002 | API server (`SERVER_PORT`) |
+ * | 3003 | Admin theme preview only (`REACTPRESS_PREVIEW_PORT`, `preview-theme.json`) |
+ * | 3306 | MySQL (`DB_PORT`) |
+ */
+const DEV_PORTS = {
+ ADMIN_WEB: 3000,
+ VISITOR: 3001,
+ API: 3002,
+ THEME_PREVIEW: 3003,
+ MYSQL: 3306,
+};
+
+/** Ports theme `next dev` must not bind to (reserved for other services). 3003 is allowed — preview theme. */
+/** Never kill listeners on DB / infra ports during dev port cleanup. */
+const PROTECTED_KILL_PORTS = new Set([3306, 3307, 5432, 6379]);
+
+const BLOCKED_THEME_DEV_PORTS = new Set([
+ 22,
+ 80,
+ 443,
+ 3000,
+ 3002,
+ 5173,
+ 5432,
+ 6379,
+ 8080,
+ 8443,
+ 3306,
+ 3307,
+]);
+
+function readEnvPort(projectRoot, key, fallback) {
+ try {
+ const content = fs.readFileSync(path.join(projectRoot, '.env'), 'utf8');
+ const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));
+ if (match) {
+ const n = parseInt(match[1].trim().replace(/^['"]|['"]$/g, ''), 10);
+ if (Number.isInteger(n) && n > 0) return n;
+ }
+ } catch {
+ // ignore
+ }
+ return fallback;
+}
+
+function readVisitorPort(projectRoot) {
+ const fromEnv = readEnvPort(projectRoot, 'CLIENT_PORT', null);
+ if (fromEnv) return fromEnv;
+ try {
+ const content = fs.readFileSync(path.join(projectRoot, '.env'), 'utf8');
+ const match = content.match(/^CLIENT_SITE_URL=(.+)$/m);
+ if (match) {
+ const url = new URL(match[1].trim().replace(/^['"]|['"]$/g, ''));
+ const n = parseInt(url.port || String(DEV_PORTS.VISITOR), 10);
+ if (Number.isInteger(n) && n > 0) return n;
+ }
+ } catch {
+ // ignore
+ }
+ return DEV_PORTS.VISITOR;
+}
+
+function isPortListening(port) {
+ const n = parseInt(port, 10);
+ if (!Number.isInteger(n) || n < 1) return false;
+ const result = spawnSync('lsof', [`-tiTCP:${n}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
+ return result.status === 0 && Boolean(result.stdout?.trim());
+}
+
+/** Kill processes listening on `port`. Returns PIDs signalled. */
+function killPortListeners(port, signal = 'KILL') {
+ const n = parseInt(port, 10);
+ if (!Number.isInteger(n) || n < 1) return [];
+
+ const flag = signal === 'TERM' ? '-TERM' : '-9';
+ const result = spawnSync('lsof', [`-tiTCP:${n}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
+ if (result.status !== 0 || !result.stdout?.trim()) return [];
+
+ const pids = [];
+ for (const pid of result.stdout.trim().split(/\s+/)) {
+ if (!pid) continue;
+ spawnSync('kill', [flag, pid], { stdio: 'ignore' });
+ pids.push(pid);
+ }
+ return pids;
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getListenerPids(port) {
+ const n = parseInt(port, 10);
+ if (!Number.isInteger(n) || n < 1) return [];
+ const result = spawnSync('lsof', [`-tiTCP:${n}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
+ if (result.status !== 0 || !result.stdout?.trim()) return [];
+ return result.stdout.trim().split(/\s+/).filter(Boolean);
+}
+
+function collectDescendantPids(rootPid) {
+ const root = parseInt(rootPid, 10);
+ if (!Number.isFinite(root) || root <= 0) return [];
+
+ const out = [];
+ const queue = [String(root)];
+ const seen = new Set();
+
+ while (queue.length) {
+ const pid = queue.shift();
+ if (!pid || seen.has(pid)) continue;
+ seen.add(pid);
+
+ const children = spawnSync('pgrep', ['-P', pid], { encoding: 'utf8' });
+ if (children.status !== 0 || !children.stdout?.trim()) continue;
+
+ for (const child of children.stdout.trim().split(/\s+/)) {
+ if (!child || seen.has(child)) continue;
+ out.push(child);
+ queue.push(child);
+ }
+ }
+ return out;
+}
+
+function collectAncestorPids(pid, maxDepth = 10) {
+ const out = [];
+ let current = parseInt(pid, 10);
+ for (let i = 0; i < maxDepth; i += 1) {
+ if (!Number.isFinite(current) || current <= 1) break;
+ const ppidRes = spawnSync('ps', ['-o', 'ppid=', '-p', String(current)], { encoding: 'utf8' });
+ const parent = parseInt(ppidRes.stdout?.trim(), 10);
+ if (!Number.isFinite(parent) || parent <= 1) break;
+ out.push(String(parent));
+ current = parent;
+ }
+ return out;
+}
+
+function getProcessCommand(pid) {
+ const res = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' });
+ return (res.stdout || '').trim();
+}
+
+function isDockerInfrastructureProcess(pid) {
+ const cmd = getProcessCommand(pid).toLowerCase();
+ if (!cmd) return false;
+ return (
+ cmd.includes('com.docker') ||
+ cmd.includes('docker desktop') ||
+ cmd.includes('dockerd') ||
+ cmd.includes('vpnkit') ||
+ cmd.includes('containerd') ||
+ (cmd.includes('docker') && cmd.includes('proxy'))
+ );
+}
+
+/** Nest / pnpm API dev parent — killing only the listener leaves watch respawning children. */
+function isReactPressApiProcess(pid, projectRoot) {
+ if (isDockerInfrastructureProcess(pid)) return false;
+ const cmd = getProcessCommand(pid);
+ if (!cmd) return false;
+ if (
+ /nest start|api-dev-runner|server\/dist\/starter|@nestjs\/cli|reactpress\.js/.test(cmd)
+ ) {
+ return true;
+ }
+ const serverDir = path.join(path.resolve(projectRoot), 'server');
+ const res = spawnSync('lsof', ['-p', String(pid)], { encoding: 'utf8' });
+ if (res.status !== 0) return false;
+ return res.stdout.split('\n').some((line) => {
+ if (!line.includes(' cwd ')) return false;
+ const parts = line.trim().split(/\s+/);
+ const cwd = parts[parts.length - 1];
+ return cwd === serverDir || cwd.startsWith(`${serverDir}${path.sep}`);
+ });
+}
+
+function signalPidSet(pids, signal) {
+ const flag = signal === 'TERM' ? '-TERM' : '-9';
+ for (const pid of pids) {
+ if (!pid || pid === String(process.pid)) continue;
+ spawnSync('kill', [flag, pid], { stdio: 'ignore' });
+ }
+}
+
+function collectApiPortProcessTree(projectRoot, port) {
+ const toSignal = new Set();
+ for (const pid of getListenerPids(port)) {
+ if (isDockerInfrastructureProcess(pid)) continue;
+ toSignal.add(pid);
+ for (const child of collectDescendantPids(pid)) {
+ if (!isDockerInfrastructureProcess(child)) toSignal.add(child);
+ }
+ for (const ancestor of collectAncestorPids(pid)) {
+ if (isReactPressApiProcess(ancestor, projectRoot)) toSignal.add(ancestor);
+ }
+ }
+ return toSignal;
+}
+
+/** Stop Nest / api-dev listeners on `port` (TERM then KILL). */
+async function stopApiPortListeners(projectRoot, port, { label = 'API' } = {}) {
+ const n = parseInt(port, 10);
+ if (!Number.isInteger(n) || n < 1 || !isPortListening(n)) return true;
+
+ console.warn(
+ `[reactpress] Port ${n} (${label}) is busy — stopping existing API processes…`,
+ );
+
+ const toSignal = collectApiPortProcessTree(projectRoot, n);
+ signalPidSet(toSignal, 'TERM');
+ await sleep(600);
+
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ if (!isPortListening(n)) return true;
+ for (const pid of getListenerPids(n)) {
+ toSignal.add(pid);
+ for (const child of collectDescendantPids(pid)) toSignal.add(child);
+ }
+ signalPidSet(toSignal, 'KILL');
+ await sleep(500);
+ }
+
+ if (isPortListening(n)) {
+ console.warn(
+ `[reactpress] Port ${n} (${label}) still in use — try: lsof -tiTCP:${n} -sTCP:LISTEN | xargs kill -9`,
+ );
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Free API port: skip when health check passes; otherwise stop listener + nest watch tree.
+ * @returns {{ reused: boolean, port: number }}
+ */
+async function ensureApiPortFree(projectRoot, { allowReuse = true } = {}) {
+ const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API);
+ const { getHealthUrl, checkHealth } = require('./http');
+ if (allowReuse) {
+ const health = await checkHealth(getHealthUrl(projectRoot), 1500);
+ if (health.ok) {
+ return { reused: true, port };
+ }
+ }
+
+ if (!isPortListening(port)) {
+ return { reused: false, port };
+ }
+
+ await stopApiPortListeners(projectRoot, port);
+ return { reused: false, port };
+}
+
+/** Always stop API listeners — used when replacing a prior `reactpress dev` session. */
+async function forceReleaseApiPort(projectRoot) {
+ const port = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API);
+ if (!isPortListening(port)) return port;
+ console.warn(`[reactpress] Releasing API port ${port} for new dev session…`);
+ await stopApiPortListeners(projectRoot, port);
+ return port;
+}
+
+/** Stop orphaned dev-stack listeners after session takeover or crash. */
+async function forceReleaseDevStackPorts(projectRoot) {
+ await forceReleaseApiPort(projectRoot);
+
+ const previewPort = readEnvPort(
+ projectRoot,
+ 'REACTPRESS_PREVIEW_PORT',
+ DEV_PORTS.THEME_PREVIEW,
+ );
+ const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB);
+ const visitorPort = readVisitorPort(projectRoot);
+
+ for (const [port, label] of [
+ [adminPort, 'admin'],
+ [visitorPort, 'visitor site'],
+ [previewPort, 'theme preview'],
+ ]) {
+ await ensurePortFree(port, { label });
+ }
+}
+
+/**
+ * Free only unhealthy or non-API listeners — keeps a healthy API to skip Nest cold compile.
+ */
+async function releaseStaleDevStackPorts(projectRoot) {
+ if (process.env.REACTPRESS_FORCE_PORT_RESET === '1') {
+ await forceReleaseDevStackPorts(projectRoot);
+ return;
+ }
+
+ const { getHealthUrl, checkHealth } = require('./http');
+
+ // Embedded desktop SQLite API (:13102) is managed by local-server — not SERVER_PORT (:3002).
+ if (process.env.REACTPRESS_DESKTOP_LOCAL !== '1') {
+ const apiPort = readEnvPort(projectRoot, 'SERVER_PORT', DEV_PORTS.API);
+
+ if (isPortListening(apiPort)) {
+ const health = await checkHealth(getHealthUrl(projectRoot), 2000);
+ if (health.ok) {
+ const { logDevDetail } = require('./dev-log');
+ logDevDetail('dev.apiKept', { port: apiPort });
+ } else {
+ await forceReleaseApiPort(projectRoot);
+ }
+ }
+ }
+
+ const previewPort = readEnvPort(
+ projectRoot,
+ 'REACTPRESS_PREVIEW_PORT',
+ DEV_PORTS.THEME_PREVIEW,
+ );
+ const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB);
+ const visitorPort = readVisitorPort(projectRoot);
+
+ for (const [port, label] of [
+ [adminPort, 'admin'],
+ [visitorPort, 'visitor site'],
+ [previewPort, 'theme preview'],
+ ]) {
+ if (isPortListening(port)) {
+ await ensurePortFree(port, { label, maxWaitMs: 5000 });
+ }
+ }
+}
+
+/**
+ * If `port` is in use, terminate listeners (TERM then KILL) and wait until free.
+ */
+async function ensurePortFree(port, { label = 'service', maxWaitMs = 8000 } = {}) {
+ const n = parseInt(port, 10);
+ if (!Number.isInteger(n) || n < 1) return false;
+
+ if (PROTECTED_KILL_PORTS.has(n)) {
+ if (isPortListening(n)) {
+ console.warn(`[reactpress] Port ${n} (${label}) is protected — leaving existing listener`);
+ }
+ return true;
+ }
+
+ if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') {
+ const desktopApi = process.env.REACTPRESS_DESKTOP_LOCAL_API?.trim();
+ if (desktopApi) {
+ try {
+ const desktopPort = parseInt(new URL(desktopApi).port || '13102', 10);
+ if (Number.isInteger(desktopPort) && desktopPort === n) {
+ if (isPortListening(n)) {
+ console.warn(
+ `[reactpress] Port ${n} (${label}) is the embedded local API — leaving listener`,
+ );
+ }
+ return true;
+ }
+ } catch {
+ // ignore malformed REACTPRESS_DESKTOP_LOCAL_API
+ }
+ }
+ }
+
+ if (!isPortListening(n)) return true;
+
+ console.warn(`[reactpress] Port ${n} (${label}) is busy — stopping existing listener…`);
+ killPortListeners(n, 'TERM');
+ await sleep(500);
+
+ const deadline = Date.now() + maxWaitMs;
+ while (Date.now() < deadline) {
+ if (!isPortListening(n)) return true;
+ killPortListeners(n, 'KILL');
+ await sleep(500);
+ }
+
+ if (isPortListening(n)) {
+ console.warn(
+ `[reactpress] Port ${n} (${label}) still in use — try: lsof -tiTCP:${n} -sTCP:LISTEN | xargs kill -9`,
+ );
+ return false;
+ }
+ return true;
+}
+
+/** Free theme/admin ports (API handled in {@link forceReleaseDevStackPorts} / {@link spawnApi}). */
+async function ensureDevStackPorts(projectRoot) {
+ const previewPort = readEnvPort(
+ projectRoot,
+ 'REACTPRESS_PREVIEW_PORT',
+ DEV_PORTS.THEME_PREVIEW,
+ );
+ const adminPort = readEnvPort(projectRoot, 'WEB_ADMIN_PORT', DEV_PORTS.ADMIN_WEB);
+ const visitorPort = readVisitorPort(projectRoot);
+
+ for (const [port, label] of [
+ [adminPort, 'admin'],
+ [visitorPort, 'visitor site'],
+ [previewPort, 'theme preview'],
+ ]) {
+ await ensurePortFree(port, { label });
+ }
+}
+
+module.exports = {
+ DEV_PORTS,
+ BLOCKED_THEME_DEV_PORTS,
+ readEnvPort,
+ readVisitorPort,
+ isPortListening,
+ killPortListeners,
+ ensurePortFree,
+ ensureApiPortFree,
+ forceReleaseApiPort,
+ forceReleaseDevStackPorts,
+ releaseStaleDevStackPorts,
+ ensureDevStackPorts,
+};
diff --git a/cli/src/lib/process.ts b/cli/src/lib/process.ts
new file mode 100644
index 00000000..95fd7731
--- /dev/null
+++ b/cli/src/lib/process.ts
@@ -0,0 +1,46 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { getPidFile } = require('./paths');
+
+function readPid(projectRoot) {
+ const pidFile = getPidFile(projectRoot);
+ try {
+ const raw = fs.readFileSync(pidFile, 'utf8').trim();
+ const pid = Number.parseInt(raw, 10);
+ return Number.isFinite(pid) ? pid : null;
+ } catch {
+ return null;
+ }
+}
+
+function isProcessRunning(pid) {
+ if (!pid) return false;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function clearPidFile(projectRoot) {
+ const pidFile = getPidFile(projectRoot);
+ if (fs.existsSync(pidFile)) {
+ fs.unlinkSync(pidFile);
+ }
+}
+
+function writePid(projectRoot, pid) {
+ const pidFile = getPidFile(projectRoot);
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
+ fs.writeFileSync(pidFile, String(pid));
+}
+
+module.exports = {
+ readPid,
+ isProcessRunning,
+ clearPidFile,
+ writePid,
+ getPidFile,
+};
diff --git a/cli/src/lib/prod-memory.ts b/cli/src/lib/prod-memory.ts
new file mode 100644
index 00000000..3c50572d
--- /dev/null
+++ b/cli/src/lib/prod-memory.ts
@@ -0,0 +1,55 @@
+// @ts-nocheck
+/**
+ * Optional production memory tuning — only active when REACTPRESS_LOW_MEM=1.
+ * Default deploy does not set this; all limits stay at normal Node/PM2 defaults.
+ */
+
+function isLowMemMode() {
+ return process.env.REACTPRESS_LOW_MEM === '1';
+}
+
+/** Apply heap cap only when low-mem or explicitly configured. */
+function resolveBuildMaxOldSpaceMb() {
+ const fromEnv = parseInt(process.env.REACTPRESS_BUILD_MAX_OLD_SPACE_MB || '', 10);
+ if (Number.isInteger(fromEnv) && fromEnv >= 256) return fromEnv;
+ if (isLowMemMode()) return 768;
+ return null;
+}
+
+function resolveBuildNodeEnv(baseEnv = process.env) {
+ const mb = resolveBuildMaxOldSpaceMb();
+ if (!mb) return { ...baseEnv };
+ const flag = `--max-old-space-size=${mb}`;
+ const existing = baseEnv.NODE_OPTIONS || '';
+ if (existing.includes('max-old-space-size')) {
+ return { ...baseEnv };
+ }
+ return {
+ ...baseEnv,
+ NODE_OPTIONS: existing ? `${existing} ${flag}` : flag,
+ };
+}
+
+function getPm2ServerMemoryRestart() {
+ if (process.env.REACTPRESS_PM2_SERVER_MEMORY) {
+ return process.env.REACTPRESS_PM2_SERVER_MEMORY;
+ }
+ if (isLowMemMode()) return '384M';
+ return '1G';
+}
+
+function getPm2ClientMemoryRestart() {
+ if (process.env.REACTPRESS_PM2_CLIENT_MEMORY) {
+ return process.env.REACTPRESS_PM2_CLIENT_MEMORY;
+ }
+ if (isLowMemMode()) return '512M';
+ return '1G';
+}
+
+module.exports = {
+ isLowMemMode,
+ resolveBuildMaxOldSpaceMb,
+ resolveBuildNodeEnv,
+ getPm2ServerMemoryRestart,
+ getPm2ClientMemoryRestart,
+};
diff --git a/cli/src/lib/project-type.ts b/cli/src/lib/project-type.ts
new file mode 100644
index 00000000..6d497c22
--- /dev/null
+++ b/cli/src/lib/project-type.ts
@@ -0,0 +1,103 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Decide whether a given directory is a ReactPress monorepo checkout (with
+ * editable `server/src`, `web/`, `client/`, `toolkit/`) or a standalone project that
+ * was created with `reactpress init` and relies on the bundled runtime.
+ *
+ * @param {string} root absolute project root
+ * @returns {'monorepo' | 'standalone' | 'unknown'}
+ */
+function detectProjectType(root) {
+ if (!root) return 'unknown';
+ const abs = path.resolve(root);
+
+ const monorepoMarkers = [
+ path.join(abs, 'pnpm-workspace.yaml'),
+ path.join(abs, 'server', 'src', 'main.ts'),
+ ];
+ if (monorepoMarkers.some((p) => fs.existsSync(p))) {
+ return 'monorepo';
+ }
+
+ if (fs.existsSync(path.join(abs, '.reactpress', 'config.json'))) {
+ return 'standalone';
+ }
+
+ return 'unknown';
+}
+
+/**
+ * @param {string} root
+ */
+function hasClient(root) {
+ return fs.existsSync(path.join(root, 'client', 'package.json'));
+}
+
+/**
+ * Admin SPA (`web/`), preferred over client `/admin` in monorepo dev.
+ * @param {string} root
+ */
+function hasWeb(root) {
+ return fs.existsSync(path.join(root, 'web', 'package.json'));
+}
+
+/**
+ * @param {string} root
+ */
+function hasServerSource(root) {
+ return fs.existsSync(path.join(root, 'server', 'src', 'main.ts'));
+}
+
+/**
+ * @param {string} root
+ */
+function hasToolkit(root) {
+ return fs.existsSync(path.join(root, 'toolkit', 'package.json'));
+}
+
+/**
+ * Electron desktop client (`desktop/`).
+ * @param {string} root
+ */
+function hasDesktop(root) {
+ return fs.existsSync(path.join(root, 'desktop', 'package.json'));
+}
+
+/**
+ * Official plugins workspace (`plugins/`).
+ * @param {string} root
+ */
+function hasPluginsWorkspace(root) {
+ return fs.existsSync(path.join(root, 'plugins', 'package.json'));
+}
+
+/**
+ * @param {string} root
+ */
+function describeProject(root) {
+ const type = detectProjectType(root);
+ return {
+ type,
+ root,
+ hasClient: hasClient(root),
+ hasWeb: hasWeb(root),
+ hasServerSource: hasServerSource(root),
+ hasToolkit: hasToolkit(root),
+ hasDesktop: hasDesktop(root),
+ hasPluginsWorkspace: hasPluginsWorkspace(root),
+ };
+}
+
+module.exports = {
+ detectProjectType,
+ describeProject,
+ hasClient,
+ hasWeb,
+ hasServerSource,
+ hasToolkit,
+ hasDesktop,
+ hasPluginsWorkspace,
+};
diff --git a/cli/src/lib/publish.ts b/cli/src/lib/publish.ts
new file mode 100644
index 00000000..9dfa58d8
--- /dev/null
+++ b/cli/src/lib/publish.ts
@@ -0,0 +1,424 @@
+#!/usr/bin/env node
+// @ts-nocheck
+
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const chalk = require('chalk');
+const inquirer = require('inquirer');
+const { t } = require('./i18n');
+const { getMonorepoRoot } = require('./root');
+
+const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/;
+const NPM_REGISTRY = 'https://registry.npmjs.org';
+
+/** Publish order: dependencies first, CLI last. */
+const CORE_PUBLISH_PACKAGES = [
+ {
+ name: '@fecommunity/reactpress-toolkit',
+ path: 'toolkit',
+ description: 'API client and utilities toolkit',
+ },
+ {
+ name: '@fecommunity/reactpress-web',
+ path: 'web',
+ description: 'Admin SPA static assets and Node static server helpers',
+ },
+ {
+ name: '@fecommunity/reactpress-server',
+ path: 'server',
+ description: t('publish.pkg.server'),
+ deprecated: true,
+ },
+ {
+ name: '@fecommunity/reactpress',
+ path: 'cli',
+ description: t('publish.pkg.main'),
+ },
+];
+
+function getWorkspaceRoot() {
+ const root = getMonorepoRoot();
+ if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) {
+ return root;
+ }
+ return process.cwd();
+}
+
+function getCurrentVersion(packagePath) {
+ const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json');
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ return pkg.version;
+}
+
+function getCanonicalVersion() {
+ return getCurrentVersion('cli');
+}
+
+function incrementVersion(version, type) {
+ const base = String(version).split('-')[0];
+ const parts = base.split('.').map((p) => parseInt(p, 10));
+ while (parts.length < 3) parts.push(0);
+ const major = Number.isFinite(parts[0]) ? parts[0] : 0;
+ const minor = Number.isFinite(parts[1]) ? parts[1] : 0;
+ const patch = Number.isFinite(parts[2]) ? parts[2] : 0;
+
+ switch (type) {
+ case 'major':
+ return `${major + 1}.0.0`;
+ case 'minor':
+ return `${major}.${minor + 1}.0`;
+ case 'patch':
+ return `${major}.${minor}.${patch + 1}`;
+ case 'beta': {
+ const match = String(version).match(/^(.*)-beta\.(\d+)$/);
+ if (match) return `${match[1]}-beta.${parseInt(match[2], 10) + 1}`;
+ return `${base}-beta.0`;
+ }
+ default:
+ return version;
+ }
+}
+
+function resolveNpmTag(version, explicitTag) {
+ if (explicitTag) return explicitTag;
+ return String(version).includes('-') ? 'beta' : 'latest';
+}
+
+function parseCliPublishOptions(argv = process.argv.slice(2)) {
+ const opts = {
+ publish: argv.includes('--publish'),
+ build: argv.includes('--build'),
+ noBuild: argv.includes('--no-build'),
+ yes: argv.includes('--yes'),
+ tag: undefined,
+ version: undefined,
+ otp: process.env.NPM_OTP || undefined,
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--tag' && argv[i + 1]) opts.tag = argv[++i];
+ else if (arg.startsWith('--tag=')) opts.tag = arg.slice('--tag='.length);
+ else if (arg === '--version' && argv[i + 1]) opts.version = argv[++i];
+ else if (arg.startsWith('--version=')) opts.version = arg.slice('--version='.length);
+ else if (arg === '--otp' && argv[i + 1]) opts.otp = argv[++i];
+ else if (arg.startsWith('--otp=')) opts.otp = arg.slice('--otp='.length);
+ }
+
+ return opts;
+}
+
+function printPackageVersions() {
+ console.log(chalk.cyan('📋 Package versions:'));
+ for (const pkg of CORE_PUBLISH_PACKAGES) {
+ console.log(chalk.gray(` ${pkg.name}: ${getCurrentVersion(pkg.path)}`));
+ }
+ console.log(chalk.gray(` reactpress (root): ${getCurrentVersion('.')}`));
+ console.log();
+}
+
+function checkEnvironment() {
+ try {
+ execSync('pnpm --version', { stdio: 'ignore' });
+ } catch {
+ console.log(chalk.red('❌ pnpm is not installed.'));
+ return false;
+ }
+
+ try {
+ execSync(`pnpm whoami --registry ${NPM_REGISTRY}`, { stdio: 'ignore' });
+ } catch {
+ console.log(
+ chalk.red(`❌ Not logged in to npm. Run: pnpm login --registry ${NPM_REGISTRY}`),
+ );
+ return false;
+ }
+
+ return true;
+}
+
+function updateVersion(packagePath, newVersion) {
+ const root = getWorkspaceRoot();
+ const pkgPath = path.join(root, packagePath, 'package.json');
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ const oldVersion = pkg.version;
+ pkg.version = newVersion;
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
+ console.log(chalk.green(` ✓ ${packagePath}: ${oldVersion} → ${newVersion}`));
+}
+
+function syncMonorepoVersions(targetVersion) {
+ console.log(chalk.blue(`\n✏️ Syncing version → ${targetVersion}`));
+ updateVersion('.', targetVersion);
+ for (const pkg of CORE_PUBLISH_PACKAGES) {
+ updateVersion(pkg.path, targetVersion);
+ }
+ const desktopPkg = path.join(getWorkspaceRoot(), 'desktop/package.json');
+ if (fs.existsSync(desktopPkg)) {
+ updateVersion('desktop', targetVersion);
+ }
+}
+
+function fixWorkspaceDependenciesForPublish(packagePath, packageVersions) {
+ const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json');
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+
+ for (const depType of ['dependencies', 'devDependencies', 'peerDependencies']) {
+ if (!pkg[depType]) continue;
+ for (const [depName, depValue] of Object.entries(pkg[depType])) {
+ if (!String(depValue).startsWith('workspace:')) continue;
+ const depPackage = CORE_PUBLISH_PACKAGES.find((p) => p.name === depName);
+ if (depPackage && packageVersions[depName]) {
+ pkg[depType][depName] = packageVersions[depName];
+ }
+ }
+ }
+
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
+}
+
+function restoreWorkspaceDependenciesAfterPublish(packagePath, publishedVersion) {
+ const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json');
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+
+ for (const depType of ['dependencies', 'devDependencies', 'peerDependencies']) {
+ if (!pkg[depType]) continue;
+ for (const [depName, depValue] of Object.entries(pkg[depType])) {
+ const depPackage = CORE_PUBLISH_PACKAGES.find((p) => p.name === depName);
+ if (depPackage && depValue === publishedVersion) {
+ pkg[depType][depName] = 'workspace:*';
+ }
+ }
+ }
+
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
+}
+
+function run(cmd, cwd) {
+ execSync(cmd, { cwd, stdio: 'inherit' });
+}
+
+function buildAllForPublish() {
+ const root = getWorkspaceRoot();
+ console.log(chalk.blue('\n🔨 Building publish artifacts...\n'));
+ run('pnpm run build', path.join(root, 'toolkit'));
+ run('pnpm run build', path.join(root, 'server'));
+ run('pnpm run build', path.join(root, 'web'));
+ run('node scripts/sync-bundled-core.mjs', path.join(root, 'cli'));
+ run('node scripts/sync-monorepo-server.mjs', path.join(root, 'cli'));
+ run('pnpm run build', path.join(root, 'cli'));
+ console.log(chalk.green('\n✅ Build complete\n'));
+}
+
+function publishPackage(packagePath, packageName, tag, otp) {
+ const otpFlag = otp ? ` --otp ${otp}` : '';
+ const cmd = `pnpm publish --access public --tag ${tag} --registry ${NPM_REGISTRY} --no-git-checks${otpFlag}`;
+ console.log(chalk.blue(`\n🚀 ${packageName}@${getCurrentVersion(packagePath)} (${tag})`));
+ run(cmd, path.join(getWorkspaceRoot(), packagePath));
+ console.log(chalk.green(`✅ ${packageName} published`));
+}
+
+async function promptPublishPlan(defaults = {}) {
+ const current = getCanonicalVersion();
+ const { channel } = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'channel',
+ message: 'Release channel:',
+ choices: [
+ { name: `Beta prerelease (npm tag: beta)`, value: 'beta' },
+ { name: `Stable release (npm tag: latest)`, value: 'latest' },
+ ],
+ default: defaults.tag === 'latest' ? 1 : 0,
+ },
+ ]);
+
+ const { versionMode } = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'versionMode',
+ message: `Version (current: ${current}):`,
+ choices: [
+ { name: `Keep ${current}`, value: 'keep' },
+ { name: `Bump beta (${incrementVersion(current, 'beta')})`, value: 'beta' },
+ { name: `Bump patch (${incrementVersion(current, 'patch')})`, value: 'patch' },
+ { name: 'Enter custom version', value: 'custom' },
+ ],
+ },
+ ]);
+
+ let version = current;
+ if (versionMode === 'beta' || versionMode === 'patch') {
+ version = incrementVersion(current, versionMode);
+ } else if (versionMode === 'custom') {
+ const { customVersion } = await inquirer.prompt([
+ {
+ type: 'input',
+ name: 'customVersion',
+ message: 'Semver version for all core packages:',
+ default: current,
+ validate: (input) => SEMVER_RE.test(input) || 'Use semver, e.g. 4.0.0-beta.0',
+ },
+ ]);
+ version = customVersion;
+ }
+
+ const tag = channel === 'beta' ? 'beta' : resolveNpmTag(version, channel);
+
+ console.log(chalk.cyan(`\nPlan: ${version} → npm tag "${tag}"\n`));
+ printPackageVersions();
+
+ const { confirm } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Publish all core packages?',
+ default: false,
+ },
+ ]);
+
+ if (!confirm) {
+ console.log(chalk.yellow('Cancelled.'));
+ return null;
+ }
+
+ return { version, tag, otp: process.env.NPM_OTP };
+}
+
+async function executePublish(plan, options = {}) {
+ const { version, tag, otp } = plan;
+ const noBuild = options.noBuild === true;
+
+ if (!SEMVER_RE.test(version)) {
+ throw new Error(`Invalid semver: ${version}`);
+ }
+
+ syncMonorepoVersions(version);
+
+ if (!noBuild) {
+ buildAllForPublish();
+ }
+
+ const packageVersions = {};
+ for (const pkg of CORE_PUBLISH_PACKAGES) {
+ packageVersions[pkg.name] = version;
+ }
+
+ for (const pkg of CORE_PUBLISH_PACKAGES) {
+ fixWorkspaceDependenciesForPublish(pkg.path, packageVersions);
+ try {
+ publishPackage(pkg.path, pkg.name, tag, otp);
+ } finally {
+ restoreWorkspaceDependenciesAfterPublish(pkg.path, version);
+ }
+ }
+
+ console.log(chalk.green(`\n🎉 Published ${CORE_PUBLISH_PACKAGES.length} packages @ ${version} (${tag})`));
+ console.log(chalk.cyan('\nVerify:'));
+ console.log(chalk.gray(` npm view @fecommunity/reactpress dist-tags`));
+ if (tag === 'beta') {
+ console.log(chalk.gray(` npm i -g @fecommunity/reactpress@beta`));
+ } else {
+ console.log(chalk.gray(` npm i -g @fecommunity/reactpress@${version}`));
+ }
+ console.log(chalk.cyan('\nNext:'));
+ console.log(chalk.gray(` git tag v${version} && git push && git push --tags`));
+}
+
+async function buildPackages() {
+ console.log(chalk.blue('🏗️ ReactPress publish build\n'));
+ printPackageVersions();
+ buildAllForPublish();
+}
+
+async function publishPackages(cliOptions = {}) {
+ console.log(chalk.blue('📦 ReactPress Package Publisher\n'));
+
+ if (!checkEnvironment()) {
+ process.exit(1);
+ }
+
+ printPackageVersions();
+
+ let plan = null;
+
+ if (cliOptions.version || cliOptions.tag) {
+ const version = cliOptions.version || getCanonicalVersion();
+ const tag = resolveNpmTag(version, cliOptions.tag);
+ plan = { version, tag, otp: cliOptions.otp };
+
+ console.log(chalk.cyan(`Plan: ${version} → npm tag "${tag}"\n`));
+
+ if (!cliOptions.yes) {
+ const { confirm } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Publish all core packages?',
+ default: false,
+ },
+ ]);
+ if (!confirm) {
+ console.log(chalk.yellow('Cancelled.'));
+ return;
+ }
+ }
+ } else if (cliOptions.yes) {
+ const version = getCanonicalVersion();
+ plan = { version, tag: resolveNpmTag(version), otp: cliOptions.otp };
+ } else {
+ plan = await promptPublishPlan(cliOptions);
+ if (!plan) return;
+ }
+
+ await executePublish(plan, cliOptions);
+}
+
+async function main() {
+ const opts = parseCliPublishOptions();
+
+ if (opts.build) {
+ await buildPackages();
+ return;
+ }
+
+ if (opts.publish) {
+ await publishPackages(opts);
+ return;
+ }
+
+ console.log(chalk.blue('📦 ReactPress publish\n'));
+ console.log('Usage:');
+ console.log(' pnpm run publish:build');
+ console.log(' pnpm run publish:packages');
+ console.log('');
+ console.log('Options:');
+ console.log(' --publish Publish (interactive if no --version/--yes)');
+ console.log(' --build Build publish artifacts only');
+ console.log(' --tag beta|latest npm dist-tag (default: auto from version)');
+ console.log(' --version 4.0.0-beta.0 Target semver for all core packages');
+ console.log(' --yes Skip confirmation');
+ console.log(' --no-build Skip build before publish');
+ console.log(' --otp npm 2FA (or NPM_OTP env)');
+ console.log('');
+ console.log('Examples:');
+ console.log(' NPM_OTP=123456 pnpm run publish:packages -- --yes');
+ console.log(' pnpm run publish:packages -- --tag beta --version 4.0.0-beta.0 --yes');
+}
+
+module.exports = {
+ main,
+ buildPackages,
+ publishPackages,
+ incrementVersion,
+ resolveNpmTag,
+ CORE_PUBLISH_PACKAGES,
+};
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(chalk.red('❌ Publish failed:'), error.message || error);
+ process.exit(1);
+ });
+}
diff --git a/cli/src/lib/remote-dev.ts b/cli/src/lib/remote-dev.ts
new file mode 100644
index 00000000..4da468b3
--- /dev/null
+++ b/cli/src/lib/remote-dev.ts
@@ -0,0 +1,174 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+/** Normalize user input (e.g. api.gaoredu.com) to an HTTPS origin without trailing slash. */
+function normalizeRemoteOrigin(input) {
+ const raw = typeof input === 'string' ? input.trim() : '';
+ if (!raw) return null;
+
+ let origin = raw;
+ if (!/^https?:\/\//i.test(origin)) {
+ origin = `https://${origin}`;
+ }
+ return origin.replace(/\/$/, '');
+}
+
+function readEnvValue(projectRoot, key) {
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));
+ if (match) {
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+function readOriginFromEnv(projectRoot, envKey) {
+ const fromShell = normalizeRemoteOrigin(process.env[envKey]);
+ if (fromShell) return fromShell;
+ return normalizeRemoteOrigin(readEnvValue(projectRoot, envKey));
+}
+
+/** Default remote API URL (--remote-origin or REACTPRESS_DEV_REMOTE_ORIGIN). */
+function readDevRemoteDefault(projectRoot) {
+ return readOriginFromEnv(projectRoot, 'REACTPRESS_DEV_REMOTE_ORIGIN');
+}
+
+/** @deprecated use readDevClientApiOrigin */
+function readDevRemoteOrigin(projectRoot) {
+ return readDevClientApiOrigin(projectRoot);
+}
+
+/** Remote admin API origin; null = local Nest. */
+function readDevAdminApiOrigin(projectRoot) {
+ return readOriginFromEnv(projectRoot, 'REACTPRESS_DEV_ADMIN_API_ORIGIN');
+}
+
+/** Remote client/theme API origin (nginx /api); null = local Nest. */
+function readDevClientApiOrigin(projectRoot) {
+ return readOriginFromEnv(projectRoot, 'REACTPRESS_DEV_CLIENT_API_ORIGIN');
+}
+
+/**
+ * Parse one origin flag: local | remote | URL/host.
+ * @returns {{ url: string|null } | { error: string }}
+ */
+function parseOriginSpec(value, remoteDefault) {
+ const trimmed = typeof value === 'string' ? value.trim() : '';
+ if (!trimmed) return { url: null };
+
+ const lower = trimmed.toLowerCase();
+ if (lower === 'local') return { url: null };
+ if (lower === 'remote') {
+ if (!remoteDefault) return { error: 'REMOTE_DEFAULT_REQUIRED' };
+ return { url: remoteDefault };
+ }
+
+ const url = normalizeRemoteOrigin(trimmed);
+ if (!url) return { error: 'INVALID_ORIGIN' };
+ return { url };
+}
+
+/**
+ * Resolve admin/client API targets for this dev session.
+ * @returns {{ admin: string|null, client: string|null, remoteDefault: string|null, needsLocalApi: boolean, error?: string }}
+ */
+function resolveDevApiOrigins(projectRoot, cli = {}) {
+ const remoteDefault =
+ normalizeRemoteOrigin(cli.remoteOrigin) || readDevRemoteDefault(projectRoot);
+
+ const onlyRemoteShorthand =
+ cli.remoteOrigin !== undefined &&
+ cli.adminOrigin === undefined &&
+ cli.clientOrigin === undefined;
+
+ const resolveSide = (cliValue, envKey, useRemoteShorthand) => {
+ if (cliValue !== undefined) {
+ return parseOriginSpec(cliValue, remoteDefault);
+ }
+ const fromEnv = readOriginFromEnv(projectRoot, envKey);
+ if (fromEnv) return { url: fromEnv };
+ if (useRemoteShorthand && remoteDefault) return { url: remoteDefault };
+ return { url: null };
+ };
+
+ const adminParsed = resolveSide(
+ cli.adminOrigin,
+ 'REACTPRESS_DEV_ADMIN_API_ORIGIN',
+ onlyRemoteShorthand,
+ );
+ if (adminParsed.error) return { error: adminParsed.error };
+
+ const clientParsed = resolveSide(
+ cli.clientOrigin,
+ 'REACTPRESS_DEV_CLIENT_API_ORIGIN',
+ onlyRemoteShorthand,
+ );
+ if (clientParsed.error) return { error: clientParsed.error };
+
+ const admin = adminParsed.url;
+ const client = clientParsed.url;
+
+ return {
+ admin,
+ client,
+ remoteDefault,
+ needsLocalApi: !admin || !client,
+ };
+}
+
+function applyDevApiOriginsToEnv(origins) {
+ if (origins.remoteDefault) {
+ process.env.REACTPRESS_DEV_REMOTE_ORIGIN = origins.remoteDefault;
+ } else {
+ delete process.env.REACTPRESS_DEV_REMOTE_ORIGIN;
+ }
+
+ if (origins.admin) {
+ process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN = origins.admin;
+ } else {
+ delete process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN;
+ }
+
+ if (origins.client) {
+ process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN = origins.client;
+ } else {
+ delete process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN;
+ }
+}
+
+/** @deprecated use resolveDevApiOrigins */
+function applyDevRemoteOrigin(cliValue) {
+ const normalized = normalizeRemoteOrigin(cliValue);
+ if (normalized) {
+ process.env.REACTPRESS_DEV_REMOTE_ORIGIN = normalized;
+ process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN = normalized;
+ process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN = normalized;
+ }
+ return normalized;
+}
+
+/** Nest client base URL (includes /api when origin is host-only). */
+function resolveRemoteThemeApiBase(origin) {
+ const base = origin.replace(/\/$/, '');
+ if (/\/api$/i.test(base)) return base;
+ return `${base}/api`;
+}
+
+module.exports = {
+ normalizeRemoteOrigin,
+ readDevRemoteDefault,
+ readDevRemoteOrigin,
+ readDevAdminApiOrigin,
+ readDevClientApiOrigin,
+ parseOriginSpec,
+ resolveDevApiOrigins,
+ applyDevApiOriginsToEnv,
+ applyDevRemoteOrigin,
+ resolveRemoteThemeApiBase,
+};
diff --git a/cli/src/lib/root.ts b/cli/src/lib/root.ts
new file mode 100644
index 00000000..6de8ef4d
--- /dev/null
+++ b/cli/src/lib/root.ts
@@ -0,0 +1,89 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const CLI_PACKAGE_NAME = '@fecommunity/reactpress';
+
+/**
+ * Install root: monorepo checkout (repo root) or published @fecommunity/reactpress package root.
+ * cli/lib -> ../.. when pnpm-workspace.yaml exists; published lib/ -> .. only.
+ */
+function getMonorepoRoot() {
+ const packageRoot = path.resolve(__dirname, '..');
+ const parentOfPackage = path.resolve(__dirname, '../..');
+ if (fs.existsSync(path.join(parentOfPackage, 'pnpm-workspace.yaml'))) {
+ return parentOfPackage;
+ }
+ return packageRoot;
+}
+
+function isPublishedCliRoot(dir) {
+ const resolved = path.resolve(dir);
+ try {
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(resolved, 'package.json'), 'utf8')
+ );
+ if (pkg.name !== CLI_PACKAGE_NAME) return false;
+ } catch {
+ return false;
+ }
+ return !fs.existsSync(path.join(resolved, 'pnpm-workspace.yaml'));
+}
+
+function isProjectRoot(dir) {
+ const resolved = path.resolve(dir);
+ if (isPublishedCliRoot(resolved)) return false;
+ return (
+ fs.existsSync(path.join(resolved, '.reactpress', 'config.json')) ||
+ fs.existsSync(path.join(resolved, 'pnpm-workspace.yaml')) ||
+ fs.existsSync(path.join(resolved, 'server', 'src', 'main.ts')) ||
+ fs.existsSync(path.join(resolved, 'toolkit', 'package.json'))
+ );
+}
+
+function findProjectRoot(startDir = process.cwd()) {
+ let dir = path.resolve(startDir);
+ while (true) {
+ if (isProjectRoot(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return null;
+}
+
+function getProjectRoot() {
+ const envRoot = process.env.REACTPRESS_ORIGINAL_CWD;
+ if (envRoot) {
+ const resolved = path.resolve(envRoot);
+ if (isProjectRoot(resolved)) return resolved;
+ }
+ const discovered = findProjectRoot(process.cwd());
+ if (discovered) return discovered;
+ if (envRoot) return path.resolve(envRoot);
+ return path.resolve(process.cwd());
+}
+
+function ensureOriginalCwd() {
+ const root = getProjectRoot();
+ process.env.REACTPRESS_ORIGINAL_CWD = root;
+ return root;
+}
+
+function isMonorepoCheckout(cwd) {
+ const resolved = path.resolve(cwd || process.cwd());
+ return (
+ fs.existsSync(path.join(resolved, 'pnpm-workspace.yaml')) ||
+ fs.existsSync(path.join(resolved, 'server', 'src', 'main.ts'))
+ );
+}
+
+module.exports = {
+ getMonorepoRoot,
+ getProjectRoot,
+ ensureOriginalCwd,
+ isMonorepoCheckout,
+ isProjectRoot,
+ findProjectRoot,
+ isPublishedCliRoot,
+};
diff --git a/cli/src/lib/spawn.ts b/cli/src/lib/spawn.ts
new file mode 100644
index 00000000..3d18fa8d
--- /dev/null
+++ b/cli/src/lib/spawn.ts
@@ -0,0 +1,106 @@
+// @ts-nocheck
+const { spawn, spawnSync } = require('child_process');
+const path = require('path');
+const chalk = require('chalk');
+const { ensureOriginalCwd } = require('./root');
+const { getCliPackageRoot } = require('./paths');
+const { t, resolveLocale } = require('./i18n');
+
+function runSync(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd || ensureOriginalCwd(),
+ stdio: options.stdio ?? 'inherit',
+ env: {
+ ...process.env,
+ REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(),
+ REACTPRESS_ORIGINAL_CWD:
+ options.cwd || process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(),
+ ...options.env,
+ },
+ shell: options.shell ?? false,
+ });
+ if (result.status !== 0) {
+ const err = new Error(
+ t('spawn.commandFailed', { command, code: result.status ?? 1 })
+ );
+ err.exitCode = result.status ?? 1;
+ throw err;
+ }
+ return result;
+}
+
+function runNodeScript(scriptPath, args = [], options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [scriptPath, ...args], {
+ stdio: 'inherit',
+ cwd: options.cwd || ensureOriginalCwd(),
+ env: {
+ ...process.env,
+ REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(),
+ REACTPRESS_ORIGINAL_CWD:
+ options.cwd || process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(),
+ ...options.env,
+ },
+ });
+
+ child.on('error', (error) => {
+ console.error(chalk.red('[ReactPress]'), error.message || error);
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ if (code !== 0) {
+ reject(Object.assign(new Error(t('spawn.exitCode', { code })), { exitCode: code }));
+ return;
+ }
+ resolve(code);
+ });
+ });
+}
+
+function spawnDetached(scriptPath, args = [], options = {}) {
+ const child = spawn(process.execPath, [scriptPath, ...args], {
+ stdio: options.stdio ?? 'ignore',
+ detached: true,
+ cwd: options.cwd,
+ env: {
+ ...process.env,
+ REACTPRESS_LANG: process.env.REACTPRESS_LANG || resolveLocale(),
+ REACTPRESS_ORIGINAL_CWD:
+ options.cwd || process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(),
+ ...options.env,
+ },
+ });
+ child.unref();
+ return child;
+}
+
+async function runReactpressCli(args, options = {}) {
+ const { initProject } = require('../core/services/init');
+ const directory = args[1] ?? options.cwd ?? ensureOriginalCwd();
+ const force = args.includes('--force');
+ const local = args.includes('--local');
+ const result = await initProject({
+ directory: path.resolve(String(directory)),
+ force,
+ local,
+ });
+ console.log(`[reactpress] ${result.message}`);
+ if (!result.ok) {
+ const err = new Error(result.message);
+ (err as NodeJS.ErrnoException & { exitCode?: number }).exitCode = 1;
+ throw err;
+ }
+}
+
+function resolveCliScript(relativePath) {
+ return path.join(__dirname, '..', relativePath);
+}
+
+module.exports = {
+ runSync,
+ runNodeScript,
+ spawnDetached,
+ runReactpressCli,
+ resolveCliScript,
+};
diff --git a/cli/src/lib/status.ts b/cli/src/lib/status.ts
new file mode 100644
index 00000000..a47f1cb8
--- /dev/null
+++ b/cli/src/lib/status.ts
@@ -0,0 +1,133 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const {
+ brand,
+ icon,
+ divider,
+ padRight,
+ statusPill,
+ sectionHeader,
+ terminalWidth,
+ gradientText,
+ palette,
+} = require('../ui/theme');
+const {
+ loadServerSiteUrl,
+ loadClientSiteUrl,
+ getHealthUrl,
+ checkHealth,
+ isHttpResponding,
+} = require('./http');
+const { isUsingMonorepoServer } = require('./paths');
+const { readPid, isProcessRunning } = require('./process');
+const { isDockerRunning } = require('./docker');
+const { ensureOriginalCwd } = require('./root');
+const { t } = require('./i18n');
+
+function envFileStatus(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ const configPath = path.join(projectRoot, '.reactpress', 'config.json');
+ return {
+ env: fs.existsSync(envPath),
+ config: fs.existsSync(configPath),
+ envPath,
+ configPath,
+ };
+}
+
+function fieldRow(label, value) {
+ return ` ${brand.muted(padRight(label, 10))} ${value}`;
+}
+
+async function printUnifiedStatus(projectRoot = ensureOriginalCwd()) {
+ const env = envFileStatus(projectRoot);
+ const apiUrl = loadServerSiteUrl(projectRoot);
+ const clientUrl = loadClientSiteUrl(projectRoot);
+ const pid = readPid(projectRoot);
+ const healthUrl = getHealthUrl(projectRoot);
+ const [apiHttp, clientHttp, health] = await Promise.all([
+ isHttpResponding(apiUrl),
+ isHttpResponding(clientUrl),
+ checkHealth(healthUrl),
+ ]);
+
+ const apiSource = isUsingMonorepoServer(projectRoot)
+ ? t('status.apiSource.monorepo')
+ : t('status.apiSource.bundle');
+
+ const w = Math.min(terminalWidth() - 4, 52);
+ const httpOn = { on: t('status.apiOnline'), off: t('status.apiOffline') };
+
+ console.log('');
+ console.log(` ${gradientText(t('status.title'), [palette.primary, palette.accent], { bold: true })}`);
+ console.log(` ${divider(w)}`);
+
+ console.log(sectionHeader(t('status.section.project')));
+ console.log(fieldRow(t('status.field.dir'), brand.dim(projectRoot)));
+ console.log(fieldRow(t('status.field.source'), brand.accent(apiSource)));
+ console.log(
+ fieldRow(
+ t('status.field.config'),
+ env.config ? brand.success(t('status.configOk')) : brand.warn(t('status.configBad'))
+ )
+ );
+ console.log(
+ fieldRow(
+ t('status.field.env'),
+ env.env ? brand.success(t('status.envOk')) : brand.warn(t('status.envBad'))
+ )
+ );
+
+ console.log('');
+ console.log(sectionHeader(t('status.section.api')));
+ console.log(fieldRow(t('status.field.url'), brand.dim(apiUrl)));
+ console.log(fieldRow(t('status.field.http'), statusPill(apiHttp, httpOn)));
+ console.log(
+ fieldRow(
+ t('status.field.health'),
+ health.ok
+ ? `${icon.ok} ${brand.dim(healthUrl)}`
+ : brand.dim(t('status.apiUnreachable', { url: healthUrl }))
+ )
+ );
+ if (health.ok && health.data?.data) {
+ const db = health.data.data.database;
+ const dbOk = db === 'up';
+ console.log(
+ fieldRow(
+ t('status.field.database'),
+ statusPill(dbOk, { on: t('status.dbUp'), off: t('status.dbDown') })
+ )
+ );
+ }
+ const pidAlive = pid && isProcessRunning(pid);
+ console.log(
+ fieldRow(
+ t('status.field.pid'),
+ `${brand.dim(pid ?? '—')}${pidAlive ? ` ${brand.success(t('status.pidRunning'))}` : ''}`
+ )
+ );
+
+ console.log('');
+ console.log(sectionHeader(t('status.section.frontend')));
+ console.log(fieldRow(t('status.field.url'), brand.dim(clientUrl)));
+ console.log(fieldRow(t('status.field.http'), statusPill(clientHttp, httpOn)));
+
+ console.log('');
+ console.log(sectionHeader(t('status.section.docker')));
+ console.log(
+ fieldRow(
+ t('status.field.engine'),
+ statusPill(isDockerRunning(), {
+ on: t('status.dockerUp'),
+ off: t('status.dockerDown'),
+ })
+ )
+ );
+
+ console.log(` ${divider(w)}`);
+ console.log('');
+}
+
+module.exports = { printUnifiedStatus, envFileStatus };
diff --git a/cli/src/lib/theme-catalog.ts b/cli/src/lib/theme-catalog.ts
new file mode 100644
index 00000000..0e285f39
--- /dev/null
+++ b/cli/src/lib/theme-catalog.ts
@@ -0,0 +1,3 @@
+// @ts-nocheck
+/** @deprecated Import from ./theme-registry — kept for backward compatibility. */
+module.exports = require('./theme-registry');
diff --git a/cli/src/lib/theme-cli.ts b/cli/src/lib/theme-cli.ts
new file mode 100644
index 00000000..26355dfd
--- /dev/null
+++ b/cli/src/lib/theme-cli.ts
@@ -0,0 +1,54 @@
+// @ts-nocheck
+const chalk = require('chalk');
+const { installThemeFromNpm } = require('./theme-install');
+const { readThemeLock } = require('./theme-lock');
+const { readThemeCatalog, resolveCatalogInstallSpec } = require('./theme-catalog');
+const { listAvailableThemeIds } = require('./theme-runtime');
+const { t } = require('./i18n');
+
+async function runThemeAdd(projectRoot, spec, options = {}) {
+ const trimmed = String(spec || '').trim();
+ if (!trimmed) {
+ throw new Error(t('themeInstall.specRequired'));
+ }
+
+ const resolvedSpec = resolveCatalogInstallSpec(projectRoot, trimmed) || trimmed;
+ console.log(chalk.cyan('[reactpress]'), t('themeInstall.installing', { spec: resolvedSpec }));
+ const result = await installThemeFromNpm(projectRoot, resolvedSpec, {
+ skipDependencies: options.skipDependencies === true,
+ });
+
+ console.log(
+ chalk.green('[reactpress]'),
+ t('themeInstall.success', {
+ id: result.themeId,
+ name: result.name,
+ dir: result.themeDirRel,
+ }),
+ );
+ console.log(chalk.gray(t('themeInstall.nextActivate', { id: result.themeId })));
+ return result;
+}
+
+function runThemeList(projectRoot) {
+ const ids = listAvailableThemeIds(projectRoot);
+ const lock = readThemeLock(projectRoot);
+ if (!ids.length) {
+ console.log(t('themeInstall.listEmpty'));
+ return;
+ }
+ console.log(t('themeInstall.listHeading'));
+ for (const id of ids.sort()) {
+ const npm = lock.themes[id];
+ if (npm?.source === 'npm') {
+ console.log(` - ${id} (${npm.spec})`);
+ } else {
+ console.log(` - ${id}`);
+ }
+ }
+}
+
+module.exports = {
+ runThemeAdd,
+ runThemeList,
+};
diff --git a/cli/src/lib/theme-dev.ts b/cli/src/lib/theme-dev.ts
new file mode 100644
index 00000000..c9599099
--- /dev/null
+++ b/cli/src/lib/theme-dev.ts
@@ -0,0 +1,844 @@
+// @ts-nocheck
+const { spawnSync } = require('child_process');
+const { spawnDevChild } = require('./dev-child-io');
+const path = require('path');
+const fs = require('fs');
+const { loadClientSiteUrl, loadServerSiteUrl, getApiPrefix, waitForHttpOk } = require('./http');
+const { warmupThemeHomepage } = require('./theme-warmup');
+const { nginxEntryUrl } = require('./nginx');
+const {
+ readActiveThemeManifest,
+ readPreviewThemeManifest,
+ resolveThemeDirectory,
+ readManifestSignature,
+ readPreviewManifestSignature,
+ getPreviewThemePort,
+ isThemePackageDir,
+ isAllowedThemePort,
+ themeWorkspaceRoot,
+ listAvailableThemeIds,
+} = require('./theme-runtime');
+const { isDevVerbose, logDevDetail, logDevLine } = require('./dev-log');
+const { resolveProjectRoot } = require('./paths');
+const { t } = require('./i18n');
+const {
+ ensurePreviewThemeRunning,
+ stopAllPreviewPool,
+ stopPreviewPoolTheme,
+ isPreviewHomepageReady,
+ getPreviewSiteUrlForPort,
+ getPreviewProxyPort,
+ ensurePreviewProxyRunning,
+ resolvePreviewThemeLaunchPlan,
+ spawnThemeProcess,
+ withPreviewPortLock,
+ setPreviewProxyTarget,
+ isIntegratedDesktopDev,
+} = require('./theme-preview-pool');
+const { enqueueThemeBuild, ensureThemeDependenciesInstalled } = require('./theme-prod');
+
+let themeChild = null;
+let themeWatchStop = null;
+let runningSignature = null;
+let trackedThemePid = null;
+let restartChain = Promise.resolve();
+
+let previewRunningSignature = null;
+let previewRestartChain = Promise.resolve();
+
+/** Drop `.next` only when it does not match the theme package React major (avoids wiping React 17 caches). */
+function cleanStaleThemeDevCache(themeDir) {
+ if (process.env.REACTPRESS_KEEP_THEME_CACHE === '1') return;
+
+ const nextDir = path.join(themeDir, '.next');
+ if (!fs.existsSync(nextDir)) return;
+
+ if (process.env.REACTPRESS_CLEAR_THEME_CACHE === '1') {
+ fs.rmSync(nextDir, { recursive: true, force: true });
+ logDevDetail('themeDev.cacheCleared');
+ return;
+ }
+
+ let expectedMajor = '17';
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8'));
+ const reactDep = pkg.dependencies?.react || pkg.devDependencies?.react || '';
+ const match = String(reactDep).match(/(\d+)/);
+ if (match) expectedMajor = match[1];
+ } catch {
+ return;
+ }
+
+ try {
+ const marker = `react@${expectedMajor}`;
+ const result = spawnSync('grep', ['-rl', marker, nextDir], {
+ encoding: 'utf8',
+ maxBuffer: 1024 * 1024,
+ });
+ if (result.stdout?.trim()) return;
+
+ fs.rmSync(nextDir, { recursive: true, force: true });
+ logDevDetail('themeDev.cacheStaleCleared', { marker });
+ } catch {
+ // ignore grep / rm failures
+ }
+}
+
+function getClientPort(projectRoot) {
+ try {
+ const url = new URL(loadClientSiteUrl(projectRoot));
+ const port = parseInt(url.port || '3001', 10);
+ if (isAllowedThemePort(port)) return String(port);
+ } catch {
+ // fall through
+ }
+ return '3001';
+}
+
+function assertThemePort(port) {
+ const n = parseInt(port, 10);
+ if (!isAllowedThemePort(n)) {
+ throw new Error(`Refusing theme dev on protected port ${port}`);
+ }
+ return n;
+}
+
+function isPortListening(port) {
+ const result = spawnSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
+ return result.status === 0 && Boolean(result.stdout?.trim());
+}
+
+function getProcessCwd(pid) {
+ const n = parseInt(pid, 10);
+ if (!Number.isFinite(n) || n <= 0) return null;
+ const res = spawnSync('lsof', ['-p', String(n)], { encoding: 'utf8' });
+ if (res.status !== 0) return null;
+ const line = res.stdout.split('\n').find((row) => row.includes(' cwd '));
+ if (!line) return null;
+ const parts = line.trim().split(/\s+/);
+ return parts[parts.length - 1] || null;
+}
+
+function isUnderThemesDir(projectRoot, cwd) {
+ if (!cwd) return false;
+ const themesRoot = path.join(path.resolve(projectRoot), 'themes');
+ const resolved = path.resolve(cwd);
+ return resolved === themesRoot || resolved.startsWith(`${themesRoot}${path.sep}`);
+}
+
+/** Child PIDs of `rootPid` (pnpm → next dev tree). */
+function collectDescendantPids(rootPid) {
+ const root = parseInt(rootPid, 10);
+ if (!Number.isFinite(root) || root <= 0) return [];
+
+ const out = [];
+ const queue = [String(root)];
+ const seen = new Set();
+
+ while (queue.length) {
+ const pid = queue.shift();
+ if (!pid || seen.has(pid)) continue;
+ seen.add(pid);
+
+ const children = spawnSync('pgrep', ['-P', pid], { encoding: 'utf8' });
+ if (children.status !== 0 || !children.stdout?.trim()) continue;
+
+ for (const child of children.stdout.trim().split(/\s+/)) {
+ if (!child || seen.has(child)) continue;
+ out.push(child);
+ queue.push(child);
+ }
+ }
+ return out;
+}
+
+function isPidSafeToSignal(pid) {
+ const n = parseInt(pid, 10);
+ if (!Number.isFinite(n) || n <= 1) return false;
+ if (n === process.pid) return false;
+ if (process.ppid && n === process.ppid) return false;
+ return true;
+}
+
+/** LISTEN pids for this theme dev port (package cwd, themes/, or tracked child tree). */
+function getThemeListenerPids(projectRoot, port) {
+ const result = spawnSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
+ if (result.status !== 0 || !result.stdout?.trim()) return [];
+
+ const allowed = new Set();
+
+ if (trackedThemePid && isPidSafeToSignal(trackedThemePid)) {
+ allowed.add(String(trackedThemePid));
+ for (const child of collectDescendantPids(trackedThemePid)) {
+ if (isPidSafeToSignal(child)) allowed.add(child);
+ }
+ }
+
+ for (const pid of result.stdout.trim().split(/\s+/)) {
+ if (!isPidSafeToSignal(pid)) continue;
+ const cwd = getProcessCwd(pid);
+ if (
+ cwd &&
+ (isThemePackageDir(projectRoot, cwd) || isUnderThemesDir(projectRoot, cwd))
+ ) {
+ allowed.add(pid);
+ for (const child of collectDescendantPids(pid)) {
+ if (isPidSafeToSignal(child)) allowed.add(child);
+ }
+ }
+ }
+
+ return [...allowed];
+}
+
+function signalPids(pids, signal) {
+ const flag = signal === 'KILL' ? '-9' : '-TERM';
+ for (const pid of pids) {
+ if (isPidSafeToSignal(pid)) {
+ spawnSync('kill', [flag, pid], { stdio: 'ignore' });
+ }
+ }
+}
+
+function killThemeListenersOnPort(projectRoot, port, signal = 'TERM') {
+ signalPids(getThemeListenerPids(projectRoot, port), signal);
+}
+
+function waitForPortFree(port, timeoutMs = 10_000) {
+ const start = Date.now();
+ return new Promise((resolve) => {
+ const tick = () => {
+ if (!isPortListening(port)) {
+ resolve(true);
+ return;
+ }
+ if (Date.now() - start >= timeoutMs) {
+ resolve(false);
+ return;
+ }
+ setTimeout(tick, 250);
+ };
+ tick();
+ });
+}
+
+async function releaseThemePort(projectRoot, port, { fast = false } = {}) {
+ stopActiveThemeProcess();
+
+ const maxAttempts = fast ? 3 : 4;
+ const waitSchedule = fast ? [1200, 800, 800] : [12_000, 6000, 6000, 6000];
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ const signal = fast ? (attempt === 0 ? 'TERM' : 'KILL') : attempt < 2 ? 'TERM' : 'KILL';
+ killThemeListenersOnPort(projectRoot, port, signal);
+ if (trackedThemePid && isPidSafeToSignal(trackedThemePid)) {
+ const tree = [String(trackedThemePid), ...collectDescendantPids(trackedThemePid)];
+ signalPids(tree, signal);
+ }
+ const waitMs = waitSchedule[attempt] ?? 6000;
+ const freed = await waitForPortFree(port, waitMs);
+ if (freed) {
+ trackedThemePid = null;
+ return true;
+ }
+ }
+
+ trackedThemePid = null;
+ return false;
+}
+
+/** Optional theme-only API override (admin / Nest API stay on SERVER_SITE_URL). */
+function readThemeApiOverride(projectRoot, envKey) {
+ const fromShell = process.env[envKey]?.trim();
+ if (fromShell) return fromShell.replace(/\/$/, '');
+
+ const envPath = path.join(projectRoot, '.env');
+ try {
+ const content = fs.readFileSync(envPath, 'utf8');
+ const match = content.match(new RegExp(`^${envKey}=(.+)$`, 'm'));
+ if (match) {
+ const raw = match[1].trim().replace(/^['"]|['"]$/g, '');
+ if (raw) return raw.replace(/\/$/, '');
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+function useLocalThemeApiInDev() {
+ return process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API === '1';
+}
+
+function buildLocalThemeApiUrl(projectRoot, { forBrowser = false } = {}) {
+ const desktopApi = process.env.REACTPRESS_DESKTOP_LOCAL_API?.trim().replace(/\/$/, '');
+ if (desktopApi) {
+ return desktopApi;
+ }
+ if (forBrowser && process.env.REACTPRESS_BEHIND_NGINX === '1') {
+ return `${nginxEntryUrl(projectRoot).replace(/\/$/, '')}/api`;
+ }
+ const server = loadServerSiteUrl(projectRoot).replace(/\/$/, '');
+ const prefix = getApiPrefix(projectRoot).replace(/\/$/, '') || '/api';
+ return `${server}${prefix.startsWith('/') ? prefix : `/${prefix}`}`;
+}
+
+/** Direct Nest API — used for Next.js SSR (runs before nginx is up). */
+function buildThemeServerApiUrl(projectRoot) {
+ if (useLocalThemeApiInDev()) {
+ return buildLocalThemeApiUrl(projectRoot, { forBrowser: false });
+ }
+
+ const override = readThemeApiOverride(projectRoot, 'REACTPRESS_THEME_API_URL');
+ if (override) return override;
+
+ return buildLocalThemeApiUrl(projectRoot);
+}
+
+/** Browser-facing API — nginx unified entry when behind proxy. */
+function buildThemePublicApiUrl(projectRoot) {
+ if (useLocalThemeApiInDev()) {
+ return buildLocalThemeApiUrl(projectRoot, { forBrowser: true });
+ }
+
+ const publicOverride = readThemeApiOverride(projectRoot, 'REACTPRESS_THEME_PUBLIC_API_URL');
+ if (publicOverride) return publicOverride;
+
+ const themeOverride = readThemeApiOverride(projectRoot, 'REACTPRESS_THEME_API_URL');
+ if (themeOverride) return themeOverride;
+
+ return buildLocalThemeApiUrl(projectRoot);
+}
+
+/** @deprecated use buildThemeServerApiUrl / buildThemePublicApiUrl */
+function buildThemeApiUrl(projectRoot) {
+ return buildThemeServerApiUrl(projectRoot);
+}
+
+function buildThemeChildEnv(projectRoot, { port, serverApiUrl, publicApiUrl, themeId }) {
+ const keys = [
+ 'PATH',
+ 'HOME',
+ 'USER',
+ 'LANG',
+ 'LC_ALL',
+ 'NODE_ENV',
+ 'PNPM_HOME',
+ 'npm_config_user_agent',
+ ];
+ const env = {};
+ for (const key of keys) {
+ if (process.env[key] !== undefined) env[key] = process.env[key];
+ }
+ return {
+ ...env,
+ PORT: String(port),
+ // Next inlines SERVER_API_URL from next.config (localhost); override for remote dev SSR.
+ SERVER_API_URL: serverApiUrl,
+ REACTPRESS_API_URL: serverApiUrl,
+ NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl,
+ REACTPRESS_THEME_ID: themeId || '',
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ REACTPRESS_SKIP_BROWSER_OPEN: '1',
+ NEXT_IGNORE_INCORRECT_LOCKFILE: '1',
+ NEXT_TELEMETRY_DISABLED: '1',
+ ...(process.env.REACTPRESS_NGINX_ENTRY_URL
+ ? {
+ REACTPRESS_NGINX_ENTRY_URL: process.env.REACTPRESS_NGINX_ENTRY_URL,
+ NGINX_ENTRY_URL: process.env.REACTPRESS_NGINX_ENTRY_URL,
+ NEXT_PUBLIC_REACTPRESS_ADMIN_URL: `${String(process.env.REACTPRESS_NGINX_ENTRY_URL).replace(/\/$/, '')}/admin`,
+ }
+ : { REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1' }),
+ ...(process.env.REACTPRESS_DESKTOP_LOCAL === '1' || process.env.REACTPRESS_DESKTOP_SITE_ROOT
+ ? { REACTPRESS_HONOR_PREVIEW: '1' }
+ : {}),
+ };
+}
+
+function stopThemeProcess(childRef, trackedPidRef) {
+ const child = childRef.current;
+ if (!child || child.killed) {
+ childRef.current = null;
+ return;
+ }
+
+ const pid = child.pid;
+ if (pid && isPidSafeToSignal(pid)) {
+ trackedPidRef.current = pid;
+ }
+ try {
+ if (process.platform !== 'win32' && pid && isPidSafeToSignal(pid)) {
+ try {
+ process.kill(-pid, 'SIGTERM');
+ } catch {
+ spawnSync('pkill', ['-TERM', '-P', String(pid)], { stdio: 'ignore' });
+ child.kill('SIGTERM');
+ }
+ for (const descendant of collectDescendantPids(pid)) {
+ if (isPidSafeToSignal(descendant)) {
+ spawnSync('kill', ['-TERM', descendant], { stdio: 'ignore' });
+ }
+ }
+ } else if (pid && isPidSafeToSignal(pid)) {
+ child.kill('SIGTERM');
+ }
+ } catch {
+ // ignore — process may already be gone
+ }
+ childRef.current = null;
+}
+
+const activeChildRef = { get current() { return themeChild; }, set current(v) { themeChild = v; } };
+const activeTrackedPidRef = {
+ get current() { return trackedThemePid; },
+ set current(v) { trackedThemePid = v; },
+};
+
+function stopActiveThemeProcess() {
+ stopThemeProcess(activeChildRef, activeTrackedPidRef);
+}
+
+function stopPreviewThemeProcess() {
+ /* Preview pool stays warm — torn down only on full dev shutdown. */
+}
+
+function stopThemeSite() {
+ if (themeWatchStop) {
+ themeWatchStop();
+ themeWatchStop = null;
+ }
+ stopActiveThemeProcess();
+ void stopAllPreviewPool(resolveProjectRoot());
+ runningSignature = null;
+ previewRunningSignature = null;
+ restartChain = Promise.resolve();
+ previewRestartChain = Promise.resolve();
+}
+
+async function spawnThemeSite(projectRoot, { onClose } = {}) {
+ const signature = readManifestSignature(projectRoot);
+ if (!signature) {
+ console.warn(`[reactpress] ${t('themeDev.invalidManifest')}`);
+ runningSignature = null;
+ return null;
+ }
+
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const themeDir = resolveThemeDirectory(projectRoot, activeTheme);
+ const port = assertThemePort(getClientPort(projectRoot));
+ const serverApiUrl = buildThemeServerApiUrl(projectRoot);
+ const publicApiUrl = buildThemePublicApiUrl(projectRoot);
+ const siteUrl = loadClientSiteUrl(projectRoot);
+
+ if (!themeDir || !isThemePackageDir(projectRoot, themeDir)) {
+ console.warn(
+ `[reactpress] ${t('themeDev.notFound', { id: activeTheme })} — ${siteUrl} ${t('themeDev.unavailable')}`,
+ );
+ runningSignature = null;
+ return null;
+ }
+
+ const relDir = path.relative(projectRoot, themeDir) || themeDir;
+ const launch = resolvePreviewThemeLaunchPlan(themeDir, port);
+ const useProduction = launch.mode === 'production';
+
+ logDevDetail('themeDev.startingShort', {
+ id: activeTheme,
+ port,
+ dir: relDir,
+ mode: useProduction ? 'production' : 'dev',
+ });
+ if (isDevVerbose()) {
+ logDevLine('themeDev.apiSplit', { ssr: serverApiUrl, browser: publicApiUrl });
+ }
+
+ if (useProduction) {
+ try {
+ ensureThemeDependenciesInstalled(projectRoot, themeDir, activeTheme, 'themeProd');
+ } catch (err) {
+ console.warn(
+ `[reactpress] ${t('themePreview.buildFailed', {
+ id: activeTheme,
+ message: err.message || err,
+ })}`,
+ );
+ runningSignature = null;
+ return null;
+ }
+ try {
+ await enqueueThemeBuild(projectRoot, activeTheme, { logPrefix: 'themeProd' });
+ const { ensureBuildAllowsPreviewFrame } = require('./theme-preview-frame');
+ ensureBuildAllowsPreviewFrame(themeDir, '.next');
+ } catch (err) {
+ console.warn(
+ `[reactpress] ${t('themePreview.buildFailed', {
+ id: activeTheme,
+ message: err.message || err,
+ })}`,
+ );
+ runningSignature = null;
+ return null;
+ }
+ } else {
+ cleanStaleThemeDevCache(themeDir);
+ }
+
+ try {
+ const { ensurePreviewFrameAllowed } = require('./theme-preview-frame');
+ ensurePreviewFrameAllowed(themeDir);
+ } catch {
+ // ignore — preview patch optional for themes without next.config headers
+ }
+
+ if (useProduction) {
+ themeChild = spawnThemeProcess(projectRoot, {
+ themeDir,
+ themeId: activeTheme,
+ port,
+ serverApiUrl,
+ publicApiUrl,
+ launch,
+ role: 'visitor',
+ });
+ } else {
+ themeChild = spawnDevChild('pnpm', ['run', 'dev'], {
+ cwd: themeDir,
+ detached: process.platform !== 'win32',
+ shell: process.platform === 'win32',
+ env: buildThemeChildEnv(projectRoot, { port, serverApiUrl, publicApiUrl, themeId: activeTheme }),
+ });
+ }
+
+ const child = themeChild;
+ trackedThemePid = child.pid ?? null;
+ runningSignature = signature;
+
+ child.on('close', (code) => {
+ if (themeChild === child) {
+ themeChild = null;
+ trackedThemePid = null;
+ if (runningSignature === signature) {
+ runningSignature = null;
+ }
+ }
+ if (onClose) onClose(code);
+ });
+
+ if (process.env.REACTPRESS_DEV_FORCE_LOCAL_THEME_API !== '1') {
+ const homepageUrl = `${siteUrl.replace(/\/$/, '')}/`;
+ const pollMs = parseInt(process.env.REACTPRESS_THEME_READY_POLL_MS || '150', 10) || 150;
+ waitForHttpOk(homepageUrl, 120_000, pollMs).then((ready) => {
+ if (ready && runningSignature === signature) {
+ console.log(t('themeDev.ready', { url: siteUrl, id: activeTheme }));
+ warmupThemeHomepage(projectRoot, siteUrl).catch(() => {});
+ } else if (!ready && runningSignature === signature) {
+ console.warn(t('themeDev.slow', { url: siteUrl }));
+ }
+ });
+ }
+
+ return themeChild;
+}
+
+async function restartThemeSite(projectRoot, { onClose } = {}) {
+ const signature = readManifestSignature(projectRoot);
+ if (!signature) return;
+
+ if (signature === runningSignature && themeChild && !themeChild.killed) {
+ return;
+ }
+
+ const port = assertThemePort(getClientPort(projectRoot));
+ let freed = await releaseThemePort(projectRoot, port, { fast: true });
+ if (!freed || isPortListening(port)) {
+ freed = await releaseThemePort(projectRoot, port, { fast: true });
+ }
+ if (!freed || isPortListening(port)) {
+ console.warn(`[reactpress] ${t('themeDev.portBusy', { port })}`);
+ console.warn(
+ `[reactpress] ${t('themeDev.portBusyHint', {
+ port,
+ cmd: `lsof -tiTCP:${port} -sTCP:LISTEN | xargs kill -9`,
+ })}`,
+ );
+ return;
+ }
+
+ await spawnThemeSite(projectRoot, { onClose });
+}
+
+async function restartPreviewThemeSite(projectRoot, { onClose } = {}) {
+ await withPreviewPortLock(async () => {
+ const signature = readPreviewManifestSignature(projectRoot);
+
+ if (!signature) {
+ previewRunningSignature = null;
+ await stopAllPreviewPool(projectRoot);
+ if (onClose) onClose(0);
+ return;
+ }
+
+ const previewManifest = readPreviewThemeManifest(projectRoot);
+ const themeId = previewManifest?.activeTheme;
+ if (!themeId) return;
+
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ if (themeId === activeTheme) {
+ previewRunningSignature = null;
+ await stopAllPreviewPool(projectRoot);
+ if (onClose) onClose(0);
+ return;
+ }
+
+ await ensurePreviewProxyRunning(getPreviewProxyPort());
+
+ if (signature === previewRunningSignature) {
+ const { previewPool } = require('./theme-preview-pool');
+ const entry = previewPool.get(themeId);
+ const proxyPort = getPreviewProxyPort();
+ const childAlive =
+ entry?.child &&
+ !entry.child.killed &&
+ entry.child.exitCode == null &&
+ entry.child.signalCode == null;
+ if (childAlive && entry?.backendPort) {
+ setPreviewProxyTarget(entry.backendPort);
+ entry.lastUsed = Date.now();
+ const ready = await isPreviewHomepageReady(projectRoot, proxyPort);
+ if (ready) return;
+ }
+ if (entry) {
+ stopPreviewPoolTheme(themeId);
+ }
+ previewRunningSignature = null;
+ }
+
+ const serverApiUrl = buildThemeServerApiUrl(projectRoot);
+ const publicApiUrl = buildThemePublicApiUrl(projectRoot);
+
+ const result = await ensurePreviewThemeRunning(projectRoot, themeId, {
+ serverApiUrl,
+ publicApiUrl,
+ });
+
+ if (!result) {
+ previewRunningSignature = null;
+ console.warn(`[reactpress] Preview failed to start for theme "${themeId}"`);
+ if (onClose) onClose(1);
+ return;
+ }
+
+ previewRunningSignature = signature;
+ if (result.reused) {
+ console.log(`[reactpress] Preview ready (reused): ${result.url} (theme: ${themeId})`);
+ } else {
+ console.log(`[reactpress] Preview ready: ${result.url} (theme: ${themeId})`);
+ }
+ if (onClose) onClose(0);
+ });
+}
+
+async function prewarmPreviewThemeBackends(projectRoot) {
+ if (process.env.REACTPRESS_SKIP_PREVIEW_BUILD === '1') return;
+ if (!isIntegratedDesktopDev()) return;
+
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const themeIds = listAvailableThemeIds(projectRoot).filter((id) => id !== activeTheme);
+ if (themeIds.length === 0) return;
+
+ console.log(
+ `[reactpress] ${t('dev.previewPrewarmStarting')} (${themeIds.length} backend(s) on :${getPreviewProxyPort()}+)`,
+ );
+
+ await ensurePreviewProxyRunning(getPreviewProxyPort());
+ const serverApiUrl = buildThemeServerApiUrl(projectRoot);
+ const publicApiUrl = buildThemePublicApiUrl(projectRoot);
+
+ for (const themeId of themeIds) {
+ try {
+ const result = await ensurePreviewThemeRunning(projectRoot, themeId, {
+ serverApiUrl,
+ publicApiUrl,
+ });
+ if (result?.reused) {
+ console.log(`[reactpress] Preview backend reused for "${themeId}" (:${result.backendPort})`);
+ } else if (result) {
+ console.log(`[reactpress] Preview backend ready for "${themeId}" (:${result.backendPort})`);
+ }
+ } catch (err) {
+ console.warn(
+ `[reactpress] Preview backend prewarm failed for "${themeId}": ${err.message || err}`,
+ );
+ }
+ }
+}
+
+function watchActiveThemeManifest(projectRoot, onChange) {
+ const manifestPath = path.join(themeWorkspaceRoot(projectRoot), '.reactpress', 'active-theme.json');
+ const dir = path.dirname(manifestPath);
+ fs.mkdirSync(dir, { recursive: true });
+
+ let lastSignature = readManifestSignature(projectRoot);
+ let debounce = null;
+
+ const scheduleCheck = () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => {
+ const next = readManifestSignature(projectRoot);
+ if (!next || next === lastSignature) return;
+ lastSignature = next;
+ console.log(`\n[reactpress] ${t('themeDev.restart')}`);
+ restartChain = restartChain
+ .then(() => onChange())
+ .catch((err) => {
+ console.warn(`[reactpress] ${t('themeDev.restartFailed', { message: err.message || err })}`);
+ });
+ }, 200);
+ };
+
+ const watcher = fs.watch(dir, scheduleCheck);
+ const poller = setInterval(scheduleCheck, 1000);
+
+ return () => {
+ clearTimeout(debounce);
+ clearInterval(poller);
+ watcher.close();
+ };
+}
+
+function watchPreviewThemeManifest(projectRoot, onChange) {
+ const manifestPath = path.join(themeWorkspaceRoot(projectRoot), '.reactpress', 'preview-theme.json');
+ const dir = path.dirname(manifestPath);
+ fs.mkdirSync(dir, { recursive: true });
+
+ let lastSignature = readPreviewManifestSignature(projectRoot);
+ let debounce = null;
+ /** Delay teardown when manifest is deleted — admin preview session may remount immediately. */
+ let clearDebounce = null;
+ const PREVIEW_CLEAR_GRACE_MS = 500;
+
+ const enqueueRestart = (nextSignature) => {
+ if (nextSignature === lastSignature) return;
+ lastSignature = nextSignature;
+ if (nextSignature) {
+ console.log('\n[reactpress] preview-theme.json changed — restarting preview theme…');
+ }
+ previewRestartChain = previewRestartChain
+ .then(() => onChange())
+ .catch((err) => {
+ console.warn(
+ `[reactpress] ${t('themeDev.restartFailed', { message: err.message || err })}`,
+ );
+ });
+ };
+
+ const scheduleCheck = () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => {
+ const next = readPreviewManifestSignature(projectRoot);
+ if (next === lastSignature) return;
+
+ if (!next && lastSignature) {
+ if (clearDebounce) clearTimeout(clearDebounce);
+ clearDebounce = setTimeout(() => {
+ clearDebounce = null;
+ const still = readPreviewManifestSignature(projectRoot);
+ if (still) {
+ if (still !== lastSignature) enqueueRestart(still);
+ return;
+ }
+ enqueueRestart('');
+ }, PREVIEW_CLEAR_GRACE_MS);
+ return;
+ }
+
+ if (clearDebounce) {
+ clearTimeout(clearDebounce);
+ clearDebounce = null;
+ }
+ enqueueRestart(next);
+ }, 200);
+ };
+
+ const watcher = fs.watch(dir, (event, filename) => {
+ if (filename && filename !== 'preview-theme.json') return;
+ scheduleCheck();
+ });
+ const poller = setInterval(scheduleCheck, 1000);
+
+ return () => {
+ clearTimeout(debounce);
+ if (clearDebounce) clearTimeout(clearDebounce);
+ clearInterval(poller);
+ watcher.close();
+ };
+}
+
+/** Drop stale preview manifest so `pnpm dev` does not auto-start :3003 from a prior admin session. */
+function clearPreviewThemeManifestFile(projectRoot) {
+ const manifestPath = path.join(themeWorkspaceRoot(projectRoot), '.reactpress', 'preview-theme.json');
+ if (fs.existsSync(manifestPath)) {
+ fs.unlinkSync(manifestPath);
+ }
+}
+
+async function releaseThemePortIfBusy(projectRoot, port, options) {
+ if (!isPortListening(port)) return true;
+ return releaseThemePort(projectRoot, port, options);
+}
+
+async function prepareThemeDevBoot(projectRoot) {
+ clearPreviewThemeManifestFile(projectRoot);
+ stopActiveThemeProcess();
+ previewRunningSignature = null;
+ runningSignature = null;
+ trackedThemePid = null;
+
+ await ensurePreviewProxyRunning(getPreviewProxyPort());
+
+ const visitorPort = assertThemePort(getClientPort(projectRoot));
+ await releaseThemePortIfBusy(projectRoot, visitorPort);
+}
+
+async function startThemeSiteWithWatch(projectRoot, { onClose } = {}) {
+ await prepareThemeDevBoot(projectRoot);
+
+ const restartActive = () => restartThemeSite(projectRoot, { onClose });
+ const restartPreview = () => restartPreviewThemeSite(projectRoot, { onClose });
+
+ restartChain = restartChain.then(() => restartThemeSite(projectRoot, { onClose }));
+ await restartChain;
+
+ const stopActiveWatch = watchActiveThemeManifest(projectRoot, restartActive);
+ const stopPreviewWatch = watchPreviewThemeManifest(projectRoot, restartPreview);
+
+ const initialPreviewSignature = readPreviewManifestSignature(projectRoot);
+ if (initialPreviewSignature) {
+ previewRestartChain = previewRestartChain.then(() => restartPreviewThemeSite(projectRoot, { onClose }));
+ }
+
+ themeWatchStop = () => {
+ stopActiveWatch();
+ stopPreviewWatch();
+ };
+
+ if (isIntegratedDesktopDev()) {
+ void prewarmPreviewThemeBackends(projectRoot);
+ }
+
+ return Boolean(runningSignature && themeChild && !themeChild.killed);
+}
+
+module.exports = {
+ spawnThemeSite,
+ restartThemeSite,
+ startThemeSiteWithWatch,
+ stopThemeSite,
+ getClientPort,
+ buildThemeApiUrl,
+ buildThemeServerApiUrl,
+ buildThemePublicApiUrl,
+ readManifestSignature,
+ isPortListening,
+ getThemeListenerPids,
+};
diff --git a/cli/src/lib/theme-env.ts b/cli/src/lib/theme-env.ts
new file mode 100644
index 00000000..520ceddc
--- /dev/null
+++ b/cli/src/lib/theme-env.ts
@@ -0,0 +1,112 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const { loadClientSiteUrl, loadServerSiteUrl, getApiPrefix } = require('./http');
+
+function parseEnvFile(content) {
+ const out = {};
+ for (const line of String(content).split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const idx = trimmed.indexOf('=');
+ if (idx <= 0) continue;
+ const key = trimmed.slice(0, idx).trim();
+ let value = trimmed.slice(idx + 1).trim();
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+ out[key] = value;
+ }
+ return out;
+}
+
+function readProjectEnv(projectRoot) {
+ const envPath = path.join(path.resolve(projectRoot), '.env');
+ if (!fs.existsSync(envPath)) return {};
+ try {
+ return parseEnvFile(fs.readFileSync(envPath, 'utf8'));
+ } catch {
+ return {};
+ }
+}
+
+function buildThemeEnvOverrides(projectRoot, projectEnv = readProjectEnv(projectRoot)) {
+ const clientSiteUrl = (
+ projectEnv.CLIENT_SITE_URL || loadClientSiteUrl(projectRoot)
+ ).replace(/\/$/, '');
+ const serverSiteUrl = (
+ projectEnv.SERVER_SITE_URL || loadServerSiteUrl(projectRoot)
+ ).replace(/\/$/, '');
+ const apiPrefix = projectEnv.SERVER_API_PREFIX || getApiPrefix(projectRoot) || '/api';
+ const normalizedPrefix = apiPrefix.startsWith('/') ? apiPrefix : `/${apiPrefix}`;
+ const apiUrl =
+ projectEnv.REACTPRESS_API_URL || `${serverSiteUrl}${normalizedPrefix}`.replace(/\/$/, '');
+
+ const publicApiUrl =
+ projectEnv.NEXT_PUBLIC_REACTPRESS_API_URL ||
+ projectEnv.REACTPRESS_THEME_PUBLIC_API_URL ||
+ apiUrl;
+
+ const adminUrl =
+ projectEnv.NEXT_PUBLIC_REACTPRESS_ADMIN_URL ||
+ projectEnv.WEB_ADMIN_URL ||
+ 'http://localhost:3000';
+
+ return {
+ REACTPRESS_API_URL: apiUrl,
+ SERVER_API_URL: apiUrl,
+ NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl,
+ CLIENT_SITE_URL: clientSiteUrl,
+ NEXT_PUBLIC_REACTPRESS_ADMIN_URL: adminUrl.replace(/\/$/, ''),
+ };
+}
+
+function upsertEnvLines(existingContent, overrides) {
+ const lines = existingContent.split('\n');
+ for (const [key, value] of Object.entries(overrides)) {
+ if (value == null || value === '') continue;
+ const entry = `${key}=${value}`;
+ const index = lines.findIndex((line) => {
+ const trimmed = line.trim();
+ return trimmed && !trimmed.startsWith('#') && trimmed.startsWith(`${key}=`);
+ });
+ if (index >= 0) {
+ lines[index] = entry;
+ } else {
+ lines.push(entry);
+ }
+ }
+ return `${lines.join('\n').trimEnd()}\n`;
+}
+
+/**
+ * Point an installed theme's `.env` at the host ReactPress project API URLs.
+ */
+function syncThemeEnvFromProject(projectRoot, themeDir) {
+ const root = path.resolve(projectRoot);
+ const dir = path.resolve(themeDir);
+ const overrides = buildThemeEnvOverrides(root);
+ const envPath = path.join(dir, '.env');
+ const examplePath = path.join(dir, '.env.example');
+
+ let base = '';
+ if (fs.existsSync(envPath)) {
+ base = fs.readFileSync(envPath, 'utf8');
+ } else if (fs.existsSync(examplePath)) {
+ base = fs.readFileSync(examplePath, 'utf8');
+ }
+
+ const next = upsertEnvLines(base, overrides);
+ fs.writeFileSync(envPath, next, 'utf8');
+ return envPath;
+}
+
+module.exports = {
+ buildThemeEnvOverrides,
+ readProjectEnv,
+ syncThemeEnvFromProject,
+};
diff --git a/cli/src/lib/theme-install.ts b/cli/src/lib/theme-install.ts
new file mode 100644
index 00000000..4072a202
--- /dev/null
+++ b/cli/src/lib/theme-install.ts
@@ -0,0 +1,379 @@
+// @ts-nocheck
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const { upsertNpmThemeLock } = require('./theme-lock');
+const { buildThemeEnvOverrides, syncThemeEnvFromProject } = require('./theme-env');
+const { ensurePreviewFrameAllowed } = require('./theme-preview-frame');
+const { resolveCatalogInstallSpec } = require('./theme-registry');
+
+const THEME_ID_RE = /^[a-z0-9][a-z0-9-]*$/i;
+const THEME_RUNTIME_REL = path.join('.reactpress', 'runtime');
+const COPY_SKIP_NAMES = new Set([
+ 'node_modules',
+ '.next',
+ '.git',
+ 'dist',
+ '.turbo',
+ 'coverage',
+ '.reactpress',
+ '.cache',
+ 'package-lock.json',
+]);
+
+function isValidThemeId(id) {
+ return typeof id === 'string' && THEME_ID_RE.test(id) && id.length <= 64;
+}
+
+function parseNpmSpec(spec) {
+ const trimmed = String(spec || '').trim();
+ if (!trimmed) {
+ return { error: 'EMPTY_SPEC' };
+ }
+ if (trimmed.endsWith('.tgz') || trimmed.endsWith('.tar.gz')) {
+ const resolved = path.resolve(trimmed);
+ if (!fs.existsSync(resolved)) {
+ return { error: 'TARBALL_NOT_FOUND', path: resolved };
+ }
+ return { kind: 'tarball', path: resolved };
+ }
+ if (/^file:/i.test(trimmed)) {
+ const filePath = trimmed.replace(/^file:/i, '');
+ const resolved = path.resolve(filePath);
+ if (!fs.existsSync(resolved)) {
+ return { error: 'TARBALL_NOT_FOUND', path: resolved };
+ }
+ return { kind: 'tarball', path: resolved };
+ }
+ return { kind: 'npm', spec: trimmed };
+}
+
+function readThemeManifestFromDir(dir) {
+ const manifestPath = path.join(dir, 'theme.json');
+ if (!fs.existsSync(manifestPath)) return null;
+ try {
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ if (raw && typeof raw.id === 'string' && typeof raw.name === 'string') {
+ return raw;
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+function inferThemeIdFromPackageName(name) {
+ if (typeof name !== 'string' || !name) return null;
+ const base = name.includes('/') ? name.split('/').pop() : name;
+ const match = base.match(/(?:reactpress-)?(?:template-)?(.+)$/i);
+ if (!match) return null;
+ const id = match[1].replace(/^template-/, '');
+ return isValidThemeId(id) ? id : null;
+}
+
+function resolveThemeIdentity(packageDir) {
+ const manifest = readThemeManifestFromDir(packageDir);
+ if (manifest?.id && isValidThemeId(manifest.id)) {
+ return {
+ themeId: manifest.id,
+ manifest,
+ };
+ }
+
+ const pkgPath = path.join(packageDir, 'package.json');
+ if (fs.existsSync(pkgPath)) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ const fromReactpress = pkg.reactpress?.themeId || pkg.reactpress?.id;
+ if (typeof fromReactpress === 'string' && isValidThemeId(fromReactpress)) {
+ return {
+ themeId: fromReactpress,
+ manifest: manifest || {
+ id: fromReactpress,
+ name: typeof pkg.description === 'string' ? pkg.description : fromReactpress,
+ version: typeof pkg.version === 'string' ? pkg.version : '1.0.0',
+ },
+ };
+ }
+ const inferred = inferThemeIdFromPackageName(pkg.name);
+ if (inferred) {
+ return {
+ themeId: inferred,
+ manifest: manifest || {
+ id: inferred,
+ name: typeof pkg.description === 'string' ? pkg.description : inferred,
+ version: typeof pkg.version === 'string' ? pkg.version : '1.0.0',
+ },
+ packageName: pkg.name,
+ packageVersion: pkg.version,
+ };
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ return null;
+}
+
+function isThemePackageDir(dir) {
+ return (
+ fs.existsSync(path.join(dir, 'theme.json')) ||
+ fs.existsSync(path.join(dir, 'package.json'))
+ );
+}
+
+function copyDir(src, dest) {
+ fs.mkdirSync(dest, { recursive: true });
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+ if (COPY_SKIP_NAMES.has(entry.name)) continue;
+ const from = path.join(src, entry.name);
+ const to = path.join(dest, entry.name);
+ if (entry.isSymbolicLink()) {
+ const link = fs.readlinkSync(from);
+ fs.symlinkSync(link, to);
+ } else if (entry.isDirectory()) {
+ copyDir(from, to);
+ } else if (entry.isFile()) {
+ fs.copyFileSync(from, to);
+ }
+ }
+}
+
+function removeDir(dir) {
+ if (!fs.existsSync(dir)) return;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const target = path.join(dir, entry.name);
+ if (entry.isSymbolicLink()) {
+ fs.unlinkSync(target);
+ } else if (entry.isDirectory()) {
+ removeDir(target);
+ } else {
+ fs.unlinkSync(target);
+ }
+ }
+ fs.rmdirSync(dir);
+}
+
+function extractTarball(tarballPath, destDir) {
+ fs.mkdirSync(destDir, { recursive: true });
+ const result = spawnSync('tar', ['-xzf', tarballPath, '-C', destDir], {
+ stdio: 'pipe',
+ encoding: 'utf8',
+ });
+ if (result.status !== 0) {
+ throw new Error(result.stderr?.trim() || result.stdout?.trim() || 'Failed to extract theme tarball');
+ }
+ const packageDir = path.join(destDir, 'package');
+ if (fs.existsSync(packageDir) && fs.statSync(packageDir).isDirectory()) {
+ return packageDir;
+ }
+ const entries = fs.readdirSync(destDir, { withFileTypes: true }).filter((d) => d.isDirectory());
+ if (entries.length === 1) {
+ return path.join(destDir, entries[0].name);
+ }
+ if (isThemePackageDir(destDir)) {
+ return destDir;
+ }
+ throw new Error('Theme package root not found after extracting tarball');
+}
+
+function npmPack(spec, destDir) {
+ fs.mkdirSync(destDir, { recursive: true });
+ const result = spawnSync('npm', ['pack', spec, '--pack-destination', destDir], {
+ stdio: 'pipe',
+ encoding: 'utf8',
+ shell: process.platform === 'win32',
+ });
+ if (result.status !== 0) {
+ const message = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
+ throw new Error(message || `npm pack failed for "${spec}"`);
+ }
+ const files = fs
+ .readdirSync(destDir)
+ .filter((name) => name.endsWith('.tgz') || name.endsWith('.tar.gz'))
+ .map((name) => path.join(destDir, name))
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
+ if (!files.length) {
+ throw new Error(`npm pack produced no tarball for "${spec}"`);
+ }
+ return files[0];
+}
+
+function ensureRuntimeThemeTsconfigBase(projectRoot, runtimeDir) {
+ const baseSrc = path.join(path.resolve(projectRoot), 'tsconfig.base.json');
+ if (!fs.existsSync(baseSrc)) return;
+ const runtimeBase = path.join(runtimeDir, 'tsconfig.base.json');
+ fs.mkdirSync(runtimeDir, { recursive: true });
+ fs.copyFileSync(baseSrc, runtimeBase);
+}
+
+function readThemePackageManager(themeDir) {
+ const pkgPath = path.join(themeDir, 'package.json');
+ if (!fs.existsSync(pkgPath)) return null;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ return typeof pkg.packageManager === 'string' ? pkg.packageManager : null;
+ } catch {
+ return null;
+ }
+}
+
+function themePrefersPnpm(themeDir) {
+ if (fs.existsSync(path.join(themeDir, 'pnpm-lock.yaml'))) return true;
+ const pm = readThemePackageManager(themeDir);
+ return typeof pm === 'string' && pm.startsWith('pnpm@');
+}
+
+function installThemeDependencies(themeDir, projectRoot) {
+ const envOverrides = buildThemeEnvOverrides(projectRoot);
+ const installEnv = {
+ ...process.env,
+ ...envOverrides,
+ npm_config_ignore_scripts: 'false',
+ };
+
+ const usePnpm = themePrefersPnpm(themeDir);
+ let result;
+
+ if (usePnpm) {
+ result = spawnSync('pnpm', ['install', '--ignore-workspace'], {
+ cwd: themeDir,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ env: installEnv,
+ });
+ } else {
+ result = spawnSync('npm', ['install', '--legacy-peer-deps'], {
+ cwd: themeDir,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ env: installEnv,
+ });
+ }
+
+ if (result.status !== 0 && usePnpm) {
+ result = spawnSync('npm', ['install', '--legacy-peer-deps'], {
+ cwd: themeDir,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ env: installEnv,
+ });
+ }
+
+ if (result.status !== 0 && !usePnpm) {
+ result = spawnSync('npm', ['install', '--ignore-scripts', '--legacy-peer-deps'], {
+ cwd: themeDir,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ env: installEnv,
+ });
+ }
+
+ if (result.status !== 0) {
+ throw new Error(`${usePnpm ? 'pnpm' : 'npm'} install failed in theme directory`);
+ }
+
+ syncThemeEnvFromProject(projectRoot, themeDir);
+}
+
+function readPackageMeta(packageDir) {
+ const pkgPath = path.join(packageDir, 'package.json');
+ if (!fs.existsSync(pkgPath)) return { name: undefined, version: undefined };
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
+ return {
+ name: typeof pkg.name === 'string' ? pkg.name : undefined,
+ version: typeof pkg.version === 'string' ? pkg.version : undefined,
+ };
+ } catch {
+ return { name: undefined, version: undefined };
+ }
+}
+
+/**
+ * Install a theme from an npm spec or local .tgz into `.reactpress/runtime/{id}/`.
+ * @param {string} projectRoot
+ * @param {string} spec npm package spec or path to .tgz
+ * @param {{ skipDependencies?: boolean }} [options]
+ */
+async function installThemeFromNpm(projectRoot, spec, options = {}) {
+ const root = path.resolve(projectRoot);
+ const resolvedSpec = resolveCatalogInstallSpec(root, spec) || spec;
+ const parsed = parseNpmSpec(resolvedSpec);
+ if (parsed.error === 'EMPTY_SPEC') {
+ throw new Error('Theme npm spec is required');
+ }
+ if (parsed.error === 'TARBALL_NOT_FOUND') {
+ throw new Error(`Theme tarball not found: ${parsed.path}`);
+ }
+
+ const tmpRoot = path.join(root, '.reactpress', 'tmp', `theme-npm-${crypto.randomBytes(4).toString('hex')}`);
+ fs.mkdirSync(tmpRoot, { recursive: true });
+
+ try {
+ const tarballPath =
+ parsed.kind === 'tarball' ? parsed.path : npmPack(parsed.spec, tmpRoot);
+ const extractDir = path.join(tmpRoot, 'extract');
+ const packageDir = extractTarball(tarballPath, extractDir);
+
+ if (!isThemePackageDir(packageDir)) {
+ throw new Error('Package is not a ReactPress theme (missing theme.json or package.json)');
+ }
+
+ const identity = resolveThemeIdentity(packageDir);
+ if (!identity?.themeId) {
+ throw new Error('Could not resolve theme id from theme.json or package.json');
+ }
+
+ const { themeId, manifest } = identity;
+ const runtimeRoot = path.join(root, THEME_RUNTIME_REL);
+ const targetDir = path.join(runtimeRoot, themeId);
+ const pkgMeta = readPackageMeta(packageDir);
+
+ if (fs.existsSync(targetDir)) {
+ removeDir(targetDir);
+ }
+ fs.mkdirSync(runtimeRoot, { recursive: true });
+ copyDir(packageDir, targetDir);
+ ensureRuntimeThemeTsconfigBase(root, runtimeRoot);
+
+ if (!options.skipDependencies) {
+ installThemeDependencies(targetDir, root);
+ } else {
+ syncThemeEnvFromProject(root, targetDir);
+ }
+ ensurePreviewFrameAllowed(targetDir);
+
+ const npmSpec = parsed.kind === 'npm' ? parsed.spec : resolvedSpec;
+ upsertNpmThemeLock(root, themeId, {
+ spec: npmSpec,
+ resolvedVersion: pkgMeta.version || manifest.version || '0.0.0',
+ packageName: pkgMeta.name,
+ });
+
+ return {
+ themeId,
+ name: manifest.name,
+ version: manifest.version || pkgMeta.version || '0.0.0',
+ packageName: pkgMeta.name,
+ npmSpec,
+ themeDir: targetDir,
+ themeDirRel: path.relative(root, targetDir),
+ };
+ } finally {
+ removeDir(tmpRoot);
+ }
+}
+
+module.exports = {
+ THEME_RUNTIME_REL,
+ parseNpmSpec,
+ resolveThemeIdentity,
+ installThemeFromNpm,
+ installThemeDependencies,
+ isValidThemeId,
+};
diff --git a/cli/src/lib/theme-lock.ts b/cli/src/lib/theme-lock.ts
new file mode 100644
index 00000000..cae14928
--- /dev/null
+++ b/cli/src/lib/theme-lock.ts
@@ -0,0 +1,72 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const LOCK_REL = path.join('.reactpress', 'themes.lock.json');
+const LOCK_VERSION = 1;
+
+function lockPath(projectRoot) {
+ return path.join(path.resolve(projectRoot), LOCK_REL);
+}
+
+function readThemeLock(projectRoot) {
+ const file = lockPath(projectRoot);
+ if (!fs.existsSync(file)) {
+ return { version: LOCK_VERSION, themes: {} };
+ }
+ try {
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (!raw || typeof raw !== 'object') {
+ return { version: LOCK_VERSION, themes: {} };
+ }
+ return {
+ version: typeof raw.version === 'number' ? raw.version : LOCK_VERSION,
+ themes: raw.themes && typeof raw.themes === 'object' ? raw.themes : {},
+ };
+ } catch {
+ return { version: LOCK_VERSION, themes: {} };
+ }
+}
+
+function writeThemeLock(projectRoot, lock) {
+ const file = lockPath(projectRoot);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`, 'utf8');
+}
+
+function upsertNpmThemeLock(projectRoot, themeId, entry) {
+ const lock = readThemeLock(projectRoot);
+ lock.themes[themeId] = {
+ source: 'npm',
+ spec: entry.spec,
+ resolvedVersion: entry.resolvedVersion,
+ packageName: entry.packageName,
+ installedAt: entry.installedAt || new Date().toISOString(),
+ };
+ writeThemeLock(projectRoot, lock);
+ return lock.themes[themeId];
+}
+
+function getNpmThemeLockEntry(projectRoot, themeId) {
+ const lock = readThemeLock(projectRoot);
+ const entry = lock.themes[themeId];
+ if (!entry || entry.source !== 'npm') return null;
+ return entry;
+}
+
+function removeThemeLockEntry(projectRoot, themeId) {
+ const lock = readThemeLock(projectRoot);
+ if (!lock.themes[themeId]) return false;
+ delete lock.themes[themeId];
+ writeThemeLock(projectRoot, lock);
+ return true;
+}
+
+module.exports = {
+ LOCK_REL,
+ readThemeLock,
+ writeThemeLock,
+ upsertNpmThemeLock,
+ getNpmThemeLockEntry,
+ removeThemeLockEntry,
+};
diff --git a/cli/src/lib/theme-paths.ts b/cli/src/lib/theme-paths.ts
new file mode 100644
index 00000000..c56bb9ea
--- /dev/null
+++ b/cli/src/lib/theme-paths.ts
@@ -0,0 +1,82 @@
+// @ts-nocheck
+const path = require('path');
+
+/** Shared path and id constants for theme runtime, registry, and server bridge. */
+const REACTPRESS_DIR = '.reactpress';
+const THEMES_DIR = 'themes';
+
+const THEME_RUNTIME_REL = path.join(REACTPRESS_DIR, 'runtime');
+const LEGACY_THEMES_RUNTIME_REL = path.join(THEMES_DIR, 'runtime');
+const ACTIVE_THEME_MANIFEST_REL = path.join(REACTPRESS_DIR, 'active-theme.json');
+const PREVIEW_THEME_MANIFEST_REL = path.join(REACTPRESS_DIR, 'preview-theme.json');
+const PREVIEW_POOL_MANIFEST_REL = path.join(REACTPRESS_DIR, 'preview-pool.json');
+const THEME_LOCK_REL = path.join(REACTPRESS_DIR, 'themes.lock.json');
+
+const THEMES_PACKAGE_REL = path.join(THEMES_DIR, 'package.json');
+const THEMES_CATALOG_REL = path.join(THEMES_DIR, 'catalog.json');
+const CLI_CATALOG_TEMPLATE_REL = path.join('cli', 'templates', 'theme-catalog.json');
+
+/** Reserved under `themes/` — not bundled templates or theme source trees. */
+const THEMES_RESERVED_SUBDIRS = ['starter', 'bundled', 'core', 'theme-starter'];
+const THEMES_LEGACY_STARTER_SUBDIRS = ['starter', 'bundled', 'core'];
+
+const THEME_ID_RE = /^[a-z0-9][a-z0-9-]*$/i;
+const DEFAULT_ACTIVE_THEME = 'hello-world';
+const PREVIEW_PROXY_PORT = 3003;
+const PREVIEW_BACKEND_BASE = 3004;
+const DEFAULT_PREVIEW_POOL_MAX = 3;
+
+function getPreviewPoolMaxSize() {
+ const parsed = parseInt(process.env.REACTPRESS_PREVIEW_POOL_MAX || '', 10);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_PREVIEW_POOL_MAX;
+}
+
+function getPreviewProxyPort() {
+ const parsed = parseInt(process.env.REACTPRESS_PREVIEW_PORT || '', 10);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : PREVIEW_PROXY_PORT;
+}
+
+function getPreviewBackendPorts() {
+ const max = getPreviewPoolMaxSize();
+ const parsed = parseInt(process.env.REACTPRESS_PREVIEW_BACKEND_BASE || '', 10);
+ const base = Number.isInteger(parsed) && parsed > 0 ? parsed : PREVIEW_BACKEND_BASE;
+ return Array.from({ length: max }, (_, index) => base + index);
+}
+
+/** Public preview URL port (proxy). Backend slots use getPreviewBackendPorts(). */
+const PREVIEW_POOL_PORTS = [PREVIEW_PROXY_PORT];
+
+function themesRoot(projectRoot) {
+ return path.join(path.resolve(projectRoot), THEMES_DIR);
+}
+
+function runtimeRoot(projectRoot) {
+ return path.join(path.resolve(projectRoot), THEME_RUNTIME_REL);
+}
+
+module.exports = {
+ REACTPRESS_DIR,
+ THEMES_DIR,
+ THEME_RUNTIME_REL,
+ LEGACY_THEMES_RUNTIME_REL,
+ ACTIVE_THEME_MANIFEST_REL,
+ PREVIEW_THEME_MANIFEST_REL,
+ PREVIEW_POOL_MANIFEST_REL,
+ THEME_LOCK_REL,
+ THEMES_PACKAGE_REL,
+ THEMES_CATALOG_REL,
+ CLI_CATALOG_TEMPLATE_REL,
+ THEMES_RESERVED_SUBDIRS,
+ THEMES_LEGACY_STARTER_SUBDIRS,
+ THEME_ID_RE,
+ DEFAULT_ACTIVE_THEME,
+ PREVIEW_PROXY_PORT,
+ PREVIEW_BACKEND_BASE,
+ DEFAULT_PREVIEW_POOL_MAX,
+ PREVIEW_POOL_PORTS,
+ getPreviewPoolMaxSize,
+ getPreviewProxyPort,
+ getPreviewBackendPorts,
+ themesRoot,
+ runtimeRoot,
+};
diff --git a/cli/src/lib/theme-placeholder-cover.ts b/cli/src/lib/theme-placeholder-cover.ts
new file mode 100644
index 00000000..96ed0c41
--- /dev/null
+++ b/cli/src/lib/theme-placeholder-cover.ts
@@ -0,0 +1,102 @@
+/**
+ * SVG placeholder cover for themes without a cover image file.
+ * Used by the extension API and web dev mocks.
+ */
+
+export type ThemePlaceholderCoverOptions = {
+ id: string;
+ name: string;
+ primary?: string;
+ accent?: string;
+ version?: string;
+};
+
+function escapeXml(text: string): string {
+ return String(text).replace(/[<>&"']/g, (ch) => {
+ const map: Record = {
+ "<": "<",
+ ">": ">",
+ "&": "&",
+ '"': """,
+ "'": "'",
+ };
+ return map[ch] ?? ch;
+ });
+}
+
+function sanitizeColor(color: string | undefined, fallback: string): string {
+ if (!color) return fallback;
+ const trimmed = String(color).trim();
+ if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
+ return fallback;
+}
+
+function safeSvgId(id: string): string {
+ return String(id).replace(/[^a-zA-Z0-9_-]/g, "") || "theme";
+}
+
+export function buildThemePlaceholderCoverSvg(options: ThemePlaceholderCoverOptions): string {
+ const svgId = safeSvgId(options.id);
+ const primary = sanitizeColor(options.primary, "#2563eb");
+ const accent = sanitizeColor(options.accent, "#7c3aed");
+ const rawName = String(options.name ?? options.id ?? "Theme");
+ const safeName = escapeXml(rawName.length > 48 ? `${rawName.slice(0, 45)}…` : rawName);
+ const version = options.version ? escapeXml(String(options.version)) : "";
+ const versionBadge = version
+ ? `
+ v${version} `
+ : "";
+
+ return ``;
+}
diff --git a/cli/src/lib/theme-preview-frame.ts b/cli/src/lib/theme-preview-frame.ts
new file mode 100644
index 00000000..53c0b470
--- /dev/null
+++ b/cli/src/lib/theme-preview-frame.ts
@@ -0,0 +1,113 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const PATCH_MARKER = '.reactpress-preview-frame-patched';
+const X_FRAME_OPTIONS_RE =
+ /\{\s*key:\s*['"]X-Frame-Options['"],\s*value:\s*['"]SAMEORIGIN['"]\s*\},?\s*\n?/g;
+const X_FRAME_OPTIONS_HEADER_KEY = 'X-Frame-Options';
+
+/** Admin iframe loads theme on another port — skip X-Frame-Options in local/desktop dev. */
+function shouldHonorThemePreviewFrame() {
+ if (process.env.REACTPRESS_HONOR_PREVIEW === '1') return true;
+ if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') return true;
+ if (process.env.REACTPRESS_DESKTOP_SITE_ROOT?.trim()) return true;
+ return false;
+}
+
+/**
+ * Next.js bakes `headers()` into routes-manifest.json at build time.
+ * Strip X-Frame-Options so admin iframes work without a full rebuild.
+ */
+function stripBakedFrameOptionsFromBuild(themeDir, distDir = '.next') {
+ if (!themeDir || !distDir) return false;
+
+ const manifestPath = path.join(themeDir, distDir, 'routes-manifest.json');
+ if (!fs.existsSync(manifestPath)) return false;
+
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ } catch {
+ return false;
+ }
+
+ if (!Array.isArray(manifest.headers)) return false;
+
+ let changed = false;
+ for (const entry of manifest.headers) {
+ if (!entry || !Array.isArray(entry.headers)) continue;
+ const nextHeaders = entry.headers.filter(
+ (header) => header?.key !== X_FRAME_OPTIONS_HEADER_KEY,
+ );
+ if (nextHeaders.length !== entry.headers.length) {
+ entry.headers = nextHeaders;
+ changed = true;
+ }
+ }
+
+ if (!changed) return false;
+
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
+ return true;
+}
+
+/** Patch next.config and strip baked headers when admin preview needs iframe embedding. */
+function ensureBuildAllowsPreviewFrame(themeDir, distDir = '.next') {
+ if (!shouldHonorThemePreviewFrame()) return false;
+ ensurePreviewFrameAllowed(themeDir);
+ return stripBakedFrameOptionsFromBuild(themeDir, distDir);
+}
+
+const X_FRAME_OPTIONS_PATCH = `...(process.env.REACTPRESS_HONOR_PREVIEW === '1'
+ ? []
+ : [{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }]),
+ `;
+
+/**
+ * Admin preview iframes load :3003 from a different origin than /admin/.
+ * Drop X-Frame-Options for preview dev only (REACTPRESS_HONOR_PREVIEW=1).
+ */
+function ensurePreviewFrameAllowed(themeDir) {
+ if (!themeDir || !fs.existsSync(themeDir)) return false;
+
+ const markerPath = path.join(themeDir, PATCH_MARKER);
+ const configPath = path.join(themeDir, 'next.config.js');
+ const configMjsPath = path.join(themeDir, 'next.config.mjs');
+
+ const target = fs.existsSync(configPath)
+ ? configPath
+ : fs.existsSync(configMjsPath)
+ ? configMjsPath
+ : null;
+
+ if (!target) return false;
+ if (fs.existsSync(markerPath)) return true;
+
+ let src = fs.readFileSync(target, 'utf8');
+ if (!X_FRAME_OPTIONS_RE.test(src)) {
+ fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
+ return false;
+ }
+
+ X_FRAME_OPTIONS_RE.lastIndex = 0;
+ if (src.includes('REACTPRESS_HONOR_PREVIEW')) {
+ fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
+ return true;
+ }
+
+ const next = src.replace(X_FRAME_OPTIONS_RE, X_FRAME_OPTIONS_PATCH);
+ if (next === src) return false;
+
+ fs.writeFileSync(target, next, 'utf8');
+ fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
+ return true;
+}
+
+module.exports = {
+ PATCH_MARKER,
+ shouldHonorThemePreviewFrame,
+ stripBakedFrameOptionsFromBuild,
+ ensureBuildAllowsPreviewFrame,
+ ensurePreviewFrameAllowed,
+};
diff --git a/cli/src/lib/theme-preview-pool.ts b/cli/src/lib/theme-preview-pool.ts
new file mode 100644
index 00000000..7ba6e32a
--- /dev/null
+++ b/cli/src/lib/theme-preview-pool.ts
@@ -0,0 +1,570 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { spawnDevChild } = require('./dev-child-io');
+const { loadClientSiteUrl, normalizeProbeUrl, probeHttp, waitForHttpOk } = require('./http');
+const { isPortListening, killPortListeners } = require('./ports');
+const {
+ resolveThemeDirectory,
+ isThemePackageDir,
+ themeWorkspaceRoot,
+} = require('./theme-runtime');
+const {
+ getPreviewBackendPorts,
+ getPreviewPoolMaxSize,
+ getPreviewProxyPort,
+ PREVIEW_POOL_PORTS,
+} = require('./theme-paths');
+const {
+ enqueueThemeBuild,
+ resolvePreviewThemeEnv,
+ ensureThemeDependenciesInstalled,
+ PREVIEW_DIST_DIR,
+} = require('./theme-prod');
+const { warmupThemeHomepage } = require('./theme-warmup');
+const {
+ ensurePreviewFrameAllowed,
+ ensureBuildAllowsPreviewFrame,
+ shouldHonorThemePreviewFrame,
+} = require('./theme-preview-frame');
+const {
+ ensurePreviewProxyRunning,
+ setPreviewProxyTarget,
+ stopPreviewProxy,
+} = require('./theme-preview-proxy');
+const { t } = require('./i18n');
+
+const PREVIEW_POOL_MANIFEST = path.join('.reactpress', 'preview-pool.json');
+const PREVIEW_READY_POLL_MS = 100;
+const PREVIEW_READY_TIMEOUT_MS =
+ process.env.REACTPRESS_DESKTOP_LOCAL === '1' ? 15_000 : 120_000;
+const PREVIEW_PORT_RELEASE_PAUSE_MS =
+ process.env.REACTPRESS_DESKTOP_LOCAL === '1' ? 120 : 400;
+
+/** @type {Map} */
+const previewPool = new Map();
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getPreviewPoolManifestPath(projectRoot) {
+ return path.join(themeWorkspaceRoot(projectRoot), PREVIEW_POOL_MANIFEST);
+}
+
+function getPreviewSiteUrlForPort(projectRoot, port) {
+ try {
+ const url = new URL(loadClientSiteUrl(projectRoot));
+ url.port = String(port);
+ return `${url.origin}/`;
+ } catch {
+ return `http://127.0.0.1:${port}/`;
+ }
+}
+
+function getPreviewPublicUrl(projectRoot) {
+ return getPreviewSiteUrlForPort(projectRoot, getPreviewProxyPort());
+}
+
+function readPreviewPoolManifest(projectRoot) {
+ const manifestPath = getPreviewPoolManifestPath(projectRoot);
+ if (!fs.existsSync(manifestPath)) return {};
+ try {
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ return raw && typeof raw === 'object' ? raw : {};
+ } catch {
+ return {};
+ }
+}
+
+function writePreviewPoolManifest(projectRoot) {
+ const manifestPath = getPreviewPoolManifestPath(projectRoot);
+ const proxyPort = getPreviewProxyPort();
+ const next = {};
+ for (const [themeId, entry] of previewPool) {
+ if (!entry?.backendPort) continue;
+ next[themeId] = {
+ port: String(proxyPort),
+ backendPort: String(entry.backendPort),
+ url: getPreviewSiteUrlForPort(projectRoot, proxyPort),
+ updatedAt: new Date(entry.lastUsed || Date.now()).toISOString(),
+ };
+ }
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
+ fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
+}
+
+async function isBackendReady(projectRoot, backendPort) {
+ const url = `${getPreviewSiteUrlForPort(projectRoot, backendPort).replace(/\/$/, '')}/`;
+ const result = await probeHttp(normalizeProbeUrl(url), 1200);
+ return result.ok;
+}
+
+async function isPreviewHomepageReady(projectRoot, port) {
+ const url = `${getPreviewSiteUrlForPort(projectRoot, port).replace(/\/$/, '')}/`;
+ const result = await probeHttp(normalizeProbeUrl(url), 1200);
+ return result.ok;
+}
+
+function stopPreviewPoolChild(entry) {
+ const child = entry?.child;
+ if (!child || child.killed) {
+ if (entry) entry.child = null;
+ return;
+ }
+ const pid = child.pid;
+ try {
+ if (process.platform !== 'win32' && pid) {
+ try {
+ process.kill(-pid, 'SIGTERM');
+ } catch {
+ child.kill('SIGTERM');
+ }
+ } else if (pid) {
+ child.kill('SIGTERM');
+ }
+ } catch {
+ // ignore
+ }
+ entry.child = null;
+}
+
+function stopPreviewPoolTheme(themeId) {
+ const entry = previewPool.get(themeId);
+ if (!entry) return;
+ stopPreviewPoolChild(entry);
+ previewPool.delete(themeId);
+}
+
+async function stopAllPreviewPool(projectRoot) {
+ for (const themeId of [...previewPool.keys()]) {
+ stopPreviewPoolTheme(themeId);
+ }
+ await stopPreviewProxy();
+ const manifestPath = getPreviewPoolManifestPath(projectRoot);
+ if (fs.existsSync(manifestPath)) {
+ fs.unlinkSync(manifestPath);
+ }
+}
+
+async function releasePreviewPort(port) {
+ if (!isPortListening(port)) return true;
+ killPortListeners(port, 'TERM');
+ await sleep(PREVIEW_PORT_RELEASE_PAUSE_MS);
+ if (!isPortListening(port)) return true;
+ killPortListeners(port, 'KILL');
+ await sleep(PREVIEW_PORT_RELEASE_PAUSE_MS);
+ return !isPortListening(port);
+}
+
+/** Serialize preview pool mutations (desktop + CLI). */
+let previewPortLock = Promise.resolve();
+
+function withPreviewPortLock(fn) {
+ const run = previewPortLock.then(() => fn());
+ previewPortLock = run.catch(() => {});
+ return run;
+}
+
+function allocateBackendPort(themeId) {
+ const existing = previewPool.get(themeId);
+ if (existing?.backendPort) {
+ return existing.backendPort;
+ }
+
+ const backendPorts = getPreviewBackendPorts();
+ const usedPorts = new Set(
+ [...previewPool.values()]
+ .map((entry) => entry.backendPort)
+ .filter((port) => Number.isInteger(port)),
+ );
+
+ for (const port of backendPorts) {
+ if (!usedPorts.has(port)) return port;
+ }
+
+ let oldestId = null;
+ let oldestAt = Infinity;
+ for (const [id, entry] of previewPool) {
+ if (id === themeId) continue;
+ const ts = entry.lastUsed || 0;
+ if (ts < oldestAt) {
+ oldestAt = ts;
+ oldestId = id;
+ }
+ }
+
+ if (oldestId) {
+ const evicted = previewPool.get(oldestId);
+ const port = evicted?.backendPort ?? backendPorts[0];
+ stopPreviewPoolTheme(oldestId);
+ return port;
+ }
+
+ return backendPorts[0];
+}
+
+function isChildAlive(child) {
+ return Boolean(child && !child.killed && child.exitCode == null && child.signalCode == null);
+}
+
+function buildPreviewResult(projectRoot, themeId, backendPort, reused) {
+ const proxyPort = getPreviewProxyPort();
+ return {
+ themeId,
+ port: proxyPort,
+ backendPort,
+ url: getPreviewPublicUrl(projectRoot),
+ reused,
+ };
+}
+
+async function activateWarmPreviewEntry(projectRoot, themeId, entry) {
+ setPreviewProxyTarget(entry.backendPort);
+ entry.lastUsed = Date.now();
+ writePreviewPoolManifest(projectRoot);
+ const proxyPort = getPreviewProxyPort();
+ const ready = await isPreviewHomepageReady(projectRoot, proxyPort);
+ if (!ready) {
+ const homepageUrl = `${getPreviewPublicUrl(projectRoot).replace(/\/$/, '')}/`;
+ await waitForHttpOk(homepageUrl, PREVIEW_READY_TIMEOUT_MS, PREVIEW_READY_POLL_MS);
+ }
+ return buildPreviewResult(projectRoot, themeId, entry.backendPort, true);
+}
+
+/**
+ * Spawn a theme server child for desktop visitor/preview roles.
+ * Caller owns lifecycle logging and process tracking.
+ */
+function spawnThemeProcess(projectRoot, options) {
+ const { spawn } = require('child_process');
+ const {
+ themeDir,
+ themeId,
+ port,
+ serverApiUrl,
+ publicApiUrl,
+ launch,
+ role = 'visitor',
+ extraEnv = {},
+ } = options;
+ const distDir = role === 'preview' ? PREVIEW_DIST_DIR : '.next';
+ const { cmd, args } = launch;
+
+ if (launch.mode === 'production' && shouldHonorThemePreviewFrame()) {
+ ensureBuildAllowsPreviewFrame(themeDir, distDir);
+ }
+
+ return spawn(cmd, args, {
+ cwd: themeDir,
+ detached: process.platform !== 'win32',
+ shell: process.platform === 'win32',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...resolvePreviewThemeEnv(projectRoot, themeDir, port, {
+ mode: launch.mode,
+ distDir,
+ }),
+ SERVER_API_URL: serverApiUrl,
+ REACTPRESS_API_URL: serverApiUrl,
+ NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl,
+ REACTPRESS_THEME_ID: themeId,
+ REACTPRESS_HONOR_PREVIEW: role === 'preview' || shouldHonorThemePreviewFrame() ? '1' : '0',
+ REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1',
+ REACTPRESS_SKIP_BROWSER_OPEN: '1',
+ REACTPRESS_DESKTOP_THEME_ROLE: role,
+ ...extraEnv,
+ },
+ });
+}
+
+function themeHasCustomServer(themeDir) {
+ return fs.existsSync(path.join(themeDir, 'server.js'));
+}
+
+function themeHasDevScript(themeDir) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8'));
+ return typeof pkg.scripts?.dev === 'string';
+ } catch {
+ return false;
+ }
+}
+
+function themeUsesAppRouter(themeDir) {
+ return fs.existsSync(path.join(themeDir, 'app'));
+}
+
+function isThemeOnlyDevMode() {
+ return process.env.REACTPRESS_THEME_DEV_ONLY === '1';
+}
+
+function isIntegratedDesktopDev() {
+ if (isThemeOnlyDevMode()) return false;
+ if (process.env.REACTPRESS_DESKTOP_LOCAL === '1') return true;
+ if (process.env.REACTPRESS_DESKTOP_SITE_ROOT?.trim()) return true;
+ return false;
+}
+
+function shouldPreferProductionLaunch(themeDir) {
+ if (isThemeOnlyDevMode()) return false;
+ if (themeUsesAppRouter(themeDir)) return true;
+ if (isIntegratedDesktopDev() && themeHasCustomServer(themeDir)) return true;
+ return false;
+}
+
+/** Resolve Next CLI bin — theme-local, NODE_PATH, or packaged Resources/runtime-deps. */
+function resolveThemeNextBin(themeDir) {
+ const rel = path.join('next', 'dist', 'bin', 'next');
+ const local = path.join(themeDir, 'node_modules', rel);
+ if (fs.existsSync(local)) return local;
+
+ const nodePath = process.env.NODE_PATH?.split(path.delimiter).filter(Boolean) ?? [];
+ for (const dir of nodePath) {
+ const candidate = path.join(dir, rel);
+ if (fs.existsSync(candidate)) return candidate;
+ }
+
+ const monorepoRoot = process.env.REACTPRESS_MONOREPO_ROOT?.trim();
+ if (monorepoRoot) {
+ const bundled = [
+ path.join(monorepoRoot, 'runtime-deps', 'node_modules', rel),
+ path.join(monorepoRoot, 'node_modules', rel),
+ ];
+ for (const candidate of bundled) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ }
+
+ try {
+ return require.resolve('next/dist/bin/next', { paths: [themeDir, ...nodePath] });
+ } catch {
+ return null;
+ }
+}
+
+function resolvePreviewThemeLaunchPlan(themeDir, port, options = {}) {
+ const preferProduction =
+ options.preferProduction ?? shouldPreferProductionLaunch(themeDir);
+
+ if (!preferProduction && themeHasDevScript(themeDir)) {
+ return { mode: 'dev', cmd: 'pnpm', args: ['run', 'dev', '--', '--port', String(port)] };
+ }
+
+ const nextBin = resolveThemeNextBin(themeDir);
+ // Prefer `next start -p` — hello-world server.js uses Next CLI internals that ignore `-p` on Next 15.
+ if (preferProduction && nextBin) {
+ return { mode: 'production', cmd: process.execPath, args: [nextBin, 'start', '-p', String(port)] };
+ }
+
+ if (themeHasCustomServer(themeDir)) {
+ return { mode: 'production', cmd: 'node', args: ['server.js'] };
+ }
+
+ if (nextBin) {
+ return { mode: 'production', cmd: process.execPath, args: [nextBin, 'start', '-p', String(port)] };
+ }
+
+ return { mode: 'production', cmd: 'pnpm', args: ['run', 'start'] };
+}
+
+/** @type {Map>} */
+const previewEnsureInflight = new Map();
+
+async function ensurePreviewThemeRunning(
+ projectRoot,
+ themeId,
+ { serverApiUrl, publicApiUrl, spawnOptions = {} } = {},
+) {
+ const inflight = previewEnsureInflight.get(themeId);
+ if (inflight) return inflight;
+
+ const job = startPreviewThemeRunning(projectRoot, themeId, {
+ serverApiUrl,
+ publicApiUrl,
+ spawnOptions,
+ }).finally(() => {
+ if (previewEnsureInflight.get(themeId) === job) {
+ previewEnsureInflight.delete(themeId);
+ }
+ });
+ previewEnsureInflight.set(themeId, job);
+ return job;
+}
+
+async function startPreviewThemeRunning(
+ projectRoot,
+ themeId,
+ { serverApiUrl, publicApiUrl, spawnOptions = {} } = {},
+) {
+ let themeDir = resolveThemeDirectory(projectRoot, themeId);
+ if (spawnOptions.resolveThemeDir) {
+ themeDir = spawnOptions.resolveThemeDir(projectRoot, themeId) || themeDir;
+ }
+ if (!themeDir || !isThemePackageDir(projectRoot, themeDir)) {
+ return null;
+ }
+
+ await ensurePreviewProxyRunning(getPreviewProxyPort());
+
+ const pooled = previewPool.get(themeId);
+ if (pooled && isChildAlive(pooled.child)) {
+ const backendReady = await isBackendReady(projectRoot, pooled.backendPort);
+ if (backendReady) {
+ return activateWarmPreviewEntry(projectRoot, themeId, pooled);
+ }
+ stopPreviewPoolChild(pooled);
+ }
+
+ const backendPort = allocateBackendPort(themeId);
+ await releasePreviewPort(backendPort);
+
+ try {
+ ensureThemeDependenciesInstalled(projectRoot, themeDir, themeId, 'themePreview');
+ ensurePreviewFrameAllowed(themeDir);
+ } catch (err) {
+ console.warn(
+ `[reactpress] ${t('themePreview.buildFailed', {
+ id: themeId,
+ message: err.message || err,
+ })}`,
+ );
+ return null;
+ }
+
+ let launch = resolvePreviewThemeLaunchPlan(themeDir, backendPort);
+ if (typeof spawnOptions.normalizeLaunch === 'function') {
+ launch = spawnOptions.normalizeLaunch(launch, {
+ themeDir,
+ port: backendPort,
+ projectRoot,
+ themeId,
+ });
+ }
+
+ if (launch.mode === 'production') {
+ try {
+ await enqueueThemeBuild(projectRoot, themeId, {
+ logPrefix: 'themePreview',
+ distDir: PREVIEW_DIST_DIR,
+ });
+ ensureBuildAllowsPreviewFrame(themeDir, PREVIEW_DIST_DIR);
+ } catch (err) {
+ console.warn(
+ `[reactpress] ${t('themePreview.buildFailed', {
+ id: themeId,
+ message: err.message || err,
+ })}`,
+ );
+ return null;
+ }
+ }
+
+ const relDir = path.relative(projectRoot, themeDir) || themeDir;
+ const modeLabel = launch.mode === 'dev' ? 'dev' : 'production';
+ console.log(
+ `[reactpress] ${t('themePreview.starting', {
+ id: themeId,
+ url: getPreviewPublicUrl(projectRoot),
+ port: backendPort,
+ dir: relDir,
+ mode: modeLabel,
+ })}`,
+ );
+
+ const { cmd, args } = launch;
+ const child = spawnOptions.useThemeProcessSpawn
+ ? spawnThemeProcess(projectRoot, {
+ themeDir,
+ themeId,
+ port: backendPort,
+ serverApiUrl,
+ publicApiUrl,
+ launch,
+ role: 'preview',
+ extraEnv: spawnOptions.extraEnv || {},
+ })
+ : spawnDevChild(cmd, args, {
+ cwd: themeDir,
+ detached: process.platform !== 'win32',
+ shell: process.platform === 'win32',
+ env: {
+ ...resolvePreviewThemeEnv(projectRoot, themeDir, backendPort, {
+ mode: launch.mode,
+ distDir: PREVIEW_DIST_DIR,
+ }),
+ SERVER_API_URL: serverApiUrl,
+ REACTPRESS_API_URL: serverApiUrl,
+ NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl,
+ REACTPRESS_THEME_ID: themeId,
+ REACTPRESS_HONOR_PREVIEW: '1',
+ REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1',
+ REACTPRESS_SKIP_BROWSER_OPEN: '1',
+ ...(spawnOptions.extraEnv || {}),
+ },
+ });
+
+ previewPool.set(themeId, { child, backendPort, lastUsed: Date.now() });
+ writePreviewPoolManifest(projectRoot);
+
+ child.on('exit', () => {
+ const current = previewPool.get(themeId);
+ if (current?.child === child) {
+ previewPool.delete(themeId);
+ writePreviewPoolManifest(projectRoot);
+ }
+ });
+
+ const backendUrl = `${getPreviewSiteUrlForPort(projectRoot, backendPort).replace(/\/$/, '')}/`;
+ const backendReady = await waitForHttpOk(
+ backendUrl,
+ PREVIEW_READY_TIMEOUT_MS,
+ PREVIEW_READY_POLL_MS,
+ );
+ if (!backendReady) {
+ console.warn(t('themeDev.slow', { url: backendUrl }));
+ }
+
+ setPreviewProxyTarget(backendPort);
+ writePreviewPoolManifest(projectRoot);
+
+ const homepageUrl = `${getPreviewPublicUrl(projectRoot).replace(/\/$/, '')}/`;
+ const proxyReady = await waitForHttpOk(homepageUrl, PREVIEW_READY_TIMEOUT_MS, PREVIEW_READY_POLL_MS);
+ if (proxyReady) {
+ console.log(`[reactpress] ${t('themePreview.ready', { url: homepageUrl, id: themeId })}`);
+ warmupThemeHomepage(projectRoot, homepageUrl).catch(() => {});
+ } else {
+ console.warn(t('themeDev.slow', { url: homepageUrl }));
+ }
+
+ return buildPreviewResult(projectRoot, themeId, backendPort, false);
+}
+
+module.exports = {
+ PREVIEW_POOL_PORTS,
+ PREVIEW_POOL_MANIFEST,
+ previewPool,
+ getPreviewProxyPort,
+ getPreviewBackendPorts,
+ getPreviewPoolMaxSize,
+ getPreviewSiteUrlForPort,
+ getPreviewPublicUrl,
+ readPreviewPoolManifest,
+ writePreviewPoolManifest,
+ ensurePreviewThemeRunning,
+ ensurePreviewProxyRunning,
+ stopPreviewPoolTheme,
+ stopAllPreviewPool,
+ isPreviewHomepageReady,
+ isBackendReady,
+ resolvePreviewThemeLaunchPlan,
+ themeUsesAppRouter,
+ shouldPreferProductionLaunch,
+ isIntegratedDesktopDev,
+ shouldHonorThemePreviewFrame,
+ releasePreviewPort,
+ withPreviewPortLock,
+ spawnThemeProcess,
+ allocateBackendPort,
+ setPreviewProxyTarget,
+};
diff --git a/cli/src/lib/theme-preview-proxy.ts b/cli/src/lib/theme-preview-proxy.ts
new file mode 100644
index 00000000..64609ac3
--- /dev/null
+++ b/cli/src/lib/theme-preview-proxy.ts
@@ -0,0 +1,117 @@
+// @ts-nocheck
+/** Stable :3003 front door — forwards to warm theme backends on :3004+. */
+const http = require('http');
+const { getPreviewProxyPort } = require('./theme-paths');
+
+const PREVIEW_CORS_HEADERS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
+};
+
+let proxyTargetPort = null;
+/** @type {import('http').Server | null} */
+let proxyServer = null;
+let listenPort = null;
+
+function setPreviewProxyTarget(backendPort) {
+ proxyTargetPort = backendPort;
+}
+
+function getPreviewProxyTarget() {
+ return proxyTargetPort;
+}
+
+function proxyRequest(req, res) {
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, PREVIEW_CORS_HEADERS);
+ res.end();
+ return;
+ }
+
+ if (!proxyTargetPort) {
+ res.writeHead(503, {
+ ...PREVIEW_CORS_HEADERS,
+ 'Content-Type': 'text/plain; charset=utf-8',
+ });
+ res.end('Theme preview starting…');
+ return;
+ }
+
+ const headers = {
+ ...req.headers,
+ host: `127.0.0.1:${proxyTargetPort}`,
+ };
+ delete headers.origin;
+ delete headers.referer;
+
+ const proxyReq = http.request(
+ {
+ hostname: '127.0.0.1',
+ port: proxyTargetPort,
+ path: req.url,
+ method: req.method,
+ headers,
+ },
+ (proxyRes) => {
+ const outHeaders = { ...proxyRes.headers, ...PREVIEW_CORS_HEADERS };
+ res.writeHead(proxyRes.statusCode || 502, outHeaders);
+ proxyRes.pipe(res);
+ },
+ );
+
+ proxyReq.on('error', () => {
+ if (!res.headersSent) {
+ res.writeHead(502, {
+ ...PREVIEW_CORS_HEADERS,
+ 'Content-Type': 'text/plain; charset=utf-8',
+ });
+ res.end('Preview backend unavailable');
+ }
+ });
+
+ if (req.method === 'GET' || req.method === 'HEAD') {
+ proxyReq.end();
+ } else {
+ req.pipe(proxyReq);
+ }
+}
+
+function ensurePreviewProxyRunning(port) {
+ const proxyPort = port ?? getPreviewProxyPort();
+ if (proxyServer && listenPort === proxyPort) {
+ return Promise.resolve(proxyServer);
+ }
+
+ return stopPreviewProxy().then(
+ () =>
+ new Promise((resolve, reject) => {
+ const server = http.createServer(proxyRequest);
+ server.on('error', reject);
+ server.listen(proxyPort, '127.0.0.1', () => {
+ proxyServer = server;
+ listenPort = proxyPort;
+ resolve(server);
+ });
+ }),
+ );
+}
+
+function stopPreviewProxy() {
+ proxyTargetPort = null;
+ if (!proxyServer) return Promise.resolve();
+
+ const server = proxyServer;
+ proxyServer = null;
+ listenPort = null;
+ return new Promise((resolve) => {
+ server.close(() => resolve());
+ });
+}
+
+module.exports = {
+ setPreviewProxyTarget,
+ getPreviewProxyTarget,
+ ensurePreviewProxyRunning,
+ stopPreviewProxy,
+};
diff --git a/cli/src/lib/theme-prod.ts b/cli/src/lib/theme-prod.ts
new file mode 100644
index 00000000..82bcc834
--- /dev/null
+++ b/cli/src/lib/theme-prod.ts
@@ -0,0 +1,439 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { runSync, runNodeScript, resolveCliScript } = require('./spawn');
+const { getThemeBin, resolveProjectRoot } = require('./paths');
+const {
+ readActiveThemeManifest,
+ resolveThemeDirectory,
+ listAvailableThemeIds,
+} = require('./theme-runtime');
+const { t } = require('./i18n');
+const { resolveBuildNodeEnv } = require('./prod-memory');
+const { shouldHonorThemePreviewFrame } = require('./theme-preview-frame');
+
+function resolveProductionThemeEnv(projectRoot, themeDir) {
+ const nginxEntry = (
+ process.env.REACTPRESS_NGINX_ENTRY_URL ||
+ process.env.NGINX_ENTRY_URL ||
+ 'http://localhost'
+ ).replace(/\/$/, '');
+ const visitorPort =
+ process.env.CLIENT_PORT || process.env.PORT || '3001';
+ const serverApiUrl =
+ process.env.REACTPRESS_THEME_API_URL ||
+ process.env.SERVER_API_URL ||
+ process.env.REACTPRESS_API_URL ||
+ `${nginxEntry}/api`;
+ const publicApiUrl =
+ process.env.REACTPRESS_THEME_PUBLIC_API_URL ||
+ process.env.NEXT_PUBLIC_REACTPRESS_API_URL ||
+ `${nginxEntry}/api`;
+
+ const clientSiteUrl =
+ process.env.CLIENT_SITE_URL?.trim() || `http://127.0.0.1:${visitorPort}`;
+
+ return {
+ ...process.env,
+ NODE_ENV: 'production',
+ REACTPRESS_ORIGINAL_CWD: projectRoot,
+ REACTPRESS_THEME_DIR: themeDir,
+ PORT: String(visitorPort),
+ CLIENT_PORT: String(visitorPort),
+ CLIENT_SITE_URL: clientSiteUrl,
+ NGINX_ENTRY_URL: nginxEntry,
+ REACTPRESS_NGINX_ENTRY_URL: nginxEntry,
+ REACTPRESS_API_URL: serverApiUrl,
+ SERVER_API_URL: serverApiUrl,
+ NEXT_PUBLIC_REACTPRESS_API_URL: publicApiUrl,
+ NEXT_PUBLIC_REACTPRESS_ADMIN_URL:
+ process.env.NEXT_PUBLIC_REACTPRESS_ADMIN_URL || `${nginxEntry}/admin`,
+ };
+}
+
+function resolveThemeClientBin(projectRoot, themeDir) {
+ const themeBin = path.join(themeDir, 'bin', 'reactpress-client.js');
+ if (fs.existsSync(themeBin)) return themeBin;
+ const generic = resolveCliScript('bin/reactpress-theme-client.js');
+ if (fs.existsSync(generic)) return generic;
+ throw new Error(`Theme entry not found under ${themeDir}`);
+}
+
+const LAUNCH_FILE_REL_PATHS = ['server.js'];
+
+function syncThemeLaunchFilesFromTemplate(projectRoot, themeId, themeDir) {
+ const templateDir = path.join(resolveProjectRoot(projectRoot), 'themes', themeId);
+ if (!templateDir || !fs.existsSync(templateDir)) return;
+ if (path.resolve(templateDir) === path.resolve(themeDir)) return;
+
+ for (const rel of LAUNCH_FILE_REL_PATHS) {
+ const src = path.join(templateDir, rel);
+ const dest = path.join(themeDir, rel);
+ if (!fs.existsSync(src)) continue;
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(src, dest);
+ }
+
+ const templatePkg = path.join(templateDir, 'package.json');
+ const destPkg = path.join(themeDir, 'package.json');
+ if (fs.existsSync(templatePkg) && fs.existsSync(destPkg)) {
+ try {
+ const srcScripts = JSON.parse(fs.readFileSync(templatePkg, 'utf8')).scripts || {};
+ const destPkgJson = JSON.parse(fs.readFileSync(destPkg, 'utf8'));
+ destPkgJson.scripts = { ...destPkgJson.scripts, start: srcScripts.start, dev: srcScripts.dev };
+ fs.writeFileSync(destPkg, `${JSON.stringify(destPkgJson, null, 2)}\n`, 'utf8');
+ } catch {
+ // ignore corrupt package.json
+ }
+ }
+}
+
+const PREVIEW_DIST_DIR = '.next-preview';
+const BUILD_STAMP_REL = path.join('.next', '.reactpress-theme-id');
+
+function resolveBuildDistDir(options = {}) {
+ return options.distDir || '.next';
+}
+
+function buildStampRel(distDir) {
+ return path.join(distDir, '.reactpress-theme-id');
+}
+
+function writeThemeBuildStamp(themeDir, themeId, options = {}) {
+ const distDir = resolveBuildDistDir(options);
+ const stampPath = path.join(themeDir, buildStampRel(distDir));
+ fs.mkdirSync(path.dirname(stampPath), { recursive: true });
+ fs.writeFileSync(stampPath, themeId, 'utf8');
+}
+
+function newestSourceMtime(rootDir, depth = 0) {
+ if (!fs.existsSync(rootDir)) return 0;
+ let max = 0;
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
+ if (entry.name === 'node_modules' || entry.name === '.next' || entry.name === PREVIEW_DIST_DIR) {
+ continue;
+ }
+ const full = path.join(rootDir, entry.name);
+ if (entry.isDirectory()) {
+ if (depth < 10) max = Math.max(max, newestSourceMtime(full, depth + 1));
+ continue;
+ }
+ if (entry.isFile()) max = Math.max(max, fs.statSync(full).mtimeMs);
+ }
+ return max;
+}
+
+/** Post-build artifacts — not source changes; ignored when comparing freshness. */
+const GENERATED_PUBLIC_FILES = new Set([
+ 'robots.txt',
+ 'sitemap.xml',
+ 'sitemap-0.xml',
+ 'sitemap-1.xml',
+]);
+
+function themeSourcesNewerThanBuild(themeDir, distDir = '.next') {
+ const stampPath = path.join(themeDir, buildStampRel(distDir));
+ if (!fs.existsSync(stampPath)) return true;
+ const buildMtime = fs.statSync(stampPath).mtimeMs;
+
+ for (const rel of [
+ 'app',
+ 'pages',
+ 'src',
+ 'public',
+ 'theme.json',
+ 'package.json',
+ 'next.config.js',
+ ]) {
+ const target = path.join(themeDir, rel);
+ if (!fs.existsSync(target)) continue;
+ const stat = fs.statSync(target);
+ if (stat.isDirectory()) {
+ if (rel === 'public') {
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
+ if (!entry.isFile() || GENERATED_PUBLIC_FILES.has(entry.name)) continue;
+ if (fs.statSync(path.join(target, entry.name)).mtimeMs > buildMtime) return true;
+ }
+ continue;
+ }
+ if (newestSourceMtime(target) > buildMtime) return true;
+ continue;
+ }
+ if (stat.mtimeMs > buildMtime) return true;
+ }
+ return false;
+}
+
+function hasProductionBuildArtifacts(nextDir) {
+ if (fs.existsSync(path.join(nextDir, 'BUILD_ID'))) return true;
+ // Next 12 Pages Router — no BUILD_ID at dist root
+ return fs.existsSync(path.join(nextDir, 'server', 'pages-manifest.json'));
+}
+
+function hasUsableProductionBuild(themeDir, themeId, options = {}) {
+ if (process.env.REACTPRESS_FORCE_THEME_BUILD === '1') return false;
+ const distDir = resolveBuildDistDir(options);
+ const nextDir = path.join(themeDir, distDir);
+ if (!hasProductionBuildArtifacts(nextDir)) return false;
+ if (!fs.existsSync(path.join(nextDir, 'server'))) return false;
+ const stampPath = path.join(themeDir, buildStampRel(distDir));
+ if (!fs.existsSync(stampPath)) return false;
+ try {
+ if (fs.readFileSync(stampPath, 'utf8').trim() !== themeId) return false;
+ } catch {
+ return false;
+ }
+ if (themeSourcesNewerThanBuild(themeDir, distDir)) return false;
+ return true;
+}
+
+function resolvePreviewThemeEnv(projectRoot, themeDir, port, options = {}) {
+ const distDir = options.distDir || PREVIEW_DIST_DIR;
+ const base = resolveProductionThemeEnv(projectRoot, themeDir);
+ let clientSiteUrl = base.CLIENT_SITE_URL;
+ try {
+ const url = new URL(clientSiteUrl || 'http://127.0.0.1:3001');
+ url.port = String(port);
+ clientSiteUrl = url.origin;
+ } catch {
+ clientSiteUrl = `http://127.0.0.1:${port}`;
+ }
+ return {
+ ...base,
+ NODE_ENV: options.mode === 'dev' ? 'development' : 'production',
+ INIT_CWD: themeDir,
+ NEXT_DIST_DIR: distDir,
+ PORT: String(port),
+ CLIENT_PORT: String(port),
+ CLIENT_SITE_URL: clientSiteUrl,
+ REACTPRESS_THEME_DIR: themeDir,
+ NEXT_TELEMETRY_DISABLED: '1',
+ NEXT_IGNORE_INCORRECT_LOCKFILE: '1',
+ };
+}
+
+function canResolveSharedNext(themeDir) {
+ const searchPaths = [themeDir];
+ const nodePath = String(process.env.NODE_PATH || '').trim();
+ if (nodePath) {
+ searchPaths.push(...nodePath.split(path.delimiter).filter(Boolean));
+ }
+ try {
+ require.resolve('next/package.json', { paths: searchPaths });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function ensureThemeDependenciesInstalled(projectRoot, themeDir, themeId, logPrefix = 'themePreview') {
+ if (process.env.REACTPRESS_SKIP_THEME_INSTALL === '1' || canResolveSharedNext(themeDir)) {
+ return;
+ }
+
+ const nextModule = path.join(themeDir, 'node_modules', 'next');
+ if (fs.existsSync(nextModule)) return;
+
+ const { installThemeDependencies } = require('./theme-install');
+ const installingKey =
+ logPrefix === 'themePreview' ? 'themePreview.installingDeps' : 'themeProd.installingDeps';
+ console.log(`[reactpress] ${t(installingKey, { id: themeId })}`);
+ installThemeDependencies(themeDir, projectRoot);
+}
+
+function resolveThemeBuildState(projectRoot, themeId) {
+ const themeDir = resolveThemeDirectory(projectRoot, themeId);
+ if (!themeDir || !fs.existsSync(path.join(themeDir, 'package.json'))) {
+ return null;
+ }
+ return { themeId, themeDir };
+}
+
+function readActiveThemeBuildState(projectRoot) {
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const state = resolveThemeBuildState(projectRoot, activeTheme);
+ if (!state) return null;
+ return { activeTheme, themeDir: state.themeDir };
+}
+
+/** @type {Promise} */
+let themeBuildChain = Promise.resolve();
+
+function doBuildThemeSync(
+ projectRoot,
+ themeId,
+ { force = false, logPrefix = 'themeProd', distDir } = {},
+) {
+ const state = resolveThemeBuildState(projectRoot, themeId);
+ if (!state) {
+ const err = new Error(`Theme not found: ${themeId}`);
+ err.code = 'REACTPRESS_THEME_NOT_FOUND';
+ throw err;
+ }
+ const { themeDir } = state;
+ const buildDistDir =
+ distDir || (logPrefix === 'themePreview' ? PREVIEW_DIST_DIR : '.next');
+
+ if (!force && hasUsableProductionBuild(themeDir, themeId, { distDir: buildDistDir })) {
+ if (logPrefix === 'themePreview') {
+ console.log(`[reactpress] ${t('themePreview.reusingBuild', { id: themeId })}`);
+ } else {
+ console.log(`[reactpress] ${t('themeProd.reusingBuild', { id: themeId })}`);
+ }
+ return { themeId, themeDir, skippedBuild: true };
+ }
+
+ syncThemeLaunchFilesFromTemplate(projectRoot, themeId, themeDir);
+ ensureThemeDependenciesInstalled(projectRoot, themeDir, themeId, logPrefix);
+
+ const buildingKey =
+ logPrefix === 'themePreview' ? 'themePreview.building' : 'themeProd.building';
+ console.log(`[reactpress] ${t(buildingKey, { id: themeId })}`);
+ runSync('pnpm', ['run', 'build'], {
+ cwd: themeDir,
+ stdio: ['ignore', 'inherit', 'inherit'],
+ env: resolveBuildNodeEnv({
+ ...resolveProductionThemeEnv(projectRoot, themeDir),
+ NEXT_DIST_DIR: buildDistDir,
+ CI: '1',
+ ...(logPrefix === 'themeProd' ? { REACTPRESS_BUILD_ACTIVE: '1' } : {}),
+ ...(shouldHonorThemePreviewFrame() || logPrefix === 'themePreview'
+ ? { REACTPRESS_HONOR_PREVIEW: '1' }
+ : {}),
+ }),
+ });
+ if (shouldHonorThemePreviewFrame() || logPrefix === 'themePreview') {
+ const { stripBakedFrameOptionsFromBuild } = require('./theme-preview-frame');
+ stripBakedFrameOptionsFromBuild(themeDir, buildDistDir);
+ }
+ writeThemeBuildStamp(themeDir, themeId, { distDir: buildDistDir });
+ return { themeId, themeDir, skippedBuild: false };
+}
+
+function enqueueThemeBuild(projectRoot, themeId, options = {}) {
+ const task = themeBuildChain.then(() => doBuildThemeSync(projectRoot, themeId, options));
+ themeBuildChain = task.catch(() => {});
+ return task;
+}
+
+function buildTheme(projectRoot, themeId, options = {}) {
+ return doBuildThemeSync(projectRoot, themeId, options);
+}
+
+function buildActiveTheme(projectRoot, { force = false } = {}) {
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const result = doBuildThemeSync(projectRoot, activeTheme, { force, logPrefix: 'themeProd' });
+ return { activeTheme, themeDir: result.themeDir, skippedBuild: result.skippedBuild };
+}
+
+function scheduleBackgroundThemeBuilds(projectRoot, { excludeThemeId } = {}) {
+ if (process.env.REACTPRESS_SKIP_PREVIEW_BUILD === '1') return;
+
+ const activeTheme =
+ excludeThemeId || readActiveThemeManifest(projectRoot).activeTheme;
+ const themeIds = listAvailableThemeIds(projectRoot).filter((id) => id !== activeTheme);
+ if (themeIds.length === 0) return;
+
+ console.log(
+ `[reactpress] ${t('themePreview.backgroundBuildScheduled', { count: themeIds.length })}`,
+ );
+
+ setImmediate(() => {
+ void warmupAllPreviewThemeBuilds(projectRoot, { themeIds }).catch(() => {});
+ });
+}
+
+/**
+ * Pre-build `.next-preview` for catalog themes so admin preview switches stay under ~10s.
+ * Runs builds in parallel (local/desktop dev only — awaited before the ready banner).
+ */
+async function warmupAllPreviewThemeBuilds(
+ projectRoot,
+ { themeIds, concurrency = 2, excludeThemeId } = {},
+) {
+ if (process.env.REACTPRESS_SKIP_PREVIEW_BUILD === '1') return { built: 0, skipped: 0 };
+
+ const activeTheme = excludeThemeId || readActiveThemeManifest(projectRoot).activeTheme;
+ const ids = (themeIds || listAvailableThemeIds(projectRoot)).filter((id) => id !== activeTheme);
+ if (ids.length === 0) return { built: 0, skipped: 0 };
+
+ const { mapWithConcurrency } = require('./theme-warmup');
+ const limit = Math.max(
+ 1,
+ parseInt(process.env.REACTPRESS_PREVIEW_BUILD_CONCURRENCY || String(concurrency), 10) ||
+ concurrency,
+ );
+
+ console.log(`[reactpress] ${t('themePreview.warmingAll', { count: ids.length })}`);
+
+ let built = 0;
+ let skipped = 0;
+
+ await mapWithConcurrency(ids, limit, async (themeId) => {
+ const state = resolveThemeBuildState(projectRoot, themeId);
+ if (!state) return;
+ if (hasUsableProductionBuild(state.themeDir, themeId, { distDir: PREVIEW_DIST_DIR })) {
+ skipped += 1;
+ return;
+ }
+ try {
+ const result = await enqueueThemeBuild(projectRoot, themeId, {
+ logPrefix: 'themePreview',
+ distDir: PREVIEW_DIST_DIR,
+ });
+ if (result.skippedBuild) skipped += 1;
+ else {
+ built += 1;
+ console.log(`[reactpress] ${t('themePreview.buildDone', { id: themeId })}`);
+ }
+ } catch (err) {
+ console.warn(
+ `[reactpress] ${t('themePreview.buildFailed', {
+ id: themeId,
+ message: err.message || err,
+ })}`,
+ );
+ }
+ });
+
+ if (skipped > 0) {
+ console.log(`[reactpress] ${t('themePreview.warmingAllSkipped', { count: skipped })}`);
+ }
+ return { built, skipped };
+}
+
+/**
+ * Rebuild active theme and restart PM2 visitor process (production deploy).
+ */
+async function restartProductionVisitorClient(projectRoot = resolveProjectRoot()) {
+ const { activeTheme, themeDir } = buildActiveTheme(projectRoot);
+ const bin = resolveThemeClientBin(projectRoot, themeDir);
+ const env = resolveProductionThemeEnv(projectRoot, themeDir);
+
+ spawnSync('pm2', ['delete', 'reactpress-client'], { stdio: 'ignore' });
+ spawnSync('pm2', ['delete', '@fecommunity/reactpress-template-hello-world'], {
+ stdio: 'ignore',
+ });
+
+ console.log(`[reactpress] ${t('themeProd.restarting', { id: activeTheme })}`);
+ await runNodeScript(bin, ['--pm2'], { cwd: projectRoot, env });
+ console.log(`[reactpress] ${t('themeProd.restarted', { id: activeTheme })}`);
+}
+
+module.exports = {
+ PREVIEW_DIST_DIR,
+ buildActiveTheme,
+ buildTheme,
+ enqueueThemeBuild,
+ scheduleBackgroundThemeBuilds,
+ warmupAllPreviewThemeBuilds,
+ restartProductionVisitorClient,
+ resolveProductionThemeEnv,
+ resolvePreviewThemeEnv,
+ ensureThemeDependenciesInstalled,
+ hasUsableProductionBuild,
+ readActiveThemeBuildState,
+ resolveThemeBuildState,
+ writeThemeBuildStamp,
+};
diff --git a/cli/src/lib/theme-registry.ts b/cli/src/lib/theme-registry.ts
new file mode 100644
index 00000000..1cb67f5f
--- /dev/null
+++ b/cli/src/lib/theme-registry.ts
@@ -0,0 +1,151 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const { getCliPackageRoot } = require('./paths');
+const {
+ THEMES_PACKAGE_REL,
+ themesRoot,
+} = require('./theme-paths');
+const {
+ readThemesRegistryMeta,
+ readThemesPackageMeta,
+ readNpmThemeSources,
+ readThemeSources,
+ readNpmEntryFromPackageDir,
+ normalizeNpmCatalogEntry,
+ isValidNpmCatalogEntry,
+ validateLocalThemes,
+ validateNpmThemes,
+ validateBundledThemes,
+ validateCatalogThemes,
+} = require('./theme-sources');
+
+/** Official npm spec for the theme-starter package. */
+const OFFICIAL_THEME_STARTER_SPEC = '@fecommunity/reactpress-theme-starter@1.0.0-beta.0';
+const OFFICIAL_THEME_STARTER_ID = 'reactpress-theme-starter';
+/** Catalog anchor directory under themes/ (metadata in package.json). */
+const OFFICIAL_THEME_STARTER_DIR = 'theme-starter';
+
+function readJsonFile(filePath) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function readLegacyCatalogFile(projectRoot, filePath) {
+ if (!fs.existsSync(filePath)) return null;
+ const raw = readJsonFile(filePath);
+ if (!raw) return null;
+ const themes = (Array.isArray(raw.themes) ? raw.themes : [])
+ .map(normalizeNpmCatalogEntry)
+ .filter(Boolean);
+ if (!themes.length) return null;
+ return {
+ version: typeof raw.version === 'number' ? raw.version : 1,
+ themes,
+ source: path.relative(path.resolve(projectRoot), filePath) || filePath,
+ };
+}
+
+function readCatalogFromRegistry(projectRoot) {
+ const npmSources = readNpmThemeSources(projectRoot);
+ if (npmSources.length) {
+ return {
+ version: 1,
+ themes: npmSources.map(({ kind, ...entry }) => entry),
+ source: THEMES_PACKAGE_REL,
+ };
+ }
+ return null;
+}
+
+/** Aggregate npm catalog from themes/package.json, then CLI template fallback. */
+function readThemeCatalog(projectRoot) {
+ const fromRegistry = readCatalogFromRegistry(projectRoot);
+ if (fromRegistry) return fromRegistry;
+
+ const legacyCatalog = path.join(themesRoot(projectRoot), 'catalog.json');
+ const legacy = readLegacyCatalogFile(projectRoot, legacyCatalog);
+ if (legacy) return legacy;
+
+ const templatePath = path.join(getCliPackageRoot(), 'templates', 'theme-catalog.json');
+ const fromTemplate = readLegacyCatalogFile(projectRoot, templatePath);
+ if (fromTemplate) return fromTemplate;
+
+ return { version: 1, themes: [], source: null };
+}
+
+function findCatalogTheme(projectRoot, idOrSpec) {
+ const needle = String(idOrSpec || '').trim();
+ if (!needle) return null;
+ const { themes } = readThemeCatalog(projectRoot);
+ return (
+ themes.find((entry) => entry.id === needle || entry.npm === needle) ??
+ themes.find((entry) => entry.npm.startsWith(needle) || needle.startsWith(entry.id)) ??
+ null
+ );
+}
+
+function resolveCatalogInstallSpec(projectRoot, input) {
+ const trimmed = String(input || '').trim();
+ if (!trimmed) return null;
+ if (trimmed === 'reactpress-theme-starter' || trimmed === OFFICIAL_THEME_STARTER_ID) {
+ const fromCatalog = findCatalogTheme(projectRoot, OFFICIAL_THEME_STARTER_ID);
+ if (fromCatalog?.npm) return fromCatalog.npm;
+ return OFFICIAL_THEME_STARTER_SPEC;
+ }
+ const fromCatalog = findCatalogTheme(projectRoot, trimmed);
+ if (fromCatalog?.npm) return fromCatalog.npm;
+ return trimmed;
+}
+
+/** Map npm catalog metadata to a minimal ThemeManifest-shaped object. */
+function catalogEntryToManifest(entry) {
+ if (!entry) return null;
+ return {
+ id: entry.id,
+ name: entry.name,
+ version: entry.version,
+ description: entry.description,
+ author: entry.author,
+ themeUri: entry.themeUri,
+ previewUrl: entry.previewUrl,
+ cover: entry.cover,
+ tags: entry.tags,
+ requires: entry.requires,
+ };
+}
+
+/** Build aggregated catalog JSON for CLI template sync. */
+function buildAggregatedCatalog(projectRoot) {
+ const { themes } = readCatalogFromRegistry(projectRoot) ?? { themes: [] };
+ return {
+ version: 1,
+ themes: themes.map(({ dir, ...entry }) => entry),
+ };
+}
+
+module.exports = {
+ OFFICIAL_THEME_STARTER_SPEC,
+ OFFICIAL_THEME_STARTER_ID,
+ OFFICIAL_THEME_STARTER_DIR,
+ isValidNpmCatalogEntry,
+ isValidCatalogEntry: isValidNpmCatalogEntry,
+ normalizeCatalogEntry: normalizeNpmCatalogEntry,
+ readThemesRegistryMeta,
+ readThemesPackageMeta,
+ readPackageCatalogEntry: readNpmEntryFromPackageDir,
+ readThemeSources,
+ readThemeCatalog,
+ findCatalogTheme,
+ resolveCatalogInstallSpec,
+ catalogEntryToManifest,
+ validateLocalThemes,
+ validateNpmThemes,
+ validateBundledThemes,
+ validateCatalogThemes,
+ buildAggregatedCatalog,
+};
diff --git a/cli/src/lib/theme-runtime.ts b/cli/src/lib/theme-runtime.ts
new file mode 100644
index 00000000..cf054603
--- /dev/null
+++ b/cli/src/lib/theme-runtime.ts
@@ -0,0 +1,377 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const { DEV_PORTS, BLOCKED_THEME_DEV_PORTS } = require('./ports');
+const {
+ REACTPRESS_DIR,
+ THEME_RUNTIME_REL,
+ LEGACY_THEMES_RUNTIME_REL,
+ ACTIVE_THEME_MANIFEST_REL,
+ PREVIEW_THEME_MANIFEST_REL,
+ THEMES_RESERVED_SUBDIRS,
+ THEMES_LEGACY_STARTER_SUBDIRS,
+ THEME_ID_RE,
+ DEFAULT_ACTIVE_THEME,
+} = require('./theme-paths');
+
+const MANIFEST_REL = ACTIVE_THEME_MANIFEST_REL;
+const PREVIEW_MANIFEST_REL = PREVIEW_THEME_MANIFEST_REL;
+const DEFAULT_PREVIEW_THEME_PORT = DEV_PORTS.THEME_PREVIEW;
+const BLOCKED_DEV_PORTS = BLOCKED_THEME_DEV_PORTS;
+
+/** Desktop dev stores manifests under `.reactpress/desktop-dev-site/` (embedded SQLite site root). */
+function themeWorkspaceRoot(projectRoot) {
+ const site = process.env.REACTPRESS_DESKTOP_SITE_ROOT?.trim();
+ return site ? path.resolve(site) : path.resolve(projectRoot);
+}
+
+function resolveMonorepoRoot(projectRoot) {
+ const fromEnv = process.env.REACTPRESS_MONOREPO_ROOT?.trim();
+ if (fromEnv) return path.resolve(fromEnv);
+ let dir = path.resolve(projectRoot);
+ for (let depth = 0; depth < 10; depth += 1) {
+ if (fs.existsSync(path.join(dir, 'cli', 'lib', 'theme-registry.js'))) {
+ return dir;
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return path.resolve(projectRoot);
+}
+
+function isValidThemeId(id) {
+ return typeof id === 'string' && THEME_ID_RE.test(id) && id.length <= 64;
+}
+
+function isUnderDir(child, parent) {
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
+}
+
+function themeRoots(projectRoot) {
+ const root = themeWorkspaceRoot(projectRoot);
+ const themes = path.join(root, 'themes');
+ return {
+ themes,
+ runtime: path.join(root, THEME_RUNTIME_REL),
+ legacyThemesRuntime: path.join(root, LEGACY_THEMES_RUNTIME_REL),
+ legacyStarter: THEMES_LEGACY_STARTER_SUBDIRS.map((name) => path.join(themes, name)),
+ legacyBundled: path.join(root, 'templates'),
+ };
+}
+
+function isThemePackageAt(dir) {
+ return (
+ fs.existsSync(path.join(dir, 'package.json')) ||
+ fs.existsSync(path.join(dir, 'theme.json'))
+ );
+}
+
+/** `readdir` symlinks report as links, not directories — follow for desktop-dev-site theme seeds. */
+function isResolvableThemeDirEntry(entry, parentDir) {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) return false;
+ try {
+ return fs.statSync(path.join(parentDir, entry.name)).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function isThemePackageDir(projectRoot, dir) {
+ if (!dir) return false;
+ const resolved = path.resolve(dir);
+ const { themes, runtime, legacyThemesRuntime, legacyStarter, legacyBundled } =
+ themeRoots(projectRoot);
+
+ if (isUnderDir(resolved, runtime) && isThemePackageAt(resolved)) {
+ return true;
+ }
+
+ if (isUnderDir(resolved, legacyThemesRuntime) && isThemePackageAt(resolved)) {
+ return true;
+ }
+
+ if (isUnderDir(resolved, themes)) {
+ const rel = path.relative(themes, resolved);
+ const top = rel.split(path.sep)[0];
+ if (top && !THEMES_RESERVED_SUBDIRS.includes(top) && isThemePackageAt(resolved)) {
+ return true;
+ }
+ }
+
+ for (const base of [...legacyStarter, legacyBundled]) {
+ if (!fs.existsSync(base)) continue;
+ if (isUnderDir(resolved, base) && isThemePackageAt(resolved)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function isAllowedThemePort(port) {
+ const n = Number(port);
+ return Number.isInteger(n) && n >= 1024 && n <= 65535 && !BLOCKED_DEV_PORTS.has(n);
+}
+
+function isAllowedThemeDirRel(themeDir) {
+ if (typeof themeDir !== 'string') return false;
+ if (themeDir.includes('..') || path.isAbsolute(themeDir)) return false;
+
+ if (themeDir.startsWith(`${THEME_RUNTIME_REL}/`)) {
+ return true;
+ }
+
+ if (themeDir.startsWith(`${LEGACY_THEMES_RUNTIME_REL}/`)) {
+ return true;
+ }
+
+ if (themeDir.startsWith('themes/') && !themeDir.startsWith(`${LEGACY_THEMES_RUNTIME_REL}/`)) {
+ const rest = themeDir.slice('themes/'.length);
+ const top = rest.split('/')[0];
+ if (top && !THEMES_RESERVED_SUBDIRS.includes(top)) {
+ return true;
+ }
+ }
+
+ const legacyPrefixes = THEMES_LEGACY_STARTER_SUBDIRS.map((name) => `themes/${name}/`);
+ if (legacyPrefixes.some((prefix) => themeDir.startsWith(prefix))) {
+ return true;
+ }
+
+ return themeDir.startsWith('templates/');
+}
+
+function readActiveThemeManifest(projectRoot) {
+ const manifestPath = path.join(themeWorkspaceRoot(projectRoot), MANIFEST_REL);
+ if (!fs.existsSync(manifestPath)) {
+ return { activeTheme: DEFAULT_ACTIVE_THEME };
+ }
+ try {
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ const id =
+ typeof raw.activeTheme === 'string' && isValidThemeId(raw.activeTheme)
+ ? raw.activeTheme
+ : DEFAULT_ACTIVE_THEME;
+ const themeDir = isAllowedThemeDirRel(raw.themeDir) ? raw.themeDir : undefined;
+ return { activeTheme: id, themeDir, updatedAt: raw.updatedAt };
+ } catch {
+ return { activeTheme: DEFAULT_ACTIVE_THEME };
+ }
+}
+
+function resolveThemeDirectory(projectRoot, themeId) {
+ if (!isValidThemeId(themeId)) return null;
+
+ const root = themeWorkspaceRoot(projectRoot);
+ const runtime = path.join(root, THEME_RUNTIME_REL, themeId);
+ if (isThemePackageAt(runtime)) return runtime;
+
+ const legacyRuntime = path.join(root, LEGACY_THEMES_RUNTIME_REL, themeId);
+ if (isThemePackageAt(legacyRuntime)) return legacyRuntime;
+
+ const template = path.join(root, 'themes', themeId);
+ if (isThemePackageAt(template) && !THEMES_RESERVED_SUBDIRS.includes(themeId)) {
+ return template;
+ }
+
+ for (const legacyStarterName of THEMES_LEGACY_STARTER_SUBDIRS) {
+ const legacyStarter = path.join(root, 'themes', legacyStarterName, themeId);
+ if (isThemePackageAt(legacyStarter)) return legacyStarter;
+ }
+
+ const legacyBundled = path.join(root, 'templates', themeId);
+ if (isThemePackageAt(legacyBundled)) return legacyBundled;
+
+ const mono = resolveMonorepoRoot(projectRoot);
+ if (mono !== root) {
+ const monoRuntime = path.join(mono, THEME_RUNTIME_REL, themeId);
+ if (isThemePackageAt(monoRuntime)) return monoRuntime;
+ const monoTheme = path.join(mono, 'themes', themeId);
+ if (isThemePackageAt(monoTheme) && !THEMES_RESERVED_SUBDIRS.includes(themeId)) {
+ return monoTheme;
+ }
+ }
+
+ return null;
+}
+
+function readManifestSignatureFromPath(projectRoot, manifestRel) {
+ const root = themeWorkspaceRoot(projectRoot);
+ const manifestPath = path.join(root, manifestRel);
+ try {
+ if (!fs.existsSync(manifestPath)) return '';
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ const id = typeof raw.activeTheme === 'string' ? raw.activeTheme : '';
+ if (!isValidThemeId(id)) return '';
+ const themeDir = resolveThemeDirectory(projectRoot, id);
+ if (!themeDir) return '';
+ const rel = path.relative(root, themeDir);
+ return `${id}:${rel}`;
+ } catch {
+ return '';
+ }
+}
+
+function readManifestSignature(projectRoot) {
+ return readManifestSignatureFromPath(projectRoot, MANIFEST_REL);
+}
+
+function readPreviewManifestSignature(projectRoot) {
+ const root = themeWorkspaceRoot(projectRoot);
+ const manifestPath = path.join(root, PREVIEW_MANIFEST_REL);
+ try {
+ if (!fs.existsSync(manifestPath)) return '';
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ const id = typeof raw.activeTheme === 'string' ? raw.activeTheme : '';
+ if (!isValidThemeId(id)) return '';
+ const themeDir = resolveThemeDirectory(projectRoot, id);
+ if (!themeDir) return '';
+ const rel = path.relative(root, themeDir);
+ const stamp = typeof raw.updatedAt === 'string' ? raw.updatedAt : '';
+ return `${id}:${rel}:${stamp}`;
+ } catch {
+ return '';
+ }
+}
+
+function readPreviewThemeManifest(projectRoot) {
+ const root = themeWorkspaceRoot(projectRoot);
+ const manifestPath = path.join(root, PREVIEW_MANIFEST_REL);
+ if (!fs.existsSync(manifestPath)) {
+ return null;
+ }
+ try {
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+ const id =
+ typeof raw.activeTheme === 'string' && isValidThemeId(raw.activeTheme)
+ ? raw.activeTheme
+ : null;
+ if (!id) return null;
+ const themeDir = resolveThemeDirectory(projectRoot, id);
+ return { activeTheme: id, themeDir: themeDir ? path.relative(root, themeDir) : null };
+ } catch {
+ return null;
+ }
+}
+
+function getPreviewThemePort() {
+ const fromEnv = parseInt(process.env.REACTPRESS_PREVIEW_PORT || '', 10);
+ if (Number.isInteger(fromEnv) && isAllowedThemePort(fromEnv)) {
+ return String(fromEnv);
+ }
+ return String(DEFAULT_PREVIEW_THEME_PORT);
+}
+
+function hasResolvableActiveTheme(projectRoot) {
+ if (!hasThemePackages(projectRoot)) return false;
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const themeDir = resolveThemeDirectory(projectRoot, activeTheme);
+ return Boolean(themeDir && isThemePackageDir(projectRoot, themeDir));
+}
+
+/** Installed / bundled theme ids (active-theme.json entries may point into runtime/). */
+function listAvailableThemeIds(projectRoot) {
+ const ids = new Set();
+ const { themes, runtime, legacyThemesRuntime, legacyStarter, legacyBundled } =
+ themeRoots(projectRoot);
+
+ for (const dir of [runtime, legacyThemesRuntime]) {
+ if (!fs.existsSync(dir)) continue;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (!isResolvableThemeDirEntry(entry, dir) || !isValidThemeId(entry.name)) continue;
+ if (isThemePackageAt(path.join(dir, entry.name))) ids.add(entry.name);
+ }
+ }
+
+ if (fs.existsSync(themes)) {
+ for (const entry of fs.readdirSync(themes, { withFileTypes: true })) {
+ if (!isResolvableThemeDirEntry(entry, themes)) continue;
+ if (THEMES_RESERVED_SUBDIRS.includes(entry.name)) continue;
+ if (!isValidThemeId(entry.name)) continue;
+ if (isThemePackageAt(path.join(themes, entry.name))) ids.add(entry.name);
+ }
+ }
+
+ for (const base of [...legacyStarter, legacyBundled]) {
+ if (!fs.existsSync(base)) continue;
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
+ if (!isResolvableThemeDirEntry(entry, base) || !isValidThemeId(entry.name)) continue;
+ if (isThemePackageAt(path.join(base, entry.name))) ids.add(entry.name);
+ }
+ }
+
+ return [...ids].sort();
+}
+
+function hasThemePackages(projectRoot) {
+ const { themes, runtime, legacyThemesRuntime, legacyStarter, legacyBundled } =
+ themeRoots(projectRoot);
+
+ for (const dir of [runtime, legacyThemesRuntime]) {
+ if (!fs.existsSync(dir)) continue;
+ if (
+ fs
+ .readdirSync(dir, { withFileTypes: true })
+ .some((entry) => isResolvableThemeDirEntry(entry, dir))
+ ) {
+ return true;
+ }
+ }
+
+ if (fs.existsSync(themes)) {
+ if (
+ fs
+ .readdirSync(themes, { withFileTypes: true })
+ .some(
+ (entry) =>
+ isResolvableThemeDirEntry(entry, themes) &&
+ !THEMES_RESERVED_SUBDIRS.includes(entry.name),
+ )
+ ) {
+ return true;
+ }
+ }
+
+ for (const dir of [...legacyStarter, legacyBundled]) {
+ if (!fs.existsSync(dir)) continue;
+ if (
+ fs
+ .readdirSync(dir, { withFileTypes: true })
+ .some((entry) => isResolvableThemeDirEntry(entry, dir))
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+module.exports = {
+ MANIFEST_REL,
+ PREVIEW_MANIFEST_REL,
+ DEFAULT_PREVIEW_THEME_PORT,
+ THEME_ID_RE,
+ THEME_RUNTIME_REL,
+ LEGACY_THEMES_RUNTIME_REL,
+ THEMES_RESERVED_SUBDIRS,
+ THEMES_LEGACY_STARTER_SUBDIRS,
+ BLOCKED_DEV_PORTS,
+ isValidThemeId,
+ isThemePackageDir,
+ isAllowedThemePort,
+ isAllowedThemeDirRel,
+ readActiveThemeManifest,
+ resolveThemeDirectory,
+ readManifestSignature,
+ readPreviewManifestSignature,
+ readPreviewThemeManifest,
+ getPreviewThemePort,
+ hasThemePackages,
+ hasResolvableActiveTheme,
+ listAvailableThemeIds,
+ themeRoots,
+ themeWorkspaceRoot,
+};
diff --git a/cli/src/lib/theme-sources.ts b/cli/src/lib/theme-sources.ts
new file mode 100644
index 00000000..61422f4b
--- /dev/null
+++ b/cli/src/lib/theme-sources.ts
@@ -0,0 +1,377 @@
+// @ts-nocheck
+/**
+ * ReactPress theme sources — unified model.
+ *
+ * TWO SOURCES (how a theme is installed into `.reactpress/runtime/{id}/`):
+ * local — copy from `themes/{id}/` (must contain `theme.json`)
+ * npm — `npm pack` a package spec, then copy into runtime
+ *
+ * NPM REGISTRY SPEC (canonical):
+ * themes/{anchor}/package.json → dependencies + reactpress.theme (see npm-catalog.schema.json)
+ * themes/package.json → reactpress.npm: ["{anchor}", …]
+ *
+ * THREE LAYERS:
+ * themes/ registry — what is available to install
+ * .reactpress/runtime/ materialized — installed copies the CLI runs
+ * DB + *.json activation — which theme is active / previewing
+ *
+ * Legacy: reactpress.bundled / catalog keys; inline objects in reactpress.npm array.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const { THEMES_PACKAGE_REL, themesRoot } = require('./theme-paths');
+
+function readJsonFile(filePath) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function isNonEmptyString(value) {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function formatNpmInstallSpec(name, version) {
+ return `${name.trim()}@${version.trim()}`;
+}
+
+/** Split `package@version` into structured dependency (supports scoped packages). */
+function parseNpmSpecToDependency(spec) {
+ const trimmed = String(spec || '').trim();
+ const atIndex = trimmed.lastIndexOf('@');
+ if (atIndex <= 0) return null;
+ const name = trimmed.slice(0, atIndex).trim();
+ const version = trimmed.slice(atIndex + 1).trim();
+ if (!name || !version) return null;
+ return { name, version };
+}
+
+function readPackageDependencies(raw) {
+ const deps = raw?.dependencies;
+ if (!deps || typeof deps !== 'object') return null;
+ for (const [name, version] of Object.entries(deps)) {
+ if (isNonEmptyString(name) && isNonEmptyString(version)) {
+ return { name: name.trim(), version: String(version).trim() };
+ }
+ }
+ return null;
+}
+
+function readThemeDependency(theme, raw) {
+ const fromDeps = raw ? readPackageDependencies(raw) : null;
+ if (fromDeps) return fromDeps;
+
+ if (theme && typeof theme === 'object') {
+ const dep = theme.dependency;
+ if (dep && typeof dep === 'object' && isNonEmptyString(dep.name) && isNonEmptyString(dep.version)) {
+ return { name: dep.name.trim(), version: dep.version.trim() };
+ }
+ if (isNonEmptyString(theme.npm)) {
+ return parseNpmSpecToDependency(theme.npm);
+ }
+ }
+
+ const pkgName = typeof raw?.name === 'string' ? raw.name : undefined;
+ const pkgVersion = typeof raw?.version === 'string' ? raw.version : undefined;
+ if (isNonEmptyString(pkgName) && isNonEmptyString(pkgVersion)) {
+ return { name: pkgName.trim(), version: pkgVersion.trim() };
+ }
+
+ return null;
+}
+
+function resolveThemeNpmSpec(theme, raw) {
+ const dependency = readThemeDependency(theme, raw);
+ return dependency ? formatNpmInstallSpec(dependency.name, dependency.version) : undefined;
+}
+
+function isValidNpmCatalogEntry(entry) {
+ return (
+ entry &&
+ isNonEmptyString(entry.id) &&
+ isNonEmptyString(entry.name) &&
+ (isNonEmptyString(entry.npm) ||
+ (entry.dependency &&
+ typeof entry.dependency === 'object' &&
+ isNonEmptyString(entry.dependency.name) &&
+ isNonEmptyString(entry.dependency.version)))
+ );
+}
+
+function normalizeNpmCatalogEntry(entry) {
+ if (!entry || !isNonEmptyString(entry.id) || !isNonEmptyString(entry.name)) return null;
+
+ let dependency =
+ entry.dependency &&
+ typeof entry.dependency === 'object' &&
+ isNonEmptyString(entry.dependency.name) &&
+ isNonEmptyString(entry.dependency.version)
+ ? { name: entry.dependency.name.trim(), version: entry.dependency.version.trim() }
+ : undefined;
+
+ let npmSpec = isNonEmptyString(entry.npm) ? entry.npm.trim() : undefined;
+ if (dependency) {
+ npmSpec = formatNpmInstallSpec(dependency.name, dependency.version);
+ } else if (npmSpec) {
+ dependency = parseNpmSpecToDependency(npmSpec) ?? undefined;
+ }
+
+ if (!npmSpec) return null;
+
+ return {
+ id: entry.id.trim(),
+ name: entry.name,
+ version: typeof entry.version === 'string' ? entry.version : '0.0.0',
+ description: entry.description,
+ author: entry.author,
+ authorUri: entry.authorUri,
+ themeUri: entry.themeUri,
+ previewUrl: entry.previewUrl,
+ cover: entry.cover,
+ tags: Array.isArray(entry.tags) ? entry.tags : undefined,
+ dependency,
+ npm: npmSpec,
+ featured: entry.featured === true,
+ requires: entry.requires,
+ dir: typeof entry.dir === 'string' ? entry.dir.trim() : undefined,
+ };
+}
+
+/** Read `themes/package.json` registry lists (local ids + npm catalog refs). */
+function readThemesRegistryMeta(projectRoot) {
+ const pkgPath = path.join(path.resolve(projectRoot), THEMES_PACKAGE_REL);
+ if (!fs.existsSync(pkgPath)) {
+ return { local: [], npm: [] };
+ }
+
+ const raw = readJsonFile(pkgPath);
+ if (!raw?.reactpress || typeof raw.reactpress !== 'object') {
+ return { local: [], npm: [] };
+ }
+
+ const reactpress = raw.reactpress;
+ const localSource = reactpress.local ?? reactpress.bundled;
+ const npmSource = reactpress.npm ?? reactpress.catalog;
+
+ const local = Array.isArray(localSource)
+ ? localSource.filter((id) => isNonEmptyString(id)).map((id) => id.trim())
+ : [];
+
+ const npm = Array.isArray(npmSource) ? npmSource : [];
+
+ return { local, npm };
+}
+
+/** @deprecated Use readThemesRegistryMeta — kept for existing imports. */
+function readThemesPackageMeta(projectRoot) {
+ const { local, npm } = readThemesRegistryMeta(projectRoot);
+ const catalog = npm
+ .map((item) => {
+ if (isNonEmptyString(item)) return item.trim();
+ if (item && typeof item === 'object' && isNonEmptyString(item.id)) return item.id.trim();
+ return null;
+ })
+ .filter(Boolean);
+ return { bundled: local, catalog, local, npm };
+}
+
+function readNpmEntryFromPackageDir(catalogDir, pkgPath) {
+ const raw = readJsonFile(pkgPath);
+ if (!raw) return null;
+
+ const theme =
+ raw.reactpress && typeof raw.reactpress === 'object' && raw.reactpress.theme
+ ? raw.reactpress.theme
+ : null;
+ if (!theme || !isNonEmptyString(theme.id)) return null;
+
+ const pkgVersion = typeof raw.version === 'string' ? raw.version : '0.0.0';
+ const dependency = readThemeDependency(theme, raw);
+ const npmSpec = dependency ? formatNpmInstallSpec(dependency.name, dependency.version) : undefined;
+ if (!npmSpec) return null;
+
+ return normalizeNpmCatalogEntry({
+ id: theme.id,
+ dir: catalogDir,
+ name:
+ isNonEmptyString(theme.name) ? theme.name : isNonEmptyString(raw.description) ? raw.description : theme.id,
+ version: isNonEmptyString(theme.version) ? theme.version : dependency.version ?? pkgVersion,
+ description:
+ isNonEmptyString(theme.description) ? theme.description : isNonEmptyString(raw.description) ? raw.description : undefined,
+ author: isNonEmptyString(theme.author) ? theme.author : raw.author,
+ authorUri: isNonEmptyString(theme.authorUri) ? theme.authorUri : undefined,
+ themeUri:
+ isNonEmptyString(theme.themeUri) ? theme.themeUri : isNonEmptyString(raw.homepage) ? raw.homepage : undefined,
+ previewUrl: isNonEmptyString(theme.previewUrl) ? theme.previewUrl.trim() : undefined,
+ cover: isNonEmptyString(theme.cover) ? theme.cover.trim() : undefined,
+ tags: Array.isArray(theme.tags) ? theme.tags : undefined,
+ dependency,
+ npm: npmSpec,
+ featured: theme.featured === true,
+ requires: isNonEmptyString(theme.requires) ? theme.requires : undefined,
+ });
+}
+
+function readInlineNpmEntry(item) {
+ if (!item || typeof item !== 'object') return null;
+ const dependency =
+ readPackageDependencies(item) ??
+ (item.dependency &&
+ typeof item.dependency === 'object' &&
+ isNonEmptyString(item.dependency.name) &&
+ isNonEmptyString(item.dependency.version)
+ ? { name: item.dependency.name.trim(), version: item.dependency.version.trim() }
+ : isNonEmptyString(item.npm)
+ ? parseNpmSpecToDependency(item.npm)
+ : undefined);
+ if (!dependency && !isNonEmptyString(item.npm)) return null;
+ return normalizeNpmCatalogEntry({
+ id: item.id,
+ name: item.name,
+ version: item.version,
+ description: item.description,
+ author: item.author,
+ authorUri: item.authorUri,
+ themeUri: item.themeUri,
+ previewUrl: item.previewUrl,
+ cover: item.cover,
+ tags: item.tags,
+ dependency,
+ npm: dependency ? formatNpmInstallSpec(dependency.name, dependency.version) : item.npm,
+ featured: item.featured,
+ requires: item.requires,
+ });
+}
+
+/** Local theme ids registered in themes/package.json with theme.json on disk. */
+function readLocalThemeSources(projectRoot) {
+ const root = path.resolve(projectRoot);
+ const { local } = readThemesRegistryMeta(root);
+ const templates = themesRoot(root);
+ const sources = [];
+
+ for (const id of local) {
+ const themeJson = path.join(templates, id, 'theme.json');
+ if (!fs.existsSync(themeJson)) continue;
+ const manifest = readJsonFile(themeJson);
+ if (!manifest?.id) continue;
+ sources.push({
+ kind: 'local',
+ id: manifest.id,
+ dir: id,
+ manifest,
+ });
+ }
+
+ return sources;
+}
+
+/** npm catalog entries — anchor dirs (themes/{anchor}/package.json) are canonical. */
+function readNpmThemeSources(projectRoot) {
+ const root = path.resolve(projectRoot);
+ const { npm } = readThemesRegistryMeta(root);
+ const templates = themesRoot(root);
+ const byId = new Map();
+
+ for (const item of npm) {
+ if (isNonEmptyString(item)) {
+ const dir = item.trim();
+ const entry = readNpmEntryFromPackageDir(dir, path.join(templates, dir, 'package.json'));
+ if (entry) byId.set(entry.id, { kind: 'npm', ...entry });
+ continue;
+ }
+
+ const inline = readInlineNpmEntry(item);
+ if (inline) byId.set(inline.id, { kind: 'npm', ...inline });
+ }
+
+ return [...byId.values()];
+}
+
+/** Combined registry view used by CLI and server. */
+function readThemeSources(projectRoot) {
+ return {
+ local: readLocalThemeSources(projectRoot),
+ npm: readNpmThemeSources(projectRoot),
+ };
+}
+
+function validateLocalThemes(projectRoot) {
+ const root = path.resolve(projectRoot);
+ const { local } = readThemesRegistryMeta(root);
+ const missing = [];
+ const templates = themesRoot(root);
+
+ for (const id of local) {
+ const themeJson = path.join(templates, id, 'theme.json');
+ if (!fs.existsSync(themeJson)) {
+ missing.push(id);
+ }
+ }
+ return { local, missing };
+}
+
+/** @deprecated Use validateLocalThemes */
+function validateBundledThemes(projectRoot) {
+ const { local, missing } = validateLocalThemes(projectRoot);
+ return { bundled: local, missing };
+}
+
+function validateNpmThemes(projectRoot) {
+ const root = path.resolve(projectRoot);
+ const { npm } = readThemesRegistryMeta(root);
+ const missing = [];
+ const templates = themesRoot(root);
+
+ for (const item of npm) {
+ if (isNonEmptyString(item)) {
+ const dir = item.trim();
+ const pkgPath = path.join(templates, dir, 'package.json');
+ const themeJson = path.join(templates, dir, 'theme.json');
+ if (fs.existsSync(themeJson)) {
+ missing.push(`${dir}/ must not contain theme.json (npm anchor vs local theme)`);
+ }
+ const entry = readNpmEntryFromPackageDir(dir, pkgPath);
+ if (!entry) missing.push(`${dir}/package.json (dependencies + reactpress.theme)`);
+ continue;
+ }
+ if (!readInlineNpmEntry(item)) {
+ missing.push(`inline npm entry (id + dependencies or npm required): ${JSON.stringify(item)}`);
+ }
+ }
+
+ return { npm, missing };
+}
+
+/** @deprecated Use validateNpmThemes */
+function validateCatalogThemes(projectRoot) {
+ const { npm, missing } = validateNpmThemes(projectRoot);
+ const catalog = npm
+ .map((item) => (isNonEmptyString(item) ? item.trim() : null))
+ .filter(Boolean);
+ return { catalog, missing };
+}
+
+module.exports = {
+ readThemesRegistryMeta,
+ readThemesPackageMeta,
+ readLocalThemeSources,
+ readNpmThemeSources,
+ readThemeSources,
+ readNpmEntryFromPackageDir,
+ readInlineNpmEntry,
+ formatNpmInstallSpec,
+ parseNpmSpecToDependency,
+ readPackageDependencies,
+ readThemeDependency,
+ resolveThemeNpmSpec,
+ normalizeNpmCatalogEntry,
+ isValidNpmCatalogEntry,
+ validateLocalThemes,
+ validateNpmThemes,
+ validateBundledThemes,
+ validateCatalogThemes,
+};
diff --git a/cli/src/lib/theme-warmup.ts b/cli/src/lib/theme-warmup.ts
new file mode 100644
index 00000000..a589d428
--- /dev/null
+++ b/cli/src/lib/theme-warmup.ts
@@ -0,0 +1,172 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const { readActiveThemeManifest, resolveThemeDirectory } = require('./theme-runtime');
+const { loadClientSiteUrl } = require('./http');
+
+/** Placeholder for dynamic segments — only triggers page bundle compilation in dev. */
+const WARMUP_PARAM = '__reactpress_dev_warmup__';
+
+function pageFileToRoute(pageFile) {
+ let route = String(pageFile)
+ .replace(/^pages\//, '')
+ .replace(/\.(tsx|ts|jsx|js)$/, '');
+ if (route === 'index') return '/';
+ route = route.replace(/\/index$/, '');
+ route = route.replace(/\[([^\]]+)\]/g, WARMUP_PARAM);
+ return `/${route}`;
+}
+
+/** Dynamic SSR routes need real API data — warmup only compiles static visitor pages. */
+function isWarmupSafeRoute(route) {
+ if (!route || typeof route !== 'string') return false;
+ if (route.includes(WARMUP_PARAM)) return false;
+ if (route.startsWith('/admin')) return false;
+ return true;
+}
+
+function collectWarmupRoutes(themeDir) {
+ const themeJsonPath = path.join(themeDir, 'theme.json');
+ const routes = new Set(['/']);
+ let fromThemeJson = false;
+
+ if (fs.existsSync(path.join(themeDir, 'app'))) {
+ routes.add('/');
+ }
+
+ if (fs.existsSync(themeJsonPath)) {
+ try {
+ const manifest = JSON.parse(fs.readFileSync(themeJsonPath, 'utf8'));
+ const templates = manifest?.reactpress?.templates;
+ if (templates && typeof templates === 'object') {
+ fromThemeJson = true;
+ for (const file of Object.values(templates)) {
+ if (typeof file !== 'string') continue;
+ const route = pageFileToRoute(file);
+ if (isWarmupSafeRoute(route)) routes.add(route);
+ }
+ }
+ } catch {
+ // fall through to pages scan
+ }
+ }
+
+ if (!fromThemeJson) {
+ const pagesDir = path.join(themeDir, 'pages');
+ if (fs.existsSync(pagesDir)) {
+ walkPages(pagesDir, pagesDir).forEach((file) => {
+ const rel = path.relative(themeDir, file);
+ if (rel.startsWith(`pages${path.sep}admin${path.sep}`) || rel.includes(`${path.sep}admin${path.sep}`)) {
+ return;
+ }
+ const route = pageFileToRoute(rel);
+ if (isWarmupSafeRoute(route)) routes.add(route);
+ });
+ }
+ }
+
+ routes.add('/404');
+ return [...routes].filter(isWarmupSafeRoute);
+}
+
+function walkPages(pagesDir, currentDir, files = []) {
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
+ const fullPath = path.join(currentDir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name.startsWith('_') || entry.name === 'api' || entry.name === 'admin') continue;
+ walkPages(pagesDir, fullPath, files);
+ continue;
+ }
+ if (/\.(tsx|ts|jsx|js)$/.test(entry.name)) {
+ if (entry.name.startsWith('_')) continue;
+ files.push(fullPath);
+ }
+ }
+ return files;
+}
+
+function fetchRoute(baseUrl, routePath) {
+ return new Promise((resolve) => {
+ const normalizedBase = baseUrl.replace(/\/$/, '');
+ const url = `${normalizedBase}${routePath.startsWith('/') ? routePath : `/${routePath}`}`;
+ const req = http.get(url, { timeout: 120_000 }, (res) => {
+ res.resume();
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
+ });
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ });
+}
+
+async function mapWithConcurrency(items, concurrency, fn) {
+ if (!items.length) return [];
+ const limit = Math.max(1, Math.min(concurrency, items.length));
+ const results = new Array(items.length);
+ let index = 0;
+
+ async function worker() {
+ while (index < items.length) {
+ const i = index;
+ index += 1;
+ results[i] = await fn(items[i], i);
+ }
+ }
+
+ await Promise.all(Array.from({ length: limit }, () => worker()));
+ return results;
+}
+
+/**
+ * SSR-hit theme routes on the internal dev port so Next.js compiles page chunks
+ * before the browser performs client-side navigation (avoids webpack module mismatch).
+ */
+async function warmupThemeDevRoutes(projectRoot) {
+ const { activeTheme } = readActiveThemeManifest(projectRoot);
+ const themeDir = resolveThemeDirectory(projectRoot, activeTheme);
+ if (!themeDir) return { ok: false, routes: [] };
+
+ const baseUrl = loadClientSiteUrl(projectRoot);
+ const routes = collectWarmupRoutes(themeDir);
+ const concurrency = Math.max(
+ 1,
+ parseInt(process.env.REACTPRESS_THEME_WARMUP_CONCURRENCY || '6', 10) || 6,
+ );
+ await mapWithConcurrency(routes, concurrency, (route) => fetchRoute(baseUrl, route));
+ return { ok: true, routes, themeId: activeTheme };
+}
+
+function shouldBlockOnThemeWarmup() {
+ return process.env.REACTPRESS_THEME_WARMUP === '1';
+}
+
+/** Fire-and-forget SSR compile — off by default (saves ~10–20s Next compile after banner). */
+function warmupThemeDevRoutesInBackground(projectRoot) {
+ if (process.env.REACTPRESS_SKIP_THEME_WARMUP !== '0') return;
+ if (process.env.REACTPRESS_THEME_WARMUP !== '1') return;
+ warmupThemeDevRoutes(projectRoot).catch(() => {
+ // non-fatal — first browser navigation will compile anyway
+ });
+}
+
+/** SSR-hit homepage so first visitor load after theme switch is fast. */
+async function warmupThemeHomepage(projectRoot, baseUrl) {
+ const url = (baseUrl || loadClientSiteUrl(projectRoot)).replace(/\/$/, '');
+ await fetchRoute(url, '/');
+ return { ok: true };
+}
+
+module.exports = {
+ WARMUP_PARAM,
+ pageFileToRoute,
+ isWarmupSafeRoute,
+ collectWarmupRoutes,
+ mapWithConcurrency,
+ shouldBlockOnThemeWarmup,
+ warmupThemeDevRoutes,
+ warmupThemeDevRoutesInBackground,
+ warmupThemeHomepage,
+};
diff --git a/cli/src/lib/toolkit-build.ts b/cli/src/lib/toolkit-build.ts
new file mode 100644
index 00000000..35b8b334
--- /dev/null
+++ b/cli/src/lib/toolkit-build.ts
@@ -0,0 +1,54 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+
+const SOURCE_EXT = /\.(ts|tsx|js|json)$/;
+
+function newestSourceMtime(dir, depth = 0) {
+ if (!fs.existsSync(dir)) return 0;
+ let max = 0;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (depth < 12) max = Math.max(max, newestSourceMtime(full, depth + 1));
+ continue;
+ }
+ if (!SOURCE_EXT.test(entry.name)) continue;
+ max = Math.max(max, fs.statSync(full).mtimeMs);
+ }
+ return max;
+}
+
+/**
+ * Whether `pnpm run build` in toolkit/ is needed before dev.
+ * Skips when dist is newer than all toolkit/src sources (unless forced).
+ */
+function shouldBuildToolkit(projectRoot) {
+ if (process.env.REACTPRESS_FORCE_TOOLKIT_BUILD === '1') return true;
+ if (process.env.REACTPRESS_SKIP_TOOLKIT_BUILD === '1') return false;
+
+ const toolkitDir = path.join(path.resolve(projectRoot), 'toolkit');
+ const distEntry = path.join(toolkitDir, 'dist', 'index.js');
+ if (!fs.existsSync(distEntry)) return true;
+
+ const srcDir = path.join(toolkitDir, 'src');
+ if (!fs.existsSync(srcDir)) return false;
+
+ const distMtime = fs.statSync(distEntry).mtimeMs;
+ const localesDir = path.join(toolkitDir, 'src', 'config', 'locales');
+ const localesDist = path.join(toolkitDir, 'dist', 'config', 'locales');
+ if (fs.existsSync(localesDir)) {
+ const localesMtime = newestSourceMtime(localesDir);
+ if (!fs.existsSync(localesDist) || localesMtime > fs.statSync(localesDist).mtimeMs) {
+ return true;
+ }
+ }
+
+ return newestSourceMtime(srcDir) > distMtime;
+}
+
+module.exports = {
+ shouldBuildToolkit,
+ newestSourceMtime,
+};
diff --git a/cli/src/types/config.ts b/cli/src/types/config.ts
new file mode 100644
index 00000000..243fe3b2
--- /dev/null
+++ b/cli/src/types/config.ts
@@ -0,0 +1,64 @@
+/** ReactPress 4.x 数据库模式 */
+export type DatabaseMode = 'embedded-docker' | 'external' | 'embedded-sqlite';
+
+export type DatabaseType = 'mysql' | 'sqlite';
+
+export interface ReactPressConfig {
+ version: number;
+ database: {
+ mode: DatabaseMode;
+ containerName?: string;
+ host?: string;
+ port?: number;
+ user?: string;
+ password?: string;
+ database?: string;
+ /** SQLite 文件路径(相对项目根或绝对路径) */
+ sqlitePath?: string;
+ };
+ server: {
+ port: number;
+ clientUrl?: string;
+ serverUrl?: string;
+ apiPrefix?: string;
+ siteUrl?: string;
+ };
+}
+
+export interface EnvMap {
+ [key: string]: string;
+}
+
+export interface MysqlCredentials {
+ host: string;
+ port: number;
+ user: string;
+ password: string;
+ database: string;
+}
+
+export interface SqliteCredentials {
+ database: string;
+}
+
+export interface DatabaseProfile {
+ type: DatabaseType;
+ mode: DatabaseMode;
+ mysql?: MysqlCredentials;
+ sqlite?: SqliteCredentials;
+}
+
+export interface DatabaseEnsureResult {
+ ok: boolean;
+ message?: string;
+}
+
+export interface ServerStatus {
+ running: boolean;
+ pid?: number;
+ port?: number;
+ url?: string;
+ databaseReady: boolean;
+ databaseMode: DatabaseMode;
+ databaseType: DatabaseType;
+}
diff --git a/cli/src/ui/banner.ts b/cli/src/ui/banner.ts
new file mode 100644
index 00000000..4b176d79
--- /dev/null
+++ b/cli/src/ui/banner.ts
@@ -0,0 +1,442 @@
+// @ts-nocheck
+const os = require('os');
+const path = require('path');
+const chalk = require('chalk');
+const {
+ brand,
+ icon,
+ palette,
+ visibleLength,
+ padRight,
+ terminalWidth,
+ gradientText,
+ pulseBar,
+ statusLights,
+} = require('./theme');
+const { t } = require('../lib/i18n');
+
+/**
+ * "REACTPRESS" rendered in the ANSI Shadow font.
+ * Each row is exactly 81 single-cell columns, so we can size the surrounding
+ * cyber-card deterministically without measuring per-glyph widths.
+ */
+const TECH_LOGO = [
+ '██████╗ ███████╗ █████╗ ██████╗████████╗██████╗ ██████╗ ███████╗███████╗███████╗',
+ '██╔══██╗██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝',
+ '██████╔╝█████╗ ███████║██║ ██║ ██████╔╝██████╔╝█████╗ ███████╗███████╗',
+ '██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██╔═══╝ ██╔══██╗██╔══╝ ╚════██║╚════██║',
+ '██║ ██║███████╗██║ ██║╚██████╗ ██║ ██║ ██║ ██║███████╗███████║███████║',
+ '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝',
+];
+
+const LOGO_WIDTH = 81;
+const LOGO_GRADIENTS = [
+ [palette.pink, palette.primary],
+ [palette.pink, palette.primary],
+ [palette.primary, palette.accent],
+ [palette.primary, palette.accent],
+ [palette.accent, palette.primary],
+ [palette.accent, palette.primary],
+];
+
+const REPO_URL = 'https://github.com/fecommunity/reactpress';
+/**
+ * Shorter, human-friendly form of REPO_URL shown beneath the title bar.
+ * The clickable hyperlink still resolves to the full https:// URL via
+ * `hyperlink()`, so users can `cmd+click` from any modern terminal.
+ */
+const REPO_DISPLAY = 'github.com/fecommunity/reactpress';
+
+/**
+ * Wrap text in an OSC-8 hyperlink escape so terminals that support it (iTerm2,
+ * Warp, WezTerm, modern macOS Terminal, VS Code, GNOME Terminal, Kitty, …)
+ * render the label as a clickable link. We only emit the escape sequence when
+ * stdout is a real TTY — otherwise (CI logs, file redirects, dumb terminals)
+ * we fall back to the plain styled label so users never see the raw `]8;;`.
+ */
+function hyperlink(url, label) {
+ if (!process.stdout.isTTY) return label;
+ if (process.env.TERM === 'dumb') return label;
+ return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
+}
+
+function safeReadCliVersion() {
+ try {
+ return require(path.join(__dirname, '..', 'package.json')).version;
+ } catch {
+ return 'dev';
+ }
+}
+
+function homify(p) {
+ if (!p) return p;
+ const home = os.homedir();
+ if (home && p.startsWith(home)) {
+ return '~' + p.slice(home.length);
+ }
+ return p;
+}
+
+function renderLogoLines() {
+ return TECH_LOGO.map((line, i) => gradientText(line, LOGO_GRADIENTS[i]));
+}
+
+function modeChip(type) {
+ if (type === 'monorepo') {
+ return chalk
+ .bgHex(palette.primary)
+ .hex('#0B1220')
+ .bold(` ${t('banner.mode.monorepo')} `);
+ }
+ if (type === 'standalone') {
+ return chalk
+ .bgHex(palette.accent)
+ .hex('#0B1220')
+ .bold(` ${t('banner.mode.standalone')} `);
+ }
+ return chalk
+ .bgHex(palette.gray)
+ .hex('#0B1220')
+ .bold(` ${t('banner.mode.uninitialized')} `);
+}
+
+/**
+ * Decide how "ready" the welcome banner should look. When a fully
+ * initialized project is detected we render the pulse bar at 100% and
+ * report `ONLINE` status, instead of the static 70% placeholder that used
+ * to make `doctor` runs look incomplete even when everything passed.
+ */
+function bannerReadyState(options) {
+ const type = options && options.project && options.project.type;
+ if (type === 'monorepo' || type === 'standalone') {
+ return { ratio: 1, ready: true };
+ }
+ return { ratio: 0.4, ready: false };
+}
+
+/**
+ * Build the top edge of the cyber-card with a centered title block:
+ * ╔══════════[ REACTPRESS · v3.0.3 ]══════════╗
+ */
+function brandedTopBorder(version, width) {
+ const titleBlock =
+ brand.primary('[') +
+ ' ' +
+ gradientText('REACTPRESS', [palette.primary, palette.accent], { bold: true }) +
+ ' ' +
+ brand.muted('·') +
+ ' ' +
+ brand.accent(`v${version}`) +
+ ' ' +
+ brand.primary(']');
+ const dashTotal = Math.max(0, width - 2 - visibleLength(titleBlock));
+ const left = Math.floor(dashTotal / 2);
+ const right = dashTotal - left;
+ return (
+ brand.primary('╔' + '═'.repeat(left)) +
+ titleBlock +
+ brand.primary('═'.repeat(right) + '╗')
+ );
+}
+
+function bottomBorder(width) {
+ return brand.primary('╚' + '═'.repeat(width - 2) + '╝');
+}
+
+function bodyLine(content, innerWidth) {
+ const padded = padRight(content, innerWidth);
+ return brand.primary('║ ') + padded + brand.primary(' ║');
+}
+
+function emptyBodyLine(innerWidth) {
+ return bodyLine('', innerWidth);
+}
+
+/**
+ * A subtle "CRT scan-line" rendered just under the logo.
+ * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ */
+function scanline(width) {
+ return brand.muted('▔'.repeat(width));
+}
+
+/**
+ * Width of the left-side banner label column.
+ *
+ * Sized to fit our longest English label (`MODE` / `PATH` → 4 cells)
+ * plus a 2-cell trailing gap, which also accommodates the Chinese
+ * translations `模式` / `路径` (4 East-Asian cells each).
+ */
+const LABEL_WIDTH = 6;
+
+/**
+ * Centered, dim repo subtitle that sits directly under the top border.
+ * Replaces the previous in-body `◇ REPO ↗ …` row, which competed visually
+ * with the operational fields (MODE / PATH / pulse) further down.
+ */
+function repoSubline(innerWidth) {
+ const link =
+ brand.muted('↗ ') + hyperlink(REPO_URL, brand.accent.underline(REPO_DISPLAY));
+ const pad = Math.max(0, Math.floor((innerWidth - visibleLength(link)) / 2));
+ return ' '.repeat(pad) + link;
+}
+
+/**
+ * Single-cell-wide chip label, e.g. `◇ MODE ▸ monorepo`.
+ */
+function infoRow(label, value) {
+ return (
+ brand.accent('◇ ') +
+ brand.muted(padRight(label, LABEL_WIDTH)) +
+ ' ' +
+ brand.primary('▸ ') +
+ brand.dim(value)
+ );
+}
+
+/**
+ * Render the "command rail" navigation footer:
+ * ⟫ init ⟫ dev ⟫ build ⟫ deploy ⟫ publish
+ */
+function commandRail() {
+ const items = ['init', 'dev', 'build', 'deploy', 'publish'];
+ return items
+ .map(
+ (name) =>
+ brand.primary('⟫ ') + gradientText(name, [palette.primary, palette.accent])
+ )
+ .join(brand.muted(' '));
+}
+
+/**
+ * Wide, full-fat cyber banner: ASCII logo + scan-line + bordered card.
+ */
+function printWideBanner(version, options) {
+ const cols = terminalWidth();
+ const cardWidth = Math.min(Math.max(LOGO_WIDTH + 8, 88), cols - 2);
+ const innerWidth = cardWidth - 4;
+
+ const lines = [];
+ lines.push('');
+ lines.push(' ' + brandedTopBorder(version, cardWidth));
+ lines.push(' ' + bodyLine(repoSubline(innerWidth), innerWidth));
+ lines.push(' ' + emptyBodyLine(innerWidth));
+
+ const logoIndent = Math.max(0, Math.floor((innerWidth - LOGO_WIDTH) / 2));
+ const indent = ' '.repeat(logoIndent);
+ for (const logoLine of renderLogoLines()) {
+ lines.push(' ' + bodyLine(indent + logoLine, innerWidth));
+ }
+
+ const scanWidth = Math.min(innerWidth - 2, LOGO_WIDTH);
+ const scanIndent = ' '.repeat(Math.max(0, Math.floor((innerWidth - scanWidth) / 2)));
+ lines.push(' ' + bodyLine(scanIndent + scanline(scanWidth), innerWidth));
+
+ lines.push(' ' + emptyBodyLine(innerWidth));
+
+ const ready = bannerReadyState(options);
+ const subtitle =
+ chalk.bold(brand.accent('◆ ')) +
+ gradientText(t('banner.subtitle').trim(), [palette.accent, palette.primary, palette.pink], {
+ bold: true,
+ });
+ const stateLabel = ready.ready
+ ? brand.success(t('banner.systemOnline').trim())
+ : brand.warn(t('banner.systemPending').trim());
+ const right =
+ statusLights(ready.ready ? 'online' : 'pending') +
+ ' ' +
+ brand.dim(t('banner.systemLabel').trim() + ' ') +
+ stateLabel;
+ lines.push(' ' + bodyLine(subtitle + spacer(subtitle, right, innerWidth) + right, innerWidth));
+
+ lines.push(' ' + emptyBodyLine(innerWidth));
+
+ if (options.project) {
+ lines.push(
+ ' ' +
+ bodyLine(
+ brand.accent('◇ ') +
+ brand.muted(padRight(t('banner.label.mode').trim(), LABEL_WIDTH)) +
+ ' ' +
+ modeChip(options.project.type),
+ innerWidth
+ )
+ );
+ }
+ if (options.projectRoot) {
+ lines.push(
+ ' ' +
+ bodyLine(
+ infoRow(t('banner.label.path').trim(), homify(options.projectRoot)),
+ innerWidth
+ )
+ );
+ }
+
+ const pulseWidth = Math.min(28, innerWidth - 18);
+ if (pulseWidth > 8) {
+ const filled = Math.max(1, Math.min(pulseWidth, Math.round(pulseWidth * ready.ratio)));
+ const pulse = pulseBar(pulseWidth, filled);
+ const pulseStatus = ready.ready
+ ? t('banner.pulseReady').trim()
+ : t('banner.pulsePending').trim();
+ const pulseLine =
+ brand.accent('◇ ') +
+ brand.muted(padRight(t('banner.pulseLabel').trim(), LABEL_WIDTH)) +
+ ' ' +
+ pulse +
+ ' ' +
+ (ready.ready ? brand.success(pulseStatus) : brand.warn(pulseStatus));
+ lines.push(' ' + bodyLine(pulseLine, innerWidth));
+ }
+
+ lines.push(' ' + emptyBodyLine(innerWidth));
+ lines.push(' ' + bottomBorder(cardWidth));
+ lines.push(' ' + commandRail());
+ lines.push('');
+
+ for (const line of lines) console.log(line);
+}
+
+/**
+ * Pad between a left-aligned and a right-aligned segment so they sit on the
+ * same line of the cyber card.
+ */
+function spacer(left, right, innerWidth) {
+ const used = visibleLength(left) + visibleLength(right);
+ const gap = Math.max(2, innerWidth - used);
+ return ' '.repeat(gap);
+}
+
+/**
+ * Compact cyber banner for terminals that cannot host the full ASCII logo.
+ */
+function printCompactBanner(version, options) {
+ const cols = terminalWidth();
+ const cardWidth = Math.min(cols - 2, 76);
+ const innerWidth = cardWidth - 4;
+
+ const lines = [];
+ lines.push('');
+ lines.push(' ' + brandedTopBorder(version, cardWidth));
+ lines.push(' ' + bodyLine(repoSubline(innerWidth), innerWidth));
+ lines.push(' ' + emptyBodyLine(innerWidth));
+
+ const ready = bannerReadyState(options);
+ const wordmark =
+ brand.primary('▌▍▎ ') +
+ gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], {
+ bold: true,
+ }) +
+ brand.primary(' ▎▍▌');
+ const lights = statusLights(ready.ready ? 'online' : 'pending');
+ lines.push(
+ ' ' + bodyLine(wordmark + spacer(wordmark, lights, innerWidth) + lights, innerWidth)
+ );
+
+ const subtitle =
+ chalk.bold(brand.accent('◆ ')) + brand.dim(t('banner.subtitle').trim());
+ lines.push(' ' + bodyLine(subtitle, innerWidth));
+ lines.push(' ' + emptyBodyLine(innerWidth));
+
+ if (options.project) {
+ lines.push(
+ ' ' +
+ bodyLine(
+ brand.accent('◇ ') +
+ brand.muted(padRight(t('banner.label.mode').trim(), LABEL_WIDTH)) +
+ ' ' +
+ modeChip(options.project.type),
+ innerWidth
+ )
+ );
+ }
+ if (options.projectRoot) {
+ lines.push(
+ ' ' +
+ bodyLine(
+ infoRow(t('banner.label.path').trim(), homify(options.projectRoot)),
+ innerWidth
+ )
+ );
+ }
+
+ lines.push(' ' + emptyBodyLine(innerWidth));
+ lines.push(' ' + bottomBorder(cardWidth));
+ lines.push(' ' + commandRail());
+ lines.push('');
+
+ for (const line of lines) console.log(line);
+}
+
+/**
+ * Single-line banner for ultra-narrow terminals (CI logs, embedded shells).
+ */
+function printMinimalBanner(version, options) {
+ const ready = bannerReadyState(options);
+ const wordmark = gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], {
+ bold: true,
+ });
+ console.log('');
+ console.log(` ${brand.primary('▌▍▎')} ${wordmark} ${brand.muted('·')} ${brand.accent(`v${version}`)} ${statusLights(ready.ready ? 'online' : 'pending')}`);
+ console.log(` ${brand.dim(t('banner.subtitle').trim())}`);
+ if (options.project) {
+ console.log(` ${modeChip(options.project.type)}`);
+ }
+ if (options.projectRoot) {
+ console.log(` ${icon.bullet} ${brand.dim(homify(options.projectRoot))}`);
+ }
+ console.log(
+ ` ${brand.muted('↗')} ${hyperlink(REPO_URL, brand.accent.underline(REPO_URL))}`
+ );
+ console.log('');
+}
+
+/**
+ * Print the top-of-screen banner. Adaptive to terminal width: collapses to a
+ * single-line greeting on very narrow terminals, otherwise renders a bordered
+ * cyber-card with the full ANSI Shadow logo when there is room.
+ *
+ * @param {{
+ * projectRoot?: string,
+ * project?: { type: string, hasClient: boolean, hasServerSource: boolean }
+ * }} [options]
+ */
+function printBanner(options = {}) {
+ const version = safeReadCliVersion();
+ const cols = terminalWidth();
+
+ if (cols < 64) {
+ printMinimalBanner(version, options);
+ return;
+ }
+
+ if (cols < LOGO_WIDTH + 10) {
+ printCompactBanner(version, options);
+ return;
+ }
+
+ printWideBanner(version, options);
+}
+
+/**
+ * Box helper retained for backwards compatibility: a few callers still
+ * import `box` from this module to wrap arbitrary multi-line content.
+ */
+function box(lines, { width } = {}) {
+ const innerWidth = width
+ ? width - 4
+ : lines.reduce((max, line) => Math.max(max, visibleLength(line)), 0);
+
+ const horizontal = '═'.repeat(innerWidth + 2);
+ const top = brand.primary(` ╔${horizontal}╗`);
+ const bottom = brand.primary(` ╚${horizontal}╝`);
+ const body = lines.map((line) => {
+ const padded = padRight(line, innerWidth);
+ return brand.primary(' ║ ') + padded + brand.primary(' ║');
+ });
+ return [top, ...body, bottom];
+}
+
+module.exports = { printBanner, visibleLength, padRight, box };
diff --git a/cli/src/ui/interactive.ts b/cli/src/ui/interactive.ts
new file mode 100644
index 00000000..77bca6b1
--- /dev/null
+++ b/cli/src/ui/interactive.ts
@@ -0,0 +1,436 @@
+// @ts-nocheck
+const fs = require('fs');
+const path = require('path');
+const inquirer = require('inquirer');
+const ora = require('ora');
+const open = require('open');
+const { printBanner } = require('./banner');
+const {
+ brand,
+ icon,
+ label,
+ ok,
+ fail,
+ sectionHeader,
+ statusPill,
+ padRight,
+} = require('./theme');
+const { ensureOriginalCwd } = require('../lib/root');
+const { describeProject } = require('../lib/project-type');
+const { hasResolvableActiveTheme } = require('../lib/theme-runtime');
+const { ensureProjectEnvironment, initMonorepoProject } = require('../lib/bootstrap');
+const { runDev, runThemeDev, runWebDev, runLocalWebDev, runDesktopDev } = require('../lib/dev');
+const { runApiDev } = require('../lib/api-dev');
+const { runLifecycleCommand } = require('../lib/lifecycle');
+const { runDockerCommand } = require('../lib/docker');
+const { runNginxCommand } = require('../lib/nginx');
+const { printUnifiedStatus } = require('../lib/status');
+const { runDoctor } = require('../lib/doctor');
+const { runDbBackup } = require('../lib/db-backup');
+const { runBuild, TARGETS } = require('../lib/build');
+const { loadClientSiteUrl, loadServerSiteUrl, isHttpResponding } = require('../lib/http');
+const { isDockerRunning } = require('../lib/docker');
+const { t } = require('../lib/i18n');
+
+function menuSection(title) {
+ return new inquirer.Separator(sectionHeader(title));
+}
+
+function formatChoice(key, text, hint) {
+ const keyCol = key ? brand.primary(padRight(key, 2)) : ' ';
+ const hintPart = hint ? brand.dim(` ${hint}`) : '';
+ return `${keyCol} ${text}${hintPart}`;
+}
+
+function assignShortcuts(items) {
+ let n = 0;
+ return items.map((item) => {
+ if (item instanceof inquirer.Separator || item.type === 'separator') {
+ return item;
+ }
+ n += 1;
+ const key = n <= 9 ? String(n) : '';
+ return {
+ ...item,
+ name: formatChoice(key, item._label || item.name, item._hint),
+ short: item.value,
+ };
+ });
+}
+
+function choice(labelKey, value, hintKey) {
+ return {
+ _label: t(labelKey),
+ _hint: hintKey ? t(hintKey) : '',
+ value,
+ };
+}
+
+function getMenuActions(project) {
+ const standalone = project.type === 'standalone';
+ const monorepo = project.type === 'monorepo';
+ const showTheme = hasResolvableActiveTheme(project.root);
+
+ const items = [
+ menuSection(t('menu.section.run')),
+ choice('menu.dev', 'dev', 'menu.hint.dev'),
+ choice('menu.init', 'init', 'menu.hint.init'),
+ choice('menu.status', 'status', 'menu.hint.status'),
+ choice('menu.doctor', 'doctor', 'menu.hint.doctor'),
+ ];
+
+ const extendItems = [];
+ if (project.hasDesktop) {
+ extendItems.push(choice('menu.devDesktop', 'dev:desktop', 'menu.hint.devDesktop'));
+ }
+ if (project.hasWeb) {
+ extendItems.push(choice('menu.devWeb', 'dev:web', 'menu.hint.devWeb'));
+ extendItems.push(choice('menu.devLocalWeb', 'dev:local-web', 'menu.hint.devLocalWeb'));
+ }
+ extendItems.push(choice('menu.initLocal', 'init:local', 'menu.hint.initLocal'));
+ extendItems.push(choice('menu.themeList', 'theme:list', 'menu.hint.themeList'));
+ if (monorepo && project.hasPluginsWorkspace) {
+ extendItems.push(choice('menu.pluginList', 'plugin:list', 'menu.hint.pluginList'));
+ }
+ if (extendItems.length > 0) {
+ items.push(menuSection(t('menu.section.extend')), ...extendItems);
+ }
+
+ items.push(
+ menuSection(t('menu.section.lifecycle')),
+ choice('menu.devApi', 'dev:api', 'menu.hint.devApi'),
+ );
+
+ if (showTheme) {
+ items.push(choice('menu.devClient', 'dev:client', 'menu.hint.devClient'));
+ }
+
+ items.push(
+ choice('menu.serverStart', 'server:start', 'menu.hint.serverStart'),
+ choice('menu.serverStop', 'server:stop', 'menu.hint.serverStop'),
+ choice('menu.serverRestart', 'server:restart', 'menu.hint.serverRestart'),
+ menuSection(t('menu.section.build')),
+ choice('menu.build', 'build', 'menu.hint.build')
+ );
+
+ if (monorepo) {
+ items.push(
+ choice('menu.dockerStart', 'docker:start', 'menu.hint.dockerStart'),
+ choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'),
+ choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop')
+ );
+ } else if (standalone) {
+ items.push(
+ choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'),
+ choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop')
+ );
+ }
+
+ items.push(
+ menuSection(t('menu.section.tools')),
+ choice('menu.nginxUp', 'nginx:up', 'menu.hint.nginxUp'),
+ choice('menu.nginxOpen', 'nginx:open', 'menu.hint.nginxOpen'),
+ choice('menu.nginxReload', 'nginx:reload', 'menu.hint.nginxReload'),
+ choice('menu.dbBackup', 'db:backup', 'menu.hint.dbBackup'),
+ choice('menu.openAdmin', 'open:admin', 'menu.hint.openAdmin'),
+ );
+
+ if (monorepo) {
+ items.push(choice('menu.publish', 'publish', 'menu.hint.publish'));
+ }
+
+ items.push(
+ new inquirer.Separator(),
+ choice('menu.exit', 'exit', 'menu.hint.exit')
+ );
+
+ return assignShortcuts(items);
+}
+
+function parseEnvFile(projectRoot) {
+ const envPath = path.join(projectRoot, '.env');
+ const env = {};
+ try {
+ if (!fs.existsSync(envPath)) return env;
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
+ if (m) env[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
+ }
+ } catch {
+ // ignore
+ }
+ return env;
+}
+
+async function probeDatabase(projectRoot) {
+ try {
+ const mysql = require('mysql2/promise');
+ const env = parseEnvFile(projectRoot);
+ const conn = await mysql.createConnection({
+ host: env.DB_HOST || '127.0.0.1',
+ port: Number(env.DB_PORT || 3306),
+ user: env.DB_USER || 'reactpress',
+ password: env.DB_PASSWD || env.DB_PASSWORD || 'reactpress',
+ database: env.DB_DATABASE || 'reactpress',
+ connectTimeout: 2000,
+ });
+ await conn.ping();
+ await conn.end();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function fetchContextStatus(projectRoot) {
+ const apiUrl = loadServerSiteUrl(projectRoot);
+ const [apiOk, dockerOk, dbOk] = await Promise.all([
+ isHttpResponding(apiUrl, 1500),
+ Promise.resolve(isDockerRunning()),
+ probeDatabase(projectRoot),
+ ]);
+ return { apiOk, dbOk, dockerOk };
+}
+
+function printStatusPanel(status) {
+ const on = { on: t('menu.statusOn'), off: t('menu.statusOff') };
+ const db = {
+ on: t('menu.statusReady'),
+ off: t('menu.statusNotReady'),
+ pending: t('menu.statusChecking'),
+ };
+ const docker = { on: t('menu.statusYes'), off: t('menu.statusNo') };
+
+ console.log(sectionHeader(t('menu.statusHeader')));
+ const rows = [
+ [t('menu.statusLabelApi'), statusPill(status.apiOk, on)],
+ [t('menu.statusLabelDb'), statusPill(status.dbOk, db)],
+ [t('menu.statusLabelDocker'), statusPill(status.dockerOk, docker)],
+ ];
+ for (const [name, pill] of rows) {
+ console.log(` ${brand.muted(padRight(name, 10))} ${pill}`);
+ }
+ console.log('');
+}
+
+async function printContextStatus(projectRoot) {
+ const spinner = ora({
+ text: brand.dim(t('menu.statusChecking')),
+ color: 'magenta',
+ spinner: 'dots',
+ }).start();
+ const status = await fetchContextStatus(projectRoot);
+ spinner.stop();
+ printStatusPanel(status);
+ return status;
+}
+
+async function withSpinner(text, fn) {
+ const spinner = ora({ text, color: 'magenta', spinner: 'dots' }).start();
+ try {
+ const result = await fn();
+ spinner.succeed();
+ return result;
+ } catch (err) {
+ spinner.fail();
+ throw err;
+ }
+}
+
+async function runMenuAction(action, projectRoot, project) {
+ switch (action) {
+ case 'dev':
+ console.log(label(t('menu.startingDev')));
+ await runDev(projectRoot);
+ return false;
+ case 'init': {
+ const result = await withSpinner(t('menu.initProject'), () =>
+ ensureProjectEnvironment(projectRoot)
+ );
+ console.log(ok(result.message || t('menu.done')));
+ return true;
+ }
+ case 'status':
+ await printUnifiedStatus(projectRoot);
+ return true;
+ case 'doctor': {
+ const code = await runDoctor(projectRoot);
+ if (code !== 0) process.exit(code);
+ return true;
+ }
+ case 'dev:api':
+ await runApiDev(projectRoot);
+ return false;
+ case 'dev:client':
+ await runThemeDev(projectRoot);
+ return false;
+ case 'dev:desktop':
+ await runDesktopDev(projectRoot);
+ return false;
+ case 'dev:web':
+ await runWebDev(projectRoot);
+ return false;
+ case 'dev:local-web':
+ process.env.REACTPRESS_LOCAL_MODE = '1';
+ process.env.REACTPRESS_SKIP_NGINX = '1';
+ await runLocalWebDev(projectRoot);
+ return false;
+ case 'init:local': {
+ const { isMonorepoCheckout } = require('../lib/root');
+ const { initProject } = require('../core/services/init');
+ const result = await withSpinner(t('menu.initProject'), async () => {
+ if (isMonorepoCheckout(projectRoot)) {
+ return initMonorepoProject(projectRoot, { local: true });
+ }
+ return initProject({ directory: projectRoot, force: false, local: true });
+ });
+ console.log(ok(result.message || t('menu.done')));
+ return true;
+ }
+ case 'theme:list':
+ require('../lib/theme-cli').runThemeList(projectRoot);
+ return true;
+ case 'plugin:list':
+ require('../lib/plugin-cli').runPluginList(projectRoot);
+ return true;
+ case 'db:backup':
+ await withSpinner(t('cli.db.backup'), async () => {
+ await runDbBackup(projectRoot);
+ });
+ return true;
+ case 'server:start': {
+ const code = await withSpinner(t('menu.startingApi'), () =>
+ runLifecycleCommand('start', projectRoot)
+ );
+ if (code !== 0) process.exit(code);
+ return true;
+ }
+ case 'server:stop':
+ await withSpinner(t('menu.stoppingApi'), async () => {
+ await runLifecycleCommand('stop', projectRoot);
+ });
+ return true;
+ case 'server:restart': {
+ const code = await withSpinner(t('menu.restartingApi'), () =>
+ runLifecycleCommand('restart', projectRoot)
+ );
+ if (code !== 0) process.exit(code);
+ return true;
+ }
+ case 'build': {
+ const buildChoices = TARGETS.map((target) => ({
+ name:
+ target === 'all'
+ ? t('menu.buildAll')
+ : t(`build.label.${target}`),
+ value: target,
+ }));
+ const { target } = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'target',
+ message: t('menu.buildTarget'),
+ pageSize: 12,
+ choices: buildChoices,
+ },
+ ]);
+ await runBuild(target, projectRoot);
+ return true;
+ }
+ case 'docker:start':
+ await runDockerCommand('start', projectRoot);
+ return false;
+ case 'docker:up':
+ await withSpinner(t('docker.starting'), () => runDockerCommand('up', projectRoot));
+ return true;
+ case 'docker:stop':
+ await withSpinner(t('docker.stopping'), async () => {
+ await runDockerCommand('down', projectRoot);
+ });
+ return true;
+ case 'nginx:up':
+ await withSpinner(t('cli.nginx.up'), async () => {
+ await runNginxCommand('up', projectRoot);
+ });
+ return true;
+ case 'nginx:open':
+ await runNginxCommand('open', projectRoot);
+ return true;
+ case 'nginx:reload':
+ await runNginxCommand('reload', projectRoot);
+ return true;
+ case 'open:admin': {
+ const url = loadClientSiteUrl(projectRoot);
+ console.log(label(t('menu.opening', { url })));
+ await open(url);
+ return true;
+ }
+ case 'publish': {
+ const prev = process.argv.slice();
+ process.argv = [process.argv[0], process.argv[1], '--publish'];
+ await require('../lib/publish').main();
+ process.argv = prev;
+ return true;
+ }
+ case 'exit':
+ return false;
+ default:
+ return true;
+ }
+}
+
+async function runInteractiveLoop() {
+ const projectRoot = ensureOriginalCwd();
+ const project = describeProject(projectRoot);
+
+ printBanner({ projectRoot, project });
+ await printContextStatus(projectRoot);
+ console.log(` ${brand.dim(t('menu.shortcuts'))}`);
+ console.log('');
+
+ let loop = true;
+ while (loop) {
+ const { action } = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'action',
+ message: `${brand.primary(t('menu.actionPrefix'))} ${brand.dim('›')}`,
+ pageSize: 20,
+ loop: false,
+ choices: getMenuActions(project),
+ },
+ ]);
+
+ if (action === 'exit') {
+ console.log(brand.muted(t('menu.goodbye')));
+ break;
+ }
+
+ try {
+ const stay = await runMenuAction(action, projectRoot, project);
+ if (!stay) break;
+
+ if (action !== 'status' && action !== 'doctor') {
+ console.log('');
+ await printContextStatus(projectRoot);
+ }
+ } catch (err) {
+ console.error(fail(err.message || err));
+ const { retry } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'retry',
+ message: t('menu.retry'),
+ default: true,
+ },
+ ]);
+ loop = retry;
+ if (loop) {
+ console.log('');
+ await printContextStatus(projectRoot);
+ }
+ }
+ }
+}
+
+module.exports = { runInteractiveLoop, runMenuAction, getMenuActions };
diff --git a/cli/src/ui/theme.ts b/cli/src/ui/theme.ts
new file mode 100644
index 00000000..a84bb9a6
--- /dev/null
+++ b/cli/src/ui/theme.ts
@@ -0,0 +1,269 @@
+// @ts-nocheck
+const chalk = require('chalk');
+
+/**
+ * ReactPress CLI visual identity — a single source of truth so banners,
+ * menus, status, doctor, build output all share the same colours and glyphs.
+ */
+const palette = {
+ primary: '#7C5CFF',
+ accent: '#22D3EE',
+ pink: '#F472B6',
+ green: '#22C55E',
+ amber: '#F59E0B',
+ red: '#EF4444',
+ gray: '#6B7280',
+ dim: '#9CA3AF',
+};
+
+const brand = {
+ primary: chalk.hex(palette.primary),
+ accent: chalk.hex(palette.accent),
+ pink: chalk.hex(palette.pink),
+ success: chalk.hex(palette.green),
+ warn: chalk.hex(palette.amber),
+ error: chalk.hex(palette.red),
+ muted: chalk.hex(palette.gray),
+ dim: chalk.hex(palette.dim),
+ bold: chalk.bold,
+};
+
+const icon = {
+ ok: brand.success('✓'),
+ fail: brand.error('✗'),
+ warn: brand.warn('⚠'),
+ info: brand.accent('ℹ'),
+ arrow: brand.primary('›'),
+ pointer: brand.primary('▸'),
+ bullet: brand.muted('·'),
+ dotOn: brand.success('●'),
+ dotOff: brand.muted('○'),
+ dotPending: brand.warn('◐'),
+ dotInfo: brand.accent('●'),
+ spark: brand.primary('✱'),
+ link: brand.muted('↗'),
+};
+
+/**
+ * Whether a Unicode code point should occupy two terminal cells.
+ *
+ * Covers the common "East Asian Wide / Full-width" ranges that show up in
+ * Chinese / Japanese / Korean text plus full-width punctuation. We
+ * deliberately do not pull in a heavy dependency like `string-width` to keep
+ * the CLI's startup cheap.
+ */
+function isWideCodePoint(cp) {
+ return (
+ (cp >= 0x1100 && cp <= 0x115f) ||
+ (cp >= 0x2e80 && cp <= 0x303e) ||
+ (cp >= 0x3041 && cp <= 0x33ff) ||
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
+ (cp >= 0xf900 && cp <= 0xfaff) ||
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
+ (cp >= 0xff00 && cp <= 0xff60) ||
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
+ (cp >= 0x20000 && cp <= 0x2fffd) ||
+ (cp >= 0x30000 && cp <= 0x3fffd)
+ );
+}
+
+/**
+ * Visible terminal-cell width of a string, after stripping ANSI colour codes
+ * and accounting for East Asian wide characters (which occupy 2 cells).
+ */
+function visibleLength(text) {
+ const stripped = String(text)
+ .replace(/\u001b\[[0-9;]*m/g, '')
+ .replace(/\u001b\]8;[^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '');
+ let width = 0;
+ for (const ch of stripped) {
+ const cp = ch.codePointAt(0);
+ if (cp === undefined) continue;
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue;
+ width += isWideCodePoint(cp) ? 2 : 1;
+ }
+ return width;
+}
+
+function padRight(text, width) {
+ const len = visibleLength(text);
+ if (len >= width) return text;
+ return text + ' '.repeat(width - len);
+}
+
+function padLeft(text, width) {
+ const len = visibleLength(text);
+ if (len >= width) return text;
+ return ' '.repeat(width - len) + text;
+}
+
+function terminalWidth(fallback = 80) {
+ const cols = Number(process.stdout.columns) || fallback;
+ return Math.max(48, Math.min(120, cols));
+}
+
+function divider(width = 44, char = '─', colorize = brand.muted) {
+ return colorize(char.repeat(width));
+}
+
+/**
+ * Cyberpunk-flavoured progress-bar style decoration.
+ * `filled` segments use the primary colour, the trailing track stays muted.
+ */
+function pulseBar(width = 24, filled = Math.ceil(width * 0.7)) {
+ const f = Math.max(0, Math.min(width, filled));
+ const head = brand.primary('▰'.repeat(f));
+ const tail = brand.muted('▱'.repeat(Math.max(0, width - f)));
+ return `${head}${tail}`;
+}
+
+/**
+ * Three-light status indicator used in the top-right of the banner.
+ * Mimics the running-light cluster you'd see on a server rack.
+ */
+function statusLights(state = 'online') {
+ if (state === 'offline') {
+ return `${brand.muted('●')} ${brand.muted('●')} ${brand.muted('●')}`;
+ }
+ if (state === 'degraded') {
+ return `${brand.warn('●')} ${brand.muted('●')} ${brand.muted('○')}`;
+ }
+ if (state === 'pending') {
+ return `${brand.warn('●')} ${brand.warn('●')} ${brand.muted('○')}`;
+ }
+ return `${brand.success('●')} ${brand.warn('●')} ${brand.muted('○')}`;
+}
+
+function hex2rgb(h) {
+ const s = h.replace('#', '');
+ return {
+ r: parseInt(s.substring(0, 2), 16),
+ g: parseInt(s.substring(2, 4), 16),
+ b: parseInt(s.substring(4, 6), 16),
+ };
+}
+
+function rgb2hex(r, g, b) {
+ const pad = (n) => n.toString(16).padStart(2, '0');
+ return `#${pad(r)}${pad(g)}${pad(b)}`;
+}
+
+function mixHex(a, b, t) {
+ const pa = hex2rgb(a);
+ const pb = hex2rgb(b);
+ const r = Math.round(pa.r + (pb.r - pa.r) * t);
+ const g = Math.round(pa.g + (pb.g - pa.g) * t);
+ const bl = Math.round(pa.b + (pb.b - pa.b) * t);
+ return rgb2hex(r, g, bl);
+}
+
+/**
+ * Paint a string with a left→right linear gradient across `colors` (hex).
+ * Falls back to plain text when stdout does not support truecolor.
+ */
+function gradientText(text, colors = [palette.primary, palette.accent], { bold = false } = {}) {
+ if (!text) return '';
+ const supports = chalk.supportsColor && chalk.supportsColor.has16m;
+ if (!supports || colors.length < 2) {
+ const c = chalk.hex(colors[0] || palette.primary);
+ return bold ? c.bold(text) : c(text);
+ }
+ const chars = [...String(text)];
+ const n = Math.max(chars.length - 1, 1);
+ return chars
+ .map((ch, i) => {
+ const ratio = i / n;
+ const idx = ratio * (colors.length - 1);
+ const lo = Math.floor(idx);
+ const hi = Math.min(colors.length - 1, lo + 1);
+ const local = idx - lo;
+ const c = chalk.hex(mixHex(colors[lo], colors[hi], local));
+ return bold ? c.bold(ch) : c(ch);
+ })
+ .join('');
+}
+
+function label(text) {
+ return `${icon.arrow} ${brand.primary(text)}`;
+}
+
+function ok(text) {
+ return `${icon.ok} ${brand.success(text)}`;
+}
+
+function fail(text) {
+ return `${icon.fail} ${brand.error(text)}`;
+}
+
+function warn(text) {
+ return `${icon.warn} ${brand.warn(text)}`;
+}
+
+function info(text) {
+ return `${icon.info} ${brand.accent(text)}`;
+}
+
+function chip(text, color = brand.primary) {
+ return color(`[ ${text} ]`);
+}
+
+function kv(key, value, { keyWidth = 10, valueColor = (s) => s } = {}) {
+ return `${brand.muted(padRight(key, keyWidth))} ${valueColor(value)}`;
+}
+
+/**
+ * Render a 3-state status pill, e.g. `● online` / `○ offline` / `◐ pending`.
+ *
+ * @param {boolean | 'pending'} state
+ * @param {{ on?: string, off?: string, pending?: string }} labels
+ */
+function statusPill(state, labels = {}) {
+ if (state === 'pending') {
+ return `${icon.dotPending} ${brand.warn(labels.pending || 'pending')}`;
+ }
+ if (state === true) {
+ return `${icon.dotOn} ${brand.success(labels.on || 'online')}`;
+ }
+ return `${icon.dotOff} ${brand.dim(labels.off || 'offline')}`;
+}
+
+/**
+ * Render a single-line section header: ` ── Title ────────────`.
+ */
+function sectionHeader(title, { width } = {}) {
+ const w = width ?? terminalWidth();
+ const prefix = brand.muted('── ');
+ const t = brand.bold(brand.primary(title));
+ const usedLen = visibleLength(prefix) + visibleLength(t) + 2;
+ const fillLen = Math.max(3, w - usedLen - 2);
+ const fill = brand.muted('─'.repeat(fillLen));
+ return ` ${prefix}${t} ${fill}`;
+}
+
+module.exports = {
+ palette,
+ brand,
+ icon,
+ label,
+ ok,
+ fail,
+ warn,
+ info,
+ chip,
+ kv,
+ statusPill,
+ sectionHeader,
+ visibleLength,
+ padRight,
+ padLeft,
+ terminalWidth,
+ divider,
+ gradientText,
+ pulseBar,
+ statusLights,
+};
diff --git a/cli/templates/config.local.json b/cli/templates/config.local.json
new file mode 100644
index 00000000..60648b69
--- /dev/null
+++ b/cli/templates/config.local.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "database": {
+ "mode": "embedded-sqlite",
+ "sqlitePath": "data/reactpress.db"
+ },
+ "server": {
+ "port": 3002,
+ "clientUrl": "http://localhost:3001",
+ "serverUrl": "http://127.0.0.1:3002",
+ "apiPrefix": "/api"
+ }
+}
diff --git a/cli/templates/env.local.default b/cli/templates/env.local.default
new file mode 100644
index 00000000..e33626d7
--- /dev/null
+++ b/cli/templates/env.local.default
@@ -0,0 +1,10 @@
+# ReactPress — local SQLite mode (auto-generated)
+DB_TYPE=sqlite
+DB_DATABASE=data/reactpress.db
+SERVER_PORT=3002
+SERVER_SITE_URL=http://127.0.0.1:3002
+CLIENT_SITE_URL=http://localhost:3001
+SERVER_API_PREFIX=/api
+REACTPRESS_UPLOAD_DIR=uploads
+ADMIN_USER=admin
+ADMIN_PASSWD=admin
diff --git a/cli/templates/theme-catalog.json b/cli/templates/theme-catalog.json
new file mode 100644
index 00000000..eaa37aee
--- /dev/null
+++ b/cli/templates/theme-catalog.json
@@ -0,0 +1,27 @@
+{
+ "version": 1,
+ "themes": [
+ {
+ "id": "reactpress-theme-starter",
+ "name": "ReactPress Theme Starter",
+ "version": "1.0.0-beta.0",
+ "description": "官方 Next.js 15 主题 — Tailwind CSS、知识库、评论、深色模式,Lighthouse 95+。",
+ "author": "ReactPress",
+ "themeUri": "https://github.com/fecommunity/reactpress-theme-starter",
+ "previewUrl": "https://reactpress-theme-starter.vercel.app",
+ "tags": [
+ "官方",
+ "Tailwind",
+ "App Router",
+ "Next.js 15"
+ ],
+ "dependency": {
+ "name": "@fecommunity/reactpress-theme-starter",
+ "version": "1.0.0-beta.0"
+ },
+ "npm": "@fecommunity/reactpress-theme-starter@1.0.0-beta.0",
+ "featured": true,
+ "requires": ">=3.0.0"
+ }
+ ]
+}
diff --git a/cli/tests/build.test.js b/cli/tests/build.test.js
index 5b35247e..e25ca549 100644
--- a/cli/tests/build.test.js
+++ b/cli/tests/build.test.js
@@ -1,6 +1,8 @@
+const fs = require('fs');
+const path = require('path');
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
-const { resolveBuildInvocation, TARGETS } = require('../lib/build');
+const { resolveBuildInvocation, TARGETS } = require('../out/lib/build');
const { createMonorepoFixture, createStandaloneProject, rmDir } = require('./helpers/tmp-project');
describe('lib/build', () => {
@@ -15,10 +17,10 @@ describe('lib/build', () => {
}
});
- it('skips client build when client/ is missing', () => {
+ it('skips theme build when active theme is missing', () => {
const root = createStandaloneProject();
try {
- const inv = resolveBuildInvocation('build:client', root);
+ const inv = resolveBuildInvocation('build:theme', root);
assert.equal(inv, null);
} finally {
rmDir(root);
@@ -28,5 +30,22 @@ describe('lib/build', () => {
it('exposes known targets', () => {
assert.ok(TARGETS.includes('all'));
assert.ok(TARGETS.includes('toolkit'));
+ assert.ok(TARGETS.includes('web'));
+ });
+
+ it('includes web in all steps when web/ exists', () => {
+ const root = createMonorepoFixture();
+ try {
+ fs.mkdirSync(path.join(root, 'web'));
+ fs.writeFileSync(
+ path.join(root, 'web', 'package.json'),
+ JSON.stringify({ name: 'web', scripts: { build: 'echo build' } })
+ );
+ const { getBuildSteps } = require('../out/lib/build');
+ const steps = getBuildSteps('all', root);
+ assert.ok(steps.some((s) => s.script === 'build:web'));
+ } finally {
+ rmDir(root);
+ }
});
});
diff --git a/cli/tests/docker.test.js b/cli/tests/docker.test.js
index 06142777..53d5912c 100644
--- a/cli/tests/docker.test.js
+++ b/cli/tests/docker.test.js
@@ -1,7 +1,7 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
-const { resolveComposeContext } = require('../lib/docker');
+const { resolveComposeContext } = require('../out/lib/docker');
const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project');
describe('lib/docker', () => {
diff --git a/cli/tests/http.test.js b/cli/tests/http.test.js
index 8b8d7d04..719a8043 100644
--- a/cli/tests/http.test.js
+++ b/cli/tests/http.test.js
@@ -5,7 +5,8 @@ const {
loadClientSiteUrl,
getApiPrefix,
getHealthUrl,
-} = require('../lib/http');
+ normalizeProbeUrl,
+} = require('../out/lib/http');
const { createStandaloneProject, rmDir } = require('./helpers/tmp-project');
describe('lib/http', () => {
@@ -20,4 +21,15 @@ describe('lib/http', () => {
rmDir(root);
}
});
+
+ it('normalizes localhost probes to IPv4 loopback', () => {
+ assert.equal(
+ normalizeProbeUrl('http://localhost:3002/api/health'),
+ 'http://127.0.0.1:3002/api/health',
+ );
+ assert.equal(
+ normalizeProbeUrl('http://[::1]:3001/'),
+ 'http://127.0.0.1:3001/',
+ );
+ });
});
diff --git a/cli/tests/i18n.test.js b/cli/tests/i18n.test.js
index d6e511d9..3a45be84 100644
--- a/cli/tests/i18n.test.js
+++ b/cli/tests/i18n.test.js
@@ -1,6 +1,6 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
-const { t, setLocale, getLocale } = require('../lib/i18n');
+const { t, setLocale, getLocale } = require('../out/lib/i18n');
describe('lib/i18n', () => {
it('translates known keys in en and zh', () => {
diff --git a/cli/tests/nginx.test.js b/cli/tests/nginx.test.js
index 540b6bb1..f9f4702c 100644
--- a/cli/tests/nginx.test.js
+++ b/cli/tests/nginx.test.js
@@ -6,7 +6,8 @@ const {
resolveNginxConfigPath,
resolveNginxComposeContext,
ensureNginxConfig,
-} = require('../lib/nginx');
+ renderDevNginxConfig,
+} = require('../out/lib/nginx');
const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project');
describe('lib/nginx', () => {
@@ -39,7 +40,44 @@ describe('lib/nginx', () => {
assert.equal(created, true);
assert.equal(configPath, target);
assert.ok(fs.existsSync(target));
- assert.ok(fs.readFileSync(target, 'utf8').includes('host.docker.internal'));
+ const content = fs.readFileSync(target, 'utf8');
+ assert.ok(content.includes('host.docker.internal'));
+ assert.ok(content.includes('host.docker.internal:3000/admin/'));
+ assert.ok(!content.includes(':5173'));
+ assert.ok(!content.includes('expires 1y'));
+ } finally {
+ rmDir(root);
+ }
+ });
+
+ it('renderDevNginxConfig uses local API port by default', () => {
+ const content = renderDevNginxConfig({
+ adminPort: 3000,
+ visitorPort: 3001,
+ apiPort: 3002,
+ });
+ assert.ok(content.includes('host.docker.internal:3002'));
+ });
+
+ it('ensureNginxConfig refreshes stale prod nginx (13001 → env ports)', () => {
+ const root = createMonorepoFixture();
+ try {
+ const configPath = path.join(root, 'nginx.conf');
+ fs.writeFileSync(
+ configPath,
+ 'location / { proxy_pass http://host.docker.internal:13001; }\nlocation /api { proxy_pass http://host.docker.internal:13002; }\n',
+ );
+ fs.writeFileSync(
+ path.join(root, '.env'),
+ 'CLIENT_SITE_URL=http://localhost:3001\nSERVER_SITE_URL=http://localhost:3002\n',
+ );
+ const { changed } = ensureNginxConfig(root, { prod: true });
+ assert.equal(changed, true);
+ const content = fs.readFileSync(configPath, 'utf8');
+ assert.ok(content.includes('host.docker.internal:3001'));
+ assert.ok(content.includes('host.docker.internal:3002'));
+ assert.ok(!content.includes('13001'));
+ assert.ok(!content.includes('13002'));
} finally {
rmDir(root);
}
diff --git a/cli/tests/parity-pack.test.js b/cli/tests/parity-pack.test.js
index 9754d0a8..e9128e9a 100644
--- a/cli/tests/parity-pack.test.js
+++ b/cli/tests/parity-pack.test.js
@@ -10,12 +10,12 @@ const REQUIRED_SHIPPED = [
'package.json',
'bin/reactpress.js',
'bin/reactpress-cli-shim.js',
- 'lib/root.js',
- 'lib/publish.js',
- 'lib/project-type.js',
- 'ui/interactive.js',
- 'ui/banner.js',
- 'ui/theme.js',
+ 'out/lib/root.js',
+ 'out/lib/publish.js',
+ 'out/lib/project-type.js',
+ 'out/ui/interactive.js',
+ 'out/ui/banner.js',
+ 'out/ui/theme.js',
'dist/index.js',
'templates/env.default',
'templates/config.default.json',
@@ -36,7 +36,7 @@ describe('publish/local file parity', () => {
const top = required.split('/')[0];
assert.ok(
declared.has(top) || declared.has(required),
- `package.json files[] missing "${top}" (needed for ${required})`
+ `package.json files[] missing "${top}" (needed for ${required})`,
);
}
});
diff --git a/cli/tests/project-type.test.js b/cli/tests/project-type.test.js
index d4982ad9..3606d626 100644
--- a/cli/tests/project-type.test.js
+++ b/cli/tests/project-type.test.js
@@ -4,7 +4,7 @@ const {
detectProjectType,
describeProject,
hasClient,
-} = require('../lib/project-type');
+} = require('../out/lib/project-type');
const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project');
describe('lib/project-type', () => {
diff --git a/cli/tests/publish-version.test.js b/cli/tests/publish-version.test.js
index e1051a9d..1eb4eafe 100644
--- a/cli/tests/publish-version.test.js
+++ b/cli/tests/publish-version.test.js
@@ -20,7 +20,7 @@ function incrementVersion(version, type) {
case 'beta': {
const match = version.match(/^(.*)-beta\.(\d+)$/);
if (match) return `${match[1]}-beta.${parseInt(match[2], 10) + 1}`;
- return `${version}-beta.1`;
+ return `${base}-beta.0`;
}
default:
return version;
@@ -36,7 +36,11 @@ describe('publish version bump', () => {
assert.equal(incrementVersion('3.0', 'patch'), '3.0.1');
});
- it('bumps beta', () => {
+ it('bumps beta from stable', () => {
+ assert.equal(incrementVersion('4.0.0', 'beta'), '4.0.0-beta.0');
+ });
+
+ it('bumps beta prerelease', () => {
assert.equal(incrementVersion('3.0.0-beta.1', 'beta'), '3.0.0-beta.2');
});
});
diff --git a/cli/tests/remote-dev.test.js b/cli/tests/remote-dev.test.js
new file mode 100644
index 00000000..d26810d5
--- /dev/null
+++ b/cli/tests/remote-dev.test.js
@@ -0,0 +1,98 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { renderDevNginxConfig } = require('../out/lib/nginx');
+const {
+ normalizeRemoteOrigin,
+ resolveRemoteThemeApiBase,
+ parseOriginSpec,
+ resolveDevApiOrigins,
+ applyDevApiOriginsToEnv,
+} = require('../out/lib/remote-dev');
+
+describe('lib/remote-dev', () => {
+ it('normalizes bare hostnames to https origins', () => {
+ assert.equal(normalizeRemoteOrigin('api.gaoredu.com'), 'https://api.gaoredu.com');
+ assert.equal(normalizeRemoteOrigin('https://api.gaoredu.com/'), 'https://api.gaoredu.com');
+ assert.equal(normalizeRemoteOrigin(''), null);
+ });
+
+ it('parseOriginSpec supports local, remote, and URL', () => {
+ assert.deepEqual(parseOriginSpec('local', 'https://api.gaoredu.com'), { url: null });
+ assert.deepEqual(parseOriginSpec('remote', 'https://api.gaoredu.com'), {
+ url: 'https://api.gaoredu.com',
+ });
+ assert.deepEqual(parseOriginSpec('remote', null), { error: 'REMOTE_DEFAULT_REQUIRED' });
+ assert.deepEqual(parseOriginSpec('api.gaoredu.com', null), { url: 'https://api.gaoredu.com' });
+ });
+
+ it('resolveDevApiOrigins splits admin and client', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-origins-'));
+ try {
+ const mixed = resolveDevApiOrigins(root, {
+ remoteOrigin: 'https://api.gaoredu.com',
+ adminOrigin: 'local',
+ clientOrigin: 'remote',
+ });
+ assert.equal(mixed.admin, null);
+ assert.equal(mixed.client, 'https://api.gaoredu.com');
+ assert.equal(mixed.needsLocalApi, true);
+
+ const both = resolveDevApiOrigins(root, {
+ remoteOrigin: 'api.gaoredu.com',
+ });
+ assert.equal(both.admin, 'https://api.gaoredu.com');
+ assert.equal(both.client, 'https://api.gaoredu.com');
+ assert.equal(both.needsLocalApi, false);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it('applyDevApiOriginsToEnv sets per-side env keys', () => {
+ const keys = [
+ 'REACTPRESS_DEV_REMOTE_ORIGIN',
+ 'REACTPRESS_DEV_ADMIN_API_ORIGIN',
+ 'REACTPRESS_DEV_CLIENT_API_ORIGIN',
+ ];
+ const prev = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
+ try {
+ applyDevApiOriginsToEnv({
+ remoteDefault: 'https://api.gaoredu.com',
+ admin: null,
+ client: 'https://api.gaoredu.com',
+ });
+ assert.equal(process.env.REACTPRESS_DEV_REMOTE_ORIGIN, 'https://api.gaoredu.com');
+ assert.equal(process.env.REACTPRESS_DEV_ADMIN_API_ORIGIN, undefined);
+ assert.equal(process.env.REACTPRESS_DEV_CLIENT_API_ORIGIN, 'https://api.gaoredu.com');
+ } finally {
+ for (const key of keys) {
+ if (prev[key] === undefined) delete process.env[key];
+ else process.env[key] = prev[key];
+ }
+ }
+ });
+
+ it('builds theme API base with /api suffix', () => {
+ assert.equal(
+ resolveRemoteThemeApiBase('https://api.gaoredu.com'),
+ 'https://api.gaoredu.com/api',
+ );
+ });
+});
+
+describe('renderDevNginxConfig client /api', () => {
+ it('proxies /api to remote upstream when clientApiOrigin is set', () => {
+ const content = renderDevNginxConfig({
+ adminPort: 3000,
+ visitorPort: 3001,
+ apiPort: 3002,
+ clientApiOrigin: 'https://api.gaoredu.com',
+ });
+ assert.ok(content.includes('proxy_pass https://api.gaoredu.com'));
+ assert.ok(content.includes('proxy_set_header Host api.gaoredu.com'));
+ assert.ok(!content.includes('host.docker.internal:3002'));
+ });
+});
diff --git a/cli/tests/root.test.js b/cli/tests/root.test.js
index 7b9dc672..c1a8c167 100644
--- a/cli/tests/root.test.js
+++ b/cli/tests/root.test.js
@@ -6,7 +6,7 @@ const {
isProjectRoot,
isPublishedCliRoot,
getMonorepoRoot,
-} = require('../lib/root');
+} = require('../out/lib/root');
const { createStandaloneProject, createMonorepoFixture, rmDir } = require('./helpers/tmp-project');
describe('lib/root', () => {
diff --git a/cli/tests/theme-catalog.test.js b/cli/tests/theme-catalog.test.js
new file mode 100644
index 00000000..35d5af1e
--- /dev/null
+++ b/cli/tests/theme-catalog.test.js
@@ -0,0 +1,13 @@
+const path = require('path');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+describe('lib/theme-catalog (re-export)', () => {
+ it('re-exports theme-registry API', () => {
+ const catalog = require('../out/lib/theme-catalog');
+ const registry = require('../out/lib/theme-registry');
+ assert.equal(catalog.OFFICIAL_THEME_STARTER_ID, registry.OFFICIAL_THEME_STARTER_ID);
+ assert.equal(typeof catalog.readThemeCatalog, 'function');
+ assert.equal(typeof catalog.validateBundledThemes, 'function');
+ });
+});
diff --git a/cli/tests/theme-dev-watch.test.js b/cli/tests/theme-dev-watch.test.js
new file mode 100644
index 00000000..bc3330a2
--- /dev/null
+++ b/cli/tests/theme-dev-watch.test.js
@@ -0,0 +1,75 @@
+const fs = require('fs');
+const path = require('path');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ hasThemePackages,
+ hasResolvableActiveTheme,
+ readActiveThemeManifest,
+ resolveThemeDirectory,
+ readManifestSignature,
+} = require('../out/lib/theme-runtime');
+const { createStandaloneProject, rmDir } = require('./helpers/tmp-project');
+
+const HELLO_WORLD = path.join(__dirname, '../../themes/hello-world');
+
+describe('theme dev watcher prerequisites', () => {
+ it('detects theme packages even when active theme is missing on disk', () => {
+ const root = createStandaloneProject();
+ try {
+ const runtimeHello = path.join(root, '.reactpress', 'runtime', 'hello-world');
+ fs.mkdirSync(path.dirname(runtimeHello), { recursive: true });
+ fs.cpSync(HELLO_WORLD, runtimeHello, { recursive: true });
+
+ const manifestPath = path.join(root, '.reactpress', 'active-theme.json');
+ fs.writeFileSync(
+ manifestPath,
+ JSON.stringify({ activeTheme: 'missing-theme', themeDir: null }, null, 2),
+ );
+
+ assert.equal(hasThemePackages(root), true);
+ assert.equal(hasResolvableActiveTheme(root), false);
+ assert.equal(readManifestSignature(root), '');
+ assert.ok(resolveThemeDirectory(root, 'hello-world'));
+ } finally {
+ rmDir(root);
+ }
+ });
+
+ it('manifest signature updates when active theme becomes resolvable', () => {
+ const root = createStandaloneProject();
+ try {
+ const runtimeHello = path.join(root, '.reactpress', 'runtime', 'hello-world');
+ fs.mkdirSync(path.dirname(runtimeHello), { recursive: true });
+ fs.cpSync(HELLO_WORLD, runtimeHello, { recursive: true });
+
+ const manifestPath = path.join(root, '.reactpress', 'active-theme.json');
+ fs.writeFileSync(
+ manifestPath,
+ JSON.stringify({ activeTheme: 'missing-theme' }, null, 2),
+ );
+ assert.equal(readManifestSignature(root), '');
+
+ fs.writeFileSync(
+ manifestPath,
+ JSON.stringify(
+ {
+ activeTheme: 'hello-world',
+ themeDir: '.reactpress/runtime/hello-world',
+ updatedAt: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ),
+ );
+
+ const signature = readManifestSignature(root);
+ assert.match(signature, /^hello-world:/);
+ assert.equal(readActiveThemeManifest(root).activeTheme, 'hello-world');
+ assert.equal(hasResolvableActiveTheme(root), true);
+ } finally {
+ rmDir(root);
+ }
+ });
+});
diff --git a/cli/tests/theme-install.test.js b/cli/tests/theme-install.test.js
new file mode 100644
index 00000000..576a727b
--- /dev/null
+++ b/cli/tests/theme-install.test.js
@@ -0,0 +1,63 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ parseNpmSpec,
+ resolveThemeIdentity,
+ installThemeFromNpm,
+} = require('../out/lib/theme-install');
+const { readThemeLock } = require('../out/lib/theme-lock');
+const { createStandaloneProject, rmDir } = require('./helpers/tmp-project');
+
+const HELLO_WORLD_THEME = path.join(__dirname, '../../themes/hello-world');
+
+describe('lib/theme-install', () => {
+ it('parseNpmSpec accepts npm specs and tarball paths', () => {
+ assert.deepEqual(parseNpmSpec('@fecommunity/reactpress-template-hello-world@3.0.4'), {
+ kind: 'npm',
+ spec: '@fecommunity/reactpress-template-hello-world@3.0.4',
+ });
+ assert.equal(parseNpmSpec('').error, 'EMPTY_SPEC');
+ });
+
+ it('resolveThemeIdentity reads theme.json id', () => {
+ const identity = resolveThemeIdentity(HELLO_WORLD_THEME);
+ assert.equal(identity?.themeId, 'hello-world');
+ assert.equal(identity?.manifest?.name, 'Hello World');
+ });
+
+ it('installThemeFromNpm materializes a local npm pack into runtime', async () => {
+ const root = createStandaloneProject();
+ const packDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-pack-'));
+ try {
+ const packResult = spawnSync(
+ 'npm',
+ ['pack', HELLO_WORLD_THEME, '--pack-destination', packDir],
+ { encoding: 'utf8', shell: process.platform === 'win32' },
+ );
+ assert.equal(packResult.status, 0, packResult.stderr || packResult.stdout);
+
+ const tarball = fs
+ .readdirSync(packDir)
+ .find((name) => name.endsWith('.tgz'));
+ assert.ok(tarball, 'npm pack should produce a tarball');
+
+ const result = await installThemeFromNpm(root, path.join(packDir, tarball), {
+ skipDependencies: true,
+ });
+ assert.equal(result.themeId, 'hello-world');
+ assert.ok(fs.existsSync(path.join(root, '.reactpress', 'runtime', 'hello-world', 'theme.json')));
+
+ const lock = readThemeLock(root);
+ assert.equal(lock.themes['hello-world']?.source, 'npm');
+ assert.match(lock.themes['hello-world']?.spec ?? '', /\.tgz$/);
+ } finally {
+ rmDir(root);
+ rmDir(packDir);
+ }
+ });
+});
diff --git a/cli/tests/theme-paths.test.js b/cli/tests/theme-paths.test.js
new file mode 100644
index 00000000..08e9f3fe
--- /dev/null
+++ b/cli/tests/theme-paths.test.js
@@ -0,0 +1,27 @@
+const path = require('path');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ THEME_RUNTIME_REL,
+ THEMES_CATALOG_REL,
+ PREVIEW_POOL_PORTS,
+ PREVIEW_PROXY_PORT,
+ getPreviewBackendPorts,
+ themesRoot,
+} = require('../out/lib/theme-paths');
+
+describe('lib/theme-paths', () => {
+ it('exports stable relative paths', () => {
+ assert.equal(THEME_RUNTIME_REL, '.reactpress/runtime');
+ assert.equal(THEMES_CATALOG_REL, 'themes/catalog.json');
+ assert.deepEqual(PREVIEW_POOL_PORTS, [3003]);
+ assert.equal(PREVIEW_PROXY_PORT, 3003);
+ assert.deepEqual(getPreviewBackendPorts(), [3004, 3005, 3006]);
+ });
+
+ it('themesRoot resolves under project root', () => {
+ const root = path.join(__dirname, '../..');
+ assert.equal(themesRoot(root), path.join(root, 'themes'));
+ });
+});
diff --git a/cli/tests/theme-preview-frame.test.js b/cli/tests/theme-preview-frame.test.js
new file mode 100644
index 00000000..33d2786a
--- /dev/null
+++ b/cli/tests/theme-preview-frame.test.js
@@ -0,0 +1,93 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ ensurePreviewFrameAllowed,
+ stripBakedFrameOptionsFromBuild,
+ shouldHonorThemePreviewFrame,
+ PATCH_MARKER,
+} = require('../out/lib/theme-preview-frame');
+
+describe('lib/theme-preview-frame', () => {
+ it('patches next.config.js to skip X-Frame-Options during admin preview', () => {
+ const themeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-frame-'));
+ try {
+ fs.writeFileSync(
+ path.join(themeDir, 'next.config.js'),
+ `module.exports = {
+ async headers() {
+ return [{
+ source: '/:path*',
+ headers: [
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
+ ],
+ }];
+ },
+};`,
+ );
+
+ assert.equal(ensurePreviewFrameAllowed(themeDir), true);
+ assert.ok(fs.existsSync(path.join(themeDir, PATCH_MARKER)));
+
+ const patched = fs.readFileSync(path.join(themeDir, 'next.config.js'), 'utf8');
+ assert.match(patched, /REACTPRESS_HONOR_PREVIEW === '1'/);
+ assert.match(patched, /\?\s*\[\]\s*:\s*\[\{ key: 'X-Frame-Options'/);
+
+ assert.equal(ensurePreviewFrameAllowed(themeDir), true);
+ } finally {
+ fs.rmSync(themeDir, { recursive: true, force: true });
+ }
+ });
+
+ it('strips baked X-Frame-Options from routes-manifest.json', () => {
+ const themeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reactpress-frame-manifest-'));
+ try {
+ const distDir = '.next-preview';
+ const manifestDir = path.join(themeDir, distDir);
+ fs.mkdirSync(manifestDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(manifestDir, 'routes-manifest.json'),
+ `${JSON.stringify(
+ {
+ headers: [
+ {
+ source: '/:path*',
+ headers: [
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
+ ],
+ },
+ ],
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ assert.equal(stripBakedFrameOptionsFromBuild(themeDir, distDir), true);
+ const parsed = JSON.parse(
+ fs.readFileSync(path.join(manifestDir, 'routes-manifest.json'), 'utf8'),
+ );
+ const keys = parsed.headers[0].headers.map((h) => h.key);
+ assert.deepEqual(keys, ['X-Content-Type-Options']);
+ assert.equal(stripBakedFrameOptionsFromBuild(themeDir, distDir), false);
+ } finally {
+ fs.rmSync(themeDir, { recursive: true, force: true });
+ }
+ });
+
+ it('detects desktop local preview frame mode', () => {
+ const prev = process.env.REACTPRESS_DESKTOP_LOCAL;
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+ try {
+ assert.equal(shouldHonorThemePreviewFrame(), true);
+ } finally {
+ if (prev === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL;
+ else process.env.REACTPRESS_DESKTOP_LOCAL = prev;
+ }
+ });
+});
diff --git a/cli/tests/theme-preview-pool.test.js b/cli/tests/theme-preview-pool.test.js
new file mode 100644
index 00000000..13b7ee9a
--- /dev/null
+++ b/cli/tests/theme-preview-pool.test.js
@@ -0,0 +1,90 @@
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const { resolvePreviewThemeLaunchPlan } = require('../out/lib/theme-preview-pool');
+
+const HELLO_WORLD = path.join(__dirname, '../../themes/hello-world');
+const STARTER = path.join(__dirname, '../../.reactpress/runtime/reactpress-theme-starter');
+
+describe('lib/theme-preview-pool launch plan', () => {
+ it('prefers dev script for hello-world outside integrated desktop dev', () => {
+ const prev = process.env.REACTPRESS_DESKTOP_LOCAL;
+ delete process.env.REACTPRESS_DESKTOP_LOCAL;
+ delete process.env.REACTPRESS_DESKTOP_SITE_ROOT;
+ try {
+ const plan = resolvePreviewThemeLaunchPlan(HELLO_WORLD, 3003);
+ assert.equal(plan.mode, 'dev');
+ assert.equal(plan.cmd, 'pnpm');
+ assert.deepEqual(plan.args, ['run', 'dev', '--', '--port', '3003']);
+ } finally {
+ if (prev === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL;
+ else process.env.REACTPRESS_DESKTOP_LOCAL = prev;
+ }
+ });
+
+ it('uses production next start for hello-world in dev:web:local stack', () => {
+ const prevLocal = process.env.REACTPRESS_DESKTOP_LOCAL;
+ const prevSite = process.env.REACTPRESS_DESKTOP_SITE_ROOT;
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+ try {
+ const plan = resolvePreviewThemeLaunchPlan(HELLO_WORLD, 3004);
+ assert.equal(plan.mode, 'production');
+ assert.equal(plan.cmd, process.execPath);
+ assert.match(plan.args.join(' '), /next start -p 3004/);
+ } finally {
+ if (prevLocal === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL;
+ else process.env.REACTPRESS_DESKTOP_LOCAL = prevLocal;
+ if (prevSite === undefined) delete process.env.REACTPRESS_DESKTOP_SITE_ROOT;
+ else process.env.REACTPRESS_DESKTOP_SITE_ROOT = prevSite;
+ }
+ });
+
+ it('uses shared next when packaged theme has no local node_modules', () => {
+ const prevLocal = process.env.REACTPRESS_DESKTOP_LOCAL;
+ const prevPath = process.env.NODE_PATH;
+ const prevRoot = process.env.REACTPRESS_MONOREPO_ROOT;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-theme-packaged-'));
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+ process.env.NODE_PATH = path.join(HELLO_WORLD, 'node_modules');
+ process.env.REACTPRESS_MONOREPO_ROOT = path.join(__dirname, '../..');
+ fs.copyFileSync(path.join(HELLO_WORLD, 'package.json'), path.join(tmpDir, 'package.json'));
+ fs.copyFileSync(path.join(HELLO_WORLD, 'server.js'), path.join(tmpDir, 'server.js'));
+ try {
+ const plan = resolvePreviewThemeLaunchPlan(tmpDir, 3005);
+ assert.equal(plan.mode, 'production');
+ assert.equal(plan.cmd, process.execPath);
+ assert.match(plan.args.join(' '), /next start -p 3005/);
+ assert.doesNotMatch(plan.args.join(' '), /server\.js/);
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ if (prevLocal === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL;
+ else process.env.REACTPRESS_DESKTOP_LOCAL = prevLocal;
+ if (prevPath === undefined) delete process.env.NODE_PATH;
+ else process.env.NODE_PATH = prevPath;
+ if (prevRoot === undefined) delete process.env.REACTPRESS_MONOREPO_ROOT;
+ else process.env.REACTPRESS_MONOREPO_ROOT = prevRoot;
+ }
+ });
+
+ it('uses production launch for App Router reactpress-theme-starter (fast switch when pre-built)', () => {
+ const plan = resolvePreviewThemeLaunchPlan(STARTER, 3003);
+ assert.equal(plan.mode, 'production');
+ assert.equal(plan.cmd, process.execPath);
+ assert.match(plan.args.join(' '), /next/);
+ });
+
+ it('allows admin iframe when REACTPRESS_DESKTOP_LOCAL is set', () => {
+ const { shouldHonorThemePreviewFrame } = require('../out/lib/theme-preview-pool');
+ const prev = process.env.REACTPRESS_DESKTOP_LOCAL;
+ process.env.REACTPRESS_DESKTOP_LOCAL = '1';
+ try {
+ assert.equal(shouldHonorThemePreviewFrame(), true);
+ } finally {
+ if (prev === undefined) delete process.env.REACTPRESS_DESKTOP_LOCAL;
+ else process.env.REACTPRESS_DESKTOP_LOCAL = prev;
+ }
+ });
+});
diff --git a/cli/tests/theme-registry.test.js b/cli/tests/theme-registry.test.js
new file mode 100644
index 00000000..f19d2c45
--- /dev/null
+++ b/cli/tests/theme-registry.test.js
@@ -0,0 +1,89 @@
+const path = require('path');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ OFFICIAL_THEME_STARTER_ID,
+ OFFICIAL_THEME_STARTER_SPEC,
+ readThemeCatalog,
+ readThemesPackageMeta,
+ readThemesRegistryMeta,
+ readThemeSources,
+ resolveCatalogInstallSpec,
+ validateLocalThemes,
+ validateNpmThemes,
+ validateBundledThemes,
+ validateCatalogThemes,
+ catalogEntryToManifest,
+} = require('../out/lib/theme-registry');
+
+const REPO_ROOT = path.join(__dirname, '../..');
+
+describe('lib/theme-registry', () => {
+ it('reads official theme-starter from themes/theme-starter/package.json anchor', () => {
+ const catalog = readThemeCatalog(REPO_ROOT);
+ const starter = catalog.themes.find((entry) => entry.id === OFFICIAL_THEME_STARTER_ID);
+ assert.ok(starter);
+ assert.equal(starter.npm, OFFICIAL_THEME_STARTER_SPEC);
+ assert.deepEqual(starter.dependency, {
+ name: '@fecommunity/reactpress-theme-starter',
+ version: '1.0.0-beta.0',
+ });
+ assert.equal(starter.featured, true);
+ assert.equal(starter.dir, 'theme-starter');
+ assert.equal(starter.previewUrl, 'https://reactpress-theme-starter.vercel.app');
+ assert.equal(catalog.source, 'themes/package.json');
+ });
+
+ it('resolveCatalogInstallSpec maps catalog id to npm spec', () => {
+ assert.equal(
+ resolveCatalogInstallSpec(REPO_ROOT, OFFICIAL_THEME_STARTER_ID),
+ OFFICIAL_THEME_STARTER_SPEC,
+ );
+ });
+
+ it('readThemesRegistryMeta lists local ids and npm anchor dirs', () => {
+ const meta = readThemesRegistryMeta(REPO_ROOT);
+ assert.ok(meta.local.includes('hello-world'));
+ assert.ok(meta.npm.includes('theme-starter'));
+ });
+
+ it('readThemesPackageMeta keeps bundled/catalog aliases for legacy callers', () => {
+ const meta = readThemesPackageMeta(REPO_ROOT);
+ assert.ok(meta.bundled.includes('hello-world'));
+ assert.ok(meta.local.includes('hello-world'));
+ });
+
+ it('readThemeSources exposes local and npm kinds', () => {
+ const sources = readThemeSources(REPO_ROOT);
+ assert.ok(sources.local.some((entry) => entry.id === 'hello-world' && entry.kind === 'local'));
+ assert.ok(
+ sources.npm.some((entry) => entry.id === OFFICIAL_THEME_STARTER_ID && entry.kind === 'npm'),
+ );
+ });
+
+ it('catalogEntryToManifest maps catalog metadata', () => {
+ const entry = readThemeCatalog(REPO_ROOT).themes[0];
+ const manifest = catalogEntryToManifest(entry);
+ assert.equal(manifest.id, entry.id);
+ assert.equal(manifest.name, entry.name);
+ });
+
+ it('validateLocalThemes reports missing template dirs', () => {
+ const { local, missing } = validateLocalThemes(REPO_ROOT);
+ assert.ok(local.includes('hello-world'));
+ assert.equal(missing.length, 0);
+ });
+
+ it('validateNpmThemes accepts theme-starter anchor package.json', () => {
+ const { missing } = validateNpmThemes(REPO_ROOT);
+ assert.equal(missing.length, 0);
+ });
+
+ it('validateBundledThemes and validateCatalogThemes remain as aliases', () => {
+ const bundled = validateBundledThemes(REPO_ROOT);
+ const catalog = validateCatalogThemes(REPO_ROOT);
+ assert.equal(bundled.missing.length, 0);
+ assert.equal(catalog.missing.length, 0);
+ });
+});
diff --git a/cli/tests/theme-warmup.test.js b/cli/tests/theme-warmup.test.js
new file mode 100644
index 00000000..d27a5e14
--- /dev/null
+++ b/cli/tests/theme-warmup.test.js
@@ -0,0 +1,55 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+const { pageFileToRoute, collectWarmupRoutes, isWarmupSafeRoute } = require('../out/lib/theme-warmup');
+const { createMonorepoFixture, rmDir } = require('./helpers/tmp-project');
+
+describe('lib/theme-warmup', () => {
+ it('filters dynamic and admin routes from warmup', () => {
+ assert.equal(isWarmupSafeRoute('/'), true);
+ assert.equal(isWarmupSafeRoute('/archives'), true);
+ assert.equal(isWarmupSafeRoute('/tag/__reactpress_dev_warmup__'), false);
+ assert.equal(isWarmupSafeRoute('/admin/article'), false);
+ });
+
+ it('maps template files to warmup routes', () => {
+ assert.equal(pageFileToRoute('pages/index.tsx'), '/');
+ assert.equal(pageFileToRoute('pages/about.tsx'), '/about');
+ assert.equal(pageFileToRoute('pages/tag/[tag].tsx'), '/tag/__reactpress_dev_warmup__');
+ assert.equal(pageFileToRoute('pages/category/[category].tsx'), '/category/__reactpress_dev_warmup__');
+ assert.equal(pageFileToRoute('pages/article/[id].tsx'), '/article/__reactpress_dev_warmup__');
+ });
+
+ it('collects routes from theme.json templates', () => {
+ const root = createMonorepoFixture();
+ try {
+ const themeDir = path.join(root, 'themes', 'demo-theme');
+ const pagesDir = path.join(themeDir, 'pages');
+ require('fs').mkdirSync(path.join(pagesDir, 'tag'), { recursive: true });
+ require('fs').writeFileSync(
+ path.join(themeDir, 'theme.json'),
+ JSON.stringify({
+ id: 'demo-theme',
+ reactpress: {
+ templates: {
+ home: 'pages/index.tsx',
+ 'archive-tag': 'pages/tag/[tag].tsx',
+ search: 'pages/search.tsx',
+ },
+ },
+ }),
+ );
+ require('fs').writeFileSync(path.join(pagesDir, 'index.tsx'), '');
+ require('fs').writeFileSync(path.join(pagesDir, 'search.tsx'), '');
+ require('fs').writeFileSync(path.join(pagesDir, 'tag', '[tag].tsx'), '');
+
+ const routes = collectWarmupRoutes(themeDir);
+ assert.ok(routes.includes('/'));
+ assert.ok(routes.includes('/search'));
+ assert.ok(!routes.some((r) => r.includes('__reactpress_dev_warmup__')));
+ assert.ok(routes.includes('/404'));
+ } finally {
+ rmDir(root);
+ }
+ });
+});
diff --git a/cli/tests/toolkit-build.test.js b/cli/tests/toolkit-build.test.js
new file mode 100644
index 00000000..2e330dce
--- /dev/null
+++ b/cli/tests/toolkit-build.test.js
@@ -0,0 +1,50 @@
+const fs = require('fs');
+const path = require('path');
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { shouldBuildToolkit } = require('../out/lib/toolkit-build');
+const { createMonorepoFixture, rmDir } = require('./helpers/tmp-project');
+
+describe('lib/toolkit-build', () => {
+ it('requires build when dist is missing', () => {
+ const root = createMonorepoFixture();
+ try {
+ assert.equal(shouldBuildToolkit(root), true);
+ } finally {
+ rmDir(root);
+ }
+ });
+
+ it('skips build when dist is newer than src', () => {
+ const root = createMonorepoFixture();
+ try {
+ const toolkitDir = path.join(root, 'toolkit');
+ const distDir = path.join(toolkitDir, 'dist');
+ fs.mkdirSync(distDir, { recursive: true });
+ fs.writeFileSync(path.join(distDir, 'index.js'), 'module.exports = {};\n');
+ const srcDir = path.join(toolkitDir, 'src');
+ fs.mkdirSync(srcDir, { recursive: true });
+ fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export {};\n');
+ const past = new Date(Date.now() - 60_000);
+ fs.utimesSync(path.join(srcDir, 'index.ts'), past, past);
+ const future = new Date(Date.now() + 60_000);
+ fs.utimesSync(path.join(distDir, 'index.js'), future, future);
+ assert.equal(shouldBuildToolkit(root), false);
+ } finally {
+ rmDir(root);
+ }
+ });
+
+ it('honors REACTPRESS_SKIP_TOOLKIT_BUILD', () => {
+ const root = createMonorepoFixture();
+ const prev = process.env.REACTPRESS_SKIP_TOOLKIT_BUILD;
+ process.env.REACTPRESS_SKIP_TOOLKIT_BUILD = '1';
+ try {
+ assert.equal(shouldBuildToolkit(root), false);
+ } finally {
+ if (prev === undefined) delete process.env.REACTPRESS_SKIP_TOOLKIT_BUILD;
+ else process.env.REACTPRESS_SKIP_TOOLKIT_BUILD = prev;
+ rmDir(root);
+ }
+ });
+});
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
new file mode 100644
index 00000000..7b0f13c8
--- /dev/null
+++ b/cli/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "moduleResolution": "node",
+ "moduleDetection": "force",
+ "outDir": "out",
+ "rootDir": "src",
+ "strict": false,
+ "noImplicitAny": false,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "out", "dist", "server", "tests"]
+}
diff --git a/cli/ui/banner.js b/cli/ui/banner.js
deleted file mode 100644
index c4fa9f77..00000000
--- a/cli/ui/banner.js
+++ /dev/null
@@ -1,441 +0,0 @@
-const os = require('os');
-const path = require('path');
-const chalk = require('chalk');
-const {
- brand,
- icon,
- palette,
- visibleLength,
- padRight,
- terminalWidth,
- gradientText,
- pulseBar,
- statusLights,
-} = require('./theme');
-const { t } = require('../lib/i18n');
-
-/**
- * "REACTPRESS" rendered in the ANSI Shadow font.
- * Each row is exactly 81 single-cell columns, so we can size the surrounding
- * cyber-card deterministically without measuring per-glyph widths.
- */
-const TECH_LOGO = [
- '██████╗ ███████╗ █████╗ ██████╗████████╗██████╗ ██████╗ ███████╗███████╗███████╗',
- '██╔══██╗██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝██╔════╝',
- '██████╔╝█████╗ ███████║██║ ██║ ██████╔╝██████╔╝█████╗ ███████╗███████╗',
- '██╔══██╗██╔══╝ ██╔══██║██║ ██║ ██╔═══╝ ██╔══██╗██╔══╝ ╚════██║╚════██║',
- '██║ ██║███████╗██║ ██║╚██████╗ ██║ ██║ ██║ ██║███████╗███████║███████║',
- '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝',
-];
-
-const LOGO_WIDTH = 81;
-const LOGO_GRADIENTS = [
- [palette.pink, palette.primary],
- [palette.pink, palette.primary],
- [palette.primary, palette.accent],
- [palette.primary, palette.accent],
- [palette.accent, palette.primary],
- [palette.accent, palette.primary],
-];
-
-const REPO_URL = 'https://github.com/fecommunity/reactpress';
-/**
- * Shorter, human-friendly form of REPO_URL shown beneath the title bar.
- * The clickable hyperlink still resolves to the full https:// URL via
- * `hyperlink()`, so users can `cmd+click` from any modern terminal.
- */
-const REPO_DISPLAY = 'github.com/fecommunity/reactpress';
-
-/**
- * Wrap text in an OSC-8 hyperlink escape so terminals that support it (iTerm2,
- * Warp, WezTerm, modern macOS Terminal, VS Code, GNOME Terminal, Kitty, …)
- * render the label as a clickable link. We only emit the escape sequence when
- * stdout is a real TTY — otherwise (CI logs, file redirects, dumb terminals)
- * we fall back to the plain styled label so users never see the raw `]8;;`.
- */
-function hyperlink(url, label) {
- if (!process.stdout.isTTY) return label;
- if (process.env.TERM === 'dumb') return label;
- return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
-}
-
-function safeReadCliVersion() {
- try {
- return require(path.join(__dirname, '..', 'package.json')).version;
- } catch {
- return 'dev';
- }
-}
-
-function homify(p) {
- if (!p) return p;
- const home = os.homedir();
- if (home && p.startsWith(home)) {
- return '~' + p.slice(home.length);
- }
- return p;
-}
-
-function renderLogoLines() {
- return TECH_LOGO.map((line, i) => gradientText(line, LOGO_GRADIENTS[i]));
-}
-
-function modeChip(type) {
- if (type === 'monorepo') {
- return chalk
- .bgHex(palette.primary)
- .hex('#0B1220')
- .bold(` ${t('banner.mode.monorepo')} `);
- }
- if (type === 'standalone') {
- return chalk
- .bgHex(palette.accent)
- .hex('#0B1220')
- .bold(` ${t('banner.mode.standalone')} `);
- }
- return chalk
- .bgHex(palette.gray)
- .hex('#0B1220')
- .bold(` ${t('banner.mode.uninitialized')} `);
-}
-
-/**
- * Decide how "ready" the welcome banner should look. When a fully
- * initialized project is detected we render the pulse bar at 100% and
- * report `ONLINE` status, instead of the static 70% placeholder that used
- * to make `doctor` runs look incomplete even when everything passed.
- */
-function bannerReadyState(options) {
- const type = options && options.project && options.project.type;
- if (type === 'monorepo' || type === 'standalone') {
- return { ratio: 1, ready: true };
- }
- return { ratio: 0.4, ready: false };
-}
-
-/**
- * Build the top edge of the cyber-card with a centered title block:
- * ╔══════════[ REACTPRESS · v3.0.3 ]══════════╗
- */
-function brandedTopBorder(version, width) {
- const titleBlock =
- brand.primary('[') +
- ' ' +
- gradientText('REACTPRESS', [palette.primary, palette.accent], { bold: true }) +
- ' ' +
- brand.muted('·') +
- ' ' +
- brand.accent(`v${version}`) +
- ' ' +
- brand.primary(']');
- const dashTotal = Math.max(0, width - 2 - visibleLength(titleBlock));
- const left = Math.floor(dashTotal / 2);
- const right = dashTotal - left;
- return (
- brand.primary('╔' + '═'.repeat(left)) +
- titleBlock +
- brand.primary('═'.repeat(right) + '╗')
- );
-}
-
-function bottomBorder(width) {
- return brand.primary('╚' + '═'.repeat(width - 2) + '╝');
-}
-
-function bodyLine(content, innerWidth) {
- const padded = padRight(content, innerWidth);
- return brand.primary('║ ') + padded + brand.primary(' ║');
-}
-
-function emptyBodyLine(innerWidth) {
- return bodyLine('', innerWidth);
-}
-
-/**
- * A subtle "CRT scan-line" rendered just under the logo.
- * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- */
-function scanline(width) {
- return brand.muted('▔'.repeat(width));
-}
-
-/**
- * Width of the left-side banner label column.
- *
- * Sized to fit our longest English label (`MODE` / `PATH` → 4 cells)
- * plus a 2-cell trailing gap, which also accommodates the Chinese
- * translations `模式` / `路径` (4 East-Asian cells each).
- */
-const LABEL_WIDTH = 6;
-
-/**
- * Centered, dim repo subtitle that sits directly under the top border.
- * Replaces the previous in-body `◇ REPO ↗ …` row, which competed visually
- * with the operational fields (MODE / PATH / pulse) further down.
- */
-function repoSubline(innerWidth) {
- const link =
- brand.muted('↗ ') + hyperlink(REPO_URL, brand.accent.underline(REPO_DISPLAY));
- const pad = Math.max(0, Math.floor((innerWidth - visibleLength(link)) / 2));
- return ' '.repeat(pad) + link;
-}
-
-/**
- * Single-cell-wide chip label, e.g. `◇ MODE ▸ monorepo`.
- */
-function infoRow(label, value) {
- return (
- brand.accent('◇ ') +
- brand.muted(padRight(label, LABEL_WIDTH)) +
- ' ' +
- brand.primary('▸ ') +
- brand.dim(value)
- );
-}
-
-/**
- * Render the "command rail" navigation footer:
- * ⟫ init ⟫ dev ⟫ build ⟫ deploy ⟫ publish
- */
-function commandRail() {
- const items = ['init', 'dev', 'build', 'deploy', 'publish'];
- return items
- .map(
- (name) =>
- brand.primary('⟫ ') + gradientText(name, [palette.primary, palette.accent])
- )
- .join(brand.muted(' '));
-}
-
-/**
- * Wide, full-fat cyber banner: ASCII logo + scan-line + bordered card.
- */
-function printWideBanner(version, options) {
- const cols = terminalWidth();
- const cardWidth = Math.min(Math.max(LOGO_WIDTH + 8, 88), cols - 2);
- const innerWidth = cardWidth - 4;
-
- const lines = [];
- lines.push('');
- lines.push(' ' + brandedTopBorder(version, cardWidth));
- lines.push(' ' + bodyLine(repoSubline(innerWidth), innerWidth));
- lines.push(' ' + emptyBodyLine(innerWidth));
-
- const logoIndent = Math.max(0, Math.floor((innerWidth - LOGO_WIDTH) / 2));
- const indent = ' '.repeat(logoIndent);
- for (const logoLine of renderLogoLines()) {
- lines.push(' ' + bodyLine(indent + logoLine, innerWidth));
- }
-
- const scanWidth = Math.min(innerWidth - 2, LOGO_WIDTH);
- const scanIndent = ' '.repeat(Math.max(0, Math.floor((innerWidth - scanWidth) / 2)));
- lines.push(' ' + bodyLine(scanIndent + scanline(scanWidth), innerWidth));
-
- lines.push(' ' + emptyBodyLine(innerWidth));
-
- const ready = bannerReadyState(options);
- const subtitle =
- chalk.bold(brand.accent('◆ ')) +
- gradientText(t('banner.subtitle').trim(), [palette.accent, palette.primary, palette.pink], {
- bold: true,
- });
- const stateLabel = ready.ready
- ? brand.success(t('banner.systemOnline').trim())
- : brand.warn(t('banner.systemPending').trim());
- const right =
- statusLights(ready.ready ? 'online' : 'pending') +
- ' ' +
- brand.dim(t('banner.systemLabel').trim() + ' ') +
- stateLabel;
- lines.push(' ' + bodyLine(subtitle + spacer(subtitle, right, innerWidth) + right, innerWidth));
-
- lines.push(' ' + emptyBodyLine(innerWidth));
-
- if (options.project) {
- lines.push(
- ' ' +
- bodyLine(
- brand.accent('◇ ') +
- brand.muted(padRight(t('banner.label.mode').trim(), LABEL_WIDTH)) +
- ' ' +
- modeChip(options.project.type),
- innerWidth
- )
- );
- }
- if (options.projectRoot) {
- lines.push(
- ' ' +
- bodyLine(
- infoRow(t('banner.label.path').trim(), homify(options.projectRoot)),
- innerWidth
- )
- );
- }
-
- const pulseWidth = Math.min(28, innerWidth - 18);
- if (pulseWidth > 8) {
- const filled = Math.max(1, Math.min(pulseWidth, Math.round(pulseWidth * ready.ratio)));
- const pulse = pulseBar(pulseWidth, filled);
- const pulseStatus = ready.ready
- ? t('banner.pulseReady').trim()
- : t('banner.pulsePending').trim();
- const pulseLine =
- brand.accent('◇ ') +
- brand.muted(padRight(t('banner.pulseLabel').trim(), LABEL_WIDTH)) +
- ' ' +
- pulse +
- ' ' +
- (ready.ready ? brand.success(pulseStatus) : brand.warn(pulseStatus));
- lines.push(' ' + bodyLine(pulseLine, innerWidth));
- }
-
- lines.push(' ' + emptyBodyLine(innerWidth));
- lines.push(' ' + bottomBorder(cardWidth));
- lines.push(' ' + commandRail());
- lines.push('');
-
- for (const line of lines) console.log(line);
-}
-
-/**
- * Pad between a left-aligned and a right-aligned segment so they sit on the
- * same line of the cyber card.
- */
-function spacer(left, right, innerWidth) {
- const used = visibleLength(left) + visibleLength(right);
- const gap = Math.max(2, innerWidth - used);
- return ' '.repeat(gap);
-}
-
-/**
- * Compact cyber banner for terminals that cannot host the full ASCII logo.
- */
-function printCompactBanner(version, options) {
- const cols = terminalWidth();
- const cardWidth = Math.min(cols - 2, 76);
- const innerWidth = cardWidth - 4;
-
- const lines = [];
- lines.push('');
- lines.push(' ' + brandedTopBorder(version, cardWidth));
- lines.push(' ' + bodyLine(repoSubline(innerWidth), innerWidth));
- lines.push(' ' + emptyBodyLine(innerWidth));
-
- const ready = bannerReadyState(options);
- const wordmark =
- brand.primary('▌▍▎ ') +
- gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], {
- bold: true,
- }) +
- brand.primary(' ▎▍▌');
- const lights = statusLights(ready.ready ? 'online' : 'pending');
- lines.push(
- ' ' + bodyLine(wordmark + spacer(wordmark, lights, innerWidth) + lights, innerWidth)
- );
-
- const subtitle =
- chalk.bold(brand.accent('◆ ')) + brand.dim(t('banner.subtitle').trim());
- lines.push(' ' + bodyLine(subtitle, innerWidth));
- lines.push(' ' + emptyBodyLine(innerWidth));
-
- if (options.project) {
- lines.push(
- ' ' +
- bodyLine(
- brand.accent('◇ ') +
- brand.muted(padRight(t('banner.label.mode').trim(), LABEL_WIDTH)) +
- ' ' +
- modeChip(options.project.type),
- innerWidth
- )
- );
- }
- if (options.projectRoot) {
- lines.push(
- ' ' +
- bodyLine(
- infoRow(t('banner.label.path').trim(), homify(options.projectRoot)),
- innerWidth
- )
- );
- }
-
- lines.push(' ' + emptyBodyLine(innerWidth));
- lines.push(' ' + bottomBorder(cardWidth));
- lines.push(' ' + commandRail());
- lines.push('');
-
- for (const line of lines) console.log(line);
-}
-
-/**
- * Single-line banner for ultra-narrow terminals (CI logs, embedded shells).
- */
-function printMinimalBanner(version, options) {
- const ready = bannerReadyState(options);
- const wordmark = gradientText('REACTPRESS', [palette.pink, palette.primary, palette.accent], {
- bold: true,
- });
- console.log('');
- console.log(` ${brand.primary('▌▍▎')} ${wordmark} ${brand.muted('·')} ${brand.accent(`v${version}`)} ${statusLights(ready.ready ? 'online' : 'pending')}`);
- console.log(` ${brand.dim(t('banner.subtitle').trim())}`);
- if (options.project) {
- console.log(` ${modeChip(options.project.type)}`);
- }
- if (options.projectRoot) {
- console.log(` ${icon.bullet} ${brand.dim(homify(options.projectRoot))}`);
- }
- console.log(
- ` ${brand.muted('↗')} ${hyperlink(REPO_URL, brand.accent.underline(REPO_URL))}`
- );
- console.log('');
-}
-
-/**
- * Print the top-of-screen banner. Adaptive to terminal width: collapses to a
- * single-line greeting on very narrow terminals, otherwise renders a bordered
- * cyber-card with the full ANSI Shadow logo when there is room.
- *
- * @param {{
- * projectRoot?: string,
- * project?: { type: string, hasClient: boolean, hasServerSource: boolean }
- * }} [options]
- */
-function printBanner(options = {}) {
- const version = safeReadCliVersion();
- const cols = terminalWidth();
-
- if (cols < 64) {
- printMinimalBanner(version, options);
- return;
- }
-
- if (cols < LOGO_WIDTH + 10) {
- printCompactBanner(version, options);
- return;
- }
-
- printWideBanner(version, options);
-}
-
-/**
- * Box helper retained for backwards compatibility: a few callers still
- * import `box` from this module to wrap arbitrary multi-line content.
- */
-function box(lines, { width } = {}) {
- const innerWidth = width
- ? width - 4
- : lines.reduce((max, line) => Math.max(max, visibleLength(line)), 0);
-
- const horizontal = '═'.repeat(innerWidth + 2);
- const top = brand.primary(` ╔${horizontal}╗`);
- const bottom = brand.primary(` ╚${horizontal}╝`);
- const body = lines.map((line) => {
- const padded = padRight(line, innerWidth);
- return brand.primary(' ║ ') + padded + brand.primary(' ║');
- });
- return [top, ...body, bottom];
-}
-
-module.exports = { printBanner, visibleLength, padRight, box };
diff --git a/cli/ui/interactive.js b/cli/ui/interactive.js
deleted file mode 100644
index 282a2b08..00000000
--- a/cli/ui/interactive.js
+++ /dev/null
@@ -1,380 +0,0 @@
-const fs = require('fs');
-const path = require('path');
-const inquirer = require('inquirer');
-const ora = require('ora');
-const open = require('open');
-const { printBanner } = require('./banner');
-const {
- brand,
- icon,
- label,
- ok,
- fail,
- sectionHeader,
- statusPill,
- padRight,
-} = require('./theme');
-const { ensureOriginalCwd } = require('../lib/root');
-const { describeProject, hasClient } = require('../lib/project-type');
-const { ensureProjectEnvironment } = require('../lib/bootstrap');
-const { runDev } = require('../lib/dev');
-const { runApiDev } = require('../lib/api-dev');
-const { runLifecycleCommand } = require('../lib/lifecycle');
-const { runDockerCommand } = require('../lib/docker');
-const { runNginxCommand } = require('../lib/nginx');
-const { printUnifiedStatus } = require('../lib/status');
-const { runDoctor } = require('../lib/doctor');
-const { runBuild, TARGETS } = require('../lib/build');
-const { runNodeScript } = require('../lib/spawn');
-const { getClientBin } = require('../lib/paths');
-const { loadClientSiteUrl, loadServerSiteUrl, isHttpResponding } = require('../lib/http');
-const { isDockerRunning } = require('../lib/docker');
-const { t } = require('../lib/i18n');
-
-function menuSection(title) {
- return new inquirer.Separator(sectionHeader(title));
-}
-
-function formatChoice(key, text, hint) {
- const keyCol = key ? brand.primary(padRight(key, 2)) : ' ';
- const hintPart = hint ? brand.dim(` ${hint}`) : '';
- return `${keyCol} ${text}${hintPart}`;
-}
-
-function assignShortcuts(items) {
- let n = 0;
- return items.map((item) => {
- if (item instanceof inquirer.Separator || item.type === 'separator') {
- return item;
- }
- n += 1;
- const key = n <= 9 ? String(n) : '';
- return {
- ...item,
- name: formatChoice(key, item._label || item.name, item._hint),
- short: item.value,
- };
- });
-}
-
-function choice(labelKey, value, hintKey) {
- return {
- _label: t(labelKey),
- _hint: hintKey ? t(hintKey) : '',
- value,
- };
-}
-
-function getMenuActions(project) {
- const standalone = project.type === 'standalone';
- const monorepo = project.type === 'monorepo';
- const showClient = hasClient(project.root);
-
- const items = [
- menuSection(t('menu.section.run')),
- choice('menu.dev', 'dev', 'menu.hint.dev'),
- choice('menu.init', 'init', 'menu.hint.init'),
- choice('menu.status', 'status', 'menu.hint.status'),
- choice('menu.doctor', 'doctor', 'menu.hint.doctor'),
- menuSection(t('menu.section.lifecycle')),
- choice('menu.devApi', 'dev:api', 'menu.hint.devApi'),
- ];
-
- if (showClient) {
- items.push(choice('menu.devClient', 'dev:client', 'menu.hint.devClient'));
- }
-
- items.push(
- choice('menu.serverStart', 'server:start', 'menu.hint.serverStart'),
- choice('menu.serverStop', 'server:stop', 'menu.hint.serverStop'),
- choice('menu.serverRestart', 'server:restart', 'menu.hint.serverRestart'),
- menuSection(t('menu.section.build')),
- choice('menu.build', 'build', 'menu.hint.build')
- );
-
- if (monorepo) {
- items.push(
- choice('menu.dockerStart', 'docker:start', 'menu.hint.dockerStart'),
- choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'),
- choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop')
- );
- } else if (standalone) {
- items.push(
- choice('menu.dockerUp', 'docker:up', 'menu.hint.dockerUp'),
- choice('menu.dockerStop', 'docker:stop', 'menu.hint.dockerStop')
- );
- }
-
- items.push(
- menuSection(t('menu.section.tools')),
- choice('menu.nginxUp', 'nginx:up', 'menu.hint.nginxUp'),
- choice('menu.nginxOpen', 'nginx:open', 'menu.hint.nginxOpen'),
- choice('menu.nginxReload', 'nginx:reload', 'menu.hint.nginxReload'),
- choice('menu.openAdmin', 'open:admin', 'menu.hint.openAdmin')
- );
-
- if (monorepo) {
- items.push(choice('menu.publish', 'publish', 'menu.hint.publish'));
- }
-
- items.push(
- new inquirer.Separator(),
- choice('menu.exit', 'exit', 'menu.hint.exit')
- );
-
- return assignShortcuts(items);
-}
-
-function parseEnvFile(projectRoot) {
- const envPath = path.join(projectRoot, '.env');
- const env = {};
- try {
- if (!fs.existsSync(envPath)) return env;
- for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
- const m = line.match(/^([A-Z_]+)=(.*)$/);
- if (m) env[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
- }
- } catch {
- // ignore
- }
- return env;
-}
-
-async function probeDatabase(projectRoot) {
- try {
- const mysql = require('mysql2/promise');
- const env = parseEnvFile(projectRoot);
- const conn = await mysql.createConnection({
- host: env.DB_HOST || '127.0.0.1',
- port: Number(env.DB_PORT || 3306),
- user: env.DB_USER || 'reactpress',
- password: env.DB_PASSWD || env.DB_PASSWORD || 'reactpress',
- database: env.DB_DATABASE || 'reactpress',
- connectTimeout: 2000,
- });
- await conn.ping();
- await conn.end();
- return true;
- } catch {
- return false;
- }
-}
-
-async function fetchContextStatus(projectRoot) {
- const apiUrl = loadServerSiteUrl(projectRoot);
- const [apiOk, dockerOk, dbOk] = await Promise.all([
- isHttpResponding(apiUrl, 1500),
- Promise.resolve(isDockerRunning()),
- probeDatabase(projectRoot),
- ]);
- return { apiOk, dbOk, dockerOk };
-}
-
-function printStatusPanel(status) {
- const on = { on: t('menu.statusOn'), off: t('menu.statusOff') };
- const db = {
- on: t('menu.statusReady'),
- off: t('menu.statusNotReady'),
- pending: t('menu.statusChecking'),
- };
- const docker = { on: t('menu.statusYes'), off: t('menu.statusNo') };
-
- console.log(sectionHeader(t('menu.statusHeader')));
- const rows = [
- [t('menu.statusLabelApi'), statusPill(status.apiOk, on)],
- [t('menu.statusLabelDb'), statusPill(status.dbOk, db)],
- [t('menu.statusLabelDocker'), statusPill(status.dockerOk, docker)],
- ];
- for (const [name, pill] of rows) {
- console.log(` ${brand.muted(padRight(name, 10))} ${pill}`);
- }
- console.log('');
-}
-
-async function printContextStatus(projectRoot) {
- const spinner = ora({
- text: brand.dim(t('menu.statusChecking')),
- color: 'magenta',
- spinner: 'dots',
- }).start();
- const status = await fetchContextStatus(projectRoot);
- spinner.stop();
- printStatusPanel(status);
- return status;
-}
-
-async function withSpinner(text, fn) {
- const spinner = ora({ text, color: 'magenta', spinner: 'dots' }).start();
- try {
- const result = await fn();
- spinner.succeed();
- return result;
- } catch (err) {
- spinner.fail();
- throw err;
- }
-}
-
-async function runMenuAction(action, projectRoot, project) {
- switch (action) {
- case 'dev':
- console.log(label(t('menu.startingDev')));
- await runDev(projectRoot);
- return false;
- case 'init': {
- const result = await withSpinner(t('menu.initProject'), () =>
- ensureProjectEnvironment(projectRoot)
- );
- console.log(ok(result.message || t('menu.done')));
- return true;
- }
- case 'status':
- await printUnifiedStatus(projectRoot);
- return true;
- case 'doctor': {
- const code = await runDoctor(projectRoot);
- if (code !== 0) process.exit(code);
- return true;
- }
- case 'dev:api':
- await runApiDev(projectRoot);
- return false;
- case 'dev:client':
- await runNodeScript(getClientBin(), [], { cwd: projectRoot });
- return false;
- case 'server:start': {
- const code = await withSpinner(t('menu.startingApi'), () =>
- runLifecycleCommand('start', projectRoot)
- );
- if (code !== 0) process.exit(code);
- return true;
- }
- case 'server:stop':
- await withSpinner(t('menu.stoppingApi'), async () => {
- await runLifecycleCommand('stop', projectRoot);
- });
- return true;
- case 'server:restart': {
- const code = await withSpinner(t('menu.restartingApi'), () =>
- runLifecycleCommand('restart', projectRoot)
- );
- if (code !== 0) process.exit(code);
- return true;
- }
- case 'build': {
- const buildChoices = TARGETS.map((target) => ({
- name:
- target === 'all'
- ? t('menu.buildAll')
- : t(`build.label.${target}`),
- value: target,
- }));
- const { target } = await inquirer.prompt([
- {
- type: 'list',
- name: 'target',
- message: t('menu.buildTarget'),
- pageSize: 12,
- choices: buildChoices,
- },
- ]);
- await runBuild(target, projectRoot);
- return true;
- }
- case 'docker:start':
- await runDockerCommand('start', projectRoot);
- return false;
- case 'docker:up':
- await withSpinner(t('docker.starting'), () => runDockerCommand('up', projectRoot));
- return true;
- case 'docker:stop':
- await withSpinner(t('docker.stopping'), async () => {
- await runDockerCommand('down', projectRoot);
- });
- return true;
- case 'nginx:up':
- await withSpinner(t('cli.nginx.up'), async () => {
- await runNginxCommand('up', projectRoot);
- });
- return true;
- case 'nginx:open':
- await runNginxCommand('open', projectRoot);
- return true;
- case 'nginx:reload':
- await runNginxCommand('reload', projectRoot);
- return true;
- case 'open:admin': {
- const url = loadClientSiteUrl(projectRoot);
- console.log(label(t('menu.opening', { url })));
- await open(url);
- return true;
- }
- case 'publish': {
- const prev = process.argv.slice();
- process.argv = [process.argv[0], process.argv[1], '--publish'];
- await require('../lib/publish').main();
- process.argv = prev;
- return true;
- }
- case 'exit':
- return false;
- default:
- return true;
- }
-}
-
-async function runInteractiveLoop() {
- const projectRoot = ensureOriginalCwd();
- const project = describeProject(projectRoot);
-
- printBanner({ projectRoot, project });
- await printContextStatus(projectRoot);
- console.log(` ${brand.dim(t('menu.shortcuts'))}`);
- console.log('');
-
- let loop = true;
- while (loop) {
- const { action } = await inquirer.prompt([
- {
- type: 'list',
- name: 'action',
- message: `${brand.primary(t('menu.actionPrefix'))} ${brand.dim('›')}`,
- pageSize: 20,
- loop: false,
- choices: getMenuActions(project),
- },
- ]);
-
- if (action === 'exit') {
- console.log(brand.muted(t('menu.goodbye')));
- break;
- }
-
- try {
- const stay = await runMenuAction(action, projectRoot, project);
- if (!stay) break;
-
- if (action !== 'status' && action !== 'doctor') {
- console.log('');
- await printContextStatus(projectRoot);
- }
- } catch (err) {
- console.error(fail(err.message || err));
- const { retry } = await inquirer.prompt([
- {
- type: 'confirm',
- name: 'retry',
- message: t('menu.retry'),
- default: true,
- },
- ]);
- loop = retry;
- if (loop) {
- console.log('');
- await printContextStatus(projectRoot);
- }
- }
- }
-}
-
-module.exports = { runInteractiveLoop, runMenuAction, getMenuActions };
diff --git a/cli/ui/theme.js b/cli/ui/theme.js
deleted file mode 100644
index 7b06f7ab..00000000
--- a/cli/ui/theme.js
+++ /dev/null
@@ -1,265 +0,0 @@
-const chalk = require('chalk');
-
-/**
- * ReactPress CLI visual identity — a single source of truth so banners,
- * menus, status, doctor, build output all share the same colours and glyphs.
- */
-const palette = {
- primary: '#7C5CFF',
- accent: '#22D3EE',
- pink: '#F472B6',
- green: '#22C55E',
- amber: '#F59E0B',
- red: '#EF4444',
- gray: '#6B7280',
- dim: '#9CA3AF',
-};
-
-const brand = {
- primary: chalk.hex(palette.primary),
- accent: chalk.hex(palette.accent),
- pink: chalk.hex(palette.pink),
- success: chalk.hex(palette.green),
- warn: chalk.hex(palette.amber),
- error: chalk.hex(palette.red),
- muted: chalk.hex(palette.gray),
- dim: chalk.hex(palette.dim),
- bold: chalk.bold,
-};
-
-const icon = {
- ok: brand.success('✓'),
- fail: brand.error('✗'),
- warn: brand.warn('⚠'),
- info: brand.accent('ℹ'),
- arrow: brand.primary('›'),
- pointer: brand.primary('▸'),
- bullet: brand.muted('·'),
- dotOn: brand.success('●'),
- dotOff: brand.muted('○'),
- dotPending: brand.warn('◐'),
- dotInfo: brand.accent('●'),
- spark: brand.primary('✱'),
- link: brand.muted('↗'),
-};
-
-/**
- * Whether a Unicode code point should occupy two terminal cells.
- *
- * Covers the common "East Asian Wide / Full-width" ranges that show up in
- * Chinese / Japanese / Korean text plus full-width punctuation. We
- * deliberately do not pull in a heavy dependency like `string-width` to keep
- * the CLI's startup cheap.
- */
-function isWideCodePoint(cp) {
- return (
- (cp >= 0x1100 && cp <= 0x115f) ||
- (cp >= 0x2e80 && cp <= 0x303e) ||
- (cp >= 0x3041 && cp <= 0x33ff) ||
- (cp >= 0x3400 && cp <= 0x4dbf) ||
- (cp >= 0x4e00 && cp <= 0x9fff) ||
- (cp >= 0xa000 && cp <= 0xa4cf) ||
- (cp >= 0xac00 && cp <= 0xd7a3) ||
- (cp >= 0xf900 && cp <= 0xfaff) ||
- (cp >= 0xfe30 && cp <= 0xfe4f) ||
- (cp >= 0xff00 && cp <= 0xff60) ||
- (cp >= 0xffe0 && cp <= 0xffe6) ||
- (cp >= 0x1f300 && cp <= 0x1f64f) ||
- (cp >= 0x1f900 && cp <= 0x1f9ff) ||
- (cp >= 0x20000 && cp <= 0x2fffd) ||
- (cp >= 0x30000 && cp <= 0x3fffd)
- );
-}
-
-/**
- * Visible terminal-cell width of a string, after stripping ANSI colour codes
- * and accounting for East Asian wide characters (which occupy 2 cells).
- */
-function visibleLength(text) {
- const stripped = String(text)
- .replace(/\u001b\[[0-9;]*m/g, '')
- .replace(/\u001b\]8;[^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '');
- let width = 0;
- for (const ch of stripped) {
- const cp = ch.codePointAt(0);
- if (cp === undefined) continue;
- if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue;
- width += isWideCodePoint(cp) ? 2 : 1;
- }
- return width;
-}
-
-function padRight(text, width) {
- const len = visibleLength(text);
- if (len >= width) return text;
- return text + ' '.repeat(width - len);
-}
-
-function padLeft(text, width) {
- const len = visibleLength(text);
- if (len >= width) return text;
- return ' '.repeat(width - len) + text;
-}
-
-function terminalWidth(fallback = 80) {
- const cols = Number(process.stdout.columns) || fallback;
- return Math.max(48, Math.min(120, cols));
-}
-
-function divider(width = 44, char = '─', colorize = brand.muted) {
- return colorize(char.repeat(width));
-}
-
-/**
- * Cyberpunk-flavoured progress-bar style decoration.
- * `filled` segments use the primary colour, the trailing track stays muted.
- */
-function pulseBar(width = 24, filled = Math.ceil(width * 0.7)) {
- const f = Math.max(0, Math.min(width, filled));
- const head = brand.primary('▰'.repeat(f));
- const tail = brand.muted('▱'.repeat(Math.max(0, width - f)));
- return `${head}${tail}`;
-}
-
-/**
- * Three-light status indicator used in the top-right of the banner.
- * Mimics the running-light cluster you'd see on a server rack.
- */
-function statusLights(state = 'online') {
- if (state === 'offline') {
- return `${brand.muted('●')} ${brand.muted('●')} ${brand.muted('●')}`;
- }
- if (state === 'pending') {
- return `${brand.warn('●')} ${brand.warn('●')} ${brand.muted('○')}`;
- }
- return `${brand.success('●')} ${brand.warn('●')} ${brand.muted('○')}`;
-}
-
-function hex2rgb(h) {
- const s = h.replace('#', '');
- return {
- r: parseInt(s.substring(0, 2), 16),
- g: parseInt(s.substring(2, 4), 16),
- b: parseInt(s.substring(4, 6), 16),
- };
-}
-
-function rgb2hex(r, g, b) {
- const pad = (n) => n.toString(16).padStart(2, '0');
- return `#${pad(r)}${pad(g)}${pad(b)}`;
-}
-
-function mixHex(a, b, t) {
- const pa = hex2rgb(a);
- const pb = hex2rgb(b);
- const r = Math.round(pa.r + (pb.r - pa.r) * t);
- const g = Math.round(pa.g + (pb.g - pa.g) * t);
- const bl = Math.round(pa.b + (pb.b - pa.b) * t);
- return rgb2hex(r, g, bl);
-}
-
-/**
- * Paint a string with a left→right linear gradient across `colors` (hex).
- * Falls back to plain text when stdout does not support truecolor.
- */
-function gradientText(text, colors = [palette.primary, palette.accent], { bold = false } = {}) {
- if (!text) return '';
- const supports = chalk.supportsColor && chalk.supportsColor.has16m;
- if (!supports || colors.length < 2) {
- const c = chalk.hex(colors[0] || palette.primary);
- return bold ? c.bold(text) : c(text);
- }
- const chars = [...String(text)];
- const n = Math.max(chars.length - 1, 1);
- return chars
- .map((ch, i) => {
- const ratio = i / n;
- const idx = ratio * (colors.length - 1);
- const lo = Math.floor(idx);
- const hi = Math.min(colors.length - 1, lo + 1);
- const local = idx - lo;
- const c = chalk.hex(mixHex(colors[lo], colors[hi], local));
- return bold ? c.bold(ch) : c(ch);
- })
- .join('');
-}
-
-function label(text) {
- return `${icon.arrow} ${brand.primary(text)}`;
-}
-
-function ok(text) {
- return `${icon.ok} ${brand.success(text)}`;
-}
-
-function fail(text) {
- return `${icon.fail} ${brand.error(text)}`;
-}
-
-function warn(text) {
- return `${icon.warn} ${brand.warn(text)}`;
-}
-
-function info(text) {
- return `${icon.info} ${brand.accent(text)}`;
-}
-
-function chip(text, color = brand.primary) {
- return color(`[ ${text} ]`);
-}
-
-function kv(key, value, { keyWidth = 10, valueColor = (s) => s } = {}) {
- return `${brand.muted(padRight(key, keyWidth))} ${valueColor(value)}`;
-}
-
-/**
- * Render a 3-state status pill, e.g. `● online` / `○ offline` / `◐ pending`.
- *
- * @param {boolean | 'pending'} state
- * @param {{ on?: string, off?: string, pending?: string }} labels
- */
-function statusPill(state, labels = {}) {
- if (state === 'pending') {
- return `${icon.dotPending} ${brand.warn(labels.pending || 'pending')}`;
- }
- if (state === true) {
- return `${icon.dotOn} ${brand.success(labels.on || 'online')}`;
- }
- return `${icon.dotOff} ${brand.dim(labels.off || 'offline')}`;
-}
-
-/**
- * Render a single-line section header: ` ── Title ────────────`.
- */
-function sectionHeader(title, { width } = {}) {
- const w = width ?? terminalWidth();
- const prefix = brand.muted('── ');
- const t = brand.bold(brand.primary(title));
- const usedLen = visibleLength(prefix) + visibleLength(t) + 2;
- const fillLen = Math.max(3, w - usedLen - 2);
- const fill = brand.muted('─'.repeat(fillLen));
- return ` ${prefix}${t} ${fill}`;
-}
-
-module.exports = {
- palette,
- brand,
- icon,
- label,
- ok,
- fail,
- warn,
- info,
- chip,
- kv,
- statusPill,
- sectionHeader,
- visibleLength,
- padRight,
- padLeft,
- terminalWidth,
- divider,
- gradientText,
- pulseBar,
- statusLights,
-};
diff --git a/client/Dockerfile b/client/Dockerfile
deleted file mode 100644
index 2e70ebfc..00000000
--- a/client/Dockerfile
+++ /dev/null
@@ -1,38 +0,0 @@
-# Use Node.js 18 as the base image
-FROM node:18-alpine
-
-# Set working directory
-WORKDIR /app
-
-# Install pnpm globally
-RUN npm install -g pnpm
-
-# Copy ALL files from the project root
-COPY . .
-
-# Debug: Show what files were copied
-RUN echo "=== Files in /app ===" && ls -la
-RUN echo "=== pnpm-lock.yaml exists? ===" && test -f pnpm-lock.yaml && echo "YES" || echo "NO"
-RUN echo "=== pnpm-workspace.yaml exists? ===" && test -f pnpm-workspace.yaml && echo "YES" || echo "NO"
-RUN echo "=== client/package.json exists? ===" && test -f client/package.json && echo "YES" || echo "NO"
-
-# Install dependencies - ALWAYS use --no-frozen-lockfile to avoid issues
-RUN pnpm install --no-frozen-lockfile
-
-# Build the client application
-WORKDIR /app/client
-RUN pnpm run build
-
-# Expose port
-EXPOSE 3001
-
-# Create a non-root user
-RUN addgroup -g 1001 -S nodejs && \
- adduser -S nextjs -u 1001
-
-# Change ownership of the app directory
-RUN chown -R nextjs:nodejs /app
-USER nextjs
-
-# Start the application
-CMD ["pnpm", "run", "start"]
\ No newline at end of file
diff --git a/client/README.md b/client/README.md
deleted file mode 100644
index 154717ef..00000000
--- a/client/README.md
+++ /dev/null
@@ -1,341 +0,0 @@
-# @fecommunity/reactpress-client
-
-ReactPress Client - Next.js 14 frontend for ReactPress CMS with modern UI and responsive design.
-
-[](https://www.npmjs.com/package/@fecommunity/reactpress-client)
-[](https://github.com/fecommunity/reactpress/blob/master/client/LICENSE)
-[](https://nodejs.org)
-[](http://www.typescriptlang.org/)
-[](https://nextjs.org/)
-
-## Overview
-
-ReactPress Client is a responsive frontend application built with Next.js 14 that serves as the user interface for the ReactPress CMS platform. It provides a clean design, intuitive navigation, and content management capabilities.
-
-The client is designed with a component-based architecture that promotes reusability and maintainability. It integrates with the ReactPress backend through the [ReactPress Toolkit](../toolkit), providing type-safe API interactions.
-
-## Quick Start
-
-### Installation & Setup
-
-```bash
-# Regular startup
-npx @fecommunity/reactpress-client
-
-# PM2 startup for production
-npx @fecommunity/reactpress-client --pm2
-```
-
-## Features
-
-- ⚡ **App Router Architecture** with Server Components for optimal SSR
-- 🎨 **Theme System** with light/dark mode switching
-- 🌍 **Internationalization** - Supports Chinese and English languages
-- 🌙 **Theme Switching** with system preference detection
-- ✍️ **Markdown Editor** with live preview
-- 📊 **Analytics Dashboard** with metrics and visualizations
-- 🔍 **Search** with filtering
-- 🖼️ **Media Management** with drag-and-drop upload
-- 📱 **PWA Support** with offline capabilities
-- ♿ **Accessibility Compliance** - WCAG 2.1 AA standards
-- 🚀 **Performance Optimized** - Code splitting, image optimization, and caching
-
-## Requirements
-
-- Node.js >= 18.20.4
-- npm or pnpm package manager
-- ReactPress Server running (for API connectivity)
-
-## Usage Scenarios
-
-### Standalone Client
-Perfect for:
-- Connecting to remote ReactPress API
-- Headless CMS implementation
-- Custom deployment scenarios
-- Microfrontend architecture
-
-### Full ReactPress Stack
-Use with ReactPress API for complete CMS solution:
-```bash
-# Start API first
-pnpm exec reactpress-cli start
-
-# In another terminal, start client
-npx @fecommunity/reactpress-client
-```
-
-## Core Components
-
-ReactPress Client includes a comprehensive set of UI components:
-
-- **Admin Dashboard** - Content management interface with role-based access
-- **Article Editor** - Advanced markdown editor with media embedding
-- **Comment System** - Moderation tools with spam detection
-- **Media Library** - File management
-- **User Management** - Account and profile settings with 2FA
-- **Analytics Views** - Data visualization components with export capabilities
-- **Theme Switcher** - Light/dark mode toggle with system preference detection
-- **Language Selector** - Internationalization controls with RTL support
-
-## PM2 Support
-
-ReactPress client supports PM2 process management for production deployments:
-
-```bash
-# Start with PM2
-npx @fecommunity/reactpress-client --pm2
-```
-
-PM2 features:
-- Automatic process restart on crash
-- Memory monitoring
-- Log management with rotation
-- Process management
-- Health checks
-
-## Configuration
-
-The client connects to the ReactPress server via environment variables:
-
-```env
-# Server API URL
-SERVER_API_URL=https://api.yourdomain.com
-
-# Client URL
-CLIENT_URL=https://yourdomain.com
-CLIENT_PORT=3001
-
-# Analytics
-GOOGLE_ANALYTICS_ID=your_ga_id
-
-# Security
-NEXT_PUBLIC_CRYPTO_KEY=your_encryption_key
-```
-
-## Development
-
-```bash
-# Clone repository
-git clone https://github.com/fecommunity/reactpress.git
-cd reactpress/client
-
-# Install dependencies
-pnpm install
-
-# Start development server with hot reload
-pnpm run dev
-
-# Start with PM2 (development)
-pnpm run pm2
-
-# Build for production
-pnpm run build
-
-# Start production server
-pnpm run start
-```
-
-## Project Structure
-
-```
-client/
-├── app/ # Next.js 14 App Router
-│ ├── (admin)/ # Admin dashboard routes
-│ ├── (public)/ # Public facing routes
-│ └── api/ # API routes
-├── components/ # Reusable UI components
-├── lib/ # Business logic and utilities
-├── providers/ # React context providers
-├── hooks/ # Custom React hooks
-├── styles/ # Global styles and design tokens
-├── public/ # Static assets
-└── bin/ # CLI entry points
-```
-
-## Environment Variables
-
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `SERVER_API_URL` | ReactPress server API URL | `http://localhost:3002` |
-| `CLIENT_URL` | Client site URL | `http://localhost:3001` |
-| `CLIENT_PORT` | Client port | `3001` |
-| `NEXT_PUBLIC_GA_ID` | Google Analytics ID | - |
-| `NEXT_PUBLIC_SITE_TITLE` | Site title | `ReactPress` |
-| `NEXT_PUBLIC_CRYPTO_KEY` | Encryption key for sensitive data | - |
-
-## CLI Commands
-
-```bash
-# Show help
-npx @fecommunity/reactpress-client --help
-
-# Start client
-npx @fecommunity/reactpress-client
-
-# Start with PM2
-npx @fecommunity/reactpress-client --pm2
-
-# Specify port
-npx @fecommunity/reactpress-client --port 3001
-
-# Enable verbose logging
-npx @fecommunity/reactpress-client --verbose
-```
-
-## Integration with ReactPress Toolkit
-
-The client seamlessly integrates with the ReactPress Toolkit for API interactions:
-
-```typescript
-import { api, types } from '@fecommunity/reactpress-toolkit';
-
-// Fetch articles with proper typing
-const articles: types.IArticle[] = await api.article.findAll();
-
-// Create new article
-const newArticle = await api.article.create({
- title: 'My New Article',
- content: 'Article content here...',
- // ... other properties
-});
-```
-
-The toolkit provides:
-- Strongly-typed API clients for all modules
-- TypeScript definitions for all data models
-- Utility functions for common operations
-- Built-in authentication and error handling
-- Automatic retry mechanisms for failed requests
-
-## Theme Customization
-
-ReactPress Client supports advanced theme customization:
-
-### Design Token System
-```typescript
-// Custom theme tokens
-const customTokens = {
- colors: {
- primary: '#0070f3',
- secondary: '#7928ca',
- background: '#ffffff',
- text: '#000000'
- },
- typography: {
- fontFamily: 'Inter, sans-serif',
- fontSize: {
- small: '12px',
- medium: '16px',
- large: '20px'
- }
- }
-};
-```
-
-### Component-Level Customization
-```typescript
-// Extend existing components
-import { Button } from '@fecommunity/reactpress-components';
-
-const CustomButton = styled(Button)`
- background-color: ${props => props.theme.colors.primary};
- border-radius: 8px;
- padding: 12px 24px;
-`;
-```
-
-## Performance Optimization
-
-- **App Router Architecture** - Server Components for optimal SSR
-- **Automatic Code Splitting** - Route-based code splitting
-- **Image Optimization** - Next.js built-in image optimization with automatic format selection
-- **Lazy Loading** - Component and route lazy loading
-- **Caching Strategies** - HTTP caching and in-memory caching
-- **Bundle Analysis** - Built-in bundle analysis tools
-
-## PWA Support
-
-ReactPress Client is a Progressive Web App with:
-- Offline support with service workers
-- Installable on devices with native app experience
-- Push notifications (coming soon)
-- App-like experience with splash screens
-
-## Testing
-
-```bash
-# Run unit tests with Vitest
-pnpm run test
-
-# Run integration tests with Playwright
-pnpm run test:e2e
-
-# Run linting
-pnpm run lint
-
-# Run formatting
-pnpm run format
-
-# Run type checking
-pnpm run type-check
-
-# Run bundle analysis
-pnpm run analyze
-```
-
-## Templates
-
-ReactPress Client can be used with various professional templates:
-
-### Hello World Template
-```bash
-npx @fecommunity/reactpress-template-hello-world my-blog
-```
-
-### Twenty Twenty Five Template
-```bash
-npx @fecommunity/reactpress-template-twentytwentyfive my-blog
-```
-
-### Custom Templates
-Create your own templates by extending the client with custom components and pages.
-
-## Deployment
-
-### Vercel Deployment (Recommended)
-
-[](https://vercel.com/new/clone?repository-url=https://github.com/fecommunity/reactpress)
-
-### Custom Deployment
-
-```bash
-# Build for production
-pnpm run build
-
-# Start production server
-pnpm run start
-```
-
-## Support
-
-- 📖 [Documentation](https://github.com/fecommunity/reactpress)
-- 🐛 [Issues](https://github.com/fecommunity/reactpress/issues)
-- 💬 [Discussions](https://github.com/fecommunity/reactpress/discussions)
-- 📧 [Support](mailto:support@reactpress.dev)
-
-## Contributing
-
-1. Fork the repository
-2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
-3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
-4. Push to the branch (`git push origin feature/AmazingFeature`)
-5. Open a pull request
-
-## License
-
-MIT License - see [LICENSE](LICENSE) file for details.
-
----
-
-Built with ❤️ by [FECommunity](https://github.com/fecommunity)
\ No newline at end of file
diff --git a/client/bin/reactpress-client.js b/client/bin/reactpress-client.js
deleted file mode 100755
index c045124c..00000000
--- a/client/bin/reactpress-client.js
+++ /dev/null
@@ -1,190 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * ReactPress Client CLI Entry Point
- * This script allows starting the ReactPress client via npx
- * Supports both regular and PM2 startup modes
- */
-
-const path = require('path');
-const fs = require('fs');
-const { spawn, spawnSync } = require('child_process');
-
-// Capture the original working directory where npx was executed
-// BUT prioritize the REACTPRESS_ORIGINAL_CWD environment variable if it exists
-// This ensures consistency when running via pnpm dev from root directory
-const originalCwd = process.env.REACTPRESS_ORIGINAL_CWD || process.cwd();
-
-// Get command line arguments
-const args = process.argv.slice(2);
-const usePM2 = args.includes('--pm2');
-const showHelp = args.includes('--help') || args.includes('-h');
-
-// Show help if requested
-if (showHelp) {
- console.log(`
-ReactPress Client - Next.js-based frontend for ReactPress CMS
-
-Usage:
- npx @fecommunity/reactpress-client [options]
-
-Options:
- --pm2 Start client with PM2 process manager
- --help, -h Show this help message
-
-Examples:
- npx @fecommunity/reactpress-client # Start client normally
- npx @fecommunity/reactpress-client --pm2 # Start client with PM2
- npx @fecommunity/reactpress-client --help # Show this help message
- `);
- process.exit(0);
-}
-
-// Get the directory where this script is located
-const binDir = __dirname;
-const clientDir = path.join(binDir, '..');
-const nextDir = path.join(clientDir, '.next');
-
-// Function to check if PM2 is installed
-function isPM2Installed() {
- try {
- require.resolve('pm2');
- return true;
- } catch (e) {
- // Check if PM2 is installed globally
- try {
- spawnSync('pm2', ['--version'], { stdio: 'ignore' });
- return true;
- } catch (e) {
- return false;
- }
- }
-}
-
-// Function to install PM2
-function installPM2() {
- console.log('[ReactPress Client] Installing PM2...');
- const installResult = spawnSync('npm', ['install', 'pm2', '--no-save'], {
- stdio: 'inherit',
- cwd: clientDir
- });
-
- if (installResult.status !== 0) {
- console.error('[ReactPress Client] Failed to install PM2');
- return false;
- }
-
- return true;
-}
-
-// Function to start with PM2
-function startWithPM2() {
- // Check if PM2 is installed
- if (!isPM2Installed()) {
- // Try to install PM2
- if (!installPM2()) {
- console.error('[ReactPress Client] Cannot start with PM2');
- process.exit(1);
- }
- }
-
- // Check if the client is built
- if (!fs.existsSync(nextDir)) {
- console.log('[ReactPress Client] Client not built yet. Building...');
-
- // Try to build the client
- const buildResult = spawnSync('npm', ['run', 'build'], {
- stdio: 'inherit',
- cwd: clientDir
- });
-
- if (buildResult.status !== 0) {
- console.error('[ReactPress Client] Failed to build client');
- process.exit(1);
- }
- }
-
- console.log('[ReactPress Client] Starting with PM2...');
-
- // Use PM2 to start the Next.js production server
- let pm2Command = 'pm2';
- try {
- // Try to resolve PM2 path
- pm2Command = path.join(clientDir, 'node_modules', '.bin', 'pm2');
- if (!fs.existsSync(pm2Command)) {
- pm2Command = 'pm2';
- }
- } catch (e) {
- pm2Command = 'pm2';
- }
-
- // Start with PM2 using direct command
- const pm2 = spawn(pm2Command, ['start', 'npm', '--name', 'reactpress-client', '--', 'run', 'start'], {
- stdio: 'inherit',
- cwd: clientDir
- });
-
- pm2.on('close', (code) => {
- console.log(`[ReactPress Client] PM2 process exited with code ${code}`);
- process.exit(code);
- });
-
- pm2.on('error', (error) => {
- console.error('[ReactPress Client] Failed to start with PM2:', error);
- process.exit(1);
- });
-}
-
-// Function to start with regular Node.js (npm start)
-function startWithNode() {
- // Check if the app is built
- if (!fs.existsSync(nextDir)) {
- console.log('[ReactPress Client] Client not built yet. Building...');
-
- // Try to build the client
- const buildResult = spawnSync('npm', ['run', 'build'], {
- stdio: 'inherit',
- cwd: clientDir
- });
-
- if (buildResult.status !== 0) {
- console.error('[ReactPress Client] Failed to build client');
- process.exit(1);
- }
- }
-
- // ONLY set the environment variable if it's not already set
- // This preserves the value set by set-env.js when running pnpm dev from root
- if (!process.env.REACTPRESS_ORIGINAL_CWD) {
- process.env.REACTPRESS_ORIGINAL_CWD = originalCwd;
- } else {
- console.log(`[ReactPress Client] Using existing REACTPRESS_ORIGINAL_CWD: ${process.env.REACTPRESS_ORIGINAL_CWD}`);
- }
-
- // Change to the client directory
- process.chdir(clientDir);
-
- // Start with npm start
- console.log('[ReactPress Client] Starting with npm start...');
- const npmStart = spawn('npm', ['start'], {
- stdio: 'inherit',
- cwd: clientDir
- });
-
- npmStart.on('close', (code) => {
- console.log(`[ReactPress Client] npm start process exited with code ${code}`);
- process.exit(code);
- });
-
- npmStart.on('error', (error) => {
- console.error('[ReactPress Client] Failed to start with npm start:', error);
- process.exit(1);
- });
-}
-
-// Main execution
-if (usePM2) {
- startWithPM2();
-} else {
- startWithNode();
-}
\ No newline at end of file
diff --git a/client/next-env.d.ts b/client/next-env.d.ts
deleted file mode 100644
index 4f11a03d..00000000
--- a/client/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/client/next-sitemap.js b/client/next-sitemap.js
deleted file mode 100644
index 63aa6ab1..00000000
--- a/client/next-sitemap.js
+++ /dev/null
@@ -1,10 +0,0 @@
-const { config } = require('@fecommunity/reactpress-toolkit');
-
-module.exports = {
- siteUrl: config.CLIENT_SITE_URL,
- generateRobotsTxt: true,
- robotsTxtOptions: {
- policies: [{ userAgent: '*', allow: '/', disallow: '/admin/' }],
- },
- exclude: ['/admin', '/admin/**'],
-};
diff --git a/client/next.config.js b/client/next.config.js
deleted file mode 100644
index 4ff813f5..00000000
--- a/client/next.config.js
+++ /dev/null
@@ -1,67 +0,0 @@
-const path = require('path');
-const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
-const withPlugins = require('next-compose-plugins');
-const withLess = require('next-with-less');
-const withPWA = require('next-pwa');
-const { config } = require('@fecommunity/reactpress-toolkit');
-const antdVariablesFilePath = path.resolve(__dirname, './antd-custom.less');
-
-const getServerApiUrl = () => {
- if (config.SERVER_URL) {
- return `${config.SERVER_SITE_URL}/api`;
- } else {
- return config.SERVER_API_URL || `${process.env.SERVER_SITE_URL}/api` || 'http://localhost:3002/api';
- }
-};
-
-/** @type {import('next').NextConfig} */
-const nextConfig = {
- assetPrefix: config.CLIENT_ASSET_PREFIX || '/',
- i18n: {
- locales: config.locales && config.locales.length > 0 ? config.locales : ['zh', 'en'],
- defaultLocale: config.defaultLocale || 'zh',
- },
- env: {
- SERVER_API_URL: getServerApiUrl(),
- GITHUB_CLIENT_ID: config.GITHUB_CLIENT_ID,
- },
- webpack: (config, { dev, isServer }) => {
- config.resolve.plugins.push(new TsconfigPathsPlugin());
- return config;
- },
- eslint: {
- ignoreDuringBuilds: true,
- },
- typescript: {
- ignoreBuildErrors: true,
- },
- compiler: {
- removeConsole: {
- exclude: ['error'],
- },
- },
-};
-
-module.exports = withPlugins(
- [
- [
- withPWA,
- {
- pwa: {
- disable: process.env.NODE_ENV !== 'production',
- dest: '.next',
- sw: 'service-worker.js',
- },
- },
- ],
- [
- withLess,
- {
- lessLoaderOptions: {
- additionalData: (content) => `${content}\n\n@import '${antdVariablesFilePath}';`,
- },
- },
- ],
- ],
- nextConfig
-);
\ No newline at end of file
diff --git a/client/package.json b/client/package.json
deleted file mode 100644
index 6f4e531d..00000000
--- a/client/package.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "name": "@fecommunity/reactpress-client",
- "version": "3.7.0",
- "bin": {
- "reactpress-client": "./bin/reactpress-client.js"
- },
- "files": [
- ".next/**/*",
- "bin/**/*",
- "public/**/*",
- "next.config.js",
- "server.js"
- ],
- "scripts": {
- "prebuild": "rimraf .next",
- "build": "next build",
- "postbuild": "next-sitemap",
- "dev": "node server.js",
- "start": "cross-env NODE_ENV=production node server.js",
- "pm2": "pm2 start npm --name @fecommunity/reactpress-client -- start"
- },
- "dependencies": {
- "@ant-design/compatible": "^1.1.0",
- "@ant-design/cssinjs": "^1.22.0",
- "@ant-design/icons": "^4.7.0",
- "@ant-design/pro-layout": "7.19.11",
- "@monaco-editor/react": "^4.6.0",
- "@fecommunity/reactpress-toolkit": "workspace:*",
- "fs-extra": "^10.0.0",
- "antd": "^5.24.4",
- "array-move": "^3.0.1",
- "axios": "^0.23.0",
- "classnames": "^2.3.1",
- "copy-to-clipboard": "^3.3.1",
- "date-fns": "^2.17.0",
- "deep-equal": "^2.0.5",
- "dotenv": "^17.2.3",
- "highlight.js": "^9.18.5",
- "less": "^4.1.2",
- "less-vars-to-js": "^1.3.0",
- "lodash-es": "^4.17.21",
- "mime-types": "^2.1.26",
- "next": "^12.3.4",
- "next-compose-plugins": "^2.2.1",
- "next-fonts": "^1.5.1",
- "next-images": "^1.3.1",
- "next-intl": "^1.5.1",
- "next-page-transitions": "^1.0.0-beta.2",
- "next-pwa": "^5.5.2",
- "next-sitemap": "^1.6.102",
- "next-with-less": "^2.0.5",
- "nprogress": "^0.2.0",
- "open": "^8.4.2",
- "preact": "^10.5.14",
- "qrcode-svg": "^1.1.0",
- "react": "17.0.2",
- "react-dom": "17.0.2",
- "react-infinite-scroller": "^1.2.4",
- "react-lazyload": "^2.6.5",
- "react-sortable-hoc": "^2.0.0",
- "react-spring": "^9.1.2",
- "react-text-loop": "2.3.0",
- "react-visibility-sensor": "^5.1.1",
- "showdown": "^1.9.1",
- "viewerjs": "^1.5.0",
- "xml": "^1.0.1",
- "echarts-for-react": "^3.0.2",
- "echarts": "^5.6.0"
- },
- "devDependencies": {
- "@types/node": "17.0.22",
- "@types/react": "17.0.42",
- "@types/react-infinite-scroller": "^1.2.3",
- "@typescript-eslint/eslint-plugin": "^5.21.0",
- "@typescript-eslint/parser": "^5.21.0",
- "cross-env": "^7.0.3",
- "eslint": "8.11.0",
- "eslint-config-next": "12.1.0",
- "eslint-config-prettier": "^8.5.0",
- "eslint-plugin-import": "^2.26.0",
- "eslint-plugin-prettier": "^4.0.0",
- "eslint-plugin-react": "^7.29.4",
- "eslint-plugin-react-hooks": "^4.5.0",
- "eslint-plugin-simple-import-sort": "^7.0.0",
- "less-loader": "^10.2.0",
- "rimraf": "^3.0.2",
- "sass": "^1.49.9",
- "tsconfig-paths-webpack-plugin": "^3.5.2",
- "typescript": "4.6.2"
- }
-}
diff --git a/client/pages/404.tsx b/client/pages/404.tsx
deleted file mode 100644
index 5e5e5df3..00000000
--- a/client/pages/404.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react';
-
-import { Error404 } from './_error';
-
-function Error() {
- return ;
-}
-
-export default Error;
diff --git a/client/pages/_app.tsx b/client/pages/_app.tsx
deleted file mode 100644
index 18c4b309..00000000
--- a/client/pages/_app.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import '@/theme/index.scss';
-import 'highlight.js/styles/atom-one-dark.css';
-import 'viewerjs/dist/viewer.css';
-
-import { NProgress } from '@components/NProgress';
-import { ConfigProvider, theme } from 'antd';
-import { IntlMessages, NextIntlProvider } from 'next-intl';
-import App from 'next/app';
-import { default as Router } from 'next/router';
-
-import { Analytics } from '@/components/Analytics';
-import { FixAntdStyleTransition } from '@/components/FixAntdStyleTransition';
-import { ViewStatistics } from '@/components/ViewStatistics';
-import { GlobalContext, IGlobalContext } from '@/context/global';
-import { AppLayout } from '@/layout/AppLayout';
-import { CategoryProvider } from '@/providers/category';
-import { PageProvider } from '@/providers/page';
-import { SettingProvider } from '@/providers/setting';
-import { TagProvider } from '@/providers/tag';
-import { UserProvider } from '@/providers/user';
-import { safeJsonParse } from '@/utils/json';
-import { toLogin } from '@/utils/login';
-
-Router.events.on('routeChangeComplete', () => {
- setTimeout(() => {
- if (document.documentElement.scrollTop > 0) {
- window.scrollTo({
- top: 0,
- behavior: 'smooth',
- });
- }
- }, 0);
-});
-
-class MyApp extends App {
- state = {
- locale: '',
- user: null,
- theme: null,
- collapsed: false,
- };
-
- static getInitialProps = async ({ Component, ctx }) => {
- const getPagePropsPromise = Component.getInitialProps ? Component.getInitialProps(ctx) : Promise.resolve({});
- const [pageProps, setting, tags, categories, pages] = await Promise.all([
- getPagePropsPromise,
- SettingProvider.getSetting(),
- TagProvider.getTags({ articleStatus: 'publish' }),
- CategoryProvider.getCategory({ articleStatus: 'publish' }),
- PageProvider.getAllPublisedPages(),
- ]);
- const i18n = safeJsonParse(setting.i18n);
- const globalSetting = safeJsonParse(setting.globalSetting)?.[ctx?.locale];
- return {
- pageProps,
- setting,
- tags,
- categories,
- pages: pages[0] || [],
- i18n,
- globalSetting,
- locales: Object.keys(i18n),
- };
- };
-
- changeLocale = (key) => {
- window.localStorage.setItem('locale', key);
- this.setState({ locale: key });
- };
-
- setUser = (user) => {
- window.localStorage.setItem('user', JSON.stringify(user));
- this.setState({ user });
- };
-
- removeUser = () => {
- window.localStorage.setItem('user', '');
- this.setState({ user: null });
- window.location.reload();
- };
-
- changeTheme = (theme: string) => {
- this.setState({ theme });
- };
-
-
- getSetting = () => {
- SettingProvider.getSetting().then((res) => {
- this.setState({ setting: res });
- });
- };
-
- isAdminPage = () => {
- const isAdminPage = this.props?.router?.route?.startsWith('/admin');
- return isAdminPage;
- }
-
- getUserFromStorage = () => {
- const str = localStorage.getItem('user');
- const isAdminPage = this.isAdminPage();
- if (!isAdminPage) {
- return;
- }
- if (str) {
- const user = JSON.parse(str);
- this.setUser(user);
- UserProvider.checkAdmin(user);
- } else {
- toLogin();
- }
- };
-
- toggleCollapse = () => {
- this.setState({ collapsed: !this.state.collapsed });
- };
-
- componentDidMount() {
- const userStr = window.localStorage.getItem('user');
- if (userStr) {
- this.setState({ user: safeJsonParse(userStr) });
- }
- this.getUserFromStorage();
- }
-
- render() {
- const { Component, pageProps, i18n, globalSetting, locales, router, ...contextValue } = this.props;
- const locale = this.state.locale || router.locale;
- const { needLayoutFooter = true, hasBg = false } = pageProps;
- const message = i18n[locale] || {};
- const algorithm = this.state.theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm;
- const isAdminPage = this.isAdminPage();
- const hasFooter = !isAdminPage && needLayoutFooter;
-
- return (
-
-
-
-
-
-
-
- {!isAdminPage && }
-
-
-
-
-
- );
- }
-}
-
-export default MyApp;
diff --git a/client/pages/_document.tsx b/client/pages/_document.tsx
deleted file mode 100644
index 4e515f7b..00000000
--- a/client/pages/_document.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
-import type { DocumentContext } from 'next/document';
-import Document, { Head, Html, Main, NextScript } from 'next/document';
-
-const MyDocument = () => (
-
-
-
-
-
-
-
-);
-
-MyDocument.getInitialProps = async (ctx: DocumentContext) => {
- const cache = createCache();
- const originalRenderPage = ctx.renderPage;
- ctx.renderPage = () =>
- originalRenderPage({
- enhanceApp: (App) => (props) => (
-
-
-
- ),
- });
-
- const initialProps = await Document.getInitialProps(ctx);
- const style = extractStyle(cache, true);
- return {
- ...initialProps,
- styles: (
- <>
- {initialProps.styles}
-
- >
- ),
- };
-};
-
-export default MyDocument;
\ No newline at end of file
diff --git a/client/pages/_error.tsx b/client/pages/_error.tsx
deleted file mode 100644
index cf231ef1..00000000
--- a/client/pages/_error.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Button, Result } from 'antd';
-import { useTranslations } from 'next-intl';
-import { default as Router } from 'next/router';
-import React from 'react';
-
-const style = {
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- height: '100%',
- textAlign: 'center',
-} as React.CSSProperties;
-
-export const Error404 = () => {
- const t = useTranslations();
-
- return (
-
- Router.replace('/')}>
- {t('backHome')}
-
- }
- />
-
- );
-};
-
-const ServerError = ({ statusCode }) => {
- const t = useTranslations();
-
- return (
-
- Router.replace('/')}>
- {t('backHome')}
-
- }
- />
-
- );
-};
-
-function Error({ statusCode }) {
- if (!statusCode) {
- return An error occurred on client
;
- }
-
- if (+statusCode === 404) {
- return ;
- }
-
- return ;
-}
-
-Error.getInitialProps = ({ res, err }) => {
- const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
- return { statusCode };
-};
-
-export default Error;
diff --git a/client/pages/admin/article/category/index.module.scss b/client/pages/admin/article/category/index.module.scss
deleted file mode 100644
index aac2be21..00000000
--- a/client/pages/admin/article/category/index.module.scss
+++ /dev/null
@@ -1,46 +0,0 @@
-.wrapper {
- .btns {
- margin-top: 16px;
-
- button + button {
- margin-left: 16px;
- }
-
- &.isEdit {
- display: flex;
- justify-content: space-between;
- }
- }
-
- ul.list {
- background-color: #fff;
- }
-
- li.item {
- display: inline-block;
- padding: 2px 8px;
- margin: 0 7px 7px 0;
- line-height: 1.5em;
- color: #8e8787;
- cursor: pointer;
- border: 1px solid #d9d9d9;
- border-radius: 2px;
- transition: all ease-in-out 0.2s;
-
- &:hover {
- color: #fff;
- background-color: var(--primary-color);
- border: 1px solid var(--primary-color);
- }
-
- &.active {
- color: var(--primary-color);
- background-color: var(--primary-color);
- border: 1px solid var(--primary-color);
- }
-
- a {
- color: inherit;
- }
- }
-}
diff --git a/client/pages/admin/article/category/index.tsx b/client/pages/admin/article/category/index.tsx
deleted file mode 100644
index ebf8fbdd..00000000
--- a/client/pages/admin/article/category/index.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { Button, Card, Col, Form, Input, message, Popconfirm, Row } from 'antd';
-import cls from 'classnames';
-import { NextPage } from 'next';
-import { useCallback, useMemo, useState } from 'react';
-
-import { AdminLayout } from '@/layout/AdminLayout';
-import { CategoryProvider } from '@/providers/category';
-
-import style from './index.module.scss';
-
-interface IProps {
- data: ICategory[];
-}
-
-const Page: NextPage = ({ data: defaultData = [] }) => {
- const [data, setData] = useState(defaultData);
- const [mode, setMode] = useState('create');
- const [current, setCurrent] = useState(null);
- const [label, setLabel] = useState(null);
- const [value, setValue] = useState(null);
-
- const isCreateMode = useMemo(() => mode === 'create', [mode]);
-
- const getData = useCallback(() => {
- CategoryProvider.getCategory().then((res) => {
- setData(res);
- });
- }, []);
-
- const reset = useCallback(() => {
- setMode('create');
- setCurrent(null);
- setLabel(null);
- setValue(null);
- }, []);
-
- const addTag = useCallback(
- (data) => {
- if (!data || !data.label) {
- return;
- }
-
- CategoryProvider.add(data).then(() => {
- message.success('添加分类成功');
- reset();
- getData();
- });
- },
- [reset, getData]
- );
-
- const updateTag = useCallback(
- (id, data) => {
- if (!data || !data.label) {
- return;
- }
-
- CategoryProvider.update(id, data).then(() => {
- message.success('更新分类成功');
- reset();
- getData();
- });
- },
- [reset, getData]
- );
-
- const deleteTag = useCallback(
- (id) => {
- CategoryProvider.delete(id).then(() => {
- message.success('删除分类成功');
- reset();
- getData();
- });
- },
- [reset, getData]
- );
-
- return (
-
-
-
-
-
- {
- setLabel(e.target.value);
- }}
- >
-
-
- {
- setValue(e.target.value);
- }}
- >
-
-
- {isCreateMode ? (
-
- ) : (
- <>
-
-
-
-
- deleteTag(current.id)}
- okText="确认"
- cancelText="取消"
- >
-
-
- >
- )}
-
-
-
-
-
-
- {data.map((d) => (
- - {
- setMode('edit');
- setCurrent(d);
- setLabel(d.label);
- setValue(d.value);
- }}
- >
-
- {d.label}
-
-
- ))}
-
-
-
-
-
- );
-};
-
-Page.getInitialProps = async () => {
- const data = await CategoryProvider.getCategory();
- return { data };
-};
-
-export default Page;
diff --git a/client/pages/admin/article/editor/[id].tsx b/client/pages/admin/article/editor/[id].tsx
deleted file mode 100644
index 46a19e7e..00000000
--- a/client/pages/admin/article/editor/[id].tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { NextPage } from 'next';
-import React from 'react';
-
-import { ArticleEditor } from '@/components/ArticleEditor';
-import { ArticleProvider } from '@/providers/article';
-import { AdminLayout } from '@/layout/AdminLayout';
-
-interface IProps {
- id: string | number;
- article: IArticle;
-}
-
-const Editor: NextPage = ({ id, article }) => {
- return (
-
-
-
- );
-};
-
-Editor.getInitialProps = async (ctx) => {
- const { id } = ctx.query;
- const article = await ArticleProvider.getArticle(id);
- return { id, article } as { id: string | number; article: IArticle };
-};
-
-export default Editor;
diff --git a/client/pages/admin/article/editor/index.tsx b/client/pages/admin/article/editor/index.tsx
deleted file mode 100644
index 3bf3ac09..00000000
--- a/client/pages/admin/article/editor/index.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { NextPage } from 'next';
-import React from 'react';
-
-import { ArticleEditor } from '@/components/ArticleEditor';
-import { AdminLayout } from '@/layout/AdminLayout';
-
-const Editor: NextPage = () => {
- return
-
-
-};
-
-export default Editor;
diff --git a/client/pages/admin/article/index.module.scss b/client/pages/admin/article/index.module.scss
deleted file mode 100644
index 5746e1d8..00000000
--- a/client/pages/admin/article/index.module.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-.wrapper {
- .createBtn {
- margin: 0 0 16px;
- }
-
- .action {
- display: flex;
- align-items: center;
-
- a {
- color: #1890ff;
- }
- }
-}
diff --git a/client/pages/admin/article/index.tsx b/client/pages/admin/article/index.tsx
deleted file mode 100644
index c5f1943e..00000000
--- a/client/pages/admin/article/index.tsx
+++ /dev/null
@@ -1,350 +0,0 @@
-import { PlusOutlined } from '@ant-design/icons';
-import { Badge, Button, Divider, message, Modal, Popconfirm, Select, Spin, Tag } from 'antd';
-import { NextPage } from 'next';
-import Link from 'next/link';
-import React, { useCallback, useEffect, useState } from 'react';
-
-import { LocaleTime } from '@/components/LocaleTime';
-import { PaginationTable } from '@/components/PaginationTable';
-import { ViewChart } from '@/components/ViewChart';
-import { getRandomColor } from '@/utils';
-import { useAsyncLoading } from '@/hooks/useAsyncLoading';
-import { usePagination } from '@/hooks/usePagination';
-import { useSetting } from '@/hooks/useSetting';
-import { useToggle } from '@/hooks/useToggle';
-import { AdminLayout } from '@/layout/AdminLayout';
-import { ArticleProvider } from '@/providers/article';
-import { CategoryProvider } from '@/providers/category';
-import { ViewProvider } from '@/providers/view';
-import { resolveUrl } from '@/utils';
-
-import style from './index.module.scss';
-
-let updateLoadingMessage = null;
-const SCROLL = { x: 1380 };
-const SEARCH_FIELDS = [
- {
- label: '标题',
- field: 'title',
- msg: '请输入文章标题',
- },
- {
- label: '状态',
- field: 'status',
- children: (
-
- ),
- },
-];
-const COMMON_COLUMNS = [
- {
- title: '状态',
- dataIndex: 'status',
- width: 100,
- key: 'status',
- render: (status) => {
- const isDraft = status === 'draft';
- return ;
- },
- },
- {
- title: '分类',
- key: 'category',
- dataIndex: 'category',
- width: 100,
- render: (category) =>
- category ? (
-
-
- {category.label}
-
-
- ) : null,
- },
- {
- title: '标签',
- key: 'tags',
- dataIndex: 'tags',
- width: 200,
- render: (tags) => (
-
- {tags.map((tag) => {
- return (
-
- {tag.label}
-
- );
- })}
-
- ),
- },
- {
- title: '阅读量',
- dataIndex: 'views',
- key: 'views',
- width: 120,
- render: (views) => (
-
- ),
- },
- {
- title: '喜欢数',
- dataIndex: 'likes',
- key: 'likes',
- width: 120,
- render: (val) => (
-
- ),
- },
- {
- title: '发布时间',
- dataIndex: 'publishAt',
- key: 'publishAt',
- width: 200,
- render: (date) => ,
- },
-];
-
-const Article: NextPage = () => {
- const setting = useSetting();
- const [modalVisible, toggleModalVisible] = useToggle(false);
- const [views, setViews] = useState([]);
- const [categorys, setCategorys] = useState>([]);
- const {
- loading: listLoading,
- data: articles,
- refresh,
- ...resetPagination
- } = usePagination(ArticleProvider.getArticles);
- const [updateApi, updateLoading] = useAsyncLoading(ArticleProvider.updateArticle);
- const [deleteAPi, deleteLoading] = useAsyncLoading(ArticleProvider.deleteArticle);
- const [getViewsByUrlApi, getViewsLoading] = useAsyncLoading(ViewProvider.getViewsByUrl);
-
- const updateAction = useCallback(
- (articles, key, value = null) => {
- if (!Array.isArray(articles)) {
- articles = [articles];
- }
- return () =>
- Promise.all(
- articles.map((article) => updateApi(article.id, { [key]: value !== null ? value : !article[key] }))
- ).then(() => {
- message.success('操作成功');
- refresh();
- });
- },
- [updateApi, refresh]
- );
-
- const deleteAction = useCallback(
- (ids, resetSelectedRows = null) => {
- if (!Array.isArray(ids)) {
- ids = [ids];
- }
- return () => {
- Promise.all(ids.map((id) => deleteAPi(id))).then(() => {
- message.success('操作成功');
- refresh();
- resetSelectedRows && resetSelectedRows();
- });
- };
- },
- [deleteAPi, refresh]
- );
-
- const getViews = useCallback(
- (url) => {
- toggleModalVisible();
- getViewsByUrlApi(url).then((res) => {
- setViews(res);
- });
- },
- [toggleModalVisible, getViewsByUrlApi]
- );
-
- const closeViewModal = useCallback(() => {
- toggleModalVisible();
- setViews([]);
- }, [toggleModalVisible]);
-
- const titleColumn = {
- title: '标题',
- dataIndex: 'title',
- key: 'title',
- fixed: 'left',
- width: 200,
- render: (text, record) => (
-
- {text}
-
- ),
- };
-
- const actionColumn = (resetSelectedRows) => ({
- title: '操作',
- key: 'action',
- fixed: 'right',
- render: (_, record: IArticle) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ),
- });
-
- useEffect(() => {
- CategoryProvider.getCategory().then((res) => setCategorys(res));
- }, []);
-
- useEffect(() => {
- if (updateLoading) {
- updateLoadingMessage = message.loading('操作中...', 0);
- } else {
- updateLoadingMessage && updateLoadingMessage();
- }
- }, [updateLoading]);
-
- return (
-
-
- [titleColumn, ...COMMON_COLUMNS, actionColumn(resetSelectedRows)]}
- {...resetPagination}
- refresh={refresh}
- renderLeftNode={({ hasSelected, selectedRowKeys, selectedRows, resetSelectedRows }) =>
- hasSelected ? (
- <>
-
-
-
-
-
-
-
- >
- ) : null
- }
- rightNode={
-
-
-
-
-
- }
- scroll={SCROLL}
- searchFields={[
- ...SEARCH_FIELDS,
- {
- label: '分类',
- field: 'category',
- children: (
-
- ),
- },
- ]}
- />
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default Article;
diff --git a/client/pages/admin/article/tags/index.module.scss b/client/pages/admin/article/tags/index.module.scss
deleted file mode 100644
index aac2be21..00000000
--- a/client/pages/admin/article/tags/index.module.scss
+++ /dev/null
@@ -1,46 +0,0 @@
-.wrapper {
- .btns {
- margin-top: 16px;
-
- button + button {
- margin-left: 16px;
- }
-
- &.isEdit {
- display: flex;
- justify-content: space-between;
- }
- }
-
- ul.list {
- background-color: #fff;
- }
-
- li.item {
- display: inline-block;
- padding: 2px 8px;
- margin: 0 7px 7px 0;
- line-height: 1.5em;
- color: #8e8787;
- cursor: pointer;
- border: 1px solid #d9d9d9;
- border-radius: 2px;
- transition: all ease-in-out 0.2s;
-
- &:hover {
- color: #fff;
- background-color: var(--primary-color);
- border: 1px solid var(--primary-color);
- }
-
- &.active {
- color: var(--primary-color);
- background-color: var(--primary-color);
- border: 1px solid var(--primary-color);
- }
-
- a {
- color: inherit;
- }
- }
-}
diff --git a/client/pages/admin/article/tags/index.tsx b/client/pages/admin/article/tags/index.tsx
deleted file mode 100644
index 65774d45..00000000
--- a/client/pages/admin/article/tags/index.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import { Form } from 'antd';
-import { Button, Card, Col, Input, message, Popconfirm, Row } from 'antd';
-import cls from 'classnames';
-import { NextPage } from 'next';
-import React, { useCallback, useMemo, useState } from 'react';
-
-import { AdminLayout } from '@/layout/AdminLayout';
-import { TagProvider } from '@/providers/tag';
-
-import style from './index.module.scss';
-
-interface ITagProps {
- tags: ITag[];
-}
-
-const TagPage: NextPage = ({ tags: defaultTags = [] }) => {
- const [tags, setTags] = useState(defaultTags);
- const [mode, setMode] = useState('create');
- const [currentTag, setCurrentTag] = useState(null);
- const [label, setLabel] = useState(null);
- const [value, setValue] = useState(null);
-
- const isCreateMode = useMemo(() => mode === 'create', [mode]);
-
- const getTags = useCallback(() => {
- TagProvider.getTags().then((tags) => {
- setTags(tags);
- });
- }, []);
-
- const reset = useCallback(() => {
- setMode('create');
- setCurrentTag(null);
- setLabel(null);
- setValue(null);
- }, []);
-
- const addTag = useCallback(
- (data) => {
- if (!data || !data.label) {
- return;
- }
-
- TagProvider.addTag(data).then(() => {
- message.success('添加标签成功');
- reset();
- getTags();
- });
- },
- [reset, getTags]
- );
-
- const updateTag = useCallback(
- (id, data) => {
- if (!data || !data.label) {
- return;
- }
-
- TagProvider.updateTag(id, data).then(() => {
- message.success('更新标签成功');
- reset();
- getTags();
- });
- },
- [reset, getTags]
- );
-
- const deleteTag = useCallback(
- (id) => {
- TagProvider.deleteTag(id).then(() => {
- message.success('删除标签成功');
- reset();
- getTags();
- });
- },
- [reset, getTags]
- );
-
- return (
-
-
-
-
-
- {
- setLabel(e.target.value);
- }}
- >
-
-
- {
- setValue(e.target.value);
- }}
- >
-
-
- {isCreateMode ? (
-
- ) : (
- <>
-
-
-
-
- deleteTag(currentTag.id)}
- okText="确认"
- cancelText="取消"
- >
-
-
- >
- )}
-
-
-
-
-
-
- {tags.map((d) => (
- - {
- setMode('edit');
- setCurrentTag(d);
- setLabel(d.label);
- setValue(d.value);
- }}
- >
-
- {d.label}
-
-
- ))}
-
-
-
-
-
- );
-};
-
-TagPage.getInitialProps = async () => {
- const tags = await TagProvider.getTags();
- return { tags };
-};
-
-export default TagPage;
diff --git a/client/pages/admin/comment/index.tsx b/client/pages/admin/comment/index.tsx
deleted file mode 100644
index 88cf79c3..00000000
--- a/client/pages/admin/comment/index.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { Button, message, Popconfirm, Select } from 'antd';
-import React, { useCallback, useEffect, useMemo } from 'react';
-
-import { CommentHTML } from '@/components/Comment/CommentAction/CommentHTML';
-import { CommentAction } from '@/components/Comment/CommentAction/CommentAction';
-import { CommentArticle } from '@/components/Comment/CommentAction/CommentArticle';
-import { CommentContent } from '@/components/Comment/CommentAction/CommentContent';
-import { CommentStatus } from '@/components/Comment/CommentAction/CommentStatus';
-import { LocaleTime } from '@/components/LocaleTime';
-import { PaginationTable } from '@/components/PaginationTable';
-import { useAsyncLoading } from '@/hooks/useAsyncLoading';
-import { usePagination } from '@/hooks/usePagination';
-import { AdminLayout } from '@/layout/AdminLayout';
-import { CommentProvider } from '@/providers/comment';
-
-let updateLoadingMessage = null;
-const SCROLL = { x: 1440 };
-const SEARCH_FIELDS = [
- {
- label: '称呼',
- field: 'name',
- msg: '请输入称呼',
- },
- {
- label: 'Email',
- field: 'email',
- msg: '请输入联系方式',
- },
- {
- label: '状态',
- field: 'pass',
- children: (
-
- ),
- },
-];
-const COMMON_COLUMNS = [
- {
- title: '状态',
- dataIndex: 'pass',
- key: 'pass',
- fixed: 'left',
- width: 100,
- render: (_, record) => ,
- },
- {
- title: '称呼',
- dataIndex: 'name',
- key: 'name',
- },
- {
- title: '联系方式',
- dataIndex: 'email',
- key: 'email',
- },
- {
- title: '原始内容',
- dataIndex: 'content',
- key: 'content',
- width: 160,
- render: (_, record) => ,
- },
- {
- title: 'HTML 内容',
- dataIndex: 'html',
- key: 'html',
- width: 160,
- render: (_, record) => ,
- },
-
- {
- title: '管理文章',
- dataIndex: 'url',
- key: 'url',
- width: 100,
- render: (_, record) => {
- return ;
- },
- },
- {
- title: '创建时间',
- dataIndex: 'createAt',
- key: 'createAt',
- width: 200,
- render: (date) => ,
- },
-];
-
-const Comment = () => {
- const { loading, data: comments, refresh, ...resetPagination } = usePagination(CommentProvider.getComments);
- const [updateApi, updateLoading] = useAsyncLoading(CommentProvider.updateComment);
- const [deleteApi, deleteLoading] = useAsyncLoading(CommentProvider.deleteComment);
-
- const updateAction = useCallback(
- (articles, key, value = null) => {
- if (!Array.isArray(articles)) {
- articles = [articles];
- }
- return () =>
- Promise.all(
- articles.map((article) => updateApi(article.id, { [key]: value !== null ? value : !article[key] }))
- ).then(() => {
- message.success('操作成功');
- refresh();
- });
- },
- [updateApi, refresh]
- );
-
- const deleteAction = useCallback(
- (ids, resetSelectedRows = null) => {
- if (!Array.isArray(ids)) {
- ids = [ids];
- }
- return () => {
- Promise.all(ids.map((id) => deleteApi(id))).then(() => {
- message.success('操作成功');
- resetSelectedRows && resetSelectedRows();
- refresh();
- });
- };
- },
- [deleteApi, refresh]
- );
-
- const parentCommentColumn = useMemo(
- () => ({
- title: '父级评论',
- dataIndex: 'parentCommentId',
- key: 'parentCommentId',
- width: 100,
- render: (id) => {
- const target = comments.find((c) => c.id === id);
- return (target && target.name) || '无';
- },
- }),
- [comments]
- );
-
- const actionColumn = useCallback(
- (resetSelectedRows) => ({
- title: '操作',
- key: 'action',
- fixed: 'right',
- render: (_, record) => (
- {
- resetSelectedRows();
- refresh();
- }}
- />
- ),
- }),
- [refresh]
- );
-
- useEffect(() => {
- if (updateLoading) {
- updateLoadingMessage = message.loading('操作中...', 0);
- } else {
- updateLoadingMessage && updateLoadingMessage();
- }
- }, [updateLoading]);
-
- return (
-
- [...COMMON_COLUMNS, parentCommentColumn, actionColumn(resetSelectedRows)]}
- refresh={refresh}
- {...resetPagination}
- renderLeftNode={({ hasSelected, selectedRowKeys, selectedRows, resetSelectedRows }) =>
- hasSelected ? (
- <>
-
-
-
-
-
- >
- ) : null
- }
- scroll={SCROLL}
- searchFields={SEARCH_FIELDS}
- />
-
- );
-};
-
-export default Comment;
diff --git a/client/pages/admin/file/index.module.scss b/client/pages/admin/file/index.module.scss
deleted file mode 100644
index 325f9aca..00000000
--- a/client/pages/admin/file/index.module.scss
+++ /dev/null
@@ -1,86 +0,0 @@
-.description {
- display: flex;
- margin-bottom: 7px;
- overflow: hidden;
- font-size: 12px;
- line-height: 22px;
- color: rgb(0 0 0 / 65%);
- text-overflow: ellipsis;
- white-space: nowrap;
- flex-wrap: nowrap;
-
- > p {
- display: inline-block;
- margin-right: 8px;
- color: rgb(0 0 0 / 85%);
- }
-
- > div {
- flex: 1;
- }
-}
-
-.previewContainer {
- position: relative;
- display: flex;
- height: 360px;
- margin-bottom: 12px;
- overflow: hidden;
- color: #888;
- background-color: #f5f5f5;
- justify-content: center;
- align-items: center;
-
- img {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 100%;
- height: 100%;
- transform: translate3d(-50%, -50%, 0);
- object-fit: cover;
- }
-}
-
-.urlContainer {
- padding: 8px;
- line-height: 1.4;
- word-break: break-all;
- word-wrap: break-word;
- white-space: normal;
- border: 1px solid #dedede;
-}
-
-.wrapper {
- .imgs {
- margin-top: 16px;
- }
-
- .preview {
- position: relative;
- display: flex;
- height: 180px;
- overflow: hidden;
- color: #888;
- background-color: #f5f5f5;
- justify-content: center;
- align-items: center;
-
- img {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 100%;
- height: 100%;
- transform: translate3d(-50%, -50%, 0);
- object-fit: cover;
- }
- }
-
- .img {
- display: inline-block;
- height: auto;
- max-width: 100%;
- max-height: 160px;
- }
-}
diff --git a/client/pages/admin/file/index.tsx b/client/pages/admin/file/index.tsx
deleted file mode 100644
index b5fc1615..00000000
--- a/client/pages/admin/file/index.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-import { Alert, Button, Card, Col, Drawer, List, message, Popconfirm, Row } from 'antd';
-import { NextPage } from 'next';
-import Link from 'next/link';
-import React, { useCallback, useMemo, useRef, useState } from 'react';
-import Viewer from 'viewerjs';
-
-import { LocaleTime } from '@/components/LocaleTime';
-import { PaginationTable } from '@/components/PaginationTable';
-import { Upload } from '@/components/Upload';
-import { useAsyncLoading } from '@/hooks/useAsyncLoading';
-import { usePagination } from '@/hooks/usePagination';
-import { useSetting } from '@/hooks/useSetting';
-import { useToggle } from '@/hooks/useToggle';
-import { AdminLayout } from '@/layout/AdminLayout';
-import { FileProvider } from '@/providers/file';
-import { formatFileSize } from '@/utils';
-import { copy } from '@/utils/copy';
-
-import style from './index.module.scss';
-
-const { Meta } = Card;
-
-const drawerFooterStyle: React.CSSProperties = {
- position: 'absolute',
- bottom: 0,
- width: 'calc(100% - 32px)',
- borderTop: '1px solid #e8e8e8',
- padding: '10px 16px',
- textAlign: 'right',
- left: 0,
- background: '#fff',
- borderRadius: '0 0 4px 4px',
-};
-
-const DescriptionItem = ({ title, content }) => (
-
- {title}:
- {content}
-
-);
-
-const SEARCH_FIELDS = [
- {
- label: '文件名称',
- field: 'originalname',
- msg: '请输入文件名称',
- },
- {
- label: '文件类型',
- field: 'type',
- msg: '请输入文件类型',
- },
-];
-
-const GRID = {
- gutter: 16,
- xs: 1,
- sm: 2,
- md: 4,
- lg: 4,
- xl: 4,
- xxl: 6,
-};
-
-let viewer = null;
-
-const File: NextPage = () => {
- const ref = useRef();
- const setting = useSetting();
- const [visible, toggleVisible] = useToggle(false);
- const [currentFile, setCurrentFile] = useState(null);
- const { loading, data: files, refresh, ...resetPagination } = usePagination(FileProvider.getFiles);
- const [deleteApi, deleteLoading] = useAsyncLoading(FileProvider.deleteFile);
- const isOSSSettingFullFiled = useMemo(() => setting && setting.oss, [setting]);
-
- const deleteAction = useCallback(
- (ids, resetSelectedRows = null) => {
- if (!Array.isArray(ids)) {
- ids = [ids];
- }
- return () => {
- Promise.all(ids.map((id) => deleteApi(id))).then(() => {
- message.success('操作成功');
- resetSelectedRows && resetSelectedRows();
- setCurrentFile(null);
- toggleVisible();
- refresh();
- });
- };
- },
- [deleteApi, toggleVisible, refresh]
- );
-
- const renderList = useCallback(
- (data) => {
- const renderItem = (file: IFile) => {
- const onClick = (file) => () => {
- setCurrentFile(file);
- toggleVisible();
- Promise.resolve().then(() => {
- if (!viewer) {
- viewer = new Viewer(ref.current, { inline: false });
- } else {
- viewer.update();
- }
- });
- };
-
- return (
-
-
-
- {article.title}
- - -- {t('archives')} -
-- {t('total')} {resolveArticlesCount(articles)} {t('piece')} -
-
-
- {t('publishAt')}
-
{t('comment')}
-{t('recommendToReading')}
-- {category && category.label} {t('categoryArticle')} -
-- {t('totalSearch')} {total} {t('piece')} -
-{t('unknownKnowledgeChapter')}
; - } - - return ( - <> -
-
- {t('publishAt')}
-
{t('comment')}
-{book.title}
-{book.summary}
-
-
- {book.views} {t('readingCount')}
-
- ·
-
-
- {article.cover && }
- {article.summary}
-
-
{t('recommendToReading')}
-{t('comment')}
-{t('recommendToReading')}
-- {t('yu')} {tag.label} {t('tagRelativeArticles')} -
-- {t('totalSearch')} {total} {t('piece')} -
-
-
-
-
-- 通过 WeChat 联系,可通过搜素微信号 \`red_tea_v2\` 或扫码加入 ,并备注来源。
-
-- 通过 GitHub Discussions 提问时,建议使用 \`Q&A\` 标签。
-
-- 通过 Stack Overflow 或者 Segment Fault 提问时,建议加上 \`reactpress\` 标签。
-
-
-1. [GitHub Discussions](https://github.com/ant-design/ant-design/discussions)
-2. [Stack Overflow](http://stackoverflow.com/questions/tagged/antd)(英文)
-3. [Segment Fault](https://segmentfault.com/t/antd)(中文)
-`;
diff --git a/client/src/components/Editor/MonacoEditor.tsx b/client/src/components/Editor/MonacoEditor.tsx
deleted file mode 100644
index d1b21467..00000000
--- a/client/src/components/Editor/MonacoEditor.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import Editor, { loader } from '@monaco-editor/react';
-import { message, Spin } from 'antd';
-import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
-
-import { FileProvider } from '@/providers/file';
-
-import { registerScollListener, removeScrollListener, subjectScrollListener } from './utils/syncScroll';
-
-const IMG_REXEXP = /^image\/(png|jpg|jpeg|gif)$/i;
-
-const MonacoEditorOptions = {
- language: 'markdown',
- automaticLayout: true,
- wordWrap: 'on',
- theme: 'vs',
- minimap: {
- enabled: false,
- },
- scrollBeyondLastLine: false,
- scrollbar: {
- useShadows: false,
- vertical: 'visible',
- horizontal: 'visible',
- verticalScrollbarSize: 6,
- horizontalScrollbarSize: 6,
- },
-} as any;
-
-
-// solve jsdelivr cdn load error
-loader.config({
- paths: {
- vs: "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.31.1/min/vs"
- }
-});
-
-const _MonacoEditor = (props, ref) => {
- const { defaultValue, onMount, onChange, onSave } = props;
- const container = useRef(null);
- const monacoRef = useRef(null);
- const editorRef = useRef(null);
- const [mounted, setMounted] = useState(false);
-
- const registerChange = useCallback(() => {
- editorRef.current.onDidChangeModelContent(() => {
- const content = editorRef.current.getValue();
- onChange(content);
- });
- }, [onChange]);
-
- const registerScroll = useCallback(() => {
- editorRef.current.onDidScrollChange(
- registerScollListener('editor', () => {
- const top =
- editorRef.current.getScrollTop() /
- (editorRef.current.getContentHeight() - editorRef.current.getLayoutInfo().height);
- return {
- id: 'editor-scroll',
- top: top,
- left: editorRef.current.getScrollLeft(),
- };
- })
- );
- }, []);
-
- const registerSave = useCallback(() => {
- // eslint-disable-next-line no-bitwise
- editorRef.current.addCommand(monacoRef.current.KeyMod.CtrlCmd | monacoRef.current.KeyCode.KEY_S, () => {
- onSave(editorRef.current.getValue());
- });
- }, [onSave]);
-
- const notifyMounted = useCallback(() => {
- window.postMessage(
- {
- id: 'editor-mounted',
- },
- window.location.href
- );
- }, []);
-
- const handleEditorDidMount = useCallback(
- (editor, monaco) => {
- monacoRef.current = monaco;
- editorRef.current = editor;
- registerScroll();
- registerChange();
- registerSave();
- notifyMounted();
- setMounted(true);
- onMount && onMount();
- },
- [onMount, registerScroll, registerChange, registerSave, notifyMounted]
- );
-
- useImperativeHandle(ref, () => ({
- editor: editorRef.current,
- monaco: monacoRef.current,
- }));
-
- useEffect(() => {
- if (!mounted) {
- return undefined;
- }
- const listener = ({ top, left }) => {
- editorRef.current.setScrollTop(top * editorRef.current.getContentHeight());
- editorRef.current.setScrollLeft(left);
- };
- subjectScrollListener('editor', 'preview', listener);
- return () => {
- removeScrollListener('preview', listener);
- };
- }, [mounted]);
-
- useEffect(() => {
- if (!mounted || !editorRef.current) {
- return;
- }
- editorRef.current.setValue(defaultValue);
- }, [mounted, defaultValue]);
-
- useEffect(() => {
- if (!mounted) {
- return undefined;
- }
-
- const editor = editorRef.current;
- const clearRef = {
- current: () => {
- return undefined;
- },
- };
- editor.onDidPaste((e) => {
- const pastePosition = e.range;
- const delta = [
- {
- range: new monacoRef.current.Range(
- pastePosition.startLineNumber,
- pastePosition.startColumn,
- pastePosition.endLineNumber,
- pastePosition.endColumn
- ),
- text: ``,
- },
- ];
- clearRef.current = () => {
- editor.executeEdits('', delta);
- };
- });
-
- const onPaste = async (e) => {
- const selection = editor.getSelection();
- const items = e.clipboardData.items;
- const imgFiles = (Array.from(items) as [DataTransferItem])
- .filter((item) => item.type.match(IMG_REXEXP))
- .map((item) => item.getAsFile());
- if (!imgFiles.length) {
- return;
- }
- const hide = message.loading('正在上传图片中', 0);
- const upload = (file) => {
- return FileProvider.uploadFile(file, 1).then(({ url }) => {
- const delta = [
- {
- range: new monacoRef.current.Range(
- selection.endLineNumber,
- selection.endColumn,
- selection.endLineNumber,
- selection.endColumn
- ),
- text: ``,
- },
- ];
- editor.executeEdits('', delta);
- const { endLineNumber, endColumn } = editor.getSelection();
- editor.setPosition({ lineNumber: endLineNumber, column: endColumn });
- });
- };
- await Promise.all(imgFiles.map(upload));
- hide();
- clearRef.current();
- };
-
- window.addEventListener('paste', onPaste);
-
- return () => {
- window.removeEventListener('paste', onPaste);
- };
- }, [mounted]);
-
- return (
- {item.description}
} - className={styles.listItem} - /> -