Add local dev bootstrap command
This commit is contained in:
153
scripts/dev-env.ts
Normal file
153
scripts/dev-env.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user