diff --git a/.gitea/workflows/app.yml b/.gitea/workflows/app.yml new file mode 100644 index 0000000..0d3ee7a --- /dev/null +++ b/.gitea/workflows/app.yml @@ -0,0 +1,67 @@ +name: Unified application CI and deployment +on: + pull_request: + branches: [main] + paths: + - 'apps/waterfall-wiki/**' + - 'packages/brand/**' + - 'scripts/**' + - 'Dockerfile' + - '.dockerignore' + - 'compose.yml' + - 'pnpm-workspace.yaml' + - '.gitea/workflows/app.yml' + push: + branches: [main] + paths: + - 'apps/waterfall-wiki/**' + - 'packages/brand/**' + - 'scripts/**' + - 'Dockerfile' + - '.dockerignore' + - 'compose.yml' + - 'pnpm-workspace.yaml' + - '.gitea/workflows/app.yml' +concurrency: + group: unified-app-${{ gitea.event.pull_request.number || gitea.ref }} + cancel-in-progress: false +jobs: + verify-and-release: + runs-on: ces-control + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check scripts and content contracts + run: | + git diff --check HEAD^ HEAD + for script in scripts/*.sh; do bash -n "$script"; done + node scripts/check-studio-contracts.mjs + node --check scripts/verify-studio-visual.cjs + node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs + - name: Build and verify the unified application once + run: | + docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" . + docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" . + - name: Verify candidate using persistent test infrastructure + env: + WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }} + WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }} + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/integration.sh + - name: Deploy unified Web and worker; verify production + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + run: bash scripts/deploy.sh "${{ gitea.sha }}" + - name: Save production acceptance evidence + id: evidence + if: always() && gitea.event_name == 'push' + uses: actions/upload-artifact@v3 + with: + name: unified-production-acceptance + path: artifacts/studio-acceptance/ + if-no-files-found: ignore + retention-days: 7 + - name: Remove uploaded local evidence + if: always() && steps.evidence.outcome == 'success' + run: rm -rf artifacts/studio-acceptance diff --git a/.gitea/workflows/brand.yml b/.gitea/workflows/brand.yml deleted file mode 100644 index 436bc88..0000000 --- a/.gitea/workflows/brand.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Endoleap brand checks -on: - pull_request: - branches: [main] - paths: - - '.dockerignore' - - 'packages/brand/**' - - 'pnpm-workspace.yaml' - - 'package.json' - - 'AGENTS.md' - - '.gitea/workflows/brand.yml' - push: - branches: [main] - paths: - - '.dockerignore' - - 'packages/brand/**' - - 'pnpm-workspace.yaml' - - '.gitea/workflows/brand.yml' -concurrency: - group: brand-${{ gitea.event.pull_request.number || gitea.ref }} - cancel-in-progress: true -jobs: - verify: - runs-on: ces-control - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - name: Verify brand assets, Tailwind compilation and typed exports in Docker - run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" . diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml deleted file mode 100644 index cbb2b86..0000000 --- a/.gitea/workflows/creative-workspace.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: creative workspace review checks -on: - pull_request: - branches: [main] - paths: - - '.dockerignore' - - 'Dockerfile' - - 'pnpm-workspace.yaml' - - 'apps/studio/**' - - 'packages/brand/**' - - 'apps/waterfall-wiki/**' - - '.gitea/workflows/**' - - 'scripts/*studio*' - - 'compose.creative-review.yml' - - 'scripts/integration.sh' - - 'scripts/cleanup-integration.sh' - - 'scripts/test-integration-cleanup.py' - - 'scripts/cleanup-review-images.sh' - - 'scripts/test-review-image-cleanup.py' -concurrency: - group: creative-workspace-${{ gitea.event.pull_request.number }} - cancel-in-progress: true -jobs: - verify-deployed-canvas: - runs-on: ces-control - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - name: Verify production acceptance against the designated deployed server - run: | - node --check scripts/verify-studio-visual.cjs - node --check apps/studio/scripts/infinite-canvas-smoke.cjs - bash -n scripts/verify-studio-production.sh - bash scripts/verify-studio-production.sh - - name: Save server acceptance evidence - id: acceptance_evidence - if: always() - uses: actions/upload-artifact@v3 - with: - name: studio-production-acceptance - path: artifacts/studio-acceptance/ - if-no-files-found: ignore - retention-days: 7 - - name: Remove temporary acceptance output after upload - if: always() && steps.acceptance_evidence.outcome == 'success' - run: rm -rf artifacts/studio-acceptance - verify: - runs-on: ces-control - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check patch formatting - run: | - git diff --check HEAD^ HEAD - bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh - node --check scripts/verify-studio-visual.cjs - python3 scripts/test-integration-cleanup.py - python3 scripts/test-review-image-cleanup.py - node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs - - name: Verify Studio types, tests, build and smoke - run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" . - - name: Build Wiki integration and runtime images - run: | - docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" . - docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" . - - name: Verify isolated PostgreSQL, S3 and browser integration - env: - STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }} - WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }} - WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }} - GITEA_RUN_ID: ${{ gitea.run_id }} - run: bash scripts/integration.sh - - name: Clean this run disposable integration resources even after cancellation - if: always() - env: - GITEA_RUN_ID: ${{ gitea.run_id }} - run: bash scripts/cleanup-integration.sh - - name: Remove this run disposable review image tags - if: always() - env: - REVIEW_SHA: ${{ gitea.sha }} - run: bash scripts/cleanup-review-images.sh diff --git a/.gitea/workflows/waterfall-wiki.yml b/.gitea/workflows/waterfall-wiki.yml deleted file mode 100644 index 9d10db1..0000000 --- a/.gitea/workflows/waterfall-wiki.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: creative workspace CI and deployment -on: - push: - branches: [main] - paths: - - '.dockerignore' - - 'apps/waterfall-wiki/**' - - 'apps/studio/**' - - 'packages/brand/**' - - 'scripts/*studio*' - - 'content/studio-examples/**' - - 'content/studio-tools/layouts-api.md' - - '.gitea/workflows/waterfall-wiki.yml' - - 'Dockerfile' - - 'compose.yml' - - 'scripts/deploy.sh' - - 'scripts/wiki-image-lifecycle.py' - - 'scripts/infra.sh' - - 'scripts/auth-admin.sh' - - 'scripts/integration.sh' - - 'scripts/cleanup-integration.sh' - - 'scripts/test-integration-cleanup.py' - - 'pnpm-workspace.yaml' - -jobs: - verify-and-deploy: - if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' - runs-on: ces-control - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check shared content and documentation contracts - run: | - python3 scripts/test-integration-cleanup.py - bash -n scripts/integration.sh scripts/cleanup-integration.sh - node scripts/check-studio-contracts.mjs - node --check scripts/export-studio-series.cjs - node --check scripts/save-studio-series.mjs - bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh - - name: Build and verify Studio - run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" . - - name: Build, verify, deploy and retain wiki images - env: - RELEASE_SHA: ${{ gitea.sha }} - run: | - if [ "$(id -u)" -eq 0 ]; then - python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA" - else - sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA" - fi - - name: Release Studio after Wiki routes are deployed - run: bash scripts/deploy-studio.sh "${{ gitea.sha }}" - - name: Verify the deployed creative workspace - run: bash scripts/verify-studio-production.sh - - name: Save server acceptance evidence - id: acceptance_evidence - if: always() - uses: actions/upload-artifact@v3 - with: - name: studio-production-acceptance - path: artifacts/studio-acceptance/ - if-no-files-found: ignore - retention-days: 7 - - name: Remove temporary acceptance output after upload - if: always() && steps.acceptance_evidence.outcome == 'success' - run: rm -rf artifacts/studio-acceptance - - name: Clean this run disposable integration resources even after cancellation - if: always() - env: - GITEA_RUN_ID: ${{ gitea.run_id }} - run: bash scripts/cleanup-integration.sh diff --git a/Dockerfile b/Dockerfile index 363449f..0b346ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY apps/waterfall-wiki/ . RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke FROM build AS integration +ENV VERIFY_CANVAS_UI=1 USER node CMD ["npm", "run", "test:integration"] diff --git a/README.md b/README.md index 1b6a690..721a33e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源 | 路径 | 职责 | | --- | --- | | `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 | -| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API | +| `apps/waterfall-wiki/src/studio` | 合并后的创作工具、排版渲染与编辑器模块 | | `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 | | `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 | | `scripts` | CI 部署、运维、API 发布与作者工具 | @@ -71,8 +71,8 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源 pnpm install pnpm --dir apps/waterfall-wiki typecheck pnpm --dir apps/waterfall-wiki test -pnpm --dir apps/studio typecheck -bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh +node scripts/check-studio-contracts.mjs +for script in scripts/*.sh; do bash -n "$script"; done ``` 完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。 @@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh ## 部署与持久化 -Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。 +Web 应用由 `.gitea/workflows/app.yml` 统一验证与发布。Wiki 和 Studio 已合并成一个 Next.js 应用,一次编译、一个 Web 运行镜像,Web 与后台 worker 使用同一镜像。PostgreSQL/S3 基础服务长期运行,不在日常发布中重建。PR 验证候选版本但不部署;只有 main push 才更新生产。完整步骤见 [统一部署流程](docs/unified-deployment.md)。 | 服务 | 入口/端口 | | --- | --- | | `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 | | PostgreSQL | `127.0.0.1:18132` | | 对象存储 | `127.0.0.1:18133` | -| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 | +| 编辑器 | 与知识站共享 `18131`;原生路由 `/graphic`、`/infinite_canvas`,历史 `/studio` 页面与 API 保留兼容 | | Python | 无网络端口,按需运行 | 生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。 diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile deleted file mode 100644 index d73b6b4..0000000 --- a/apps/studio/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM node:24-bookworm-slim AS build -WORKDIR /repo/apps/studio -RUN corepack enable && corepack prepare pnpm@11.25.0 --activate -COPY apps/studio/pnpm-lock.yaml ./ -COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml -COPY packages/brand/package.json /repo/packages/brand/package.json -COPY apps/studio/package.json ./ -RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8 -# Brand source changes invalidate compilation, not dependency installation. -COPY packages/brand/ /repo/packages/brand/ -COPY apps/studio/ . -# Compare the two public parameter validators inside the isolated CI build. -COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts -COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/series.ts -COPY apps/waterfall-wiki/src/lib/canvas-document.ts /repo/apps/waterfall-wiki/src/lib/canvas-document.ts -RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke -FROM node:24-bookworm-slim -WORKDIR /app/apps/studio -ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 -COPY --from=build --chown=node:node /repo/apps/studio/.next/standalone/ /app/ -COPY --from=build --chown=node:node /repo/apps/studio/.next/static/ ./.next/static/ -COPY --from=build --chown=node:node /repo/apps/studio/public/ ./public/ -USER node -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts deleted file mode 100644 index 15849f6..0000000 --- a/apps/studio/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts deleted file mode 100644 index 67416ad..0000000 --- a/apps/studio/next.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { resolve } from "node:path"; -import type { NextConfig } from "next"; -const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false }; -export default config; diff --git a/apps/studio/package.json b/apps/studio/package.json deleted file mode 100644 index 0b75aab..0000000 --- a/apps/studio/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@prompt/studio", - "private": true, - "scripts": { - "dev": "pnpm brand:prepare && next dev -p 3001", - "build": "pnpm brand:prepare && next build", - "start": "next start", - "typecheck": "next typegen && tsc --noEmit", - "test": "node --test tests/*.test.mjs", - "test:smoke": "node scripts/smoke.mjs", - "brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs" - }, - "dependencies": { - "next": "16.4.2", - "react": "19.3.1", - "react-dom": "19.3.1", - "lucide-react": "1.61.0", - "@endoleap/brand": "workspace:*" - }, - "devDependencies": { - "@tailwindcss/postcss": "4.3.3", - "@types/node": "24.19.2", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "tailwindcss": "4.3.3", - "typescript": "5.9.3" - }, - "packageManager": "pnpm@11.25.0" -} diff --git a/apps/studio/pnpm-lock.yaml b/apps/studio/pnpm-lock.yaml deleted file mode 100644 index d65df4b..0000000 --- a/apps/studio/pnpm-lock.yaml +++ /dev/null @@ -1,1033 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@endoleap/brand': - specifier: workspace:* - version: link:../../packages/brand - lucide-react: - specifier: 1.61.0 - version: 1.61.0(@types/react@19.3.0)(react@19.3.1) - next: - specifier: 16.4.2 - version: 16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - react: - specifier: 19.3.1 - version: 19.3.1 - react-dom: - specifier: 19.3.1 - version: 19.3.1(react@19.3.1) - devDependencies: - '@tailwindcss/postcss': - specifier: 4.3.3 - version: 4.3.3 - '@types/node': - specifier: 24.19.2 - version: 24.19.2 - '@types/react': - specifier: 19.3.0 - version: 19.3.0 - '@types/react-dom': - specifier: 19.3.0 - version: 19.3.0(@types/react@19.3.0) - tailwindcss: - specifier: 4.3.3 - version: 4.3.3 - typescript: - specifier: 5.9.3 - version: 5.9.3 - -packages: - - '@alloc/quick-lru@5.3.0': - resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} - engines: {node: '>=10'} - - '@emnapi/runtime@1.11.3': - resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.35.5': - resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.35.5': - resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [darwin] - - '@img/sharp-freebsd-wasm32@0.35.5': - resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} - engines: {node: '>=20.9.0'} - os: [freebsd] - - '@img/sharp-libvips-darwin-arm64@1.3.4': - resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.3.4': - resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.3.4': - resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.3.4': - resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.3.4': - resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.3.4': - resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.3.4': - resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.3.4': - resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.3.4': - resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.3.4': - resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.35.5': - resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.35.5': - resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} - engines: {node: '>=20.9.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.35.5': - resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} - engines: {node: '>=20.9.0'} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.35.5': - resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} - engines: {node: '>=20.9.0'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.35.5': - resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} - engines: {node: '>=20.9.0'} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.35.5': - resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.35.5': - resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.35.5': - resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.35.5': - resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} - engines: {node: '>=20.9.0'} - - '@img/sharp-webcontainers-wasm32@0.35.5': - resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} - engines: {node: '>=20.9.0'} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.35.5': - resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} - engines: {node: '>=20.9.0'} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.35.5': - resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} - engines: {node: ^20.9.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.35.5': - resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} - engines: {node: '>=20.9.0'} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.6.0': - resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@next/env@16.4.2': - resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==} - - '@next/swc-darwin-arm64@16.4.2': - resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@16.4.2': - resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@16.4.2': - resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-arm64-musl@16.4.2': - resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@next/swc-linux-x64-gnu@16.4.2': - resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-x64-musl@16.4.2': - resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@next/swc-win32-arm64-msvc@16.4.2': - resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@16.4.2': - resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@swc/helpers@0.5.23': - resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - - '@tailwindcss/node@4.3.3': - resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - - '@tailwindcss/oxide-android-arm64@4.3.3': - resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.3.3': - resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.3.3': - resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.3.3': - resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': - resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': - resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.3.3': - resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.3.3': - resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.3.3': - resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.3.3': - resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.3.3': - resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.3.3': - resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} - engines: {node: '>= 20'} - - '@tailwindcss/postcss@4.3.3': - resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - - '@types/node@24.19.2': - resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} - - '@types/react-dom@19.3.0': - resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} - peerDependencies: - '@types/react': ^19.3.0 - - '@types/react@19.3.0': - resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} - - baseline-browser-mapping@2.11.32: - resolution: {integrity: sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - caniuse-lite@1.0.30001819: - resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - enhanced-resolve@5.26.0: - resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} - engines: {node: '>=10.13.0'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - lucide-react@1.61.0: - resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==} - peerDependencies: - '@types/react': '*' - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - nanoid@3.3.20: - resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - next@16.4.2: - resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - postcss@8.5.23: - resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.31: - resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} - engines: {node: ^10 || ^12 || >=14} - - react-dom@19.3.1: - resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} - peerDependencies: - react: ^19.3.1 - - react@19.3.1: - resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} - engines: {node: '>=0.10.0'} - - scheduler@0.28.0: - resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.35.5: - resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} - engines: {node: '>=20.9.0'} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - source-map-js@1.2.2: - resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} - engines: {node: '>=0.10.0'} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - - tailwindcss@4.3.3: - resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - -snapshots: - - '@alloc/quick-lru@5.3.0': {} - - '@emnapi/runtime@1.11.3': - dependencies: - tslib: 2.8.1 - optional: true - - '@img/colour@1.1.0': - optional: true - - '@img/sharp-darwin-arm64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.4 - optional: true - - '@img/sharp-darwin-x64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.4 - optional: true - - '@img/sharp-freebsd-wasm32@0.35.5': - dependencies: - '@img/sharp-wasm32': 0.35.5 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.3.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.3.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.3.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.3.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.3.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.3.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.3.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.3.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.3.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.3.4': - optional: true - - '@img/sharp-linux-arm64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.4 - optional: true - - '@img/sharp-linux-arm@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.4 - optional: true - - '@img/sharp-linux-ppc64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.4 - optional: true - - '@img/sharp-linux-riscv64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.4 - optional: true - - '@img/sharp-linux-s390x@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.4 - optional: true - - '@img/sharp-linux-x64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.35.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.4 - optional: true - - '@img/sharp-wasm32@0.35.5': - dependencies: - '@emnapi/runtime': 1.11.3 - optional: true - - '@img/sharp-webcontainers-wasm32@0.35.5': - dependencies: - '@img/sharp-wasm32': 0.35.5 - optional: true - - '@img/sharp-win32-arm64@0.35.5': - optional: true - - '@img/sharp-win32-ia32@0.35.5': - optional: true - - '@img/sharp-win32-x64@0.35.5': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.6.0': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 - - '@next/env@16.4.2': {} - - '@next/swc-darwin-arm64@16.4.2': - optional: true - - '@next/swc-darwin-x64@16.4.2': - optional: true - - '@next/swc-linux-arm64-gnu@16.4.2': - optional: true - - '@next/swc-linux-arm64-musl@16.4.2': - optional: true - - '@next/swc-linux-x64-gnu@16.4.2': - optional: true - - '@next/swc-linux-x64-musl@16.4.2': - optional: true - - '@next/swc-win32-arm64-msvc@16.4.2': - optional: true - - '@next/swc-win32-x64-msvc@16.4.2': - optional: true - - '@swc/helpers@0.5.23': - dependencies: - tslib: 2.8.1 - - '@tailwindcss/node@4.3.3': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.26.0 - jiti: 2.7.0 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.2 - tailwindcss: 4.3.3 - - '@tailwindcss/oxide-android-arm64@4.3.3': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.3.3': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.3.3': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.3.3': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.3.3': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.3.3': - optional: true - - '@tailwindcss/oxide@4.3.3': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.3 - '@tailwindcss/oxide-darwin-arm64': 4.3.3 - '@tailwindcss/oxide-darwin-x64': 4.3.3 - '@tailwindcss/oxide-freebsd-x64': 4.3.3 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 - '@tailwindcss/oxide-linux-x64-musl': 4.3.3 - '@tailwindcss/oxide-wasm32-wasi': 4.3.3 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - - '@tailwindcss/postcss@4.3.3': - dependencies: - '@alloc/quick-lru': 5.3.0 - '@tailwindcss/node': 4.3.3 - '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.31 - tailwindcss: 4.3.3 - - '@types/node@24.19.2': - dependencies: - undici-types: 7.24.6 - - '@types/react-dom@19.3.0(@types/react@19.3.0)': - dependencies: - '@types/react': 19.3.0 - - '@types/react@19.3.0': - dependencies: - csstype: 3.2.3 - - baseline-browser-mapping@2.11.32: {} - - caniuse-lite@1.0.30001819: {} - - client-only@0.0.1: {} - - csstype@3.2.3: {} - - detect-libc@2.1.2: {} - - enhanced-resolve@5.26.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - graceful-fs@4.2.11: {} - - jiti@2.7.0: {} - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - lucide-react@1.61.0(@types/react@19.3.0)(react@19.3.1): - dependencies: - react: 19.3.1 - optionalDependencies: - '@types/react': 19.3.0 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - - nanoid@3.3.20: {} - - next@16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): - dependencies: - '@next/env': 16.4.2 - '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.32 - caniuse-lite: 1.0.30001819 - postcss: 8.5.23 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - styled-jsx: 5.1.6(react@19.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 16.4.2 - '@next/swc-darwin-x64': 16.4.2 - '@next/swc-linux-arm64-gnu': 16.4.2 - '@next/swc-linux-arm64-musl': 16.4.2 - '@next/swc-linux-x64-gnu': 16.4.2 - '@next/swc-linux-x64-musl': 16.4.2 - '@next/swc-win32-arm64-msvc': 16.4.2 - '@next/swc-win32-x64-msvc': 16.4.2 - sharp: 0.35.5(@types/node@24.19.2) - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - babel-plugin-macros - - picocolors@1.1.1: {} - - postcss@8.5.23: - dependencies: - nanoid: 3.3.20 - picocolors: 1.1.1 - source-map-js: 1.2.2 - - postcss@8.5.31: - dependencies: - nanoid: 3.3.20 - picocolors: 1.1.1 - source-map-js: 1.2.2 - - react-dom@19.3.1(react@19.3.1): - dependencies: - react: 19.3.1 - scheduler: 0.28.0 - - react@19.3.1: {} - - scheduler@0.28.0: {} - - semver@7.8.5: - optional: true - - sharp@0.35.5(@types/node@24.19.2): - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.5 - '@img/sharp-darwin-x64': 0.35.5 - '@img/sharp-freebsd-wasm32': 0.35.5 - '@img/sharp-libvips-darwin-arm64': 1.3.4 - '@img/sharp-libvips-darwin-x64': 1.3.4 - '@img/sharp-libvips-linux-arm': 1.3.4 - '@img/sharp-libvips-linux-arm64': 1.3.4 - '@img/sharp-libvips-linux-ppc64': 1.3.4 - '@img/sharp-libvips-linux-riscv64': 1.3.4 - '@img/sharp-libvips-linux-s390x': 1.3.4 - '@img/sharp-libvips-linux-x64': 1.3.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 - '@img/sharp-libvips-linuxmusl-x64': 1.3.4 - '@img/sharp-linux-arm': 0.35.5 - '@img/sharp-linux-arm64': 0.35.5 - '@img/sharp-linux-ppc64': 0.35.5 - '@img/sharp-linux-riscv64': 0.35.5 - '@img/sharp-linux-s390x': 0.35.5 - '@img/sharp-linux-x64': 0.35.5 - '@img/sharp-linuxmusl-arm64': 0.35.5 - '@img/sharp-linuxmusl-x64': 0.35.5 - '@img/sharp-webcontainers-wasm32': 0.35.5 - '@img/sharp-win32-arm64': 0.35.5 - '@img/sharp-win32-ia32': 0.35.5 - '@img/sharp-win32-x64': 0.35.5 - '@types/node': 24.19.2 - optional: true - - source-map-js@1.2.2: {} - - styled-jsx@5.1.6(react@19.3.1): - dependencies: - client-only: 0.0.1 - react: 19.3.1 - - tailwindcss@4.3.3: {} - - tapable@2.3.3: {} - - tslib@2.8.1: {} - - typescript@5.9.3: {} - - undici-types@7.24.6: {} diff --git a/apps/studio/postcss.config.mjs b/apps/studio/postcss.config.mjs deleted file mode 100644 index a2dc18a..0000000 --- a/apps/studio/postcss.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -export default { - plugins: { "@tailwindcss/postcss": {} }, -}; diff --git a/apps/studio/scripts/graphic-smoke.cjs b/apps/studio/scripts/graphic-smoke.cjs deleted file mode 100644 index c9a1efd..0000000 --- a/apps/studio/scripts/graphic-smoke.cjs +++ /dev/null @@ -1,24 +0,0 @@ -const {chromium}=require('playwright-core'); -const assert=require('node:assert/strict'); -(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message)); - await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}})); - await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic'); - await page.getByRole('button',{name:'切换图层面板',exact:true}).click(); - assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false); - await page.getByRole('button',{name:'切换图层面板',exact:true}).click(); - await page.getByRole('button',{name:'切换属性面板',exact:true}).click(); - assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false); - await page.getByRole('button',{name:'切换属性面板',exact:true}).click(); - await page.getByRole('button',{name:'添加文字',exact:true}).click(); - await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始'); - await page.getByRole('button',{name:'添加矩形',exact:true}).click(); - await page.getByRole('button',{name:'撤销',exact:true}).click(); - await page.getByRole('button',{name:'重做',exact:true}).click(); - await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor(); - assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始'))); - const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg')); - await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'}); - await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true}); - await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); - assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.'); - }finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs deleted file mode 100644 index 729976b..0000000 --- a/apps/studio/scripts/smoke.mjs +++ /dev/null @@ -1,29 +0,0 @@ -/** CI-only isolated runtime checks, never a local preview workflow. */ -import {readFileSync} from 'node:fs'; -import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises'; -const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002'; -const standardId='00000000-0000-4000-8000-000000000003'; -const seriesId='00000000-0000-4000-8000-000000000004'; -const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document; -const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId; -let reads=0,mediaReads=0; -const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))}); -wiki.listen(0,'127.0.0.1');await once(wiki,'listening'); -const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/studio/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port; -try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28); -for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render '}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));} -assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures'); -const headers={cookie:'session=smoke-test'}; -for(const id of [designId,standardId]){ -const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml)); -} -const complete=await fetch(base+'/studio/template-layout?id='+seriesId,{headers});assert.equal(complete.status,200);const completeHtml=await complete.text(),artwork=completeHtml.match(//)[0];assert.equal((artwork.match(/data-page-key=/g)||[]).length,3);assert.ok(artwork.includes('data-style-version="autumn-journal/1"'));assert.ok(artwork.includes('把秋天'));assert.ok(artwork.includes('先看见'));assert.ok(artwork.includes('留一点白'));assert.ok(!/button|contenteditable|workbench-top/.test(artwork)); -const second=await fetch(base+'/studio/template-layout?id='+seriesId+'&page=2&export=1',{headers});assert.equal(second.status,200);const secondHtml=(await second.text()).match(//)[0];assert.equal((secondHtml.match(/data-page-key=/g)||[]).length,1);assert.ok(secondHtml.includes('data-page="2"'));assert.ok(secondHtml.includes('height:1440px'));assert.ok(secondHtml.includes('width=2160'));assert.ok(secondHtml.includes('profile=export'));assert.ok(secondHtml.includes('Noto Serif CJK SC')); -for(const suffix of ['0','4','bad'])assert.equal((await fetch(base+'/studio/template-layout?id='+seriesId+'&page='+suffix,{headers})).status,404); -for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404); -assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404); -assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404); -const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid); -const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId); -const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId); -assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))} diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts deleted file mode 100644 index d170267..0000000 --- a/apps/studio/src/lib/canvas-document.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type NodeKind = 'text' | 'image' | 'prompt' | 'script'; -export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number}; -export type CanvasNode = { - id:string;kind:NodeKind;title:string;text:string;x:number;y:number; - image?:string;width?:number;height?:number;style?:TextStyle; - instruction?:string; - operation?:'generate'|'upscale'|'script'; -}; -export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]}; -const kinds:NodeKind[]=['text','image','prompt','script']; -const object=(v:unknown):v is Record=>!!v&&typeof v==='object'&&!Array.isArray(v); -const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max; -const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max; -const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v); - -export function parseCanvasDocument(value:unknown):CanvasDocument { - const d=typeof value==='string'?JSON.parse(value):value; - if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确'); - const ids=new Set(); - const cards:CanvasNode[]=d.cards.map((n:unknown)=>{ - if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确'); - if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确'); - if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确'); - if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确'); - let style:TextStyle|undefined; - if(n.style!==undefined){ - if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确'); - style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})}; - } - ids.add(n.id as string); - return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})}; - }); - const pairs=new Set(); - const links:[string,string][]=d.links.map((l:unknown)=>{ - if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在'); - const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]]; - }); - const outgoing=new Map(),indegree=new Map(cards.map(n=>[n.id,0])); - for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)} - const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0; - while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}} - if(visited!==cards.length)throw Error('引用不能形成循环'); - return {version:1,cards,links}; -} - -export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{ - return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean); -} -export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument { - if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点'); - if(doc.links.some(([a,b])=>a===from&&b===to))return doc; - if(doc.links.length>=200)throw Error('最多 200 条引用'); - const seen=new Set();const pending=[to]; - while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));} - return {...doc,links:[...doc.links,[from,to]]}; -} -export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { - return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))}; -} -export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument { - if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确'); - const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0]; - for(let attempt=0;attempt<=doc.cards.length+1;attempt++){ - const blockers=doc.cards.filter(n=>target.xn.x&&target.yn.y); - if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id)); - const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64)); - target.y=nextY>100000?-100000:nextY; - } - throw Error('请移动现有节点后再创建引用节点'); -} -export function agentTask(doc:CanvasDocument,id:string){ - const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); - const instruction=node.instruction??node.text; - if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求'); - const references=referencedNodes(doc,id); - if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片'); - return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}}; -} diff --git a/apps/studio/src/lib/layout-parameters.ts b/apps/studio/src/lib/layout-parameters.ts deleted file mode 100644 index f6125b0..0000000 --- a/apps/studio/src/lib/layout-parameters.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string}; -export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]}; -export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number}; -export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number}; -export type DecorationValue={color?:string;hidden?:boolean}; -export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean}; -export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'}; -export type Parameters={texts:Record;images:Record;decorations:Record;paper?:string;layers?:Record;added?:AddedSlot[]}; -export class LayoutParameterError extends Error{} -export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v); -function fail(message:string):never{throw new LayoutParameterError(message)} -function object(v:unknown):Record{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record} -function keys(v:Record,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')} -function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||vmax)fail(`数值范围 ${min}–${max}`);return v} -function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v} -export function parameters(layout:Layout,input:unknown):Parameters{ - const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}}; - const added:AddedSlot[]=[]; - if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added} - const elements=[...layout.elements,...added]; - if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}} - if(raw.paper!==undefined)result.paper=color(raw.paper); - for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){ - const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id)); - for(const [id,value] of Object.entries(entries)){const v=object(value); - if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t; - }else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image; - }else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d} - } - } - return result; -} diff --git a/apps/studio/src/lib/series.ts b/apps/studio/src/lib/series.ts deleted file mode 100644 index 214d425..0000000 --- a/apps/studio/src/lib/series.ts +++ /dev/null @@ -1,42 +0,0 @@ -import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters'; -export type Typography=Required>; -export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record;parameters:Parameters}[]}; -function fail(message:string):never{throw new LayoutParameterError(message)} -const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record}; -function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o} -function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string} -function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||vmax)fail('作品数值超出范围');return v as number} -/** Closed, bounded content document. The same validator runs in persistence and SSR. */ -export function series(input:unknown,layouts:Layout[]):Series{ - const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本'); - const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000); - const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80); - const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB'); - const typography=exact(style.typography,['title','body','caption']); - for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)} - const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大'); - if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条'); - const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]}); - if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页'); - const seen=new Set(); - const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key); - const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')} - // Every visible text is authored; template placeholder copy must never leak into a finished series. - for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素'); - for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程'); - return {...p,parameters:params,bindings} as Series['pages'][number]}); - return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages}; -} -/** Style units use a 120-wide reference canvas; explicit element values always win. */ -export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{ - const p=page.parameters,s=doc.style,k=layout.width/120; - const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}}; - for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}} - for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}} - if(layout.id==='standard-01'){ - const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m; - const boxes:Record={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]}; - for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]}; - } - return result; -} diff --git a/apps/studio/src/pages/_app.tsx b/apps/studio/src/pages/_app.tsx deleted file mode 100644 index 680063e..0000000 --- a/apps/studio/src/pages/_app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import type { AppProps } from "next/app"; -import "../styles/globals.css"; -import "../styles/brand.css"; -export default function App({Component,pageProps}:AppProps){const router = useRouter(); - useEffect(() => { const scope = /^\/(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]); - return ;} diff --git a/apps/studio/src/pages/_document.tsx b/apps/studio/src/pages/_document.tsx deleted file mode 100644 index 68e90da..0000000 --- a/apps/studio/src/pages/_document.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { Html, Head, Main, NextScript } from 'next/document'; -const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`; -export default function Document() { return ',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false}; const doc={name:'作品',background:'#ffffff',items:[item]}; test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)}); diff --git a/apps/studio/tests/layout-images.test.mjs b/apps/waterfall-wiki/tests/studio/layout-images.test.mjs similarity index 96% rename from apps/studio/tests/layout-images.test.mjs rename to apps/waterfall-wiki/tests/studio/layout-images.test.mjs index a091b3f..873562d 100644 --- a/apps/studio/tests/layout-images.test.mjs +++ b/apps/waterfall-wiki/tests/studio/layout-images.test.mjs @@ -1,6 +1,6 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; -import {embedSvgMedia} from '../src/lib/layout-images.ts'; +import {embedSvgMedia} from '../../src/studio/lib/layout-images.ts'; const id='a05dc841-240a-5a3a-b01b-d589fc21cd36'; test('SVG export fetches each internal media once with the caller authentication',async()=>{ const original=globalThis.fetch;let calls=0; diff --git a/apps/studio/tests/layout-parameters.test.mjs b/apps/waterfall-wiki/tests/studio/layout-parameters.test.mjs similarity index 70% rename from apps/studio/tests/layout-parameters.test.mjs rename to apps/waterfall-wiki/tests/studio/layout-parameters.test.mjs index 3185fba..05bf05c 100644 --- a/apps/studio/tests/layout-parameters.test.mjs +++ b/apps/waterfall-wiki/tests/studio/layout-parameters.test.mjs @@ -1,7 +1,7 @@ import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs'; -import {parameters} from '../src/lib/layout-parameters.ts'; -const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url))); +import {parameters} from '../../src/studio/lib/layout-parameters.ts'; +const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url))); test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}}); test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'');assert.equal(p.decorations.D01.hidden,true)}); -test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}}); +test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}}); test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))}); diff --git a/apps/studio/tests/native-layout.test.mjs b/apps/waterfall-wiki/tests/studio/native-layout.test.mjs similarity index 91% rename from apps/studio/tests/native-layout.test.mjs rename to apps/waterfall-wiki/tests/studio/native-layout.test.mjs index b4e3f29..578b2a9 100644 --- a/apps/studio/tests/native-layout.test.mjs +++ b/apps/waterfall-wiki/tests/studio/native-layout.test.mjs @@ -1,8 +1,8 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; -import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../src/lib/native-layout.ts'; -const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url))); +import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../../src/studio/lib/native-layout.ts'; +const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url))); test('all 27 native pages are independent of source images and supply actual text',()=>{ for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000); for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}} diff --git a/apps/studio/tests/series.test.mjs b/apps/waterfall-wiki/tests/studio/series.test.mjs similarity index 77% rename from apps/studio/tests/series.test.mjs rename to apps/waterfall-wiki/tests/studio/series.test.mjs index f565f95..c3a226f 100644 --- a/apps/studio/tests/series.test.mjs +++ b/apps/waterfall-wiki/tests/studio/series.test.mjs @@ -3,10 +3,9 @@ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {registerHooks} from 'node:module'; registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}}); -const {series,pageParameters}=await import('../src/lib/series.ts'); -const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url))); +const {series,pageParameters}=await import('../../src/studio/lib/series.ts'); +const layout=JSON.parse(readFileSync(new URL('../../src/studio/content/standard-layout.json',import.meta.url))); // Fixture copied into the CI image; no build is performed by these tests. -const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))); +const input=JSON.parse(readFileSync(new URL('../../fixtures/studio/autumn-journal.json',import.meta.url))); test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')}); test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}}); -test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8'))); diff --git a/apps/waterfall-wiki/tsconfig.json b/apps/waterfall-wiki/tsconfig.json index 6d66853..1969bc3 100644 --- a/apps/waterfall-wiki/tsconfig.json +++ b/apps/waterfall-wiki/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -14,8 +18,20 @@ "jsx": "react-jsx", "incremental": true, "baseUrl": ".", - "paths": { "@/*": ["./src/*"] } + "paths": { + "@/*": [ + "./src/*" + ] + }, + "allowImportingTsExtensions": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/compose.creative-review.yml b/compose.creative-review.yml deleted file mode 100644 index c5fbd80..0000000 --- a/compose.creative-review.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Added only by isolated CI integration; no production ports or volumes. -services: - prompt-studio: - image: ${STUDIO_REVIEW_IMAGE:?Build the Studio review image first} - init: true - environment: - HOSTNAME: 0.0.0.0 - PORT: "3000" - STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000 - security_opt: - - no-new-privileges:true - cap_drop: [ALL] - healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/studio/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] - interval: 5s - timeout: 8s - retries: 24 - tests: - environment: - VERIFY_CANVAS_UI: "1" diff --git a/docs/docker-build-cache.md b/docs/docker-build-cache.md index 72aa2c8..472f572 100644 --- a/docs/docker-build-cache.md +++ b/docs/docker-build-cache.md @@ -9,15 +9,15 @@ ## 依赖层与源码层 - Wiki 的根 Dockerfile 是实际 CI 入口,`apps/waterfall-wiki/Dockerfile` 是对应副本,两者的功能内容保持同步;根文件额外保留镜像生命周期标签。 -- Web 应用安装前只复制自身清单、锁文件、workspace 配置和品牌包清单,满足 `workspace:*` 依赖发现。完整品牌包在安装后复制;颜色、Logo 或品牌脚本变化仍会重新编译应用,但不会仅因此重新下载依赖。 +- 统一 Web 应用安装前只复制自身清单、锁文件、workspace 配置和品牌包清单,满足 `workspace:*` 依赖发现。完整品牌包在安装后复制;颜色、Logo 或品牌脚本变化仍会重新编译应用,但不会仅因此重新下载依赖。 - Python 从 `pyproject.toml` 读取固定运行依赖与构建后端要求,在独立依赖阶段安装。源码阶段安装当前包并运行单测,最终镜像仅继承系统运行环境并复制已验证的虚拟环境,不携带应用源码目录和测试目录。虚拟环境仍包含 pip 和构建后端,未声称完全剥离构建工具。 -- 品牌检查镜像只用于 CI。其 Python 图片校验依赖安装放在品牌源码之前,避免源码变化重新下载这些依赖。 -- 不因共享包缓存命中而漏掉消费者:品牌变更需触发 Wiki/Studio PR 检查和 main 发布。根忽略规则变化也触发相关检查。 +- 品牌包只作为 workspace 依赖随 Web 应用构建,不提供独立 Dockerfile、镜像构建工作流或常驻服务。专用品牌检查已停止自动运行,保留测试脚本供明确需要时在远程执行。 +- 不因共享包缓存命中而漏掉消费者:品牌变更需触发 统一应用 PR 检查和 main 发布。根忽略规则变化也触发相关检查。 ## 验证与限制 -构建、打包与产物验证只在已确认的 `ces-control` runner 执行。Python PR 运行镜像构建和单测,但只有 main push 才会发布 latest 标签。Web PR 与品牌检查沿用现有远程 Docker 验证流程。 +构建、打包与产物验证只在已确认的 `ces-control` runner 执行。Python PR 运行镜像构建和单测,但只有 main push 才会发布 latest 标签。Web PR 沿用现有远程 Docker 验证流程,不再单独构建品牌检查镜像。 -本次没有升级 Docker builder,也没有承诺消除 legacy builder 的镜像层提交耗时;缓存层调整后的实际收益需比较后续 CI 日志。源码变化仍需构建,持久测试环境和按影响范围选择测试属于后续 CI 调整。 +本次没有升级 Docker builder,也没有承诺消除 legacy builder 的镜像层提交耗时;缓存层调整后的实际收益需比较后续 CI 日志。源码变化仍需构建,当前采用持久测试基础设施;本次应用合并仍保留完整集成验证,按影响范围选择测试留作后续优化。 本机不新增 Dev Containers/Tilt 启动方式;这类方案需要单独确认本地运行和编译授权。 diff --git a/docs/monorepo.md b/docs/monorepo.md index 25cba82..652fae1 100644 --- a/docs/monorepo.md +++ b/docs/monorepo.md @@ -1,20 +1,16 @@ # PROMPT monorepo - `apps/waterfall-wiki`: existing Pages Router application, media library, PostgreSQL, object storage and video processing worker. `/wiki/Namespace:slug` has matching `/api/wiki/Namespace:slug` Markdown; `/api/wiki-index` discovers pages. Existing `/p/slug` has `/api/wiki-post/slug` and alternate metadata. Main/File/Category/Template/Draft/Help are public content types, not permission boundaries. Categories cross namespaces; template pages currently describe reusable structures rather than a transclusion engine. -- `apps/studio`: independent Next.js + TypeScript + Tailwind Pages Router. Add ideas in `src/content/ideas.ts`, open `/ideas/slug`, export Markdown or versioned JSON, or print to PDF. Add custom React pages under `src/pages` when a composition needs bespoke rendering. +- `apps/waterfall-wiki/src/studio`: merged editor modules in the shared Pages Router application. Add ideas in `src/content/ideas.ts`, open `/ideas/slug`, export Markdown or versioned JSON, or print to PDF. Add custom React pages under `src/pages` when a composition needs bespoke rendering. - `apps/python`: install with `pip install -e apps/python`. Run `prompt-export idea.json --output ./output --font /path/to/cjk.ttf [--video]`. Generates 1080×1440 PNGs and an optional silent MP4 (4 seconds per slide), requiring FFmpeg. JSON describes standard slides; arbitrary React animations need a separate browser recording pipeline. Python Docker image includes CJK fonts and FFmpeg. ## Development -Node 24, pnpm 11.25.0, Python 3.12+. `pnpm install`; `pnpm dev:waterfall-wiki`; `pnpm dev:studio`. Do not use local preview for acceptance; see root AGENTS.md. Each JS application owns its frozen pnpm lockfile. Production Docker builds install each project independently. +Node 24, pnpm 11.25.0, Python 3.12+. `pnpm install`; `pnpm dev:waterfall-wiki`; Do not use local preview for acceptance; see root AGENTS.md. Each JS application owns its frozen pnpm lockfile. Production Docker builds install each project independently. ## Releases -Three path-filtered Gitea workflows run on ces-control. Changes to one app trigger that app only. Workspace configuration changes intentionally verify both JS applications. Documentation changes do not deploy. Each workflow can be manually dispatched for recovery after a failed release. All app content, including Markdown, triggers its workflow. - -Wiki keeps `/opt/prompt`, port 18131 and existing persistent volumes. Studio uses its own Compose project, `/opt/prompt-studio`, port 18134, and verifies its AI index before recording the release; port 18134 is bound to loopback so access goes through Wiki authentication. Python publishes `prompt-python:` and the local `latest` alias, used on demand rather than running an empty daemon. No Python network port is exposed. Root Dockerfile is Wiki's build entry; `apps/waterfall-wiki/Dockerfile` mirrors it for discovery. - -CI uses root build context and application-scoped COPY instructions. Wiki runs its existing Docker integration suite before deploying. The former Web-only release path is retired because its source-copy and image assumptions predated the monorepo. No `down -v` is used in production. +当前以 [统一部署流程](unified-deployment.md) 为准。Wiki 和 Studio 已合并为一个 Next.js 应用,`.gitea/workflows/app.yml` 负责候选验证与 main 发布。品牌只作为内部源码包,Python 保留独立工具镜像流程。生产 PostgreSQL/S3 长期运行,日常应用发布不会重建基础服务。 ## Explore 页面与轻量图片 @@ -22,7 +18,7 @@ CI uses root build context and application-scoped COPY instructions. Wiki runs i `/api/preview` 只接受本站静态栅格图或媒体 ID,限制四档尺寸,输出 WebP:列表目标最多 100 KB、详情目标最多 220 KB。原图按需下载。服务端预览使用并发限制、请求合并、磁盘缓存和 ETag;缓存数量有限并定期清理。SVG 图标不经过栅格化。 -Studio 部署到同一 Docker 网络,通过 `http://8.211.154.134:18131/studio` 访问,不依赖额外公网端口。直连端口 `127.0.0.1:18134/studio` 仅服务器本机可用;公网访问统一经过 Wiki 登录保护。 +Studio 部署到同一 Docker 网络,已合并到同一 Web 容器,编辑器使用 `/graphic`、`/infinite_canvas`,历史 `/studio` 路径兼容保留,不再部署 18134 服务。 Docker 在单独的依赖下载层执行 `pnpm fetch`,再离线安装。源码变化复用依赖层,包脚本变化复用已下载的包。CI 不清理 Docker 缓存,不使用 `--no-cache`。 diff --git a/docs/unified-deployment.md b/docs/unified-deployment.md new file mode 100644 index 0000000..82c91ad --- /dev/null +++ b/docs/unified-deployment.md @@ -0,0 +1,35 @@ +# 统一应用的 CI/CD + +## 范围 + +Wiki 与 Studio 合并为 `apps/waterfall-wiki` 内一个 Next.js Pages Router 应用。编辑器组件与内容在 `src/studio`;原生页面为 `/graphic`、`/infinite_canvas`,历史 `/studio/layouts/*`、`/studio/template-layout` 等继续存在。旧 `/studio/api/*` 通过同应用 rewrite 进入 `pages/api/studio-ui`,保留既有 API 地址与权限边界。没有第二个 Next.js 服务或 18134 新部署。 + +品牌包只作为 workspace 源码依赖,不独立构建、测试或部署。Python workflow 保留。`codex/unified-web` 包含未合并 PR #46 的缓存改动作为依赖,评审时必须包含完整差异;不得先合并已失效的旧品牌检查配置。 + +## main 更新后 + +1. `.gitea/workflows/app.yml` 按应用、品牌、脚本、Docker/Compose 配置路径筛选 main push(含 PR 合并)。文档修改不会部署。 +2. runner checkout 本次提交,逐文件检查 Shell 语法与内容约定。 +3. 构建 integration 与 runtime 两个目标。Next.js 的类型检查、单元测试、生产编译、冒烟测试只在共享 build 阶段执行;runtime 复用这一层,不再次编译。运行镜像同供 Web 与 worker 使用。 +4. 在锁保护下使用 `prompt-ci-shared` 测试槽位。首次创建专用 PostgreSQL/S3,此后 `--no-recreate` 复用。停止上一轮测试应用,确认数据库容器属于该测试项目后重置测试数据库。本轮使用 `ci-` 对象 bucket。 +5. 启动候选 Web/worker,迁移测试数据库,执行接口、账户、画布、编辑器及浏览器集成验证。失败则停止发布。退出时停止测试应用并删除本轮 bucket,保留基础容器和卷。 +6. 生产部署先验证 `/etc/prompt.env` 和既有 `prompt` 基础服务,绝不自动生成新凭据或重建 PostgreSQL/S3。检查是否被更新的 main 取代并获取部署锁。 +7. 停止应用写入,备份数据库,执行迁移及已有内容初始化,使用 `--no-deps` 更新 Web/worker。 +8. 检查健康、账户、已有内容和完整线上验收。验收处于部署脚本回滚边界内;失败尝试恢复数据库备份和上一版应用。 +9. 验收通过后更新 current 和成功记录,停止有明确 Compose 所属标签的旧 Studio 容器(保留旧镜像)。上传验收证据;上传成功后删除 runner 临时报告。镜像盘点清理不在发布关键路径执行。 + +PR 运行同一候选构建与测试,但部署步骤只允许 main push。首次应用合并保留完整远程验证;尚未按改动选择浏览器用例,不能宣称已消除全部重复测试。发布不自动取消进行中的任务,以免中断数据库迁移;过期版本由部署脚本检查跳过。 + +## 基础设施初始化与维护 + +生产首次安装或明确升级基础镜像时,管理员单独配置 `/etc/prompt.env`,执行 `bash scripts/init-infra.sh`。日常发布只检查基础服务;既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷名与凭据不变,不执行生产 `down -v`。 + +持久测试凭据仅保存在 `/var/lib/prompt-ci/credentials.env`,创建时权限受 umask 077 限制;不打印或提交。测试基础设施只使用 `prompt-ci-shared` Compose 项目、随机绑定端口和自己的卷,绝不复用生产数据。runner 必须能写入 `/var/lib/prompt-ci` 和 `/var/lock`;当前确认的 runner 为 root host executor,变更运行身份时需重新配置目录权限。 + +镜像维护继续提供 `wiki-image-lifecycle.py preview/apply`,需在确认无协作构建的维护窗口执行。数据库备份及既有内容导入暂保留在部署流程;这部分尚未按 schema 或内容变更跳过。 + +## 回滚与验收 + +首次迁移前保留旧 Studio 容器、镜像及 `/opt/prompt-studio` 元数据。新版本验收失败发生在旧 Studio 停止之前,可恢复旧 Web/worker。若首次迁移成功后人工回滚到双应用版本,还必须重新启动保留的旧 Studio 容器;不能只回滚 Web 镜像。 + +构建及浏览器验证只在 ces-control 执行。本地只能做语法、引用及差异检查;实际通过状态以 PR 对应提交的远程 CI 为准。PR 等待用户手动合并,不自动部署或合并。 diff --git a/package.json b/package.json index 598e48b..3af5177 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "packageManager": "pnpm@11.25.0", "scripts": { "dev:waterfall-wiki": "pnpm --filter @prompt/waterfall-wiki dev", - "dev:studio": "pnpm --filter @prompt/studio dev", "build": "pnpm -r build", "typecheck": "pnpm -r typecheck", "test": "pnpm --filter @prompt/waterfall-wiki test", diff --git a/packages/brand/Dockerfile b/packages/brand/Dockerfile deleted file mode 100644 index 2778ff8..0000000 --- a/packages/brand/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM node:24-bookworm-slim -WORKDIR /repo -RUN corepack enable && corepack prepare pnpm@11.25.0 --activate -COPY packages/brand/package.json packages/brand/pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile --ignore-scripts -RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \ - && rm -rf /var/lib/apt/lists/* \ - && python3 -m venv /opt/brand-python \ - && /opt/brand-python/bin/pip install --only-binary=:all: Pillow==13.0.0 resvg-py==0.5.0 -COPY packages/brand/ ./ -RUN pnpm test && pnpm build && pnpm test:tailwind && pnpm test:types -RUN /opt/brand-python/bin/python tests/assets.py diff --git a/packages/brand/README.md b/packages/brand/README.md index e960459..940c54f 100644 --- a/packages/brand/README.md +++ b/packages/brand/README.md @@ -32,10 +32,6 @@ 可进行的本地轻量检查:`node --test packages/brand/tests/brand.test.mjs`。它只读取源码和资产,检查主题、对比度、SVG 路径及 PNG/ICO 文件头,不编译和输出产物。 -完整验证在已确认的 ces-control Docker runner 执行: +品牌包不提供独立 Docker 镜像或常驻服务,也不再运行专用品牌 CI 工作流。Wiki 和 Studio 通过 `workspace:*` 使用本包,品牌资源随消费应用的 Docker 构建处理,并由应用类型检查、测试和构建验证接入。 -```sh -docker build -f packages/brand/Dockerfile -t endoleap-brand-check . -``` - -包含真实 Tailwind 编译、TypeScript 导入检查、品牌导出和 72 个 PNG 与 SVG 逐像素比较。不要在本机运行该 Docker 构建或 `build` / `test:tailwind` / `test:types` / `test:assets`。 +原专用工作流中的品牌导出一致性、单独 Tailwind/TypeScript 测试和 72 个 PNG 与 SVG 逐像素比较不再自动运行;相关脚本与测试文件保留,不能将应用检查视为对这些检查的等价替代。需要这些完整检查时,仍须在已确认的远程 runner 执行,不在本机运行 `build` / `test:tailwind` / `test:types` / `test:assets`。 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1d6e04..0d9a5e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,5 @@ packages: - apps/waterfall-wiki - - apps/studio - packages/brand # Each application owns its lockfile, so releases stay independent. sharedWorkspaceLockfile: false diff --git a/scripts/build-studio-layouts.mjs b/scripts/build-studio-layouts.mjs index 34dbf3d..89d44f7 100644 --- a/scripts/build-studio-layouts.mjs +++ b/scripts/build-studio-layouts.mjs @@ -5,7 +5,6 @@ const root=new URL('../',import.meta.url),read=p=>readFile(new URL(p,root)),raw= const image=async p=>'/api/media/'+managedMedia['content/layout-reference/assets/'+p.replace(/^asset:/,'')].id; const layouts=[]; for(const m of raw){const elements=[];for(const e of m.elements)elements.push({...e,raster:await image(e.asset),...(e.mask?{shape:await image(e.mask)}:{})});layouts.push({id:m.id,name:m.name,width:m.width,height:m.height,tutorialUrl:'/explore/'+ids[m.id],sourceSha256:m.source.sha256,source:await image(m.reference),background:await image(m.background),elements});} -await writeFile(new URL('apps/studio/src/content/templates/layouts.json',root),JSON.stringify(layouts)); +await writeFile(new URL('apps/waterfall-wiki/src/studio/content/templates/layouts.json',root),JSON.stringify(layouts)); await writeFile(new URL('apps/waterfall-wiki/src/content/studio-layouts.json',root),JSON.stringify(layouts.map(({source,background,elements,...l})=>({...l,elements:elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))})))); -await writeFile(new URL('apps/waterfall-wiki/src/lib/layout-parameters.ts',root),await read('apps/studio/src/lib/layout-parameters.ts')); console.log('Packaged '+layouts.length+' corresponding layouts'); diff --git a/scripts/check-studio-contracts.mjs b/scripts/check-studio-contracts.mjs index e39543f..62732e0 100644 --- a/scripts/check-studio-contracts.mjs +++ b/scripts/check-studio-contracts.mjs @@ -2,8 +2,8 @@ import {readFile} from 'node:fs/promises'; import assert from 'node:assert/strict'; const root=new URL('../',import.meta.url),read=path=>readFile(new URL(path,root),'utf8'); -for(const file of ['layout-parameters.ts','series.ts','canvas-document.ts'])assert.equal(await read('apps/studio/src/lib/'+file),await read('apps/waterfall-wiki/src/lib/'+file),file+' contract drift'); -const canonical=await read('content/studio-examples/autumn-journal.json');for(const app of ['studio','waterfall-wiki'])assert.equal(canonical,await read('apps/'+app+'/fixtures/autumn-journal.json'),'Example fixture drift'); +for(const file of ['layout-parameters.ts','series.ts','canvas-document.ts'])assert.equal(await read('apps/waterfall-wiki/src/studio/lib/'+file),`export * from '../../lib/${file}';\n`,file+' must use shared implementation'); +const canonical=await read('content/studio-examples/autumn-journal.json');for(const file of ['apps/waterfall-wiki/fixtures/autumn-journal.json','apps/waterfall-wiki/fixtures/studio/autumn-journal.json'])assert.equal(canonical,await read(file),'Example fixture drift'); const doc=await read('apps/waterfall-wiki/src/lib/studio-design-documentation.ts'),encoded=doc.match(/export const designDocs=(.*);\n/); assert.ok(encoded);assert.equal(JSON.parse(encoded[1]),(await read('content/studio-tools/layouts-api.md')).replace('{{LAYOUTS}}','读取 /studio/api/layouts 获取全部27套构图。'),'Wiki/agent documentation drift'); console.log('Studio content, validation and documentation contracts agree'); diff --git a/scripts/deploy-studio.sh b/scripts/deploy-studio.sh deleted file mode 100644 index 9df86c3..0000000 --- a/scripts/deploy-studio.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail -if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi -sha=${1:?release SHA required} -[[ "$sha" =~ ^[0-9a-f]{40}$ ]] -exec 9>/var/lock/prompt-studio.lock -flock -w 300 9 -latest=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1) -if [ "$latest" != "$sha" ]; then - git -c safe.directory="$PWD" fetch origin main - git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml || exit 0 -fi -mkdir -p /opt/prompt-studio -previous=$(cat /opt/prompt-studio/image 2>/dev/null || true) -write_compose(){ printf 'services:\n studio:\n image: %s\n restart: unless-stopped\n ports: ["127.0.0.1:18134:3000"]\n networks:\n waterfall-wiki:\n aliases: [prompt-studio]\nnetworks:\n waterfall-wiki:\n external: true\n name: prompt_default\n' "$1" > /opt/prompt-studio/compose.yml; } -write_compose "prompt-studio:$sha" -rollback(){ if [ -n "$previous" ]; then write_compose "$previous"; docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d; fi; } -trap rollback ERR -docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d -for attempt in {1..40}; do - if curl -fsS http://127.0.0.1:18134/studio/api/health >/dev/null; then printf '%s' "prompt-studio:$sha" > /opt/prompt-studio/image; exit 0; fi - sleep 2 -done -false diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 023e0c9..5a3b900 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -17,7 +17,7 @@ test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA" LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1) if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then git -c safe.directory="$PWD" fetch origin main - if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/wiki-image-lifecycle.py scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then + if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/wiki-image-lifecycle.py scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/app.yml pnpm-workspace.yaml packages/brand; then echo 'Skipping deployment: Wiki has a newer commit'; exit 0 fi fi @@ -61,7 +61,7 @@ rollback(){ echo 'Restored the database backup compatible with the previous application' fi if [ -n "$PREVIOUS" ] && [ -f "$PREVIOUS/compose.yml" ]; then - compose_at "$PREVIOUS" up -d --no-build "$PREVIOUS_WEB" "$PREVIOUS_WORKER" + compose_at "$PREVIOUS" up -d --no-build --no-deps "$PREVIOUS_WEB" "$PREVIOUS_WORKER" echo 'Restored the previous Docker application image' else compose stop waterfall-wiki waterfall-wiki-worker @@ -82,7 +82,8 @@ rollback(){ } trap rollback ERR # Keep the same prompt_postgres_data and prompt_objects volumes. No production down -v. -compose up -d --no-build --wait --wait-timeout 120 postgres objects +# Infrastructure already exists; never recreate it during an app release. +compose exec -T postgres pg_isready -U prompt -d prompt install -d -o root -g root -m 0700 /var/lib/prompt/backups CUTOVER=1 compose stop waterfall-wiki waterfall-wiki-worker @@ -98,7 +99,7 @@ CUTOVER=1 # One-time migration: finish a current host worker job before switching the web port. if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl disable --now prompt-worker.service; fi if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi -compose up -d --no-build --wait --wait-timeout 180 waterfall-wiki waterfall-wiki-worker +compose up -d --no-build --no-deps --wait --wait-timeout 180 waterfall-wiki waterfall-wiki-worker compose exec -T waterfall-wiki node container-health.mjs compose exec -T waterfall-wiki node auth-cli.cjs verify compose exec -T waterfall-wiki node auth-cli.cjs run scripts/import-character-packs.mjs @@ -123,6 +124,8 @@ compose exec -T waterfall-wiki sh -c 'cat > /tmp/prompt-release-check.mjs && nod })().catch(error=>{console.error(error.message);process.exit(1)}); NODE +bash scripts/verify-studio-production.sh + ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next" mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current" install -m 0755 scripts/auth-admin.sh "$APP_ROOT/auth" @@ -134,6 +137,11 @@ done if command -v systemctl >/dev/null; then if systemctl is-active --quiet prompt.service || systemctl is-active --quiet prompt-worker.service; then echo 'Legacy host service is unexpectedly running'; false; fi fi +if docker inspect prompt-studio-studio-1 >/dev/null 2>&1; then + if [ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' prompt-studio-studio-1)" = prompt-studio ]; then + docker stop prompt-studio-studio-1 + fi +fi echo "Full Docker deployment verified: web+worker+postgres+objects port=$PORT release=$RELEASE_SHA" # Register only after every deployment check succeeded; ledger failure retains old images. if ! python3 scripts/wiki-image-lifecycle.py record-success --sha "$RELEASE_SHA"; then diff --git a/scripts/infra.sh b/scripts/infra.sh index 70c9721..b752d3e 100644 --- a/scripts/infra.sh +++ b/scripts/infra.sh @@ -1,17 +1,9 @@ #!/usr/bin/env bash set -Eeuo pipefail -IMAGE="${1:?Application image is required}" -# Preserve existing credentials and Docker project/volume names during migration. -if [ ! -f /etc/prompt.env ]; then - umask 077 - TEMP_ENV=$(mktemp /etc/prompt.env.XXXXXX) - trap 'rm -f "$TEMP_ENV"' EXIT - docker run --rm --entrypoint node "$IMAGE" -e "const {randomBytes}=require('crypto');const hex=()=>randomBytes(32).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex()+'\nS3_BUCKET=prompt')" > "$TEMP_ENV" - mv "$TEMP_ENV" /etc/prompt.env - trap - EXIT -fi - -# Current public entry uses HTTP. Operators can set true here after enabling HTTPS. -if ! grep -q '^AUTH_COOKIE_SECURE=' /etc/prompt.env; then - printf '\nAUTH_COOKIE_SECURE=false\n' >> /etc/prompt.env -fi +# Provision credentials and base containers separately; normal releases only verify. +test -s /etc/prompt.env || { echo 'Missing /etc/prompt.env; initialize infrastructure separately' >&2; exit 1; } +for service in postgres objects; do + id=$(docker compose -p prompt --env-file /etc/prompt.env -f compose.yml ps -q "$service") + test -n "$id" || { echo "Infrastructure missing: $service; run scripts/init-infra.sh separately" >&2; exit 1; } + test "$(docker inspect --format '{{.State.Running}}' "$id")" = true +done diff --git a/scripts/init-infra.sh b/scripts/init-infra.sh new file mode 100644 index 0000000..8e2045e --- /dev/null +++ b/scripts/init-infra.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# Explicit operator action, never invoked by the application release workflow. +if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi +test -s /etc/prompt.env || { echo 'Configure /etc/prompt.env before initializing infrastructure' >&2; exit 1; } +docker compose -p prompt --env-file /etc/prompt.env -f compose.yml up -d --no-build --wait postgres objects diff --git a/scripts/integration.sh b/scripts/integration.sh index 919f839..ce5668d 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -1,49 +1,49 @@ #!/usr/bin/env bash set -Eeuo pipefail -: "${WATERFALL_WIKI_IMAGE:?Build the runtime image first}" -: "${WATERFALL_WIKI_TEST_IMAGE:?Build the integration image first}" +: "${WATERFALL_WIKI_IMAGE:?Build runtime image first}" +: "${WATERFALL_WIKI_TEST_IMAGE:?Build integration image first}" export GITEA_RUN_ID="${GITEA_RUN_ID:-$$}" -export COMPOSE_PROJECT_NAME="prompt-ci-$GITEA_RUN_ID" +[[ "$GITEA_RUN_ID" =~ ^[1-9][0-9]*$ ]] +# Only one candidate uses the shared test slot at a time. Production is project prompt. +exec 8>/var/lock/prompt-ci-integration.lock +flock -w 900 8 +export COMPOSE_PROJECT_NAME=prompt-ci-shared export PG_PORT=0 S3_PORT=0 WEB_PORT=0 WEB_BIND_ADDRESS=127.0.0.1 -TEST_ENV=$(mktemp) -chmod 0600 "$TEST_ENV" -COMPOSE_FILES=(-f compose.yml) -STUDIO_SERVICES=() -if [[ -n "${STUDIO_REVIEW_IMAGE:-}" ]]; then - COMPOSE_FILES+=(-f compose.creative-review.yml) - STUDIO_SERVICES+=(prompt-studio) +STATE=/var/lib/prompt-ci +umask 077 +mkdir -p "$STATE" +if [ ! -s "$STATE/credentials.env" ]; then + docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$STATE/credentials.env.tmp" + mv "$STATE/credentials.env.tmp" "$STATE/credentials.env" fi -compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${COMPOSE_FILES[@]}" "$@"; } +TEST_ENV=$(mktemp) +cat "$STATE/credentials.env" > "$TEST_ENV" +printf '\nS3_BUCKET=ci-%s\nAUTH_DISABLED_FOR_TESTS=true\nACCOUNT_PUBLIC_ORIGIN=http://waterfall-wiki:3000\nAUTH_COOKIE_SECURE=false\n' "$GITEA_RUN_ID" >> "$TEST_ENV" +compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" -f compose.yml "$@"; } cleanup(){ code=$? - trap - EXIT INT TERM - if [ "$code" -ne 0 ]; then - echo 'Runner pressure at integration failure (before disposable stack cleanup)' - uptime || true - free -m || true - df -h / || true - for resource in cpu memory io; do cat "/proc/pressure/$resource" || true; done - timeout 10s docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} io={{.BlockIO}} pids={{.PIDs}}' || true - compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true - fi - cleanup_code=0 - bash scripts/cleanup-integration.sh || cleanup_code=$? + trap - EXIT + if [ "$code" -ne 0 ]; then compose logs --tail 100 waterfall-wiki waterfall-wiki-worker || true; fi + compose stop waterfall-wiki waterfall-wiki-worker || true + docker run --rm --network prompt-ci-shared_default --env-file "$TEST_ENV" \ + --mount "type=bind,src=$PWD/apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs,dst=/repo/apps/waterfall-wiki/cleanup-ci-bucket.mjs,readonly" \ + --entrypoint node "$WATERFALL_WIKI_TEST_IMAGE" /repo/apps/waterfall-wiki/cleanup-ci-bucket.mjs || { echo 'CI bucket cleanup failed' >&2; code=1; } rm -f "$TEST_ENV" - if [ "$code" -ne 0 ]; then exit "$code"; fi - exit "$cleanup_code" + exit "$code" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM -# Random credentials exist only for this disposable stack; no host Node required. -docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV" -# Explicitly isolate legacy unauthenticated integration coverage; production fails closed. -printf '\nAUTH_DISABLED_FOR_TESTS=true\n' >> "$TEST_ENV" -if [[ -n "${STUDIO_REVIEW_IMAGE:-}" ]]; then printf '\nACCOUNT_PUBLIC_ORIGIN=http://waterfall-wiki:3000\nAUTH_COOKIE_SECURE=false\n' >> "$TEST_ENV"; fi -compose up -d --no-build --wait --wait-timeout 120 postgres objects +# Bootstrap once, otherwise reuse base containers and their volumes. +compose up -d --no-build --no-recreate --wait --wait-timeout 120 postgres objects +compose stop waterfall-wiki waterfall-wiki-worker +id=$(compose ps -q postgres) +[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id")" == prompt-ci-shared ]] +# Reset only this dedicated test database; no production volume or database is addressed. +compose exec -T postgres dropdb -U prompt --if-exists --force prompt +compose exec -T postgres createdb -U prompt prompt compose run --rm --no-deps migrate -compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" -# The test runner calls the actual non-root production web/worker containers. +compose up -d --no-build --no-deps --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker compose run --rm --no-deps tests compose exec -T waterfall-wiki node container-health.mjs compose exec -T waterfall-wiki-worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null' @@ -51,8 +51,8 @@ compose exec -T waterfall-wiki-worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -ve # Repeat route and login checks with production authentication enabled. printf '\nAUTH_CONFIG_FILE=/auth/config.json\nAUTH_COOKIE_SECURE=false\n' >> "$TEST_ENV" compose run --rm --no-deps auth-admin -compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki +compose up -d --no-build --no-deps --wait --wait-timeout 150 waterfall-wiki compose exec -T waterfall-wiki node auth-cli.cjs exercise compose restart waterfall-wiki -compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki +compose up -d --no-build --no-deps --wait --wait-timeout 150 waterfall-wiki compose exec -T waterfall-wiki node auth-cli.cjs verify diff --git a/scripts/verify-studio-production.sh b/scripts/verify-studio-production.sh index 7c803de..23b5d5d 100644 --- a/scripts/verify-studio-production.sh +++ b/scripts/verify-studio-production.sh @@ -49,4 +49,4 @@ docker run --rm \ --mount "type=bind,src=$RESULT_DIR,dst=/tmp/studio-library" \ -e NODE_PATH=/repo/apps/waterfall-wiki/node_modules -e CHROMIUM_PATH=/usr/bin/chromium \ -e SCREENSHOT_DIR=/tmp/studio-library/visual \ - --entrypoint node "$TEST_IMAGE" /delivery/apps/studio/scripts/infinite-canvas-smoke.cjs + --entrypoint node "$TEST_IMAGE" /delivery/apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs diff --git a/scripts/wiki-image-lifecycle.py b/scripts/wiki-image-lifecycle.py index f181e95..a87b22f 100644 --- a/scripts/wiki-image-lifecycle.py +++ b/scripts/wiki-image-lifecycle.py @@ -175,12 +175,6 @@ def ci(root, sha): code = 0 except subprocess.CalledProcessError as error: code = error.returncode - finally: - try: - cleanup(root, apply=True, completed_test=sha if code == 0 else None) - except Exception as error: - # Housekeeping must not mask the build/deployment result or roll back healthy services. - print(f"Cleanup failed; images retained for later retry: {error}", file=sys.stderr) return code