完成 Editor Agent Mock Agent P1 收尾
接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面 新增 Mock Agent、静态构建 runner 与独立预览网关 补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅 修复 sandbox 预览资源跨域加载并补充并发保护 接入本地 dev 预览端口漂移与服务身份初始化 更新 P1 技术方案、验收清单和 Hermes 共享记忆
This commit is contained in:
@@ -55,8 +55,8 @@ export function parsePortRangeSpec(value) {
|
||||
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
|
||||
}
|
||||
|
||||
if (end - start + 1 < 4) {
|
||||
throw new Error(`端口段至少需要 4 个端口: ${spec}`);
|
||||
if (end - start + 1 < 5) {
|
||||
throw new Error(`端口段至少需要 5 个端口: ${spec}`);
|
||||
}
|
||||
|
||||
return {start, end, label: `${start}-${end}`};
|
||||
@@ -118,6 +118,7 @@ export function mapDevPortsToPortRange(portRange) {
|
||||
apiPort: normalizedRange.start + 1,
|
||||
spacetimePort: normalizedRange.start + 2,
|
||||
adminWebPort: normalizedRange.start + 3,
|
||||
webProjectPreviewPort: normalizedRange.start + 4,
|
||||
range: normalizedRange,
|
||||
};
|
||||
}
|
||||
@@ -556,6 +557,7 @@ export async function resolveDevStackPorts(config) {
|
||||
['api', config.api],
|
||||
['web', config.web],
|
||||
['adminWeb', config.adminWeb],
|
||||
['webProjectPreview', config.webProjectPreview],
|
||||
].filter(([, portConfig]) => Boolean(portConfig));
|
||||
const result = {};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function reservePort(port) {
|
||||
}
|
||||
|
||||
describe('dev stack port utils', () => {
|
||||
it('解析端口段并映射到四个 dev 端口', () => {
|
||||
it('解析端口段并映射到 dev 端口和预览网关第五端口', () => {
|
||||
expect(parsePortRangeSpec('10000-10099')).toEqual({
|
||||
start: 10000,
|
||||
end: 10099,
|
||||
@@ -35,6 +35,7 @@ describe('dev stack port utils', () => {
|
||||
apiPort: 10001,
|
||||
spacetimePort: 10002,
|
||||
adminWebPort: 10003,
|
||||
webProjectPreviewPort: 10004,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,9 +75,11 @@ describe('dev stack port utils', () => {
|
||||
api: {host: '127.0.0.1', preferredPort: 0},
|
||||
web: {host: '127.0.0.1', preferredPort: 0},
|
||||
adminWeb: {host: '127.0.0.1', preferredPort: 0},
|
||||
webProjectPreview: {host: '127.0.0.1', preferredPort: 0},
|
||||
});
|
||||
|
||||
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
|
||||
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
|
||||
expect(resolvedPorts.webProjectPreview).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
|
||||
|
||||
417
scripts/dev.mjs
417
scripts/dev.mjs
@@ -37,9 +37,18 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
|
||||
const webProjectRunnerBinPath = resolve(
|
||||
serverRsDir,
|
||||
'target/debug',
|
||||
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner',
|
||||
);
|
||||
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? '' : '/usr/bin/env';
|
||||
const WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV =
|
||||
'GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET';
|
||||
const WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE = 'api-server-web-project-identity.json';
|
||||
|
||||
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
||||
const SNAPSHOT_SERVICE_NAMES = [...SERVICE_NAMES, 'web-project-preview'];
|
||||
const SERVICE_ALIASES = new Map([
|
||||
['api', 'api-server'],
|
||||
['admin', 'admin-web'],
|
||||
@@ -62,6 +71,8 @@ function usage() {
|
||||
--web-port <port> 主站 Vite 端口
|
||||
--admin-web-host <host> 后台 Vite 监听地址
|
||||
--admin-web-port <port> 后台 Vite 端口
|
||||
--web-project-preview-host <host> Web Project preview gateway 监听地址
|
||||
--web-project-preview-port <port> Web Project preview gateway 端口
|
||||
--spacetime-host <host> SpacetimeDB 监听地址
|
||||
--spacetime-port <port> SpacetimeDB 端口
|
||||
--spacetime-data-dir <path> SpacetimeDB 本地数据目录
|
||||
@@ -113,6 +124,12 @@ function parseArgs(argv, baseEnv) {
|
||||
webPort: normalizePort(env.WEB_PORT, 3000),
|
||||
adminWebHost: env.ADMIN_WEB_HOST || '127.0.0.1',
|
||||
adminWebPort: normalizePort(env.ADMIN_WEB_PORT, 3102),
|
||||
webProjectPreviewHost:
|
||||
env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST || '127.0.0.1',
|
||||
webProjectPreviewPort: normalizePort(
|
||||
env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT,
|
||||
3104,
|
||||
),
|
||||
spacetimeHost: env.SPACETIME_HOST || '127.0.0.1',
|
||||
spacetimePort: normalizePort(env.SPACETIME_PORT, 3101),
|
||||
spacetimeDataDir: resolve(serverRsDir, '.spacetimedb/local/data'),
|
||||
@@ -130,6 +147,14 @@ function parseArgs(argv, baseEnv) {
|
||||
preserveDatabase: false,
|
||||
migrationBootstrapSecret: '',
|
||||
migrationBootstrapSecretMode: 'auto',
|
||||
webProjectServiceBootstrapSecret: String(
|
||||
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] ?? '',
|
||||
).trim(),
|
||||
webProjectServiceBootstrapSecretMode: String(
|
||||
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] ?? '',
|
||||
).trim()
|
||||
? 'manual'
|
||||
: 'auto',
|
||||
watch: false,
|
||||
interactive: true,
|
||||
};
|
||||
@@ -175,6 +200,17 @@ function parseArgs(argv, baseEnv) {
|
||||
options.adminWebPort = normalizePort(readValue(), options.adminWebPort);
|
||||
explicitOptions.add('adminWebPort');
|
||||
break;
|
||||
case '--web-project-preview-host':
|
||||
options.webProjectPreviewHost = readValue();
|
||||
explicitOptions.add('webProjectPreviewHost');
|
||||
break;
|
||||
case '--web-project-preview-port':
|
||||
options.webProjectPreviewPort = normalizePort(
|
||||
readValue(),
|
||||
options.webProjectPreviewPort,
|
||||
);
|
||||
explicitOptions.add('webProjectPreviewPort');
|
||||
break;
|
||||
case '--spacetime-host':
|
||||
options.spacetimeHost = readValue();
|
||||
options.spacetimeServerUrl = '';
|
||||
@@ -226,6 +262,14 @@ function parseArgs(argv, baseEnv) {
|
||||
options.migrationBootstrapSecret = '';
|
||||
options.migrationBootstrapSecretMode = 'disabled';
|
||||
break;
|
||||
case '--web-project-service-bootstrap-secret':
|
||||
options.webProjectServiceBootstrapSecret = readValue();
|
||||
options.webProjectServiceBootstrapSecretMode = 'manual';
|
||||
break;
|
||||
case '--no-web-project-service-bootstrap-secret':
|
||||
options.webProjectServiceBootstrapSecret = '';
|
||||
options.webProjectServiceBootstrapSecretMode = 'disabled';
|
||||
break;
|
||||
case '--watch':
|
||||
options.watch = true;
|
||||
break;
|
||||
@@ -262,9 +306,27 @@ function resolveDevStackStatePath(root = repoRoot) {
|
||||
return join(root, '.app/dev-stack.json');
|
||||
}
|
||||
|
||||
function buildWebProjectPreviewPublicBaseUrl({host, port}) {
|
||||
return `http://${resolveClientHost(host)}:${port}`;
|
||||
}
|
||||
|
||||
function resolveWebProjectPreviewFrameAncestors({baseEnv, webOrigin}) {
|
||||
const explicitAncestors = String(
|
||||
baseEnv.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS ?? '',
|
||||
).trim();
|
||||
if (explicitAncestors) {
|
||||
const ancestors = uniqueNonEmpty([explicitAncestors, webOrigin]).join(' ');
|
||||
return ancestors || webOrigin;
|
||||
}
|
||||
|
||||
const webUrl = new URL(webOrigin);
|
||||
const localhostOrigin = `${webUrl.protocol}//localhost:${webUrl.port}`;
|
||||
return uniqueNonEmpty([webOrigin, localhostOrigin]).join(' ');
|
||||
}
|
||||
|
||||
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
|
||||
const services = {};
|
||||
for (const serviceName of SERVICE_NAMES) {
|
||||
for (const serviceName of SNAPSHOT_SERVICE_NAMES) {
|
||||
services[serviceName] = buildDevStackServiceSnapshot(
|
||||
runner,
|
||||
serviceName,
|
||||
@@ -341,6 +403,12 @@ function resolveDevStackServiceEndpoint(runner, serviceName) {
|
||||
port: options.adminWebPort,
|
||||
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
|
||||
};
|
||||
case 'web-project-preview':
|
||||
return {
|
||||
host: options.webProjectPreviewHost,
|
||||
port: options.webProjectPreviewPort,
|
||||
url: state.webProjectPreviewPublicBaseUrl,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
host: null,
|
||||
@@ -383,6 +451,8 @@ function resolveDevStackServiceCommand(runner, serviceName) {
|
||||
`--port=${options.adminWebPort}`,
|
||||
'--strictPort',
|
||||
].join(' ');
|
||||
case 'web-project-preview':
|
||||
return 'api-server embedded Web Project preview gateway';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -779,6 +849,10 @@ class DevRunner {
|
||||
adminWebTargetHost: resolveClientHost(options.adminWebHost),
|
||||
spacetimeServer: initialSpacetimeServer,
|
||||
apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`,
|
||||
webProjectPreviewPublicBaseUrl: buildWebProjectPreviewPublicBaseUrl({
|
||||
host: options.webProjectPreviewHost,
|
||||
port: options.webProjectPreviewPort,
|
||||
}),
|
||||
portRange: null,
|
||||
portRangeReservation: null,
|
||||
};
|
||||
@@ -851,9 +925,16 @@ class DevRunner {
|
||||
if (!this.explicitOptions.has('adminWebPort')) {
|
||||
this.options.adminWebPort = mappedPorts.adminWebPort;
|
||||
}
|
||||
if (!this.explicitOptions.has('webProjectPreviewPort')) {
|
||||
this.options.webProjectPreviewPort = mappedPorts.webProjectPreviewPort;
|
||||
}
|
||||
|
||||
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`;
|
||||
this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`;
|
||||
this.state.webProjectPreviewPublicBaseUrl = buildWebProjectPreviewPublicBaseUrl({
|
||||
host: this.options.webProjectPreviewHost,
|
||||
port: this.options.webProjectPreviewPort,
|
||||
});
|
||||
}
|
||||
|
||||
shouldValidateSpacetimeToolVersion(command) {
|
||||
@@ -973,6 +1054,11 @@ class DevRunner {
|
||||
preferredPort: options.apiPort,
|
||||
portRange: this.state.portRange,
|
||||
};
|
||||
portConfig.webProjectPreview = {
|
||||
host: options.webProjectPreviewHost,
|
||||
preferredPort: options.webProjectPreviewPort,
|
||||
portRange: this.state.portRange,
|
||||
};
|
||||
}
|
||||
|
||||
if (command === 'all' || command === 'web') {
|
||||
@@ -1021,6 +1107,9 @@ class DevRunner {
|
||||
if (resolvedPorts.adminWeb) {
|
||||
options.adminWebPort = resolvedPorts.adminWeb;
|
||||
}
|
||||
if (resolvedPorts.webProjectPreview) {
|
||||
options.webProjectPreviewPort = resolvedPorts.webProjectPreview;
|
||||
}
|
||||
|
||||
this.state.apiTargetHost = resolveClientHost(options.apiHost);
|
||||
this.state.adminWebTargetHost = resolveClientHost(options.adminWebHost);
|
||||
@@ -1028,6 +1117,10 @@ class DevRunner {
|
||||
this.state.spacetimeServer = `http://${options.spacetimeHost}:${options.spacetimePort}`;
|
||||
}
|
||||
this.state.apiTarget = `http://${this.state.apiTargetHost}:${options.apiPort}`;
|
||||
this.state.webProjectPreviewPublicBaseUrl = buildWebProjectPreviewPublicBaseUrl({
|
||||
host: options.webProjectPreviewHost,
|
||||
port: options.webProjectPreviewPort,
|
||||
});
|
||||
}
|
||||
|
||||
registerServices() {
|
||||
@@ -1092,6 +1185,7 @@ class DevRunner {
|
||||
console.log(`[dev] web: http://127.0.0.1:${options.webPort}`);
|
||||
console.log(`[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`);
|
||||
console.log(`[dev] api-server: ${state.apiTarget}`);
|
||||
console.log(`[dev] web project preview: ${state.webProjectPreviewPublicBaseUrl}`);
|
||||
console.log(`[dev] spacetime: ${state.spacetimeServer}`);
|
||||
console.log(`[dev] database: ${options.database}`);
|
||||
}
|
||||
@@ -1266,6 +1360,7 @@ class DevRunner {
|
||||
async publishSpacetimeModule() {
|
||||
const env = buildLocalRustProcessEnv(this.baseEnv);
|
||||
this.prepareMigrationBootstrapSecret(env);
|
||||
this.prepareWebProjectServiceBootstrapSecret(env);
|
||||
|
||||
const args = buildSpacetimePublishArgs({
|
||||
database: this.options.database,
|
||||
@@ -1304,12 +1399,55 @@ class DevRunner {
|
||||
console.log(`[dev:spacetime] 迁移引导密钥: ${this.options.migrationBootstrapSecret}`);
|
||||
}
|
||||
|
||||
prepareWebProjectServiceBootstrapSecret(env) {
|
||||
switch (this.options.webProjectServiceBootstrapSecretMode) {
|
||||
case 'auto':
|
||||
if (!this.options.webProjectServiceBootstrapSecret) {
|
||||
this.options.webProjectServiceBootstrapSecret = randomHex(32);
|
||||
}
|
||||
break;
|
||||
case 'manual':
|
||||
if (this.options.webProjectServiceBootstrapSecret.length < 16) {
|
||||
throw new Error('Web Project 服务身份引导密钥至少需要 16 个字符');
|
||||
}
|
||||
break;
|
||||
case 'disabled':
|
||||
delete env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV];
|
||||
console.log('[dev:spacetime] 未启用 Web Project 服务身份引导密钥');
|
||||
return;
|
||||
default:
|
||||
throw new Error(
|
||||
`未知 Web Project 服务身份引导密钥模式: ${this.options.webProjectServiceBootstrapSecretMode}`,
|
||||
);
|
||||
}
|
||||
|
||||
env[WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET_ENV] =
|
||||
this.options.webProjectServiceBootstrapSecret;
|
||||
this.state.webProjectServiceBootstrapSecretPrepared = true;
|
||||
console.log(
|
||||
`[dev:spacetime] 已准备 Web Project 服务身份引导密钥: mode=${this.options.webProjectServiceBootstrapSecretMode}, length=${this.options.webProjectServiceBootstrapSecret.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
async ensureApiServerSpacetimeToken() {
|
||||
const existingToken = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
|
||||
if (existingToken && shouldTrustExistingSpacetimeToken(existingToken, this.state.spacetimeServer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedIdentity = readCachedSpacetimeIdentity(
|
||||
this.options.spacetimeDataDir,
|
||||
this.state.spacetimeServer,
|
||||
);
|
||||
if (cachedIdentity) {
|
||||
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = cachedIdentity.token;
|
||||
this.state.spacetimeIdentity = cachedIdentity.identity;
|
||||
console.log(
|
||||
`[dev:spacetime] 复用本地 api-server identity: ${cachedIdentity.identity.slice(0, 12)}...`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const identityUrl = buildUrl(this.state.spacetimeServer, '/v1/identity');
|
||||
if (!identityUrl) {
|
||||
throw new Error(`无法构造 SpacetimeDB identity 地址: ${this.state.spacetimeServer}`);
|
||||
@@ -1318,6 +1456,11 @@ class DevRunner {
|
||||
const response = await fetchSpacetimeIdentity(identityUrl);
|
||||
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = response.token;
|
||||
this.state.spacetimeIdentity = response.identity;
|
||||
cacheSpacetimeIdentity(
|
||||
this.options.spacetimeDataDir,
|
||||
this.state.spacetimeServer,
|
||||
response,
|
||||
);
|
||||
console.log(
|
||||
`[dev:spacetime] 已创建本地 Web identity: ${response.identity.slice(0, 12)}...`,
|
||||
);
|
||||
@@ -1325,6 +1468,8 @@ class DevRunner {
|
||||
|
||||
async startApiServer(service) {
|
||||
await this.ensureApiServerSpacetimeToken();
|
||||
await this.ensureWebProjectServiceIdentityAuthorized();
|
||||
await this.ensureWebProjectRunnerBinary();
|
||||
|
||||
const mergedEnv = buildApiServerProcessEnv({
|
||||
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
|
||||
@@ -1382,6 +1527,75 @@ class DevRunner {
|
||||
service.registerChild(child);
|
||||
}
|
||||
|
||||
async ensureWebProjectRunnerBinary() {
|
||||
if (String(this.baseEnv.GENARRATIVE_WEB_PROJECT_RUNNER_BIN ?? '').trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(webProjectRunnerBinPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[dev:api-server] 构建 Web Project runner');
|
||||
await runForeground(
|
||||
'cargo',
|
||||
['build', '-p', 'web-project-runner', '--manifest-path', 'server-rs/Cargo.toml'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: buildLocalRustProcessEnv(this.baseEnv),
|
||||
label: 'web-project-runner',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async ensureWebProjectServiceIdentityAuthorized() {
|
||||
if (this.options.webProjectServiceBootstrapSecretMode === 'disabled') {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceIdentity = String(this.state.spacetimeIdentity ?? '').trim();
|
||||
const token = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
|
||||
if (!serviceIdentity || !token) {
|
||||
console.warn('[dev:spacetime] 未能确认 api-server SpacetimeDB identity,跳过 Web Project 自动授权');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.options.webProjectServiceBootstrapSecretMode !== 'manual' &&
|
||||
!this.state.webProjectServiceBootstrapSecretPrepared
|
||||
) {
|
||||
console.warn(
|
||||
'[dev:spacetime] 本轮未发布带 Web Project 引导密钥的 spacetime-module,跳过自动授权',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapSecret = String(
|
||||
this.options.webProjectServiceBootstrapSecret ?? '',
|
||||
).trim();
|
||||
if (bootstrapSecret.length < 16) {
|
||||
console.warn('[dev:spacetime] Web Project 服务身份引导密钥缺失,跳过自动授权');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authorizeWebProjectServiceIdentity({
|
||||
serverUrl: this.state.spacetimeServer,
|
||||
database: this.options.database,
|
||||
token,
|
||||
bootstrapSecret,
|
||||
serviceIdentity,
|
||||
});
|
||||
console.log(
|
||||
`[dev:spacetime] 已授权 Web Project 本地服务 identity: ${(result.service_identity_hex ?? serviceIdentity).slice(0, 12)}...`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[dev:spacetime] Web Project 本地服务 identity 自动授权失败:${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForApiServer() {
|
||||
const healthUrl = `${this.state.apiTarget}/healthz`;
|
||||
const deadline = Date.now() + this.options.apiTimeoutSeconds * 1000;
|
||||
@@ -1950,6 +2164,19 @@ function buildUrl(baseUrl, path) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpacetimeProcedureUrl(serverUrl, database, procedureName) {
|
||||
if (!database) {
|
||||
throw new Error('必须提供 SpacetimeDB 数据库名');
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(serverUrl).href.replace(/\/+$/u, '');
|
||||
return `${baseUrl}/v1/database/${encodeURIComponent(database)}/call/${encodeURIComponent(procedureName)}`;
|
||||
} catch {
|
||||
throw new Error(`无法构造 SpacetimeDB procedure 地址: ${serverUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeUrlPort(rawUrl) {
|
||||
try {
|
||||
return Number(new URL(rawUrl).port);
|
||||
@@ -2015,6 +2242,171 @@ async function fetchSpacetimeIdentity(url) {
|
||||
return {identity, token};
|
||||
}
|
||||
|
||||
function readCachedSpacetimeIdentity(spacetimeDataDir, serverUrl) {
|
||||
const cachePath = resolve(spacetimeDataDir, WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(readFileSync(cachePath, 'utf8'));
|
||||
if (
|
||||
payload?.serverUrl !== serverUrl ||
|
||||
typeof payload.identity !== 'string' ||
|
||||
typeof payload.token !== 'string' ||
|
||||
!payload.identity.trim() ||
|
||||
!payload.token.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
identity: payload.identity.trim(),
|
||||
token: payload.token.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[dev:spacetime] 忽略无效本地 api-server identity 缓存: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheSpacetimeIdentity(spacetimeDataDir, serverUrl, identityPayload) {
|
||||
const cachePath = resolve(spacetimeDataDir, WEB_PROJECT_SERVICE_IDENTITY_CACHE_FILE);
|
||||
ensureParentDir(cachePath);
|
||||
writeFileSync(
|
||||
cachePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
serverUrl,
|
||||
identity: identityPayload.identity,
|
||||
token: identityPayload.token,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
async function authorizeWebProjectServiceIdentity({
|
||||
serverUrl,
|
||||
database,
|
||||
token,
|
||||
bootstrapSecret,
|
||||
serviceIdentity,
|
||||
}) {
|
||||
const url = buildSpacetimeProcedureUrl(
|
||||
serverUrl,
|
||||
database,
|
||||
'authorize_web_project_service_identity',
|
||||
);
|
||||
const input = {
|
||||
bootstrap_secret: bootstrapSecret,
|
||||
service_identity_hex: serviceIdentity,
|
||||
note: 'local api-server web project',
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([input]),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`SpacetimeDB Web Project 服务身份授权请求失败: ${url}; ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`SpacetimeDB HTTP ${response.status}: ${trimPreview(text)}`);
|
||||
}
|
||||
|
||||
const result = parseWebProjectServiceIdentityResult(text);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error_message ?? 'Web Project 服务身份授权失败');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseWebProjectServiceIdentityResult(output) {
|
||||
const candidates = [];
|
||||
const trimmed = output.trim();
|
||||
if (trimmed) {
|
||||
candidates.push(trimmed);
|
||||
}
|
||||
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
const value = line.trim();
|
||||
if (value.startsWith('{') || value.startsWith('[')) {
|
||||
candidates.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return normalizeWebProjectServiceIdentityResult(JSON.parse(candidate));
|
||||
} catch {
|
||||
// SpacetimeDB 输出可能夹带说明文本,继续尝试后续候选。
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法解析 Web Project 服务身份授权返回值: ${trimPreview(trimmed)}`);
|
||||
}
|
||||
|
||||
function normalizeWebProjectServiceIdentityResult(value) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
service_identity_hex: normalizeSatsOption(value[1]),
|
||||
error_message: normalizeSatsOption(value[2]),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Web Project 服务身份授权返回值不是合法对象。');
|
||||
}
|
||||
|
||||
function normalizeSatsValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeSatsValue(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSatsOption(value) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 2 && value[0] === 0) {
|
||||
return normalizeSatsValue(value[1]);
|
||||
}
|
||||
if (value.length === 0 || value[0] === 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeSatsValue(value);
|
||||
}
|
||||
|
||||
function shouldTrustExistingSpacetimeToken(existingToken, serverUrl) {
|
||||
const shellToken = String(process.env.GENARRATIVE_SPACETIME_TOKEN ?? '').trim();
|
||||
if (shellToken && shellToken === existingToken) {
|
||||
@@ -2156,24 +2548,45 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
|
||||
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
|
||||
GENARRATIVE_SPACETIME_DATABASE: options.database,
|
||||
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
|
||||
GENARRATIVE_WEB_PROJECT_RUNNER_BIN:
|
||||
String(baseEnv.GENARRATIVE_WEB_PROJECT_RUNNER_BIN ?? '').trim() ||
|
||||
webProjectRunnerBinPath,
|
||||
GENARRATIVE_WEB_PROJECT_PREVIEW_HOST: options.webProjectPreviewHost,
|
||||
GENARRATIVE_WEB_PROJECT_PREVIEW_PORT: String(options.webProjectPreviewPort),
|
||||
GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL:
|
||||
state.webProjectPreviewPublicBaseUrl ||
|
||||
buildWebProjectPreviewPublicBaseUrl({
|
||||
host: options.webProjectPreviewHost,
|
||||
port: options.webProjectPreviewPort,
|
||||
}),
|
||||
GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS:
|
||||
resolveWebProjectPreviewFrameAncestors({
|
||||
baseEnv,
|
||||
webOrigin: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
authorizeWebProjectServiceIdentity,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildLocalRustProcessEnv,
|
||||
buildSpacetimeProcedureUrl,
|
||||
buildSpacetimePublishArgs,
|
||||
cacheSpacetimeIdentity,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseWebProjectServiceIdentityResult,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
readCachedSpacetimeIdentity,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
};
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {join, resolve} from 'node:path';
|
||||
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
authorizeWebProjectServiceIdentity,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildLocalRustProcessEnv,
|
||||
buildSpacetimeProcedureUrl,
|
||||
buildSpacetimePublishArgs,
|
||||
cacheSpacetimeIdentity,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseWebProjectServiceIdentityResult,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
readCachedSpacetimeIdentity,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
@@ -109,7 +114,7 @@ describe('dev scheduler argument routing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => {
|
||||
linuxTest('Linux 启动时按系统级端口段映射 dev 端口和预览网关端口', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
|
||||
try {
|
||||
const {command, explicitOptions, options} = parseArgs([], {
|
||||
@@ -132,8 +137,10 @@ describe('dev scheduler argument routing', () => {
|
||||
expect(runner.options.apiPort).toBe(22001);
|
||||
expect(runner.options.spacetimePort).toBe(22002);
|
||||
expect(runner.options.adminWebPort).toBe(22003);
|
||||
expect(runner.options.webProjectPreviewPort).toBe(22004);
|
||||
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
|
||||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
|
||||
expect(runner.state.webProjectPreviewPublicBaseUrl).toBe('http://127.0.0.1:22004');
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
@@ -163,6 +170,7 @@ describe('dev scheduler argument routing', () => {
|
||||
expect(runner.options.apiPort).toBe(8082);
|
||||
expect(runner.options.spacetimePort).toBe(3101);
|
||||
expect(runner.options.adminWebPort).toBe(3102);
|
||||
expect(runner.options.webProjectPreviewPort).toBe(3104);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
@@ -183,6 +191,42 @@ describe('dev scheduler api-server env', () => {
|
||||
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');
|
||||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST).toBe('127.0.0.1');
|
||||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT).toBe('3104');
|
||||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL).toBe('http://127.0.0.1:3104');
|
||||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS).toBe(
|
||||
'http://127.0.0.1:3000 http://localhost:3000',
|
||||
);
|
||||
});
|
||||
|
||||
test('dev 脚本默认注入 Web Project runner 二进制路径', () => {
|
||||
const {options} = parseArgs(['api-server'], {});
|
||||
const env = buildApiServerProcessEnv({
|
||||
baseEnv: {},
|
||||
options,
|
||||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||||
});
|
||||
const runnerName =
|
||||
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner';
|
||||
|
||||
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
|
||||
resolve('server-rs', 'target/debug', runnerName),
|
||||
);
|
||||
});
|
||||
|
||||
test('dev 脚本保留显式 Web Project runner 二进制路径', () => {
|
||||
const {options} = parseArgs(['api-server'], {});
|
||||
const env = buildApiServerProcessEnv({
|
||||
baseEnv: {
|
||||
GENARRATIVE_WEB_PROJECT_RUNNER_BIN: 'C:\\tools\\web-project-runner.exe',
|
||||
},
|
||||
options,
|
||||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||||
});
|
||||
|
||||
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
|
||||
'C:\\tools\\web-project-runner.exe',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +243,7 @@ describe('dev scheduler Rust build env', () => {
|
||||
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
|
||||
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
|
||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
||||
expect(env.RUSTC_WRAPPER).toBe(process.platform === 'win32' ? '' : '/usr/bin/env');
|
||||
});
|
||||
|
||||
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
|
||||
@@ -233,6 +278,8 @@ describe('dev scheduler stack state file', () => {
|
||||
webPort: 3010,
|
||||
adminWebHost: '127.0.0.1',
|
||||
adminWebPort: 3110,
|
||||
webProjectPreviewHost: '127.0.0.1',
|
||||
webProjectPreviewPort: 3112,
|
||||
spacetimeHost: '127.0.0.1',
|
||||
spacetimePort: 3120,
|
||||
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
|
||||
@@ -243,6 +290,7 @@ describe('dev scheduler stack state file', () => {
|
||||
apiTarget: 'http://127.0.0.1:8090',
|
||||
adminWebTargetHost: '127.0.0.1',
|
||||
spacetimeServer: 'http://127.0.0.1:3120',
|
||||
webProjectPreviewPublicBaseUrl: 'http://127.0.0.1:3112',
|
||||
},
|
||||
services: new Map([
|
||||
[
|
||||
@@ -285,6 +333,14 @@ describe('dev scheduler stack state file', () => {
|
||||
port: 8090,
|
||||
url: 'http://127.0.0.1:8090',
|
||||
});
|
||||
expect(snapshot.services['web-project-preview']).toMatchObject({
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
host: '127.0.0.1',
|
||||
port: 3112,
|
||||
url: 'http://127.0.0.1:3112',
|
||||
command: 'api-server embedded Web Project preview gateway',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -500,6 +556,85 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
|
||||
);
|
||||
});
|
||||
|
||||
test('发布 spacetime-module 时准备 Web Project 服务身份引导密钥', () => {
|
||||
const {explicitOptions, options} = parseArgs([], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
const env = {};
|
||||
|
||||
runner.prepareWebProjectServiceBootstrapSecret(env);
|
||||
|
||||
expect(env.GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET).toHaveLength(64);
|
||||
expect(runner.state.webProjectServiceBootstrapSecretPrepared).toBe(true);
|
||||
});
|
||||
|
||||
test('Web Project 服务身份授权使用本地 Web token 调用 procedure', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
service_identity_hex: 'c200localidentity',
|
||||
error_message: null,
|
||||
}),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await authorizeWebProjectServiceIdentity({
|
||||
serverUrl: 'http://127.0.0.1:3101',
|
||||
database: 'genarrative-dev',
|
||||
token: 'local-web-token',
|
||||
bootstrapSecret: '0123456789abcdef',
|
||||
serviceIdentity: 'c200localidentity',
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
buildSpacetimeProcedureUrl(
|
||||
'http://127.0.0.1:3101',
|
||||
'genarrative-dev',
|
||||
'authorize_web_project_service_identity',
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer local-web-token',
|
||||
}),
|
||||
body: JSON.stringify([
|
||||
{
|
||||
bootstrap_secret: '0123456789abcdef',
|
||||
service_identity_hex: 'c200localidentity',
|
||||
note: 'local api-server web project',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Web Project 服务身份授权返回值兼容 BSATN 数组形态', () => {
|
||||
expect(parseWebProjectServiceIdentityResult('[true,[0,"c200localidentity"],[1]]')).toEqual({
|
||||
ok: true,
|
||||
service_identity_hex: 'c200localidentity',
|
||||
error_message: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('本地 api-server SpacetimeDB identity 写入并复用 data-dir 缓存', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||||
try {
|
||||
cacheSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101', {
|
||||
identity: 'c200cachedidentity',
|
||||
token: 'cached-token',
|
||||
});
|
||||
|
||||
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
|
||||
identity: 'c200cachedidentity',
|
||||
token: 'cached-token',
|
||||
});
|
||||
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3199')).toBeNull();
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
|
||||
const {explicitOptions, options} = parseArgs([], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
@@ -533,39 +668,55 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
|
||||
});
|
||||
|
||||
test('启动 api-server 前为空 token 自动创建本地 Web identity', async () => {
|
||||
const {explicitOptions, options} = parseArgs([], {
|
||||
GENARRATIVE_SPACETIME_TOKEN: '',
|
||||
});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
identity: 'c200localidentity',
|
||||
token: 'local-web-token',
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||||
try {
|
||||
const {explicitOptions, options} = parseArgs(
|
||||
['--spacetime-data-dir', tempDir],
|
||||
{
|
||||
GENARRATIVE_SPACETIME_TOKEN: '',
|
||||
},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
identity: 'c200localidentity',
|
||||
token: 'local-web-token',
|
||||
}),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await runner.ensureApiServerSpacetimeToken();
|
||||
|
||||
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
|
||||
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
|
||||
identity: 'c200localidentity',
|
||||
token: 'local-web-token',
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:3101/v1/identity',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await runner.ensureApiServerSpacetimeToken();
|
||||
|
||||
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:3101/v1/identity',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test('本地 SpacetimeDB 不信任 env 文件中的陈旧 token', async () => {
|
||||
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||||
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
|
||||
try {
|
||||
const {explicitOptions, options} = parseArgs([], {
|
||||
GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token',
|
||||
});
|
||||
const {explicitOptions, options} = parseArgs(
|
||||
['--spacetime-data-dir', tempDir],
|
||||
{
|
||||
GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token',
|
||||
},
|
||||
);
|
||||
const runner = new DevRunner(options, {GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token'}, explicitOptions);
|
||||
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
@@ -582,6 +733,7 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
|
||||
|
||||
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('fresh-web-token');
|
||||
} finally {
|
||||
rmSync(tempDir, {recursive: true, force: true});
|
||||
if (originalToken === undefined) {
|
||||
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user