AGC 陶泥儿打包链路落地与 exports 全退役

- 新增 vite-export-taonier skill:vite.config.taonier.mjs 构建到 dist-taonier,pack.mjs 产出存档根含 index.html 的 .export/taonier.zip
- skill pack 锁步登记:EXPECTED_SKILL_NAMES、AGC_SKILL_PACK_FILES、manifest.json sha 与版本、codex mock 名单
- 新增 export/draft/taonier 文件草稿模块:build:taonier 脚本发现、.export/taonier.json 注册表、四个 tauri 命令与产物读取入口
- 发布暂存改读固定 .export/taonier.zip,删除 phaser/vite 发布门与 exports 打包读取链路
- 删除 exports 初始化目录、exports/README.md 生成与渲染、playtest-package 打包/读取/列举命令
- 生成契约与 prompts 去掉 publish-package artifact 声明,run-trace 白名单与五处排除列表同步
- 同步 ts-rs 生成绑定与 export/generated 行尾空白豁免
This commit is contained in:
2026-10-07 00:54:31 +08:00
parent eea37f80f1
commit b04aceb57d
54 changed files with 2798 additions and 1887 deletions
@@ -15,6 +15,7 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([
'agc-web-game-development',
'platform-abstract',
'taonier-art-assets',
'vite-export-taonier',
'vite-export-xhs-minitool',
]);
@@ -6,7 +6,6 @@
"playtest.tetris": "完成合同要求 tetris-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level,以及 gameplay={kind:'tetris',activePieceId,rotation,row,lockedPieces,lineClearChecks,clearedLines,occupiedCells};gameplay 可包含额外 telemetry 字段,但这些字段不能替代固定必填字段。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。primary-action 必须同步旋转同一 activePieceId,不能只推进 sequence、替换活动方块或更新装饰状态;start 后 2 秒观察内必须出现同一 activePieceId 的 row 下落或真实锁定;primary-action 后 3 秒内必须真实锁定方块,锁定后 activePieceId 必须变化、lockedPieces 恰好增加 1、lineClearChecks 推进,且 occupiedCells 与 clearedLines 必须体现新增方块或实际消行,不能只增加计数;restart 后 occupiedCells、lockedPieces、lineClearChecks 与 clearedLines 必须全部归零。初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并至少持续 2 秒保持 playing。primary-action 必须同步推进 sequence 与 rotation;动作后 phase 可为 playing、won 或 lost,若保持 playing 则继续观察最多 3 秒以取得锁定和消行检查证据。restart 后必须推进 sequence、恢复 ready 或 playing,并持续 3 秒稳定观察。全部观察期间 sequence 不得回退;若首轮进入 lost,必须按 generic-v1 的重开重试合同证明第二次能进入 won 或保持 playing,不能固定失败。",
"playtest.laneDefense": "完成合同要求 lane-defense-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
"owner.visualUsage": "本轮必须实际接入已登记的平台美术切片:先用 asset.list 读取 assets/art-spritesheet-slices/manifest.json,再在 game/index.html 的可见 canvas 主循环中为 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四个切片分别创建 Image 并用相对路径加载;在 requestAnimationFrame 绘制中对每个已加载切片调用 ctx.drawImage(image, dx, dy, dw, dh) 或九参数裁剪形式,目标区域必须可见且至少 32×32。只放置 <img>/<picture>、只展示整张 assets/art-spritesheet.png、只写路径或只在注释中引用都不满足完成合同。",
"owner.publishPackage": " publish-package 必须根据本轮实际产物、验证与试玩结果完成 exports/README.md,不得留下模板字段或 forbidden marker。禁止在表示“已完成”或“无”的句子中复述任何 forbidden marker 字面词;请直接陈述实际完成内容。所有 Markdown checklist 必须使用 [x] 或 [X],不得保留未勾选项。",
"owner.visualRequirement": "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。以实际声明资源的登记状态作为验收依据。",
"owner.verifyCodePrototype": "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
"owner.verifyArtifact": "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
@@ -16,7 +15,7 @@
"background.previewReadiness": "{base}\n\n这是 只读静态验证任务,不要修改项目文件。固定核心动作是且只能是 command.run_limited(commandId=game.static_smoke);通过后直接交付验证结论,不要调用其它命令、项目 mutation 或 task.update。",
"background.previewPlaytest": "{base}\n\n这是 只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update。",
"background.artDirectionWithoutCredentials": "{base}\n\n这是 无生图凭据只读协调任务。当前未配置 External Editor 生图凭据,上述 seed task 中 assets/art-spec.png 图片产物与生成验收条款在本轮不适用;只交付正式视觉方向结论,不要修改项目文件,不调用 canvas.asset_generate、game.static_smoke、project.verify、command.run_limited、preview 或 task.update。",
"background.coordination": "{base}\n\n这是 只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update。",
"background.coordination": "{base}\n\n这是 只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/ 或 assets/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update。",
"background.relaxed": "处理 manifest ready 任务:{}\n\n任务 ID:{}\n专业组:{}\n角色:{}\n依赖(仅供参考):{}\n\n这是并行自主执行任务。请在当前项目根内按你的职责自行规划和调用可用工具,可以与其它任务同时进行。完成后直接回复实际完成情况。",
"background.standard": "处理 manifest ready 任务:{}\n\n任务 ID:{}\n专业组:{}\n角色:{}\n依赖:{}\n预期产物:{}\n验收标准:{}\n\n请按你的 Agent 职责自主规划、调用可用工具、记录观察,并在完成或阻塞时更新任务状态。",
"codexAppServer.baseInstructionsFallback": "You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Base completion claims on observed results.",
File diff suppressed because one or more lines are too long
@@ -42,6 +42,6 @@
"default_downstream": "Generator 必须响应本角色交付物并保持可试玩原型闭环。",
"code_acceptance_risk": "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。",
"asset_acceptance_risk": "未记录画板或本地资产占位会影响资产回流验收。",
"publishing_acceptance_risk": "缺发布包装会影响最终 exports/README.md 与运营组 handoff。",
"publishing_acceptance_risk": "缺发布包装会影响运营组 handoff 与发布资料整理。",
"default_acceptance_risk": "输出空泛或偏离用户需求会增加 Generator 返工概率。"
}
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.70",
"version": "2026-08-26.72",
"skills": [
{
"name": "agc-unity-editor",
@@ -181,6 +181,22 @@
],
"sha256": "26e5b0b1d2fac6321eff0c834e15bce83ce70a435965c510254e40f3c16370e2"
},
{
"name": "vite-export-taonier",
"purpose": "把 vite 项目导出并打包为陶泥儿可托管 H5 的 zip 制品",
"triggers": [
"导出陶泥儿 H5 制品",
"校验或打包 taonier 产物",
"适配陶泥儿 H5 托管环境"
],
"requiredTools": [],
"files": [
"SKILL.md",
"scripts/pack.mjs",
"scripts/vite.config.taonier.mjs"
],
"sha256": "2af5145e4cb52ad1c5c190890f6ba495977159953bb2499119bafc12efba39ab"
},
{
"name": "vite-export-xhs-minitool",
"purpose": "把 vite 项目导出并打包为符合小红书小工具规范的 zip 制品",
@@ -0,0 +1,47 @@
---
name: vite-export-taonier
description: >-
vite 项目导出陶泥儿可托管 H5 制品
metadata:
version: "1.0.0"
---
# 目标:
* 实现打包脚本 `build:taonier`: 用 `scripts/vite.config.taonier.mjs` 把 vite 项目构建到 `dist-taonier`,
再用 `scripts/pack.mjs` 产出 zip 到固定文件: `.export/taonier.zip`
1. 复制 (并按实际情况修改) `scripts/vite.config.taonier.mjs` 作为 vite 构建配置, 这个配置把 dist 收尾成可托管的 H5
根目录: 相对路径入口、根目录 `index.html`、清空空目录. 该配置不做语法降级.
2. 使用 `scripts/pack.mjs` 打包成 zip; `--zip-out` 默认 **`../.export/taonier.zip`** (假设 cwd 是 `game/` 子工程,
产物落在 **项目根** `.export/taonier.zip`; 相对 cwd 解析).
3. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本.
* 不干扰正常的web构建
# 平台要求:
* 仅支持单个 `.zip` 压缩包.
* zip 解压后**根目录**须有 `index.html`(**不得有外层文件夹**): 服务端只认存档根条目 `index.html`,
`game/index.html` 之类会被判缺入口而拒收.
* 因此 `scripts/pack.mjs` 默认把构建产物内容直接放到 zip 存档根 (`index.html`、`assets/...`), 结构不合格时
整体失败, 不会留下可误传的 zip. `--root-dir` 是可选参数: 只有调用方显式给出时才会额外套一层顶层文件夹.
# 假设和默认项:
* vite项目目录: game/
* 各个工具 (包括示例vite配置) 默认cwd 就是 vite 项目根 (所以对于非标准的目录结构需要给出显式的参数来适应)
* 陶泥儿是普通 H5 托管, 没有小红书小工具那份 Chrome 61 / IIFE / 禁内联脚本约束; 本配置不做语法降级, 只保证相对路径与
根 `index.html`.
# 脚本参数:
| 脚本 | 参数 |
|-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `scripts/vite.config.taonier.mjs` | `build.outDir`(默认 `dist-taonier`;与打包时的 `--vite-built-dir` 必须是同一个目录) |
| `scripts/pack.mjs` | `--vite-built-dir <dir>`(默认 `dist-taonier`)、`--zip-out <path>`(默认 `../.export/taonier.zip`,必须以 `.zip` 结尾)、`--root-dir <name>`(**可选**,默认不套外层文件夹;给出时才把内容套进单层文件夹 `<name>/`) |
# 一些情况:
* 需要精简游戏内容/删减资源/压缩素材 请和用户讨论
* 如果使用了外部能力, 参考platform-abstract skill对项目先重构
* 陶泥儿的托管条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本
@@ -0,0 +1,195 @@
#!/usr/bin/env node
/**
* pack.mjs 的本地测试(`.` 前缀,不随 Skill Pack 发布):
* 重点覆盖陶泥儿的三条硬要求——只收 .zip、解压后只有一个顶层文件夹、且该文件夹根目录直接带
* index.html。
*/
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, 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 { packTaonier } from './pack.mjs';
const PACK = fileURLToPath(new URL('./pack.mjs', import.meta.url));
const BASE = mkdtempSync(join(tmpdir(), 'taonier-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">'
+ '</head><body><div id="app"></div>'
+ '<script type="module" src="./assets/app.js"></script></body></html>';
/** 造一个最小的 H5 构建产物目录;extra 用来放额外文件。 */
function writeBuiltDir(name, extra = {}) {
const root = join(BASE, name);
mkdirSync(join(root, 'assets'), { recursive: true });
writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8');
writeFileSync(join(root, 'assets', '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;
}
/** 直接解析中央目录读取 zip 条目名:验证真实写出的字节,而不是只看返回值。 */
function zipEntryNames(zipPath) {
const buf = readFileSync(zipPath);
const eocd = buf.length - 22;
assert.equal(buf.readUInt32LE(eocd), 0x06054b50, '未找到 EOCD');
const count = buf.readUInt16LE(eocd + 10);
let offset = buf.readUInt32LE(eocd + 16);
const names = [];
for (let i = 0; i < count; i += 1) {
assert.equal(buf.readUInt32LE(offset), 0x02014b50, '中央目录签名错误');
const nameLength = buf.readUInt16LE(offset + 28);
const extraLength = buf.readUInt16LE(offset + 30);
const commentLength = buf.readUInt16LE(offset + 32);
names.push(buf.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
offset += 46 + nameLength + extraLength + commentLength;
}
return names;
}
test('干净产物默认不套外层:index.html 直接位于 zip 存档根', () => {
writeBuiltDir('clean');
const zipOut = join(BASE, 'clean-out', 'taonier.zip');
assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'clean', zipOut }), 0);
const names = zipEntryNames(zipOut);
assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`);
assert.ok(names.includes('assets/app.js'), `缺少 assets/app.js: ${names}`);
// 不得有外层文件夹:条目不许多出 game/ 之类的前缀。
assert.deepEqual(
names.filter((name) => !name.startsWith('assets/') && name !== 'index.html'),
[],
`存档根出现了计划外条目: ${names}`,
);
assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`);
});
test('显式传 rootDir: null 也不套外层文件夹', () => {
writeBuiltDir('explicit-null-root');
const zipOut = join(BASE, 'explicit-null-root-out', 'taonier.zip');
assert.equal(
packTaonier({ cwd: BASE, viteBuiltDir: 'explicit-null-root', zipOut, rootDir: null }),
0,
);
const names = zipEntryNames(zipOut);
assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`);
assert.ok(!names.some((name) => name.startsWith('game/')), `不应套外层: ${names}`);
});
test('--root-dir 可选:给了才套指定顶层文件夹', () => {
writeBuiltDir('custom-root');
const zipOut = join(BASE, 'custom-root-out', 'taonier.zip');
assert.equal(
packTaonier({ cwd: BASE, viteBuiltDir: 'custom-root', zipOut, rootDir: 'my-h5' }),
0,
);
const names = zipEntryNames(zipOut);
assert.ok(names.includes('my-h5/index.html'), `缺少 my-h5/index.html: ${names}`);
assert.deepEqual(names.filter((name) => !name.startsWith('my-h5/')), []);
});
test('产物根缺少 index.html 时整体失败,且不写出 zip', () => {
const root = join(BASE, 'no-index');
mkdirSync(join(root, 'assets'), { recursive: true });
writeFileSync(join(root, 'assets', 'app.js'), 'console.log(1);\n', 'utf8');
const zipOut = join(BASE, 'no-index-out', 'taonier.zip');
assert.throws(
() => packTaonier({ cwd: BASE, viteBuiltDir: 'no-index', zipOut }),
/缺少 index\.html/,
);
assert.equal(existsSync(zipOut), false, '失败时不应写出 zip');
});
test('只接受 .zip 落点', () => {
writeBuiltDir('only-zip');
assert.throws(
() => packTaonier({ cwd: BASE, viteBuiltDir: 'only-zip', zipOut: 'only-zip-out/game.tar' }),
/只支持 \.zip/,
);
});
test('非法 --root-dir 被拒绝', () => {
writeBuiltDir('bad-root');
for (const rootDir of ['', '..', 'a/b', 'a\\b']) {
assert.throws(
() => packTaonier({ cwd: BASE, viteBuiltDir: 'bad-root', zipOut: join(BASE, 'x.zip'), rootDir }),
/--root-dir/,
`rootDir=${JSON.stringify(rootDir)} 未被拒绝`,
);
}
});
test('zip 内含 sourcemap 时失败并删除已写出的文件', () => {
writeBuiltDir('with-map', { 'assets/app.js.map': '{}\n' });
const zipOut = join(BASE, 'with-map-out', 'taonier.zip');
assert.throws(
() => packTaonier({ cwd: BASE, viteBuiltDir: 'with-map', zipOut }),
/禁止文件/,
);
assert.equal(existsSync(zipOut), false, '不合规的 zip 不应留在落点');
});
test('普通 .map 游戏资源不算 sourcemap', () => {
writeBuiltDir('with-level-map', { 'assets/level.map': '{"layers":[]}\n' });
const zipOut = join(BASE, 'with-level-map-out', 'taonier.zip');
assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'with-level-map', zipOut }), 0);
assert.ok(zipEntryNames(zipOut).includes('assets/level.map'));
});
test('--zip-out 落在产物目录内时被拒绝', () => {
const root = writeBuiltDir('zip-inside');
assert.throws(
() => packTaonier({
cwd: BASE,
viteBuiltDir: 'zip-inside',
zipOut: join(root, 'taonier.zip'),
}),
/不能落在 --vite-built-dir 内/,
);
});
test('产物含符号链接时失败', () => {
const root = writeBuiltDir('with-symlink');
symlinkSync(join(root, 'assets', 'app.js'), join(root, 'assets', 'link.js'));
assert.throws(
() => packTaonier({ cwd: BASE, viteBuiltDir: 'with-symlink', zipOut: join(BASE, 'symlink.zip') }),
/符号链接/,
);
});
test('默认参数:dist-taonier + 存档根 index.html + ../.export/taonier.zip(假设 cwd 是 game/)', () => {
const game = join(BASE, 'default-layout', 'game');
writeBuiltDir(join('default-layout', 'game', 'dist-taonier'));
assert.equal(packTaonier({ cwd: game }), 0);
const zipOut = join(BASE, 'default-layout', '.export', 'taonier.zip');
assert.ok(existsSync(zipOut), '默认落点不在项目根的 .export/taonier.zip');
const names = zipEntryNames(zipOut);
assert.ok(names.includes('index.html'), `默认应当在存档根放 index.html: ${names}`);
assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`);
});
test('CLI 拒绝不认识的参数,不写出 zip', () => {
const dir = writeBuiltDir('cli');
const zipOut = join(BASE, 'cli-out.zip');
const result = spawnSync(
process.execPath,
[PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut],
{ cwd: BASE, encoding: 'utf8' },
);
assert.equal(result.status, 2);
assert.match(result.stderr, /不认识的参数/);
assert.equal(existsSync(zipOut), false, '参数被拒时不应写出 zip');
});
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* vite.config.taonier.mjs 的收尾插件端到端测试:真跑一遍 `vite build`,验证 dist 在 build
* 结束时已经是可托管的 H5 根目录(根 index.html、资源相对路径、空目录被清掉),而 pack 只
* 负责套单层文件夹并打 zip。
*
* 这是本地开发辅助(`.` 前缀),不随 Skill Pack 发布。
*/
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test, { after } from 'node:test';
import { packTaonier } from './pack.mjs';
import {
TAONIER_OUT_DIR,
assertH5Root,
defineTaonierConfig,
pruneEmptyDirs,
taonierArtifactPlugin,
} from './vite.config.taonier.mjs';
const BASE = mkdtempSync(join(tmpdir(), 'taonier-vite-config-tests-'));
after(() => {
rmSync(BASE, { recursive: true, force: true });
});
const SOURCE_HTML = '<!doctype html>\n'
+ '<html lang="zh-CN">\n'
+ ' <head>\n'
+ ' <meta charset="UTF-8" />\n'
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n'
+ ' <title>fixture</title>\n'
+ ' </head>\n'
+ ' <body>\n'
+ ' <div id="app"></div>\n'
+ ' <script type="module" src="/src/main.js"></script>\n'
+ ' </body>\n'
+ '</html>\n';
/** 造一个最小 vite 项目;返回项目根。 */
function writeProject(name) {
const root = join(BASE, name);
mkdirSync(join(root, 'src'), { recursive: true });
mkdirSync(join(root, 'public', 'empty-dir'), { recursive: true });
writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8');
writeFileSync(
join(root, 'src', 'main.js'),
"document.getElementById('app').textContent = 'ok';\n",
'utf8',
);
return root;
}
test('config 的 outDir 固定为 dist-taonier', () => {
assert.equal(TAONIER_OUT_DIR, 'dist-taonier');
assert.equal(defineTaonierConfig({}).build.outDir, 'dist-taonier');
});
test('vite 构建收尾:dist 可直接被打成存档根带 index.html 的 zip', async () => {
const root = writeProject('finalized');
const { build } = await import('vite');
const config = defineTaonierConfig({ root, logLevel: 'silent' });
await build({ ...config, configFile: false });
const outDir = join(root, 'dist-taonier');
assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成');
const html = readFileSync(join(outDir, 'index.html'), 'utf8');
assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用');
assert.match(html, /src="\.\/assets\/[^"]+\.js"/, '入口脚本不是相对路径');
// public/empty-dir 是空目录,收尾后不应留在 dist。
assert.equal(existsSync(join(outDir, 'empty-dir')), false, '空目录没有被清理');
const zipOut = join(BASE, 'out', 'taonier.zip');
assert.equal(packTaonier({ cwd: root, zipOut }), 0);
assert.ok(existsSync(zipOut), 'zip 未生成');
});
test('base 被改成根路径时构建失败', async () => {
const root = writeProject('absolute-base');
const { build } = await import('vite');
const config = defineTaonierConfig({ root, logLevel: 'silent', base: '/' });
await assert.rejects(
build({ ...config, configFile: false }),
/根路径资源/,
'根路径引用没有被收尾插件拦下',
);
});
test('构建失败时不收尾:不覆盖真实报错', async () => {
const root = join(BASE, 'failed-build');
mkdirSync(join(root, 'src'), { recursive: true });
writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8');
writeFileSync(join(root, 'src', 'main.js'), 'this is not valid js {{\n', 'utf8');
const { build } = await import('vite');
const config = defineTaonierConfig({ root, logLevel: 'silent' });
await assert.rejects(
build({ ...config, configFile: false }),
(error) => {
assert.doesNotMatch(error.message, /taonier-artifact/,
'插件把自己的报错盖在了真实构建错误上');
return true;
},
);
assert.equal(existsSync(join(root, 'dist-taonier', 'index.html')), false,
'失败的构建不该有 index.html');
});
test('defineTaonierConfig 始终追加收尾插件,且只跑一次', () => {
const withExtra = defineTaonierConfig({ plugins: [{ name: 'extra' }] });
const names = withExtra.plugins.map((plugin) => plugin.name);
assert.ok(names.includes('taonier-artifact'), `缺少收尾插件: ${names}`);
assert.equal(
names.filter((name) => name === 'taonier-artifact').length,
1,
`收尾插件重复: ${names}`,
);
assert.ok(names.includes('extra'), 'overrides 的插件丢了');
assert.equal(names.at(-1), 'taonier-artifact', `收尾插件不在最后: ${names}`);
});
test('assertH5Root 拦截根路径引用与缺失的 index.html', () => {
const root = join(BASE, 'unit-assert');
mkdirSync(root, { recursive: true });
assert.throws(() => assertH5Root(root), /index\.html 不存在/);
writeFileSync(join(root, 'index.html'), '<script src="/app.js"></script>\n', 'utf8');
assert.throws(() => assertH5Root(root), /根路径资源/);
writeFileSync(join(root, 'index.html'), '<script src="./app.js"></script>\n', 'utf8');
assert.doesNotThrow(() => assertH5Root(root));
});
test('pruneEmptyDirs 只删空目录,保留有内容的目录', () => {
const root = join(BASE, 'prune');
mkdirSync(join(root, 'empty', 'nested'), { recursive: true });
mkdirSync(join(root, 'kept'), { recursive: true });
writeFileSync(join(root, 'kept', 'file.js'), 'console.log(1);\n', 'utf8');
pruneEmptyDirs(root);
assert.equal(existsSync(join(root, 'empty')), false, '空目录未删除');
assert.ok(existsSync(join(root, 'kept', 'file.js')), '有内容的目录被误删');
});
test('taonierArtifactPlugin 声明为 build-only 的后置插件', () => {
const plugin = taonierArtifactPlugin();
assert.equal(plugin.apply, 'build');
assert.equal(plugin.enforce, 'post');
});
@@ -0,0 +1,456 @@
#!/usr/bin/env node
/**
* 把 vite 构建产物打成陶泥儿 H5 托管 zip(纯 Node,不依赖 zip / zipinfo 系统命令)。
*
* 陶泥儿的上传要求很短:
* - 仅支持单个 .zip 压缩包;
* - zip 存档根目录必须直接包含 index.html(**不得有外层文件夹**)。
*
* 所以本脚本只做三件确定性的事:
* 1. 把 `--vite-built-dir`(默认 dist-taonier)的内容原样放到 zip 存档根;
* 2. 用 node:zlib 写出 zip,index.html 位于存档根 `index.html`;
* 3. 校验结构与构建目录根:不合格时整体失败,不留下可误传的 zip。
*
* 陶泥儿服务端只认存档根 `index.html`(`game/index.html` 会被判 MissingEntry),所以默认**不套**
* 外层文件夹;`--root-dir` 只在调用方确有需要时显式给出,才会额外套一层顶层文件夹。
*
* dist 的收尾(相对路径、根 index.html、清空目录)已由 `vite.config.taonier.mjs` 的
* `taonier-artifact` 插件在构建时完成;本脚本只读 dist,不改写它。
*/
import {
closeSync,
existsSync,
lstatSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeSync,
} from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { deflateRawSync } from 'node:zlib';
const MIB = 1024 * 1024;
/**
* 默认落点:假设 npm 脚本挂在 `game/` 子工程(cwd = `game/`),宿主在**项目根**
* `.export/taonier.zip` 找产物,所以默认写 `../.export/taonier.zip`。
* 脚本挂在项目根 `package.json`(cwd = 项目根)时用 `--zip-out .export/taonier.zip` 覆盖。
*/
const DEFAULT_ZIP_OUT = join('..', '.export', 'taonier.zip');
const DEFAULT_VITE_BUILT_DIR = 'dist-taonier';
/**
* 可选外层文件夹名。陶泥儿要求存档根直接有 index.html,所以默认 `null`=不套外层;
* 调用方显式给 `--root-dir` 时才套一层(名字不能含路径分隔符)。
*/
const DEFAULT_ROOT_DIR = null;
/**
* 最终包不应携带的开发/构建残留;命中即失败。
*
* 只拦 `*.js.map` / `*.css.map`(构建生成的 sourcemap),不拦所有 `.map`——有些引擎把关卡
* 数据存成 `level.map`,那是正常游戏资源。
*/
const FORBIDDEN_ENTRY =
/(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.(?:js|css)\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i;
const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] [--zip-out <path>] [--root-dir <name>]
把 vite 构建产物打成陶泥儿 H5 zip(仅 .zip,存档根直接带 index.html)。
--vite-built-dir <dir> vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 ${DEFAULT_VITE_BUILT_DIR}。
该目录根下必须有 index.html;本脚本只读它,不改写。
--zip-out <path> 落点,必须以 .zip 结尾。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程,
产物落在项目根 .export/);相对路径按当前工作目录解析,父目录不存在会自动建。
--root-dir <name> 可选。默认不套外层文件夹(构建产物内容直接位于存档根);给了才把内容套进
单层文件夹 <name>/。不能包含路径分隔符。`;
/** 顶层文件夹名必须是单个安全路径段:非空、不含分隔符、不是 . / ..;`null` 表示不套外层。 */
export function normalizeRootDir(value) {
if (value === null || value === undefined) return null;
const name = String(value).trim();
if (!name || name === '.' || name === '..') {
throw new Error('--root-dir 必须是一个非空的文件夹名');
}
if (name.includes('/') || name.includes('\\')) {
throw new Error(`--root-dir 不能包含路径分隔符:${name}`);
}
return name;
}
/* ------------------------------------------------------------------ */
/* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */
/* ------------------------------------------------------------------ */
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(buffer) {
let c = 0xffffffff;
for (let i = 0; i < buffer.length; i += 1) {
c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
/**
* 收集 dist 下所有条目。`rootName` 为 `null` 时不加前缀:构建目录内容直接落在存档根
* (`index.html`、`assets/...`);给了 `rootName` 才整体挂到 `<rootName>/` 前缀下。
*
* 目录条目在前、文件名用 `/` 分隔、保持排序,产物稳定。套外层时顶层文件夹自身也写一个目录
* 条目,这样即使 dist 为空(实际不会),解压仍能得到唯一文件夹。
*/
function collectZipEntries(rootDir, rootName) {
const prefix = rootName ? `${rootName}/` : '';
const entries = rootName ? [{ name: prefix, dir: true, full: rootDir }] : [];
const visit = (dir) => {
for (const name of readdirSync(dir).sort()) {
const full = join(dir, name);
const st = lstatSync(full);
const nameInZip = `${prefix}${relative(rootDir, full).split(sep).join('/')}`;
if (st.isDirectory()) {
entries.push({ name: `${nameInZip}/`, dir: true, full });
visit(full);
} else if (st.isFile()) {
entries.push({ name: nameInZip, dir: false, full });
}
// 符号链接已在 assertNoSymlinks 拒绝,这里不会遇到。
}
};
visit(rootDir);
return entries;
}
/**
* 写出 zip。数据按条目顺序**流式**写盘,只在内存里保留中央目录,避免大包在内存里再拼一份完整副本。
*
* @returns {string[]} zip 内条目名(含目录条目),用于结构复核
*/
export function writeZip(rootDir, zipPath, rootName) {
return writeZipEntries(collectZipEntries(rootDir, rootName), zipPath);
}
/**
* 把已收集的条目流式写入 zip。
*
* 单独抽一层是为了让调用方能先把条目列表拿去做结构校验、再落盘:结构不合规时落点上不能出现
* 半成品,也不能把落点原有的文件覆盖掉。数据本身仍然边读边写,不在内存里拼完整副本。
*/
function writeZipEntries(entries, zipPath) {
if (entries.length > 0xffff) {
throw new Error(`zip 条目数 ${entries.length} 超过 65535,当前 Node zip 实现不支持 ZIP64`);
}
const now = new Date();
const dosTime =
((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
const dosDate =
(((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
const fd = openSync(zipPath, 'w');
const centralParts = [];
let offset = 0;
try {
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, 'utf8');
let data = Buffer.alloc(0);
let method = 0;
let crc = 0;
let uncompressed = 0;
if (!entry.dir) {
const raw = readFileSync(entry.full);
uncompressed = raw.length;
const deflated = deflateRawSync(raw, { level: 9 });
if (deflated.length < raw.length) {
data = deflated;
method = 8;
} else {
data = raw;
}
crc = crc32(raw);
}
if (data.length > 0xffffffff || uncompressed > 0xffffffff || offset > 0xffffffff) {
throw new Error(`条目 ${entry.name} 超过 4 GiB,当前 Node zip 实现不支持 ZIP64`);
}
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(dosTime, 10);
local.writeUInt16LE(dosDate, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(uncompressed, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
writeSync(fd, local);
writeSync(fd, nameBuf);
if (data.length > 0) writeSync(fd, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(method, 10);
central.writeUInt16LE(dosTime, 12);
central.writeUInt16LE(dosDate, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(uncompressed, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(entry.dir ? 0x10 : 0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, nameBuf);
offset += local.length + nameBuf.length + data.length;
}
const centralBuf = Buffer.concat(centralParts);
if (centralBuf.length > 0xffffffff) {
throw new Error('中央目录超过 4 GiB,当前 Node zip 实现不支持 ZIP64');
}
const centralOffset = offset;
writeSync(fd, centralBuf);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(centralOffset, 16);
eocd.writeUInt16LE(0, 20);
writeSync(fd, eocd);
} finally {
closeSync(fd);
}
return entries.map((entry) => entry.name);
}
/**
* 复核 zip 结构:存档根直接包含 `index.html`。
*
* `rootName` 为 `null`(默认)时要求条目名恰好是 `index.html`;给了 `rootName` 时要求
* `<rootName>/index.html`,且所有条目都在该文件夹内。
*
* @param {string[]} entries writeZip 返回的条目名
* @param {string | null} rootName
*/
function assertArchiveStructure(entries, rootName) {
const prefix = rootName ? `${rootName}/` : '';
const indexName = `${prefix}index.html`;
if (!entries.includes(indexName)) {
throw new Error(
`zip 存档根缺少 ${indexName};陶泥儿要求存档根目录直接包含 index.html(不得有外层文件夹)`,
);
}
if (rootName) {
const outside = entries.find((name) => !name.startsWith(prefix));
if (outside) {
throw new Error(`zip 里出现 ${prefix} 之外的条目:${outside};套了 --root-dir 时所有条目都必须在其中`);
}
}
const bad = entries
.map((name) => name.slice(prefix.length))
.find((name) => FORBIDDEN_ENTRY.test(name));
if (bad) {
throw new Error(`zip 内含禁止文件:${prefix}${bad}`);
}
}
/** 产物目录必须存在、根下有 index.html,且不含符号链接(链接不会被打进包)。 */
function assertPackedSource(viteBuiltDir) {
if (!existsSync(viteBuiltDir)) {
throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`);
}
if (!statSync(viteBuiltDir).isDirectory()) {
throw new Error(`vite 构建产物路径不是目录 ${viteBuiltDir}`);
}
const indexPath = join(viteBuiltDir, 'index.html');
if (!existsSync(indexPath)) {
throw new Error(
`产物根目录缺少 index.html:${indexPath};`
+ '陶泥儿要求 zip 存档根目录直接包含 index.html',
);
}
assertNoSymlinks(viteBuiltDir);
}
function assertNoSymlinks(dir) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const st = lstatSync(full);
if (st.isSymbolicLink()) {
throw new Error(`产物目录含符号链接:${full};请复制真实文件后再打包`);
}
if (st.isDirectory()) assertNoSymlinks(full);
}
}
/**
* 打包并复核。结构(存档根 index.html、可选外层文件夹、禁止条目)在落盘前就校验;任何一步失败
* 都会删掉已写出的 zip,避免把不合规文件留在落点被误上传。
*/
function makeZip(viteBuiltDir, zipPath, rootName) {
const entries = collectZipEntries(viteBuiltDir, rootName);
// 先校验再落盘:落点原有文件不会被这次失败覆盖,也不存在「不合规 zip 短暂存在」的窗口。
assertArchiveStructure(
entries.map((entry) => entry.name),
rootName,
);
try {
writeZipEntries(entries, zipPath);
const size = statSync(zipPath).size;
const location = rootName ? `顶层文件夹 ${rootName}/` : '存档根 index.html';
console.log(
`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB,${location})`,
);
} catch (error) {
rmSync(zipPath, { force: true });
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${message};已删除不合规的 zip`);
}
}
/**
* cwd 歧义预警:宿主在**项目根** `.export/taonier.zip` 找产物,而 `--zip-out` 相对 cwd 解析。
* npm 脚本挂在 `game/` 子工程时 cwd 就是 `game/`,写 `.export/...` 会落到 `game/.export/...`,
* 导出判失败却没有任何提示。这里只在落点位于「当前 cwd 的 `.export/`」时提醒(`../.export/...`
* 或绝对路径不触发),把 cwd 与绝对落点打出来,让 workdir 问题不再静默。
*/
function warnIfWorkdirRelative(cwd, zipPath, zipOut) {
if (typeof zipOut !== 'string' || isAbsolute(zipOut)) return;
const relativeOut = relative(cwd, zipPath);
if (relativeOut.startsWith('..') || !relativeOut.startsWith(`.export${sep}`)) return;
console.warn(
`[warn] --zip-out 相对 cwd 解析:cwd=${cwd},落点=${zipPath}。`
+ '宿主在项目根 `.export/taonier.zip` 找产物;若 cwd 是 game/ 子工程,'
+ '请写 `../.export/taonier.zip`,否则会落在 game/.export/ 导致导出判失败。',
);
}
/**
* @param {{
* cwd?: string,
* viteBuiltDir?: string,
* zipOut?: string,
* rootDir?: string | null,
* }} options
*/
export function packTaonier(options = {}) {
const cwd = resolve(options.cwd || process.cwd());
const viteBuiltDir = resolve(cwd, options.viteBuiltDir || DEFAULT_VITE_BUILT_DIR);
const zipPath = resolve(cwd, options.zipOut || DEFAULT_ZIP_OUT);
const rootName = normalizeRootDir(options.rootDir ?? DEFAULT_ROOT_DIR);
if (!zipPath.toLowerCase().endsWith('.zip')) {
throw new Error(`陶泥儿只支持 .zip 压缩包,--zip-out 必须以 .zip 结尾:${zipPath}`);
}
// 落点在产物目录内时,上一次的 zip 会被当成资源打进新包(写盘前已 collect),也会在写盘
// 中途被覆盖;直接拒绝这种自包含布局。
const insideBuiltDir = relative(viteBuiltDir, zipPath);
if (insideBuiltDir === '' || (!insideBuiltDir.startsWith('..') && !isAbsolute(insideBuiltDir))) {
throw new Error(`--zip-out 不能落在 --vite-built-dir 内:${zipPath}`);
}
warnIfWorkdirRelative(cwd, zipPath, options.zipOut);
assertPackedSource(viteBuiltDir);
// 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。
mkdirSync(dirname(zipPath), { recursive: true });
makeZip(viteBuiltDir, zipPath, rootName);
return 0;
}
export default packTaonier;
function isMainModule() {
const entry = process.argv[1];
if (!entry) return false;
try {
if (realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) return true;
} catch {
/* fall through to URL comparison */
}
try {
return import.meta.url === pathToFileURL(entry).href;
} catch {
return false;
}
}
if (isMainModule()) {
const raw = process.argv.slice(2);
if (raw.includes('--help') || raw.includes('-h')) {
console.log(USAGE);
process.exit(0);
}
// 取走一个参数和它的值;剩下的非空参数说明调用方传了本脚本不认的东西。
const takeFlag = (name) => {
const idx = raw.indexOf(name);
if (idx === -1) return undefined;
const value = raw[idx + 1];
if (!value || value.startsWith('-')) {
throw new Error(`${name} 后面要跟一个值`);
}
raw.splice(idx, 2);
return value;
};
let viteBuiltDir;
let zipOut;
let rootDir;
try {
viteBuiltDir = takeFlag('--vite-built-dir');
zipOut = takeFlag('--zip-out') || DEFAULT_ZIP_OUT;
// 不给就是 null=不套外层文件夹;给了才套。
rootDir = takeFlag('--root-dir');
// 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。
if (raw.length > 0) {
throw new Error(`不认识的参数:${raw.join(' ')}`);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
console.error(USAGE);
process.exit(2);
}
try {
packTaonier({ viteBuiltDir, zipOut, rootDir });
process.exit(0);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
@@ -0,0 +1,139 @@
/**
* 陶泥儿 H5 托管 Vite 配置(可直接作为项目 vite.config 使用):
*
* vite build --config <此文件路径>
*
* 或在项目自己的 vite.config 里合并:
*
* import { defineTaonierConfig } from '<此文件路径>';
* export default defineTaonierConfig({ plugins: [...] });
*
* 构建边界只有一条:**本配置负责把 dist 变成一个可托管的 H5 根目录,pack.mjs 只负责按契约打 zip。**
*
* 陶泥儿就是普通 H5 托管(不同于小红书小工具 vite-export-xhs-minitool):平台没有 Chrome 61
* 基线、IIFE 单入口、禁内联脚本等运行约束,所以本配置不降级语法,也不重写 index.html。只固定
* 三件与上传契约有关的事:
*
* - base: './' 相对路径。zip 存档根就是 `<index.html>`,会被托管在任意子路径,根路径引用
* (/assets/...)在子路径下会 404。
* - build.outDir 与 pack.mjs 的 --vite-built-dir 是同一个目录(默认 dist-taonier),两处
* 必须一致。
* - sourcemap 置 false:不把 .map 打进上传包(pack 也会拒绝构建 sourcemap),也减小包体。
*
* 收尾插件只做契约校验:dist 根必须有 index.html,且 index.html 不得引用根路径资源。这样
* 「指错目录」或「误改 base」会在构建结束时就失败,而不是上传后才暴露问题。
*
* 平台约束:仅支持单个 .zip 压缩包;zip 解压后根目录必须直接包含 index.html(不得有外层文件夹),
* 与 pack.mjs 的默认布局一致。
*/
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { defineConfig, mergeConfig } from 'vite';
/** 产物目录;必须与 pack.mjs 的 --vite-built-dir 默认值一致。 */
export const TAONIER_OUT_DIR = 'dist-taonier';
/** 收尾插件名;defineTaonierConfig 按它去重,保证插件只跑一次。 */
const ARTIFACT_PLUGIN_NAME = 'taonier-artifact';
/**
* 校验 dist 是否是一个合格的 H5 根目录:根 index.html 存在,且不引用根路径资源。
*
* 缺失 index.html 时直接抛错——那说明构建配置指错了目录,继续打 zip 只会交付一个平台拒收的包
* (解压后唯一文件夹下没有 index.html)。出现 `src="/…"` / `href="/…"` 同样抛错:交付物会被
* 托管在子路径,根路径引用一定 404。
*
* @param {string} outDir 已解析为绝对路径的构建输出目录
*/
export function assertH5Root(outDir) {
const htmlPath = join(outDir, 'index.html');
if (!existsSync(htmlPath)) {
throw new Error(`${htmlPath} 不存在,请先执行 vite build,并确认 build.outDir 指向构建输出目录`);
}
const html = readFileSync(htmlPath, 'utf8');
const absoluteRefs = [...html.matchAll(/\s(?:src|href)="(\/[^"]*)"/gi)].map((match) => match[1]);
if (absoluteRefs.length > 0) {
throw new Error(
`index.html 仍引用根路径资源(${absoluteRefs.join(', ')});`
+ "请把 vite base 设为 './',否则 zip 解压到子路径后资源会 404",
);
}
}
/** 递归删除空目录;zip 里不留无意义目录条目。 */
export function pruneEmptyDirs(dir) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
pruneEmptyDirs(path);
if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true });
}
}
}
/**
* dist 收尾插件:在 bundle 写盘后原地校验产物,让 pack 只需套文件夹、打 zip。
*
* 用 `closeBundle`(最后一个构建钩子)而不是 `writeBundle`:publicDir 已复制完,且排在其它
* 插件之后。`apply: 'build'` 保证 dev server 不触发。
*
* 注意:rollup 在构建失败时**也会**调 `closeBundle`。所以用 `buildEnd(error)` 记下失败,
* 失败就直接返回——否则会用「index.html 不存在」盖掉真正的语法报错。
*
* @returns {import('vite').Plugin}
*/
export function taonierArtifactPlugin() {
/** @type {string} */
let outDir = '';
let buildFailed = false;
return {
name: ARTIFACT_PLUGIN_NAME,
apply: 'build',
enforce: 'post',
configResolved(config) {
// config.build.outDir 是相对 config.root 的路径,必须自己解析成绝对路径。
outDir = resolve(config.root, config.build.outDir);
},
buildEnd(error) {
buildFailed = Boolean(error);
},
closeBundle() {
if (buildFailed) return;
assertH5Root(outDir);
pruneEmptyDirs(outDir);
},
};
}
export const taonierBaseConfig = {
base: './',
build: {
outDir: TAONIER_OUT_DIR,
emptyOutDir: true,
sourcemap: false,
},
plugins: [taonierArtifactPlugin()],
};
/**
* 合并项目配置,并保证收尾插件一定在 `plugins` 里。
*
* `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方
* 若先展开 `taonierBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后追加一个
* 新实例:无论 overrides 怎么给,插件都在最后且只跑一次。
*
* @param {import('vite').UserConfig} [overrides]
* @returns {import('vite').UserConfig}
*/
export function defineTaonierConfig(overrides = {}) {
const merged = mergeConfig(defineConfig(taonierBaseConfig), overrides);
merged.plugins = [
...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME),
taonierArtifactPlugin(),
];
return merged;
}
export default defineTaonierConfig();
@@ -7358,7 +7358,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es
printf '%s\n' '{"id":2,"result":{}}'
IFS= read -r skills_list
case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac
printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"}],"errors":[]}]}}'
printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"},{"name":"vite-export-taonier"}],"errors":[]}]}}'
while IFS= read -r line; do :; done
"#,
)
@@ -8103,7 +8103,7 @@ while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*) printf '{{"id":%s,"result":{{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}}}\n' "$id" ;;
*'"method":"skills/extraRoots/set"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}},{{"name":"platform-abstract"}},{{"name":"vite-export-xhs-minitool"}}],"errors":[]}}]}}}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}},{{"name":"platform-abstract"}},{{"name":"vite-export-xhs-minitool"}},{{"name":"vite-export-taonier"}}],"errors":[]}}]}}}}\n' "$id" ;;
*'"method":"thread/start"'*) printf '{{"id":%s,"result":{{"thread":{{"id":"thread-1"}}}}}}\n' "$id" ;;
*'"method":"thread/inject_items"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;;
*'"method":"turn/start"'*)
@@ -8273,7 +8273,7 @@ while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;;
*'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"}],"errors":[]}]}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"},{"name":"vite-export-taonier"}],"errors":[]}]}}\n' "$id" ;;
*'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;;
*'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
*'"method":"turn/start"'*)
@@ -3603,7 +3603,6 @@ fn direct_npm_source_paths(root: &Path) -> Vec<String> {
| "dist"
| "target"
| "memory"
| "exports"
| "auth.json"
| "credentials.json"
| "game-creator.config.json"
@@ -233,7 +233,6 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
| ".cache"
| "node_modules"
| "target"
| "exports"
| "logs"
| "coverage"
) || name.starts_with(".edge-profile")
@@ -19,7 +19,6 @@ pub(crate) fn write_local_game_draft_at(
let balance_path = root.join("game/balance.json");
let art_manifest_path = root.join("assets/manifest.art.json");
let audio_manifest_path = root.join("assets/manifest.audio.json");
let publish_readme_path = root.join("exports/README.md");
let agent_log_path = root.join(".agent/logs/agent.log");
let short_memory_path = root.join("memory/session.md");
let long_memory_path = root.join("memory/project.md");
@@ -68,20 +67,6 @@ pub(crate) fn write_local_game_draft_at(
audio_manifest_path.display()
)
})?;
fs::write(
&publish_readme_path,
format!(
"# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n",
draft.publish_readme.trim()
),
)
.map_err(|error| {
format!(
"写入发布包装草案失败:{}: {error}",
publish_readme_path.display()
)
})?;
fs::write(&game_index_path, draft.game_html.trim())
.map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?;
fs::OpenOptions::new()
@@ -107,7 +92,6 @@ pub(crate) fn write_local_game_draft_at(
"game/balance.json",
"assets/manifest.art.json",
"assets/manifest.audio.json",
"exports/README.md",
"game/index.html",
],
}),
@@ -354,7 +354,7 @@ pub(crate) fn append_agent_success_memories(
let timestamp = unix_timestamp();
let title = draft.title.trim();
let mut blackboard_entry = format!(
"\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n"
"\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n\n## 角色共享摘要\n\n"
);
for brief in briefs {
@@ -234,7 +234,6 @@ pub(crate) fn append_local_artifact_write_step(
"game/balance.json".to_string(),
"assets/manifest.art.json".to_string(),
"assets/manifest.audio.json".to_string(),
"exports/README.md".to_string(),
"game/index.html".to_string(),
".agent/manifest.json".to_string(),
],
@@ -6,7 +6,7 @@ use std::path::{Component, Path};
const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json");
const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1";
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 10] = [
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 11] = [
"agc-browser-playtest",
"agc-client-projection",
"agc-game-production-workflow",
@@ -16,10 +16,11 @@ pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 10] = [
"agc-web-game-development",
"platform-abstract",
"taonier-art-assets",
"vite-export-taonier",
"vite-export-xhs-minitool",
];
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 36] = [
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 39] = [
(
"agc-unity-editor/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-unity-editor/SKILL.md"),
@@ -128,6 +129,20 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 36] = [
"platform-abstract/SKILL.md",
include_bytes!("../../resources/agc-skills/platform-abstract/SKILL.md"),
),
(
"vite-export-taonier/SKILL.md",
include_bytes!("../../resources/agc-skills/vite-export-taonier/SKILL.md"),
),
(
"vite-export-taonier/scripts/pack.mjs",
include_bytes!("../../resources/agc-skills/vite-export-taonier/scripts/pack.mjs"),
),
(
"vite-export-taonier/scripts/vite.config.taonier.mjs",
include_bytes!(
"../../resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs"
),
),
(
"vite-export-xhs-minitool/SKILL.md",
include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/SKILL.md"),
@@ -1156,128 +1156,6 @@ pub(crate) fn create_local_project_checkpoint(
)
}
/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。
///
/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时
/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。
#[tauri::command]
pub(crate) async fn export_local_project_package(
project_path: String,
) -> Result<LocalProjectExportPackageResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let _lock = acquire_project_write_lock(root, "project.export_package")?;
advance_agent_runtime_project_revision_locked(root)?;
export_local_project_package_for_publish_at(root).await
}
/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。
///
/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过
/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。
#[tauri::command]
pub(crate) fn prepare_local_project_game_package(
app: tauri::AppHandle,
project_path: String,
package_relative_path: String,
) -> Result<crate::game_package_upload::StagedGamePackage, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let payload = read_local_project_export_package_at(root, package_relative_path.trim())?;
let staging_dir = game_package_upload_staging_dir(&app)?;
let mut staged = crate::game_package_upload::stage_game_package_bytes(
&staging_dir,
&payload.package_sha256,
&payload.package_bytes,
)?;
staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX);
Ok(staged)
}
/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。
#[tauri::command]
pub(crate) async fn upload_local_project_game_package(
app: tauri::AppHandle,
staging_path: String,
version_id: String,
api_base_url: String,
access_token: String,
idempotency_key: String,
) -> Result<crate::game_package_upload::GamePackageUploadOutcome, String> {
let staging_dir = game_package_upload_staging_dir(&app)?;
let resolved_path =
crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?;
let client = reqwest::Client::builder()
.build()
.map_err(|error| format!("创建上传客户端失败:{error}"))?;
let version_id = version_id.trim().to_string();
if version_id.is_empty() {
return Err("缺少发行版本标识".to_string());
}
let emit_handle = app.clone();
let progress_version_id = version_id.clone();
let progress_publish_id = idempotency_key.trim().to_string();
let verify_handle = app.clone();
let verify_version_id = version_id.clone();
let verify_publish_id = progress_publish_id.clone();
crate::game_package_upload::upload_staged_game_package(
&client,
crate::game_package_upload::GamePackageUploadRequest {
staging_path: &resolved_path,
version_id: &version_id,
api_base_url: api_base_url.trim(),
access_token: access_token.trim(),
idempotency_key: idempotency_key.trim(),
session_identity: None,
},
move |received_bytes, total_bytes| {
let _ = emit_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&progress_version_id,
&progress_publish_id,
"upload",
"正在上传发行包…",
received_bytes,
total_bytes,
),
);
},
move || {
let _ = verify_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&verify_version_id,
&verify_publish_id,
"verify",
"正在校验发行包…",
0,
0,
),
);
},
)
.await
}
fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
crate::game_package_upload::game_package_upload_staging_dir(&app_data_root)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
#[tauri::command]
pub(crate) fn list_local_project_export_packages(
project_path: String,
) -> Result<LocalProjectExportPackagesResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_list")?;
list_local_project_export_packages_at(root)
}
#[tauri::command]
pub(crate) fn diff_local_project_checkpoint(
project_path: String,
@@ -7,6 +7,10 @@ use asset_generation_tasks::*;
use client_extensions::*;
use environment_check::preflight_web_game_creation;
use error_report::*;
use export::draft::taonier::commands::{
read_taonier_export, read_taonier_export_hash, run_taonier_export_build,
save_taonier_export_form,
};
use export::draft::xhs_minitool::commands::{
read_xhs_minitool_export, read_xhs_minitool_export_hash, run_xhs_minitool_export_build,
save_xhs_minitool_export_form,
@@ -613,9 +617,6 @@ pub(super) fn run() {
login_client_with_password,
login_client_with_phone_code,
logout_client_session,
suggest_game_distribution_publish_metadata,
read_game_cover_generation_price,
generate_game_distribution_cover,
read_game_distribution_publication,
publish_local_project_game,
read_game_creator_app_config,
@@ -693,14 +694,14 @@ pub(super) fn run() {
append_direct_project_conversation_message,
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_xhs_minitool_export,
read_xhs_minitool_export_hash,
save_xhs_minitool_export_form,
run_xhs_minitool_export_build,
prepare_local_project_game_package,
upload_local_project_game_package,
list_local_project_export_packages,
read_taonier_export,
read_taonier_export_hash,
save_taonier_export_form,
run_taonier_export_build,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
read_project_permission_policy,
@@ -1 +1,2 @@
pub mod taonier;
pub mod xhs_minitool;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
//! 导出面板用到的四个 `#[tauri::command]`。
//!
//! 每个命令只做一件事,失败表也各有一条([`super::error`])。没有项目写锁、不推进 revision:宿主
//! 在这里读写的,就是 `.export/` 下那一个注册表文件。
//!
//! 「内容」与「注册表指纹」是两条命令:[`read_taonier_export`] 是面板自动刷新反复调的
//! 那条,拿不到指纹;指纹(写回时的 `baseHash`)只在进编辑会话、或内容真的被采纳时单独取一次。
//! 分开是为了让刷新动作在类型上就动不了写回基线——否则外部改动会被静默吞掉。
//!
//! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。
//!
//! 命令边界各记一条 `app_log!`(走在 `project/*` 模块同样的口径上):面板卡在「正在读取」时,
//! AppData 里的 `export.<命令>.failed` 那一行就是答案。读命令每 2 秒重试一次,所以**同一个失败只
//! 记一次**,变了或中间成功过一次才再记——否则日志会被同一句刷满。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use super::dto::{TaonierExportForm, TaonierExportRunResult, TaonierExportState};
use super::error::{
TaonierExportBuildError, TaonierExportReadError, TaonierExportSaveError,
TaonierExportUnavailable,
};
use super::registry::{self, RegistrySnapshot};
use super::{build, script};
/// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。
#[tauri::command]
pub(crate) async fn read_taonier_export(
project_path: String,
) -> Result<TaonierExportState, TaonierExportReadError> {
let result = offload({
let project_path = project_path.clone();
move || read_content(&project_path)
})
.await;
report("read", &project_path, &result);
result
}
/// 读注册表指纹:写回时原样带回做冲突判据。
///
/// 单独一条命令,且**只在内容被采纳时**取:让「刷新」这条高频路径在类型上碰不到写回基线。
#[tauri::command]
pub(crate) async fn read_taonier_export_hash(
project_path: String,
) -> Result<String, TaonierExportReadError> {
let result = offload({
let project_path = project_path.clone();
move || read_content_hash(&project_path)
})
.await;
report("readHash", &project_path, &result);
result
}
/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。
///
/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是
/// 一份草稿),宿主只负责把它原样写进注册表。`coverPath` / `screenshotPaths` 这类路径事实在真正读
/// 它的那条路(预览、下载)由统一的路径门禁复核,不在这里预支。
#[tauri::command]
pub(crate) async fn save_taonier_export_form(
project_path: String,
form: TaonierExportForm,
base_hash: String,
) -> Result<String, TaonierExportSaveError> {
let result = offload({
let project_path = project_path.clone();
let form = form.clone();
let base_hash = base_hash.clone();
move || save_form(&project_path, &form, &base_hash)
})
.await;
report("save", &project_path, &result);
if let Ok(hash) = &result {
app_log!(
"export.save.ok project={project_path} titleChars={} summaryChars={} categoryChars={} coverPath={} screenshotCount={} baseHash={} contentHash={}",
form.title.trim().chars().count(),
form.summary.trim().chars().count(),
form.category.trim().chars().count(),
form.cover_path.trim(),
form.screenshot_paths.len(),
short(&base_hash),
short(hash),
);
}
result
}
/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。**不校验表单**——表单不是构建输入。
#[tauri::command]
pub(crate) async fn run_taonier_export_build(
project_path: String,
) -> Result<TaonierExportRunResult, TaonierExportBuildError> {
app_log!("export.build.start project={project_path}");
let result = run_build(&project_path).await;
match &result {
Ok(run) => {
clear_failure("build", &project_path);
app_log!(
"export.build.ok project={project_path} outputChars={} omittedCharacters={}",
run.output_tail.chars().count(),
run.omitted_characters,
);
}
Err(error) => log_failure("build", &project_path, error),
}
result
}
/// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。
fn content(root: &Path, snapshot: RegistrySnapshot) -> TaonierExportState {
TaonierExportState {
form: snapshot.form,
has_script: script::find(root).is_some(),
}
}
/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From<ExportUnavailable>` 自己收口。
fn project_root(project_path: &str) -> Result<PathBuf, TaonierExportUnavailable> {
let root = PathBuf::from(project_path);
crate::project::validate_project_root(&root).map_err(TaonierExportUnavailable::new)?;
Ok(root)
}
/// 把小块同步 IO 挪到 Tokio 的阻塞线程池。
///
/// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步
/// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB
/// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。
///
/// 构建命令(`build::run`)的前后置同步 IO 也复用这里,所以对同级的 `build` 模块可见。
pub(super) async fn offload<T, E, F>(job: F) -> Result<T, E>
where
T: Send + 'static,
E: From<TaonierExportUnavailable> + Send + 'static,
F: FnOnce() -> Result<T, E> + Send + 'static,
{
match tokio::task::spawn_blocking(job).await {
Ok(result) => result,
Err(error) => Err(E::from(TaonierExportUnavailable::new(format!(
"后台执行导出读写任务失败:{error}"
)))),
}
}
fn read_content(project_path: &str) -> Result<TaonierExportState, TaonierExportReadError> {
let root = project_root(project_path)?;
Ok(content(
&root,
registry::load::<TaonierExportReadError>(&root)?,
))
}
fn read_content_hash(project_path: &str) -> Result<String, TaonierExportReadError> {
let root = project_root(project_path)?;
Ok(registry::load::<TaonierExportReadError>(&root)?.content_hash)
}
fn save_form(
project_path: &str,
form: &TaonierExportForm,
base_hash: &str,
) -> Result<String, TaonierExportSaveError> {
let root = project_root(project_path)?;
Ok(registry::save(&root, form, base_hash)?.content_hash)
}
async fn run_build(project_path: &str) -> Result<TaonierExportRunResult, TaonierExportBuildError> {
let root = project_root(project_path)?;
build::run(&root).await
}
/// 上一次记过的失败(按命令名 + 项目路径)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。
///
/// 去重键必须带上项目:`registryMalformed` 之类的现场文案不含文件路径,两个项目可能给出同一句
/// `Debug`,只按命令名去重会把项目 B 的失败当成项目 A 的重复现场静默吞掉。
static LAST_FAILURE: LazyLock<Mutex<HashMap<(&'static str, String), String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// 命令边界的统一收口:成功清掉去重记录,失败记一条。
fn report<T, E: std::fmt::Debug>(command: &'static str, project_path: &str, result: &Result<T, E>) {
match result {
Ok(_) => clear_failure(command, project_path),
Err(error) => log_failure(command, project_path, error),
}
}
/// 一条失败日志最多记多少个字符。
///
/// `CommandFailed` 的 `Debug` 里带着最多 4,000 字符的脚本输出尾部,原样落进 AppData 日志会把一次
/// 失败撑成好几 KB,去重表也跟着留一份长字符串。日志只需要认得出「哪个变体、哪条命令失败」,
/// 完整现场已经结构化回给前端了。
const MAX_LOGGED_FAILURE_CHARS: usize = 512;
fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt::Debug) {
let message = bounded_for_log(&format!("{error:?}"), MAX_LOGGED_FAILURE_CHARS);
let key = (command, project_path.trim().to_string());
if let Ok(mut last) = LAST_FAILURE.lock() {
if last.get(&key).is_some_and(|seen| *seen == message) {
return;
}
last.insert(key, message.clone());
}
app_log!("export.{command}.failed project={project_path}: {message}");
}
/// 按**字符**(不是字节)截断日志文本,避免切坏 UTF-8;发生截断时补一个省略号说明。
fn bounded_for_log(message: &str, max_chars: usize) -> String {
if message.chars().count() <= max_chars {
return message.to_string();
}
let head: String = message.chars().take(max_chars).collect();
format!("{head}…")
}
fn clear_failure(command: &'static str, project_path: &str) {
if let Ok(mut last) = LAST_FAILURE.lock() {
last.remove(&(command, project_path.trim().to_string()));
}
}
/// 日志里的指纹只留前 8 位:长度一样能看出「换了没有」,又不用把整串抄进日志。
fn short(hash: &str) -> &str {
let end = hash.char_indices().nth(8).map_or(hash.len(), |(at, _)| at);
&hash[..end]
}
#[cfg(test)]
mod tests {
use super::{bounded_for_log, short};
#[test]
fn short_keeps_first_eight_characters() {
assert_eq!(short("0123456789abcdef"), "01234567");
assert_eq!(short("0123"), "0123");
assert_eq!(short(""), "");
}
#[test]
fn bounded_for_log_keeps_short_text_and_truncates_on_char_boundaries() {
assert_eq!(bounded_for_log("构建失败", 8), "构建失败");
// 按字符截断:4 个中文字符不会被切坏,末尾补省略号。
assert_eq!(bounded_for_log("一二三四五六", 4), "一二三四…");
}
}
@@ -0,0 +1,88 @@
//! 陶泥儿导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这些值的唯一用途是让用户
/// 复制粘贴到陶泥儿平台的上传页。
///
/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能
/// 被静默忽略后带着半份表单继续走。
///
/// `cover_path` 为空字符串表示「沿用线上封面」;`screenshot_paths` 为空数组表示「整组沿用线上」。
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierExportForm {
pub(crate) title: String,
pub(crate) summary: String,
pub(crate) category: String,
/// 项目内相对路径。空字符串表示沿用线上封面。
pub(crate) cover_path: String,
/// 项目内相对路径列表。空数组表示整组沿用线上截图。
pub(crate) screenshot_paths: Vec<String>,
}
impl TaonierExportForm {
/// 落盘形态:文本字段去掉首尾空白,路径列表逐项去空白并**丢弃空串**。
///
/// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白,
/// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。截图列表里的
/// 空串是界面清空某项后的残留,不能作为一条「有路径」的记录落盘。
pub(crate) fn normalized(&self) -> Self {
Self {
title: self.title.trim().to_string(),
summary: self.summary.trim().to_string(),
category: self.category.trim().to_string(),
cover_path: self.cover_path.trim().to_string(),
screenshot_paths: self
.screenshot_paths
.iter()
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
.collect(),
}
}
}
/// 注册表文件形状:`.export/taonier.json`。
///
/// 只有 `form` 一个键——没有 status、没有已适配标记、没有产物存在标记、没有 revision,那些要么是
/// `package.json` 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。
///
/// 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它
/// 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierExportRegistry {
pub(crate) form: TaonierExportForm,
}
/// 面板读到的现场内容。
///
/// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」
/// 永远从 npm 包的 `package.json` 现算,不落盘。也没有注册表指纹:那是写回时的同步细节,
/// 由 `read_taonier_export_hash` 单独给,自动刷新读的这条命令碰不到它。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierExportState {
pub(crate) form: TaonierExportForm,
/// `scripts["build:taonier"]` 是否存在。
pub(crate) has_script: bool,
}
/// 一次成功导出的结果。产物路径是常量(`.export/taonier.zip`),不进 DTO。
///
/// 不带 `exit_code`:走到这里的出口码必然是 0,带回来只是一个恒为 0 的死字段;失败侧
/// (`CommandFailed`)自己带退出码。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierExportRunResult {
/// 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。
pub(crate) output_tail: String,
/// 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。
pub(crate) omitted_characters: usize,
}
@@ -0,0 +1,196 @@
//! 命令边界的 typed 失败。
//!
//! **一条命令一张失败表**,不合并成一张大枚举:调用方拿到的 `Result` 本身就限定了「这条命令
//! 可能怎么失败」,JS 侧也因此不用按命令再挑一遍变体。
//!
//! - [`TaonierExportReadError`]:读注册表(`read_taonier_export` /
//! `read_taonier_export_hash`);
//! - [`TaonierExportSaveError`]:写表单(`save_taonier_export_form`,自动保存与「打包」前的
//! flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是构建
//! 失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有
//! 「字段不合法」这一类;
//! - [`TaonierExportBuildError`]:跑构建(`run_taonier_export_build`)。构建不是表单的消费者,
//! 所以这张表里没有表单类变体。
//!
//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。每条命令都是
//! 首个错误即返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。
//!
//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的
//! TS 联合类型。
//!
//! 载荷结构体统一带 `Taonier` 前缀(变体名不变,所以线上 `type` 标签与小红书目标完全一致)。
//! 原因是 ts-rs 的 `export_to` 是一个**按类型名落文件的平铺目录**:`RegistryMalformed` /
//! `BuildScriptMissing` / `SaveConflict` 这些通用名会被每个导出目标各写一遍,同名互相覆盖,
//! 生成结果取决于测试执行顺序。加平台前缀后每个目标的绑定各落各的文件,重生成可逐字节复现。
use serde::Serialize;
use ts_rs::TS;
use super::dto::TaonierExportForm;
/// 读注册表可能怎么失败。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) enum TaonierExportReadError {
/// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。
RegistryMalformed(TaonierRegistryMalformed),
/// 项目根不可用或其它宿主侧事实故障。
ExportUnavailable(TaonierExportUnavailable),
}
/// 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) enum TaonierExportSaveError {
/// 注册表存在但不是本目标期望的形状。
RegistryMalformed(TaonierRegistryMalformed),
/// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。
SaveConflict(TaonierSaveConflict),
/// 项目根不可用或其它宿主侧事实故障。
ExportUnavailable(TaonierExportUnavailable),
}
/// 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) enum TaonierExportBuildError {
/// npm 包里没有 `build:taonier`;这就是「还没适配」的唯一现算判据。
BuildScriptMissing(TaonierBuildScriptMissing),
/// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。
CommandDenied(TaonierCommandDenied),
/// 脚本跑完没有正常收场(非零退出或超时)。
CommandFailed(TaonierCommandFailed),
/// 脚本正常收场但合格产物不在(没有 zip,或 zip 根目录没有 `index.html`)。
ArtifactMissing(TaonierArtifactMissing),
/// 脚本无法启动或其它宿主侧事实故障。
ExportUnavailable(TaonierExportUnavailable),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierRegistryMalformed {
/// 解析器原文。它是引导语,不是给前端做判断的依据。
pub(crate) cause: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierBuildScriptMissing {
/// 期望的脚本名(`build:taonier`)。
pub(crate) script: String,
/// 宿主找过哪些 `package.json`,让用户知道去哪看。
pub(crate) searched: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierSaveConflict {
/// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。
pub(crate) current: TaonierExportForm,
/// 冲突时文件的指纹;用户选完之后带着它重试保存。
pub(crate) content_hash: String,
}
/// 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障
/// (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierCommandDenied {
/// 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。
pub(crate) command_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierCommandFailed {
pub(crate) exit_code: Option<i32>,
/// 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。
pub(crate) output_tail: String,
/// 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。
pub(crate) omitted_characters: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierArtifactMissing {
/// 产物相对项目根的路径。
pub(crate) path: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierExportUnavailable {
pub(crate) cause: String,
}
impl TaonierExportUnavailable {
pub(crate) fn new(cause: impl Into<String>) -> Self {
Self {
cause: cause.into(),
}
}
}
/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。
impl From<TaonierExportUnavailable> for TaonierExportReadError {
fn from(payload: TaonierExportUnavailable) -> Self {
Self::ExportUnavailable(payload)
}
}
impl From<TaonierExportUnavailable> for TaonierExportSaveError {
fn from(payload: TaonierExportUnavailable) -> Self {
Self::ExportUnavailable(payload)
}
}
impl From<TaonierExportUnavailable> for TaonierExportBuildError {
fn from(payload: TaonierExportUnavailable) -> Self {
Self::ExportUnavailable(payload)
}
}
/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。
macro_rules! impl_from_registry_error {
($error:ty) => {
impl From<crate::export::RegistryError> for $error {
fn from(error: crate::export::RegistryError) -> Self {
match error {
crate::export::RegistryError::Malformed { cause, .. } => {
Self::RegistryMalformed(TaonierRegistryMalformed {
cause: cause.to_string(),
})
}
other => {
Self::ExportUnavailable(TaonierExportUnavailable::new(other.to_string()))
}
}
}
}
};
}
impl_from_registry_error!(TaonierExportReadError);
impl_from_registry_error!(TaonierExportSaveError);
@@ -0,0 +1,92 @@
//! 陶泥儿在 `.export/` 下的固定名字与路径。
//!
//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是
//! **契约常量**,只在这里定义一次,改名字要三处一起改。
use std::path::{Path, PathBuf};
use crate::export::EXPORT_WORK_RELATIVE_DIR;
/// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。
pub(crate) const SCRIPT_NAME: &str = "build:taonier";
/// 注册表文件名(`.export/` 下)。
pub(crate) const REGISTRY_FILE_NAME: &str = "taonier.json";
/// 交付产物文件名(`.export/` 下),人工上传用。
pub(crate) const ARTIFACT_FILE_NAME: &str = "taonier.zip";
pub(crate) fn export_dir(root: &Path) -> PathBuf {
root.join(EXPORT_WORK_RELATIVE_DIR)
}
pub(crate) fn registry_path(root: &Path) -> PathBuf {
export_dir(root).join(REGISTRY_FILE_NAME)
}
pub(crate) fn artifact_path(root: &Path) -> PathBuf {
export_dir(root).join(ARTIFACT_FILE_NAME)
}
/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。
pub(crate) fn artifact_relative_path() -> String {
format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}")
}
#[cfg(test)]
mod tests {
use std::path::Component;
use super::*;
/// 前端拿的是**字符串**相对路径(下载命令与 prompt 都用它),宿主写的是 `PathBuf`。两条路各自
/// 拼一遍,必须落在同一个文件上——谁只改了一边,这条先红。
#[test]
fn the_relative_path_names_the_same_file_the_host_writes() {
let root = Path::new("/tmp/project");
assert_eq!(root.join(artifact_relative_path()), artifact_path(root));
assert_eq!(
artifact_path(root)
.file_name()
.and_then(|name| name.to_str()),
Some(ARTIFACT_FILE_NAME)
);
}
/// 这个字符串会交给宿主的路径门禁(下载命令复核项目内相对路径)并写进 prompt:它必须是
/// **项目内、单层、不带 `..`** 的相对路径。前端与 agent 都按它找文件,跑出 `.export/` 就是越界。
#[test]
fn the_relative_path_cannot_walk_out_of_the_export_directory() {
let relative_path = artifact_relative_path();
let relative = Path::new(&relative_path);
assert!(relative.is_relative());
assert!(relative
.components()
.all(|component| matches!(component, Component::Normal(_))));
// flat 布局:导出目录**直接**下的一个文件名,不为目标建子目录。
assert_eq!(relative.parent(), Some(Path::new(EXPORT_WORK_RELATIVE_DIR)));
// 跨平台:它是给前端与 prompt 的字符串,Windows 上不许漏出 `\`。
assert!(!relative_path.contains('\\'));
}
/// flat 是刻意的决定:注册表与产物共用一个**项目根锚定**的目录,多目标也只是同目录不同文件名。
/// 谁加了一层 per-target 子目录,这条就红。
#[test]
fn every_file_of_this_target_shares_one_flat_directory() {
let root = Path::new("/tmp/project");
assert_eq!(registry_path(root).parent(), artifact_path(root).parent());
assert_eq!(export_dir(root).parent(), Some(root));
assert_eq!(
export_dir(root).file_name().and_then(|name| name.to_str()),
Some(EXPORT_WORK_RELATIVE_DIR)
);
}
/// 脚本名不是自由文本:`command.exec` 的白名单只放行 `build` 与 `build:<安全后缀>`。
/// 尤其不能退化成裸 `build`——那会跑掉项目自己的构建,产物永远不会出现。
#[test]
fn the_script_name_keeps_the_form_the_command_allowlist_requires() {
assert_ne!(SCRIPT_NAME, "build");
assert!(SCRIPT_NAME.starts_with("build:"));
}
}

Some files were not shown because too many files have changed in this diff Show More