Add local dev bootstrap command

This commit is contained in:
2026-03-06 21:38:28 -05:00
parent 7dd403e814
commit f69e5b671b
6 changed files with 416 additions and 11 deletions

View File

@@ -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 <support@fiscal.local>
# 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_

View File

@@ -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 <support@fiscal.local>
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

View File

@@ -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",

68
scripts/dev-env.test.ts Normal file
View File

@@ -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);
});
});

153
scripts/dev-env.ts Normal file
View File

@@ -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 <support@fiscal.local>';
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<string, string | undefined>;
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
};
}

170
scripts/dev.ts Normal file
View File

@@ -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<boolean>((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 });
});