Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template
# Conflicts: # .hermes/shared-memory/decision-log.md # .hermes/shared-memory/pitfalls.md # docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
@@ -958,16 +958,11 @@ class DevRunner {
|
||||
async startApiServer(service) {
|
||||
await this.ensureApiServerSpacetimeToken();
|
||||
|
||||
const mergedEnv = {
|
||||
...this.baseEnv,
|
||||
GENARRATIVE_API_HOST: this.options.apiHost,
|
||||
GENARRATIVE_API_PORT: String(this.options.apiPort),
|
||||
GENARRATIVE_API_LOG: this.options.apiLog,
|
||||
GENARRATIVE_SPACETIME_SERVER_URL: this.state.spacetimeServer,
|
||||
GENARRATIVE_SPACETIME_DATABASE: this.options.database,
|
||||
GENARRATIVE_SPACETIME_TOKEN:
|
||||
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
|
||||
};
|
||||
const mergedEnv = buildApiServerProcessEnv({
|
||||
baseEnv: this.baseEnv,
|
||||
options: this.options,
|
||||
state: this.state,
|
||||
});
|
||||
|
||||
const logFile = resolveApiServerLogFile(repoRoot, mergedEnv);
|
||||
ensureParentDir(logFile);
|
||||
@@ -1717,10 +1712,25 @@ function isSpacetimePublishPermissionError(error) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildApiServerProcessEnv({baseEnv, options, state}) {
|
||||
return {
|
||||
...baseEnv,
|
||||
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
|
||||
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
|
||||
GENARRATIVE_API_HOST: options.apiHost,
|
||||
GENARRATIVE_API_PORT: String(options.apiPort),
|
||||
GENARRATIVE_API_LOG: options.apiLog,
|
||||
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
|
||||
GENARRATIVE_SPACETIME_DATABASE: options.database,
|
||||
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
DevRunner,
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DevRunner,
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
@@ -89,6 +90,21 @@ describe('dev scheduler argument routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler api-server env', () => {
|
||||
test('dev 脚本默认打开密码入口自动注册', () => {
|
||||
const {options} = parseArgs(['api-server', '--api-port', '9091'], {});
|
||||
const env = buildApiServerProcessEnv({
|
||||
baseEnv: {},
|
||||
options,
|
||||
state: {spacetimeServer: 'http://127.0.0.1:3199'},
|
||||
});
|
||||
|
||||
expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true');
|
||||
expect(env.GENARRATIVE_API_PORT).toBe('9091');
|
||||
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler spacetime reuse guard', () => {
|
||||
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
|
||||
334
scripts/generate-spacetime-bindings.mjs
Normal file
334
scripts/generate-spacetime-bindings.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {cp, mkdir, readdir, rm, stat} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
|
||||
const MODULE_PATH = path.join(REPO_ROOT, 'server-rs', 'crates', 'spacetime-module');
|
||||
|
||||
const TARGETS = [
|
||||
{
|
||||
name: 'Rust',
|
||||
lang: 'rust',
|
||||
tempName: 'rs',
|
||||
outDir: path.join(
|
||||
REPO_ROOT,
|
||||
'server-rs',
|
||||
'crates',
|
||||
'spacetime-client',
|
||||
'src',
|
||||
'module_bindings',
|
||||
),
|
||||
entryFile: path.join(
|
||||
REPO_ROOT,
|
||||
'server-rs',
|
||||
'crates',
|
||||
'spacetime-client',
|
||||
'src',
|
||||
'module_bindings.rs',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const KNOWN_ARGS = new Set(['--rust-only']);
|
||||
|
||||
for (const arg of args) {
|
||||
if (!KNOWN_ARGS.has(arg)) {
|
||||
console.error(`[spacetime:generate] 未知参数: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(MODULE_PATH, 'Cargo.toml'))) {
|
||||
console.error(`[spacetime:generate] 未找到模块: ${MODULE_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tempRoot = resolveTempRoot();
|
||||
assertSafeTempRoot(tempRoot);
|
||||
const selectedTargets = TARGETS.filter((target) => shouldRunTarget(target.lang));
|
||||
|
||||
if (selectedTargets.length === 0) {
|
||||
console.error('[spacetime:generate] 没有需要生成的目标。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await mkdir(tempRoot, {recursive: true});
|
||||
|
||||
for (const target of selectedTargets) {
|
||||
const tempOutDir = path.join(tempRoot, target.tempName);
|
||||
await recreateTempDir(tempOutDir);
|
||||
|
||||
console.log(`[spacetime:generate] 生成 ${target.name} bindings 到短路径: ${tempOutDir}`);
|
||||
await generateBindings(target, tempOutDir);
|
||||
|
||||
const fileCount = await countFiles(tempOutDir);
|
||||
if (fileCount === 0) {
|
||||
throw new Error(`${target.name} bindings 未生成任何文件。`);
|
||||
}
|
||||
|
||||
console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
|
||||
await replaceGeneratedDir(tempOutDir, target.outDir);
|
||||
await moveGeneratedEntryFile(target);
|
||||
}
|
||||
|
||||
await rm(tempRoot, {recursive: true, force: true});
|
||||
console.log('[spacetime:generate] bindings 生成完成。');
|
||||
|
||||
function shouldRunTarget(lang) {
|
||||
if (args.has('--rust-only')) {
|
||||
return lang === 'rust';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveTempRoot() {
|
||||
if (process.env.GENARRATIVE_BINDGEN_TEMP_ROOT) {
|
||||
return path.resolve(process.env.GENARRATIVE_BINDGEN_TEMP_ROOT);
|
||||
}
|
||||
|
||||
// Windows 下 SpacetimeDB CLI 2.1.0 会把所有生成文件路径一次性传给 formatter;
|
||||
// Rust bindings 文件数较多,输出到仓库深目录时容易触发 CreateProcess 路径总长限制。
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(path.parse(REPO_ROOT).root, '.genarrative-bindgen');
|
||||
}
|
||||
|
||||
return path.join(REPO_ROOT, 'tmp', 'spacetime-bindgen');
|
||||
}
|
||||
|
||||
async function recreateTempDir(dir) {
|
||||
assertInside(dir, tempRoot, '临时生成目录');
|
||||
await rm(dir, {recursive: true, force: true});
|
||||
await mkdir(dir, {recursive: true});
|
||||
}
|
||||
|
||||
async function replaceGeneratedDir(fromDir, toDir) {
|
||||
assertInside(toDir, REPO_ROOT, '仓库生成目录');
|
||||
await rm(toDir, {recursive: true, force: true});
|
||||
await mkdir(toDir, {recursive: true});
|
||||
const entries = await readdir(fromDir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function moveGeneratedEntryFile(target) {
|
||||
if (!target.entryFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertInside(target.entryFile, REPO_ROOT, '生成入口文件');
|
||||
|
||||
const generatedEntryFile =
|
||||
findExistingFile(path.join(target.outDir, 'module_bindings.rs')) ??
|
||||
findExistingFile(path.join(target.outDir, 'mod.rs'));
|
||||
|
||||
if (!generatedEntryFile) {
|
||||
throw new Error(
|
||||
`${target.name} bindings 缺少入口文件: ${path.join(target.outDir, 'module_bindings.rs')} / ${path.join(target.outDir, 'mod.rs')}`,
|
||||
);
|
||||
}
|
||||
|
||||
await rm(target.entryFile, {force: true});
|
||||
await cp(generatedEntryFile, target.entryFile, {force: true});
|
||||
if (path.resolve(generatedEntryFile) !== path.resolve(target.entryFile)) {
|
||||
await rm(generatedEntryFile, {force: true});
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingFile(candidate) {
|
||||
return existsSync(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
function assertInside(candidate, parent, label) {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
||||
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} 不在预期目录内: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeTempRoot(dir) {
|
||||
const resolved = path.resolve(dir);
|
||||
const parsed = path.parse(resolved);
|
||||
const basename = path.basename(resolved).toLowerCase();
|
||||
|
||||
if (resolved === path.resolve(REPO_ROOT) || resolved === parsed.root) {
|
||||
throw new Error(`临时根目录不允许指向仓库或磁盘根目录: ${resolved}`);
|
||||
}
|
||||
|
||||
if (!basename.includes('bindgen')) {
|
||||
throw new Error(`临时根目录必须是明确的 bindings 生成目录: ${resolved}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildGenerateArgs(target, outDir) {
|
||||
const generateArgs = [
|
||||
'generate',
|
||||
'--no-config',
|
||||
'--lang',
|
||||
target.lang,
|
||||
'--out-dir',
|
||||
outDir,
|
||||
'--module-path',
|
||||
MODULE_PATH,
|
||||
'--include-private',
|
||||
'--yes',
|
||||
];
|
||||
|
||||
return generateArgs;
|
||||
}
|
||||
|
||||
async function generateBindings(target, outDir) {
|
||||
const result = await run('spacetime', buildGenerateArgs(target, outDir), {
|
||||
allowGeneratedFormatFailure: target.lang === 'rust',
|
||||
});
|
||||
|
||||
if (result.generatedFormatFailed) {
|
||||
// Windows 下 SpacetimeDB CLI 2.1.0 会把所有 Rust 文件一次性传给 formatter;
|
||||
// 这里只接管“文件已生成但 CLI 格式化失败”的尾段,并仍然只同步生成目录。
|
||||
console.warn(
|
||||
`[spacetime:generate] ${target.name} bindings 已生成,但 SpacetimeDB CLI 自带格式化失败;改用短路径分批 rustfmt。`,
|
||||
);
|
||||
await formatRustBindings(outDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function formatRustBindings(outDir) {
|
||||
const rustFiles = await collectRustFiles(outDir);
|
||||
if (rustFiles.length === 0) {
|
||||
throw new Error(`Rust bindings 未生成任何 .rs 文件,无法格式化: ${outDir}`);
|
||||
}
|
||||
|
||||
for (const chunk of chunkCommandArgs(rustFiles)) {
|
||||
await run('rustfmt', ['--edition', '2024', ...chunk]);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRustFiles(dir) {
|
||||
const files = [];
|
||||
const entries = await readdir(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectRustFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.rs')) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function chunkCommandArgs(argsToChunk) {
|
||||
// Windows CreateProcess 受命令行长度限制;分批能避免 bindings 文件变多后再次失败。
|
||||
const maxCommandLineChars = process.platform === 'win32' ? 20_000 : 100_000;
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
let currentLength = 0;
|
||||
|
||||
for (const arg of argsToChunk) {
|
||||
const argLength = arg.length + 3;
|
||||
if (current.length > 0 && currentLength + argLength > maxCommandLineChars) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
currentLength = 0;
|
||||
}
|
||||
|
||||
current.push(arg);
|
||||
currentLength += argLength;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
chunks.push(current);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: REPO_ROOT,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
process.stdout.write(text);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
process.stderr.write(text);
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${command} 被信号中断: ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedFormatFailed = output.includes('Could not format generated files');
|
||||
|
||||
if (generatedFormatFailed && options.allowGeneratedFormatFailure) {
|
||||
console.warn(
|
||||
`[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`,
|
||||
);
|
||||
resolve({generatedFormatFailed});
|
||||
return;
|
||||
}
|
||||
|
||||
if (generatedFormatFailed) {
|
||||
reject(new Error(`${command} generated files but formatting failed.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve({generatedFormatFailed: false});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} 退出码: ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function countFiles(dir) {
|
||||
let count = 0;
|
||||
const entries = await readdir(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
count += await countFiles(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() || (await stat(entryPath)).isFile()) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user