Files
Genarrative/apps/ai-game-creator-shell/scripts/check-config.mjs
T
suzmii 3f5d3b43af
Project CI / Repository checks (pull_request) Failing after 7m37s
Project CI / Frontend tests (pull_request) Failing after 8m52s
Project CI / Backend tests (pull_request) Failing after 10m24s
Project CI / Native shell tests (pull_request) Failing after 10m36s
合并最新 master 到 AGC V3 资源画布分支
- 合入 origin/master 6 个提交(#313 项目写锁残留回收与启动诊断、#315 AGC Ctrl+C 残留后端修复)
- 冲突解决:decision-log.md 保留 V3 两条 2026-09-09 决策与 master 的写锁回收条目
- 冲突解决:pitfalls.md 保留 V3 转场经验条目与 master 的 Ctrl+C 后端残留条目
2026-09-09 21:46:09 +08:00

1875 lines
57 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import {
appIdentifier,
defaultRealSwarmTestTask,
} from './agent-swarm-test-chat.mjs';
import {
askHidden,
assertSafeGameCreatorConfigDestination,
buildGameCreatorWizardConfig,
readGameCreatorWizardConfigState,
writeGameCreatorConfigAtomically,
writeGameCreatorWizardConfig,
} from './game-creator-config-wizard.mjs';
const packageConfig = JSON.parse(
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
);
const tauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.conf.json', import.meta.url),
'utf8',
),
);
const windowsTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
'utf8',
),
);
const cargoManifestSource = fs.readFileSync(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
);
const cargoPackageVersion = cargoManifestSource
.split(/\r?\n(?=\[)/u)
.find((section) => section.startsWith('[package]'))
?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1];
const eventCapabilityPath = new URL(
'../src-tauri/capabilities/events.json',
import.meta.url,
);
const defaultAppConfig = JSON.parse(
fs.readFileSync(
new URL('../game-creator.config.json', import.meta.url),
'utf8',
),
);
const rootPackageConfig = JSON.parse(
fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
);
const swarmTestChatSource = fs.readFileSync(
new URL('../scripts/agent-swarm-test-chat.mjs', import.meta.url),
'utf8',
);
const viteConfigSource = fs.readFileSync(
new URL('../vite.config.ts', import.meta.url),
'utf8',
);
const devPortSource = fs.readFileSync(
new URL('../scripts/dev-port.mjs', import.meta.url),
'utf8',
);
const startTauriDevSource = fs.readFileSync(
new URL('../scripts/start-tauri-dev.mjs', import.meta.url),
'utf8',
);
const appSource = [
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
].join('\n');
const appInvokeSources = readSourceFiles(
new URL('../src/', import.meta.url),
new Set(['.ts', '.tsx']),
);
const appEntrypointSource = fs.readFileSync(
new URL('../src/main.tsx', import.meta.url),
'utf8',
);
const tauriHandlerSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
);
const tauriRustSource = readSourceTree(
new URL('../src-tauri/src/', import.meta.url),
'.rs',
);
const sharedContractSource = fs.readFileSync(
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
import.meta.url,
),
'utf8',
);
const rustSharedContractSource = fs.readFileSync(
new URL(
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
import.meta.url,
),
'utf8',
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
'normalize_local_project_raster_resource',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
];
const sourceExtensions = new Set([
'.json',
'.md',
'.mjs',
'.rs',
'.toml',
'.ts',
'.tsx',
]);
function collectFiles(path) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs.readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
if (entry.name === 'node_modules' || entry.name === 'target') {
return [];
}
return collectFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
);
});
}
if (sourceExtensions.has(pathnameExtension(path.pathname))) {
return [path];
}
return [];
}
function readSourceTree(path, extension) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) =>
readSourceTree(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extension,
),
)
.join('\n');
}
if (pathnameExtension(path.pathname) !== extension) {
return '';
}
return fs.readFileSync(path, 'utf8');
}
function readSourceFiles(path, extensions) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.flatMap((entry) =>
readSourceFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extensions,
),
);
}
if (!extensions.has(pathnameExtension(path.pathname))) return [];
return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }];
}
function pathnameExtension(pathname) {
const index = pathname.lastIndexOf('.');
return index === -1 ? '' : pathname.slice(index);
}
function assertNoOpenAiApiKeys(paths) {
const secretPattern = /sk-[A-Za-z0-9_-]{20,}/;
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs.readFileSync(path, 'utf8');
if (secretPattern.test(source)) {
throw new Error(
`AI game creator shell source must not contain API keys: ${path.pathname}`,
);
}
}
}
function assertNoEnvironmentConfigFallbacks(paths) {
const allowedDevCheck = 'import.meta.env.DEV';
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs
.readFileSync(path, 'utf8')
.replaceAll(allowedDevCheck, '')
.replaceAll('game-creator.config.local.json', '');
if (/\bprocess\.env\b|\bdotenv\b/.test(source)) {
throw new Error(
`AI game creator shell must use runtime config, not environment config: ${path.pathname}`,
);
}
}
}
function assertNoNativeBrowserConfirm(paths) {
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs.readFileSync(path, 'utf8');
if (/\bwindow\.confirm\b/.test(source)) {
throw new Error(
`AI game creator shell confirmations must use in-app UI: ${path.pathname}`,
);
}
}
}
function assertNoBlockingNativeFilePicker(source) {
if (/\.blocking_pick_(?:file|files|folder|folders)\s*\(/.test(source)) {
throw new Error(
'AI game creator shell native file pickers must not block the Tauri event loop',
);
}
}
function extractConstArrayBlock(source, name) {
const start = source.indexOf(`const ${name}`);
if (start === -1) {
throw new Error(`Missing contract array: ${name}`);
}
const end = source.indexOf('];', start);
if (end === -1) {
throw new Error(`Missing contract array end: ${name}`);
}
return source.slice(start, end + 2);
}
function parseTsCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
return Array.from(
block.matchAll(/\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g),
([, id, permission]) => ({ id, permission }),
);
}
function parseRustCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
const permissionNames = {
Auto: 'auto',
Confirm: 'confirm',
Deny: 'deny',
};
return Array.from(
block.matchAll(
/command\(\s*"([^"]+)",\s*GameCreationAppPermission::(Auto|Confirm|Deny)\s*\)/g,
),
([, id, permission]) => ({ id, permission: permissionNames[permission] }),
);
}
function parseTsCapabilities(source) {
const block = extractConstArrayBlock(
source,
'GAME_CREATION_AGENT_CAPABILITIES',
);
return Array.from(
block.matchAll(
/\{\s*id:\s*'([^']+)',\s*area:\s*'([^']+)',\s*title:\s*'([^']+)',?\s*\}/g,
),
([, id, area, title]) => ({ id, area, title }),
);
}
function parseRustCapabilities(source) {
const block = extractConstArrayBlock(
source,
'GAME_CREATION_AGENT_CAPABILITIES',
);
return Array.from(
block.matchAll(
/capability\(\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",?\s*\)/g,
),
([, id, area, title]) => ({ id, area, title }),
);
}
function assertContractRecordsMatch(label, leftRecords, rightRecords) {
const normalize = (records) =>
records
.map((record) => JSON.stringify(record))
.sort((left, right) => left.localeCompare(right));
const left = normalize(leftRecords);
const right = normalize(rightRecords);
if (left.length === 0 || right.length === 0) {
throw new Error(`${label} parser returned no records`);
}
if (JSON.stringify(left) !== JSON.stringify(right)) {
throw new Error(
`${label} drifted between TypeScript and Rust contracts\nTS=${left.join(
'\n',
)}\nRust=${right.join('\n')}`,
);
}
}
const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024;
const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024;
const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024;
const APP_INVOKE_COMMAND_MAX_LENGTH = 128;
const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024;
const APP_INVOKE_BARE_CALL_NAMES = new Set([
'invoke',
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
'invokeDiagnostic',
]);
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
const sourceByteLength = Buffer.byteLength(source, 'utf8');
if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`,
);
}
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const parseDiagnostic = sourceFile.parseDiagnostics[0];
if (parseDiagnostic !== undefined) {
throw new Error(
`AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`,
);
}
const commands = [];
const visit = (node) => {
if (ts.isCallExpression(node)) {
const expression = node.expression;
const isBareCall =
ts.isIdentifier(expression) &&
APP_INVOKE_BARE_CALL_NAMES.has(expression.text);
const isObjectInvoke =
ts.isPropertyAccessExpression(expression) &&
expression.name.text === 'invoke';
const commandArgument =
ts.isIdentifier(expression) && expression.text === 'invokeDiagnostic'
? node.arguments[1]
: node.arguments[0];
if (
(isBareCall || isObjectInvoke) &&
commandArgument !== undefined &&
ts.isStringLiteral(commandArgument) &&
commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH &&
/^[a-z0-9_]+$/u.test(commandArgument.text)
) {
commands.push(commandArgument.text);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`,
);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return commands;
}
function parseAppInvokeSourceFiles(files) {
if (files.length > APP_INVOKE_FILE_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`,
);
}
const totalLength = files.reduce(
(length, file) => length + Buffer.byteLength(file.source, 'utf8'),
0,
);
if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`,
);
}
const commands = files.flatMap(({ fileName, source }) =>
parseAppInvokeCommandNames(source, fileName),
);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`,
);
}
return commands;
}
function parseTauriHandlerCommandNames(source) {
const match = source.match(/tauri::generate_handler!\[([\s\S]*?)\]/);
if (!match) {
throw new Error('AI game creator shell Tauri handler list is missing');
}
return Array.from(
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
([, command]) => command,
);
}
function parseRustFunctionNames(source) {
return Array.from(
source.matchAll(/\b(?:async\s+)?fn\s+([a-z][a-z0-9_]*)\s*\(/g),
([, name]) => name,
);
}
function assertCommandNamesSubset(label, leftNames, rightNames) {
const right = new Set(rightNames);
const missing = Array.from(new Set(leftNames))
.filter((name) => !right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (missing.length > 0) {
throw new Error(`${label} missing commands: ${missing.join(', ')}`);
}
}
function assertCommandNamesDisjoint(label, leftNames, rightNames) {
const right = new Set(rightNames);
const overlapping = Array.from(new Set(leftNames))
.filter((name) => right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (overlapping.length > 0) {
throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`);
}
}
function runAppInvokeParserRegressionChecks() {
assert.deepEqual(
parseAppInvokeCommandNames(`
invoke('direct_command', {});
directInvoke<Result>('generic_direct_command', {});
invokeInput < Result > ('input_wrapper_command', {});
invokeAuthenticatedInput<Result>(
'authenticated_input_wrapper_command',
{},
);
input.invoke<Result>('object_field_command', {});
`),
[
'direct_command',
'generic_direct_command',
'input_wrapper_command',
'authenticated_input_wrapper_command',
'object_field_command',
],
);
assert.deepEqual(
parseAppInvokeCommandNames(`
// invoke('line_comment_decoy')
/* invokeInput('block_comment_decoy') */
const quoted = "directInvoke('string_decoy')";
const template = \`input.invoke('template_decoy')\`;
const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u;
invokeCommand('unrelated_name');
myinvoke('unrelated_suffix');
invoke(dynamicCommand, {});
invoke('UPPERCASE_COMMAND', {});
`),
[],
);
assert.throws(
() => parseAppInvokeCommandNames("invoke<Result>('malformed_generic', {"),
/source cannot be parsed/u,
);
assert.deepEqual(
parseAppInvokeCommandNames(
`invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`,
),
[],
);
assert.throws(
() =>
parseAppInvokeCommandNames(' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1)),
/source exceeds/u,
);
const wrapperInvocations = parseAppInvokeCommandNames(
"invokeInput<Result>('wrapper_reachability_command', {})",
);
assert.doesNotThrow(() =>
assertCommandNamesSubset(
'App invoke parser reachability fixture',
['wrapper_reachability_command'],
wrapperInvocations,
),
);
assert.throws(
() =>
assertCommandNamesDisjoint(
'App invoke parser false allowlist fixture',
wrapperInvocations,
['wrapper_reachability_command'],
),
/overlapping commands: wrapper_reachability_command/u,
);
assert.throws(
() =>
assertCommandNamesSubset(
'App invoke parser removed wrapper fixture',
['wrapper_reachability_command'],
parseAppInvokeCommandNames('const wrapperWasRemoved = true;'),
),
/missing commands: wrapper_reachability_command/u,
);
}
function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) {
return { code, signal, stdout, stderr };
}
class HiddenInputFixture extends EventEmitter {
constructor({ failRawRestore = false } = {}) {
super();
this.isTTY = true;
this.isRaw = false;
this.paused = true;
this.failRawRestore = failRawRestore;
this.rawModeChanges = [];
}
isPaused() {
return this.paused;
}
setRawMode(enabled) {
this.rawModeChanges.push(enabled);
this.isRaw = enabled;
if (!enabled && this.failRawRestore) {
throw new Error('fixture raw restore failure');
}
return this;
}
resume() {
this.paused = false;
return this;
}
pause() {
this.paused = true;
return this;
}
}
function hiddenOutputFixture() {
const writes = [];
return {
writes,
write(value) {
writes.push(value);
return true;
},
};
}
function assertHiddenInputRestored(input) {
assert.equal(input.listenerCount('data'), 0);
assert.equal(input.listenerCount('end'), 0);
assert.equal(input.listenerCount('error'), 0);
assert.equal(input.paused, true);
}
async function runHiddenInputRegressionChecks() {
const endedInput = new HiddenInputFixture();
const endedPromise = askHidden('fixture', {
input: endedInput,
output: hiddenOutputFixture(),
});
endedInput.emit('end');
await assert.rejects(endedPromise, /隐藏输入在完成前已结束/u);
assert.deepEqual(endedInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(endedInput);
const erroredInput = new HiddenInputFixture();
const erroredPromise = askHidden('fixture', {
input: erroredInput,
output: hiddenOutputFixture(),
});
erroredInput.emit('error', new Error('fixture stdin error'));
await assert.rejects(erroredPromise, /fixture stdin error/u);
assert.deepEqual(erroredInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(erroredInput);
const restoreFailedInput = new HiddenInputFixture({ failRawRestore: true });
const restoreFailedPromise = askHidden('fixture', {
input: restoreFailedInput,
output: hiddenOutputFixture(),
});
restoreFailedInput.emit('data', Buffer.from('secret\n'));
await assert.rejects(restoreFailedPromise, /恢复终端 raw mode 失败/u);
assert.deepEqual(restoreFailedInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(restoreFailedInput);
const signaledInput = new HiddenInputFixture();
const signalSource = new EventEmitter();
const forwardedSignals = [];
const signaledPromise = askHidden('fixture', {
input: signaledInput,
output: hiddenOutputFixture(),
signalSource,
terminateForSignal: (signal) => forwardedSignals.push(signal),
platform: 'win32',
});
signalSource.emit('SIGBREAK');
await assert.rejects(signaledPromise, /SIGBREAK/u);
assert.deepEqual(signaledInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(signaledInput);
assert.deepEqual(forwardedSignals, ['SIGBREAK']);
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
assert.equal(signalSource.listenerCount(signal), 0);
}
}
async function runConfigWizardRegressionChecks() {
const testRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
);
try {
const canonicalTestRoot = fs.realpathSync.native(testRoot);
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
fs.mkdirSync(realConfigAncestor);
fs.symlinkSync(
realConfigAncestor,
linkedConfigAncestor,
process.platform === 'win32' ? 'junction' : 'dir',
);
const missingLinkedConfigDir = path.join(
linkedConfigAncestor,
'missing-appdata',
);
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
assert.equal(
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'),
);
const gitRoot = path.join(testRoot, 'tracked-repository');
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
fs.mkdirSync(trackedConfigDir, { recursive: true });
fs.writeFileSync(
path.join(trackedConfigDir, 'game-creator.config.json'),
'{}\n',
{ mode: 0o600 },
);
execFileSync('git', ['init', '--quiet', gitRoot], { stdio: 'ignore' });
execFileSync(
'git',
['-C', gitRoot, 'add', 'runtime-config/game-creator.config.json'],
{ stdio: 'ignore' },
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(trackedConfigDir),
/Git 已跟踪/u,
);
const untrackedConfigDir = path.join(gitRoot, 'untracked-runtime-config');
await assert.rejects(
assertSafeGameCreatorConfigDestination(untrackedConfigDir),
/Git 仓库之外/u,
);
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
assert.equal(
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
path.join(canonicalTestRoot, 'outside-appdata'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
requireDedicatedLeaf: true,
}),
new RegExp(appIdentifier.replaceAll('.', '\\.')),
);
const dedicatedConfigDir = path.join(testRoot, appIdentifier);
assert.equal(
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
requireDedicatedLeaf: true,
}),
path.join(canonicalTestRoot, appIdentifier),
);
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
assert.equal(
await assertSafeGameCreatorConfigDestination(injectedNonGitConfigDir, {
runGit: async () =>
gitCheckResult({
code: 128,
stderr:
'fatal: not a git repository (or any of the parent directories): .git\n',
}),
}),
path.join(canonicalTestRoot, 'injected-non-git'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
path.join(testRoot, 'missing-git-command'),
{
runGit: async () => {
const error = new Error('spawn git ENOENT');
error.code = 'ENOENT';
throw error;
},
},
),
/Git 命令不可用或启动失败/u,
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
path.join(testRoot, 'abnormal-rev-parse'),
{
runGit: async () =>
gitCheckResult({
code: 129,
stderr: 'fixture rev-parse failure',
}),
},
),
/Git 检查异常.*fixture rev-parse failure/u,
);
const abnormalTrackedGitRoot = path.join(
testRoot,
'abnormal-tracked-repository',
);
const abnormalTrackedConfigDir = path.join(
abnormalTrackedGitRoot,
'runtime-config',
);
fs.mkdirSync(abnormalTrackedConfigDir, { recursive: true });
let gitCheckCount = 0;
await assert.rejects(
assertSafeGameCreatorConfigDestination(abnormalTrackedConfigDir, {
runGit: async () => {
gitCheckCount += 1;
if (gitCheckCount === 1) {
return gitCheckResult({ stdout: `${abnormalTrackedGitRoot}\n` });
}
return gitCheckResult({
code: 2,
stderr: 'fixture ls-files failure',
});
},
}),
/配置文件跟踪状态.*fixture ls-files failure/u,
);
assert.equal(gitCheckCount, 2);
const overlayConfigDir = path.join(testRoot, 'overlay-appdata');
fs.mkdirSync(overlayConfigDir, { recursive: true, mode: 0o700 });
const primaryConfigPath = path.join(
overlayConfigDir,
'game-creator.config.json',
);
const localConfigPath = path.join(
overlayConfigDir,
'game-creator.config.local.json',
);
const primaryConfig = {
llm: {
apiKey: 'fixture-primary-key',
baseUrl: 'https://primary.example.test/v1',
model: 'primary-model',
apiKind: 'openai_chat',
stream: false,
requestTimeoutMs: 12345,
},
editorApi: {
apiKey: 'fixture-editor-key',
baseUrl: 'http://127.0.0.1:8082',
},
};
const localConfig = {
llm: {
apiKey: 'fixture-old-overlay-key',
model: 'old-overlay-model',
stream: true,
},
agentLlm: {
planner: { model: 'planner-overlay-model' },
},
};
fs.writeFileSync(
primaryConfigPath,
`${JSON.stringify(primaryConfig, null, 2)}\n`,
{ mode: 0o644 },
);
fs.writeFileSync(
localConfigPath,
`${JSON.stringify(localConfig, null, 2)}\n`,
{ mode: 0o644 },
);
const overlayState =
await readGameCreatorWizardConfigState(overlayConfigDir);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(primaryConfigPath).mode & 0o777, 0o600);
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
}
assert.equal(overlayState.configPath, primaryConfigPath);
assert.equal(overlayState.localConfigPath, localConfigPath);
assert.equal(overlayState.effectiveConfig.llm.model, 'old-overlay-model');
assert.equal(overlayState.effectiveConfig.llm.stream, true);
assert.equal(overlayState.effectiveConfig.llm.requestTimeoutMs, 12345);
assert.deepEqual(
overlayState.writeConfig.editorApi,
primaryConfig.editorApi,
);
const updatedPrimary = buildGameCreatorWizardConfig(
overlayState.writeConfig,
{
apiKey: 'fixture-new-key',
baseUrl: 'https://new.example.test/v1/',
model: 'new-model',
apiKind: 'openai_responses',
},
);
assert.equal(updatedPrimary.agentMode, 'provider');
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
const reloadedState =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
assert.equal(
reloadedState.effectiveConfig.llm.baseUrl,
'https://new.example.test/v1',
);
assert.equal(reloadedState.effectiveConfig.llm.requestTimeoutMs, 12345);
assert.equal(reloadedState.effectiveConfig.llm.stream, true);
assert.equal(
JSON.parse(fs.readFileSync(primaryConfigPath, 'utf8')).llm.apiKey,
'fixture-new-key',
);
const sanitizedLocalConfig = JSON.parse(
fs.readFileSync(localConfigPath, 'utf8'),
);
assert.equal(sanitizedLocalConfig.llm, undefined);
assert.deepEqual(sanitizedLocalConfig.agentLlm, localConfig.agentLlm);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
}
const unchangedLocalConfigDir = path.join(
testRoot,
'unchanged-local-appdata',
);
fs.mkdirSync(unchangedLocalConfigDir, { recursive: true, mode: 0o700 });
const unchangedPrimaryConfigPath = path.join(
unchangedLocalConfigDir,
'game-creator.config.json',
);
const unchangedLocalConfigPath = path.join(
unchangedLocalConfigDir,
'game-creator.config.local.json',
);
const unchangedLocalSource =
'{\n "editorApi": { "baseUrl": "http://127.0.0.1:8082" }\n}\n';
fs.writeFileSync(unchangedPrimaryConfigPath, '{}\n', { mode: 0o600 });
fs.writeFileSync(unchangedLocalConfigPath, unchangedLocalSource, {
mode: 0o644,
});
const unchangedLocalState = await readGameCreatorWizardConfigState(
unchangedLocalConfigDir,
);
await writeGameCreatorWizardConfig(
unchangedLocalState,
buildGameCreatorWizardConfig(unchangedLocalState.writeConfig, {
apiKey: 'fixture-private-local-key',
baseUrl: 'https://private.example.test/v1',
model: 'private-model',
apiKind: 'openai_responses',
}),
);
assert.equal(
fs.readFileSync(unchangedLocalConfigPath, 'utf8'),
unchangedLocalSource,
);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(unchangedLocalConfigPath).mode & 0o777, 0o600);
}
const invalidLocalConfigDir = path.join(testRoot, 'invalid-local-appdata');
fs.mkdirSync(invalidLocalConfigDir, { recursive: true, mode: 0o700 });
const invalidPrimaryConfigPath = path.join(
invalidLocalConfigDir,
'game-creator.config.json',
);
const invalidLocalConfigPath = path.join(
invalidLocalConfigDir,
'game-creator.config.local.json',
);
fs.writeFileSync(invalidPrimaryConfigPath, '{}\n', { mode: 0o600 });
fs.writeFileSync(invalidLocalConfigPath, '{ invalid json\n', {
mode: 0o644,
});
await assert.rejects(
writeGameCreatorWizardConfig(
{
configPath: invalidPrimaryConfigPath,
localConfigPath: invalidLocalConfigPath,
},
{ llm: { apiKey: 'fixture-invalid-local-key' } },
),
/读取客户端配置失败/u,
);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(invalidLocalConfigPath).mode & 0o777, 0o600);
}
const windowsLocalConfigDir = path.join(testRoot, 'windows-local-appdata');
fs.mkdirSync(windowsLocalConfigDir, { recursive: true });
const windowsLocalPrimaryPath = path.join(
windowsLocalConfigDir,
'game-creator.config.json',
);
const windowsLocalConfigPath = path.join(
windowsLocalConfigDir,
'game-creator.config.local.json',
);
fs.writeFileSync(windowsLocalPrimaryPath, '{}\n');
fs.writeFileSync(windowsLocalConfigPath, unchangedLocalSource);
const windowsReadAclPaths = [];
const windowsLocalState = await readGameCreatorWizardConfigState(
windowsLocalConfigDir,
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
windowsReadAclPaths.push({ targetPath, isDirectory });
},
},
);
assert.deepEqual(windowsReadAclPaths, [
{ targetPath: windowsLocalConfigDir, isDirectory: true },
{ targetPath: windowsLocalPrimaryPath, isDirectory: false },
{ targetPath: windowsLocalConfigPath, isDirectory: false },
]);
const windowsLocalAclPaths = [];
await writeGameCreatorWizardConfig(
windowsLocalState,
buildGameCreatorWizardConfig(windowsLocalState.writeConfig, {
apiKey: 'fixture-windows-local-key',
baseUrl: 'https://windows.example.test/v1',
model: 'windows-model',
apiKind: 'openai_responses',
}),
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
if (!isDirectory) windowsLocalAclPaths.push(targetPath);
},
},
);
assert.equal(
fs.readFileSync(windowsLocalConfigPath, 'utf8'),
unchangedLocalSource,
);
assert.equal(windowsLocalAclPaths.includes(windowsLocalConfigPath), true);
const guiSavedConfig = JSON.parse(
fs.readFileSync(primaryConfigPath, 'utf8'),
);
guiSavedConfig.llm = {
...guiSavedConfig.llm,
apiKey: 'fixture-gui-key',
model: 'gui-model',
};
await writeGameCreatorConfigAtomically(primaryConfigPath, guiSavedConfig);
const afterGuiSave =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(afterGuiSave.effectiveConfig.llm.apiKey, 'fixture-gui-key');
assert.equal(afterGuiSave.effectiveConfig.llm.model, 'gui-model');
const windowsConfigDir = path.join(testRoot, 'windows-appdata');
const windowsConfigPath = path.join(
windowsConfigDir,
'game-creator.config.json',
);
const aclEvents = [];
await writeGameCreatorConfigAtomically(
windowsConfigPath,
{ llm: { apiKey: 'fixture-windows-key' } },
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
const metadata = fs.lstatSync(targetPath);
const temporaryFile =
!isDirectory && path.basename(targetPath).startsWith('.');
if (temporaryFile) {
assert.equal(metadata.size, 0);
assert.equal(fs.readFileSync(targetPath, 'utf8'), '');
}
aclEvents.push({ isDirectory, temporaryFile });
},
},
);
assert.deepEqual(aclEvents, [
{ isDirectory: true, temporaryFile: false },
{ isDirectory: false, temporaryFile: true },
{ isDirectory: false, temporaryFile: false },
]);
assert.equal(
JSON.parse(fs.readFileSync(windowsConfigPath, 'utf8')).llm.apiKey,
'fixture-windows-key',
);
const failedWindowsConfigDir = path.join(
testRoot,
'failed-windows-appdata',
);
const failedWindowsConfigPath = path.join(
failedWindowsConfigDir,
'game-creator.config.json',
);
await assert.rejects(
writeGameCreatorConfigAtomically(
failedWindowsConfigPath,
{ llm: { apiKey: 'fixture-must-not-reach-disk' } },
{
platform: 'win32',
secureWindowsPath: async (_targetPath, { isDirectory }) => {
if (!isDirectory) throw new Error('fixture DACL failure');
},
},
),
/fixture DACL failure/u,
);
assert.equal(fs.existsSync(failedWindowsConfigPath), false);
assert.deepEqual(fs.readdirSync(failedWindowsConfigDir), []);
} finally {
fs.rmSync(testRoot, { recursive: true, force: true });
}
}
assertNoOpenAiApiKeys([
new URL('../src/', import.meta.url),
new URL('../scripts/', import.meta.url),
new URL('../game-creator.config.json', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
import.meta.url,
),
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.test.ts',
import.meta.url,
),
new URL(
'../../../server-rs/crates/platform-agent/src/game_creation.rs',
import.meta.url,
),
new URL(
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
import.meta.url,
),
new URL(
'../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md',
import.meta.url,
),
]);
assertNoEnvironmentConfigFallbacks([
new URL('../src/', import.meta.url),
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
new URL('../scripts/start-dev-server.mjs', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
]);
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
assertNoBlockingNativeFilePicker(tauriRustSource);
runAppInvokeParserRegressionChecks();
const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources);
assertContractRecordsMatch(
'AI game creator shell command contract',
parseTsCommands(sharedContractSource),
parseRustCommands(rustSharedContractSource),
);
assertContractRecordsMatch(
'AI game creator shell capability contract',
parseTsCapabilities(sharedContractSource),
parseRustCapabilities(rustSharedContractSource),
);
assertCommandNamesSubset(
'AI game creator shell Tauri handler',
appInvokeCommandNames,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesSubset(
'AI game creator shell Tauri command implementation',
parseTauriHandlerCommandNames(tauriHandlerSource),
parseRustFunctionNames(tauriRustSource),
);
assertCommandNamesSubset(
'AI game creator shell App invoke or explicit native-only allowlist',
parseTauriHandlerCommandNames(tauriHandlerSource),
[...appInvokeCommandNames, ...allowedUncalledTauriCommands],
);
assertCommandNamesSubset(
'AI game creator shell explicit native-only allowlist',
allowedUncalledTauriCommands,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesDisjoint(
'AI game creator shell App invoke and explicit native-only allowlist',
appInvokeCommandNames,
allowedUncalledTauriCommands,
);
const tauriHandlerCommandNames =
parseTauriHandlerCommandNames(tauriHandlerSource);
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
throw new Error(
'AI game creator shell home creation must expose automatic project creation',
);
}
if (
tauriHandlerCommandNames.includes('chat_with_game_creator_home_direct_codex')
) {
throw new Error(
'AI game creator shell home surface must not expose a projectless Codex conversation',
);
}
if (packageConfig.name !== '@genarrative/ai-game-creator-shell') {
throw new Error('AI game creator shell package name drifted');
}
if (
packageConfig.scripts?.['llm-status'] !==
'node scripts/run-cli-with-config.mjs --llm-status'
) {
throw new Error(
'AI game creator shell llm-status must use client config before checking LLM config',
);
}
if (
packageConfig.scripts?.['agent-run'] !==
'node scripts/run-cli-with-config.mjs --agent-run'
) {
throw new Error(
'AI game creator shell agent-run must use client config before running the provider path',
);
}
if (
packageConfig.scripts?.['agent-task'] !==
'node scripts/run-cli-with-config.mjs --agent-task'
) {
throw new Error(
'AI game creator shell agent-task must use client config before starting the single Agent runtime',
);
}
if (
packageConfig.scripts?.swarm !==
'node scripts/run-cli-with-config.mjs --swarm-chat'
) {
throw new Error(
'AI game creator shell swarm must use client config before starting the interactive Agent runtime',
);
}
if (
packageConfig.scripts?.config !==
'node scripts/game-creator-config-wizard.mjs'
) {
throw new Error(
'AI game creator shell config must use the AppData configuration wizard',
);
}
if (
packageConfig.scripts?.['test:chat'] !==
`node scripts/agent-swarm-test-chat.mjs --task ${JSON.stringify(defaultRealSwarmTestTask)} --no-open`
) {
throw new Error(
'AI game creator shell test:chat must use the one-click Swarm test entry',
);
}
if (
packageConfig.scripts?.['test:chat:manual'] !==
'node scripts/agent-swarm-test-chat.mjs'
) {
throw new Error(
'AI game creator shell test:chat:manual must keep the interactive Swarm test entry',
);
}
if (
rootPackageConfig.scripts?.['agc:test'] !==
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --'
) {
throw new Error('agc:test must delegate to the deterministic playable E2E');
}
if (
rootPackageConfig.scripts?.['agc:config'] !==
'npm --prefix apps/ai-game-creator-shell run config --'
) {
throw new Error(
'agc:config must delegate to the AppData configuration wizard',
);
}
if (
rootPackageConfig.scripts?.['agc:test:chat'] !==
'npm --prefix apps/ai-game-creator-shell run test:chat --'
) {
throw new Error(
'agc:test:chat must delegate to the one-click Swarm test entry',
);
}
if (
rootPackageConfig.scripts?.['agc:test:chat:manual'] !==
'npm --prefix apps/ai-game-creator-shell run test:chat:manual --'
) {
throw new Error(
'agc:test:chat:manual must delegate to the interactive Swarm test entry',
);
}
for (const requiredSource of [
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
"'--swarm-chat'",
"'--autonomous-game-build'",
"'--preview-serve'",
'cleanupSwarmTestProject(project)',
]) {
if (!swarmTestChatSource.includes(requiredSource)) {
throw new Error(`Swarm test entry contract drifted: ${requiredSource}`);
}
}
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
throw new Error('AI game creator shell productName drifted');
}
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted');
}
const expectedBundledCodexResources = {
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'codex/win-x64/bin/codex-code-mode-host.exe',
'resources/codex/win-x64/codex-path/rg.exe':
'codex/win-x64/codex-path/rg.exe',
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
'codex/win-x64/codex-resources/codex-command-runner.exe',
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
'resources/codex/win-x64/codex-package.json':
'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
};
if (tauriConfig.bundle?.resources !== undefined) {
throw new Error(
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
);
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
expectedBundledCodexResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
);
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
);
}
if (tauriConfig.app?.withGlobalTauri !== true) {
throw new Error(
'AI game creator shell must expose window.__TAURI__ for local commands',
);
}
if (!fs.existsSync(eventCapabilityPath)) {
throw new Error(
'AI game creator shell must declare a Tauri event-listener capability',
);
}
const eventCapability = JSON.parse(
fs.readFileSync(eventCapabilityPath, 'utf8'),
);
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
for (const windowLabel of [
'client',
'developer',
'main',
'launcher',
'supervisor-chat',
]) {
if (!eventCapabilityWindows.has(windowLabel)) {
throw new Error(
`AI game creator shell event capability missing window: ${windowLabel}`,
);
}
}
for (const permission of [
'core:event:allow-listen',
'core:event:allow-unlisten',
]) {
if (!eventCapabilityPermissions.has(permission)) {
throw new Error(
`AI game creator shell event capability missing permission: ${permission}`,
);
}
}
if (
eventCapabilityPermissions.has('core:event:allow-emit') ||
eventCapabilityPermissions.has('core:event:allow-emit-to') ||
eventCapabilityPermissions.has('core:event:default')
) {
throw new Error(
'AI game creator shell frontend event capability must stay listen-only',
);
}
if (
!Array.isArray(tauriConfig.app?.windows) ||
tauriConfig.app.windows.length !== 1 ||
tauriConfig.app.windows[0]?.label !== 'client' ||
tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
tauriConfig.app.windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator shell must start with only the client window',
);
}
if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
);
}
const allowedLlmReasoningEfforts = new Set([
'default',
'low',
'medium',
'high',
'max',
]);
if (defaultAppConfig.llm?.reasoningEffort !== 'max') {
throw new Error(
'AI game creator shell default llm.reasoningEffort must stay max',
);
}
for (const [agentId, agentConfig] of Object.entries(
defaultAppConfig.agentLlm ?? {},
)) {
if (
agentConfig?.reasoningEffort !== undefined &&
!allowedLlmReasoningEfforts.has(agentConfig.reasoningEffort)
) {
throw new Error(
`AI game creator shell agentLlm.${agentId}.reasoningEffort is invalid`,
);
}
}
if (defaultAppConfig.editorApi !== undefined) {
throw new Error(
'AI game creator shell ordinary default config must not contain editorApi',
);
}
if (
defaultAppConfig.llm?.requestTimeoutMs < 1000 ||
defaultAppConfig.llm?.maxRetries < 0 ||
defaultAppConfig.llm?.retryBackoffMs < 1
) {
throw new Error('AI game creator shell default LLM timing config is invalid');
}
const windows = tauriConfig.app?.windows ?? [];
if (
windows.length !== 1 ||
windows[0]?.label !== 'client' ||
windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator shell release config must expose only the client window',
);
}
const clientWindow = windows[0];
if (
clientWindow.width !== 1280 ||
clientWindow.height !== 800 ||
clientWindow.minWidth !== 1280 ||
clientWindow.minHeight !== 720
) {
throw new Error(
'AI game creator shell client window must default to 1280x800 and stay at least 1280x720',
);
}
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
throw new Error(
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
);
}
if (!viteConfigSource.includes("host: '127.0.0.1'")) {
throw new Error(
'AI game creator shell Vite dev server must bind to localhost',
);
}
if (
!viteConfigSource.includes('port: 3080') ||
!viteConfigSource.includes('port: server.config.server.port')
) {
throw new Error(
'AI game creator shell Vite config must retain its fallback and report the actual CLI-selected port',
);
}
for (const snippet of [
'mapDevPortsToPortRange',
'agcVitePort',
'resolveAgcDevEndpoint',
'GENARRATIVE_AGC_VITE_PORT',
]) {
if (!devPortSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev port resolver drifted: ${snippet}`,
);
}
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"'--config'",
'configOverride',
]) {
if (!startTauriDevSource.includes(snippet)) {
throw new Error(
`AI game creator shell Tauri dev port injection drifted: ${snippet}`,
);
}
}
if (!viteConfigSource.includes('strictPort: true')) {
throw new Error(
'AI game creator shell Vite dev server must not drift away from Tauri devUrl',
);
}
if (!viteConfigSource.includes('allow: [repoRoot]')) {
throw new Error(
'AI game creator shell Vite dev server must allow shared repository sources',
);
}
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
}
if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
throw new Error(
'AI game creator shell dev must run through the managed Tauri dev launcher',
);
}
if (
!tauriHandlerSource.includes('.build(tauri_context)') ||
!tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)')
) {
throw new Error(
'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook',
);
}
if (
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
) {
throw new Error(
'AI game creator shell beforeBuildCommand must resolve vite config from app root',
);
}
const releaseVersions = [
packageConfig.version,
tauriConfig.version,
cargoPackageVersion,
];
if (
releaseVersions.some((version) => !/^\d+\.\d+\.\d+$/u.test(version ?? '')) ||
new Set(releaseVersions).size !== 1
) {
throw new Error(
'AI game creator release versions must be valid three-part semver and synchronized',
);
}
const devServerSource = fs.readFileSync(
new URL('../scripts/start-dev-server.mjs', import.meta.url),
'utf8',
);
const runCliWithConfigSource = fs.readFileSync(
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
'utf8',
);
if (!runCliWithConfigSource.includes('resolveGameCreatorAppConfigDir')) {
throw new Error(
'AI game creator shell CLI wrapper must resolve the GUI AppData directory',
);
}
if (
!runCliWithConfigSource.includes(
"'--config-dir', resolveGameCreatorAppConfigDir()",
)
) {
throw new Error(
'AI game creator shell CLI wrapper must pass the GUI AppData directory to native commands',
);
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"response.body.includes('<title>陶泥儿</title>')",
'function isPortListening()',
'cannot be safely reused',
'non-HTTP or unrecognized server',
"'--config'",
"'vite.config.ts'",
"'--port'",
]) {
if (!devServerSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev server wrapper drifted: ${snippet}`,
);
}
}
const configWizardSource = fs.readFileSync(
new URL('./game-creator-config-wizard.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
'assertSafeGameCreatorConfigDestination',
'readGameCreatorWizardConfigState',
'$security.SetAccessRuleProtection($true, $false)',
'$targetItem = Get-Item -LiteralPath $target -Force',
'$targetItem.SetAccessControl($security)',
'$verified = $targetItem.GetAccessControl()',
'$rules.Count -ne 1',
'[System.Security.AccessControl.FileSystemRights]::FullControl',
"'-NoProfile'",
"'-Command'",
'windowsPrivateAclScript',
'await secureWindowsPath(temporaryPath, { isDirectory: false })',
'await temporaryFile.writeFile',
]) {
if (!configWizardSource.includes(snippet)) {
throw new Error(
`AI game creator config wizard guardrail drifted: ${snippet}`,
);
}
}
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
);
}
if (/\bGet-Acl\b/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
);
}
await runConfigWizardRegressionChecks();
await runHiddenInputRegressionChecks();
for (const snippet of [
"new URL('..', import.meta.url)",
"'--manifest-path'",
"'src-tauri/Cargo.toml'",
]) {
if (!runCliWithConfigSource.includes(snippet)) {
throw new Error(
`AI game creator shell config CLI wrapper drifted: ${snippet}`,
);
}
}
for (const snippet of [
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
'fn configure_game_creator_runtime_config_dir(',
'game_creator_runtime_config_dir()',
'.unwrap_or_else(|| app.path().app_config_dir())?',
'fn load_game_creator_app_config()',
'fn read_game_creator_app_config()',
'fn write_game_creator_app_config(',
'fn writable_game_creator_config_path()',
'fn normalize_game_creator_app_config(',
'fn merge_game_creator_config_file(',
'.join("apps")',
'.join("ai-game-creator-shell")',
'read_game_creator_app_config,',
'write_game_creator_app_config,',
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
'resolve_game_creator_llm_config_for_agent(app_config, "generator")',
'build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?',
'build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?',
'agentLlm.{agent_id}',
'let app_config = match load_game_creator_app_config()',
'fn append_local_permission_log_at(',
'"command.auto"',
'GameCreationAppPermission::Auto',
'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH',
'validate_game_creator_bundled_codex_cli',
'内置 Codex CLI 完整性校验失败',
]) {
if (!tauriRustSource.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
const runtimeConfigSetupStart = tauriHandlerSource.indexOf(
'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {',
);
const runtimeConfigSetupEnd = tauriHandlerSource.indexOf(
'})?;',
runtimeConfigSetupStart,
);
const runtimeConfigSetupSource = tauriHandlerSource.slice(
runtimeConfigSetupStart,
runtimeConfigSetupEnd,
);
if (
runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
!runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}',
) ||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
!tauriHandlerSource.includes(
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
) ||
!tauriHandlerSource.includes('early_startup_log_path(')
) {
throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
);
}
for (const snippet of [
'import.meta.env.DEV',
'supervisorChatMode',
'supervisorChatOnly',
'open_project_supervisor_chat_window',
'index.html?supervisor-chat&projectPath=',
]) {
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
throw new Error(
'AI game creator normal startup must not automatically open the developer window',
);
}
const smokeAgentRunSource = fs.readFileSync(
new URL('./smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
'agentLlm',
'planner-smoke-model',
'generator-smoke-model',
'global-smoke-model-unused',
]) {
if (!smokeAgentRunSource.includes(snippet)) {
throw new Error(
`AI game creator local-provider smoke lost per-agent LLM coverage: ${snippet}`,
);
}
}
for (const snippet of [
"'game.run_local'",
"'read_game_creator_app_config'",
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'陶泥儿智能创作(固定)',
'官方账号服务(固定)',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
'已切换到客户端运行视图',
'async function executeRunLocal',
'function needsInitializedChatProject',
'function resolvePendingCommandProjectPath',
'resolveChatProjectPath(localProject) ?? draftProjectPath',
'`permission.cancel ${command.id} missing-project`',
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
'function parseRememberInput',
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
'async function executeAgentTraceChat',
"relativePath: '.agent/logs/command.log'",
"'permission.pending'",
"'permission.confirm'",
"'permission.cancel'",
"'command.auto'",
"'agent.run_status'",
'function summarizeAgentRunTrace',
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
'agentRunTrace.error ?',
'className="trace-error"',
'agentRunTrace.taskGraph.repairRoutes.map',
"in: ${step.inputPaths.join(', ') || 'none'}",
"out: ${step.outputPaths.join(', ') || 'none'}",
]) {
if (!appSource.includes(snippet)) {
throw new Error(
`AI game creator shell trace panel guardrail drifted: ${snippet}`,
);
}
}
for (const script of [
'ai-game-creator-shell:dev',
'ai-game-creator-shell:dev-server',
'ai-game-creator-shell:build',
'ai-game-creator-shell:agent-task',
'agc:swarm',
'ai-game-creator-shell:typecheck',
'ai-game-creator-shell:agent-run:smoke',
'ai-game-creator-shell:check',
]) {
if (!rootPackageConfig.scripts?.[script]) {
throw new Error(`root package missing ${script}`);
}
}
if (
rootPackageConfig.scripts?.['agc:swarm'] !==
'npm --prefix apps/ai-game-creator-shell run swarm --'
) {
throw new Error('root agc:swarm script must forward CLI args');
}
if (
rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !==
'npm --prefix apps/ai-game-creator-shell run build --'
) {
throw new Error(
'root ai-game-creator-shell:build script must forward build args',
);
}
const agentRunSmokeSource = fs.readFileSync(
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
"const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'",
"const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'",
"mediaType: 'audio/mpeg'",
'document.body.dataset.smokeFrame = String(frameCount)',
"ctx.fillStyle = '#ff00ff'",
'ctx.fillRect(4, 4, 8, 24)',
'const sample = ctx.getImageData(4, 4, 24, 24).data',
'chef.complete && chef.naturalWidth > 0',
'document.body.dataset.smokeCanvasPixels = String(litPixels)',
'document.body.dataset.smokeCanvasColors = String(colors.size)',
'readBrowserDom(previewUrl)',
"extractDomNumber(previewDom, 'smoke-canvas-pixels')",
'--dump-dom',
'readHttpHead(new URL(smokeAssetPath, previewUrl).toString())',
'readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString())',
'function writeStreamingChatCompletion',
'requestJson?.stream === true',
`requestBodies.every((body) => body.includes('"stream":true'))`,
"const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')",
"apiKind: 'openai_chat'",
'stream: true',
'await restoreOptionalFile(localConfigPath, previousLocalConfig)',
"method: 'HEAD'",
'previewAssetHead.contentLength === String(smokeAssetBytes.length)',
"previewAudioHead.contentType === 'audio/mpeg'",
"previewAssetHead.body === ''",
'trace missing professional group ${group}',
]) {
if (!agentRunSmokeSource.includes(snippet)) {
throw new Error(
`AI game creator shell agent-run smoke drifted: ${snippet}`,
);
}
}