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:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+13
-15
@@ -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对项目先重构
|
||||
* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本
|
||||
|
||||
以下是其他参考文档, 非必要不读
|
||||
|
||||
|
||||
+86
-2
@@ -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/);
|
||||
});
|
||||
|
||||
+48
-54
@@ -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'),
|
||||
|
||||
+22
@@ -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 = [];
|
||||
|
||||
+2
-2
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user