diff --git a/.env b/.env index f8bd3b74..4098c604 100644 --- a/.env +++ b/.env @@ -1,13 +1,9 @@ -# ReactPress — copy to .env and run `pnpm init` to sync from .reactpress/config.json +# ReactPress — managed by reactpress-cli DB_HOST=127.0.0.1 -DB_PORT=3306 +DB_PORT=3307 DB_USER=reactpress DB_PASSWD=reactpress DB_DATABASE=reactpress - - -# Client Config CLIENT_SITE_URL=http://localhost:3001 - -# Server Config -SERVER_SITE_URL=http://localhost:3002 \ No newline at end of file +SERVER_SITE_URL=http://localhost:3002 +SERVER_PORT=3002 diff --git a/.eslintrc.js b/.eslintrc.js index 8102f728..1923bb0c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,19 +7,63 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, overrides: [ { - files: ['**/*.{ts,tsx,js,jsx}'], + files: ['web/**/*.{ts,tsx}'], + parserOptions: { + project: ['./web/tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + { + files: ['toolkit/**/*.ts'], + parserOptions: { + project: ['./toolkit/tsconfig.json'], + tsconfigRootDir: __dirname, + }, + }, + { + files: ['server/**/*.ts'], parserOptions: { - project: ['./client/tsconfig.json'], + project: ['./server/tsconfig.json'], tsconfigRootDir: __dirname, - sourceType: 'module', + }, + }, + { + files: ['docs/**/*.{ts,tsx}'], + parserOptions: { + project: ['./docs/tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + { + files: ['themes/hello-world/**/*.{ts,tsx}'], + parserOptions: { + project: ['./themes/hello-world/tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + { + // Next.js regenerates next-env.d.ts with /// for route types. + files: ['**/next-env.d.ts'], + rules: { + '@typescript-eslint/triple-slash-reference': 'off', }, }, ], settings: { react: { - version: '17.0', + version: 'detect', + }, + 'import/resolver': { + node: { + paths: ['./'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, }, }, env: { @@ -43,12 +87,23 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/ban-types': 0, 'react-hooks/rules-of-hooks': 2, - 'react-hooks/exhaustive-deps': 2, + 'react-hooks/exhaustive-deps': 1, 'react/prop-types': 0, 'react/react-in-jsx-scope': 0, - 'prettier/prettier': 'error', - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', + // Prettier 2.x cannot parse modern TS (`import type`, inline `type` imports). + // web uses `vp fmt` (Oxfmt); keep formatting out of ESLint to avoid false IDE errors. + 'prettier/prettier': 0, + 'simple-import-sort/imports': 'off', + 'simple-import-sort/exports': 'off', }, - ignorePatterns: ['dist/', 'node_modules', 'scripts'], + ignorePatterns: [ + 'dist/', + 'node_modules', + 'scripts', + 'examples', + '**/.next', + 'toolkit/dist', + 'server/dist', + 'web/src/routeTree.gen.ts', + ], }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44047916..8604b325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,10 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies @@ -50,22 +48,17 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build toolkit - run: pnpm run build:toolkit - - - name: Build server - run: pnpm run build:server + - name: Production build + run: pnpm run build - name: Create test .env run: | diff --git a/.github/workflows/deploy-ecs.yml b/.github/workflows/deploy-ecs.yml new file mode 100644 index 00000000..f87a0d11 --- /dev/null +++ b/.github/workflows/deploy-ecs.yml @@ -0,0 +1,110 @@ +# Build on GitHub → upload release tarball → SSH deploy on ECS. +# +# Required GitHub Secrets (Settings → Secrets and variables → Actions): +# ECS_HOST ECS public IP or domain +# ECS_USER SSH user, e.g. root or deploy +# ECS_SSH_KEY Private key (PEM), full content including BEGIN/END lines +# ECS_APP_DIR App root on server, e.g. /opt/reactpress +# +# Optional Secrets: +# ECS_SSH_PORT Default 22 +# NGINX_ENTRY_URL Public site URL written into deploy env, e.g. https://blog.example.com +# +# One-time ECS setup: +# 1. Clone repo to ECS_APP_DIR, create .env (DB, ports, domain — never commit) +# 2. Install Node 24, pnpm, PM2, Docker; run `pnpm run deploy` once manually +# 3. Add GitHub Actions deploy key / user SSH public key to ECS ~/.ssh/authorized_keys +# +name: Deploy ECS + +on: + workflow_dispatch: + push: + branches: [main, master] + +concurrency: + group: deploy-ecs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Production build + pack + run: pnpm run build + + - name: Locate release tarball + id: pack + run: | + TAR="$(ls -1 dist/reactpress-release-*.tar.gz | tail -1)" + echo "path=$TAR" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$TAR")" >> "$GITHUB_OUTPUT" + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: reactpress-release + path: ${{ steps.pack.outputs.path }} + retention-days: 7 + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + environment: production + steps: + - name: Download release artifact + uses: actions/download-artifact@v4 + with: + name: reactpress-release + path: dist + + - name: Resolve tarball name + id: tar + run: | + TAR="$(ls -1 dist/reactpress-release-*.tar.gz | tail -1)" + echo "path=$TAR" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$TAR")" >> "$GITHUB_OUTPUT" + + - name: Prepare upload directory + run: | + mkdir -p upload + cp "${{ steps.tar.outputs.path }}" "upload/${{ steps.tar.outputs.name }}" + + - name: Copy release to ECS + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.ECS_HOST }} + username: ${{ secrets.ECS_USER }} + key: ${{ secrets.ECS_SSH_KEY }} + port: ${{ secrets.ECS_SSH_PORT || 22 }} + source: upload/${{ steps.tar.outputs.name }} + target: ${{ secrets.ECS_APP_DIR }}/dist/ + + - name: Deploy on ECS + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.ECS_HOST }} + username: ${{ secrets.ECS_USER }} + key: ${{ secrets.ECS_SSH_KEY }} + port: ${{ secrets.ECS_SSH_PORT || 22 }} + command_timeout: 30m + script: | + set -e + cd "${{ secrets.ECS_APP_DIR }}" + git fetch origin "${{ github.ref_name }}" --depth=1 + git checkout "${{ github.sha }}" + export NGINX_ENTRY_URL="${{ secrets.NGINX_ENTRY_URL }}" + pnpm run deploy -- "dist/${{ steps.tar.outputs.name }}" diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 4b8df181..c3600446 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -1,33 +1,30 @@ -name: Node.js Package +name: Release on: release: types: [created] jobs: - build: + validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - - run: npm test + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 - publish-gpr: - needs: build - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: - node-version: 20 - registry-url: https://npm.pkg.github.com/ - - run: npm ci - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Production build + run: pnpm run build + + - name: Test + run: pnpm test + + - name: Build publishable packages + run: pnpm run publish:build diff --git a/.gitignore b/.gitignore index 0b7e6763..0c23f6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,17 @@ +# 官方主题模板(运行时副本在 .reactpress/runtime/,已由 .reactpress/ 忽略) +themes/* +!themes/hello-world/ +!themes/theme-starter/ +!themes/.gitkeep +!themes/README.md + node_modules .DS_Store .idea .next +.next-preview .cursor +.brand-export/ .env.prod .reactpress/ @@ -14,16 +23,33 @@ node_modules sitemap.xml lib -!cli/lib +!cli/src/lib/ +!themes/hello-world/**/lib/ +!themes/theme-starter/**/lib/ +cli/out/ dist dist-ssr coverage logs test-results +web/dist +web/playwright-report +web/.tanstack + +# Electron desktop (desktop/) +desktop/out/ +desktop/.cache/ +desktop/release/ +desktop/*.dmg +desktop/*.zip +desktop/*.exe +desktop/*.AppImage +desktop/*.blockmap +desktop/builder-debug.yml +desktop/builder-effective-config.yaml + .pnpm-store tsconfig.tsbuildinfo -.pnpm-store - # Production (root output only; do not use bare `build` — toolkit/src/theme/build is source) diff --git a/.npmrc b/.npmrc index 953b26a8..580aec53 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ strict-peer-dependencies=false -engine-strict = true +engine-strict = false engine=node >=18.20.4 \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..8fdd954d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 0b0cebb6..370c31cc 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,7 +2,7 @@ "singleQuote": true, "quoteProps": "consistent", "bracketSpacing": true, - "jsxBracketSameLine": false, + "bracketSameLine": false, "arrowParens": "always", "trailingComma": "es5", "tabWidth": 2, diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..50c388e1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.preferences.includePackageJsonAutoImports": "auto", + "eslint.useFlatConfig": false, + "eslint.workingDirectories": [{ "mode": "auto" }], + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "eslint.rules.customizations": [ + { "rule": "react-hooks/exhaustive-deps", "severity": "warn" }, + { "rule": "@typescript-eslint/no-inferrable-types", "severity": "warn" } + ], + "editor.formatOnSave": false, + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "files.associations": { + "*.css": "css" + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..9aa2da3e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1107 @@ +# ReactPress System Architecture + +> ReactPress 4.0 — modern full-stack CMS / blog publishing platform on React +> Core principle: **Admin manages content · Themes manage presentation · Plugins manage logic · API manages data · Toolkit manages contracts** + +--- + +## Table of contents + +- [1. Overview](#1-overview) +- [2. Architecture](#2-architecture) +- [3. Design principles & decisions](#3-design-principles--decisions) +- [4. Monorepo package structure](#4-monorepo-package-structure) +- [5. Runtime & ports](#5-runtime--ports) +- [6. Data flow & dependency rules](#6-data-flow--dependency-rules) +- [7. Maintainability](#7-maintainability) +- [8. Extensibility](#8-extensibility) +- [9. Technical choices](#9-technical-choices) +- [10. Cost & multi-platform](#10-cost--multi-platform) +- [11. Server (backend API)](#11-server-backend-api) +- [12. Web (admin SPA)](#12-web-admin-spa) +- [13. Themes (visitor frontend)](#13-themes-visitor-frontend) +- [14. Toolkit (shared contract layer)](#14-toolkit-shared-contract-layer) +- [15. CLI (orchestration)](#15-cli-orchestration) +- [16. Auth & security](#16-auth--security) +- [17. Configuration](#17-configuration) +- [18. Deployment](#18-deployment) +- [19. Local development](#19-local-development) +- [20. Plugins](#20-plugins) +- [21. Desktop client](#21-desktop-client) +- [22. Evolution & roadmap](#22-evolution--roadmap) +- [23. Acceptance criteria](#23-acceptance-criteria) +- [24. References](#24-references) + +--- + +## 1. Overview + +ReactPress is a WordPress-like content platform with a clear separation of concerns: + +| Domain | Capabilities | +|--------|--------------| +| Content | Articles, categories, tags, comments, static pages | +| Media | Upload, media library, storage (local / OSS) | +| Appearance | Theme install / activate / preview, site customization | +| Extensions | Plugin install / enable / configure | +| System | Users & permissions, site settings, import/export, analytics | + +### Non-functional goals + +| Goal | Target | +|------|--------| +| Admin responsiveness | Shell stays mounted; route switches feel < 100ms; list views cache on revisit | +| Visitor SEO | Core pages SSR/ISR; Lighthouse SEO ≥ 90 | +| Multi-device | One responsive web app for desktop / tablet / mobile | +| Data consistency | All frontends access API only through toolkit | + +--- + +## 2. Architecture + +ReactPress uses a **Monorepo + multi-process** model: content management, visitor presentation, and API services are decoupled; toolkit keeps types and contracts aligned. + +```mermaid +flowchart TB + subgraph Presentation["Presentation (replaceable, extensible)"] + Web["web — Admin SPA"] + Desktop["desktop — Electron shell (loads web/dist)"] + Theme["themes/* — Visitor SSR"] + PluginUI["plugins/*/admin — Plugin Admin slots"] + end + + subgraph Contract["Contract (stable, shared)"] + Toolkit["toolkit
api · types · react · admin · theme · plugin"] + end + + subgraph Platform["Platform (cannot be bypassed)"] + Server["server — REST · auth · Hook · extension registry"] + CLI["cli — process orchestration · scaffolding"] + DB[(MySQL / SQLite)] + end + + Web --> Toolkit + Desktop --> Web + Theme --> Toolkit + PluginUI --> Toolkit + Toolkit -->|HTTP /api| Server + Server -->|Hook| PluginServer["plugins/*/dist — Server modules"] + Server --> DB + CLI --> Web + CLI --> Theme + CLI --> Server + CLI --> Desktop +``` + +### Responsibility matrix + +| Package | Single responsibility | Rendering | SEO | +|---------|----------------------|-----------|-----| +| **server** | Business rules, persistence, auth, extension lifecycle | — | — | +| **web** | Admin UI | Vite CSR SPA | No | +| **themes/** | Visitor site | Next.js SSR/SSG/ISR | Yes | +| **toolkit** | API client, types, React integration, extension schemas | — | — | +| **plugins/** | Incremental logic (Hook + optional Admin UI) | Server + Admin slots | Plugin-dependent | +| **desktop/** | Electron shell, local API orchestration, IPC | Loads `web/dist` | No | +| **cli** | Local dev / deploy orchestration | — | — | +| **docs** | Project docs (Docusaurus) | SSG | — | + +### Architecture red lines + +- **No visitor pages in Admin**; **no admin routes in themes** (new themes must follow this) +- All frontends (web / themes / plugins) **depend on toolkit only** for API access +- **server must not depend on any frontend package** + +--- + +## 3. Design principles & decisions + +All trade-offs follow this priority: + +```mermaid +flowchart LR + M[Maintainability] --> E[Extensibility] + E --> T[Technical fit] + T --> C[Low cost] +``` + +| Principle | Meaning | How it lands | +|-------------|---------|--------------| +| **Maintainability** | Change one place, test one place, clear boundaries | Layering + Feature Modules + single API client + OpenAPI codegen | +| **Extensibility** | Core changes rarely; third parties can attach | Registry + Hook + manifest contracts | +| **Technical fit** | Match tech to scenario; avoid stack bloat | Admin = SPA, public pages = SSR, business logic in Server | +| **Low cost** | Few processes, few repos, little duplication | Monorepo + shared toolkit; responsive web instead of native apps | + +### Key decision summary + +| Decision | Choice | Maintainability | Extensibility | Fit | Cost | +|----------|--------|-----------------|---------------|-----|------| +| API access | toolkit as sole entry | ★★★ | ★★ | ★★★ | Low | +| Admin | Vite SPA | ★★ | ★★ | ★★★ | Low | +| Visitor | Next.js SSR/ISR | ★★ | ★★★ | ★★★ | Medium | +| Module layout | Feature Module + Registry | ★★★ | ★★★ | ★★ | Low | +| Plugins | Hook + manifest | ★★ | ★★★ | ★★★ | Medium | +| Themes | Separate process + `theme.json` | ★★ | ★★★ | ★★★ | Medium | +| List state | URL searchParams | ★★★ | ★★ | ★★★ | Low | +| Multi-device | Responsive web + Electron shell | ★★★ | ★★ | ★★★ | Medium | +| Types | OpenAPI codegen | ★★★ | ★★ | ★★★ | Low | + +--- + +## 4. Monorepo package structure + +Managed with **pnpm workspace** (`pnpm-workspace.yaml`): + +```yaml +packages: + - 'cli' # Global CLI (@fecommunity/reactpress) + - 'server' # NestJS API + - 'web' # Admin SPA + - 'desktop' # Electron shell + - 'docs' # Docusaurus docs site + - 'toolkit' # Shared API contract layer + - 'themes' # Theme registry + - 'themes/*' # Official theme templates + - 'plugins' # Plugin registry + - 'plugins/*' # Official plugins +``` + +### Repository tree (core) + +``` +easy-blog-publish/ +├── cli/ # CLI with bundled server +├── server/ # NestJS API source +├── web/ # Vite Admin SPA +├── desktop/ # Electron (local SQLite + Admin SPA) +├── toolkit/ # OpenAPI SDK + React integration +├── themes/ +│ ├── hello-world/ # Starter theme (local) +│ └── theme-starter/ # npm official theme catalog anchor +├── plugins/ +│ ├── hello-world/ # Auto summary plugin +│ ├── seo/ # SEO enhancement +│ └── image-optimizer/ # Image batch optimization +├── docs/ # Docusaurus +├── public/ # Marketing / brand assets +├── scripts/ # Build, deploy, smoke tests +├── docker-compose.*.yml +├── nginx*.conf +├── .reactpress/ # Runtime: active-theme.json, runtime/, plugins/ +└── package.json +``` + +### npm package mapping + +| Directory | npm package | Notes | +|-----------|-------------|-------| +| `cli/` | `@fecommunity/reactpress` | 4.0 main package; global `reactpress` command | +| `web/` | `@fecommunity/reactpress-web` | Admin SPA | +| `server/` | `@fecommunity/reactpress-server` | Monorepo source; standalone npm deprecated — use CLI bundled API | +| `toolkit/` | `@fecommunity/reactpress-toolkit` | Shared SDK | +| `themes/hello-world` | `@fecommunity/reactpress-template-hello-world` | Starter theme | + +--- + +## 5. Runtime & ports + +CLI orchestrates independent processes in local development: + +| Process | Default port | Stack | Notes | +|---------|--------------|-------|-------| +| **web** | 3000 | Vite + React | Admin entry | +| **active theme** | 3001 | Next.js | Current visitor theme | +| **server** | 3002 | NestJS | REST API (prefix `/api`) | +| **preview theme** | 3003 | Next.js | Admin iframe preview of non-active theme | +| **MySQL** | 3306 | MySQL 5.7 | Full-stack dev / default production persistence | +| **desktop local API** | 13102 | NestJS + SQLite | Embedded API in `pnpm dev:desktop` | +| **nginx** (optional) | 80 / 8080 | nginx | Unified reverse proxy | + +Three core processes (Admin, theme, API) deploy and scale independently — traffic patterns differ, so separation beats a monolithic Next app. + +```mermaid +flowchart LR + Browser["Browser"] + Nginx["nginx :80"] + Web["web :3000"] + Theme["theme :3001"] + Preview["preview :3003"] + API["server :3002"] + DB[(MySQL :3306)] + + Browser -->|"/admin"| Nginx + Browser -->|"/"| Nginx + Nginx -->|"/admin/"| Web + Nginx -->|"/"| Theme + Nginx -->|"/api"| API + Web -->|"/api proxy"| API + Theme -->|toolkit HTTP| API + Preview -->|toolkit HTTP| API + API --> DB +``` + +--- + +## 6. Data flow & dependency rules + +### Typical request paths + +**Admin write:** + +``` +web page → toolkit createClient() → POST /api/article → server ArticleService → DB +``` + +**Visitor read:** + +``` +theme getServerSideProps → toolkit fetchSingleArticle() → GET /api/article/:id → server → DB +``` + +**Theme management:** + +``` +web Appearance → GET /api/extension/themes → server ThemeService + → Setting.globalSetting (activeTheme / mods) + → .reactpress/active-theme.json + → CLI restarts Next.js theme process +``` + +### Sequence: publish article + +```mermaid +sequenceDiagram + participant U as Admin + participant W as web SPA + participant T as toolkit + participant S as server + participant H as Hook / plugin + + U->>W: Edit and publish + W->>T: useMutation → api.article.update + T->>S: PUT /article/:id + S->>H: applyFilters('article.beforePublish') + H-->>S: mutated payload + S->>S: persist + S->>H: doAction('article.afterPublish') + S-->>T: 200 + data + T-->>W: invalidateQueries + W-->>U: success notification +``` + +### Sequence: visitor article page + +```mermaid +sequenceDiagram + participant V as Visitor + participant Th as theme Next.js + participant T as toolkit/theme + participant S as server + + V->>Th: GET /article/hello-world + Th->>T: fetchArticle (SSR) + T->>S: GET /article/by-slug/hello-world + S-->>T: article + meta + T-->>Th: typed data + Th-->>V: full HTML + JSON-LD +``` + +### Dependency rules (hard constraints) + +``` +web / themes / plugins → toolkit only +toolkit → HTTP + stdlib only (no Ant Design / Next deps) +server → no frontend packages +plugins/server → server Hook + DI interfaces only +cli → orchestrates server/web/themes; not imported by business code +``` + +--- + +## 7. Maintainability + +### Single data entry: toolkit + +**Problem:** Multiple hand-rolled HTTP layers → type drift, inconsistent errors, N places to change on API updates. + +**Solution:** One client factory for the whole platform: + +```typescript +export function createClient(options: ClientOptions) { + const http = createHttpClient(options); + return { + article: new Article(http), + file: new File(http), + extension: new Extension(http), + // … mirrors server controllers + }; +} +``` + +**Benefits:** + +- Server API change → run codegen → TypeScript errors pinpoint callers +- Error codes, auth, retry logic written once +- New modules (web / theme / plugin) with zero HTTP boilerplate + +### Feature Modules (vertical slices) + +Each domain is self-contained: + +``` +web/src/modules/article/ +├── index.ts # public export: register(admin) +├── routes.tsx # TanStack Router routes +├── pages/ # thin pages composing hooks + components +├── components/ # module-private UI +├── hooks/ # data + URL state +├── schemas/ # Zod form + API boundary validation +└── permissions.ts # module permission declarations +``` + +**Forbidden between modules:** direct import of another module's internal components. +**Allowed:** Registry for menus/settings/permissions; toolkit hooks for shared server data. + +### URL as state + +List filters, pagination, and sort live in URL searchParams: + +``` +/article?page=2&status=published&sort=-createdAt&keyword=react +``` + +| Benefit | Why | +|---------|-----| +| Shareable | Admins copy links to restore views | +| Testable | E2E does not depend on component state | +| Cacheable | React Query uses URL params as queryKey | +| Device-agnostic | Desktop / mobile share the same data logic | + +### Codegen boundaries + +| Generated (no hand edits) | Hand-written | +|---------------------------|--------------| +| `toolkit/api/*` | `toolkit/react/hooks/*` | +| `toolkit/types/*` | `toolkit/admin/components/*` | +| OpenAPI spec | Feature Module business UI | + +--- + +## 8. Extensibility + +Modeled after WordPress `add_action` / `add_filter`, constrained by TypeScript manifests. + +### Extension model + +| Type | Extends | Carrier | +|------|---------|---------| +| **Theme** | Visitor UI | Independent Next.js package + `theme.json` | +| **Plugin** | Business logic + optional Admin UI | Server module + optional `admin/index.ts` | + +**Hooks** (in-process, can mutate) vs **Webhooks** (outbound HTTP) are separate. + +### Manifest contracts + +**theme.json** (flat structure — `templates` at root): + +```json +{ + "id": "hello-world", + "name": "Hello World", + "version": "1.0.0", + "requires": ">=3.5.0", + "templates": { + "home": "pages/index.tsx", + "single": "pages/article/[id].tsx", + "archive": "pages/category/[category].tsx" + }, + "supports": { "menus": ["primary", "footer"], "darkMode": true } +} +``` + +**plugin.json:** + +```json +{ + "id": "seo", + "name": "SEO Enhancement", + "version": "1.0.0", + "server": { "module": "./dist/index.js" }, + "admin": { + "slots": { "subscribe": ["article.editor.meta.afterSummary"] } + }, + "settings": { "schema": { "type": "object" } } +} +``` + +Schemas live in `toolkit/extension`; CLI validates on install — invalid packages fail at startup, not at runtime. + +### Server Hook + +```typescript +interface HookService { + applyFilters(name: string, value: T, ctx?: unknown): Promise; + doAction(name: string, payload?: unknown): Promise; +} +``` + +| Hook | When | +|------|------| +| `article.beforePublish` | Mutate fields before publish | +| `article.afterPublish` | Notify, index after publish | +| `comment.beforeCreate` | Spam filter | +| `setting.beforeSave` | Validate extension config | + +### Admin Registry + +```typescript +interface AdminModule { + id: string; + register(ctx: AdminContext): void; +} + +interface AdminContext { + menu: MenuRegistry; + settings: SettingsRegistry; + permissions: PermissionRegistry; + routes: RouteRegistry; +} +``` + +Core modules and plugins use the same API — new official features = new module + `register()`, no Shell edits. + +### Theme switching strategy + +| Phase | Strategy | Rationale | +|-------|----------|-----------| +| MVP (current) | Update `activeTheme` + restart theme process | Simple, stable SSR, no runtime federation | +| Later | Hot swap / multi-theme preview | Only when product requires it | + +### Permission model + +```typescript +type Permission = + | 'article:read' | 'article:write' | 'article:publish' + | 'media:manage' | 'page:manage' + | 'user:manage' | 'setting:manage' + | 'extension:manage'; +``` + +- **Server:** Guard checks JWT + Permission +- **Web:** `usePermission()` + route-level `` +- **Plugins:** manifest declares `permissions`; merged into roles on activate + +String capabilities beat hard-coded `role === 'admin'`. + +### Plugin three-layer model + +| Layer | Path | Role | +|-------|------|------| +| Registry | `plugins/` + `plugins/package.json` | What can be installed | +| Materialized | `.reactpress/plugins/{id}/` | Installed copy with `dist/` | +| Active | `Setting.globalSetting.plugins` | Enabled list + per-plugin config | + +| Action | Effect | +|--------|--------| +| Install | Materialize to `.reactpress/plugins/` | +| Enable | Hot-load `server.module` → `register(hooks, ctx)` | +| Disable | Remove hooks; optional `deactivate()` | +| Config | JSON Schema validation then reload | + +Built-in plugins: `hello-world`, `seo`, `image-optimizer`. See [plugins/README.md](./plugins/README.md). + +--- + +## 9. Technical choices + +### Rendering by scenario + +| Scenario | Tech | Why | +|----------|------|-----| +| Admin | **Vite + React SPA** | No SEO; small CSR bundle, fast HMR, static deploy | +| Visitor theme | **Next.js SSR/SSG/ISR** | Crawlers and social previews need full HTML | +| API | **NestJS REST** | Mature modules; OpenAPI codegen chain | + +**Not chosen:** + +| Approach | Why not | +|----------|---------| +| Admin on Next.js | No SSR/RSC benefit; extra routing + server complexity | +| Admin + theme in one app | Coupled responsibilities, bundle bloat, cannot deploy separately | +| GraphQL instead of REST | Existing Swagger pipeline; GraphQL adds schema maintenance | +| Micro-frontends (qiankun, etc.) | Team/scale mismatch; Registry + dynamic import is enough | + +### Admin frontend stack + +| Layer | Choice | Role | +|-------|--------|------| +| Build | Vite+ (`vp dev/build`) | Fast dev, native ESM | +| Routing | TanStack Router | Type-safe, file routes, searchParams first-class | +| Server state | TanStack Query | Cache, retry, optimistic mutations | +| Client state | Zustand (auth/settings only) | Light persistence; avoid global store abuse | +| UI | Ant Design 6 | Complete admin components, responsive grid | +| Validation | Zod | Unified form + API boundary | + +State split: **URL for list state · React Query for server data · Zustand for session/UI prefs**. + +### Admin performance + +| Technique | Mechanism | +|-----------|-----------| +| Persistent shell | Layout route stays mounted; only `` swaps | +| Route-level code split | Each module is its own chunk | +| Lazy heavy deps | Rich text, charts via `React.lazy()` | +| List cache | `staleTime: 30s` for instant back-navigation | +| Prefetch | Sidebar hover preloads next route chunk | + +### Visitor SEO + +| Page type | Mode | +|-----------|------| +| Home, article, archives | ISR `revalidate: 60` | +| About, privacy | SSG | +| Search | SSR | +| Comment submit | CSR island | + +`toolkit/theme` provides `fetchArticle`, `buildPageMeta`, `buildJsonLd` — theme authors call helpers, not SEO boilerplate. + +--- + +## 10. Cost & multi-platform + +### Cost model + +| Cost type | Control strategy | +|-----------|-------------------| +| Development | Monorepo + toolkit reuse; Feature Module templates for CRUD | +| Operations | Admin static hosting; theme = standard Next deploy; API single process | +| Multi-device | Responsive web — no native iOS/Android Admin | +| Extensions | manifest + Registry — no core PR for third-party features | +| Learning curve | Stack converges on React + Nest; theme authors need Next + toolkit only | + +### Responsive web (one codebase, three viewports) + +Breakpoints align with Ant Design (single standard across repo): + +| Breakpoint | Width | Admin | Theme | +|------------|-------|-------|-------| +| `< md` | < 768px | Drawer nav; table → cards | Single column | +| `md–lg` | 768–992px | Collapsed sidebar | Two columns | +| `≥ lg` | ≥ 992px | Fixed sidebar + wide table | Sidebar + main | + +Shared components in `toolkit/admin`: `ResponsiveTable`, `ResponsiveFilterToolbar`, `ResponsiveFormModal`. + +**Principle:** API has no device fields; differences are UI-only. + +**Progressive path:** + +1. Default: responsive web (zero extra engineering) +2. Optional: **Electron desktop** (same `web/dist`) +3. Optional: PWA caches shell static assets only +4. Future: Capacitor wraps `web/dist` without rewriting UI + +### What we deliberately skip (cost control) + +| Skip | Reason | +|------|--------| +| Native mobile Admin app | Responsive web covers most ops | +| Electron-embedded duplicate Admin UI | Shell only loads `web/dist` | +| Plugin marketplace sandbox (v1) | Local dir + admin trust model is enough | +| Theme runtime federation | Separate process + restart is simpler | +| Multi-DB / multi-tenant (v1) | Single-site CMS first | +| Custom ORM / UI library | TypeORM + Ant Design | + +--- + +## 11. Server (backend API) + +### Stack + +| Layer | Technology | +|-------|------------| +| Framework | NestJS 6 | +| ORM | TypeORM 0.2 | +| Database | MySQL (default); **SQLite** for desktop local mode (`DB_TYPE=sqlite`) | +| Auth | Passport + JWT, API Key | +| Docs | Swagger at `/api` | +| Other | helmet, compression, rate-limit, log4js, nodemailer, ali-oss | + +### Module layout + +``` +server/src/modules/ +├── article/ category/ tag/ comment/ page/ file/ # content +├── user/ auth/ # identity +├── setting/ smtp/ # config +├── view/ search/ knowledge/ # data +├── extension/ # theme + plugin lifecycle +├── hook/ # Action/Filter registry +├── api-key/ webhook/ health/ +└── … +``` + +Each module: thin controller → service (business + Hook calls) → entity. **extension** manages install/activate state, not domain business logic. + +### Domain entities (15) + +User, Article, ArticleRevision, Category, Tag, Comment, Page, Knowledge, File, Setting, SMTP, Search, View, ApiKey, Webhook. + +### API conventions + +- Global prefix: `/api` (`SERVER_API_PREFIX` configurable) +- Unified response: `{ statusCode, success, data }` (except `/health`) + +### Startup paths + +1. **First install:** Express wizard in `main.ts` (`/test-db`, `/install` writes `.env`) → NestJS bootstrap +2. **Normal / production:** Direct `starter.ts` bootstrap + +--- + +## 12. Web (admin SPA) + +### Stack + +| Category | Technology | +|----------|------------| +| Build | Vite+ | +| UI | React 18 + Ant Design 6 | +| Routing | TanStack Router (file routes) | +| Data | TanStack Query + Zustand (auth persist) | +| Editor | Monaco + Showdown (Markdown) | +| i18n | i18next | +| Testing | MSW + Playwright E2E | + +### Directory structure + +``` +web/src/ +├── routes/ # TanStack file routes +│ ├── login/ +│ └── _auth/ # authenticated routes +│ ├── dashboard/ article/ media/ page/ +│ ├── appearance/ settings/ plugins/ data/ +├── modules/ # feature domains (mirror routes) +├── shell/ # bootstrap, permissions, Admin Registry +├── shared/ components/ mocks/ stores/ hooks/ i18n/ +``` + +### Admin route map + +| Module | Route | APIs | +|--------|-------|------| +| Dashboard | `/` | view, article stats | +| Articles | `/article`, `/article/editor/:id?` | article, category, tag | +| Comments | `/article/comment` | comment | +| Media | `/media` | file | +| Pages | `/page`, `/page/editor/:id?` | page | +| Appearance | `/appearance/themes`, `/appearance/customize` | extension, setting | +| Plugins | `/plugins`, `/plugins/:id/settings` | extension | +| Users | `/users`, `/profile` | user | +| Settings | `/settings/:tab` | setting, smtp, api-key, webhook | +| Data | `/data/analytics`, `/data/export`, `/data/import` | view, search, export | + +Settings use routes (not tab query params). Plugins insert tabs via `settings.registerTab({ id, title, path, permission })`. + +### Module registration example + +```typescript +export const articleModule: AdminModule = { + id: 'article', + register({ menu, permissions }) { + menu.register({ + id: 'content', + title: 'Content', + children: [ + { id: 'article.list', title: 'Articles', path: '/article' }, + { id: 'article.new', title: 'New article', path: '/article/editor' }, + { id: 'article.comment', title: 'Comments', path: '/article/comment' }, + ], + }); + permissions.register(['article:read', 'article:write', 'article:publish']); + }, +}; +``` + +Shell `bootstrap()` registers core modules, then loads active plugins. Menu order uses `sort`, not import order. + +### API connection + +- Dev: `VITE_API_BASE_URL=/api`, Vite proxy to `:3002` +- Client: `getToolkitClient()` → `@fecommunity/reactpress-toolkit/react` +- Mock: `VITE_AUTH_MODE=mock` + MSW +- Live: `VITE_AUTH_MODE=server` + +--- + +## 13. Themes (visitor frontend) + +Since 3.0, visitor sites are independent Next.js packages under `themes/` (replacing legacy `client/`). + +### Package structure (hello-world) + +``` +themes/hello-world/ +├── theme.json # manifest (id, templates, customizer) +├── pages/_app.tsx # createThemeApp(manifest) +├── pages/index.tsx, article/[id].tsx, … +├── components/ styles/ next.config.js +``` + +### WordPress mapping + +| WordPress | ReactPress | +|-----------|------------| +| `style.css` header | `theme.json` | +| `functions.php` | `pages/_app.tsx` → `createThemeApp()` | +| Template hierarchy | `theme.json` → `templates` + `pages/*` | +| Customizer | `appearance.sections` + Formily + `useThemeMod` | + +### Official themes + +| Theme | Source | Role | +|-------|--------|------| +| **hello-world** | local | Minimal Pages Router starter | +| **reactpress-theme-starter** | npm (`theme-starter` anchor) | Full theme: search, knowledge base, comments, dark mode | + +### Theme lifecycle + +```mermaid +sequenceDiagram + participant Admin as web Admin + participant API as server ThemeService + participant FS as filesystem + participant CLI as cli theme-dev + participant Next as Next.js theme + + Admin->>API: POST /extension/themes/install + API->>FS: copy themes/{id} → .reactpress/runtime/{id} + Admin->>API: POST /extension/themes/activate + API->>FS: write active-theme.json + CLI->>Next: start theme :3001 + Admin->>API: beginPreviewSession + API->>FS: write preview-theme.json + CLI->>Next: start preview :3003 +``` + +See [themes/README.md](./themes/README.md). + +--- + +## 14. Toolkit (shared contract layer) + +Single API contract layer for the platform; generated from server OpenAPI. + +### Structure + +``` +toolkit/src/ +├── api/ types/ # generated from Swagger +├── react/ # createClient(), resolveApiBaseUrl(), runtime detection +├── theme/ ui/ # SSR helpers, headless components +├── admin/ plugin/ # Registry, plugin SDK +├── extension/ # theme.json / plugin.json JSON Schema +├── config/ utils/ +``` + +### Export paths + +| Path | Use | +|------|-----| +| `@fecommunity/reactpress-toolkit` | Main entry | +| `@fecommunity/reactpress-toolkit/react` | React client factory | +| `@fecommunity/reactpress-toolkit/theme` | Theme SSR | +| `@fecommunity/reactpress-toolkit/plugin/server` | Plugin Hook SDK | +| `@fecommunity/reactpress-toolkit/plugin/admin` | Plugin Admin registration | + +### Regenerate + +```bash +pnpm run generate:swagger # server → swagger.json +pnpm run build:toolkit # regenerate api/types +``` + +See [toolkit/README.md](./toolkit/README.md). + +--- + +## 15. CLI (orchestration) + +Published as `@fecommunity/reactpress` — zero-config project lifecycle. + +### Core commands + +| Command | Description | +|---------|-------------| +| `reactpress init` | Init project (`.env` + `.reactpress/config.json`; `--local` = SQLite) | +| `reactpress dev` | Full-stack dev (API + web + theme + Docker MySQL) | +| `reactpress dev --api-only` | API only (headless) | +| `reactpress dev --web-only` | Admin + API | +| `reactpress build` / `start` | Production build / start | +| `reactpress doctor` / `status` | Diagnostics / status | +| `reactpress plugin list/install` | Plugin registry | +| `reactpress theme list/add` | Theme catalog | +| `reactpress desktop dev` | Desktop dev (SQLite + Admin + Electron) | + +### CLI layout (4.0 TypeScript) + +``` +cli/ +├── bin/ # thin entry → out/bin/ +├── src/ # TypeScript source +├── out/ # compiled (gitignored) +│ ├── bin/ core/ ui/ +│ └── lib/ # dev/build/docker/theme/plugin orchestration +├── server/ # bundled NestJS runtime for npm +└── templates/ # init scaffolds +``` + +Server resolution: monorepo `server/` if present, else `cli/server/` bundled copy. + +See [cli/README.md](./cli/README.md). + +--- + +## 16. Auth & security + +### JWT (admin / user sessions) + +- Login: `POST /api/auth/login` → token (4h default) +- Protected routes: `@UseGuards(JwtAuthGuard)` + Bearer +- Roles: `@Roles('admin')` + `RolesGuard` (admin / visitor) + +### API Key (headless / integrations) + +- Header: `X-API-Key` or `Authorization: Bearer ` +- Scopes: `read` / `write` + +### Other + +- GitHub OAuth: `POST /api/auth/github` +- Passwords: bcrypt via `User.comparePassword()` + +--- + +## 17. Configuration + +**`.reactpress/config.json`** is the source of truth; `.env` is synced on `init`. **`--local`** uses SQLite with `config.local.json` / `env.local.default`. + +| File | Purpose | +|------|---------| +| `.reactpress/config.json` | Project config | +| `.reactpress/active-theme.json` | Active theme id | +| `.reactpress/preview-theme.json` | Preview theme id | +| `.reactpress/runtime/{id}/` | Materialized theme copy | +| `.reactpress/plugins/{id}/` | Materialized plugin copy | +| `.env` | DB, ports, secrets | + +| Variable | Default | +|----------|---------| +| `SERVER_PORT` | `3002` | +| `REACTPRESS_API_URL` | `http://localhost:3002/api` | + +--- + +## 18. Deployment + +### Development (recommended) + +- App processes on host (`pnpm dev`) +- Docker for **MySQL + nginx** only +- nginx forwards to host via `host.docker.internal` + +### Production options + +| Mode | Notes | +|------|-------| +| **PM2** | `pnpm build` → `pnpm start` | +| **Docker** | MySQL container + nginx; API can run on host | +| **Vercel** | Theme / Admin static deploy | + +### nginx routes (dev) + +| Path | Target | +|------|--------| +| `/` | Theme `:3001` | +| `/admin/` | Admin `:3000` | +| `/api` | API `:3002` | + +--- + +## 19. Local development + +```mermaid +flowchart LR + subgraph init [First time] + A[pnpm install] --> B[reactpress init] + B --> C[.env + .reactpress/config.json] + end + subgraph dev [Daily] + D[pnpm dev] --> E[toolkit build] + E --> F[server :3002] + F --> G[web :3000] + G --> H[theme :3001] + end + init --> dev +``` + +```bash +pnpm install +pnpm dev # API + Admin + theme + MySQL +pnpm dev:web:local # Admin + SQLite API (no Docker) +pnpm dev:desktop # Electron + SQLite +pnpm build:plugins # compile official plugins +pnpm build # toolkit → server → web → themes +``` + +After API changes: + +```bash +pnpm run generate:swagger && pnpm run build:toolkit +``` + +--- + +## 20. Plugins + +**Themes handle presentation; plugins handle logic.** Server-side Hooks extend business rules; optional Admin UI via slots. + +Covered in [§8 Extensibility](#8-extensibility) and [plugins/README.md](./plugins/README.md). + +--- + +## 21. Desktop client + +Electron shell loads the same Admin SPA as the browser — **no duplicate business UI**. + +```mermaid +flowchart TB + subgraph DesktopApp["desktop/ — Electron"] + Main[Main: window · tray · IPC · local API spawn] + Preload[Preload: contextBridge] + Renderer[Renderer: web/dist] + end + Renderer --> Toolkit[toolkit] + Toolkit --> API[server :3002 or :13102 local] +``` + +| Layer | Responsibility | +|-------|----------------| +| **web** | All Admin UI (same as browser) | +| **toolkit** | API client, auth, `getRuntime()` / `getDesktopApi()` | +| **desktop** | Main/Preload only: window, IPC, SQLite API spawn, config | +| **server** | REST API; local mode spawned by Main, remote mode connects externally | + +### Modes + +| Mode | Description | +|------|-------------| +| **Local (default)** | Main spawns embedded API (SQLite, default `:13102`); default `admin` / `admin` | +| **Remote** | Connect to existing ReactPress API; sync local content to remote site | + +### Load modes + +| Mode | Use case | +|------|----------| +| **A. Bundled (production)** | `file://` or custom protocol → `web/dist/index.html` | +| **B. Remote URL** | Load `https://admin.example.com` (enterprise intranet) | +| **C. Dev** | `http://localhost:3000` (Vite dev server) | + +### Security (Electron) + +| Rule | Required | +|------|----------| +| `contextIsolation: true` | Yes | +| `nodeIntegration: false` in renderer | Yes | +| Preload whitelist IPC channels | Yes | +| `webSecurity: true` | Yes | +| Remote URL allowlist | When using mode B | + +### Desktop roadmap + +| Phase | Content | Status | +|-------|---------|--------| +| D0 | Scaffold; dev loads Vite; prod loads `web/dist` | ✅ 4.0 | +| D1 | Local SQLite, remote API switch, login, macOS/Windows packages | ✅ 4.0 MVP | +| D1+ | Local → remote content sync | ✅ 4.0 | +| D2 | Tray, shortcuts, native notifications | Planned | +| D3 | `electron-updater` | Planned | + +**Why Electron over Tauri:** mature updater/tray/builder ecosystem; Chromium matches Admin stack; shell is swappable — **web + toolkit stay unchanged**. + +See [desktop/README.md](./desktop/README.md). + +--- + +## 22. Evolution & roadmap + +### 4.0 vs 3.x + +| 3.x | 4.0 | +|-----|-----| +| No official plugin runtime | Hook + manifest + Admin slots; built-in hello-world, seo, image-optimizer | +| Web Admin only | Optional **Electron desktop** (SQLite local mode) | +| hello-world–centric themes | npm catalog (`theme-starter` anchor) | +| CLI pure JS `lib/` | TypeScript `src/` → `out/` | + +### 3.0 vs 2.x + +| 2.x | 3.0 | +|-----|-----| +| Monolithic `client/` Next (incl. /admin) | `web/` Admin SPA + `themes/` visitor | +| Multiple HTTP layers | Unified toolkit client | +| Manual setup | CLI `init` + `dev` | + +### Implementation phases (historical) + +| Step | Deliverable | Status | +|------|-------------|--------| +| 1–2 | toolkit client + web Shell + auth | ✅ 3.x | +| 3–4 | article module template + core CRUD | ✅ 3.x | +| 5–6 | server extension/hook + appearance/plugins UI | ✅ 3.x–4.0 | +| 7 | theme.json + CLI theme commands | ✅ 3.x | +| 8–9 | responsive components + import/export | ✅ 3.x | +| 10 | Electron desktop shell | ✅ 4.0 MVP | +| 11 | Plugin Hook + Admin slots | ✅ 4.0 | + +### Known legacy / future work + +- Standalone `server` npm package deprecated — use CLI bundled API +- `dev:client` script name retained; starts active theme +- Plugin npm catalog, marketplace, Desktop auto-update → future 4.x iterations + +### Feature coverage + +| Domain | Status | +|--------|--------| +| Content, media, appearance, system settings | ✅ | +| Plugins (Hook + Registry + Admin slots) | ✅ 4.0 | +| Desktop (Electron + SQLite) | ✅ 4.0 MVP | +| Knowledge base | ✅ server module | + +--- + +## 23. Acceptance criteria + +| Dimension | Standard | +|-----------|----------| +| Maintainability | New CRUD module ≤ one directory + one `register()`; API changes = server + codegen only | +| Extensibility | Official SEO plugin mounts menu + Hook without core edits | +| Performance | Admin route switch < 100ms; theme Lighthouse SEO ≥ 90 | +| Multi-device | No horizontal scroll at 390px; core flows pass E2E on three viewports | +| Consistency | web / themes / plugins have no custom HTTP clients | +| Desktop | Packaged app shows same Admin as browser; no forked Admin source | + +--- + +## 24. References + +- [README.md](./README.md) — quick start (English) +- [README-zh_CN.md](./README-zh_CN.md) — quick start (Chinese) +- [docs/migration-3-to-4.md](./docs/migration-3-to-4.md) — 3.x → 4.0 migration +- [docs/](./docs/) — Docusaurus tutorials +- [themes/README.md](./themes/README.md) — theme development +- [plugins/README.md](./plugins/README.md) — plugin development +- [desktop/README.md](./desktop/README.md) — desktop client +- [toolkit/README.md](./toolkit/README.md) — SDK usage +- [cli/README.md](./cli/README.md) — CLI reference diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c1080f..2a990976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# [4.0.0-beta.0](https://github.com/fecommunity/reactpress/compare/v3.7.0...v4.0.0-beta.0) (2026-06-27) + +> **Pre-release** — install with `npm i -g @fecommunity/reactpress@beta` for testing. Final 4.0.0 follows after validation. + +### Plugin System + +* **Hook + manifest**: `plugin.json` lifecycle (install / activate / config / hot reload); `HookService` with filters and actions +* **Admin slots**: `AdminSlot` + `PluginAdminProvider`; SEO plugin integrates article editor +* **Built-in plugins**: `hello-world` (auto summary), `seo` (slug, keywords, meta description), `image-optimizer` (legacy media WebP batch optimization) +* **CLI**: `reactpress plugin list` / `install`; `pnpm build:plugins` in full build pipeline +* **Security**: manifest JSON Schema validation, module path constraints, Ajv config validation + +### Desktop Client + +* **Electron shell**: loads same Admin SPA (`web/dist`); `pnpm dev:desktop` / `pnpm build:desktop` +* **Local mode**: embedded SQLite API (default port `13102`), no Docker/MySQL required +* **Remote mode**: connect to existing ReactPress API +* **Sync**: push articles, pages, and settings from local to remote site + +### Themes & Docs + +* **Theme catalog**: npm anchor `theme-starter`; enhanced theme management; removed deprecated bundled themes +* **hello-world**: updated README aligned with Pages Router + `createThemeApp` +* **Docs**: ARCHITECTURE / design sync; migration 3→4; ReactPress 4.0 guide + # [3.7.0](https://github.com/fecommunity/reactpress/compare/v3.6.0...v3.7.0) (2026-06-23) ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 039cf75a..6e1cc784 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Security Advisories when applicable. ### Prerequisites - Node.js >= 18.0.0 -- pnpm >= 8.0.0 +- pnpm 9.x(与根目录 `packageManager` 一致,推荐 `corepack enable` 后使用) - MySQL 5.7+ (or Docker via `pnpm run init` / `pnpm docker:dev`) ### First run @@ -52,9 +52,10 @@ Run `pnpm test` and `pnpm test:smoke` before submitting changes that touch the C reactpress/ ├── cli/ # @fecommunity/reactpress — init, dev, build, doctor ├── server/ # NestJS API (primary backend) -├── client/ # Next.js admin & public frontend +├── web/ # Admin SPA (Vite) +├── client/ # Next.js legacy admin & public frontend +├── themes/ # Visitor theme templates (Next.js) ├── toolkit/ # OpenAPI-generated API SDK + theme utilities -├── themes/ # Classic theme manifests & reference themes ├── templates/ # Starter project templates ├── docs/ # Docusaurus documentation site ├── scripts/ # Dev, deploy, and lifecycle scripts @@ -71,7 +72,7 @@ reactpress/ | Docker MySQL + proxy | `pnpm docker:dev` | | Regenerate API types | `pnpm run build:toolkit` | | Swagger spec | `pnpm run generate:swagger` | -| API lifecycle | `pnpm run start:api` / `stop` / `restart` / `status` | +| API lifecycle | `pnpm run start` / `stop` / `restart` / `status` | `pnpm dev` builds toolkit first, waits for API health, then starts the client. @@ -80,8 +81,9 @@ After API changes: `pnpm run generate:swagger` → `pnpm run build:toolkit`. ## Building ```bash -pnpm run build # toolkit + server + client +pnpm run build # toolkit + server + web + active theme pnpm run build:server # Nest only +pnpm run build:web # Admin SPA only pnpm run build:client # Next.js only pnpm run build:docs # Docusaurus site ``` @@ -121,12 +123,22 @@ sh scripts/deploy.sh Maintainers only: ```bash -pnpm login +pnpm login --registry https://registry.npmjs.org + +# Interactive (choose beta/stable + version) pnpm run publish:packages + +# Beta prerelease (uses package.json versions, npm tag: beta) +NPM_OTP=123456 pnpm run publish:packages -- --tag beta --yes + +# Explicit version +NPM_OTP=123456 pnpm run publish:packages -- --tag beta --version 4.0.0-beta.0 --yes + +# Build artifacts only (no npm publish) +pnpm run publish:build ``` -Published packages: root meta, **server**, **client**, **toolkit**, **templates**. -`@fecommunity/reactpress` is the CLI entry (`init`, `dev`, Docker database helpers). +Published packages: **toolkit**, **web**, **server** (deprecated), **cli** (`@fecommunity/reactpress`). ## Architecture & Documentation diff --git a/README-zh_CN.md b/README-zh_CN.md index c5a6b876..5ba2023e 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -4,8 +4,8 @@

- 快速、流畅、轻松 — 约一分钟即可上线的全栈发布平台。
- 一条 CLI · 全栈 CMS · Headless 主题 · 面向生产环境部署 + ReactPress 4.0 — 快速、流畅、轻松,约一分钟即可上线的全栈发布平台。
+ 一条 CLI · 插件系统 · 桌面客户端 · Headless 主题 · MySQL & SQLite · 面向生产环境部署

@@ -44,6 +44,7 @@ ## 目录 - [ReactPress 是什么?](#reactpress-是什么) +- [4.0 新特性](#40-新特性) - [为什么选 ReactPress?](#为什么选-reactpress) - [怎么用?](#怎么用) - [贡献](#贡献) @@ -52,19 +53,23 @@ ## ReactPress 是什么? -**ReactPress 是以 CMS 后端、管理后台和可选前台为核心的发布平台** — 安装 CLI 即可运行 API、在后台管理内容,并按需接入访客站主题。 +**ReactPress 4.0 是以 CMS 后端、管理后台、可选前台、插件与桌面客户端为核心的发布平台** — 安装 CLI 即可运行 API、在后台管理内容、用插件扩展能力、在桌面本地写作,并按需接入访客站主题。 | 组件 | 作用 | | :--- | :--- | -| **CMS 后端(API)** | 存储并提供文章、页面、媒体、分类与站点设置 | +| **CMS 后端(API)** | 存储并提供文章、页面、媒体、分类与站点设置(MySQL 或 SQLite) | | **管理后台** | 写作与内容管理的 Web 界面(全栈部署中包含) | +| **插件系统** | 基于 Hook 的扩展 + Admin 插槽 — SEO、自动摘要、图片批量优化 | +| **桌面客户端** | Electron + 内嵌 SQLite — 本地写作,可同步到远程站点 | | **[官方主题](https://github.com/fecommunity/reactpress-theme-starter)** | 推荐访客站 — 搜索、知识库、评论、深色模式 | -| **CLI(`reactpress`)** | 初始化、本地运行、构建与部署 | +| **CLI(`reactpress`)** | 初始化、本地运行、构建部署、主题与插件管理 | ### 能做什么 - **发布内容** — 文章、页面、定时发布、分类与标签 - **管理媒体** — 上传图片与文件,在内容中复用 +- **插件扩展** — 后台或 CLI 安装内置/自定义插件 +- **桌面写作** — SQLite 本地模式,无需 Docker;内容可同步到线上 - **定制站点** — 标题、Logo、导航、外观,均在后台完成 - **选择前台** — 使用官方主题,或通过 API 接入自定义前端 - **即时预览主题** — 在主题仓库以示例数据预览,无需启动后端 @@ -79,6 +84,25 @@ --- +## 4.0 新特性 + +4.0(代号 **Extend**)在 3.x 平台能力之上新增三大能力 — 仍是**一条 CLI、一套 Admin**: + +| 重点 | 你能得到什么 | +| :--- | :--- | +| **插件** | Hook 系统 + `plugin.json` 契约 + Admin 插槽。内置 SEO 面板、自动摘要、历史图片 WebP 批量优化 | +| **桌面** | Electron 壳 + SQLite 本地 API。不开 Docker 也能写作管理;可连远程 API 或将内容同步到线上 | +| **主题** | npm 主题 catalog — 一条命令安装 `@fecommunity/reactpress-theme-starter` | + +```bash +npm i -g @fecommunity/reactpress@4 +reactpress init && reactpress dev +``` + +完整说明:[ReactPress 4.0 扩展版](./docs/tutorial/tutorial-extras/reactpress-4-0.md) · 从 3.x 升级:[迁移指南](./docs/tutorial/tutorial-extras/migration-3-to-4.md) + +--- + ## 为什么选 ReactPress? ### 为什么要用? @@ -114,6 +138,8 @@ | **内容编辑** | **Web 后台** | Web 后台 | Web 后台 | Git 中的 Markdown / MDX | | **前台速度与 SEO** | **Lighthouse 95/100/100/100**(官方主题演示)² | 因主题与插件差异大 | 通常较好 | 优秀,但无内置 CMS | | **前端灵活性** | **Headless — 可对接或替换主题** | 主题/插件生态强,耦合度高 | 与 Ghost 主题体系绑定 | 构建时固定 | +| **Headless 扩展** | **插件 Hook + Admin 插槽**(SEO、自动摘要等) | +| **本地写作** | **Electron 桌面客户端**(SQLite,免 Docker) | | **发布相关内置能力** | **搜索、评论、知识库**(官方主题 + API) | 常靠插件扩展 | 侧重会员/通讯 | 需自行实现 | | **更适合** | **博客、内容站、定制发布** | 通用网站 | 通讯与出版业务 | 文档站、开发者博客 | @@ -137,7 +163,7 @@ **环境要求:** Node.js 18+ · 推荐 Docker(用于内置 MySQL) ```bash -npm i -g @fecommunity/reactpress@3 +npm i -g @fecommunity/reactpress@4 mkdir my-blog && cd my-blog reactpress init reactpress dev @@ -145,11 +171,15 @@ reactpress dev CLI 会启动 **CMS API**,并在就绪后打印访问地址: -| 服务 | 典型地址 | -| :--- | :--- | -| API | `http://localhost:3002/api` | -| API 文档(Swagger) | `http://localhost:3002/api` | -| 健康检查 | `http://localhost:3002/api/health` | +| 服务 | 端口 | 典型地址 | +| :--- | :---: | :--- | +| 访客站(已启用主题) | **3001** | `http://localhost:3001` | +| API | **3002** | `http://localhost:3002/api/health` | +| 主题预览(仅后台 iframe) | **3003** | `http://localhost:3003` | +| 管理后台 Web(Vite) | **3000** | `http://localhost:3000` | +| MySQL | **3306** | `127.0.0.1:3306` | + +> 说明:`3001` 是 Next.js **访客主题**,不是管理后台;后台开发入口在 **3000**。预览未启用的主题时,前台 `:3001` 不变,预览走 `:3003`。 随时运行 `reactpress` 可打开交互菜单。启动失败时请使用 `reactpress doctor`。 @@ -173,15 +203,43 @@ pnpm dev:mock 当主题需要展示 **CMS 中的真实内容** 时: -1. 保持 ReactPress API 运行(`reactpress init` → `reactpress dev`,或 `reactpress dev --api-only`)。 -2. 克隆 [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) 并执行 `pnpm install`。 -3. 复制 `.env.example` 为 `.env`,然后运行 `pnpm dev`。 +**推荐 — 从 npm 安装**(与 CMS 同一项目): -**http://localhost:3001** 为访客站。在全栈部署中,可在 ReactPress 管理后台调整颜色、Logo 与导航。 +```bash +reactpress theme add @fecommunity/reactpress-theme-starter@1.0.0-beta.0 +# 或在管理后台 → 外观 → 主题 → 从 npm 安装 +reactpress dev +``` + +npm 包:[@fecommunity/reactpress-theme-starter](https://www.npmjs.com/package/@fecommunity/reactpress-theme-starter) · 在后台启用 **ReactPress Theme Starter** 后打开 **http://localhost:3001**。 + +**备选 — 独立主题仓库:** 克隆 [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter),`pnpm install`,复制 `.env.example` → `.env`,`pnpm dev`。 完整说明:[主题 README](https://github.com/fecommunity/reactpress-theme-starter/blob/master/README_zh.md)。 -### 4. 演示 +### 4. 桌面客户端(4.0) + +无需 Docker,本地 SQLite 即可写作与管理: + +```bash +# 在 monorepo 根目录 +pnpm dev:desktop +``` + +打包安装程序:`pnpm build:desktop`。默认本地账号 `admin` / `admin`;可在设置中切换远程 API 或将内容同步到线上站点。详见 [desktop/README.md](./desktop/README.md)。 + +### 5. 插件(4.0) + +管理后台 → **插件** → 安装/启用内置插件(如 SEO 增强、自动摘要、图片优化)。或使用 CLI: + +```bash +reactpress plugin list +reactpress plugin install seo +``` + +开发说明见 [plugins/README.md](./plugins/README.md)。 + +### 6. 演示
@@ -195,24 +253,28 @@ pnpm dev:mock [全栈演示](https://blog.gaoredu.com) · [主题演示](https://reactpress-theme-starter.vercel.app) -### 5. 常用命令 +### 7. 常用命令 | 命令 | 作用 | | :--- | :--- | | `reactpress` | 打开交互式菜单 | | `reactpress init` | 初始化新站点 | -| `reactpress dev` | 本地运行 CMS API(访客站需接入主题) | +| `reactpress dev` | 本地运行 API、后台与已启用主题 | | `reactpress build` | 生产环境构建 | | `reactpress start` | 生产环境启动 | | `reactpress doctor` | 诊断环境问题 | | `reactpress status` | 查看运行状态 | +| `reactpress theme add ` | 从 npm 安装主题 | +| `reactpress plugin install ` | 安装插件 | +| `pnpm dev:desktop` | 桌面客户端开发(SQLite + Electron) | +| `pnpm build:desktop` | 打包桌面安装程序 | -更多选项见 [官方文档](https://reactpress-docs.vercel.app/)。 +更多选项见 [官方文档](https://reactpress-docs.vercel.app/) 与 [4.0 扩展版说明](./docs/tutorial/tutorial-extras/reactpress-4-0.md)。 -### 6. 部署上线 +### 8. 部署上线 ```bash -npm i -g @fecommunity/reactpress@3 +npm i -g @fecommunity/reactpress@4 reactpress build reactpress start ``` diff --git a/README.md b/README.md index 5827cbf1..030df994 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@

- 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.

@@ -44,6 +44,7 @@ ## Table of contents - [What is ReactPress?](#what-is-reactpress) +- [What's new in 4.0](#whats-new-in-40) - [Why ReactPress?](#why-reactpress) - [How to use](#how-to-use) - [Contributing](#contributing) @@ -52,19 +53,23 @@ ## What is ReactPress? -**ReactPress is a publishing platform built around a CMS backend, an admin console, and optional frontends** — install one CLI package to run the API, manage content in the admin, and connect a public theme when you are ready. +**ReactPress 4.0 is a publishing platform built around a CMS backend, an admin console, optional frontends, plugins, and a desktop client** — install one CLI package to run the API, manage content in the admin, extend with plugins, write locally on desktop, and connect a public theme when you are ready. | Component | Role | | :-------- | :--- | -| **CMS backend (API)** | Stores and serves posts, pages, media, categories, and settings | +| **CMS backend (API)** | Stores and serves posts, pages, media, categories, and settings (MySQL or SQLite) | | **Admin console** | Web UI for writing and managing content (included in full-stack setups) | +| **Plugin system** | Hook-based extensions with Admin slots — SEO, auto-summary, image optimization | +| **Desktop client** | Electron app with embedded SQLite — write offline, sync to remote | | **[Official theme](https://github.com/fecommunity/reactpress-theme-starter)** | Recommended public site — search, knowledge base, comments, dark mode | -| **CLI (`reactpress`)** | Initialize, run locally, build, and deploy | +| **CLI (`reactpress`)** | Initialize, run locally, build, deploy, manage themes and plugins | ### What you can do - **Publish** — posts, pages, scheduled publishing, categories, and tags - **Manage media** — upload images and files, reuse across content +- **Extend with plugins** — install built-in or custom plugins from Admin or CLI +- **Write on desktop** — local SQLite mode, no Docker required; sync to production - **Customize the site** — title, logo, navigation, and appearance from the admin - **Choose your frontend** — use the official theme or connect a custom one via the API - **Preview the theme instantly** — sample-data mode in the theme repo, no backend required @@ -79,6 +84,25 @@ --- +## What's new in 4.0 + +4.0 (codename **Extend**) builds on the 3.x platform with three major additions — still **one CLI, one Admin**: + +| Focus | What you get | +| :---- | :----------- | +| **Plugins** | Hook system + `plugin.json` manifest + Admin UI slots. Built-in: SEO panel, auto-summary, batch WebP optimization | +| **Desktop** | Electron shell + SQLite local API. Write and manage without Docker; connect remote API or sync content upstream | +| **Themes** | npm theme catalog — install `@fecommunity/reactpress-theme-starter` with one command | + +```bash +npm i -g @fecommunity/reactpress@4 +reactpress init && reactpress dev +``` + +Full guide: [ReactPress 4.0 docs](./docs/tutorial/tutorial-extras/reactpress-4-0.md) · Upgrade from 3.x: [migration guide](./docs/tutorial/tutorial-extras/migration-3-to-4.md) + +--- + ## Why ReactPress? ### Why use it? @@ -90,8 +114,9 @@ Most publishing tools force a trade-off: either **an easy CMS with a slow or tig | **Start quickly** | One global install; `init` + `dev` brings up the CMS backend in about a minute¹ | | **Write in a familiar way** | Web admin for posts, pages, media, and categories | | **A site visitors enjoy** | Official theme: fast pages, search, comments, knowledge base, dark mode | -| **Room to grow** | Headless API — swap or customize the public frontend without migrating content | -| **Fewer moving parts** | Core publishing features without assembling plugins; official theme demo scores Lighthouse **95 / 100 / 100 / 100**² | +| **Room to grow** | Headless API + plugin hooks — swap the frontend or extend behavior without migrating content | +| **Write anywhere** | Web admin, or **Electron desktop** with SQLite when you do not want Docker locally | +| **Fewer moving parts** | Core publishing built-in; official theme demo scores Lighthouse **95 / 100 / 100 / 100**² | **In one line:** WordPress-style content workflow + a modern public site — with a clearer path to performance and frontend flexibility. @@ -114,6 +139,8 @@ Most publishing tools force a trade-off: either **an easy CMS with a slow or tig | **Content editing** | **Web admin** | Web admin | Web admin | Markdown or MDX in Git | | **Public site speed & SEO** | **Lighthouse 95/100/100/100** (official theme demo)² | Varies widely by theme and plugins | Generally strong | Excellent, but no built-in CMS | | **Frontend flexibility** | **Headless — connect or replace the theme** | Strong theme/plugin ecosystem, often coupled | Theme system tied to Ghost | Fixed at build time | +| **Headless extensions** | **Plugin hooks + Admin slots** (SEO, auto-summary, etc.) | Plugin ecosystem | Limited | Build yourself | +| **Local writing** | **Electron desktop client** (SQLite, no Docker) | — | Desktop app available | — | | **Built-in publishing extras** | **Search, comments, knowledge base** (official theme + API) | Often via plugins | Membership/newsletter focus | Build yourself | | **Best for** | **Blogs, content sites, custom publishing** | General-purpose websites | Newsletters and publishing businesses | Docs and developer blogs | @@ -137,7 +164,7 @@ Most publishing tools force a trade-off: either **an easy CMS with a slow or tig **Requirements:** Node.js 18+ · Docker recommended (for the bundled MySQL) ```bash -npm i -g @fecommunity/reactpress@3 +npm i -g @fecommunity/reactpress@4 mkdir my-blog && cd my-blog reactpress init reactpress dev @@ -145,11 +172,15 @@ reactpress dev The CLI starts the **CMS API** and prints the URLs when ready: -| Service | Typical URL | -| :------ | :------------ | -| API | `http://localhost:3002/api` | -| API docs (Swagger) | `http://localhost:3002/api` | -| Health check | `http://localhost:3002/api/health` | +| Service | Port | Typical URL | +| :------------------- | :----: | :----------------------------------- | +| Public site (theme) | 3001 | `http://localhost:3001` | +| API | 3002 | `http://localhost:3002/api/health` | +| Theme preview (admin)| 3003 | `http://localhost:3003` | +| Admin Web (Vite) | 3000 | `http://localhost:3000` | +| MySQL | 3306 | `127.0.0.1:3306` | + +Port **3001** is the active theme (visitor site), not the admin SPA. Theme preview uses **3003** so the public site stays on the activated theme. Run `reactpress` anytime for the interactive menu. Use `reactpress doctor` if startup fails. @@ -173,15 +204,43 @@ Open **http://localhost:3001** — same as the [live demo](https://reactpress-th When the theme should show **your** content from the CMS: -1. Keep the ReactPress API running (`reactpress init` → `reactpress dev`, or `reactpress dev --api-only`). -2. Clone [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter) and run `pnpm install`. -3. Copy `.env.example` to `.env`, then run `pnpm dev`. +**Recommended — install from npm** (same project as the CMS): + +```bash +reactpress theme add @fecommunity/reactpress-theme-starter@1.0.0-beta.0 +# or in Admin → Appearance → Themes → Install from npm +reactpress dev +``` -Open **http://localhost:3001** for the public site. Customize colors, logo, and navigation in the ReactPress admin when running a full-stack deployment. +Package: [@fecommunity/reactpress-theme-starter](https://www.npmjs.com/package/@fecommunity/reactpress-theme-starter) · Activate **ReactPress Theme Starter** in the admin, then open **http://localhost:3001**. + +**Alternative — standalone theme repo:** clone [reactpress-theme-starter](https://github.com/fecommunity/reactpress-theme-starter), `pnpm install`, copy `.env.example` → `.env`, `pnpm dev`. Full guide: [theme starter README](https://github.com/fecommunity/reactpress-theme-starter#readme). -### 4. See it in action +### 4. Desktop client (4.0) + +Write and manage locally with SQLite — no Docker required: + +```bash +# at monorepo root +pnpm dev:desktop +``` + +Build installer: `pnpm build:desktop`. Default local account `admin` / `admin`. Switch to remote API or sync content to your production site in Settings. See [desktop/README.md](./desktop/README.md). + +### 5. Plugins (4.0) + +Admin → **Plugins** → install and enable built-in plugins (SEO, auto-summary, image optimizer). Or use CLI: + +```bash +reactpress plugin list +reactpress plugin install seo +``` + +Plugin development: [plugins/README.md](./plugins/README.md). + +### 6. See it in action
@@ -195,24 +254,28 @@ Full guide: [theme starter README](https://github.com/fecommunity/reactpress-the [Full stack demo](https://blog.gaoredu.com) · [Theme demo](https://reactpress-theme-starter.vercel.app) -### 5. Everyday commands +### 7. Everyday commands | Command | What it does | | :------ | :----------- | | `reactpress` | Open the interactive menu | | `reactpress init` | Set up a new site | -| `reactpress dev` | Run the CMS API locally (add a theme for the public site) | +| `reactpress dev` | Run API, admin, and active theme locally | | `reactpress build` | Prepare for production | | `reactpress start` | Run in production | | `reactpress doctor` | Diagnose setup issues | | `reactpress status` | See what is running | +| `reactpress theme add ` | Install a theme from npm | +| `reactpress plugin install ` | Install a plugin | +| `pnpm dev:desktop` | Desktop client dev (SQLite + Electron) | +| `pnpm build:desktop` | Build desktop installer | -More options: [documentation](https://reactpress-docs.vercel.app/) +More options: [documentation](https://reactpress-docs.vercel.app/) · [4.0 guide](./docs/tutorial/tutorial-extras/reactpress-4-0.md) -### 6. Deploy to production +### 8. Deploy to production ```bash -npm i -g @fecommunity/reactpress@3 +npm i -g @fecommunity/reactpress@4 reactpress build reactpress start ``` diff --git a/cli/README.md b/cli/README.md index fdaf2ace..0ce2f4fa 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,47 +1,85 @@ -# @fecommunity/reactpress-cli +# @fecommunity/reactpress -零配置一键初始化与管理 ReactPress CMS & 博客服务器。内置 NestJS 服务端,无需单独克隆 [fecommunity/reactpress](https://github.com/fecommunity/reactpress)。 +ReactPress **4.0** main package — zero-config CMS CLI with built-in NestJS API, plugin system, theme catalog, and desktop client orchestration. -完整文档与贡献指南见:[github.com/fecommunity/reactpress-cli](https://github.com/fecommunity/reactpress-cli) +Global command: `reactpress` (`reactpress-cli` is a compatibility shim and deprecated). -## 安装 +## Install ```bash -npm install -g @fecommunity/reactpress-cli +npm i -g @fecommunity/reactpress@4 +# beta +npm i -g @fecommunity/reactpress@beta ``` -全局命令为 `reactpress-cli`(与 npm 包名无关)。 +**Requirements:** Node.js ≥ 18 · macOS / Linux / Windows · Docker recommended for full-stack mode (MySQL) -> npm 上的无作用域包名 `reactpress-cli` 已被占用,本包发布为 `@fecommunity/reactpress-cli`。 - -## 快速开始 +## Quick start ```bash mkdir my-blog && cd my-blog -reactpress-cli init -reactpress-cli start +reactpress init # MySQL + Docker (default) +reactpress dev # API + Admin + active theme +``` + +Local writing without Docker: + +```bash +reactpress init --local # SQLite +reactpress dev --local # or reactpress dev --web-only --local ``` -浏览器访问 `http://localhost:3002`(API 文档:`/api`)。 +Run `reactpress` with no arguments to open the interactive menu. + +## Core commands + +| Command | Description | +|------|------| +| `reactpress init [dir]` | Initialize project (`--force` overwrite; `--local` SQLite) | +| `reactpress dev` | Full-stack dev (API 3002 · Admin 3000 · Theme 3001) | +| `reactpress dev --api-only` | API only (headless) | +| `reactpress dev --web-only` | Admin + API | +| `reactpress dev --client-only` | Visitor theme only | +| `reactpress dev --local` | SQLite mode (no Docker/nginx) | +| `reactpress build [-t target]` | Production build (`toolkit` \| `plugins` \| `server` \| `web` \| `theme` \| `docs` \| `all`) | +| `reactpress start` | Start API + visitor theme in production mode | +| `reactpress server start` | Start API (`--bg` / `--pm2`) | +| `reactpress client start` | Start visitor theme (`--pm2`) | +| `reactpress status` | Combined runtime status | +| `reactpress doctor` | Environment diagnostics | +| `reactpress db backup` | MySQL backup | -## 常用命令 +## 4.0 extensions -| 命令 | 说明 | +| Command | Description | |------|------| -| `reactpress-cli init [dir]` | 初始化项目 | -| `reactpress-cli start` | 启动服务(自动准备数据库) | -| `reactpress-cli stop` | 停止服务 | -| `reactpress-cli restart` | 重启服务 | -| `reactpress-cli status` | 查看状态 | -| `reactpress-cli config [key] [value]` | 查看/修改配置 | -| `reactpress-cli config server.port 3003 --apply` | 改端口并重启 | +| `reactpress desktop dev` | Electron desktop dev (SQLite + Admin, monorepo) | +| `reactpress plugin list` | List plugin registry | +| `reactpress plugin install ` | Install plugin to `.reactpress/plugins` | +| `reactpress theme list` | List available themes | +| `reactpress theme add ` | Install theme from npm | + +## Docker & Nginx + +| Command | Description | +|------|------| +| `reactpress docker start` | Docker + full-stack dev | +| `reactpress docker up/down` | MySQL container only | +| `reactpress nginx up` | Unified entry on `:80` reverse proxy | + +## Maintainers + +```bash +reactpress publish --build # Build publish artifacts only +reactpress publish --publish # Publish core npm packages +``` -## 要求 +## Documentation -- Node.js 18+ -- macOS / Linux / Windows -- 默认使用 Docker 运行嵌入式 MySQL;也可在 `.reactpress/config.json` 中配置外部数据库 +- [ReactPress 4.0 extended guide](https://github.com/fecommunity/reactpress/blob/master/docs/tutorial/tutorial-extras/reactpress-4-0.md) +- [ARCHITECTURE.md](https://github.com/fecommunity/reactpress/blob/master/ARCHITECTURE.md) +- [中文文档](../README-zh_CN.md) -## 许可证 +## License MIT © FECommunity diff --git a/cli/bin/reactpress-cli-shim.js b/cli/bin/reactpress-cli-shim.js index bf2dc2b0..35931a94 100755 --- a/cli/bin/reactpress-cli-shim.js +++ b/cli/bin/reactpress-cli-shim.js @@ -4,7 +4,7 @@ * @deprecated 3.0 起请使用 `reactpress`(@fecommunity/reactpress)。3.1 将移除此 bin。 */ const chalk = require('chalk'); -const { t } = require('../lib/i18n'); +const { t } = require('../out/lib/i18n'); function mapLegacyArgv(argv) { const [cmd, ...rest] = argv; @@ -16,11 +16,7 @@ function mapLegacyArgv(argv) { } if (!process.env.REACTPRESS_SUPPRESS_DEPRECATION) { - console.warn( - chalk.yellow( - t('shim.deprecated') - ) - ); + console.warn(chalk.yellow(t('shim.deprecated'))); } const mapped = mapLegacyArgv(process.argv.slice(2)); diff --git a/cli/bin/reactpress-theme-client.js b/cli/bin/reactpress-theme-client.js new file mode 100644 index 00000000..3118c0a1 --- /dev/null +++ b/cli/bin/reactpress-theme-client.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../out/bin/reactpress-theme-client.js'); diff --git a/cli/bin/reactpress.js b/cli/bin/reactpress.js index 67829e15..a89dc68c 100755 --- a/cli/bin/reactpress.js +++ b/cli/bin/reactpress.js @@ -1,381 +1,2 @@ #!/usr/bin/env node - -/** - * ReactPress unified CLI — init, dev, build, server, docker, publish. - * Run without arguments for an interactive menu (Claude Code–style). - */ - -const { Command } = require('commander'); -const path = require('path'); -const chalk = require('chalk'); -const { brand, divider } = require('../ui/theme'); -const { ensureOriginalCwd } = require('../lib/root'); -const { ensureProjectEnvironment, initMonorepoProject } = 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 { runDbBackup } = require('../lib/db-backup'); -const { runBuild } = require('../lib/build'); -const { startApiWithPm2 } = require('../lib/pm2'); -const { runNodeScript, runReactpressCli } = require('../lib/spawn'); -const { getClientBin } = require('../lib/paths'); -const { runInteractiveLoop } = require('../ui/interactive'); -const { t } = require('../lib/i18n'); - -const rootPkg = require(path.join(__dirname, '..', 'package.json')); - -const program = new Command(); - -program - .name('reactpress') - .description(t('cli.description')) - .version(rootPkg.version); - -program - .command('init') - .description(t('cli.init.description')) - .argument('[directory]', t('cli.init.directory'), '.') - .option('-f, --force', t('cli.init.force')) - .action(async (directory, options) => { - const projectRoot = path.resolve(directory); - process.env.REACTPRESS_ORIGINAL_CWD = projectRoot; - const { isMonorepoCheckout } = require('../lib/bootstrap'); - if (isMonorepoCheckout(projectRoot)) { - const result = await initMonorepoProject(projectRoot, { force: !!options.force }); - console.log(`[reactpress] ${result.message}`); - process.exit(result.ok ? 0 : 1); - return; - } - const args = ['init', directory]; - if (options.force) args.push('--force'); - runReactpressCli(args, { cwd: projectRoot }); - }); - -program - .command('dev') - .description(t('cli.dev.description')) - .option('--api-only', t('cli.dev.apiOnly')) - .option('--client-only', t('cli.dev.clientOnly')) - .action(async (options) => { - const projectRoot = ensureOriginalCwd(); - try { - if (options.clientOnly) { - await runNodeScript(getClientBin(), [], { cwd: projectRoot }); - return; - } - if (options.apiOnly) { - await runApiDev(projectRoot); - return; - } - await runDev(projectRoot); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(err.exitCode ?? 1); - } - }); - -const serverCmd = program.command('server').description(t('cli.server.description')); - -serverCmd - .command('start') - .description(t('cli.server.start.description')) - .option('--pm2', t('cli.server.start.pm2')) - .option('--bg', t('cli.server.start.bg')) - .action(async (options) => { - const projectRoot = ensureOriginalCwd(); - try { - if (options.pm2) { - await startApiWithPm2(projectRoot); - return; - } - const cmd = options.bg ? 'start:bg' : 'start'; - const code = await runLifecycleCommand(cmd, projectRoot); - process.exit(code ?? 0); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } - }); - -serverCmd.command('stop').description(t('cli.server.stop')).action(async () => { - const code = await runLifecycleCommand('stop', ensureOriginalCwd()); - process.exit(code ?? 0); -}); - -serverCmd.command('restart').description(t('cli.server.restart')).action(async () => { - const code = await runLifecycleCommand('restart', ensureOriginalCwd()); - process.exit(code ?? 0); -}); - -serverCmd.command('status').description(t('cli.server.status')).action(async () => { - await runLifecycleCommand('status', ensureOriginalCwd()); -}); - -const clientCmd = program.command('client').description(t('cli.client.description')); - -clientCmd - .command('start') - .description(t('cli.client.start')) - .option('--pm2', t('cli.client.start.pm2')) - .action(async (options) => { - const args = options.pm2 ? ['--pm2'] : []; - await runNodeScript(getClientBin(), args, { cwd: ensureOriginalCwd() }); - }); - -program - .command('build') - .description(t('cli.build.description')) - .option('-t, --target ', t('cli.build.target'), 'all') - .action(async (options) => { - try { - await runBuild(options.target, ensureOriginalCwd()); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } - }); - -const dockerCmd = program.command('docker').description(t('cli.docker.description')); - -dockerCmd - .command('up') - .description(t('cli.docker.up')) - .action(async () => { - await runDockerCommand('up', ensureOriginalCwd()); - }); - -dockerCmd - .command('down') - .alias('stop') - .description(t('cli.docker.down')) - .action(async () => { - await runDockerCommand('down', ensureOriginalCwd()); - }); - -dockerCmd - .command('start') - .description(t('cli.docker.start')) - .action(async () => { - await runDockerCommand('start', ensureOriginalCwd()); - }); - -dockerCmd.command('restart').description(t('cli.docker.restart')).action(async () => { - await runDockerCommand('restart', ensureOriginalCwd()); -}); - -dockerCmd.command('status').description(t('cli.docker.status')).action(async () => { - await runDockerCommand('status', ensureOriginalCwd()); -}); - -dockerCmd - .command('logs [service]') - .description(t('cli.docker.logs')) - .action(async (service) => { - await runDockerCommand('logs', ensureOriginalCwd(), service ? [service] : []); - }); - -const nginxCmd = program.command('nginx').description(t('cli.nginx.description')); - -function nginxActionOptions(cmd) { - return cmd.option('--prod', t('cli.nginx.prod')).option('-f, --force', t('cli.nginx.force')); -} - -nginxActionOptions(nginxCmd.command('ensure').description(t('cli.nginx.ensure'))).action(async (options) => { - try { - await runNginxCommand('ensure', ensureOriginalCwd(), [], options); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -nginxActionOptions(nginxCmd.command('up').description(t('cli.nginx.up'))).action(async (options) => { - try { - await runNginxCommand('up', ensureOriginalCwd(), [], options); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -nginxCmd - .command('down') - .alias('stop') - .description(t('cli.nginx.down')) - .option('--prod', t('cli.nginx.prod')) - .action(async (options) => { - try { - await runNginxCommand('down', ensureOriginalCwd(), [], options); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } - }); - -nginxActionOptions(nginxCmd.command('restart').description(t('cli.nginx.restart'))).action(async (options) => { - try { - await runNginxCommand('restart', ensureOriginalCwd(), [], options); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -nginxCmd - .command('status') - .description(t('cli.nginx.status')) - .option('--prod', t('cli.nginx.prod')) - .action(async (options) => { - try { - await runNginxCommand('status', ensureOriginalCwd(), [], options); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } - }); - -nginxCmd.command('logs').description(t('cli.nginx.logs')).action(async () => { - try { - await runNginxCommand('logs', ensureOriginalCwd()); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -nginxCmd.command('test').description(t('cli.nginx.test')).action(async () => { - try { - await runNginxCommand('test', ensureOriginalCwd()); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -nginxCmd.command('reload').description(t('cli.nginx.reload')).action(async () => { - try { - await runNginxCommand('reload', ensureOriginalCwd()); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -nginxCmd.command('open').description(t('cli.nginx.open')).action(async () => { - try { - await runNginxCommand('open', ensureOriginalCwd()); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } -}); - -program - .command('status') - .description(t('cli.status.description')) - .action(async () => { - await printUnifiedStatus(ensureOriginalCwd()); - }); - -program - .command('doctor') - .description(t('cli.doctor.description')) - .action(async () => { - const code = await runDoctor(ensureOriginalCwd()); - process.exit(code); - }); - -const dbCmd = program.command('db').description(t('cli.db.description')); - -dbCmd - .command('backup') - .description(t('cli.db.backup')) - .option('-o, --output ', t('cli.db.backup.output')) - .action(async (options) => { - try { - await runDbBackup(ensureOriginalCwd(), options.output); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } - }); - -program - .command('publish') - .description(t('cli.publish.description')) - .option('--build', t('cli.publish.build')) - .option('--publish', t('cli.publish.publish')) - .action(async (options) => { - try { - const publish = require('../lib/publish'); - if (options.build) { - await publish.buildPackages(); - return; - } - await publish.publishPackages(); - } catch (err) { - console.error(chalk.red('[reactpress]'), err.message || err); - process.exit(1); - } - }); - -program - .command('start') - .description(t('cli.start.description')) - .action(async () => { - const projectRoot = ensureOriginalCwd(); - const { hasClient } = require('../lib/project-type'); - const code = await runLifecycleCommand('start', projectRoot); - if (code !== 0) process.exit(code); - if (!hasClient(projectRoot)) { - console.log(t('dev.standaloneHint')); - return; - } - const { spawn } = require('child_process'); - const child = spawn('pnpm', ['run', '--dir', './client', 'start'], { - stdio: 'inherit', - shell: true, - cwd: projectRoot, - }); - 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.init'), - 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.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); -}); +require('../out/bin/reactpress.js'); diff --git a/cli/lib/api-dev-runner.js b/cli/lib/api-dev-runner.js deleted file mode 100644 index 4bbde612..00000000 --- a/cli/lib/api-dev-runner.js +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node -const { runApiDev } = require('./api-dev'); -const { ensureOriginalCwd } = require('./root'); - -ensureOriginalCwd(); -runApiDev(); diff --git a/cli/lib/api-dev.js b/cli/lib/api-dev.js deleted file mode 100644 index cac3fc4c..00000000 --- a/cli/lib/api-dev.js +++ /dev/null @@ -1,89 +0,0 @@ -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'); - -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()) { - try { - await ensureProjectEnvironment(projectRoot); - } catch (err) { - console.error(t('dev.envFailed'), err.message || err); - process.exit(1); - } - - process.on('SIGINT', () => { - stopApiDev(projectRoot); - process.exit(0); - }); - process.on('SIGTERM', () => { - stopApiDev(projectRoot); - process.exit(0); - }); - - startApiDev(projectRoot); -} - -function getApiDevScriptPath() { - return path.join(__dirname, 'api-dev-runner.js'); -} - -module.exports = { - runApiDev, - stopApiDev, - getApiDevScriptPath, -}; diff --git a/cli/lib/bootstrap.js b/cli/lib/bootstrap.js deleted file mode 100644 index 9d5dd7ba..00000000 --- a/cli/lib/bootstrap.js +++ /dev/null @@ -1,114 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { pathToFileURL } = require('url'); -const { ensureOriginalCwd, isMonorepoCheckout } = require('./root'); -const { getCliPackageRoot } = require('./paths'); -const { t } = require('./i18n'); - -async function importCliModule(relativePath) { - const modulePath = path.join(getCliPackageRoot(), 'dist', relativePath); - return import(pathToFileURL(modulePath).href); -} - -async function copyTemplateFile(src, dest) { - await fs.promises.mkdir(path.dirname(dest), { recursive: true }); - await fs.promises.copyFile(src, dest); -} - -async function initMonorepoProject(projectRoot, { force = false } = {}) { - const { getProjectPaths, getTemplatesDir } = await importCliModule('utils/paths.js'); - const { saveConfig, syncEnvFromConfig } = await importCliModule('services/config.js'); - const { ensureDatabase, ensureDatabaseHostPort } = await importCliModule('services/database.js'); - - const paths = getProjectPaths(projectRoot); - const templatesDir = getTemplatesDir(); - - if (fs.existsSync(paths.configPath) && !force) { - const config = await (await importCliModule('services/config.js')).loadConfig(projectRoot); - await ensureDatabaseHostPort(projectRoot, undefined, config); - const dbResult = await ensureDatabase(projectRoot, config); - if (!dbResult.ok) { - return { ok: false, projectRoot, message: dbResult.message }; - } - 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'), - }; -} - -async function ensureProjectEnvironment(projectRoot = ensureOriginalCwd()) { - const root = path.resolve(projectRoot); - const { setProjectCwd } = await importCliModule('utils/cli-context.js'); - setProjectCwd(root); - - const { isReactPressProject, loadConfig } = await importCliModule('services/config.js'); - const { ensureDatabase, ensureDatabaseHostPort } = await importCliModule('services/database.js'); - - 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 { initProject } = await importCliModule('services/init.js'); - 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); - 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') }; -} - -module.exports = { - ensureProjectEnvironment, - initMonorepoProject, - isMonorepoCheckout, -}; diff --git a/cli/lib/build.js b/cli/lib/build.js deleted file mode 100644 index ee70bc19..00000000 --- a/cli/lib/build.js +++ /dev/null @@ -1,162 +0,0 @@ -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 { t } = require('./i18n'); - -const FORBIDDEN_SCRIPTS = new Set(['build']); - -/** @type {Record} */ -const BUILD_STEPS = { - toolkit: [{ script: 'build:toolkit', labelKey: 'build.label.toolkit' }], - server: [{ script: 'build:server', labelKey: 'build.label.server' }], - client: [{ script: 'build:client', labelKey: 'build.label.client' }], - docs: [{ script: 'build:docs', labelKey: 'build.label.docs' }], - all: [ - { script: 'build:toolkit', labelKey: 'build.label.toolkit' }, - { script: 'build:server', labelKey: 'build.label.server' }, - { script: 'build:client', labelKey: 'build.label.client' }, - ], -}; - -const TARGETS = Object.keys(BUILD_STEPS); - -const buildChildEnv = { 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:client') { - const clientDir = path.join(root, 'client'); - if (fs.existsSync(path.join(clientDir, 'package.json'))) { - return { command: 'pnpm', args: ['run', 'build'], cwd: clientDir }; - } - } - - 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 = BUILD_STEPS[target]; - 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); - - 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, - resolveBuildInvocation, -}; diff --git a/cli/lib/db-backup.js b/cli/lib/db-backup.js deleted file mode 100644 index 729cb5f9..00000000 --- a/cli/lib/db-backup.js +++ /dev/null @@ -1,67 +0,0 @@ -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/lib/dev-banner.js b/cli/lib/dev-banner.js deleted file mode 100644 index 876f7206..00000000 --- a/cli/lib/dev-banner.js +++ /dev/null @@ -1,72 +0,0 @@ -const { - brand, - icon, - ok, - divider, - padRight, - terminalWidth, - gradientText, - palette, - pulseBar, - statusLights, -} = require('../ui/theme'); -const { - loadClientSiteUrl, - loadServerSiteUrl, - getApiPrefix, - getHealthUrl, -} = require('./http'); -const { t } = require('./i18n'); - -function getDevUrls(projectRoot) { - const client = loadClientSiteUrl(projectRoot).replace(/\/$/, ''); - const server = loadServerSiteUrl(projectRoot).replace(/\/$/, ''); - const prefix = getApiPrefix(projectRoot).replace(/\/$/, '') || '/api'; - return { - site: client, - admin: `${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 } = {}) { - const urls = getDevUrls(projectRoot); - const w = Math.min(terminalWidth() - 4, 56); - - console.log(''); - console.log( - ` ${icon.ok} ${gradientText(t('devBanner.ready'), [palette.green, palette.accent], { bold: true })} ${statusLights('online')}` - ); - console.log(` ${brand.primary('╔' + '═'.repeat(w) + '╗')}`); - - if (!apiOnly) { - 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)} ${brand.success(t('devBanner.allSystemsGo'))}` - ); - } - - 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/lib/dev.js b/cli/lib/dev.js deleted file mode 100644 index 838585e2..00000000 --- a/cli/lib/dev.js +++ /dev/null @@ -1,141 +0,0 @@ -const { spawn } = require('child_process'); -const path = require('path'); -const ora = require('ora'); -const { runBuild } = require('./build'); -const { ensureProjectEnvironment } = require('./bootstrap'); -const { loadServerSiteUrl, loadClientSiteUrl, waitForHttp } = require('./http'); -const { printDevReadyBanner } = require('./dev-banner'); -const { ensureOriginalCwd } = require('./root'); -const { detectProjectType, hasClient, hasToolkit } = require('./project-type'); -const { t } = require('./i18n'); - -const CLIENT_READY_TIMEOUT_MS = 120_000; -const API_READY_TIMEOUT_MS = 180_000; - -function formatDevFailureHint() { - return [ - t('dev.nextSteps'), - t('dev.nextDoctor'), - t('dev.nextDocker'), - t('dev.nextEnv'), - ].join('\n'); -} - -let apiChild; -let webChild; -let shuttingDown = false; - -function shutdown(signal = 'SIGINT') { - if (shuttingDown) return; - shuttingDown = true; - if (webChild && !webChild.killed) webChild.kill(signal); - if (apiChild && !apiChild.killed) apiChild.kill(signal); -} - -async function buildToolkit(projectRoot) { - if (!hasToolkit(projectRoot)) return; - await runBuild('toolkit', projectRoot); -} - -function spawnApi(projectRoot) { - const apiDevRunner = path.join(__dirname, 'api-dev-runner.js'); - console.log(t('dev.startingApi')); - apiChild = spawn(process.execPath, [apiDevRunner], { - stdio: 'inherit', - cwd: projectRoot, - env: { - ...process.env, - REACTPRESS_ORIGINAL_CWD: projectRoot, - }, - }); - - apiChild.on('close', (code) => { - if (shuttingDown) { - process.exit(code ?? 0); - return; - } - if (webChild && !webChild.killed) webChild.kill('SIGINT'); - process.exit(code ?? 1); - }); -} - -async function waitForApiReady(projectRoot) { - const serverUrl = loadServerSiteUrl(projectRoot); - const spinner = ora({ - text: t('dev.waitingApi', { url: serverUrl }), - color: 'magenta', - spinner: 'dots', - }).start(); - const ready = await waitForHttp(serverUrl, API_READY_TIMEOUT_MS); - if (!ready) { - spinner.fail(t('dev.apiTimeout', { seconds: API_READY_TIMEOUT_MS / 1000 })); - shutdown('SIGINT'); - process.exit(1); - } - spinner.succeed(t('dev.apiReady')); -} - -function spawnClient(projectRoot) { - webChild = spawn('pnpm', ['run', '--dir', './client', 'dev'], { - stdio: 'inherit', - shell: true, - cwd: projectRoot, - }); - - const clientUrl = loadClientSiteUrl(projectRoot); - waitForHttp(clientUrl, CLIENT_READY_TIMEOUT_MS).then((clientReady) => { - if (clientReady) { - printDevReadyBanner(projectRoot); - } else { - console.warn( - t('dev.clientSlow', { - seconds: CLIENT_READY_TIMEOUT_MS / 1000, - url: clientUrl, - }) - ); - } - }); - - webChild.on('close', (code) => { - if (!shuttingDown) shutdown('SIGINT'); - process.exit(code ?? 0); - }); -} - -async function startDevStack(projectRoot) { - const includeClient = hasClient(projectRoot); - - spawnApi(projectRoot); - await waitForApiReady(projectRoot); - printDevReadyBanner(projectRoot, { apiOnly: !includeClient }); - - if (!includeClient) { - console.log(t('dev.standaloneHint')); - return; - } - spawnClient(projectRoot); -} - -async function runDev(projectRoot = ensureOriginalCwd()) { - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); - - try { - const result = await ensureProjectEnvironment(projectRoot); - if (result.message) console.log(`[reactpress] ${result.message}`); - } catch (err) { - console.error(t('dev.envFailed'), err.message || err); - console.error(formatDevFailureHint()); - process.exit(1); - } - - await buildToolkit(projectRoot); - await startDevStack(projectRoot); -} - -module.exports = { - runDev, - buildToolkit, - startDevStack, - detectProjectType, -}; diff --git a/cli/lib/docker.js b/cli/lib/docker.js deleted file mode 100644 index cf4932b3..00000000 --- a/cli/lib/docker.js +++ /dev/null @@ -1,275 +0,0 @@ -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, hasClient } = 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 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 result = runCompose(['up', '-d'], ctx); - if (result.status !== 0) { - throw new Error(t('docker.notRunning')); - } - console.log(t('docker.started')); -} - -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 waitForMysql(projectRoot, maxAttempts = 30) { - const ctx = resolveComposeContext(projectRoot); - const container = resolveDbContainerName(ctx, projectRoot); - const { user, password } = resolveDbCredentialsFromEnv(projectRoot); - - const spinner = ora({ - text: t('docker.waitingMysql'), - color: 'magenta', - spinner: 'dots', - }).start(); - - let attempts = 0; - while (attempts < maxAttempts) { - const probe = spawnSync( - 'docker', - ['exec', container, 'mysql', `-u${user}`, `-p${password}`, '-e', 'SELECT 1'], - { stdio: 'ignore' } - ); - if (probe.status === 0) { - spinner.succeed(t('docker.mysqlReady')); - return true; - } - attempts += 1; - spinner.text = t('docker.waitingMysqlProgress', { attempts, max: maxAttempts }); - await new Promise((r) => setTimeout(r, 1000)); - } - spinner.fail(t('docker.mysqlTimeout')); - return false; -} - -async function dockerStartWithDev(projectRoot) { - startDockerServices(projectRoot); - const ready = await waitForMysql(projectRoot); - if (!ready) { - throw new Error(t('docker.mysqlNotReady')); - } - - if (!hasClient(projectRoot)) { - console.log(t('dev.standaloneHint')); - return; - } - - const { buildToolkit } = require('./dev'); - await buildToolkit(projectRoot); - - const apiRunner = path.join(__dirname, 'api-dev-runner.js'); - console.log(t('docker.startDevStack')); - console.log(t('docker.visitUrls')); - - return new Promise((resolve, reject) => { - const child = spawn( - 'npx', - [ - 'concurrently', - '-n', - 'api,web', - '-c', - 'blue,green', - `node "${apiRunner}"`, - 'pnpm run --dir ./client dev', - ], - { - stdio: 'inherit', - shell: true, - cwd: projectRoot, - env: { - ...process.env, - REACTPRESS_ORIGINAL_CWD: projectRoot, - }, - } - ); - - child.on('error', reject); - child.on('close', (code) => { - if (code !== 0) { - reject(Object.assign(new Error(t('docker.devProcessExit', { code })), { exitCode: code })); - return; - } - resolve(); - }); - }); -} - -/** - * 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': - 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)); - 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, - isDockerRunning, - resolveComposeContext, - pickDockerComposeCommand, - mysqldumpFromDbContainer, -}; diff --git a/cli/lib/doctor.js b/cli/lib/doctor.js deleted file mode 100644 index 13d0e8ea..00000000 --- a/cli/lib/doctor.js +++ /dev/null @@ -1,266 +0,0 @@ -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 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 }; diff --git a/cli/lib/http.js b/cli/lib/http.js deleted file mode 100644 index 403c486a..00000000 --- a/cli/lib/http.js +++ /dev/null @@ -1,182 +0,0 @@ -const fs = require('fs'); -const http = require('http'); -const path = require('path'); - -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 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`; -} - -function probeHttp(url, timeoutMs = 3000) { - return new Promise((resolve) => { - let parsed; - try { - parsed = new URL(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, - 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(url, timeoutMs); - if (primary.ok) return primary; - - if (primary.statusCode === 404 || primary.statusCode === 0) { - try { - const parsed = new URL(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: true, - statusCode: 200, - data: { status: 'ok', database: 'unknown' }, - }; - } - } - } catch { - // ignore - } - } - - return primary; -} - -function isHttpResponding(url, timeoutMs = 2000) { - return new Promise((resolve) => { - let parsed; - try { - parsed = new URL(url); - } catch { - resolve(false); - return; - } - - const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80); - const req = http.request( - { - hostname: parsed.hostname, - port, - 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; -} - -module.exports = { - loadServerSiteUrl, - loadClientSiteUrl, - getApiPrefix, - getHealthUrl, - checkHealth, - isHttpResponding, - waitForHttp, -}; diff --git a/cli/lib/i18n/strings.js b/cli/lib/i18n/strings.js deleted file mode 100644 index 4469cc24..00000000 --- a/cli/lib/i18n/strings.js +++ /dev/null @@ -1,846 +0,0 @@ -/** CLI user-facing strings — English first, Chinese via REACTPRESS_LANG=zh or zh LANG */ -const STRINGS = { - en: { - 'cli.description': 'ReactPress CLI — init, dev, build, deploy, 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.dev.description': 'Zero-config dev: env check + toolkit build + API + frontend', - 'cli.dev.apiOnly': 'API only (watch)', - 'cli.dev.clientOnly': 'Frontend only', - '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.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 (interactive)', - 'cli.publish.build': 'Build all packages only', - 'cli.publish.publish': 'Interactive publish', - '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 client Build one target (toolkit|server|client|docs|all)', - 'cli.help.publish': ' reactpress publish Publish npm packages', - 'cli.build.target': 'Build target: toolkit | server | client | docs | all', - '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.lifecycle': 'Lifecycle', - 'menu.section.build': 'Build & Deploy', - 'menu.section.tools': 'Tools', - '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.startingApi': '[reactpress] Starting API (first run may install deps)…', - 'dev.waitingApi': '[reactpress] Waiting for API: {url}', - '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.apiReady': '[reactpress] API ready, starting frontend…', - 'dev.clientSlow': '[reactpress] Frontend not responding within {seconds}s; it may still be compiling. Visit {url} later', - '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.', - 'devBanner.ready': 'ReactPress dev environment is ready', - 'devBanner.site': 'Site', - 'devBanner.admin': 'Admin', - 'devBanner.api': 'API', - 'devBanner.swagger': 'Swagger', - 'devBanner.health': 'Health', - 'devBanner.hint': 'Diagnostics: reactpress doctor · Status: reactpress status', - 'devBanner.shortcuts': 'Ctrl+C stop', - 'devBanner.allSystemsGo': 'ALL SYSTEMS GO', - '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.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': 'reactpress-cli', - '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.started': '[reactpress] Docker services started.', - 'docker.waitingMysql': '[reactpress] Waiting for MySQL…', - 'docker.mysqlReady': '[reactpress] MySQL is ready.', - '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.done': 'Build finished in {seconds}s', - 'build.label.toolkit': 'Toolkit', - 'build.label.server': 'API (server)', - 'build.label.client': 'Frontend (client)', - '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 全栈 CLI — 初始化、开发、构建、部署、发布', - 'cli.init.description': '初始化项目 (.reactpress/config.json + .env + Docker MySQL)', - 'cli.init.directory': '项目目录', - 'cli.init.force': '覆盖已有配置', - 'cli.dev.description': '零配置开发: 环境检查 + toolkit 构建 + API + 前端', - 'cli.dev.apiOnly': '仅启动 API (watch)', - 'cli.dev.clientOnly': '仅启动前端', - '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.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': '交互式发布', - '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 client 构建指定目标 (toolkit|server|client|docs|all)', - 'cli.help.publish': ' reactpress publish 发布 npm 包', - 'cli.build.target': '构建目标: toolkit | server | client | docs | all', - '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.lifecycle': '生命周期', - 'menu.section.build': '构建与部署', - 'menu.section.tools': '工具', - '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.startingApi': '[reactpress] 正在启动 API(首次可能需安装依赖,请稍候)…', - 'dev.waitingApi': '[reactpress] 等待 API 就绪: {url}', - 'dev.apiTimeout': '[reactpress] API 在 {seconds}s 内未就绪。\n → 运行 reactpress doctor 查看详情\n → 嵌入式 MySQL:reactpress docker up\n → 检查 .env 中 DB_* 与 SERVER_SITE_URL', - 'dev.apiReady': '[reactpress] API 已就绪,正在启动前端…', - 'dev.clientSlow': '[reactpress] 前端在 {seconds}s 内未响应,可能仍在编译。稍后访问 {url}', - '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,前端请单独构建。', - 'devBanner.ready': 'ReactPress 开发环境已就绪', - 'devBanner.site': '前台', - 'devBanner.admin': '管理端', - 'devBanner.api': 'API', - 'devBanner.swagger': 'Swagger', - 'devBanner.health': '健康检查', - 'devBanner.hint': '诊断: reactpress doctor · 状态: reactpress status', - 'devBanner.shortcuts': 'Ctrl+C 停止', - 'devBanner.allSystemsGo': '一切就绪', - '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.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': 'reactpress-cli', - '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.started': '[reactpress] Docker 服务已启动。', - 'docker.waitingMysql': '[reactpress] 等待 MySQL 就绪…', - 'docker.mysqlReady': '[reactpress] MySQL 已就绪。', - '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.done': '构建完成,耗时 {seconds}s', - 'build.label.toolkit': 'Toolkit', - 'build.label.server': 'API (server)', - 'build.label.client': '前端 (client)', - '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/lib/lifecycle.js b/cli/lib/lifecycle.js deleted file mode 100644 index e1c2ce7b..00000000 --- a/cli/lib/lifecycle.js +++ /dev/null @@ -1,190 +0,0 @@ -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/lib/nginx.js b/cli/lib/nginx.js deleted file mode 100644 index e056cab8..00000000 --- a/cli/lib/nginx.js +++ /dev/null @@ -1,342 +0,0 @@ -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 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}`; -} - -/** - * Write default nginx config from CLI templates when missing (or when force). - * - * @returns {{ configPath: string, created: boolean, mode: 'dev' | 'prod' }} - */ -function ensureNginxConfig(projectRoot, options = {}) { - const mode = resolveNginxMode(options); - const configPath = resolveNginxConfigPath(projectRoot, 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 result = runComposeOnContext(ctx, ['up', '-d', ctx.service]); - if (result.status !== 0) { - throw new Error(t('nginx.startFailed')); - } - } else { - startNginxContainer(configPath, port); - } - - 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'), - }; -} - -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, - nginxEntryUrl, - resolveNginxPort, - isNginxContainerRunning, - probeNginxHealth, - checkNginx, - runNginxCommand, -}; diff --git a/cli/lib/paths.js b/cli/lib/paths.js deleted file mode 100644 index 42351d46..00000000 --- a/cli/lib/paths.js +++ /dev/null @@ -1,108 +0,0 @@ -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, '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 getClientBin(projectRoot) { - const binPath = path.join( - resolveProjectRoot(projectRoot), - 'client', - 'bin', - 'reactpress-client.js' - ); - if (!fs.existsSync(binPath)) { - const err = new Error( - `Client entry not found: ${binPath}. Run from a ReactPress monorepo root or use reactpress dev --client-only with a remote API.` - ); - err.code = 'REACTPRESS_CLIENT_NOT_FOUND'; - throw err; - } - return binPath; -} - -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, - getClientBin, - getPidFile, -}; diff --git a/cli/lib/pm2.js b/cli/lib/pm2.js deleted file mode 100644 index 1473b061..00000000 --- a/cli/lib/pm2.js +++ /dev/null @@ -1,32 +0,0 @@ -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/lib/process.js b/cli/lib/process.js deleted file mode 100644 index d0f3ced1..00000000 --- a/cli/lib/process.js +++ /dev/null @@ -1,45 +0,0 @@ -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/lib/project-type.js b/cli/lib/project-type.js deleted file mode 100644 index 32e53012..00000000 --- a/cli/lib/project-type.js +++ /dev/null @@ -1,72 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -/** - * Decide whether a given directory is a ReactPress monorepo checkout (with - * editable `server/src`, `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')); -} - -/** - * @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')); -} - -/** - * @param {string} root - */ -function describeProject(root) { - const type = detectProjectType(root); - return { - type, - root, - hasClient: hasClient(root), - hasServerSource: hasServerSource(root), - hasToolkit: hasToolkit(root), - }; -} - -module.exports = { - detectProjectType, - describeProject, - hasClient, - hasServerSource, - hasToolkit, -}; diff --git a/cli/lib/publish.js b/cli/lib/publish.js deleted file mode 100644 index 976fcb5b..00000000 --- a/cli/lib/publish.js +++ /dev/null @@ -1,968 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const chalk = require('chalk'); -const inquirer = require('inquirer'); -const crypto = require('crypto'); -const { t } = require('./i18n'); -const { getMonorepoRoot } = require('./root'); - -function getWorkspaceRoot() { - const root = getMonorepoRoot(); - if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) { - return root; - } - return process.cwd(); -} - -function getPackages() { - return [ - { - name: '@fecommunity/reactpress', - path: 'cli', - description: t('publish.pkg.main') - }, - { - name: '@fecommunity/reactpress-toolkit', - path: 'toolkit', - description: 'API client and utilities toolkit' - }, - { - name: '@fecommunity/reactpress-client', - path: 'client', - description: 'Frontend application package' - }, - { - name: '@fecommunity/reactpress-server', - path: 'server', - description: t('publish.pkg.server'), - deprecated: true - }, - { - name: '@fecommunity/reactpress-template-hello-world', - path: 'templates/hello-world', - description: 'Hello World template for ReactPress' - }, - { - name: '@fecommunity/reactpress-template-twentytwentyfive', - path: 'templates/twentytwentyfive', - description: 'Twenty Twenty Five blog template for ReactPress' - } -]; -} - -const packages = getPackages(); - -// Generate a hash for a file or directory -function generateHash(filePath) { - try { - if (fs.statSync(filePath).isDirectory()) { - const files = fs.readdirSync(filePath); - const hashes = files - .filter(file => !file.startsWith('.') && file !== 'node_modules' && file !== 'dist' && file !== '.next') // Ignore hidden files and build directories - .map(file => generateHash(path.join(filePath, file))) - .sort(); - return crypto.createHash('md5').update(hashes.join('')).digest('hex'); - } else { - const content = fs.readFileSync(filePath); - return crypto.createHash('md5').update(content).digest('hex'); - } - } catch (error) { - return ''; - } -} - -// Get package content hash -function getPackageHash(packagePath) { - const fullPath = path.join(getWorkspaceRoot(), packagePath); - return generateHash(fullPath); -} - -// Check if package has meaningful changes (for build) -function hasMeaningfulChangesForBuild(packagePath, packageName) { - try { - // Create a hash file path to store previous hash in node_modules - const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.build-cache', `${packageName.replace('/', '_')}.hash`); - - // Generate current hash - const currentHash = getPackageHash(packagePath); - - // Check if we have a previous hash - if (fs.existsSync(hashFilePath)) { - const previousHash = fs.readFileSync(hashFilePath, 'utf8').trim(); - return currentHash !== previousHash; - } - - // If no previous hash, consider it as having changes - return true; - } catch (error) { - console.log(chalk.yellow(`⚠️ Could not check changes for ${packageName}, assuming changes exist`)); - return true; - } -} - -// Check if package has meaningful changes (for publish) -function hasMeaningfulChangesForPublish(packagePath, packageName) { - try { - // Create a hash file path to store previous hash in node_modules - const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.publish-cache', `${packageName.replace('/', '_')}.hash`); - - // Generate current hash - const currentHash = getPackageHash(packagePath); - - // Check if we have a previous hash - if (fs.existsSync(hashFilePath)) { - const previousHash = fs.readFileSync(hashFilePath, 'utf8').trim(); - return currentHash !== previousHash; - } - - // If no previous hash, consider it as having changes - return true; - } catch (error) { - console.log(chalk.yellow(`⚠️ Could not check changes for ${packageName}, assuming changes exist`)); - return true; - } -} - -// Save package hash (for build) -function savePackageHashForBuild(packagePath, packageName) { - try { - const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.build-cache', `${packageName.replace('/', '_')}.hash`); - - // Ensure cache directory exists - const cacheDir = path.dirname(hashFilePath); - if (!fs.existsSync(cacheDir)) { - fs.mkdirSync(cacheDir, { recursive: true }); - } - - // Generate and save current hash - const currentHash = getPackageHash(packagePath); - fs.writeFileSync(hashFilePath, currentHash); - } catch (error) { - console.log(chalk.yellow(`⚠️ Could not save hash for ${packageName}`)); - } -} - -// Save package hash (for publish) -function savePackageHashForPublish(packagePath, packageName) { - try { - const hashFilePath = path.join(getWorkspaceRoot(), 'node_modules', '.publish-cache', `${packageName.replace('/', '_')}.hash`); - - // Ensure cache directory exists - const cacheDir = path.dirname(hashFilePath); - if (!fs.existsSync(cacheDir)) { - fs.mkdirSync(cacheDir, { recursive: true }); - } - - // Generate and save current hash - const currentHash = getPackageHash(packagePath); - fs.writeFileSync(hashFilePath, currentHash); - } catch (error) { - console.log(chalk.yellow(`⚠️ Could not save hash for ${packageName}`)); - } -} - -// Get current versions -function getCurrentVersion(packagePath) { - try { - const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - return pkg.version; - } catch (error) { - return 'unknown'; - } -} - -// Increment version based on type -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': - // For beta, we increment the beta number or add beta.1 if not present - const match = version.match(/^(.*)-beta\.(\d+)$/); - if (match) { - const baseVersion = match[1]; - const betaNumber = parseInt(match[2]); - return `${baseVersion}-beta.${betaNumber + 1}`; - } else { - // If no beta version exists, add beta.1 - return `${version}-beta.1`; - } - case 'alpha': - // For alpha, we increment the alpha number or add alpha.1 if not present - const alphaMatch = version.match(/^(.*)-alpha\.(\d+)$/); - if (alphaMatch) { - const baseVersion = alphaMatch[1]; - const alphaNumber = parseInt(alphaMatch[2]); - return `${baseVersion}-alpha.${alphaNumber + 1}`; - } else { - // If no alpha version exists, add alpha.1 - return `${version}-alpha.1`; - } - default: - return version; - } -} - -// Get next available version from npm registry -function getNextAvailableVersion(packageName, currentVersion, versionType) { - try { - // First, increment the version locally - let nextVersion = incrementVersion(currentVersion, versionType); - - // Check if this version already exists on npm - let versionExists = true; - let attempts = 0; - const maxAttempts = 100; // Prevent infinite loop - - while (versionExists && attempts < maxAttempts) { - try { - execSync(`npm view ${packageName}@${nextVersion} version`, { stdio: 'ignore' }); - // If we get here, the version exists, so we need to increment again - nextVersion = incrementVersion(nextVersion, versionType); - attempts++; - } catch (error) { - // If we get an error, the version doesn't exist, which is what we want - versionExists = false; - } - } - - if (attempts >= maxAttempts) { - throw new Error('Too many attempts to find available version'); - } - - return nextVersion; - } catch (error) { - // Fallback to simple increment if npm view fails - console.log(chalk.yellow(`⚠️ Could not check npm registry, using local increment for ${packageName}`)); - return incrementVersion(currentVersion, versionType); - } -} - -// Update package version -function updateVersion(packagePath, newVersion) { - console.log(chalk.blue(`\n✏️ Updating version to ${newVersion}...`)); - - const pkgPath = path.join(getWorkspaceRoot(), 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(`✅ Version updated from ${oldVersion} to ${newVersion}`)); -} - -// Fix workspace dependencies for build -function fixWorkspaceDependenciesForBuild(packagePath) { - console.log(chalk.blue(`🔧 Fixing workspace dependencies for build: ${packagePath}...`)); - - const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - - // Fix dependencies - const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; - - depTypes.forEach(depType => { - if (pkg[depType]) { - Object.keys(pkg[depType]).forEach(depName => { - // Check if it's a workspace dependency - if (pkg[depType][depName] === 'workspace:*' || pkg[depType][depName].startsWith('workspace:')) { - // For build purposes, we'll use the file: protocol to reference local packages - const depPackage = packages.find(p => p.name === depName); - if (depPackage) { - console.log(chalk.gray(` Replacing ${depName} workspace dependency with file reference`)); - pkg[depType][depName] = `file:../${depPackage.path}`; - } - } - }); - } - }); - - // Write the updated package.json - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(chalk.green(`✅ Workspace dependencies fixed for build: ${packagePath}`)); -} - -// Restore workspace dependencies after build -function restoreWorkspaceDependenciesAfterBuild(packagePath) { - console.log(chalk.blue(`🔄 Restoring workspace dependencies after build: ${packagePath}...`)); - - const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - - // Restore dependencies - const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; - - depTypes.forEach(depType => { - if (pkg[depType]) { - Object.keys(pkg[depType]).forEach(depName => { - // Check if this is one of our internal packages referenced with file: - const depPackage = packages.find(p => p.name === depName); - if (depPackage && pkg[depType][depName].startsWith('file:')) { - console.log(chalk.gray(` Restoring ${depName} to workspace dependency`)); - pkg[depType][depName] = 'workspace:*'; - } - }); - } - }); - - // Write the updated package.json - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(chalk.green(`✅ Workspace dependencies restored after build: ${packagePath}`)); -} - -// Fix workspace dependencies for publish -function fixWorkspaceDependenciesForPublish(packagePath, packageVersions) { - console.log(chalk.blue(`🔧 Fixing workspace dependencies for publish: ${packagePath}...`)); - - const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - - // Fix dependencies - const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; - - depTypes.forEach(depType => { - if (pkg[depType]) { - Object.keys(pkg[depType]).forEach(depName => { - // Check if it's a workspace dependency - if (pkg[depType][depName] === 'workspace:*' || pkg[depType][depName].startsWith('workspace:')) { - // Replace with actual version - const depPackage = packages.find(p => p.name === depName); - if (depPackage && packageVersions[depName]) { - console.log(chalk.gray(` Replacing ${depName} workspace dependency with version ${packageVersions[depName]}`)); - pkg[depType][depName] = packageVersions[depName]; - } - } - }); - } - }); - - // Write the updated package.json - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(chalk.green(`✅ Workspace dependencies fixed for publish: ${packagePath}`)); -} - -// Restore workspace dependencies after publish -function restoreWorkspaceDependenciesAfterPublish(packagePath) { - console.log(chalk.blue(`🔄 Restoring workspace dependencies for ${packagePath}...`)); - - const pkgPath = path.join(getWorkspaceRoot(), packagePath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - - // Restore dependencies - const depTypes = ['dependencies', 'devDependencies', 'peerDependencies']; - - depTypes.forEach(depType => { - if (pkg[depType]) { - Object.keys(pkg[depType]).forEach(depName => { - // Check if this is one of our internal packages - const depPackage = packages.find(p => p.name === depName); - if (depPackage) { - console.log(chalk.gray(` Restoring ${depName} to workspace dependency`)); - pkg[depType][depName] = 'workspace:*'; - } - }); - } - }); - - // Write the updated package.json - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(chalk.green(`✅ Workspace dependencies restored for ${packagePath}`)); -} - -// Build package -function buildPackage(pkg) { - console.log(chalk.blue(`\n🔨 Building ${pkg.name} (${pkg.description})...`)); - - try { - // Fix workspace dependencies for build - fixWorkspaceDependenciesForBuild(pkg.path); - - try { - const pkgDir = path.join(getWorkspaceRoot(), pkg.path); - if (pkg.path === 'cli') { - execSync('node scripts/sync-bundled-core.mjs', { cwd: pkgDir, stdio: 'inherit' }); - if (fs.existsSync(path.join(pkgDir, 'server', 'package.json'))) { - execSync('pnpm run build', { cwd: path.join(pkgDir, 'server'), stdio: 'inherit' }); - } - } else if (pkg.path === 'server') { - execSync('pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); - } else if (pkg.path === 'client') { - execSync('pnpm run prebuild && pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); - } else if (pkg.path === 'toolkit') { - execSync('pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); - } else if (pkg.path === 'templates/hello-world' || pkg.path === 'templates/twentytwentyfive') { - console.log(chalk.gray(' Templates do not require building, skipping...')); - } else if (fs.existsSync(path.join(pkgDir, 'package.json'))) { - execSync('pnpm run build', { cwd: pkgDir, stdio: 'inherit' }); - } - console.log(chalk.green(`✅ ${pkg.name} built successfully`)); - } finally { - // Always restore workspace dependencies - restoreWorkspaceDependenciesAfterBuild(pkg.path); - } - } catch (error) { - console.log(chalk.red(`❌ Failed to build ${pkg.name}`)); - throw error; - } -} - -// Publish package -function publishPackage(packagePath, packageName, tag = 'latest') { - console.log(chalk.blue(`\n🚀 Publishing ${packageName} with tag ${tag}...`)); - - try { - const command = `pnpm publish --access public --tag ${tag} --registry https://registry.npmjs.org --no-git-checks`; - execSync(command, { cwd: path.join(getWorkspaceRoot(), packagePath), stdio: 'inherit' }); - console.log(chalk.green(`✅ ${packageName} published successfully!`)); - } catch (error) { - console.log(chalk.red(`❌ Failed to publish ${packageName}`)); - throw error; - } -} - -// Create GitHub release -function createGitHubRelease(tagName, releaseNotes) { - console.log(chalk.blue(`\n📝 Creating GitHub release ${tagName}...`)); - - try { - // Create release using GitHub CLI if available - const command = `gh release create ${tagName} --title "${tagName}" --notes "${releaseNotes}"`; - execSync(command, { stdio: 'inherit' }); - console.log(chalk.green(`✅ GitHub release ${tagName} created successfully!`)); - } catch (error) { - console.log(chalk.yellow(`⚠️ Failed to create GitHub release (GitHub CLI may not be installed or configured)`)); - console.log(chalk.gray('You can manually create the release at: https://github.com/fecommunity/reactpress/releases/new')); - } -} - -// Check environment -function checkEnvironment() { - // Check if pnpm is installed - try { - execSync('pnpm --version', { stdio: 'ignore' }); - } catch (error) { - console.log(chalk.red('❌ pnpm is not installed. Please install pnpm first.')); - return false; - } - - // Check if logged in to npm - try { - execSync('pnpm whoami --registry https://registry.npmjs.org', { stdio: 'ignore' }); - } catch (error) { - console.log(chalk.red('❌ Not logged in to npm. Please run "pnpm login --registry https://registry.npmjs.org" first.')); - return false; - } - - return true; -} - -// Build packages function -async function buildPackages() { - console.log(chalk.blue('🏗️ ReactPress Package Builder\n')); - - // Show current versions - console.log(chalk.cyan('📋 Current package versions:')); - packages.forEach(pkg => { - const version = getCurrentVersion(pkg.path); - console.log(chalk.gray(` ${pkg.name}: ${version}`)); - }); - console.log(); - - try { - // Track which packages actually need to be built - const packagesToBuild = []; - - // Check for meaningful changes in each package - for (const pkg of packages) { - if (fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { - if (hasMeaningfulChangesForBuild(pkg.path, pkg.name)) { - packagesToBuild.push(pkg); - console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be built`)); - } else { - console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); - } - } else { - console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); - } - } - - if (packagesToBuild.length === 0) { - console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to build!')); - return; - } - - // Build packages that have changes - for (const pkg of packagesToBuild) { - await buildPackage(pkg); - // Save the hash after successful build - savePackageHashForBuild(pkg.path, pkg.name); - } - - console.log(chalk.green(`\n🎉 ${packagesToBuild.length} package(s) built successfully!`)); - } catch (error) { - console.error(chalk.red('❌ Build failed:'), error); - process.exit(1); - } -} - -// Publish packages function -async function publishPackages() { - // Check if called with --no-build flag - const noBuild = process.argv.includes('--no-build'); - - console.log(chalk.blue('📦 ReactPress Package Publisher\n')); - - // Run environment checks - if (!checkEnvironment()) { - process.exit(1); - } - - // Show current versions - console.log(chalk.cyan('📋 Current package versions:')); - packages.forEach(pkg => { - const version = getCurrentVersion(pkg.path); - console.log(chalk.gray(` ${pkg.name}: ${version}`)); - }); - console.log(); - - // Ask for publishing options - const { action } = await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: 'What would you like to do?', - choices: [ - { name: '🚀 Publish all packages with version bump', value: 'publish-all' }, - { name: '📦 Publish specific package', value: 'publish-one' }, - { name: '🔨 Build all packages only', value: 'build-all' }, - { name: '🏷️ Publish as beta/alpha', value: 'publish-prerelease' }, - { name: '❌ Cancel', value: 'cancel' } - ] - } - ]); - - if (action === 'cancel') { - console.log(chalk.yellow('Operation cancelled.')); - return; - } - - if (action === 'build-all') { - console.log(chalk.blue('🔨 Building all packages...\n')); - - // Track which packages actually need to be built - const packagesToBuild = []; - - // Check for meaningful changes in each package - for (const pkg of packages) { - if (fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { - if (hasMeaningfulChangesForPublish(pkg.path, pkg.name)) { - packagesToBuild.push(pkg); - console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be built`)); - } else { - console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); - } - } else { - console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); - } - } - - if (packagesToBuild.length === 0) { - console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to build!')); - return; - } - - for (const pkg of packagesToBuild) { - await buildPackage(pkg); - savePackageHashForPublish(pkg.path, pkg.name); - } - - console.log(chalk.green(`\n🎉 ${packagesToBuild.length} package(s) built successfully!`)); - return; - } - - if (action === 'publish-one') { - const { selectedPackage } = await inquirer.prompt([ - { - type: 'list', - name: 'selectedPackage', - message: 'Which package would you like to publish?', - choices: packages.map(pkg => ({ - name: `${pkg.name} (${pkg.description})`, - value: pkg - })) - } - ]); - - // Check if the selected package has meaningful changes - if (!hasMeaningfulChangesForPublish(selectedPackage.path, selectedPackage.name)) { - console.log(chalk.gray(`\n⏭️ ${selectedPackage.name} has no meaningful changes, skipping...`)); - console.log(chalk.green('✅ Nothing to publish!')); - return; - } - - const { versionType } = await inquirer.prompt([ - { - type: 'list', - name: 'versionType', - message: 'Version bump type:', - choices: [ - { name: 'Beta (1.0.0-beta.1 -> 1.0.0-beta.2)', value: 'beta' }, - { name: 'Patch (1.0.0 -> 1.0.1)', value: 'patch' }, - { name: 'Minor (1.0.0 -> 1.1.0)', value: 'minor' }, - { name: 'Major (1.0.0 -> 2.0.0)', value: 'major' }, - { name: 'Custom version', value: 'custom' } - ] - } - ]); - - let newVersion; - const currentVersion = getCurrentVersion(selectedPackage.path); - - if (versionType === 'custom') { - const { customVersion } = await inquirer.prompt([ - { - type: 'input', - name: 'customVersion', - message: `Enter new version for ${selectedPackage.name} (current: ${currentVersion}):`, - validate: (input) => { - const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/; - return semverRegex.test(input) || 'Please enter a valid semver version (e.g., 1.0.0)'; - } - } - ]); - newVersion = customVersion; - } else { - newVersion = getNextAvailableVersion(selectedPackage.name, currentVersion, versionType); - } - - // Get all package versions for dependency resolution - const packageVersions = {}; - packages.forEach(pkg => { - packageVersions[pkg.name] = getCurrentVersion(pkg.path); - }); - // Update the selected package version - packageVersions[selectedPackage.name] = newVersion; - - // Fix workspace dependencies before publishing - fixWorkspaceDependenciesForPublish(selectedPackage.path, packageVersions); - - try { - updateVersion(selectedPackage.path, newVersion); - // Only build if not disabled - if (!noBuild) { - buildPackage(selectedPackage); - } - - // Determine tag based on version type - const tag = versionType === 'beta' ? 'beta' : 'latest'; - publishPackage(selectedPackage.path, selectedPackage.name, tag); - - // Save the hash after successful publish - savePackageHashForPublish(selectedPackage.path, selectedPackage.name); - - console.log(chalk.green(`\n🎉 ${selectedPackage.name} v${newVersion} published successfully!`)); - } finally { - // Always restore workspace dependencies - restoreWorkspaceDependenciesAfterPublish(selectedPackage.path); - } - - return; - } - - if (action === 'publish-prerelease') { - const { tag } = await inquirer.prompt([ - { - type: 'list', - name: 'tag', - message: 'Select prerelease tag:', - choices: ['beta', 'alpha', 'rc', 'next'] - } - ]); - - const { versionType } = await inquirer.prompt([ - { - type: 'list', - name: 'versionType', - message: 'Version bump type:', - choices: [ - { name: `Prerelease (${tag})`, value: tag }, - { name: 'Patch (1.0.0 -> 1.0.1)', value: 'patch' }, - { name: 'Minor (1.0.0 -> 1.1.0)', value: 'minor' }, - { name: 'Major (1.0.0 -> 2.0.0)', value: 'major' }, - { name: 'Custom version', value: 'custom' } - ] - } - ]); - - // Get all package versions for dependency resolution - const packageVersions = {}; - packages.forEach(pkg => { - const currentVersion = getCurrentVersion(pkg.path); - packageVersions[pkg.name] = currentVersion; - }); - - // Track which packages actually need to be published - const packagesToPublish = []; - - // Check for meaningful changes in each package - for (const pkg of packages) { - if (!fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { - console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); - continue; - } - - if (hasMeaningfulChangesForPublish(pkg.path, pkg.name)) { - packagesToPublish.push(pkg); - console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be published`)); - } else { - console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); - } - } - - if (packagesToPublish.length === 0) { - console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to publish!')); - return; - } - - // Process each package that has changes - for (const pkg of packagesToPublish) { - let newVersion; - const currentVersion = getCurrentVersion(pkg.path); - - if (versionType === 'custom') { - const { customVersion } = await inquirer.prompt([ - { - type: 'input', - name: 'customVersion', - message: `Enter version for ${pkg.name} (current: ${currentVersion}):`, - default: currentVersion - } - ]); - newVersion = customVersion; - } else { - newVersion = getNextAvailableVersion(pkg.name, currentVersion, versionType); - } - - // Update package version in our tracking - packageVersions[pkg.name] = newVersion; - - // Fix workspace dependencies before publishing - fixWorkspaceDependenciesForPublish(pkg.path, packageVersions); - - try { - updateVersion(pkg.path, newVersion); - // Only build if not disabled - if (!noBuild) { - buildPackage(pkg); - } - publishPackage(pkg.path, pkg.name, tag); - - // Save the hash after successful publish - savePackageHashForPublish(pkg.path, pkg.name); - } finally { - // Always restore workspace dependencies - restoreWorkspaceDependenciesAfterPublish(pkg.path); - } - } - - console.log(chalk.green(`\n🎉 ${packagesToPublish.length} package(s) published with ${tag} tag!`)); - return; - } - - if (action === 'publish-all') { - // Check if we're on master branch for final release - let isMasterBranch = false; - try { - const branch = execSync('git branch --show-current', { encoding: 'utf8' }).trim(); - isMasterBranch = branch === 'master' || branch === 'main'; - } catch (error) { - console.log(chalk.yellow('⚠️ Unable to determine current branch')); - } - - const { versionType } = await inquirer.prompt([ - { - type: 'list', - name: 'versionType', - message: 'Version bump type:', - choices: [ - { name: `Beta ${isMasterBranch ? '(will publish as final)' : '(will publish as beta)'}`, value: 'beta' }, - { name: 'Patch (1.0.0 -> 1.0.1)', value: 'patch' }, - { name: 'Minor (1.0.0 -> 1.1.0)', value: 'minor' }, - { name: 'Major (1.0.0 -> 2.0.0)', value: 'major' }, - { name: 'Custom version', value: 'custom' } - ] - } - ]); - - // Get all package versions for dependency resolution - const packageVersions = {}; - const originalVersions = {}; - - packages.forEach(pkg => { - const currentVersion = getCurrentVersion(pkg.path); - originalVersions[pkg.name] = currentVersion; - packageVersions[pkg.name] = currentVersion; - }); - - let baseVersion; - if (versionType === 'custom') { - const { customVersion } = await inquirer.prompt([ - { - type: 'input', - name: 'customVersion', - message: 'Enter new version for all packages:', - validate: (input) => { - const semverRegex = /^\d+\.\d+\.\d+$/; - return semverRegex.test(input) || 'Please enter a valid semver version (e.g., 1.0.0)'; - } - } - ]); - baseVersion = customVersion; - } else { - // Use the highest current version as base and increment - const nextVersion = getNextAvailableVersion(packages[0].name, originalVersions[packages[0].name], versionType); - baseVersion = nextVersion; - } - - console.log(chalk.cyan(`\n📋 Will publish all packages with version: ${baseVersion}\n`)); - - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: 'Are you sure you want to proceed?', - default: false - } - ]); - - if (!confirm) { - console.log(chalk.yellow('Operation cancelled.')); - return; - } - - // Track which packages actually need to be published - const packagesToPublish = []; - - // Check for meaningful changes in each package - for (const pkg of packages) { - if (!fs.existsSync(path.join(getWorkspaceRoot(), pkg.path))) { - console.log(chalk.yellow(`⚠️ Package ${pkg.name} directory not found, skipping...`)); - continue; - } - - if (hasMeaningfulChangesForPublish(pkg.path, pkg.name)) { - packagesToPublish.push(pkg); - console.log(chalk.blue(`\n📦 ${pkg.name} has changes, will be published`)); - } else { - console.log(chalk.gray(`\n⏭️ ${pkg.name} has no meaningful changes, skipping...`)); - } - } - - if (packagesToPublish.length === 0) { - console.log(chalk.green('\n✅ No packages have meaningful changes. Nothing to publish!')); - return; - } - - // Update versions, build and publish only packages with changes - for (const pkg of packagesToPublish) { - console.log(chalk.blue(`\n📦 Processing ${pkg.name}...`)); - - // For publish-all, we use the same version for all packages - const pkgVersion = baseVersion; - packageVersions[pkg.name] = pkgVersion; - - // Fix workspace dependencies before publishing - fixWorkspaceDependenciesForPublish(pkg.path, packageVersions); - - try { - updateVersion(pkg.path, pkgVersion); - // Only build if not disabled - if (!noBuild) { - buildPackage(pkg); - } - - // Determine tag based on version type and branch - const tag = (versionType === 'beta' && !isMasterBranch) ? 'beta' : 'latest'; - publishPackage(pkg.path, pkg.name, tag); - - // Save the hash after successful publish - savePackageHashForPublish(pkg.path, pkg.name); - } finally { - // Always restore workspace dependencies - restoreWorkspaceDependenciesAfterPublish(pkg.path); - } - } - - // Create GitHub release if on master and we actually published something - if (isMasterBranch && packagesToPublish.length > 0) { - const tagName = `v${baseVersion}`; - const releaseNotes = `Release ${baseVersion} - -Packages released: -${Object.entries(packageVersions).map(([name, version]) => `- ${name}@${version}`).join('\n')}`; - createGitHubRelease(tagName, releaseNotes); - } - - console.log(chalk.green(`\n🎉 ${packagesToPublish.length} package(s) published successfully with version ${baseVersion}!`)); - console.log(chalk.cyan('\n📋 Next steps:')); - console.log(chalk.gray('1. Create a git tag: git tag v' + baseVersion)); - console.log(chalk.gray('2. Push changes: git push && git push --tags')); - } -} - -// Main function -async function main() { - // Check command line arguments - const args = process.argv.slice(2); - - if (args.includes('--publish')) { - // When called with --publish, start the publish process - console.log(chalk.blue('🏗️ ReactPress Package Builder\n')); - - // Show current versions - console.log(chalk.cyan('📋 Current package versions:')); - packages.forEach(pkg => { - const version = getCurrentVersion(pkg.path); - console.log(chalk.gray(` ${pkg.name}: ${version}`)); - }); - console.log(); - - // Start the publish process - await publishPackages(); - } else if (args.includes('--build')) { - // When called with --build, just build packages - await buildPackages(); - } else { - // Default behavior - show help - console.log(chalk.blue('🏗️ ReactPress CLI\n')); - console.log('Usage:'); - console.log(' reactpress publish --build Build all packages'); - console.log(' reactpress publish --publish Publish packages (interactive)'); - console.log(''); - } -} - -module.exports = { main, buildPackages, publishPackages }; - -if (require.main === module) { - main().catch((error) => { - console.error(chalk.red('❌ Operation failed:'), error); - process.exit(1); - }); -} \ No newline at end of file diff --git a/cli/lib/root.js b/cli/lib/root.js deleted file mode 100644 index cfb53a79..00000000 --- a/cli/lib/root.js +++ /dev/null @@ -1,88 +0,0 @@ -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/lib/spawn.js b/cli/lib/spawn.js deleted file mode 100644 index 4753e978..00000000 --- a/cli/lib/spawn.js +++ /dev/null @@ -1,92 +0,0 @@ -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: '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; -} - -function runReactpressCli(args, options = {}) { - const cliBin = path.join(getCliPackageRoot(), 'dist', 'index.js'); - return runSync(process.execPath, [cliBin, ...args], options); -} - -function resolveCliScript(relativePath) { - return path.join(__dirname, '..', relativePath); -} - -module.exports = { - runSync, - runNodeScript, - spawnDetached, - runReactpressCli, - resolveCliScript, -}; diff --git a/cli/lib/status.js b/cli/lib/status.js deleted file mode 100644 index a14ce81d..00000000 --- a/cli/lib/status.js +++ /dev/null @@ -1,132 +0,0 @@ -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/package.json b/cli/package.json index 106be0e5..ac8cfb6a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@fecommunity/reactpress", - "version": "3.7.0", - "description": "ReactPress 3.0 — zero-config CMS: one package, one reactpress command", + "version": "4.0.0-beta.0", + "description": "ReactPress 4.0 — zero-config CMS: CLI, plugins, desktop, MySQL & SQLite", "author": "fecommunity", "license": "MIT", "repository": { @@ -22,11 +22,10 @@ "reactpress": "./bin/reactpress.js", "reactpress-cli": "./bin/reactpress-cli-shim.js" }, - "main": "./bin/reactpress.js", + "main": "./out/bin/reactpress.js", "files": [ "bin", - "lib", - "ui", + "out", "dist", "server", "templates", @@ -35,9 +34,11 @@ "LICENSE" ], "scripts": { - "test": "node --test tests", - "prepare": "node scripts/sync-bundled-core.mjs", - "prepack": "node scripts/sync-bundled-core.mjs && node scripts/sync-monorepo-server.mjs" + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "pnpm run build && node --test tests", + "prepare": "node scripts/sync-bundled-core.mjs && pnpm run build", + "prepack": "node scripts/sync-bundled-core.mjs && node scripts/sync-monorepo-server.mjs && pnpm run build" }, "publishConfig": { "access": "public", @@ -57,6 +58,11 @@ "ora": "^5.4.1" }, "devDependencies": { - "@fecommunity/reactpress-cli-core": "npm:@fecommunity/reactpress-cli@0.1.0" + "@fecommunity/reactpress-cli-core": "npm:@fecommunity/reactpress-cli@0.1.0", + "@types/cross-spawn": "^6.0.6", + "@types/fs-extra": "^11.0.4", + "@types/inquirer": "^8.2.10", + "@types/node": "^24.5.2", + "typescript": "~5.9.3" } } diff --git a/cli/scripts/sync-bundled-core.mjs b/cli/scripts/sync-bundled-core.mjs index 68021882..d5831a59 100644 --- a/cli/scripts/sync-bundled-core.mjs +++ b/cli/scripts/sync-bundled-core.mjs @@ -72,7 +72,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); -const { t, getLocale, setLocale } = require(join(dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'i18n', 'index.js')); +const { t, getLocale, setLocale } = require(join(dirname(fileURLToPath(import.meta.url)), '..', 'out', 'lib', 'i18n', 'index.js')); export { t, getLocale, setLocale }; ` diff --git a/cli/server/public/favicon.png b/cli/server/public/favicon.png index f455437f..521edf80 100644 Binary files a/cli/server/public/favicon.png and b/cli/server/public/favicon.png differ diff --git a/themes/my-blog/public/apple-touch-icon.png b/cli/server/public/uploads/2026-05-30/QR3X4UZQ93ZTUPGXCFA9II..png similarity index 100% rename from themes/my-blog/public/apple-touch-icon.png rename to cli/server/public/uploads/2026-05-30/QR3X4UZQ93ZTUPGXCFA9II..png diff --git a/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp new file mode 100644 index 00000000..95aa0a7b Binary files /dev/null and b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8.webp differ diff --git a/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp new file mode 100644 index 00000000..a8eb1d19 Binary files /dev/null and b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_medium.webp differ diff --git a/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp new file mode 100644 index 00000000..83366634 Binary files /dev/null and b/cli/server/public/uploads/2026-05-31/BXQV3NX9ZVWHGNOSX9AXD8_thumb.webp differ diff --git a/cli/server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg b/cli/server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg new file mode 100644 index 00000000..75bdbac7 Binary files /dev/null and b/cli/server/public/uploads/2026-05-31/I2SZC0ONA7CF31UM46MN56..jpg differ diff --git a/cli/src/bin/reactpress-cli-shim.ts b/cli/src/bin/reactpress-cli-shim.ts new file mode 100644 index 00000000..f6c924be --- /dev/null +++ b/cli/src/bin/reactpress-cli-shim.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// @ts-nocheck + +/** + * @deprecated 3.0 起请使用 `reactpress`(@fecommunity/reactpress)。3.1 将移除此 bin。 + */ +const chalk = require('chalk'); +const { t } = require('../lib/i18n'); + +function mapLegacyArgv(argv) { + const [cmd, ...rest] = argv; + if (cmd === 'start') return ['server', 'start', ...rest]; + if (cmd === 'stop') return ['server', 'stop', ...rest]; + if (cmd === 'restart') return ['server', 'restart', ...rest]; + if (cmd === 'status') return ['server', 'status', ...rest]; + return argv; +} + +if (!process.env.REACTPRESS_SUPPRESS_DEPRECATION) { + console.warn( + chalk.yellow( + t('shim.deprecated') + ) + ); +} + +const mapped = mapLegacyArgv(process.argv.slice(2)); +process.argv = [process.argv[0], process.argv[1], ...mapped]; +require('./reactpress.js'); diff --git a/cli/src/bin/reactpress-theme-client.ts b/cli/src/bin/reactpress-theme-client.ts new file mode 100644 index 00000000..87fb35d7 --- /dev/null +++ b/cli/src/bin/reactpress-theme-client.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// @ts-nocheck + +/** + * Generic ReactPress theme client launcher (for themes without bin/reactpress-client.js). + * Set REACTPRESS_THEME_DIR to the active theme package root. + */ + +const path = require('path'); +const fs = require('fs'); +const { spawn, spawnSync } = require('child_process'); +const { hasUsableProductionBuild } = require('../lib/theme-prod'); +const { getPm2ClientMemoryRestart, resolveBuildNodeEnv } = require('../lib/prod-memory'); +const { readActiveThemeManifest } = require('../lib/theme-runtime'); + +const originalCwd = process.env.REACTPRESS_ORIGINAL_CWD || process.cwd(); +const args = process.argv.slice(2); +const usePM2 = args.includes('--pm2'); + +const clientDir = process.env.REACTPRESS_THEME_DIR + ? path.resolve(process.env.REACTPRESS_THEME_DIR) + : null; + +if (!clientDir || !fs.existsSync(path.join(clientDir, 'package.json'))) { + console.error('[ReactPress Client] REACTPRESS_THEME_DIR must point to a theme package'); + process.exit(1); +} + +const nextDir = path.join(clientDir, '.next'); +const startScript = fs.existsSync(path.join(clientDir, 'server.js')) ? 'server.js' : null; + +function runStartCommand() { + if (startScript) { + return ['node', [startScript]]; + } + return ['npm', ['run', 'start']]; +} + +function ensureBuilt() { + const { activeTheme } = readActiveThemeManifest(originalCwd); + const themeId = process.env.REACTPRESS_THEME_ID || activeTheme || path.basename(clientDir); + if (hasUsableProductionBuild(clientDir, themeId)) return; + console.log('[ReactPress Client] Client not built yet. Building…'); + const build = spawnSync('pnpm', ['run', 'build'], { + stdio: 'inherit', + cwd: clientDir, + env: resolveBuildNodeEnv({ ...process.env, REACTPRESS_BUILD_ACTIVE: '1' }), + }); + if (build.status !== 0) process.exit(build.status || 1); +} + +function startWithPm2() { + ensureBuilt(); + const [cmd, cmdArgs] = runStartCommand(); + const visitorPort = process.env.CLIENT_PORT || process.env.PORT || '3001'; + const apiPort = process.env.SERVER_PORT || '3002'; + const nginxEntry = (process.env.REACTPRESS_NGINX_ENTRY_URL || process.env.NGINX_ENTRY_URL || '') + .replace(/\/$/, ''); + const pm2Env = { + ...process.env, + NODE_ENV: 'production', + PORT: String(visitorPort), + CLIENT_PORT: String(visitorPort), + REACTPRESS_ORIGINAL_CWD: originalCwd, + REACTPRESS_THEME_DIR: clientDir, + REACTPRESS_API_URL: process.env.REACTPRESS_API_URL || `http://127.0.0.1:${apiPort}/api`, + SERVER_API_URL: process.env.SERVER_API_URL || `http://127.0.0.1:${apiPort}/api`, + NEXT_PUBLIC_REACTPRESS_API_URL: + process.env.NEXT_PUBLIC_REACTPRESS_API_URL || + (nginxEntry ? `${nginxEntry}/api` : `http://127.0.0.1:${apiPort}/api`), + ...(nginxEntry + ? { REACTPRESS_NGINX_ENTRY_URL: nginxEntry, NGINX_ENTRY_URL: nginxEntry } + : { REACTPRESS_SKIP_DEV_PORT_REDIRECT: '1' }), + }; + + const pm2Mem = getPm2ClientMemoryRestart(); + const pm2Args = + cmd === 'node' + ? [ + 'start', + cmd, + '--name', + 'reactpress-client', + '--update-env', + '--max-memory-restart', + pm2Mem, + '--', + ...cmdArgs, + ] + : [ + 'start', + cmd, + '--name', + 'reactpress-client', + '--update-env', + '--max-memory-restart', + pm2Mem, + '--', + ...cmdArgs, + ]; + + const child = spawn('pm2', pm2Args, { stdio: 'inherit', cwd: clientDir, env: pm2Env }); + child.on('close', (code) => process.exit(code ?? 0)); + child.on('error', (err) => { + console.error('[ReactPress Client] PM2 failed:', err); + process.exit(1); + }); +} + +function startWithNode() { + ensureBuilt(); + process.chdir(clientDir); + const [cmd, cmdArgs] = runStartCommand(); + const child = spawn(cmd, cmdArgs, { stdio: 'inherit', cwd: clientDir, env: process.env }); + child.on('close', (code) => process.exit(code ?? 0)); +} + +if (usePM2) { + startWithPm2(); +} else { + startWithNode(); +} diff --git a/cli/src/bin/reactpress.ts b/cli/src/bin/reactpress.ts new file mode 100755 index 00000000..aeef2e4c --- /dev/null +++ b/cli/src/bin/reactpress.ts @@ -0,0 +1,599 @@ +#!/usr/bin/env node +// @ts-nocheck + +/** + * ReactPress unified CLI — init, dev, build, server, docker, publish. + * Run without arguments for an interactive menu (Claude Code–style). + */ + +const { Command } = require('commander'); +const path = require('path'); +const chalk = require('chalk'); +const { brand, divider } = require('../ui/theme'); +const { ensureOriginalCwd } = require('../lib/root'); +const { ensureProjectEnvironment, initMonorepoProject } = require('../lib/bootstrap'); +const { runDev, runWebDev, runLocalWebDev, runLocalMonorepoDev, runThemeDev } = require('../lib/dev'); +const { resolveDevApiOrigins, applyDevApiOriginsToEnv } = require('../lib/remote-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 } = require('../lib/build'); +const { startApiWithPm2 } = require('../lib/pm2'); +const { runNodeScript, runReactpressCli } = require('../lib/spawn'); +const { getThemeBin } = require('../lib/paths'); +const { runInteractiveLoop } = require('../ui/interactive'); +const { t } = require('../lib/i18n'); + +const rootPkg = require(path.join(__dirname, '..', '..', 'package.json')); + +const program = new Command(); + +program + .name('reactpress') + .description(t('cli.description')) + .version(rootPkg.version); + +program + .command('init') + .description(t('cli.init.description')) + .argument('[directory]', t('cli.init.directory'), '.') + .option('-f, --force', t('cli.init.force')) + .option('--local', t('cli.init.local')) + .action(async (directory, options) => { + const projectRoot = path.resolve(directory); + process.env.REACTPRESS_ORIGINAL_CWD = projectRoot; + const { isMonorepoCheckout } = require('../lib/bootstrap'); + if (isMonorepoCheckout(projectRoot)) { + const result = await initMonorepoProject(projectRoot, { + force: !!options.force, + local: !!options.local, + }); + console.log(`[reactpress] ${result.message}`); + process.exit(result.ok ? 0 : 1); + return; + } + const args = ['init', directory]; + if (options.force) args.push('--force'); + if (options.local) args.push('--local'); + await runReactpressCli(args, { cwd: projectRoot }); + }); + +program + .command('dev') + .description(t('cli.dev.description')) + .option('--api-only', t('cli.dev.apiOnly')) + .option('--client-only', t('cli.dev.clientOnly')) + .option('--web-only', t('cli.dev.webOnly')) + .option('--remote-origin ', t('cli.dev.remoteOrigin')) + .option('--admin-origin ', t('cli.dev.adminOrigin')) + .option('--client-origin ', t('cli.dev.clientOrigin')) + .option('--local', t('cli.dev.local')) + .action(async (options) => { + const projectRoot = ensureOriginalCwd(); + if (options.local) { + process.env.REACTPRESS_LOCAL_MODE = '1'; + process.env.REACTPRESS_SKIP_NGINX = '1'; + } + try { + const hasOriginFlag = + options.remoteOrigin !== undefined || + options.adminOrigin !== undefined || + options.clientOrigin !== undefined; + + let apiOrigins = { admin: null, client: null, needsLocalApi: true }; + if (hasOriginFlag) { + const resolved = resolveDevApiOrigins(projectRoot, { + remoteOrigin: options.remoteOrigin, + adminOrigin: options.adminOrigin, + clientOrigin: options.clientOrigin, + }); + if (resolved.error === 'REMOTE_DEFAULT_REQUIRED') { + console.error(chalk.red('[reactpress]'), t('cli.dev.remoteDefaultRequired')); + process.exit(1); + } + if (resolved.error === 'INVALID_ORIGIN') { + console.error(chalk.red('[reactpress]'), t('cli.dev.invalidOrigin')); + process.exit(1); + } + if ( + options.remoteOrigin !== undefined && + !resolved.remoteDefault && + options.adminOrigin === undefined && + options.clientOrigin === undefined + ) { + console.error(chalk.red('[reactpress]'), t('cli.dev.remoteOriginRequired')); + process.exit(1); + } + apiOrigins = resolved; + applyDevApiOriginsToEnv(apiOrigins); + } + + if (options.clientOnly) { + await runThemeDev(projectRoot, { apiOrigins }); + return; + } + if (options.webOnly) { + if (options.local) { + await runLocalWebDev(projectRoot, { apiOrigins }); + return; + } + await runWebDev(projectRoot, { apiOrigins }); + return; + } + if (options.apiOnly) { + if (!apiOrigins.needsLocalApi) { + console.error(chalk.red('[reactpress]'), t('cli.dev.remoteOriginIncompatibleApiOnly')); + process.exit(1); + } + await runApiDev(projectRoot); + return; + } + if (options.local) { + const { isMonorepoCheckout } = require('../lib/root'); + const { hasWeb } = require('../lib/project-type'); + if (isMonorepoCheckout(projectRoot) && hasWeb(projectRoot)) { + await runLocalMonorepoDev(projectRoot); + return; + } + } + await runDev(projectRoot, { apiOrigins }); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(err.exitCode ?? 1); + } + }); + +const desktopCmd = program.command('desktop').description(t('cli.desktopDev.description')); + +desktopCmd + .command('dev') + .description(t('cli.desktopDev.description')) + .action(async () => { + const projectRoot = ensureOriginalCwd(); + const script = path.join(projectRoot, 'desktop/scripts/dev-full.mjs'); + try { + const { spawn } = require('child_process'); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script], { + cwd: projectRoot, + stdio: 'inherit', + env: { + ...process.env, + REACTPRESS_ORIGINAL_CWD: projectRoot, + }, + }); + child.on('close', (code) => { + if (code && code !== 0) { + reject(Object.assign(new Error(`desktop dev exited with code ${code}`), { exitCode: code })); + return; + } + resolve(); + }); + child.on('error', reject); + }); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(err.exitCode ?? 1); + } + }); + +const serverCmd = program.command('server').description(t('cli.server.description')); + +serverCmd + .command('start') + .description(t('cli.server.start.description')) + .option('--pm2', t('cli.server.start.pm2')) + .option('--bg', t('cli.server.start.bg')) + .action(async (options) => { + const projectRoot = ensureOriginalCwd(); + try { + if (options.pm2) { + await startApiWithPm2(projectRoot); + return; + } + const cmd = options.bg ? 'start:bg' : 'start'; + const code = await runLifecycleCommand(cmd, projectRoot); + process.exit(code ?? 0); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +serverCmd.command('stop').description(t('cli.server.stop')).action(async () => { + const code = await runLifecycleCommand('stop', ensureOriginalCwd()); + process.exit(code ?? 0); +}); + +serverCmd.command('restart').description(t('cli.server.restart')).action(async () => { + const code = await runLifecycleCommand('restart', ensureOriginalCwd()); + process.exit(code ?? 0); +}); + +serverCmd.command('status').description(t('cli.server.status')).action(async () => { + await runLifecycleCommand('status', ensureOriginalCwd()); +}); + +const clientCmd = program.command('client').description(t('cli.client.description')); + +clientCmd + .command('start') + .description(t('cli.client.start')) + .option('--pm2', t('cli.client.start.pm2')) + .action(async (options) => { + const projectRoot = ensureOriginalCwd(); + const { resolveThemeDirectory, readActiveThemeManifest } = require('../lib/theme-runtime'); + const { resolveProductionThemeEnv } = require('../lib/theme-prod'); + const { activeTheme } = readActiveThemeManifest(projectRoot); + const themeDir = resolveThemeDirectory(projectRoot, activeTheme); + const args = options.pm2 ? ['--pm2'] : []; + const env = + themeDir && options.pm2 + ? resolveProductionThemeEnv(projectRoot, themeDir) + : themeDir + ? { REACTPRESS_THEME_DIR: themeDir } + : undefined; + await runNodeScript(getThemeBin(projectRoot), args, { + cwd: projectRoot, + env, + }); + }); + +clientCmd + .command('restart') + .description(t('cli.client.restart')) + .option('--pm2', t('cli.client.start.pm2')) + .action(async (options) => { + try { + const projectRoot = ensureOriginalCwd(); + const { restartProductionVisitorClient } = require('../lib/theme-prod'); + if (options.pm2) { + await restartProductionVisitorClient(projectRoot); + } else { + const { buildActiveTheme } = require('../lib/theme-prod'); + const { activeTheme, themeDir } = buildActiveTheme(projectRoot); + const args = []; + await runNodeScript(getThemeBin(projectRoot), args, { + cwd: projectRoot, + env: { REACTPRESS_THEME_DIR: themeDir }, + }); + console.log(`[reactpress] ${require('../lib/i18n').t('themeProd.restarted', { id: activeTheme })}`); + } + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +program + .command('build') + .description(t('cli.build.description')) + .option('-t, --target ', t('cli.build.target'), 'all') + .option('--low-mem', t('cli.build.lowMem')) + .action(async (options) => { + try { + if (options.lowMem) { + process.env.REACTPRESS_LOW_MEM = '1'; + } + await runBuild(options.target, ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +const dockerCmd = program.command('docker').description(t('cli.docker.description')); + +dockerCmd + .command('up') + .description(t('cli.docker.up')) + .action(async () => { + await runDockerCommand('up', ensureOriginalCwd()); + }); + +dockerCmd + .command('down') + .alias('stop') + .description(t('cli.docker.down')) + .action(async () => { + await runDockerCommand('down', ensureOriginalCwd()); + }); + +dockerCmd + .command('start') + .description(t('cli.docker.start')) + .action(async () => { + await runDockerCommand('start', ensureOriginalCwd()); + }); + +dockerCmd.command('restart').description(t('cli.docker.restart')).action(async () => { + await runDockerCommand('restart', ensureOriginalCwd()); +}); + +dockerCmd.command('status').description(t('cli.docker.status')).action(async () => { + await runDockerCommand('status', ensureOriginalCwd()); +}); + +dockerCmd + .command('logs [service]') + .description(t('cli.docker.logs')) + .action(async (service) => { + await runDockerCommand('logs', ensureOriginalCwd(), service ? [service] : []); + }); + +const nginxCmd = program.command('nginx').description(t('cli.nginx.description')); + +function nginxActionOptions(cmd) { + return cmd.option('--prod', t('cli.nginx.prod')).option('-f, --force', t('cli.nginx.force')); +} + +nginxActionOptions(nginxCmd.command('ensure').description(t('cli.nginx.ensure'))).action(async (options) => { + try { + await runNginxCommand('ensure', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxActionOptions(nginxCmd.command('up').description(t('cli.nginx.up'))).action(async (options) => { + try { + await runNginxCommand('up', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd + .command('down') + .alias('stop') + .description(t('cli.nginx.down')) + .option('--prod', t('cli.nginx.prod')) + .action(async (options) => { + try { + await runNginxCommand('down', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +nginxActionOptions(nginxCmd.command('restart').description(t('cli.nginx.restart'))).action(async (options) => { + try { + await runNginxCommand('restart', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd + .command('status') + .description(t('cli.nginx.status')) + .option('--prod', t('cli.nginx.prod')) + .action(async (options) => { + try { + await runNginxCommand('status', ensureOriginalCwd(), [], options); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +nginxCmd.command('logs').description(t('cli.nginx.logs')).action(async () => { + try { + await runNginxCommand('logs', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd.command('test').description(t('cli.nginx.test')).action(async () => { + try { + await runNginxCommand('test', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd.command('reload').description(t('cli.nginx.reload')).action(async () => { + try { + await runNginxCommand('reload', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +nginxCmd.command('open').description(t('cli.nginx.open')).action(async () => { + try { + await runNginxCommand('open', ensureOriginalCwd()); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } +}); + +program + .command('status') + .description(t('cli.status.description')) + .action(async () => { + await printUnifiedStatus(ensureOriginalCwd()); + }); + +program + .command('doctor') + .description(t('cli.doctor.description')) + .action(async () => { + const code = await runDoctor(ensureOriginalCwd()); + process.exit(code); + }); + +const dbCmd = program.command('db').description(t('cli.db.description')); + +dbCmd + .command('backup') + .description(t('cli.db.backup')) + .option('-o, --output ', t('cli.db.backup.output')) + .action(async (options) => { + try { + await runDbBackup(ensureOriginalCwd(), options.output); + } catch (err) { + console.error(chalk.red('[reactpress]'), err.message || err); + process.exit(1); + } + }); + +program + .command('publish') + .description(t('cli.publish.description')) + .option('--build', t('cli.publish.build')) + .option('--publish', t('cli.publish.publish')) + .option('--tag ', 'npm dist-tag: beta or latest') + .option('--version ', 'semver for all core packages') + .option('--yes', 'skip confirmation prompt') + .option('--no-build', 'skip build before publish') + .option('--otp ', '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 ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REACTPRESS + ${versionBadge} + + + + + + + + + + + + + + + + + + ${safeName} + Theme Preview +`; +} 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. - -[![NPM Version](https://img.shields.io/npm/v/@fecommunity/reactpress-client.svg)](https://www.npmjs.com/package/@fecommunity/reactpress-client) -[![License](https://img.shields.io/npm/l/@fecommunity/reactpress-client.svg)](https://github.com/fecommunity/reactpress/blob/master/client/LICENSE) -[![Node Version](https://img.shields.io/node/v/@fecommunity/reactpress-client.svg)](https://nodejs.org) -[![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) -[![Next.js](https://img.shields.io/badge/Next.js-14-black)](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) - -[![Deploy with Vercel](https://vercel.com/button)](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 const GitHub = () => { - return ( - -
  • - - - -
  • -
    - ); -}; - -export const Comment = () => { - return ( - - } - > -
  • - -
  • -
    - ); -}; - -export const WeChat = () => { - return ( - } - > -
  • - -
  • -
    - ); -}; - -export const ContactInfo = () => { - return ( -
    -
      - - - - - - - - - - - - - -
    -
    - ); -}; - -const AboutUs = ({ setting, className = '', hasBg = false }: IProps) => { - const t = useTranslations(); - return ( - - - {t('aboutUs')} - - } - className={style.card} - > -
    - {setting?.systemFooterInfo && ( -
    - )} -
    -
    - - -
    -
    -
    -
    - ); -}; - -export default AboutUs; diff --git a/client/src/components/AdvanceSearch/index.module.scss b/client/src/components/AdvanceSearch/index.module.scss deleted file mode 100644 index f49fff68..00000000 --- a/client/src/components/AdvanceSearch/index.module.scss +++ /dev/null @@ -1,129 +0,0 @@ -.searchItem { - display: flex; - flex-direction: column; - cursor: pointer; - &:hover { - color: var(--primary-color); - } - .description { - color: var(--second-text-color); - } -} -.wrapper { - padding: 16px 24px !important; -} -.pop { - background-color: var(--bg-box) !important; -} -.searchWrapper { - background-color: var(--bg-box) !important; - .autoComplete { - width: 100%; - height: 48px !important; - * { - background-color: var(--bg-box) !important; - } - } - - .searchCategory, - .searchSubCategory { - background-color: var(--bg-box) !important; - > div { - margin: 0 !important; - div { - justify-content: center !important; - } - } - } - .searchSubCategory { - > div { - > div { - > div { - > div:last-child { - top: 0 !important; - content: ''; - border-width: 8px 8px 0px 8px; - border-style: solid; - border-color: #f44336 transparent transparent; - position: absolute; - left: 50%; - top: 0; - margin-left: -8px; - background: none; - width: 0px !important; - } - } - } - } - } - - .searchInput { - border-radius: 24px; - } - - a { - color: inherit; - text-decoration: none; - - &:hover { - color: var(--primary-color); - } - } - - .wrapper { - height: fit-content; - max-height: 70vh; - overflow: scroll; - } -} -.inner { - box-shadow: var(--box-shadow) !important; - - .ant-modal-header { - font-size: 1.5rem; - font-weight: 600; - } - - .ant-modal-close-x { - font-size: 18px; - } - - .result { - flex: 1; - overflow: auto; - } - - ul { - list-style: circle; - - li { - display: flex; - align-items: center; - - &:hover { - background: var(--bg-body); - } - - a { - display: inline-block; - width: 100%; - padding: 12px 8px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - } -} - -@media (min-width: 992px) { - .inner { - width: 768px !important; - } -} - -@media (max-width: 576px) { - .inner { - width: 92% !important; - } -} diff --git a/client/src/components/AdvanceSearch/index.tsx b/client/src/components/AdvanceSearch/index.tsx deleted file mode 100644 index 32f0b68d..00000000 --- a/client/src/components/AdvanceSearch/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { AutoComplete, Button, Input, Spin, Tabs } from 'antd'; -import React, { useContext, useEffect, useState } from 'react'; - -import { useAsyncLoading } from '@/hooks/useAsyncLoading'; -import { SearchProvider } from '@/providers/search'; -import { SearchOutlined } from '@ant-design/icons'; - -import { GlobalContext } from '@/context/global'; -import { ArticleProvider } from '@/providers/article'; -import { jsonp } from '@/utils/jsonp'; -import styles from './index.module.scss'; - -interface IProps { - globalSetting?: any; -} - -export const AdvanceSearch: React.FC = (props) => { - const { globalSetting } = useContext(GlobalContext); - const { subCategories = {}, categories } = globalSetting?.globalConfig?.navConfig || props.globalSetting || {}; - const [category, setCategory] = useState(categories?.[0]?.key); - const [subCategory, setSubCategory] = useState(subCategories?.[category]?.[0]?.key); - const [options, setOptions] = useState([]); - const [searchVal, setSearchVal] = useState(); - - useEffect(() => { - setSubCategory(subCategories?.[category]?.[0]?.key); - fetchSuggestions(searchVal); - }, [category]); - - const fetchLocalData = (keyword: string) => { - if (keyword?.length) { - return SearchProvider.searchArticles(keyword); - } else { - return ArticleProvider.getRecommend(); - } - }; - - const [searchArticles, loading] = useAsyncLoading(fetchLocalData); - - const fetchSuggestions = (keyword: string) => { - switch (category) { - case 'local': - return searchArticles(keyword).then((res) => { - const options = res - .filter((t) => t.status === 'publish') - .map((item) => ({ - label: item?.title, - value: item?.title, - description: item?.summary, - link: `/article/${item?.id}`, - data: item, - })); - setOptions(options); - }); - default: - return jsonp( - `https://suggestion.baidu.com/su`, - { - wd: keyword || '高热度网', - }, - (res) => { - const data = subCategories[category]?.find((item) => item.key === subCategory); - const options = (res?.s || []).map((item) => ({ - link: data?.url ? `${data.url}${item}` : null, - label: item, - value: item, - })); - setOptions(options); - } - ); - } - }; - - const onValueChange = (val) => { - setSearchVal(val); - fetchSuggestions(val); - }; - - const handleSearch = () => { - const data = subCategories[category]?.find((item) => item.key === subCategory); - const link = data?.url ? `${data.url}${searchVal || '高热度网'}` : null; - if (category === 'local' || !!searchVal) { - fetchSuggestions(searchVal); - } else { - window.open(link, '_blank'); - } - }; - - const optionRender = (record, info) => { - const { label, value, data: { link, description, id } = {} as any } = record; - - return ( -
    { - !!link && window.open(link, '_blank'); - e.stopPropagation(); - }} - key={info?.index} - > -
    {label}
    -

    -

    - ); - }; - - return ( -
    -
    -
    - { - setCategory(val); - }} - /> - fetchSuggestions(searchVal)} - notFoundContent={loading ? : null} - popupClassName={styles.pop} - > - } type="text" />} - /> - - { - setSubCategory(value); - fetchSuggestions(searchVal); - }} - /> -
    -
    - ); -}; diff --git a/client/src/components/Analytics/index.tsx b/client/src/components/Analytics/index.tsx deleted file mode 100644 index 739d9689..00000000 --- a/client/src/components/Analytics/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useContext, useEffect } from 'react'; - -import { GlobalContext } from '@/context/global'; - -export const Analytics = (props) => { - const { setting } = useContext(GlobalContext); - - useEffect(() => { - const googleAnalyticsId = setting.googleAnalyticsId; - - if (!googleAnalyticsId) { - return; - } - - // @ts-ignore - window.dataLayer = window.dataLayer || []; - function gtag() { - // @ts-ignore - window.dataLayer.push(arguments); // eslint-disable-line prefer-rest-params - } - // @ts-ignore - gtag('js', new Date()); - // @ts-ignore - gtag('config', googleAnalyticsId); - - const script = document.createElement('script'); - script.src = `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsId}`; - script.async = true; - - if (document.body) { - document.body.appendChild(script); - } - }, [setting.googleAnalyticsId]); - - useEffect(() => { - const baiduAnalyticsId = setting.baiduAnalyticsId; - - if (!baiduAnalyticsId) { - return; - } - - const hm = document.createElement('script'); - hm.src = `https://hm.baidu.com/hm.js?${baiduAnalyticsId}`; - const s = document.getElementsByTagName('script')[0]; - s.parentNode.insertBefore(hm, s); - }, [setting.baiduAnalyticsId]); - - return props.children || null; -}; diff --git a/client/src/components/Animation/Opacity.tsx b/client/src/components/Animation/Opacity.tsx deleted file mode 100644 index 0537d667..00000000 --- a/client/src/components/Animation/Opacity.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -import { Spring, SpringProps } from './Spring'; - -export const Opacity: React.FC = (props) => { - const { from = {}, to = {}, ...rest } = props; - from.opacity = 0; - to.opacity = 1; - - return ; -}; diff --git a/client/src/components/Animation/Spring.tsx b/client/src/components/Animation/Spring.tsx deleted file mode 100644 index 6db5262c..00000000 --- a/client/src/components/Animation/Spring.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { animated, useSpring } from 'react-spring'; -import VisibilitySensor from 'react-visibility-sensor'; - -import { elementInViewport } from '@/utils'; - -export interface SpringProps { - containerProps?: Record; - from?: Record; - to?: Record; -} - -export const Spring: React.FC = ({ containerProps = {}, from = {}, to = {}, children }) => { - const ref = useRef(); - const [styles, animation] = useSpring(() => ({ - ...from, - config: { mass: 10, tension: 400, friction: 40, precision: 0.00001, clamp: true }, - })); - const onViewportChange = useCallback( - (visible) => { - if (visible) { - animation.start(to); - } - }, - [animation, to] - ); - - useEffect(() => { - if (elementInViewport(ref.current)) { - animation.start(to); - } - }, [animation, to]); - - return ( - - - {children} - - - ); -}; diff --git a/client/src/components/Animation/Trail.tsx b/client/src/components/Animation/Trail.tsx deleted file mode 100644 index 49177aa9..00000000 --- a/client/src/components/Animation/Trail.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { animated, useTrail } from 'react-spring'; - -interface ListTrailProps { - length: number; - options: Record; - element?: string; - setItemContainerProps?: (index: number) => Record; - renderItem: (index: number) => React.ReactNode; -} - -export const ListTrail: React.FC = ({ - length, - options, - element = 'li', - setItemContainerProps = () => ({}), - renderItem, -}) => { - const C = animated[element]; - const trail = useTrail(length, { - config: { mass: 2, tension: 280, friction: 24, clamp: true }, - ...options, - }); - - return ( - <> - {trail.map((style, index) => { - return ( - - {renderItem(index)} - - ); - })} - - ); -}; diff --git a/client/src/components/Animation/Transition.tsx b/client/src/components/Animation/Transition.tsx deleted file mode 100644 index da7b353c..00000000 --- a/client/src/components/Animation/Transition.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { animated, useTransition } from 'react-spring'; - -type ConditionTransitionProps = { - visible: boolean; - options: Record; -}; - -export const ConditionTransition: React.FC = ({ visible, options, children }) => { - const transitions = useTransition(visible, { - config: { mass: 2, tension: 280, friction: 24, clamp: true }, - ...options, - }); - - return ( - <> - {transitions( - (style, item) => item && {children} - )} - - ); -}; diff --git a/client/src/components/ArticleCarousel/index.module.scss b/client/src/components/ArticleCarousel/index.module.scss deleted file mode 100644 index 133b2094..00000000 --- a/client/src/components/ArticleCarousel/index.module.scss +++ /dev/null @@ -1,85 +0,0 @@ -.wrapper { - background: var(--bg-second); - box-shadow: var(--box-shadow); - border-radius: var(--border-radius); - - > div { - font-size: 0 !important; - } - - .articleItem { - position: relative; - display: flex; - width: 100%; - height: 260px; - background-position: center; - background-size: cover; - background-repeat: no-repeat; - overflow: hidden; - border-radius: var(--border-radius); - - img { - width: 100%; - } - - .info { - position: absolute; - top: 0; - left: 0; - z-index: 1; - display: flex; - width: 100%; - height: 100%; - font-size: 1rem; - color: var(--font-color-base); - background-color: rgb(0 0 0 / 35%); - flex-direction: column; - justify-content: center; - align-items: center; - - h2 { - color: inherit; - text-align: center; - margin: 0 16px; - } - - .seperator { - margin: 0 4px; - } - - .meta { - text-align: right; - } - } - } - - @media (max-width: 768px) { - .articleItem { - height: 300px; - } - } - - @media (min-width: 768px) { - .container { - width: 768px; - } - } - - @media (min-width: 992px) { - .articleItem { - height: 340px; - } - } - - @media (min-width: 1200px) { - .articleItem { - height: 380px; - } - } - - @media (min-width: 1360px) { - .articleItem { - height: 460px; - } - } -} diff --git a/client/src/components/ArticleCarousel/index.tsx b/client/src/components/ArticleCarousel/index.tsx deleted file mode 100644 index 8f00e92a..00000000 --- a/client/src/components/ArticleCarousel/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Carousel } from 'antd'; -import Link from 'next/link'; -import { useTranslations } from 'next-intl'; -import React from 'react'; - -import { LocaleTime } from '@/components/LocaleTime'; - -import style from './index.module.scss'; - -interface IProps { - articles?: IArticle[]; -} - -export const ArticleCarousel: React.FC = ({ articles = [] }) => { - const t = useTranslations(); - return articles && articles.length ? ( -
    - - {(articles || []) - .filter((article) => article.cover) - .slice(0, 6) - .map((article) => { - return ( - - ); - })} - -
    - ) : null; -}; diff --git a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss b/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss deleted file mode 100644 index b38c9083..00000000 --- a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.module.scss +++ /dev/null @@ -1,38 +0,0 @@ -.formItem { - display: flex; - align-items: center; - - + .formItem { - margin-top: 16px; - } - - > span { - padding-right: 16px; - } - - > div { - flex: 1; - } -} - -.cover { - .preview { - display: flex; - justify-content: center; - align-items: center; - height: 180px; - margin-bottom: 16px; - color: #888; - background-color: #f5f5f5; - - img { - display: block; - max-width: 100%; - max-height: 180px; - } - } - - button { - margin-top: 16px; - } -} diff --git a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx b/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx deleted file mode 100644 index fd0d2daf..00000000 --- a/client/src/components/ArticleEditor/ArticleSettingDrawer/index.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { Button, Drawer, Input, Select, Switch } from 'antd'; -import React, { useEffect, useReducer, useState } from 'react'; - -import { FileSelectDrawer } from '@/components/FileSelectDrawer'; -import { CategoryProvider } from '@/providers/category'; -import { TagProvider } from '@/providers/tag'; - -import style from './index.module.scss'; - -interface IProps { - visible: boolean; - article?: Partial; - onClose: () => void; - onChange?: (arg) => void; -} - -const FormItem = ({ label, content }) => { - return ( -
    - {label} -
    {content}
    -
    - ); -}; - -const initialArticleAttrs = { - summary: null, // 摘要 - password: null, // 密码 - isCommentable: true, // 评论 - isRecommended: true, // 推荐到首页 - category: null, // 分类 - tags: [], // 标签 - cover: null, // 封面 -}; -function reducer(state: typeof initialArticleAttrs = initialArticleAttrs, action) { - const payload = action.payload; - switch (action.type) { - case 'summary': - return { ...state, summary: payload }; - case 'password': - return { ...state, password: payload }; - case 'isCommentable': - return { ...state, isCommentable: payload }; - case 'isRecommended': - return { ...state, isRecommended: payload }; - case 'category': - return { ...state, category: payload }; - case 'tags': - return { ...state, tags: payload }; - case 'cover': - return { ...state, cover: payload }; - default: - return state; - } -} -export const ArticleSettingDrawer: React.FC = ({ article, visible, onClose, onChange }) => { - const [fileVisible, setFileVisible] = useState(false); - const [attrs, dispatch] = useReducer(reducer, article as typeof initialArticleAttrs); - const [categorys, setCategorys] = useState>([]); - const [tags, setTags] = useState>([]); - - useEffect(() => { - CategoryProvider.getCategory().then((res) => setCategorys(res)); - TagProvider.getTags().then((tags) => setTags(tags)); - }, []); - - const ok = () => { - onChange({ - ...attrs, - tags: (attrs.tags || []).join(','), - }); - }; - - return ( - - { - dispatch({ type: 'summary', payload: e.target.value }); - }} - /> - } - /> - { - dispatch({ type: 'password', payload: e.target.value }); - }} - /> - } - /> - { - dispatch({ type: 'isCommentable', payload: val }); - }} - /> - } - /> - { - dispatch({ type: 'isRecommended', payload: val }); - }} - /> - } - /> - { - dispatch({ type: 'category', payload: id }); - }} - style={{ width: '100%' }} - > - {categorys.map((t) => ( - - {t.label} - - ))} - - } - /> - t.id || t)} - onChange={(tags) => { - dispatch({ type: 'tags', payload: tags }); - }} - > - {tags.map((tag) => ( - - {tag.label} - - ))} - - } - /> - -
    setFileVisible(true)} className={style.preview}> - 预览图 -
    - { - dispatch({ type: 'cover', payload: e.target.value }); - }} - /> - -
    - } - /> - setFileVisible(false)} - onChange={(url) => { - dispatch({ type: 'cover', payload: url }); - }} - /> -
    - -
    - - ); -}; diff --git a/client/src/components/ArticleEditor/index.module.scss b/client/src/components/ArticleEditor/index.module.scss deleted file mode 100644 index 8a69d715..00000000 --- a/client/src/components/ArticleEditor/index.module.scss +++ /dev/null @@ -1,31 +0,0 @@ -.wrapper { - display: flex; - flex-direction: column; - height: 100vh; - background-color: var(--bg-box); - - .header { - z-index: 1000; - height: 64px; - background-color: var(--bg-secord); - - > div { - height: 100%; - } - - input { - padding-right: 0; - padding-left: 0; - border-top: 0; - border-left: 0; - border-radius: 0 !important; - box-shadow: none !important; - border-right: 0; - } - } - - .main { - flex: 1; - overflow: hidden; - } -} diff --git a/client/src/components/ArticleEditor/index.tsx b/client/src/components/ArticleEditor/index.tsx deleted file mode 100644 index ca25ec07..00000000 --- a/client/src/components/ArticleEditor/index.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { CloseOutlined, EllipsisOutlined } from '@ant-design/icons'; -import { PageHeader } from '@ant-design/pro-layout'; -import { Editor as MDEditor } from '@components/Editor'; -import { Button, Dropdown, Input, Layout, Menu, message, Modal } from 'antd'; -import cls from 'classnames'; -import { default as Router } from 'next/router'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Head from 'next/head'; - -import { useSetting } from '@/hooks/useSetting'; -import { useToggle } from '@/hooks/useToggle'; -import { useWarningOnExit } from '@/hooks/useWarningOnExit'; -import { ArticleProvider } from '@/providers/article'; -import { resolveUrl } from '@/utils'; - -import { ArticleSettingDrawer } from './ArticleSettingDrawer'; -import style from './index.module.scss'; - -interface IProps { - id?: string | number; - article?: IArticle; -} - -const REQUIRED_ARTICLE_ATTRS = [ - ['title', '请输入文章标题'], - ['content', '请输入文章内容'], -]; - -// 副作用:传给服务端的 category 需要是 id -const transformCategory = (article) => { - if (article.category && article.category.id) { - article.category = article.category.id; - } -}; -const transformTags = (article) => { - if (Array.isArray(article.tags)) { - try { - article.tags = (article.tags as ITag[]).map((t) => t.id).join(','); - } catch (e) { - console.log(e); - } - } -}; - -export const ArticleEditor: React.FC = ({ id: defaultId, article: defaultArticle = { title: '' } }) => { - const isCreate = !defaultId; // 一开始是否是新建 - const setting = useSetting(); - const [id, setId] = useState(defaultId); - const [article, setArticle] = useState>(defaultArticle); - const [settingDrawerVisible, toggleSettingDrawerVisible] = useToggle(false); - const [hasSaved, toggleHasSaved] = useToggle(false); - - const patchArticle = useMemo( - () => (key) => (value) => { - if (value.target) { - value = value.target.value; - } - setArticle((article) => { - article[key] = value; - return article; - }); - }, - [] - ); - - // 校验文章必要属性 - const check = useCallback(() => { - let canPublish = true; - let errorMsg = null; - REQUIRED_ARTICLE_ATTRS.forEach(([key, msg]) => { - if (!article[key]) { - errorMsg = msg; - canPublish = false; - } - }); - if (!canPublish) { - return Promise.reject(new Error(errorMsg)); - } - return Promise.resolve(); - }, [article]); - - // 打开发布抽屉 - const openSetting = useCallback(() => { - check() - .then(() => { - toggleSettingDrawerVisible(); - }) - .catch((err) => { - message.warning(err.message); - }); - }, [check, toggleSettingDrawerVisible]); - - const saveSetting = useCallback( - (setting) => { - toggleSettingDrawerVisible(); - Object.assign(article, setting); - }, - [article, toggleSettingDrawerVisible] - ); - - // 保存草稿或者发布线上 - const saveOrPublish = useCallback( - (patch = {}) => { - const data = { ...article, ...patch }; - return check() - .then(() => { - transformCategory(data); - transformTags(data); - const promise = !isCreate ? ArticleProvider.updateArticle(id, data) : ArticleProvider.addArticle(data); - return promise.then((res) => { - setId(res.id); - toggleHasSaved(true); - message.success(res.status === 'draft' ? '文章已保存为草稿' : '文章已发布'); - }); - }) - .catch((err) => { - message.warning(err.message); - return Promise.reject(err); - }); - }, - [article, isCreate, check, id, toggleHasSaved] - ); - - const saveDraft = useCallback(() => { - return saveOrPublish({ status: 'draft' }); - }, [saveOrPublish]); - - const publish = useCallback(() => { - return saveOrPublish({ status: 'publish' }); - }, [saveOrPublish]); - - // 预览文章 - const preview = useCallback(() => { - if (id) { - if (!setting.systemUrl) { - message.error('尚未配置前台地址,无法正确构建预览地址'); - return; - } - window.open(resolveUrl(setting.systemUrl, '/article/' + id)); - } else { - message.warning('请先保存'); - } - }, [id, setting.systemUrl]); - - const deleteArticle = useCallback(() => { - if (!id) { - return; - } - const handle = () => { - ArticleProvider.deleteArticle(id).then(() => { - toggleHasSaved(true); - message.success('文章删除成功'); - Router.push('/article'); - }); - }; - Modal.confirm({ - title: '确认删除?', - content: '删除内容后,无法恢复。', - onOk: handle, - okText: '确认', - cancelText: '取消', - transitionName: '', - maskTransitionName: '', - }); - }, [id, toggleHasSaved]); - - const goback = useCallback(() => { - Router.push('/admin/article'); - }, []); - - useEffect(() => { - if (isCreate && id) { - Router.replace('/admin/article/editor/' + id); - } - }, [id, isCreate]); - - useWarningOnExit(!hasSaved, () => window.confirm('确认关闭?如果有内容变更,请先保存!')); - - return ( -
    - - {id ? `编辑文章 ${article.title ? '-' + article.title : ''}` : '新建文章'} - -
    - } />} - style={{ - borderBottom: '1px solid rgb(235, 237, 240)', - }} - onBack={goback} - title={ - - } - extra={[ - , - - - 查看 - - - 设置 - - - - 保存草稿 - - - - 删除 - - - } - > - - , - ]} - /> -
    -
    - { - patchArticle('content')(value); - patchArticle('html')(html); - patchArticle('toc')(toc); - }} - /> -
    - -
    - ); -}; diff --git a/client/src/components/ArticleList/index.module.scss b/client/src/components/ArticleList/index.module.scss deleted file mode 100644 index 5d1c64fa..00000000 --- a/client/src/components/ArticleList/index.module.scss +++ /dev/null @@ -1,265 +0,0 @@ -.wrapper { - overflow: hidden; - border-radius: var(--border-radius); - box-shadow: var(--box-shadow); - margin-top: 1rem; -} - -.articleItem { - position: relative; - display: flex; - justify-content: space-between; - overflow: hidden; - padding: 1rem; - background-color: var(--bg-box); - border-radius: var(--border-radius); - - .info { - display: flex; - align-items: center; - } - - &:hover { - img { - transform: scale(1.1); - transition: all 0.2s ease-in; - } - } - - .antBadge { - margin-left: 4px; - &:hover { - opacity: 0.7; - } - .category { - color: var(--second-text-color); - } - } - - .coverWrapper { - position: relative; - height: 114px; - width: 200px; - margin: 0 10px 0 0; - flex-shrink: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - border-radius: 5px; - cursor: pointer; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - @media (max-width: 992px) { - .coverWrapper { - width: 180px; - } - } - - .badge { - position: absolute; - top: 20px; - left: -1px; - width: 5px; - height: 25px; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); - background-color: var(--primary-color); - } - - .link { - display: inline-block; - height: 100%; - width: 100%; - } - - .articleWrapper { - flex: 1; - } - - & + .articleItem { - margin-top: 1rem; - } - - &::after { - position: absolute; - bottom: 0rem; - width: calc(100% - 32px); - height: 1px; - // background: var(--border-color); - content: ''; - } - - &:last-of-type { - &::after { - height: 0; - } - } - - &:hover { - header .title { - color: var(--primary-color); - } - } - - header { - display: flex; - align-items: flex-start; - - .title { - overflow: hidden; - font-size: 16px; - font-weight: 600; - line-height: 22px; - color: var(--main-text-color); - text-overflow: ellipsis; - font-synthesis: style; - - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2em; - max-height: 2.4em; - } - - .time, - .category { - color: #fff; - } - } - - main { - display: flex; - flex-wrap: nowrap; - height: calc(100% - 28px); - - .coverWrapper { - position: relative; - width: 120px; - max-height: 100px; - min-height: 80px; - margin-left: 1.5rem; - overflow: hidden; - border-radius: var(--border-radius); - flex: 0 0 auto; - - &:hover { - transform: scale(1.2); - } - - img { - position: absolute; - top: 50%; - left: 50%; - width: 100%; - height: 100%; - transform: translate3d(-50%, -50%, 0); - object-fit: cover; - } - } - - .contentWrapper { - flex: 1 1 auto; - display: flex; - flex-direction: column; - justify-content: space-between; - - .desc { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - font-size: 14px; - color: var(--second-text-color); - text-overflow: ellipsis; - line-height: 1.2em; - max-height: 2.4em; - max-width: 100%; - width: calc(100% - 24px); - } - - .meta { - width: 100%; - margin-top: 0.8rem; - font-size: 14px; - line-height: 20px; - color: #8590a6; - display: flex; - justify-content: space-between; - white-space: nowrap; - - .separator { - margin: 0 8px; - } - - .number { - margin-left: 6px; - color: var(--second-text-color); - } - .time { - > * { - margin-left: 6px; - } - } - } - } - } -} - -@media (max-width: 658px) { - .articleItem { - .coverWrapper { - width: 140px; - height: 80px; - } - - > a { - flex-direction: column; - } - - .info { - display: none; - } - - header { - flex-direction: column; - align-items: flex-start; - - .info { - font-size: 0.8em; - - > div:first-of-type { - display: none; - } - } - .category { - display: none; - } - } - - main { - .contentWrapper { - .desc { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2em; - max-height: 2.4em; - } - - .time { - display: none; - } - } - } - } -} diff --git a/client/src/components/ArticleList/index.tsx b/client/src/components/ArticleList/index.tsx deleted file mode 100644 index a305bc19..00000000 --- a/client/src/components/ArticleList/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** - * ArticleList Component - * - * This component displays a list of articles in a card format. - * Each article card includes: - * - Cover image (with lazy loading) - * - Title - * - Category tag - * - Summary - * - Meta information (likes, views, publish date) - * - * Features: - * - Lazy loading for images - * - Responsive design - * - Category navigation - * - Article statistics display - */ - -import { EyeOutlined, FolderOutlined, HeartOutlined, HistoryOutlined } from '@ant-design/icons'; -import { Spin, Tag } from 'antd'; -import { useTranslations } from 'next-intl'; -import Link from 'next/link'; -import React, { useContext, useMemo } from 'react'; -import LazyLoad from 'react-lazyload'; -import LogoSvg from '../../assets/LogoSvg'; - -import { LocaleTime } from '@/components/LocaleTime'; -import { GlobalContext } from '@/context/global'; -import { getColorFromNumber } from '@/utils'; -import style from './index.module.scss'; - -interface Article { - id: string; - title: string; - cover?: string; - summary: string; - category?: { - value: string; - label: string; - }; - likes: number; - views: number; - publishAt: string; -} - -interface ArticleListProps { - articles: Article[]; - coverHeight?: number; - asRecommend?: boolean; -} - -/** - * ArticleCard Component - * Renders a single article card with all its details - */ -const ArticleCard: React.FC<{ article: Article; categoryIndex: number }> = ({ article, categoryIndex }) => { - return ( - - ); -}; - -/** - * Main ArticleList Component - * Renders a list of article cards with proper handling of empty state - */ -export const ArticleList: React.FC = ({ articles = [] }) => { - const t = useTranslations(); - const { categories } = useContext(GlobalContext); - - // Memoize the category indices to avoid recalculating on every render - const categoryIndices = useMemo(() => { - return articles.map(article => - categories?.findIndex((category) => category?.value === article?.category?.value) - ); - }, [articles, categories]); - - return ( -
    - {articles && articles.length ? ( - articles.map((article, index) => ( - - )) - ) : ( -
    {t('empty')}
    - )} -
    - ); -}; diff --git a/client/src/components/ArticleRecommend/index.module.scss b/client/src/components/ArticleRecommend/index.module.scss deleted file mode 100644 index c4a27426..00000000 --- a/client/src/components/ArticleRecommend/index.module.scss +++ /dev/null @@ -1,152 +0,0 @@ -.wrapper { - margin-bottom: 1.3rem; - overflow: hidden; - line-height: 1.29; - - &.inline { - background-color: var(--bg-box); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow); - } - - .recommendIcon { - margin-right: 8px; - } - - .title { - padding: 1rem; - font-weight: bold; - color: var(--main-text-color); - border-bottom: 1px solid var(--border-color); - } - - ul.inlineWrapper { - padding: 0 1rem 1rem; - - .article { - display: flex; - justify-content: space-between; - width: 100%; - .seqId { - display: inline-block; - width: 40px; - border-radius: 4px; - background-color: var(--primary-color); - } - .articleTitle { - flex: 1; - width: 100%; - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - &::before { - background-color: var(--primary-color); - color: #fff; - content: attr(data-num); - display: inline-block; - font-size: 14px; - line-height: 18px; - margin-right: 5px; - text-align: center; - width: 18px; - } - } - .views { - display: inline-block; - width: 54px; - color: var(--second-text-color); - } - } - - li { - display: flex; - flex-wrap: nowrap; - align-items: stretch; - padding-top: 1rem; - color: var(--second-text-color); - - > div:last-of-type { - display: flex; - align-items: center; - } - - a { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: inherit; - - span:first-of-type { - color: var(--main-text-color); - } - - &:hover { - color: var(--primary-color); - - span:first-of-type { - color: inherit; - } - } - } - - p { - margin: 0; - } - - img { - display: inline-block; - width: 6.8rem; - height: 3.8rem; - margin-right: 0.8rem; - } - } - } -} - -.articleItem { - position: relative; - width: 100%; - padding: 0; - margin-right: 14px; - overflow: hidden; - background: var(--bg-second); - border-radius: 5px; - box-shadow: var(--box-shadow); - transition: transform 0.3s; - - &:hover { - transform: scale(1.04); - } - - img { - width: 100%; - height: 123px; - border-top-right-radius: 5px; - border-top-left-radius: 5px; - object-fit: cover; - object-fit: cover; - } - - .title { - min-width: 225px; - padding: 12px; - margin-bottom: 0; - overflow: hidden; - font-size: 16px; - font-weight: 600; - line-height: 22px; - color: var(--main-text-color); - text-overflow: ellipsis; - white-space: nowrap; - border: 0; - font-synthesis: style; - } - - .meta { - width: 100%; - padding: 0 12px 12px; - font-size: 14px; - line-height: 20px; - color: #8590a6; - } -} diff --git a/client/src/components/ArticleRecommend/index.tsx b/client/src/components/ArticleRecommend/index.tsx deleted file mode 100644 index 25f72083..00000000 --- a/client/src/components/ArticleRecommend/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { ArticleList } from '@components/ArticleList'; -import { Spin } from 'antd'; -import cls from 'classnames'; -import { useTranslations } from 'next-intl'; -import Link from 'next/link'; -import React, { useEffect, useState } from 'react'; - -import { useAsyncLoading } from '@/hooks/useAsyncLoading'; -import { ArticleProvider } from '@/providers/article'; -import { LikeOutlined, EyeOutlined } from '@ant-design/icons'; - -import style from './index.module.scss'; - -interface IProps { - articleId?: string; - mode?: 'inline' | 'vertical'; - needTitle?: boolean; -} - -export const ArticleRecommend: React.FC = ({ mode = 'vertical', articleId = null, needTitle = true }) => { - const t = useTranslations(); - const [getRecommend, loading] = useAsyncLoading(ArticleProvider.getRecommend, 150, true); - const [fetched, setFetched] = useState(''); - const [articles, setArticles] = useState([]); - - useEffect(() => { - if (fetched === articleId) return; - getRecommend(articleId).then((res) => { - const articles = res.slice(0, 6); - articles.sort((a, b) => b.views - a.views); - setArticles(articles); - setFetched(articleId); - }); - }, [articleId, getRecommend, fetched]); - - return ( -
    - {needTitle && ( -
    - - {t('recommendToReading')} -
    - )} - - - {loading ? ( -
    - ) : mode === 'inline' ? ( - articles.length <= 0 ? ( - loading ? ( -
    - ) : ( -
    {t('empty')}
    - ) - ) : ( - - ) - ) : ( - - )} -
    -
    - ); -}; diff --git a/client/src/components/Categories/index.module.scss b/client/src/components/Categories/index.module.scss deleted file mode 100644 index fa76f47b..00000000 --- a/client/src/components/Categories/index.module.scss +++ /dev/null @@ -1,58 +0,0 @@ -.wrapper { - margin-bottom: 1.3rem; - overflow: hidden; - line-height: 1.29; - background-color: var(--bg-box); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow); - - .title { - padding: 1rem 1.3rem; - font-weight: bold; - color: var(--main-text-color); - border-bottom: 1px solid var(--border-color); - } - - .categoryIcon { - margin-right: 8px; - } - - ul { - padding: 1rem; - } - - li { - padding: 8px 7px; - line-height: 1.5em; - color: var(--second-text-color); - border-radius: var(--border-radius); - transition: all ease-in-out 0.2s; - - a { - display: inline-flex; - justify-content: space-between; - width: 100%; - - > span:first-of-type { - color: var(var(--main-text-color)); - } - } - - &:hover { - color: var(--primary-color); - - a > span:first-of-type { - color: inherit; - } - } - - &.active { - color: var(--bg); - background-color: var(--primary-color); - - a > span:first-of-type { - color: inherit; - } - } - } -} diff --git a/client/src/components/Categories/index.tsx b/client/src/components/Categories/index.tsx deleted file mode 100644 index 93c9baf2..00000000 --- a/client/src/components/Categories/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTranslations } from 'next-intl'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; - -import { FolderOutlined } from '@ant-design/icons'; - -import style from './index.module.scss'; - -export const Categories = ({ categories = [] }) => { - const t = useTranslations(); - - return ( -
    -
    - - {t('categoryTitle')} -
    - -
    - ); -}; diff --git a/client/src/components/Comment/CommentAction/CommentAction.tsx b/client/src/components/Comment/CommentAction/CommentAction.tsx deleted file mode 100644 index c148b106..00000000 --- a/client/src/components/Comment/CommentAction/CommentAction.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Divider, Input, message, Modal, notification, Popconfirm } from 'antd'; -import React, { useCallback, useState } from 'react'; - -import { useSetting } from '@/hooks/useSetting'; -import { CommentProvider } from '@/providers/comment'; -import { SettingProvider } from '@/providers/setting'; - -import style from './index.module.scss'; - -export const CommentAction = ({ comment, refresh }) => { - const setting = useSetting(); - const [replyContent, setReplyContent] = useState(null); - const [replyVisible, setReplyVisible] = useState(false); - - // 修改评论 - const updateComment = useCallback( - (comment, pass = false) => { - CommentProvider.updateComment(comment.id, { pass }).then(() => { - message.success(pass ? '评论已通过' : '评论已拒绝'); - refresh(); - }); - }, - [refresh] - ); - - const reply = useCallback(() => { - if (!replyContent) { - return; - } - const userInfo = JSON.parse(window.localStorage.getItem('user')); - const email = (userInfo && userInfo.mail) || (setting && setting.smtpFromUser); - const notify = () => { - notification.error({ - message: '回复评论失败', - description: '请前往系统设置完善 SMTP 设置,前往个人中心更新个人邮箱。', - }); - }; - - const handle = (email) => { - const data = { - name: userInfo.name, - email, - content: replyContent, - parentCommentId: comment.parentCommentId || comment.id, - hostId: comment.hostId, - isHostInPage: comment.isHostInPage, - replyUserName: comment.name, - replyUserEmail: comment.email, - url: comment.url, - createByAdmin: true, - }; - - CommentProvider.addComment(data) - .then(() => { - message.success('回复成功'); - setReplyContent(''); - refresh(); - }) - .catch(() => notify()); - }; - - if (!email) { - SettingProvider.getSetting() - .then((res) => { - if (res && res.smtpFromUser) { - handle(res.smtpFromUser); - } else { - notify(); - } - setReplyVisible(false); - }) - .catch(() => { - notify(); - setReplyVisible(false); - }); - } else { - handle(email); - setReplyVisible(false); - } - }, [ - replyContent, - comment.email, - comment.hostId, - comment.id, - comment.isHostInPage, - comment.name, - comment.parentCommentId, - comment.url, - refresh, - setting, - ]); - - // 删除评论 - const deleteComment = useCallback( - (id) => { - CommentProvider.deleteComment(id).then(() => { - message.success('评论删除成功'); - refresh(); - }); - }, - [refresh] - ); - - return ( -
    - - updateComment(comment, true)}>通过 - - updateComment(comment, false)}>拒绝 - - setReplyVisible(true)}>回复 - - deleteComment(comment.id)} - okText="确认" - cancelText="取消" - > - 删除 - - - setReplyVisible(false)} - transitionName={''} - maskTransitionName={''} - > - { - const val = e.target.value; - setReplyContent(val); - }} - > - -
    - ); -}; diff --git a/client/src/components/Comment/CommentAction/CommentArticle.tsx b/client/src/components/Comment/CommentAction/CommentArticle.tsx deleted file mode 100644 index c7d34545..00000000 --- a/client/src/components/Comment/CommentAction/CommentArticle.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Popover } from 'antd'; -import React from 'react'; - -import { useSetting } from '@/hooks/useSetting'; -import { resolveUrl } from '@/utils'; - -import style from './index.module.scss'; - -export const CommentArticle = ({ comment }) => { - const setting = useSetting(); - const { url: link } = comment; - const href = resolveUrl(setting?.systemUrl, link); - - return ( - } placement={'right'} mouseEnterDelay={0.5}> - - 文章 - - - ); -}; diff --git a/client/src/components/Comment/CommentAction/CommentContent.tsx b/client/src/components/Comment/CommentAction/CommentContent.tsx deleted file mode 100644 index 11cf73a9..00000000 --- a/client/src/components/Comment/CommentAction/CommentContent.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Button, Popover } from 'antd'; -import React from 'react'; - -export const CommentContent = ({ comment }) => { - return ( - -
    - } - > - - - - ); -}; diff --git a/client/src/components/Comment/CommentAction/CommentHTML.tsx b/client/src/components/Comment/CommentAction/CommentHTML.tsx deleted file mode 100644 index fd358130..00000000 --- a/client/src/components/Comment/CommentAction/CommentHTML.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Button, Popover } from 'antd'; -import React from 'react'; - -export const CommentHTML = ({ comment }) => { - return ( - - - } - > - - - - ); -}; diff --git a/client/src/components/Comment/CommentAction/CommentStatus.tsx b/client/src/components/Comment/CommentAction/CommentStatus.tsx deleted file mode 100644 index 9588928d..00000000 --- a/client/src/components/Comment/CommentAction/CommentStatus.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Badge } from 'antd'; -import React from 'react'; - -export const CommentStatus = ({ comment }) => { - return ; -}; diff --git a/client/src/components/Comment/CommentAction/index.module.scss b/client/src/components/Comment/CommentAction/index.module.scss deleted file mode 100644 index 7bf73382..00000000 --- a/client/src/components/Comment/CommentAction/index.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.action a { - color: #1890ff; -} - -.link { - color: #1890ff; -} diff --git a/client/src/components/Comment/CommentEditor/Emoji/emojis.ts b/client/src/components/Comment/CommentEditor/Emoji/emojis.ts deleted file mode 100644 index 75a2633c..00000000 --- a/client/src/components/Comment/CommentEditor/Emoji/emojis.ts +++ /dev/null @@ -1,152 +0,0 @@ -export const emojis = { - grinning: '😀', - smiley: '😃', - smile: '😄', - grin: '😁', - laughing: '😆', - satisfied: '😆', - sweat_smile: '😅', - joy: '😂', - wink: '😉', - blush: '😊', - innocent: '😇', - heart_eyes: '😍', - kissing_heart: '😘', - kissing: '😗', - kissing_closed_eyes: '😚', - kissing_smiling_eyes: '😙', - yum: '😋', - stuck_out_tongue: '😛', - stuck_out_tongue_winking_eye: '😜', - stuck_out_tongue_closed_eyes: '😝', - neutral_face: '😐', - expressionless: '😑', - no_mouth: '😶', - smirk: '😏', - unamused: '😒', - relieved: '😌', - pensive: '😔', - sleepy: '😪', - sleeping: '😴', - mask: '😷', - dizzy_face: '😵', - sunglasses: '😎', - confused: '😕', - worried: '😟', - open_mouth: '😮', - hushed: '😯', - astonished: '😲', - flushed: '😳', - frowning: '😦', - anguished: '😧', - fearful: '😨', - cold_sweat: '😰', - disappointed_relieved: '😥', - cry: '😢', - sob: '😭', - scream: '😱', - confounded: '😖', - persevere: '😣', - disappointed: '😞', - sweat: '😓', - weary: '😩', - tired_face: '😫', - rage: '😡', - pout: '😡', - angry: '😠', - smiling_imp: '😈', - smiley_cat: '😺', - smile_cat: '😸', - joy_cat: '😹', - heart_eyes_cat: '😻', - smirk_cat: '😼', - kissing_cat: '😽', - scream_cat: '🙀', - crying_cat_face: '😿', - pouting_cat: '😾', - heart: '❤️', - hand: '✋', - raised_hand: '✋', - v: '✌️', - point_up: '☝️', - fist_raised: '✊', - fist: '✊', - monkey_face: '🐵', - cat: '🐱', - cow: '🐮', - mouse: '🐭', - coffee: '☕', - hotsprings: '♨️', - anchor: '⚓', - airplane: '✈️', - hourglass: '⌛', - watch: '⌚', - sunny: '☀️', - star: '⭐', - cloud: '☁️', - umbrella: '☔', - zap: '⚡', - snowflake: '❄️', - sparkles: '✨', - black_joker: '🃏', - mahjong: '🀄', - phone: '☎️', - telephone: '☎️', - envelope: '✉️', - pencil2: '✏️', - black_nib: '✒️', - scissors: '✂️', - wheelchair: '♿', - warning: '⚠️', - aries: '♈', - taurus: '♉', - gemini: '♊', - cancer: '♋', - leo: '♌', - virgo: '♍', - libra: '♎', - scorpius: '♏', - sagittarius: '♐', - capricorn: '♑', - aquarius: '♒', - pisces: '♓', - heavy_multiplication_x: '✖️', - heavy_plus_sign: '➕', - heavy_minus_sign: '➖', - heavy_division_sign: '➗', - bangbang: '‼️', - interrobang: '⁉️', - question: '❓', - grey_question: '❔', - grey_exclamation: '❕', - exclamation: '❗', - heavy_exclamation_mark: '❗', - wavy_dash: '〰️', - recycle: '♻️', - white_check_mark: '✅', - ballot_box_with_check: '☑️', - heavy_check_mark: '✔️', - x: '❌', - negative_squared_cross_mark: '❎', - curly_loop: '➰', - loop: '➿', - part_alternation_mark: '〽️', - eight_spoked_asterisk: '✳️', - eight_pointed_black_star: '✴️', - sparkle: '❇️', - copyright: '©️', - registered: '®️', - tm: '™️', - information_source: 'ℹ️', - m: 'Ⓜ️', - black_circle: '⚫', - white_circle: '⚪', - black_large_square: '⬛', - white_large_square: '⬜', - black_medium_square: '◼️', - white_medium_square: '◻️', - black_medium_small_square: '◾', - white_medium_small_square: '◽', - black_small_square: '▪️', - white_small_square: '▫️', -}; diff --git a/client/src/components/Comment/CommentEditor/Emoji/index.module.scss b/client/src/components/Comment/CommentEditor/Emoji/index.module.scss deleted file mode 100644 index 04887ca9..00000000 --- a/client/src/components/Comment/CommentEditor/Emoji/index.module.scss +++ /dev/null @@ -1,36 +0,0 @@ -.wrapper { - display: flex; - display: flex; - width: 390px; - height: 240px; - overflow: auto; - flex-wrap: wrap; - flex-wrap: wrap; - - li { - position: relative; - display: flex; - width: 32px; - height: 32px; - font-size: 18px; - cursor: pointer; - align-items: center; - justify-content: center; - } -} - -.text { - display: flex; - align-items: center; - color: var(--disable-text-color); - cursor: pointer; - - &:hover { - color: var(--primary-color); - } - - > span { - margin-left: 4px; - transform: translateY(1px); - } -} diff --git a/client/src/components/Comment/CommentEditor/Emoji/index.tsx b/client/src/components/Comment/CommentEditor/Emoji/index.tsx deleted file mode 100644 index 5843f2a4..00000000 --- a/client/src/components/Comment/CommentEditor/Emoji/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Popover } from 'antd'; -import React from 'react'; - -import { emojis } from './emojis'; -import styles from './index.module.scss'; - -export const Emoji: React.FC<{ onClickEmoji: (arg: string) => void }> = ({ onClickEmoji, children }) => { - return ( - - {Object.keys(emojis).map((key) => { - return ( -
  • onClickEmoji(emojis[key])}> - {emojis[key]} -
  • - ); - })} - - } - placement="bottomRight" - trigger="click" - > - {children} -
    - ); -}; diff --git a/client/src/components/Comment/CommentEditor/index.module.scss b/client/src/components/Comment/CommentEditor/index.module.scss deleted file mode 100644 index b408edc3..00000000 --- a/client/src/components/Comment/CommentEditor/index.module.scss +++ /dev/null @@ -1,60 +0,0 @@ -.wrapper { - main { - display: flex; - flex-wrap: nowrap; - } - - .textareaWrapper { - position: relative; - flex: 1; - margin-left: 16px; - - .mask { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 100%; - height: 100%; - cursor: pointer; - } - - textarea { - border-color: var(--comment-editor-border-color); - color: var(--main-text-color); - background-color: var(--bg-second); - box-shadow: none !important; - } - } - - > footer { - display: flex; - justify-content: space-between; - padding-top: 8px; - padding-left: 44px; - - :global { - .ant-btn-primary[disabled] { - border-color: var(--comment-editor-border-color); - color: var(--main-text-color); - background-color: var(--comment-editor-disable-bg); - } - } - - .emojiTrigger { - display: flex; - align-items: center; - color: var(--disable-text-color); - cursor: pointer; - - &:hover { - color: var(--primary-color); - } - - > span { - margin-left: 4px; - transform: translateY(1px); - } - } - } -} diff --git a/client/src/components/Comment/CommentEditor/index.tsx b/client/src/components/Comment/CommentEditor/index.tsx deleted file mode 100644 index 7b9a0304..00000000 --- a/client/src/components/Comment/CommentEditor/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { Button, Input, message } from 'antd'; -import cls from 'classnames'; -import { useTranslations } from 'next-intl'; -import React, { useCallback, useContext, useMemo, useState } from 'react'; - -import { isValidUser, UserInfo } from '@/components/UserInfo'; -import { GlobalContext } from '@/context/global'; -import { useAsyncLoading } from '@/hooks/useAsyncLoading'; -import { useToggle } from '@/hooks/useToggle'; -import { CommentProvider } from '@/providers/comment'; - -import { Emoji } from './Emoji'; -import styles from './index.module.scss'; -import { default as Router } from 'next/router'; - -const { TextArea } = Input; - -interface Props { - hostId: string; - parentComment?: IComment; - replyComment?: IComment; - onOk?: () => void; - onClose?: () => void; - small?: boolean; -} - -export const CommentEditor: React.FC = ({ hostId, parentComment, replyComment, onOk, onClose, small }) => { - const t = useTranslations('commentNamespace'); - const { user } = useContext(GlobalContext); - const [addComment, loading] = useAsyncLoading(CommentProvider.addComment); - const [needSetInfo, toggleNeedSetInfo] = useToggle(false); - const [content, setContent] = useState(''); - // @ts-ignore - const hasValidUser = useMemo(() => isValidUser(user), [user]); - const textareaPlaceholder = useMemo( - () => (replyComment ? `${t('reply')} ${replyComment.name}` : t('replyPlaceholder')), - [t, replyComment] - ); - const textareaSize = useMemo(() => (small ? { minRows: 3, maxRows: 6 } : { minRows: 4, maxRows: 8 }), [small]); - const btnSize = useMemo(() => (small ? 'small' : 'middle'), [small]); - const emojiTrigger = ( - - - - - {t('emoji')} - - ); - - const onInput = useCallback( - (e) => { - if (!hasValidUser) { - return; - } - setContent(e.target.value); - }, - [hasValidUser] - ); - - const addEmoji = useCallback( - (emoji) => { - if (!hasValidUser) { - return; - } - setContent(`${content}${emoji}`); - }, - [content, hasValidUser] - ); - - const submit = useCallback(() => { - const data = { - hostId, - name: user.name, - email: user.email, - avatar: user.avatar || '', - content, - url: window.location.pathname, - }; - - if (parentComment && parentComment.id) { - Object.assign(data, { parentCommentId: parentComment.id }); - } - - if (replyComment) { - Object.assign(data, { - replyUserName: replyComment.name, - replyUserEmail: replyComment.email, - }); - } - - addComment(data).then(() => { - message.success(t('commentSuccess')); - setContent(''); - onOk && onOk(); - }); - }, [t, hostId, parentComment, replyComment, onOk, user, content, addComment]); - - return ( -
    -
    - -
    - {!hasValidUser && ( -
    { - if (user) { - message.warning(t('toggleNeedSetInfo')); - Router.push('/admin/ownspace'); - } else { - toggleNeedSetInfo(true); - } - }} - >
    - )} -