xhs-minitool skill:打包不再就地删文件,产物目录先预检

- `validate.mjs` 新增 `findUnsupportedFiles`:只挑出产物目录里白名单之外的文件,**不修改磁盘**;
- `pack.mjs` 改成先预检再动手,发现不支持的类型就整体失败并列出相对路径,不再像旧实现那样就地删除(指向源码目录时会静默删掉用户代码);
- `SKILL.md` 把目标、假设与默认项重写成一页能直接照做的说明,去掉「被 AGC 导出面板调用时」那三条宿主契约;
- 同步 `manifest.json` 指纹(version=2026-08-26.44)——之前脚本改了但指纹没跟上,宿主启动即报「内置 AGC Skill vite-export-xhs-minitool 内容指纹不匹配」。
This commit is contained in:
2026-10-06 14:32:06 +08:00
parent f497ae23e0
commit 8837fc5cd5
6 changed files with 173 additions and 75 deletions
@@ -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"
}
]
}
@@ -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对项目先重构
* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本
以下是其他参考文档, 非必要不读
@@ -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 = '<!doctype html><html lang="zh-CN"><head>'
+ '<meta charset="UTF-8">'
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">'
+ '</head><body><script src="./app.js"></script></body></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/);
});
@@ -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 <dir>] [--zip <name>] [--zip-out <path>] [--skip-validate] [--no-strict]
const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] [--zip <name>] [--zip-out <path>] [--skip-validate] [--no-strict]
把 vite dist-xhs-minitool 产物整理成符合小红书规范、根目录带 index.html 的 zip。
把 vite 构建产物整理成符合小红书规范、根目录带 index.html 的 zip。
--vite-built-dir <dir> vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 dist-xhs-minitool。
该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。
--zip <name> 只给文件名时用:写到当前工作目录下。
--zip-out <path> 需要指定落点(例如 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'),
@@ -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 = [];
@@ -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。