diff --git a/Dockerfile b/Dockerfile index f5911c3..245d4e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,9 +26,10 @@ USER node CMD ["npm", "run", "test:integration"] FROM base AS runtime +WORKDIR /app/apps/waterfall-wiki ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \ FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe -COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ ./ +COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/ COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./ COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/ COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./ diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index accf262..37ada5b 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -13,9 +13,9 @@ COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/ser 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 +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/ ./ +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 diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 992e9a4..67416ad 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -1,4 +1,4 @@ import { resolve } from "node:path"; import type { NextConfig } from "next"; -const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false }; +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/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs index aae6ca9..729976b 100644 --- a/apps/studio/scripts/smoke.mjs +++ b/apps/studio/scripts/smoke.mjs @@ -9,7 +9,7 @@ const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,autho 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/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; +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'); diff --git a/apps/waterfall-wiki/Dockerfile b/apps/waterfall-wiki/Dockerfile index d1efa99..0d77b6a 100644 --- a/apps/waterfall-wiki/Dockerfile +++ b/apps/waterfall-wiki/Dockerfile @@ -24,9 +24,10 @@ USER node CMD ["npm", "run", "test:integration"] FROM base AS runtime +WORKDIR /app/apps/waterfall-wiki ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \ FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe -COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ ./ +COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/ COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./ COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/ COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./ diff --git a/apps/waterfall-wiki/next.config.ts b/apps/waterfall-wiki/next.config.ts index 762ca8e..21ddc67 100644 --- a/apps/waterfall-wiki/next.config.ts +++ b/apps/waterfall-wiki/next.config.ts @@ -22,7 +22,7 @@ const nextConfig: NextConfig = { {source:"/agent/index.md",destination:"/api/agent-index"}, {source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"}, ]}, - output: "standalone", outputFileTracingRoot: process.cwd(), + output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]}, poweredByHeader: false, }; diff --git a/apps/waterfall-wiki/scripts/prepare-standalone.mjs b/apps/waterfall-wiki/scripts/prepare-standalone.mjs index edd5d63..376230b 100644 --- a/apps/waterfall-wiki/scripts/prepare-standalone.mjs +++ b/apps/waterfall-wiki/scripts/prepare-standalone.mjs @@ -1,4 +1,4 @@ import { cp,mkdir,access } from 'node:fs/promises'; -await mkdir('.next/standalone/.next',{recursive:true}); -await cp('.next/static','.next/standalone/.next/static',{recursive:true}); -try{await access('public');await cp('public','.next/standalone/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;} +await mkdir('.next/standalone/apps/waterfall-wiki/.next',{recursive:true}); +await cp('.next/static','.next/standalone/apps/waterfall-wiki/.next/static',{recursive:true}); +try{await access('public');await cp('public','.next/standalone/apps/waterfall-wiki/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;} diff --git a/apps/waterfall-wiki/scripts/smoke.mjs b/apps/waterfall-wiki/scripts/smoke.mjs index 0ec877f..b9a73dc 100644 --- a/apps/waterfall-wiki/scripts/smoke.mjs +++ b/apps/waterfall-wiki/scripts/smoke.mjs @@ -17,9 +17,9 @@ async function availablePort() { const port = process.env.SMOKE_PORT || await availablePort(); const release = "smoke-test"; -const previewFixture='.next/standalone/public/__smoke-preview.png'; +const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png'; await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer()); -const child = spawn(process.execPath, [".next/standalone/server.js"], { +const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], { // This loopback-only child checks retained resources without production credentials. // Production keeps failing closed; the bypass requires both explicit test settings. env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release }, diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 297bed1..1f9953a 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -28,7 +28,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise(r=>socket.close(()=>r())); const external=process.env.PROMPT_TEST_BASE_URL; const base=external||`http://127.0.0.1:${port}`; - const server=external?null:spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'}); + const server=external?null:spawn(process.execPath,['.next/standalone/apps/waterfall-wiki/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'}); const worker=external?null:spawn(process.execPath,['dist/worker.cjs'],{env:process.env,stdio:'inherit'});const dir=await mkdtemp(join(tmpdir(),'prompt-test-')); const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();}; try{ diff --git a/packages/brand/docs/integration.md b/packages/brand/docs/integration.md index 0709b27..8b3d4f3 100644 --- a/packages/brand/docs/integration.md +++ b/packages/brand/docs/integration.md @@ -4,7 +4,7 @@ 在消费应用的 `package.json` 声明 `"@endoleap/brand": "workspace:*"`,按仓库独立锁文件策略更新该应用锁文件。包为私有内部资源,无需 npm 发布、React peer dependency 或运行时主题库。 -Studio / Wiki 已声明 `workspace:*` 依赖。Docker 构建在 `/repo/apps/` 保留与 `/repo/packages/brand` 的相对位置;运行镜像仍在 `/app` 执行 standalone 服务。保持消费应用与品牌包的相对位置,不恢复原有孤立安装流程。 +Studio / Wiki 已声明 `workspace:*` 依赖。Docker 构建在 `/repo/apps/` 保留与 `/repo/packages/brand` 的相对位置;Next.js 的模块解析与文件追踪都以 `/repo` 为根,standalone 保留 `apps/` 层次。运行镜像复制完整 standalone 到 `/app`,在 `/app/apps/` 执行服务;静态文件、图标、worker 与健康检查文件都放在对应应用目录。保持消费应用与品牌包的相对位置,不恢复原有孤立安装流程。 ## Tailwind CSS v4