新增 AGC 随包资源准备步骤(Codex 与插件两条纯复制路径)
- 新增 scripts/prepare-bundled-resources.mjs:按声明解析目标与替换单位,用上游 package-lock 的 resolved/integrity 组成缓存 key,写临时目录后原子替换,命中缓存不重写任何文件,只替换本工具产物,失败即退出并给出可执行提示 - Codex 路径按声明复制组件、保留受版本控制的第三方声明、按现有字节格式生成 manifest.json;macOS 双架构整体 staging - 插件路径按声明的子目录白名单与跳过规则复制,顶层出现非本工具条目即失败 - 新增 scripts/prepare-bundled-resources.test.mjs:9 条用例覆盖 staging 结果、幂等(内容与时间戳)、上游缺失、目标不支持、目录被非本工具占用、dry-run 不落盘与白名单过滤 - .gitignore 忽略准备步骤产生的 staging 临时目录
This commit is contained in:
@@ -57,6 +57,8 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
DECLARATION_PATH,
|
||||
findCodexSource,
|
||||
pluginDirectories,
|
||||
prepareBundledResources,
|
||||
readDeclaration,
|
||||
resolveHostTarget,
|
||||
stagingUnit,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
|
||||
const WINDOWS_TARGET = 'x86_64-pc-windows-msvc';
|
||||
const MAC_TARGET = 'aarch64-apple-darwin';
|
||||
|
||||
function sha256File(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
|
||||
function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
|
||||
const appRoot = path.join(root, 'app');
|
||||
const repoRoot = path.join(root, 'repo');
|
||||
const destinationRoot = path.join(appRoot, 'src-tauri');
|
||||
const declaration = readDeclaration(DECLARATION_PATH);
|
||||
const lockfile = { packages: {} };
|
||||
|
||||
for (const target of targets) {
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === target,
|
||||
);
|
||||
assert.ok(layout, `声明缺少目标 ${target}`);
|
||||
const vendor = path.join(
|
||||
appRoot,
|
||||
`node_modules/@openai/codex-${layout.platform}/vendor/${target}`,
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
const file = path.join(vendor, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `component ${target} ${relative}\n`);
|
||||
if (relative === layout.executable) {
|
||||
fs.chmodSync(file, 0o755);
|
||||
}
|
||||
}
|
||||
lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = {
|
||||
resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`,
|
||||
integrity: `sha512-${target}`,
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
for (const entry of declaration.codex.noticeSources) {
|
||||
if (entry.preserve) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(destinationRoot, entry.source);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'mac codex notice\n');
|
||||
}
|
||||
if (targets.includes(WINDOWS_TARGET)) {
|
||||
const tracked = path.join(
|
||||
destinationRoot,
|
||||
'resources/codex/win-x64/NOTICE.md',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(tracked), { recursive: true });
|
||||
fs.writeFileSync(tracked, 'windows codex notice\n');
|
||||
}
|
||||
|
||||
if (plugins) {
|
||||
const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor');
|
||||
fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'plugin.json'),
|
||||
'{"name":"agc-demo-editor"}\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'src/entry.mjs'),
|
||||
'export const entry = 1;\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'panels/panel.html'),
|
||||
'<html></html>\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
|
||||
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
|
||||
}
|
||||
|
||||
const lockfilePath = path.join(root, 'package-lock.json');
|
||||
fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
|
||||
return {
|
||||
root,
|
||||
appRoot,
|
||||
repoRoot,
|
||||
destinationRoot,
|
||||
lockfilePath,
|
||||
recordPath: path.join(root, 'record.json'),
|
||||
declaration,
|
||||
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(directory) {
|
||||
const entries = [];
|
||||
const stack = [['', directory]];
|
||||
while (stack.length > 0) {
|
||||
const [prefix, current] = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push([relative, full]);
|
||||
} else {
|
||||
const info = fs.statSync(full);
|
||||
entries.push({
|
||||
relative,
|
||||
size: info.size,
|
||||
mtimeMs: info.mtimeMs,
|
||||
mode: info.mode & 0o777,
|
||||
sha256: sha256File(full),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
left.relative.localeCompare(right.relative),
|
||||
);
|
||||
}
|
||||
|
||||
function prepare(fixture, overrides = {}) {
|
||||
return prepareBundledResources({
|
||||
target: WINDOWS_TARGET,
|
||||
destinationRoot: fixture.destinationRoot,
|
||||
declarationPath: DECLARATION_PATH,
|
||||
recordPath: fixture.recordPath,
|
||||
lockfilePath: fixture.lockfilePath,
|
||||
repoRoot: fixture.repoRoot,
|
||||
appRoot: fixture.appRoot,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('stages declared codex components with manifest and preserved notice', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const summaries = await prepare(fixture);
|
||||
assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/);
|
||||
|
||||
const declaration = fixture.declaration;
|
||||
const layout = declaration.codex.targets.find(
|
||||
(entry) => entry.target === WINDOWS_TARGET,
|
||||
);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex',
|
||||
layout.directory,
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(unit, relative)),
|
||||
`缺少组件 ${relative}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'),
|
||||
'windows codex notice\n',
|
||||
'受版本控制的第三方声明必须原地保留',
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(Object.keys(manifest), [
|
||||
'files',
|
||||
'platform',
|
||||
'schemaVersion',
|
||||
'version',
|
||||
]);
|
||||
assert.equal(manifest.platform, layout.platform);
|
||||
assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema);
|
||||
assert.equal(
|
||||
manifest.version,
|
||||
`${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(manifest.files).sort(),
|
||||
[...layout.files].sort(),
|
||||
'清单文件集合必须等于组件白名单',
|
||||
);
|
||||
for (const relative of layout.files) {
|
||||
assert.equal(
|
||||
manifest.files[relative],
|
||||
sha256File(path.join(unit, relative)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('second run is a no-op: identical content and timestamps', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
await prepare(fixture);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex',
|
||||
'win-x64',
|
||||
);
|
||||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||||
const before = { codex: snapshot(unit), plugins: snapshot(plugins) };
|
||||
const summaries = await prepare(fixture);
|
||||
assert.match(summaries[0], /命中缓存/);
|
||||
assert.match(summaries[1], /命中缓存/);
|
||||
assert.deepEqual(
|
||||
snapshot(unit),
|
||||
before.codex,
|
||||
'codex 产物内容与时间戳必须不变',
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshot(plugins),
|
||||
before.plugins,
|
||||
'插件产物内容与时间戳必须不变',
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('stages the macOS universal group with both architectures', async () => {
|
||||
const fixture = buildFixture({
|
||||
targets: [MAC_TARGET, 'x86_64-apple-darwin'],
|
||||
});
|
||||
try {
|
||||
const summaries = await prepare(fixture, { target: MAC_TARGET });
|
||||
assert.match(summaries[0], /mac-native/);
|
||||
const unit = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/mac-native',
|
||||
);
|
||||
for (const directory of ['darwin-arm64', 'darwin-x64']) {
|
||||
for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(unit, directory, file)),
|
||||
`缺少 ${directory}/${file}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'),
|
||||
'mac codex notice\n',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(unit).sort(), [
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
]);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('copies only whitelisted plugin subdirectories', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
await prepare(fixture);
|
||||
const staged = path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/plugins/agc-demo-editor',
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(staged, 'plugin.json')));
|
||||
assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs')));
|
||||
assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html')));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(staged, 'panels/panel.test.mjs')),
|
||||
'测试文件不随包',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(staged, 'target')),
|
||||
'构建产物目录不随包',
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包');
|
||||
assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包');
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the upstream package is missing', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
fs.rmSync(path.join(fixture.appRoot, 'node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await assert.rejects(prepare(fixture), /npm ci/);
|
||||
assert.ok(
|
||||
!fs.existsSync(
|
||||
path.join(
|
||||
fixture.destinationRoot,
|
||||
'resources/codex/win-x64/manifest.json',
|
||||
),
|
||||
),
|
||||
'失败时不得留下半成品清单',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for unsupported targets', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
await assert.rejects(
|
||||
prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }),
|
||||
/声明不含目标/,
|
||||
);
|
||||
assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the destination is owned by something else', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||||
fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
|
||||
await assert.rejects(prepare(fixture), /被非本工具内容占用/);
|
||||
|
||||
const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
|
||||
fs.rmSync(path.join(unit, 'foreign.bin'), { force: true });
|
||||
fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), {
|
||||
recursive: true,
|
||||
});
|
||||
await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dry run writes nothing', async () => {
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const summaries = await prepare(fixture, { dryRun: true });
|
||||
assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
|
||||
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(unit),
|
||||
['NOTICE.md'],
|
||||
'dry-run 不得写入任何组件或清单',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
|
||||
);
|
||||
assert.ok(!fs.existsSync(fixture.recordPath));
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('declaration drives source lookup and staging units', () => {
|
||||
const declaration = readDeclaration(DECLARATION_PATH);
|
||||
const windows = stagingUnit(declaration, WINDOWS_TARGET);
|
||||
assert.equal(windows.directory, 'win-x64');
|
||||
assert.deepEqual(
|
||||
windows.targets.map((member) => member.target),
|
||||
[WINDOWS_TARGET],
|
||||
);
|
||||
const mac = stagingUnit(declaration, MAC_TARGET);
|
||||
assert.equal(mac.directory, 'mac-native');
|
||||
assert.deepEqual(
|
||||
mac.targets.map((member) => member.target),
|
||||
['aarch64-apple-darwin', 'x86_64-apple-darwin'],
|
||||
);
|
||||
|
||||
const fixture = buildFixture();
|
||||
try {
|
||||
const source = findCodexSource(declaration, WINDOWS_TARGET, {
|
||||
app: fixture.appRoot,
|
||||
repo: fixture.repoRoot,
|
||||
});
|
||||
assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/);
|
||||
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user