Standardize local development profiles

This commit is contained in:
tianma-if committed 2026-08-10 23:18:15 +08:00
1 parent 6bc240c393
commit c737544593
11 files changed
+593 -3

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Optional bootstrap profile for the persistent local development instance.
# Copy this file to .env.local-dev and keep the real API key local. When the
# local D1 has no AI providers, `bun run dev` creates this provider and model.
EDGE_EVER_LOCAL_AI_PROVIDER=openai-compatible
EDGE_EVER_LOCAL_AI_DISPLAY_NAME=OpenAI-compatible
EDGE_EVER_LOCAL_AI_BASE_URL=https://openrouter.ai/api/v1
EDGE_EVER_LOCAL_AI_API_KEY=
EDGE_EVER_LOCAL_AI_MODEL_ID=gpt-4.1-mini
+1
View File
@@ -4,6 +4,7 @@
!.env.example
!.env.local.example
!.env.android.local.example
!.env.local-dev.example
node_modules/
.idea/
+23
View File
@@ -138,6 +138,29 @@ bun install
bun run dev
```
The local runner exposes two isolated profiles backed by the same application
code. `bun run dev` starts the persistent `LOCAL DEV` profile, always uses
`.wrangler/state`, applies pending D1 migrations before startup, and preserves
local notes and AI settings across restarts. `bun run dev:demo` starts the
resettable `LOCAL DEMO` profile in `.wrangler/demo-state`. Browser titles and
corner badges identify the active profile; neither profile shares data with a
configured remote instance.
Optional local AI bootstrap:
```sh
cp .env.local-dev.example .env.local-dev
# Fill EDGE_EVER_LOCAL_AI_API_KEY, then restart bun run dev.
```
The bootstrap profile is gitignored and is used only when the local D1 has no
AI provider. Existing local providers are never overwritten. Run
`bun run dev:doctor` to inspect Wrangler, D1 migrations, persistence, health,
and AI bootstrap status without displaying secrets. Run
`bun run dev:demo:reset` to restore the Demo profile without touching the
persistent development database. Remote development remains explicit:
`EDGE_EVER_INSTANCE=<name> bun run dev:remote`.
## Project Structure
```text
+11
View File
@@ -148,6 +148,17 @@ bun install
bun run dev
```
本地启动器基于同一套应用代码提供两个数据隔离的 Profile。`bun run dev` 启动持久化的 `LOCAL DEV`:固定使用 `.wrangler/state`,启动前自动应用待执行的 D1 Migration,并在重启后保留本地笔记和 AI 设置。`bun run dev:demo` 启动可重置的 `LOCAL DEMO`,数据独立存放在 `.wrangler/demo-state`。浏览器标题与右下角标识会显示当前 Profile;两者都不会与已配置的远程实例共享数据。
可选的本地 AI 自动引导:
```sh
cp .env.local-dev.example .env.local-dev
# 填写 EDGE_EVER_LOCAL_AI_API_KEY,然后重新运行 bun run dev。
```
引导文件已被 Git 忽略,仅当 `LOCAL DEV` 的 D1 没有任何 AI 服务时才会创建配置,绝不会覆盖已有服务。运行 `bun run dev:doctor` 可同时检查两个 Profile 的 Wrangler、D1 Migration、健康状态和 AI 引导状态,且不会显示 Secret。运行 `bun run dev:demo:reset` 可恢复 Demo 数据,不会修改日常开发数据库。远程开发仍须显式指定:`EDGE_EVER_INSTANCE=<实例名> bun run dev:remote`。
## 目录结构
```text
+6
View File
@@ -12,6 +12,12 @@ import "./styles/globals.css";
const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000;
const DEVELOPMENT_PWA_RELOAD_KEY = "edgeever.dev-pwa-reset";
if (import.meta.env.DEV && __EDGEEVER_DEVELOPMENT_PROFILE__) {
document.documentElement.dataset.edgeeverEnvironment = __EDGEEVER_DEVELOPMENT_PROFILE__;
const environmentLabel = __EDGEEVER_DEVELOPMENT_PROFILE__ === "demo" ? "LOCAL DEMO" : "LOCAL";
document.title = `[${environmentLabel}] ${document.title}`;
}
const clearDevelopmentPwaState = async () => {
if (!("serviceWorker" in navigator)) {
return false;
+28
View File
@@ -5,6 +5,34 @@
@tailwind components;
@tailwind utilities;
html[data-edgeever-environment="local"] body::after,
html[data-edgeever-environment="demo"] body::after {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 2147483647;
border-radius: 9999px;
padding: 4px 9px;
font: 600 10px/1.2 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
letter-spacing: 0.08em;
pointer-events: none;
box-shadow: 0 2px 8px rgb(15 23 42 / 10%);
}
html[data-edgeever-environment="local"] body::after {
content: "LOCAL DEV";
border: 1px solid rgb(16 185 129 / 45%);
background: rgb(236 253 245 / 94%);
color: rgb(4 120 87);
}
html[data-edgeever-environment="demo"] body::after {
content: "LOCAL DEMO";
border: 1px solid rgb(245 158 11 / 50%);
background: rgb(255 251 235 / 96%);
color: rgb(180 83 9);
}
.ProseMirror .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 1rem 0;
+1
View File
@@ -7,6 +7,7 @@ declare const __EDGEEVER_APP_VERSION__: string;
declare const __EDGEEVER_RELEASED_AT__: string;
declare const __EDGEEVER_DEPLOYMENT_TRIGGER__: string;
declare const __EDGEEVER_DEPLOYMENT_METHOD__: string;
declare const __EDGEEVER_DEVELOPMENT_PROFILE__: "" | "local" | "demo";
interface EdgeEverDesktopBridge {
isAvailable: boolean;
+1
View File
@@ -115,6 +115,7 @@ export default defineConfig({
__EDGEEVER_RELEASED_AT__: JSON.stringify(releaseTimestamp),
__EDGEEVER_DEPLOYMENT_TRIGGER__: JSON.stringify(deploymentTrigger),
__EDGEEVER_DEPLOYMENT_METHOD__: JSON.stringify(deploymentMethod),
__EDGEEVER_DEVELOPMENT_PROFILE__: JSON.stringify(process.env.EDGE_EVER_DEVELOPMENT_PROFILE ?? ""),
},
plugins: [
developmentServiceWorkerReset,
+11 -3
View File
@@ -10,8 +10,15 @@
"scripts": {
"dev": "bun run dev:local",
"dev:api": "bun run dev:api:local",
"dev:local": "bun run db:migrate:local && concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:local\" \"bun run dev:web\"",
"dev:api:local": "EDGE_EVER_INSTANCE= EDGE_EVER_LOCAL_DEMO_SEED=true bun scripts/run-wrangler.mjs dev --local --port 8787",
"dev:local": "bun scripts/local-dev.mjs start local",
"dev:prepare": "bun scripts/local-dev.mjs prepare local",
"dev:ready": "bun scripts/local-dev.mjs ready local",
"dev:doctor": "bun scripts/local-dev.mjs doctor",
"test:local-dev": "bun test scripts/local-dev.test.mjs",
"dev:api:local": "bun scripts/local-dev.mjs api local",
"dev:demo": "bun scripts/local-dev.mjs start demo",
"dev:demo:reset": "bun scripts/local-dev.mjs reset demo",
"dev:api:demo": "bun scripts/local-dev.mjs api demo",
"dev:self-hosted": "bun --watch scripts/self-hosted-server.mjs",
"start:self-hosted": "bun scripts/self-hosted-server.mjs",
"dev:remote": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:remote\" \"bun run dev:web\"",
@@ -60,7 +67,8 @@
"typecheck:ios-editor": "bun run --cwd apps/ios/EditorSource typecheck",
"test:restore-deleted-notebook": "bun scripts/test-restore-deleted-notebook.mjs",
"test:mcp": "bun scripts/test-mcp.mjs",
"db:migrate:local": "EDGE_EVER_INSTANCE= bun scripts/run-wrangler.mjs d1 migrations apply DB --local",
"db:migrate:local": "bun scripts/local-dev.mjs prepare local",
"db:migrate:demo": "bun scripts/local-dev.mjs prepare demo",
"db:migrate:remote": "bun scripts/run-wrangler.mjs d1 migrations apply DB --remote",
"auth:hash": "bun scripts/hash-password.mjs",
"auth:reset-password": "bun scripts/reset-password.mjs",
+388
View File
@@ -0,0 +1,388 @@
import { Database } from "bun:sqlite";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
export const LOCAL_DEV_API_URL = "http://127.0.0.1:8787";
export const LOCAL_DEV_WEB_URL = "http://127.0.0.1:5173";
export const LOCAL_DEV_STATE_PATH = ".wrangler/state";
export const LOCAL_DEV_PROFILE_PATH = ".env.local-dev";
export const LOCAL_DEVELOPMENT_PROFILES = Object.freeze({
local: Object.freeze({
name: "local",
label: "LOCAL DEV",
statePath: LOCAL_DEV_STATE_PATH,
demoSeed: false,
bootstrapAi: true,
}),
demo: Object.freeze({
name: "demo",
label: "LOCAL DEMO",
statePath: ".wrangler/demo-state",
demoSeed: true,
bootstrapAi: false,
}),
});
export const getLocalDevelopmentProfile = (name = "local") => {
const profile = LOCAL_DEVELOPMENT_PROFILES[name];
if (!profile) {
throw new Error(`Unknown local development profile: ${name}. Expected local or demo.`);
}
return profile;
};
const AI_PROVIDERS = new Set([
"openai",
"openrouter",
"anthropic",
"google",
"openai-compatible",
]);
export const parseEnvironmentFile = (source) => {
const values = {};
for (const line of source.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) continue;
const key = trimmed.slice(0, separator).trim();
let value = trimmed.slice(separator + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) {
try {
value = JSON.parse(value);
} catch {
value = value.slice(1, -1);
}
} else if (value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
values[key] = value.replace(/\\\$/g, "$");
}
return values;
};
export const buildLocalAiSeed = (environment) => {
const apiKey = environment.EDGE_EVER_LOCAL_AI_API_KEY?.trim();
if (!apiKey) return null;
const provider = environment.EDGE_EVER_LOCAL_AI_PROVIDER?.trim() || "openai-compatible";
const baseUrl = environment.EDGE_EVER_LOCAL_AI_BASE_URL?.trim();
const modelId = environment.EDGE_EVER_LOCAL_AI_MODEL_ID?.trim();
if (!AI_PROVIDERS.has(provider)) {
throw new Error(`Unsupported EDGE_EVER_LOCAL_AI_PROVIDER: ${provider}`);
}
if (!baseUrl || !modelId) {
throw new Error("Local AI bootstrap requires EDGE_EVER_LOCAL_AI_BASE_URL and EDGE_EVER_LOCAL_AI_MODEL_ID.");
}
const parsedBaseUrl = new URL(baseUrl);
if (!new Set(["http:", "https:"]).has(parsedBaseUrl.protocol)) {
throw new Error("EDGE_EVER_LOCAL_AI_BASE_URL must use HTTP(S).");
}
return {
provider,
displayName: environment.EDGE_EVER_LOCAL_AI_DISPLAY_NAME?.trim() || provider,
baseUrl: baseUrl.replace(/\/+$/, ""),
apiKey,
isEnabled: true,
initialModelId: modelId,
};
};
export const findLocalD1DatabasePaths = (statePath = LOCAL_DEV_STATE_PATH) => {
const directory = resolve(statePath, "v3/d1/miniflare-D1DatabaseObject");
if (!existsSync(directory)) return [];
return Array.from(new Bun.Glob("*.sqlite").scanSync({ cwd: directory, absolute: true }))
.filter((path) => !path.endsWith("/metadata.sqlite"))
.sort();
};
export const inspectLocalD1 = (statePath = LOCAL_DEV_STATE_PATH) => {
const paths = findLocalD1DatabasePaths(statePath);
if (paths.length !== 1) {
return { databaseCount: paths.length, migrationCount: null, providerCount: null, modelCount: null };
}
// Miniflare keeps D1 in WAL mode. SQLite may need to materialize empty
// -wal/-shm sidecars even for inspection after a clean server shutdown.
const database = new Database(paths[0], { readwrite: true });
try {
const tableNames = new Set(
database.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name),
);
const count = (table) => tableNames.has(table)
? Number(database.query(`SELECT COUNT(*) AS count FROM ${table}`).get()?.count ?? 0)
: null;
return {
databaseCount: 1,
migrationCount: count("d1_migrations"),
providerCount: count("ai_provider_configs"),
modelCount: count("ai_models"),
};
} finally {
database.close();
}
};
const loadBootstrapProfile = (path = LOCAL_DEV_PROFILE_PATH) => {
if (!existsSync(path)) return { exists: false, seed: null };
return {
exists: true,
seed: buildLocalAiSeed(parseEnvironmentFile(readFileSync(path, "utf8"))),
};
};
const assertChildSucceeded = (result) => {
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
};
const runLocalMigrations = (profile) => {
mkdirSync(resolve(profile.statePath), { recursive: true });
const result = spawnSync(
process.execPath,
[
"scripts/run-wrangler.mjs",
"d1",
"migrations",
"apply",
"DB",
"--local",
"--persist-to",
profile.statePath,
],
{
cwd: resolve("."),
env: { ...process.env, EDGE_EVER_INSTANCE: "" },
stdio: "inherit",
},
);
assertChildSucceeded(result);
};
const runProfileApi = (profile) => {
const result = spawnSync(
process.execPath,
[
"scripts/run-wrangler.mjs",
"dev",
"--local",
"--persist-to",
profile.statePath,
"--port",
"8787",
],
{
cwd: resolve("."),
env: {
...process.env,
EDGE_EVER_INSTANCE: "",
EDGE_EVER_LOCAL_DEMO_SEED: String(profile.demoSeed),
},
stdio: "inherit",
},
);
assertChildSucceeded(result);
};
const runProfileWeb = (profile) => {
const result = spawnSync(process.execPath, ["run", "dev:web"], {
cwd: resolve("."),
env: {
...process.env,
EDGE_EVER_DEVELOPMENT_PROFILE: profile.name,
},
stdio: "inherit",
});
assertChildSucceeded(result);
};
const startProfile = (profile) => {
runLocalMigrations(profile);
console.log(`[local-dev] ${profile.label} migrations ready: ${resolve(profile.statePath)}`);
const result = spawnSync(
process.execPath,
[
"x",
"concurrently",
"--kill-others-on-fail",
"-n",
"api,web,ready",
"-c",
"cyan,magenta,green",
`bun scripts/local-dev.mjs api ${profile.name}`,
`bun scripts/local-dev.mjs web ${profile.name}`,
`bun scripts/local-dev.mjs ready ${profile.name}`,
],
{ cwd: resolve("."), env: process.env, stdio: "inherit" },
);
assertChildSucceeded(result);
};
const waitForLocalApi = async (timeoutMs = 30_000) => {
const deadline = Date.now() + timeoutMs;
let lastError;
while (Date.now() < deadline) {
try {
const response = await fetch(`${LOCAL_DEV_API_URL}/api/health`, {
signal: AbortSignal.timeout(1_500),
});
if (response.ok) return await response.json();
lastError = new Error(`health returned HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await Bun.sleep(500);
}
throw new Error(`Local API did not become healthy within ${timeoutMs / 1_000}s: ${lastError?.message ?? "unknown error"}`);
};
const ensureLocalAiSeed = async () => {
const settingsResponse = await fetch(`${LOCAL_DEV_API_URL}/api/v1/ai/settings`);
if (!settingsResponse.ok) {
throw new Error(`Cannot read local AI settings: HTTP ${settingsResponse.status}`);
}
const settings = await settingsResponse.json();
if (settings.providers.length > 0) {
return { state: "preserved", providerCount: settings.providers.length };
}
const profile = loadBootstrapProfile();
if (!profile.seed) {
return { state: profile.exists ? "profile-incomplete" : "profile-missing", providerCount: 0 };
}
const createResponse = await fetch(`${LOCAL_DEV_API_URL}/api/v1/ai/providers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile.seed),
});
if (!createResponse.ok) {
const body = await createResponse.json().catch(() => null);
throw new Error(body?.error?.message || `Local AI bootstrap failed: HTTP ${createResponse.status}`);
}
const created = await createResponse.json();
return { state: "seeded", providerCount: created.providers.length };
};
export const buildReadyBanner = (profile, ai = null) => {
const aiLabel = ai
? {
preserved: `preserved ${ai.providerCount} existing provider(s)`,
seeded: `restored ${ai.providerCount} provider(s) from ${LOCAL_DEV_PROFILE_PATH}`,
"profile-incomplete": `${LOCAL_DEV_PROFILE_PATH} has no API key; skipped bootstrap`,
"profile-missing": `optional ${LOCAL_DEV_PROFILE_PATH} not found`,
}[ai.state]
: null;
return [
"",
`[local-dev] ${profile.label} ready`,
`[local-dev] Web: ${LOCAL_DEV_WEB_URL}`,
`[local-dev] API: ${LOCAL_DEV_API_URL}`,
`[local-dev] Data: ${resolve(profile.statePath)} (${profile.demoSeed ? "resettable demo data" : "persistent development data"})`,
...(aiLabel ? [`[local-dev] AI: ${aiLabel}`] : []),
"",
].join("\n");
};
const printReadyBanner = (profile, ai = null) => {
console.log(buildReadyBanner(profile, ai));
};
const resetDemoProfile = async (profile) => {
let apiHealthy = false;
try {
const healthResponse = await fetch(`${LOCAL_DEV_API_URL}/api/health`, {
signal: AbortSignal.timeout(1_000),
});
apiHealthy = healthResponse.ok;
} catch {}
if (apiHealthy) {
const resetResponse = await fetch(`${LOCAL_DEV_API_URL}/api/v1/demo/reset`, { method: "POST" });
if (resetResponse.ok) {
console.log(`[local-dev] ${profile.label} reset through the running API.`);
return;
}
const body = await resetResponse.json().catch(() => null);
const details = body?.error?.message || `HTTP ${resetResponse.status}`;
throw new Error(`Demo reset failed while an API is running: ${details}. Stop it and retry.`);
}
const target = resolve(profile.statePath);
const wranglerRoot = resolve(".wrangler");
if (profile.name !== "demo" || !target.startsWith(`${wranglerRoot}${sep}`)) {
throw new Error(`Refusing to reset unsafe profile path: ${target}`);
}
rmSync(target, { recursive: true, force: true });
runLocalMigrations(profile);
console.log(`[local-dev] ${profile.label} state reset: ${target}`);
};
const doctor = async () => {
const wranglerPath = resolve("node_modules/wrangler/package.json");
const wranglerVersion = existsSync(wranglerPath)
? JSON.parse(readFileSync(wranglerPath, "utf8")).version
: null;
const profile = loadBootstrapProfile();
const localDatabase = inspectLocalD1(LOCAL_DEVELOPMENT_PROFILES.local.statePath);
const demoDatabase = inspectLocalD1(LOCAL_DEVELOPMENT_PROFILES.demo.statePath);
let health = "offline";
try {
const response = await fetch(`${LOCAL_DEV_API_URL}/api/health`, {
signal: AbortSignal.timeout(1_000),
});
health = response.ok ? "healthy" : `HTTP ${response.status}`;
} catch {}
console.log([
"EdgeEver local development doctor",
`- Wrangler: ${wranglerVersion ? `v${wranglerVersion}` : "missing (run bun install)"}`,
`- Web: ${LOCAL_DEV_WEB_URL}`,
`- API: ${LOCAL_DEV_API_URL} (${health})`,
`- LOCAL DEV data: ${resolve(LOCAL_DEVELOPMENT_PROFILES.local.statePath)}`,
` D1 databases/migrations: ${localDatabase.databaseCount}/${localDatabase.migrationCount ?? "not initialized"}`,
` AI providers/models: ${localDatabase.providerCount ?? 0}/${localDatabase.modelCount ?? 0}`,
`- Bootstrap profile: ${profile.exists ? (profile.seed ? "configured" : "present without API key") : "optional; not created"}`,
`- LOCAL DEMO data: ${resolve(LOCAL_DEVELOPMENT_PROFILES.demo.statePath)}`,
` D1 databases/migrations: ${demoDatabase.databaseCount}/${demoDatabase.migrationCount ?? "not initialized"}`,
].join("\n"));
if (!wranglerVersion) process.exitCode = 1;
};
const main = async () => {
const command = process.argv[2] ?? "doctor";
const profileName = process.argv[3] ?? "local";
const profile = command === "doctor" ? null : getLocalDevelopmentProfile(profileName);
if (command === "prepare") {
runLocalMigrations(profile);
console.log(`[local-dev] ${profile.label} migrations ready: ${resolve(profile.statePath)}`);
} else if (command === "start") {
startProfile(profile);
} else if (command === "api") {
runProfileApi(profile);
} else if (command === "web") {
runProfileWeb(profile);
} else if (command === "ready") {
await waitForLocalApi();
printReadyBanner(profile, profile.bootstrapAi ? await ensureLocalAiSeed() : null);
} else if (command === "reset") {
if (profile.name !== "demo") throw new Error("Only the demo profile is resettable.");
await resetDemoProfile(profile);
} else if (command === "doctor") {
await doctor();
} else {
console.error("Usage: bun scripts/local-dev.mjs <start|prepare|api|web|ready|reset|doctor> [local|demo]");
process.exit(1);
}
};
if (import.meta.main) {
main().catch((error) => {
console.error(`[local-dev] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildLocalAiSeed,
buildReadyBanner,
getLocalDevelopmentProfile,
inspectLocalD1,
parseEnvironmentFile,
} from "./local-dev.mjs";
describe("local development environment", () => {
test("keeps persistent development and resettable demo profiles isolated", () => {
expect(getLocalDevelopmentProfile("local")).toMatchObject({
statePath: ".wrangler/state",
demoSeed: false,
bootstrapAi: true,
});
expect(getLocalDevelopmentProfile("demo")).toMatchObject({
statePath: ".wrangler/demo-state",
demoSeed: true,
bootstrapAi: false,
});
expect(getLocalDevelopmentProfile("local").statePath).not.toBe(
getLocalDevelopmentProfile("demo").statePath,
);
});
test("rejects unknown local development profiles", () => {
expect(() => getLocalDevelopmentProfile("remote")).toThrow(
"Unknown local development profile",
);
});
test("renders a Demo banner without requiring an AI bootstrap result", () => {
const banner = buildReadyBanner(getLocalDevelopmentProfile("demo"));
expect(banner).toContain("LOCAL DEMO ready");
expect(banner).toContain("resettable demo data");
expect(banner).not.toContain("[local-dev] AI:");
});
test("inspects a stopped WAL-mode D1 database", () => {
const statePath = mkdtempSync(join(tmpdir(), "edgeever-local-dev-"));
const directory = join(statePath, "v3/d1/miniflare-D1DatabaseObject");
mkdirSync(directory, { recursive: true });
const database = new Database(join(directory, "test.sqlite"), { create: true });
database.exec("PRAGMA journal_mode = WAL");
database.exec("CREATE TABLE d1_migrations (id INTEGER PRIMARY KEY)");
database.exec("CREATE TABLE ai_provider_configs (id TEXT PRIMARY KEY)");
database.exec("CREATE TABLE ai_models (id TEXT PRIMARY KEY)");
database.exec("INSERT INTO d1_migrations (id) VALUES (1)");
database.close();
try {
expect(inspectLocalD1(statePath)).toEqual({
databaseCount: 1,
migrationCount: 1,
providerCount: 0,
modelCount: 0,
});
} finally {
rmSync(statePath, { recursive: true, force: true });
}
});
test("parses local-only environment files without exposing comments", () => {
expect(parseEnvironmentFile([
"# ignored",
"EDGE_EVER_LOCAL_AI_API_KEY='local-key'",
"EDGE_EVER_LOCAL_AI_MODEL_ID=gpt-4.1-mini",
"",
].join("\n"))).toEqual({
EDGE_EVER_LOCAL_AI_API_KEY: "local-key",
EDGE_EVER_LOCAL_AI_MODEL_ID: "gpt-4.1-mini",
});
expect(parseEnvironmentFile('EDGE_EVER_LOCAL_AI_API_KEY="key\\nwith-escape"')).toEqual({
EDGE_EVER_LOCAL_AI_API_KEY: "key\nwith-escape",
});
});
test("skips AI bootstrap until a local API key is configured", () => {
expect(buildLocalAiSeed({ EDGE_EVER_LOCAL_AI_API_KEY: "" })).toBeNull();
});
test("builds one deterministic local AI provider seed", () => {
expect(buildLocalAiSeed({
EDGE_EVER_LOCAL_AI_API_KEY: "local-key",
EDGE_EVER_LOCAL_AI_BASE_URL: "https://openrouter.ai/api/v1/",
EDGE_EVER_LOCAL_AI_MODEL_ID: "gpt-4.1-mini",
})).toEqual({
provider: "openai-compatible",
displayName: "openai-compatible",
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "local-key",
isEnabled: true,
initialModelId: "gpt-4.1-mini",
});
});
test("rejects invalid provider and base URL values", () => {
expect(() => buildLocalAiSeed({
EDGE_EVER_LOCAL_AI_API_KEY: "local-key",
EDGE_EVER_LOCAL_AI_PROVIDER: "unknown",
EDGE_EVER_LOCAL_AI_BASE_URL: "https://example.com",
EDGE_EVER_LOCAL_AI_MODEL_ID: "model",
})).toThrow("Unsupported EDGE_EVER_LOCAL_AI_PROVIDER");
expect(() => buildLocalAiSeed({
EDGE_EVER_LOCAL_AI_API_KEY: "local-key",
EDGE_EVER_LOCAL_AI_BASE_URL: "file:///tmp/model",
EDGE_EVER_LOCAL_AI_MODEL_ID: "model",
})).toThrow("must use HTTP(S)");
});
});