From f69e5b671b804955cc166b1c9907a596ee70fe8e Mon Sep 17 00:00:00 2001 From: francy51 Date: Fri, 6 Mar 2026 21:38:28 -0500 Subject: [PATCH] Add local dev bootstrap command --- .env.example | 10 ++- README.md | 23 ++++-- package.json | 3 +- scripts/dev-env.test.ts | 68 ++++++++++++++++ scripts/dev-env.ts | 153 ++++++++++++++++++++++++++++++++++++ scripts/dev.ts | 170 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 416 insertions(+), 11 deletions(-) create mode 100644 scripts/dev-env.test.ts create mode 100644 scripts/dev-env.ts create mode 100644 scripts/dev.ts diff --git a/.env.example b/.env.example index 2ec2508..0409354 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,8 @@ APP_PORT=3000 # In Docker Compose deployment, default path is /app/data/fiscal.sqlite DATABASE_URL=file:data/fiscal.sqlite BETTER_AUTH_SECRET=replace-with-a-long-random-secret -BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz -BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz +BETTER_AUTH_BASE_URL=http://localhost:3000 +BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 # AI SDK (Vercel) + Zhipu provider # Legacy OPENCLAW_* variables are removed and no longer read by the app. @@ -22,8 +22,10 @@ AI_TEMPERATURE=0.2 # SEC API etiquette SEC_USER_AGENT=Fiscal Clone -# Workflow runtime (Coolify / production) -WORKFLOW_TARGET_WORLD=@workflow/world-postgres +# Workflow runtime (local dev default) +WORKFLOW_TARGET_WORLD=local + +# Workflow runtime (Docker / production) WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10 WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_ diff --git a/README.md b/README.md index 4255f31..a6c622e 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,21 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with Vercel AI SDK integra ```bash bun install -bun run db:generate -bun run db:migrate +cp .env.example .env bun run dev ``` Open [http://localhost:3000](http://localhost:3000). -The default database path is `data/fiscal.sqlite` via `DATABASE_URL=file:data/fiscal.sqlite`. +`bun run dev` is the local-safe entrypoint. It bootstraps the local SQLite schema from `drizzle/` when needed, forces Better Auth to a localhost origin, uses same-origin API calls, and falls back to local SQLite + Workflow local runtime even if `.env` still contains deployment-oriented values. If port `3000` is already in use and you did not set `PORT`, it automatically picks the next open local port and keeps Better Auth in sync with that port. + +If you need raw `next dev` behavior without those overrides, use: + +```bash +bun run dev:next +``` + +The default local database path is `data/fiscal.sqlite` via `DATABASE_URL=file:data/fiscal.sqlite`. ## Production build @@ -116,8 +123,8 @@ Use root `.env` or root `.env.local`: NEXT_PUBLIC_API_URL= DATABASE_URL=file:data/fiscal.sqlite BETTER_AUTH_SECRET=replace-with-a-long-random-secret -BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz -BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz +BETTER_AUTH_BASE_URL=http://localhost:3000 +BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 ZHIPU_API_KEY= ZHIPU_MODEL=glm-5 @@ -126,7 +133,10 @@ AI_TEMPERATURE=0.2 SEC_USER_AGENT=Fiscal Clone -WORKFLOW_TARGET_WORLD=@workflow/world-postgres +# local dev default +WORKFLOW_TARGET_WORLD=local + +# docker / production runtime WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10 WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_ @@ -138,6 +148,7 @@ WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100 `ZHIPU_API_KEY` is required for AI workloads (extraction and report generation). Missing or invalid credentials fail AI tasks. `ZHIPU_BASE_URL` is deprecated and ignored; runtime always uses `https://api.z.ai/api/coding/paas/v4`. +`bun run dev` will still normalize Better Auth origin, same-origin API routing, SQLite path, and Workflow runtime for localhost boot if this file contains deployment values. ## API surface diff --git a/package.json b/package.json index 1fe43c0..1b6e187 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "packageManager": "bun@1.3.5", "scripts": { - "dev": "bun --bun next dev --turbopack", + "dev": "bun run scripts/dev.ts", + "dev:next": "bun --bun next dev --turbopack", "build": "bun --bun next build --turbopack", "start": "bun --bun next start", "lint": "bun --bun tsc --noEmit", diff --git a/scripts/dev-env.test.ts b/scripts/dev-env.test.ts new file mode 100644 index 0000000..d790e0b --- /dev/null +++ b/scripts/dev-env.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'bun:test'; +import { buildLocalDevConfig, LOCAL_DEV_SECRET } from './dev-env'; + +describe('buildLocalDevConfig', () => { + it('applies local-first defaults for the dev bootstrapper', () => { + const config = buildLocalDevConfig({}); + + expect(config.bindHost).toBe('127.0.0.1'); + expect(config.port).toBe('3000'); + expect(config.publicOrigin).toBe('http://localhost:3000'); + expect(config.env.BETTER_AUTH_BASE_URL).toBe('http://localhost:3000'); + expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('http://localhost:3000'); + expect(config.env.BETTER_AUTH_SECRET).toBe(LOCAL_DEV_SECRET); + expect(config.env.DATABASE_URL).toBe('file:data/fiscal.sqlite'); + expect(config.env.NEXT_PUBLIC_API_URL).toBe(''); + expect(config.env.WORKFLOW_TARGET_WORLD).toBe('local'); + expect(config.env.WORKFLOW_LOCAL_DATA_DIR).toBe('.workflow-data'); + expect(config.overrides.secretFallbackUsed).toBe(true); + }); + + it('preserves a custom local sqlite path and merges trusted origins', () => { + const config = buildLocalDevConfig({ + BETTER_AUTH_SECRET: 'real-secret', + BETTER_AUTH_TRUSTED_ORIGINS: 'https://fiscal.b11studio.xyz,http://localhost:3000', + DATABASE_URL: 'file:data/dev.sqlite', + WORKFLOW_TARGET_WORLD: 'local' + }); + + expect(config.env.BETTER_AUTH_SECRET).toBe('real-secret'); + expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('http://localhost:3000,https://fiscal.b11studio.xyz'); + expect(config.env.DATABASE_URL).toBe('file:data/dev.sqlite'); + expect(config.overrides.databaseChanged).toBe(false); + expect(config.overrides.workflowChanged).toBe(false); + }); + + it('respects an explicit public origin override', () => { + const config = buildLocalDevConfig({ + DEV_PUBLIC_ORIGIN: 'https://local.fiscal.test:4444/', + PORT: '4000' + }); + + expect(config.port).toBe('4000'); + expect(config.publicOrigin).toBe('https://local.fiscal.test:4444'); + expect(config.env.BETTER_AUTH_BASE_URL).toBe('https://local.fiscal.test:4444'); + expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('https://local.fiscal.test:4444'); + }); + + it('falls back from deployment-shaped env values to local-safe ones', () => { + const config = buildLocalDevConfig({ + BETTER_AUTH_BASE_URL: 'https://fiscal.b11studio.xyz', + BETTER_AUTH_SECRET: 'replace-with-a-long-random-secret', + DATABASE_URL: 'file:/app/data/fiscal.sqlite', + NEXT_PUBLIC_API_URL: 'https://fiscal.b11studio.xyz', + WORKFLOW_POSTGRES_URL: 'postgres://workflow:workflow@workflow-postgres:5432/workflow', + WORKFLOW_TARGET_WORLD: '@workflow/world-postgres' + }); + + expect(config.env.BETTER_AUTH_BASE_URL).toBe('http://localhost:3000'); + expect(config.env.BETTER_AUTH_SECRET).toBe(LOCAL_DEV_SECRET); + expect(config.env.DATABASE_URL).toBe('file:data/fiscal.sqlite'); + expect(config.env.NEXT_PUBLIC_API_URL).toBe(''); + expect(config.env.WORKFLOW_TARGET_WORLD).toBe('local'); + expect(config.overrides.apiBaseChanged).toBe(true); + expect(config.overrides.authOriginChanged).toBe(true); + expect(config.overrides.databaseChanged).toBe(true); + expect(config.overrides.workflowChanged).toBe(true); + }); +}); diff --git a/scripts/dev-env.ts b/scripts/dev-env.ts new file mode 100644 index 0000000..dd411e9 --- /dev/null +++ b/scripts/dev-env.ts @@ -0,0 +1,153 @@ +const DEFAULT_BIND_HOST = '127.0.0.1'; +const DEFAULT_DATABASE_URL = 'file:data/fiscal.sqlite'; +const DEFAULT_PORT = '3000'; +const DEFAULT_PUBLIC_HOST = 'localhost'; +const DEFAULT_SEC_USER_AGENT = 'Fiscal Clone '; +const DEFAULT_WORKFLOW_LOCAL_DATA_DIR = '.workflow-data'; +const DEFAULT_WORKFLOW_LOCAL_QUEUE_CONCURRENCY = '100'; +const PLACEHOLDER_SECRET = 'replace-with-a-long-random-secret'; + +export const LOCAL_DEV_SECRET = 'fiscal-local-dev-secret-fiscal-local-dev-secret'; + +type EnvMap = Record; + +type LocalDevOverrideSummary = { + apiBaseChanged: boolean; + authOriginChanged: boolean; + databaseChanged: boolean; + secretFallbackUsed: boolean; + workflowChanged: boolean; +}; + +export type LocalDevConfig = { + bindHost: string; + env: EnvMap; + overrides: LocalDevOverrideSummary; + port: string; + publicOrigin: string; +}; + +function trim(value: string | undefined) { + const candidate = value?.trim(); + return candidate ? candidate : undefined; +} + +function parseCsvList(value: string | undefined) { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function toUniqueList(values: string[]) { + return Array.from(new Set(values)); +} + +function coercePort(port: string | undefined) { + const parsed = Number.parseInt(port ?? '', 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + return String(parsed); + } + + return DEFAULT_PORT; +} + +function normalizeOrigin(origin: string) { + const url = new URL(origin); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`DEV_PUBLIC_ORIGIN must use http or https. Received: ${origin}`); + } + + url.hash = ''; + url.search = ''; + + const pathName = url.pathname.replace(/\/$/, ''); + return `${url.origin}${pathName === '/' ? '' : pathName}`; +} + +function buildPublicOrigin(sourceEnv: EnvMap, port: string) { + const explicitOrigin = trim(sourceEnv.DEV_PUBLIC_ORIGIN); + if (explicitOrigin) { + return normalizeOrigin(explicitOrigin); + } + + const publicHost = trim(sourceEnv.DEV_PUBLIC_HOST) || DEFAULT_PUBLIC_HOST; + return normalizeOrigin(`http://${publicHost}:${port}`); +} + +function isSqliteLikeDatabase(databaseUrl: string) { + return databaseUrl === ':memory:' || databaseUrl.startsWith('file:') || !databaseUrl.includes('://'); +} + +export function resolveSqlitePath(databaseUrl: string) { + let databasePath = databaseUrl.startsWith('file:') + ? databaseUrl.slice(5) + : databaseUrl; + + if (databasePath.startsWith('///')) { + databasePath = databasePath.slice(2); + } + + return databasePath; +} + +function shouldUseDefaultDatabase(databaseUrl: string | undefined) { + if (!databaseUrl) { + return true; + } + + if (!isSqliteLikeDatabase(databaseUrl)) { + return true; + } + + const path = resolveSqlitePath(databaseUrl); + return path.startsWith('/app/'); +} + +export function buildLocalDevConfig(sourceEnv: EnvMap = process.env): LocalDevConfig { + const bindHost = trim(sourceEnv.HOSTNAME) || trim(sourceEnv.HOST) || DEFAULT_BIND_HOST; + const port = coercePort(trim(sourceEnv.PORT) || trim(sourceEnv.APP_PORT)); + const publicOrigin = buildPublicOrigin(sourceEnv, port); + const databaseUrl = shouldUseDefaultDatabase(trim(sourceEnv.DATABASE_URL)) + ? DEFAULT_DATABASE_URL + : trim(sourceEnv.DATABASE_URL) ?? DEFAULT_DATABASE_URL; + const secret = trim(sourceEnv.BETTER_AUTH_SECRET); + const trustedOrigins = toUniqueList([ + publicOrigin, + ...parseCsvList(sourceEnv.BETTER_AUTH_TRUSTED_ORIGINS) + ]).join(','); + + const env: EnvMap = { + ...sourceEnv, + BETTER_AUTH_BASE_URL: publicOrigin, + BETTER_AUTH_SECRET: !secret || secret === PLACEHOLDER_SECRET + ? LOCAL_DEV_SECRET + : secret, + BETTER_AUTH_TRUSTED_ORIGINS: trustedOrigins, + DATABASE_URL: databaseUrl, + HOSTNAME: bindHost, + NEXT_PUBLIC_API_URL: '', + PORT: port, + SEC_USER_AGENT: trim(sourceEnv.SEC_USER_AGENT) || DEFAULT_SEC_USER_AGENT, + WORKFLOW_LOCAL_DATA_DIR: trim(sourceEnv.WORKFLOW_LOCAL_DATA_DIR) || DEFAULT_WORKFLOW_LOCAL_DATA_DIR, + WORKFLOW_LOCAL_QUEUE_CONCURRENCY: + trim(sourceEnv.WORKFLOW_LOCAL_QUEUE_CONCURRENCY) || DEFAULT_WORKFLOW_LOCAL_QUEUE_CONCURRENCY, + WORKFLOW_TARGET_WORLD: 'local' + }; + + return { + bindHost, + env, + overrides: { + apiBaseChanged: trim(sourceEnv.NEXT_PUBLIC_API_URL) !== undefined, + authOriginChanged: trim(sourceEnv.BETTER_AUTH_BASE_URL) !== publicOrigin + || trim(sourceEnv.BETTER_AUTH_URL) !== undefined, + databaseChanged: databaseUrl !== trim(sourceEnv.DATABASE_URL), + secretFallbackUsed: env.BETTER_AUTH_SECRET === LOCAL_DEV_SECRET, + workflowChanged: trim(sourceEnv.WORKFLOW_TARGET_WORLD) !== 'local' + }, + port, + publicOrigin + }; +} diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..f4b996c --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,170 @@ +import { spawn } from 'node:child_process'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { Database } from 'bun:sqlite'; +import { createServer } from 'node:net'; +import { dirname, join } from 'node:path'; +import { buildLocalDevConfig, resolveSqlitePath } from './dev-env'; + +type DrizzleJournal = { + entries: Array<{ tag: string }>; +}; + +type ExitResult = { + code: number | null; + signal: NodeJS.Signals | null; +}; + +function trim(value: string | undefined) { + const candidate = value?.trim(); + return candidate ? candidate : undefined; +} + +async function isPortAvailable(port: number, host: string) { + return await new Promise((resolve) => { + const server = createServer(); + + server.once('error', () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} + +async function pickLocalPort(host: string) { + const candidatePorts = [3000, 3001, 3002, 3100, 3200, 3300]; + + for (const port of candidatePorts) { + if (await isPortAvailable(port, host)) { + return String(port); + } + } + + throw new Error(`Unable to find an open local dev port from: ${candidatePorts.join(', ')}`); +} + +function hasTable(database: Database, tableName: string) { + const row = database + .query('SELECT name FROM sqlite_master WHERE type = ? AND name = ? LIMIT 1') + .get('table', tableName) as { name: string } | null; + + return row !== null; +} + +function readMigrationFiles() { + const journal = JSON.parse( + readFileSync(join(process.cwd(), 'drizzle', 'meta', '_journal.json'), 'utf8') + ) as DrizzleJournal; + + return journal.entries.map((entry) => join(process.cwd(), 'drizzle', `${entry.tag}.sql`)); +} + +function bootstrapFreshDatabase(databaseUrl: string) { + const databasePath = resolveSqlitePath(databaseUrl); + if (!databasePath || databasePath === ':memory:') { + return false; + } + + mkdirSync(dirname(databasePath), { recursive: true }); + + const database = new Database(databasePath, { create: true }); + + try { + database.exec('PRAGMA foreign_keys = ON;'); + + if (hasTable(database, 'user')) { + return false; + } + + for (const migrationFile of readMigrationFiles()) { + database.exec(readFileSync(migrationFile, 'utf8')); + } + + return true; + } finally { + database.close(); + } +} + +function exitFromResult(result: ExitResult) { + if (result.signal) { + process.exit(result.signal === 'SIGINT' || result.signal === 'SIGTERM' ? 0 : 1); + return; + } + + process.exit(result.code ?? 0); +} + +const explicitPort = trim(process.env.PORT) || trim(process.env.APP_PORT); +const bindHost = trim(process.env.HOSTNAME) || trim(process.env.HOST) || '127.0.0.1'; +const resolvedPort = explicitPort || await pickLocalPort(bindHost); +const config = buildLocalDevConfig({ + ...process.env, + HOSTNAME: bindHost, + PORT: resolvedPort +}); +const env = { + ...config.env +} as NodeJS.ProcessEnv; + +delete env.NO_COLOR; + +const databasePath = resolveSqlitePath(env.DATABASE_URL ?? ''); +if (databasePath && databasePath !== ':memory:') { + mkdirSync(dirname(databasePath), { recursive: true }); +} + +mkdirSync(env.WORKFLOW_LOCAL_DATA_DIR ?? '.workflow-data', { recursive: true }); + +const initializedDatabase = bootstrapFreshDatabase(env.DATABASE_URL ?? ''); + +console.info(`[dev] local origin ${config.publicOrigin}`); +console.info(`[dev] sqlite ${env.DATABASE_URL}`); +console.info(`[dev] workflow ${env.WORKFLOW_TARGET_WORLD} (${env.WORKFLOW_LOCAL_DATA_DIR})`); +if (!explicitPort && resolvedPort !== '3000') { + console.info(`[dev] port 3000 is busy, using http://localhost:${resolvedPort} instead`); +} +if (initializedDatabase) { + console.info('[dev] initialized the local SQLite schema from drizzle SQL files'); +} + +if (config.overrides.authOriginChanged) { + console.info('[dev] forcing Better Auth origin/trusted origins to the local origin'); +} + +if (config.overrides.apiBaseChanged) { + console.info('[dev] forcing NEXT_PUBLIC_API_URL to same-origin for local dev'); +} + +if (config.overrides.databaseChanged) { + console.info('[dev] using a local SQLite database instead of the deployment path'); +} + +if (config.overrides.workflowChanged) { + console.info('[dev] forcing Workflow to the local runtime for local dev'); +} + +if (config.overrides.secretFallbackUsed) { + console.info('[dev] using the built-in local Better Auth secret because BETTER_AUTH_SECRET is unset or still a placeholder'); +} + +const child = spawn( + 'bun', + ['--bun', 'next', 'dev', '--turbopack', '--hostname', config.bindHost, '--port', config.port], + { + env, + stdio: 'inherit' + } +); + +function forwardSignal(signal: NodeJS.Signals) { + if (!child.killed) { + child.kill(signal); + } +} + +process.on('SIGINT', () => forwardSignal('SIGINT')); +process.on('SIGTERM', () => forwardSignal('SIGTERM')); + +child.on('exit', (code, signal) => { + exitFromResult({ code, signal }); +});