Standardize local development profiles
This commit is contained in:
1 parent
6bc240c393
commit
c737544593
11 files changed
+593
-3
No files matched your search
@@ -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
|
||||
@@ -4,6 +4,7 @@
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
!.env.android.local.example
|
||||
!.env.local-dev.example
|
||||
|
||||
node_modules/
|
||||
.idea/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user