From c737544593fc8036638f9374c97443d9367c422f Mon Sep 17 00:00:00 2001 From: tianma-if Date: Mon, 10 Aug 2026 23:18:15 +0800 Subject: [PATCH] Standardize local development profiles --- .env.local-dev.example | 8 + .gitignore | 1 + README.md | 23 ++ README.zh-CN.md | 11 + apps/web/src/main.tsx | 6 + apps/web/src/styles/globals.css | 28 +++ apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 1 + package.json | 14 +- scripts/local-dev.mjs | 388 ++++++++++++++++++++++++++++++++ scripts/local-dev.test.mjs | 115 ++++++++++ 11 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 .env.local-dev.example create mode 100644 scripts/local-dev.mjs create mode 100644 scripts/local-dev.test.mjs diff --git a/.env.local-dev.example b/.env.local-dev.example new file mode 100644 index 00000000..9e7bd0f1 --- /dev/null +++ b/.env.local-dev.example @@ -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 diff --git a/.gitignore b/.gitignore index acd81701..d484643e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ !.env.example !.env.local.example !.env.android.local.example +!.env.local-dev.example node_modules/ .idea/ diff --git a/README.md b/README.md index 6e8948e4..268a12c1 100644 --- a/README.md +++ b/README.md @@ -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= bun run dev:remote`. + ## Project Structure ```text diff --git a/README.zh-CN.md b/README.zh-CN.md index bd40658c..da3ea56a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e481c7d9..7c730d13 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 185d3d61..9e8726f7 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 5d0b423c..ceb0d9af 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -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; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0e60ed0b..7ee1dab1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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, diff --git a/package.json b/package.json index 48bb797a..de83626d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/local-dev.mjs b/scripts/local-dev.mjs new file mode 100644 index 00000000..1f2dde5e --- /dev/null +++ b/scripts/local-dev.mjs @@ -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 [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); + }); +} diff --git a/scripts/local-dev.test.mjs b/scripts/local-dev.test.mjs new file mode 100644 index 00000000..28b0f590 --- /dev/null +++ b/scripts/local-dev.test.mjs @@ -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)"); + }); +});