feat: implement server configuration persistence to ~/.hapi/settings.json

Configuration now loads with priority: environment variable > settings.json > default value.
When values are read from environment variables and not present in settings.json, they are automatically saved for future use. This eliminates the need to repeatedly set environment variables.

- New serverSettings.ts module handles loading/saving with persistence logic
- Async createConfiguration() factory for proper initialization ordering
- Configuration sources tracked and displayed in startup logs
- Exported Settings interface and read/write functions from cliApiToken.ts
- Updated index.ts to display configuration sources in log output
This commit is contained in:
weishu
2025-12-25 12:27:14 +08:00
parent dbbeedea0d
commit e5f9d8cbe8
4 changed files with 354 additions and 104 deletions
+115 -83
View File
@@ -1,15 +1,37 @@
/**
* Configuration for hapi-server (Direct Connect)
*
* Configuration is loaded with priority: environment variable > settings.json > default
* When values are read from environment variables and not present in settings.json,
* they are automatically saved for future use.
*
* Optional environment variables:
* - CLI_API_TOKEN: Shared secret for hapi CLI authentication (auto-generated if not set)
* - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather
* - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs
* - WEBAPP_PORT: Port for Mini App HTTP server (default: 3006)
* - WEBAPP_URL: Public URL for Telegram Mini App
* - CORS_ORIGINS: Comma-separated CORS origins
* - HAPI_HOME: Data directory (default: ~/.hapi)
* - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db)
*/
import { existsSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { loadServerSettings, type ServerSettings, type ServerSettingsResult } from './serverSettings'
import { getOrCreateCliApiToken } from './web/cliApiToken'
export type ConfigSource = 'env' | 'file' | 'default'
export interface ConfigSources {
telegramBotToken: ConfigSource
allowedChatIds: ConfigSource
webappPort: ConfigSource
webappUrl: ConfigSource
corsOrigins: ConfigSource
cliApiToken: 'env' | 'file' | 'generated'
}
class Configuration {
/** Telegram Bot API token */
@@ -24,8 +46,11 @@ class Configuration {
/** CLI auth token (shared secret) */
public cliApiToken: string
/** Source of CLI API token ('pending' | 'env' | 'file' | 'generated') */
public cliApiTokenSource: string
/** Source of CLI API token */
public cliApiTokenSource: 'env' | 'file' | 'generated' | ''
/** Whether CLI API token was newly generated (for first-run display) */
public cliApiTokenIsNew: boolean
/** Path to settings.json file */
public readonly settingsFile: string
@@ -45,87 +70,37 @@ class Configuration {
/** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */
public readonly corsOrigins: string[]
constructor() {
// Optional: Telegram Bot Token
const botToken = process.env.TELEGRAM_BOT_TOKEN
this.telegramBotToken = botToken ?? null
/** Sources of each configuration value */
public readonly sources: ConfigSources
/** Private constructor - use createConfiguration() instead */
private constructor(
dataDir: string,
dbPath: string,
serverSettings: ServerSettings,
sources: ServerSettingsResult['sources']
) {
this.dataDir = dataDir
this.dbPath = dbPath
this.settingsFile = join(dataDir, 'settings.json')
// Apply server settings
this.telegramBotToken = serverSettings.telegramBotToken
this.telegramEnabled = Boolean(this.telegramBotToken)
this.allowedChatIds = serverSettings.allowedChatIds
this.webappPort = serverSettings.webappPort
this.miniAppUrl = serverSettings.webappUrl
this.corsOrigins = serverSettings.corsOrigins
// Optional: Allowed Chat IDs
const chatIdsStr = process.env.ALLOWED_CHAT_IDS
this.allowedChatIds = chatIdsStr
? chatIdsStr
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id))
: []
// CLI API token - will be set later by getOrCreateCliApiToken()
// CLI API token - will be set by _setCliApiToken() before create() returns
this.cliApiToken = ''
this.cliApiTokenSource = 'pending'
this.cliApiTokenSource = ''
this.cliApiTokenIsNew = false
// Mini App web server configuration
const webappPortRaw = process.env.WEBAPP_PORT
const parsedWebappPort = webappPortRaw ? parseInt(webappPortRaw, 10) : 3006
if (!Number.isFinite(parsedWebappPort) || parsedWebappPort <= 0) {
throw new Error('WEBAPP_PORT must be a valid port number')
}
this.webappPort = parsedWebappPort
// For production, Telegram requires HTTPS for Mini Apps.
// This URL is what Telegram clients will open when pressing the WebApp button.
this.miniAppUrl = process.env.WEBAPP_URL || `http://localhost:${this.webappPort}`
// CORS origin allowlist (Mini App + Socket.IO browser clients).
// - Defaults to the Mini App's origin (derived from WEBAPP_URL).
// - If set to "*", allows all origins (not recommended for internet-exposed deployments).
const corsOriginsRaw = process.env.CORS_ORIGINS
if (corsOriginsRaw) {
const entries = corsOriginsRaw
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
if (entries.includes('*')) {
this.corsOrigins = ['*']
} else {
const normalized: string[] = []
for (const entry of entries) {
try {
normalized.push(new URL(entry).origin)
} catch {
// Keep raw value if it's already an origin-like string.
normalized.push(entry)
}
}
this.corsOrigins = normalized
}
} else {
try {
this.corsOrigins = [new URL(this.miniAppUrl).origin]
} catch {
this.corsOrigins = []
}
}
// Data directory
if (process.env.HAPI_HOME) {
const expandedPath = process.env.HAPI_HOME.replace(/^~/, homedir())
this.dataDir = expandedPath
} else {
this.dataDir = join(homedir(), '.hapi')
}
// DB path (defaults inside dataDir)
if (process.env.DB_PATH) {
const expandedPath = process.env.DB_PATH.replace(/^~/, homedir())
this.dbPath = expandedPath
} else {
this.dbPath = join(this.dataDir, 'hapi.db')
}
// Settings file path
this.settingsFile = join(this.dataDir, 'settings.json')
// Store sources for logging (cliApiToken will be set by _setCliApiToken)
this.sources = {
...sources,
} as ConfigSources
// Ensure data directory exists
if (!existsSync(this.dataDir)) {
@@ -133,24 +108,81 @@ class Configuration {
}
}
/** Create configuration asynchronously */
static async create(): Promise<Configuration> {
// 1. Determine data directory (env only - not persisted)
const dataDir = process.env.HAPI_HOME
? process.env.HAPI_HOME.replace(/^~/, homedir())
: join(homedir(), '.hapi')
// Ensure data directory exists before loading settings
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true })
}
// 2. Determine DB path (env only - not persisted)
const dbPath = process.env.DB_PATH
? process.env.DB_PATH.replace(/^~/, homedir())
: join(dataDir, 'hapi.db')
// 3. Load server settings (with persistence)
const settingsResult = await loadServerSettings(dataDir)
if (settingsResult.savedToFile) {
console.log(`[Server] Configuration saved to ${join(dataDir, 'settings.json')}`)
}
// 4. Create configuration instance
const config = new Configuration(
dataDir,
dbPath,
settingsResult.settings,
settingsResult.sources
)
// 5. Load CLI API token
const tokenResult = await getOrCreateCliApiToken(dataDir)
config._setCliApiToken(tokenResult.token, tokenResult.source, tokenResult.isNew)
return config
}
/** Check if a chat ID is allowed */
isChatIdAllowed(chatId: number): boolean {
return this.allowedChatIds.includes(chatId)
}
/** Set CLI API token (called after async initialization) */
_setCliApiToken(token: string, source: string): void {
/** Set CLI API token (called during async initialization) */
_setCliApiToken(token: string, source: 'env' | 'file' | 'generated', isNew: boolean): void {
this.cliApiToken = token
this.cliApiTokenSource = source
this.cliApiTokenIsNew = isNew
;(this.sources as { cliApiToken: string }).cliApiToken = source
}
}
// Lazy initialization to allow configuration to fail gracefully
// Singleton instance (set by createConfiguration)
let _configuration: Configuration | null = null
/**
* Create and initialize configuration asynchronously.
* Must be called once at startup before getConfiguration() can be used.
*/
export async function createConfiguration(): Promise<Configuration> {
if (_configuration) {
return _configuration
}
_configuration = await Configuration.create()
return _configuration
}
/**
* Get the initialized configuration.
* Throws if createConfiguration() has not been called yet.
*/
export function getConfiguration(): Configuration {
if (!_configuration) {
_configuration = new Configuration()
throw new Error('Configuration not initialized. Call createConfiguration() first.')
}
return _configuration
}
+34 -18
View File
@@ -8,18 +8,31 @@
* - Optional Telegram bot for notifications and Mini App entrypoint
*/
import { getConfiguration } from './configuration'
import { createConfiguration, type ConfigSource } from './configuration'
import { Store } from './store'
import { SyncEngine, type SyncEvent } from './sync/syncEngine'
import { HappyBot } from './telegram/bot'
import { startWebServer } from './web/server'
import { getOrCreateJwtSecret } from './web/jwtSecret'
import { getOrCreateCliApiToken } from './web/cliApiToken'
import { createSocketServer } from './socket/server'
import { SSEManager } from './sse/sseManager'
import type { Server as BunServer } from 'bun'
import type { WebSocketData } from '@socket.io/bun-engine'
/** Format config source for logging */
function formatSource(source: ConfigSource | 'generated'): string {
switch (source) {
case 'env':
return 'environment'
case 'file':
return 'settings.json'
case 'default':
return 'default'
case 'generated':
return 'generated'
}
}
let syncEngine: SyncEngine | null = null
let happyBot: HappyBot | null = null
let webServer: BunServer<WebSocketData> | null = null
@@ -28,37 +41,40 @@ let sseManager: SSEManager | null = null
async function main() {
console.log('HAPI Server starting...')
// Load configuration
const config = getConfiguration()
// Load configuration (async - loads from env/file with persistence)
const config = await createConfiguration()
// Initialize CLI API token
const tokenResult = await getOrCreateCliApiToken(config.dataDir)
config._setCliApiToken(tokenResult.token, tokenResult.source)
// Display token information
if (tokenResult.isNew) {
// Display CLI API token information
if (config.cliApiTokenIsNew) {
console.log('')
console.log('='.repeat(70))
console.log(' NEW CLI_API_TOKEN GENERATED')
console.log('='.repeat(70))
console.log('')
console.log(` Token: ${tokenResult.token}`)
console.log(` Token: ${config.cliApiToken}`)
console.log('')
console.log(` Saved to: ${tokenResult.filePath}`)
console.log(` Saved to: ${config.settingsFile}`)
console.log('')
console.log('='.repeat(70))
console.log('')
} else {
console.log(`[Server] CLI_API_TOKEN: loaded from ${tokenResult.source}`)
console.log(`[Server] CLI_API_TOKEN: loaded from ${formatSource(config.sources.cliApiToken)}`)
}
console.log(`[Server] Mini App: ${config.miniAppUrl} (port ${config.webappPort})`)
// Display other configuration sources
console.log(`[Server] WEBAPP_PORT: ${config.webappPort} (${formatSource(config.sources.webappPort)})`)
console.log(`[Server] WEBAPP_URL: ${config.miniAppUrl} (${formatSource(config.sources.webappUrl)})`)
if (!config.telegramEnabled) {
console.log('[Server] Telegram: disabled (missing TELEGRAM_BOT_TOKEN)')
} else if (config.allowedChatIds.length === 0) {
console.log('[Server] Telegram: enabled (allowlist empty; /start shows chat ID)')
console.log('[Server] Telegram: disabled (no TELEGRAM_BOT_TOKEN)')
} else {
console.log(`[Server] Telegram: enabled (chat IDs: ${config.allowedChatIds.join(', ')})`)
const tokenSource = formatSource(config.sources.telegramBotToken)
if (config.allowedChatIds.length === 0) {
console.log(`[Server] Telegram: enabled (${tokenSource}), allowlist empty - /start shows chat ID`)
} else {
const idsSource = formatSource(config.sources.allowedChatIds)
console.log(`[Server] Telegram: enabled (${tokenSource}), chat IDs: ${config.allowedChatIds.join(', ')} (${idsSource})`)
}
}
const store = new Store(config.dbPath)
+196
View File
@@ -0,0 +1,196 @@
/**
* Server Settings Management
*
* Handles loading and persistence of server configuration.
* Priority: environment variable > settings.json > default value
*
* When a value is loaded from environment variable and not present in settings.json,
* it will be saved to settings.json for future use.
*/
import { join } from 'node:path'
import { readSettings, writeSettings, type Settings } from './web/cliApiToken'
export interface ServerSettings {
telegramBotToken: string | null
allowedChatIds: number[]
webappPort: number
webappUrl: string
corsOrigins: string[]
}
export interface ServerSettingsResult {
settings: ServerSettings
sources: {
telegramBotToken: 'env' | 'file' | 'default'
allowedChatIds: 'env' | 'file' | 'default'
webappPort: 'env' | 'file' | 'default'
webappUrl: 'env' | 'file' | 'default'
corsOrigins: 'env' | 'file' | 'default'
}
savedToFile: boolean
}
/**
* Parse comma-separated chat IDs from string
*/
function parseChatIds(str: string): number[] {
return str
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id))
}
/**
* Parse and normalize CORS origins
*/
function parseCorsOrigins(str: string): string[] {
const entries = str
.split(',')
.map(origin => origin.trim())
.filter(Boolean)
if (entries.includes('*')) {
return ['*']
}
const normalized: string[] = []
for (const entry of entries) {
try {
normalized.push(new URL(entry).origin)
} catch {
// Keep raw value if it's already an origin-like string
normalized.push(entry)
}
}
return normalized
}
/**
* Derive CORS origins from webapp URL
*/
function deriveCorsOrigins(webappUrl: string): string[] {
try {
return [new URL(webappUrl).origin]
} catch {
return []
}
}
/**
* Load server settings with priority: env > file > default
* Saves new env values to file when not already present
*/
export async function loadServerSettings(dataDir: string): Promise<ServerSettingsResult> {
const settingsFile = join(dataDir, 'settings.json')
const settings = await readSettings(settingsFile)
// If settings file exists but couldn't be parsed, fail fast
if (settings === null) {
throw new Error(
`Cannot read ${settingsFile}. Please fix or remove the file and restart.`
)
}
let needsSave = false
const sources: ServerSettingsResult['sources'] = {
telegramBotToken: 'default',
allowedChatIds: 'default',
webappPort: 'default',
webappUrl: 'default',
corsOrigins: 'default',
}
// telegramBotToken: env > file > null
let telegramBotToken: string | null = null
if (process.env.TELEGRAM_BOT_TOKEN) {
telegramBotToken = process.env.TELEGRAM_BOT_TOKEN
sources.telegramBotToken = 'env'
if (settings.telegramBotToken === undefined) {
settings.telegramBotToken = telegramBotToken
needsSave = true
}
} else if (settings.telegramBotToken !== undefined) {
telegramBotToken = settings.telegramBotToken
sources.telegramBotToken = 'file'
}
// allowedChatIds: env > file > []
let allowedChatIds: number[] = []
if (process.env.ALLOWED_CHAT_IDS) {
allowedChatIds = parseChatIds(process.env.ALLOWED_CHAT_IDS)
sources.allowedChatIds = 'env'
if (settings.allowedChatIds === undefined) {
settings.allowedChatIds = allowedChatIds
needsSave = true
}
} else if (settings.allowedChatIds !== undefined) {
allowedChatIds = settings.allowedChatIds
sources.allowedChatIds = 'file'
}
// webappPort: env > file > 3006
let webappPort = 3006
if (process.env.WEBAPP_PORT) {
const parsed = parseInt(process.env.WEBAPP_PORT, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error('WEBAPP_PORT must be a valid port number')
}
webappPort = parsed
sources.webappPort = 'env'
if (settings.webappPort === undefined) {
settings.webappPort = webappPort
needsSave = true
}
} else if (settings.webappPort !== undefined) {
webappPort = settings.webappPort
sources.webappPort = 'file'
}
// webappUrl: env > file > http://localhost:{port}
let webappUrl = `http://localhost:${webappPort}`
if (process.env.WEBAPP_URL) {
webappUrl = process.env.WEBAPP_URL
sources.webappUrl = 'env'
if (settings.webappUrl === undefined) {
settings.webappUrl = webappUrl
needsSave = true
}
} else if (settings.webappUrl !== undefined) {
webappUrl = settings.webappUrl
sources.webappUrl = 'file'
}
// corsOrigins: env > file > derived from webappUrl
let corsOrigins: string[]
if (process.env.CORS_ORIGINS) {
corsOrigins = parseCorsOrigins(process.env.CORS_ORIGINS)
sources.corsOrigins = 'env'
if (settings.corsOrigins === undefined) {
settings.corsOrigins = corsOrigins
needsSave = true
}
} else if (settings.corsOrigins !== undefined) {
corsOrigins = settings.corsOrigins
sources.corsOrigins = 'file'
} else {
corsOrigins = deriveCorsOrigins(webappUrl)
}
// Save settings if any new values were added
if (needsSave) {
await writeSettings(settingsFile, settings)
}
return {
settings: {
telegramBotToken,
allowedChatIds,
webappPort,
webappUrl,
corsOrigins,
},
sources,
savedToFile: needsSave,
}
}
+9 -3
View File
@@ -10,12 +10,18 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { dirname, join } from 'node:path'
interface Settings {
export interface Settings {
onboardingCompleted?: boolean
machineId?: string
machineIdConfirmedByServer?: boolean
daemonAutoStartWhenRunningHappy?: boolean
cliApiToken?: string
// Server configuration (persisted from environment variables)
telegramBotToken?: string
allowedChatIds?: number[]
webappPort?: number
webappUrl?: string
corsOrigins?: string[]
}
export interface CliApiTokenResult {
@@ -53,7 +59,7 @@ function isWeakToken(token: string): boolean {
* Read settings from file, preserving all existing fields.
* Returns null if file exists but cannot be parsed (to avoid data loss).
*/
async function readSettings(settingsFile: string): Promise<Settings | null> {
export async function readSettings(settingsFile: string): Promise<Settings | null> {
if (!existsSync(settingsFile)) {
return {}
}
@@ -70,7 +76,7 @@ async function readSettings(settingsFile: string): Promise<Settings | null> {
/**
* Write settings to file atomically (temp file + rename)
*/
async function writeSettings(settingsFile: string, settings: Settings): Promise<void> {
export async function writeSettings(settingsFile: string, settings: Settings): Promise<void> {
const dir = dirname(settingsFile)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true, mode: 0o700 })