/* Aurora Admin —— 部署打包器(.dockerignore 是唯一真源) * * 背景(实测事故): * 远端 /opt/aurora-admin 之前靠手工拷贝维护,没有 git、也没有 .dockerignore, * 于是 ① .design_library/aurora-admin/specs 等目录随镜像发布并对外可下载; * ② 曾经修好的 nginx.conf(absolute_redirect off)被下一次拷贝覆盖丢失。 * 本脚本把「该发什么、不该发什么」固化为可复现的一步: * - 文件集只包含 docker build 真正需要的顶层条目; * - 排除规则**解析 .dockerignore**(含 Docker 的匹配语义),不再维护第二份清单; * - 硬断言:规范原文/agent-reports/preview/ui_kits 绝不出现,站点运行必需文件必须出现。 * * 用法: * node tools/pack-deploy.mjs # 生成 dist-deploy/aurora-admin/ + manifest * node tools/pack-deploy.mjs --tar # 额外打出 dist-deploy/aurora-admin-deploy.tar.gz */ 'use strict'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; const ROOT = process.cwd(); const OUT_DIR = path.join(ROOT, 'dist-deploy'); const STAGE = path.join(OUT_DIR, 'aurora-admin'); /* 部署目录必需文件:与 Dockerfile 的 COPY 对应,且必须留在 /opt 供 docker compose 使用。 注意:它们**始终**随包发布,不受 .dockerignore 影响——.dockerignore 只决定 「哪些文件进入镜像构建上下文」,而 docker-compose.yml 这类文件必须留在部署目录里 (compose 从磁盘读取它,不从构建上下文读取)。 */ const TOP_FILES = ['Dockerfile', '.dockerignore', 'nginx.conf', 'docker-compose.yml', 'sitemap.xml']; /* 受 .dockerignore 约束的内容目录(镜像里真正要用的资源) */ const CONTENT_DIRS = ['site', 'frameworks', 'tests', '.design_library']; /* 永不打包:版本库、依赖、本地工具与产物(与 .dockerignore 无关,属于打包器自身边界) */ const NEVER = [/^\.git(\/|$)/, /^node_modules(\/|$)/, /^\.zcode(\/|$)/, /^\.playwright-mcp(\/|$)/, /^dist(-deploy)?(\/|$)/, /^\.gitignore$/]; /* ---------- Docker .dockerignore 语义 ---------- * 实测确认:不含 "/" 的模式(如 *.md)只匹配**构建上下文根目录**,不匹配嵌套路径; * 故一律按「完整相对路径」匹配,其中 * 不跨 "/",** 可跨任意层(含零层)。 * 命中某目录即视为命中其下全部内容。 */ function patternToRegExp(pat) { let re = ''; for (let i = 0; i < pat.length; i++) { const c = pat[i]; if (c === '*') { if (pat[i + 1] === '*') { i++; if (pat[i + 1] === '/') { i++; re += '(?:[^/]*/)*'; } else re += '.*'; } else re += '[^/]*'; } else if (c === '?') re += '[^/]'; else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); } return new RegExp('^' + re + '$'); } function loadDockerignore() { const file = path.join(ROOT, '.dockerignore'); if (!fs.existsSync(file)) throw new Error('.dockerignore 不存在——排除规则无法确定,拒绝打包'); const rules = []; for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const negated = line.startsWith('!'); const body = (negated ? line.slice(1) : line).replace(/^\.\//, '').replace(/\/+$/, ''); if (!body) continue; rules.push({ negated, re: patternToRegExp(body) }); } return rules; } function isIgnored(rules, rel) { const segments = rel.split('/'); let excluded = false; for (const rule of rules) { let hit = rule.re.test(rel); if (!hit) { for (let i = 1; i < segments.length && !hit; i++) hit = rule.re.test(segments.slice(0, i).join('/')); } if (hit) excluded = !rule.negated; } return excluded; } /* ---------- 采集文件 ---------- */ function walk(absDir, relPrefix, out) { for (const e of fs.readdirSync(absDir, { withFileTypes: true })) { const rel = relPrefix ? relPrefix + '/' + e.name : e.name; if (NEVER.some((re) => re.test(rel))) continue; if (e.isDirectory()) walk(path.join(absDir, e.name), rel, out); else out.push(rel); } } const rules = loadDockerignore(); const candidates = []; for (const top of CONTENT_DIRS) { const abs = path.join(ROOT, top); if (!fs.existsSync(abs)) throw new Error('缺少部署必需目录:' + top); walk(abs, top, candidates); } for (const f of TOP_FILES) { if (!fs.existsSync(path.join(ROOT, f))) throw new Error('缺少部署必需文件:' + f); } const kept = [...TOP_FILES, ...candidates.filter((f) => !isIgnored(rules, f))].sort(); const excludedCount = candidates.length - (kept.length - TOP_FILES.length); /* ---------- 硬断言:不该发的绝不允许出现 ---------- */ const MUST_ABSENT = [ '.design_library/aurora-admin/specs/组件1.txt', '.design_library/aurora-admin/agent-reports/phase3-component-modal.json', '.design_library/aurora-admin/preview', '.design_library/aurora-admin/ui_kits', '.design_library/aurora-admin/SKILL.md', 'AGENTS.md', 'CHANGELOG.md', 'ROADMAP.md', 'TESTING.md', 'docker-compose.yml.bak-20260916', 'build-site.ps1', ]; const MUST_PRESENT = [ 'site/index.html', 'site/app.js', 'site/logger.js', 'site/data.json', 'site/data.js', 'site/tokens/tokens.css', 'site/style.css', 'site/i18n.js', 'frameworks/Button.css', 'frameworks/button.html', 'frameworks/Button.vue3.vue', 'tests/index.html', '.design_library/aurora-admin/colors_and_type.css', '.design_library/aurora-admin/components.css', '.design_library/aurora-admin/css.json', '.design_library/aurora-admin/components/button.json', 'Dockerfile', '.dockerignore', 'nginx.conf', 'docker-compose.yml', 'sitemap.xml', ]; const problems = []; for (const f of MUST_ABSENT) { const bad = kept.some((k) => k === f || k.startsWith(f + '/')); if (bad) problems.push('不该出现:' + f); } for (const f of MUST_PRESENT) { if (!kept.includes(f)) problems.push('缺失必需文件:' + f); } const shells = kept.filter((f) => /^site\/components\/[^/]+\.html$/.test(f)).length; if (shells !== 79) problems.push('site/components 薄壳数应为 79,实际 ' + shells); const fw = kept.filter((f) => f.startsWith('frameworks/')).length; if (fw !== 395) problems.push('frameworks 实现文件应为 395,实际 ' + fw); if (problems.length) { console.error('[pack-deploy] FAIL'); for (const p of problems) console.error(' - ' + p); process.exit(1); } /* ---------- 落盘 ---------- */ fs.rmSync(STAGE, { recursive: true, force: true }); let bytes = 0; for (const rel of kept) { const dst = path.join(STAGE, rel); fs.mkdirSync(path.dirname(dst), { recursive: true }); const buf = fs.readFileSync(path.join(ROOT, rel)); fs.writeFileSync(dst, buf); bytes += buf.length; } const manifest = { generatedAt: new Date().toISOString(), files: kept.length, bytes, components: shells, frameworks: fw, excludedByDockerignore: excludedCount, list: kept, }; fs.writeFileSync(path.join(OUT_DIR, 'deploy-manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); console.log('[pack-deploy] OK'); console.log(' staging : ' + STAGE); console.log(' files : ' + kept.length + '(内容文件 ' + candidates.length + ',按 .dockerignore 排除 ' + excludedCount + ';部署配置文件 ' + TOP_FILES.length + ' 个始终保留)'); console.log(' bytes : ' + (bytes / 1024).toFixed(1) + ' KB'); console.log(' components : ' + shells + ' 个薄壳 / frameworks ' + fw + ' 个实现文件'); console.log(' 断言 : 排除项 ' + MUST_ABSENT.length + ' 条全部未出现;必需项 ' + MUST_PRESENT.length + ' 条全部存在'); if (process.argv.includes('--tar')) { /* 用相对路径调用 tar:bsdtar(Windows 自带)对含中文的绝对路径 + -C 组合会报 status 2 */ const tgzRel = path.join('dist-deploy', 'aurora-admin-deploy.tar.gz'); const tgz = path.join(ROOT, tgzRel); fs.rmSync(tgz, { force: true }); execFileSync('tar', ['-czf', tgzRel, '-C', 'dist-deploy', 'aurora-admin'], { cwd: ROOT, stdio: 'inherit' }); console.log(' tarball : ' + tgz + '(' + (fs.statSync(tgz).size / 1024).toFixed(1) + ' KB)'); }