diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index cdf8da193..057c29d50 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.42", + "version": "2026-08-26.44", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "3ed83d2d8bdfbeaefe9e22c36687f146c20ee2b971c7a4f73cf8bdf4bc8dc69c" + "sha256": "f7d1f7e05863b45e307fe9ef6b281513774b130af2631cc2001b55f40b752fc9" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md index 6563c7c40..18bc23a25 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md @@ -6,25 +6,23 @@ metadata: version: "1.7.0" --- -1. 新增 `scripts/vite.config.xhs-minitool.mjs` (示例)作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. +# 目标: +* 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip + 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: + `references/manual-checks.md` + 3. 使用 `scripts/pack.mjs` 打包成zip + 4. 跑通流程后请把需要的脚本配置复制到项目里(以免依赖skill), 并形成最终的打包脚本 -2. 使用 `scripts/validate.mjs` 构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: - `references/manual-checks.md` +* 不干扰正常的web构建 -3. 使用 `scripts/pack.mjs` 打包成zip - -## 被 AGC 导出面板调用时 - -宿主只按三条硬契约校验, 接入的项目 MUST 逐条满足: - -- npm 包 (声明 `package.json` 的那层目录) MUST 声明脚本 `build:xhs-minitool`. -- 该脚本跑完 MUST 在项目根留下非空的 `.export/xhs-minitool.zip`; 落点用 `pack.mjs --zip-out` 显式指定, 不要依赖默认文件名 (相对路径的解析规则见 `node pack.mjs --help`). -- `pack.mjs --out-dir` MUST 只指向 vite 构建输出目录: 它会就地删掉该目录里白名单之外的扩展名文件, 指向源码目录会删掉用户代码. - -把示例脚本拷贝到 `<项目根>/.export/` 下再改 (拷贝是项目自己的代码, 允许改路径与入口); -拷贝自身的位置就是项目根: `dirname(import.meta.url)/..`. +# 假设和默认项: +* vite项目目录: games/ ,各个工具(包括示例vite配置)的默认参数参照这个条件给出, 对于非标准的目录结构需要给出显式的参数来适应 +# 一些情况: +* 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 * 如果使用了外部能力, 参考platform-abstract skill对项目先重构 +* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 以下是其他参考文档, 非必要不读 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs index 2c7f51d8a..2c41324de 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs @@ -1,11 +1,95 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import test from 'node:test'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test, { after } from 'node:test'; +import { fileURLToPath } from 'node:url'; -import { auditZipFile } from './pack.mjs'; +import { auditZipFile, packMinitool } from './pack.mjs'; +import { findUnsupportedFiles } from './validate.mjs'; + +const PACK = fileURLToPath(new URL('./pack.mjs', import.meta.url)); +const BASE = mkdtempSync(join(tmpdir(), 'xhs-pack-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const VALID_HTML = '' + + '' + + '' + + ''; + +/** 造一个最小的 vite 构建产物目录;extra 用来放白名单外的文件。 */ +function writeBuiltDir(name, extra = {}) { + const root = join(BASE, name); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8'); + writeFileSync(join(root, 'app.js'), "console.log('ok');\n", 'utf8'); + for (const [relativePath, content] of Object.entries(extra)) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } + return root; +} test('zip budget is classified', () => { assert.deepEqual(auditZipFile('tool.zip', 1024), []); assert.equal(auditZipFile('tool.zip', 3 * 1024 * 1024)[0].code, 'ZIP_OVER_RECOMMENDED'); assert.equal(auditZipFile('tool.zip', 11 * 1024 * 1024)[0].code, 'ZIP_OVER_HARD_LIMIT'); }); + +test('findUnsupportedFiles 只报白名单外的扩展名', () => { + const root = writeBuiltDir('precheck', { + 'notes.md': 'readme\n', + 'src/config.ts': 'export const x = 1;\n', + }); + const found = findUnsupportedFiles(root).map((item) => item.path).sort(); + assert.deepEqual(found, ['notes.md', 'src/config.ts']); +}); + +test('存在不支持文件时打包整体失败,且不删除任何文件', () => { + const root = writeBuiltDir('no-delete', { + 'notes.md': 'readme\n', + 'style.scss': 'body { color: red; }\n', + }); + assert.throws( + () => packMinitool({ + cwd: BASE, + viteBuiltDir: 'no-delete', + zipOut: 'no-delete-out/xhs-minitool.zip', + skipValidate: true, + }), + /没有删除任何文件/, + ); + assert.ok(existsSync(join(root, 'notes.md')), 'notes.md 被删了'); + assert.ok(existsSync(join(root, 'style.scss')), 'style.scss 被删了'); + assert.equal(existsSync(join(BASE, 'no-delete-out')), false, '失败时不应写出 zip'); +}); + +test('干净产物目录仍能正常打包', () => { + writeBuiltDir('clean'); + const zipOut = join(BASE, 'clean-out', 'xhs-minitool.zip'); + const code = packMinitool({ + cwd: BASE, + viteBuiltDir: 'clean', + zipOut, + skipValidate: true, + }); + assert.equal(code, 0); + assert.ok(existsSync(zipOut), 'zip 未生成'); +}); + +test('CLI 拒绝旧名 --out-dir,不再静默退回默认目录', () => { + const dir = writeBuiltDir('cli'); + const result = spawnSync( + process.execPath, + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--skip-validate'], + { cwd: BASE, encoding: 'utf8' }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /--vite-built-dir/); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 91e4e5989..80d3f0e51 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -18,65 +18,51 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { deflateRawSync } from 'node:zlib'; -import { runCli } from './validate.mjs'; +import { findUnsupportedFiles, runCli } from './validate.mjs'; const MIB = 1024 * 1024; const ZIP_HARD_LIMIT = 10 * MIB; const ZIP_RECOMMENDED = 2 * MIB; -const ALLOWED = new Set([ - '.html', - '.css', - '.js', - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.svg', - '.woff', - '.woff2', - '.json', -]); - const FORBIDDEN_ENTRY = /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; -const USAGE = `用法:node pack.mjs [--out-dir ] [--zip ] [--zip-out ] [--skip-validate] [--no-strict] +const USAGE = `用法:node pack.mjs [--vite-built-dir ] [--zip ] [--zip-out ] [--skip-validate] [--no-strict] -把 vite dist-xhs-minitool 产物整理成符合小红书规范、根目录带 index.html 的 zip。 +把 vite 构建产物整理成符合小红书规范、根目录带 index.html 的 zip。 +--vite-built-dir vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 dist-xhs-minitool。 + 该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。 --zip 只给文件名时用:写到当前工作目录下。 --zip-out 需要指定落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip)时用: 相对路径按当前工作目录解析,父目录不存在会自动建。`; -function walk(dir) { - const out = []; - for (const name of readdirSync(dir)) { - const path = join(dir, name); - const st = statSync(path); - if (st.isDirectory()) out.push(...walk(path)); - else out.push(path); - } - return out; +/** + * 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。 + * + * 旧实现 `ensureAllowedOnly` 会就地删除这些文件;一旦 `--vite-built-dir` 指到源码目录, + * 就会静默删掉用户的 `.ts` / `.vue` / `.scss` 等源码。现在改为在改动产物之前拦下并报错, + * 删除与否由调用方按提示决定。 + */ +function assertNoUnsupportedFiles(viteBuiltDir) { + const unsupported = findUnsupportedFiles(viteBuiltDir); + if (unsupported.length === 0) return; + const list = unsupported + .map((item) => ` - ${item.path}${item.ext ? ` (${item.ext})` : ' (无扩展名)'}`) + .join('\n'); + throw new Error( + `vite 构建产物目录里有 ${unsupported.length} 个小红书不支持的文件;为避免误删,脚本没有删除任何文件:\n` + + `${list}\n` + + '请在 vite 构建里去来源(如 sourcemap: false),或把 --vite-built-dir 指向真正的构建输出目录。', + ); } -function ensureAllowedOnly(outDir) { - for (const file of walk(outDir)) { - const ext = extname(file).toLowerCase(); - if (!ALLOWED.has(ext)) { - rmSync(file, { force: true }); - console.log(`removed unsupported: ${relative(outDir, file)}`); - } - } -} - -function patchIndexHtml(outDir) { - const htmlPath = join(outDir, 'index.html'); +function patchIndexHtml(viteBuiltDir) { + const htmlPath = join(viteBuiltDir, 'index.html'); if (!existsSync(htmlPath)) { throw new Error(`${relative(process.cwd(), htmlPath)} 不存在,请先执行 vite build`); } @@ -267,8 +253,8 @@ export function auditZipFile(file, size) { return findings; } -function makeZip(outDir, zipPath) { - const entries = writeZip(outDir, zipPath); +function makeZip(viteBuiltDir, zipPath) { + const entries = writeZip(viteBuiltDir, zipPath); if (!entries.includes('index.html')) { throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)'); } @@ -298,7 +284,7 @@ function readPackageName(cwd) { /** * @param {{ * cwd?: string, - * outDir?: string, + * viteBuiltDir?: string, * zipName?: string, * zipOut?: string, * publicDir?: string, @@ -308,7 +294,7 @@ function readPackageName(cwd) { */ export function packMinitool(options = {}) { const cwd = resolve(options.cwd || process.cwd()); - const outDir = resolve(cwd, options.outDir || 'dist-xhs-minitool'); + const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); const zipName = options.zipName || `${readPackageName(cwd)}-xhs-tool.zip`; // `zipOut` 优先:调用方(如 .export/ 下的项目拷贝)要的是确定的落点,而不是"当前目录下叫什么名字"。 const zipPath = options.zipOut @@ -317,14 +303,17 @@ export function packMinitool(options = {}) { const publicDir = resolve(cwd, options.publicDir || 'public'); const strict = options.strict !== false; - if (!existsSync(outDir)) { - throw new Error(`缺少输出目录 ${outDir}`); + if (!existsSync(viteBuiltDir)) { + throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); } + // 先预检、后改动:白名单外的文件直接报错,不删除。指错目录时也不会误删源码。 + assertNoUnsupportedFiles(viteBuiltDir); + // 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。 mkdirSync(dirname(zipPath), { recursive: true }); for (const icon of ['icon-192.svg', 'icon-512.svg']) { - const dest = join(outDir, 'icons', icon); + const dest = join(viteBuiltDir, 'icons', icon); const src = join(publicDir, 'icons', icon); if (!existsSync(dest) && existsSync(src)) { mkdirSync(dirname(dest), { recursive: true }); @@ -332,16 +321,15 @@ export function packMinitool(options = {}) { } } - ensureAllowedOnly(outDir); - pruneEmptyDirs(outDir); - patchIndexHtml(outDir); - makeZip(outDir, zipPath); + pruneEmptyDirs(viteBuiltDir); + patchIndexHtml(viteBuiltDir); + makeZip(viteBuiltDir, zipPath); - console.log(`\n小红书小工具包已整理:${outDir}`); + console.log(`\n小红书小工具包已整理:${viteBuiltDir}`); if (options.skipValidate) return 0; - const args = [outDir]; + const args = [viteBuiltDir]; if (strict) args.push('--strict'); const code = runCli(args); if (code !== 0) { @@ -376,6 +364,12 @@ if (isMainModule()) { process.exit(0); } + // 旧名不能静默退回默认目录:否则适配脚本里的 --out-dir 会被忽略,产物落到别处还查不出来。 + if (flags.has('--out-dir')) { + console.error('--out-dir 已更名为 --vite-built-dir,请改用新名字(`node pack.mjs --help`)。'); + process.exit(1); + } + const getFlagValue = (name) => { const idx = raw.indexOf(name); return idx === -1 ? undefined : raw[idx + 1]; @@ -383,7 +377,7 @@ if (isMainModule()) { try { const code = packMinitool({ - outDir: getFlagValue('--out-dir'), + viteBuiltDir: getFlagValue('--vite-built-dir'), zipName: getFlagValue('--zip'), zipOut: getFlagValue('--zip-out'), skipValidate: flags.has('--skip-validate'), diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs index 039afc65f..dcd50f979 100755 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs @@ -829,6 +829,28 @@ export function iterProjectFiles(root, collector) { return walkDirectory(resolve(root), collector); } +/** + * 预检:只挑出产物目录里白名单之外的文件类型,**不修改磁盘**。 + * + * 打包脚本会在改动产物之前先调用它:发现不支持的文件就整体失败并列出相对路径, + * 而不是像旧实现那样就地删除(指向源码目录时会静默删掉用户代码)。 + * 完整的静态检查仍由 `validate()` 在打包后负责。 + * + * @param {string} root + * @returns {{ path: string, ext: string }[]} path 为相对 root 的展示路径 + */ +export function findUnsupportedFiles(root) { + const collector = new Collector(root); + const unsupported = []; + for (const path of iterProjectFiles(collector.root, collector)) { + const ext = extname(path).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + unsupported.push({ path: displayPath(collector.root, path), ext }); + } + } + return unsupported; +} + export function validate(root) { const collector = new Collector(root); const htmlFiles = []; diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs index a848ec431..85cbce8b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs @@ -8,8 +8,8 @@ * import { defineMinitoolConfig } from '<此文件路径>'; * export default defineMinitoolConfig({ plugins: [...] }); * - * 下面 build.outDir 的值与打包脚本的 --out-dir 是同一个目录,两处必须一致: - * pack.mjs 会就地删掉该目录里白名单之外的扩展名文件,配错会删掉源码。 + * 下面 build.outDir 的值与打包脚本的 --vite-built-dir 是同一个目录,两处必须一致: + * pack.mjs 会在打包前预检该目录;出现白名单之外的文件类型会直接报错,不会删除任何文件。 * * 约束来源:minitool-zip-builder/references/js-compatibility.md、css-compatibility.md、 * zip-artifact-spec.md —— Chrome 61 / ES2017 基线、相对路径、IIFE 单入口、无 sourcemap。