diff --git a/.agents/notes.md b/.agents/notes.md index ca620cf..1ec10f3 100644 --- a/.agents/notes.md +++ b/.agents/notes.md @@ -36,3 +36,6 @@ [0] When planning Ledger v2 migrations for this repo, confirm rollout expectations first: if the user is resetting the non-production local DB, remove backfill work entirely and prefer simple additive/drop-recreate migrations (especially for table-shape changes like Monzo category mappings). [0] In Fastify query schemas with coercion enabled, `dryRun=true` can match both boolean and string branches; using `oneOf` for `dryRun` (`boolean` + `'true'|'false'|'1'|'0'`) may fail validation with "must match exactly one schema". Use `anyOf` (or a single normalized schema) for approval-flow query params. [0] When refactoring PWA React pages/components in this repo and a React best-practices skill is available (for example `vercel-react-best-practices`), load and apply it before making UI changes; the user explicitly expects skill usage, not just local reasoning. +[0] The global `tithe` shim under `~/Library/pnpm/` is just a launcher script; after `pnpm link --global ./apps/cli` it executes the workspace path (`.../apps/cli/dist/index.js`), so `tithe web` always uses the current local checkout via relative workspace-root resolution. +[0] For daemon metadata files, resolve path by checking existing state/pid files across candidates (`~/.tithe`, workspace fallback) before choosing writable location, and pass the chosen directory to child/supervisor processes to keep `status`/`stop` targeting consistent across privilege contexts. +[0] When using `gh pr create --body` through shell commands, avoid unescaped backticks in the argument string because zsh command substitution can execute unintended commands; prefer `--body-file` for multiline markdown. diff --git a/AGENTS.md b/AGENTS.md index 71bd0c2..a215f7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,8 +102,8 @@ Failure: ### Web runtime -- `tithe web [--mode dev|preview] [--api-port ] [--pwa-port ]` -- `tithe --json web [--mode dev|preview] [--api-port ] [--pwa-port ]` +- `tithe web [--mode dev|preview] [--api-port ] [--pwa-port ] [--daemon|--status|--stop]` +- `tithe --json web [--mode dev|preview] [--api-port ] [--pwa-port ] [--daemon|--status|--stop]` ### Monzo @@ -120,11 +120,16 @@ Failure: - DB migrations are expected to run lazily on command execution, not on help-only invocations. - API and CLI entrypoints auto-load workspace-root `.env` via `dotenv` if present (existing exported env vars still take precedence). - Default `DB_PATH` is `~/.tithe/tithe.db`; leading `~` is expanded to the current user's home directory. -- `tithe web` launches API + PWA in foreground mode (`--mode dev` by default). -- `tithe web --mode preview` builds `@tithe/api` and `@tithe/pwa` before launch. +- `tithe web` launches API + PWA in foreground mode (`--mode preview` by default). +- `tithe web --daemon` starts a detached supervisor process that auto-restarts API/PWA if either child process crashes or is killed. +- `tithe web --status` reports daemon state and access metadata from `~/.tithe/web-daemon.state.json` (fallback path when `~/.tithe` is not writable: `/.tithe/web-daemon.state.json`). +- `tithe web --stop` sends `SIGTERM` to the daemon supervisor and waits for shutdown. +- `tithe web --mode preview` builds `@tithe/api` and `@tithe/pwa` before launch (foreground and daemon-supervisor startup). - `--api-port` overrides API `PORT`; for `tithe web`, PWA `VITE_API_BASE` is preserved by default and has its port rewritten when `--api-port` is provided (fallback: `http://:/v1`). - `--pwa-port` sets `PWA_PORT` in `dev` mode or `PWA_PREVIEW_PORT` in `preview` mode. -- `tithe --json web` emits one startup envelope first, then streams prefixed service logs. +- foreground `tithe --json web` emits one startup envelope first, then streams prefixed service logs. +- daemon startup/status/stop (`--daemon|--status|--stop`) emit a single JSON envelope and exit (no live log stream). +- web startup payloads include local PWA/API URLs and best-effort Tailnet URLs; if Tailscale is unavailable, startup continues with a warning and local URLs. - PWA API requests use a 10-second timeout and transition to error state if backend is unreachable. - `tithe --json monzo connect` stores short-lived OAuth `state` and returns `authUrl`. - `GET /v1/integrations/monzo/connect/callback` requires query `code+state` or `error`. diff --git a/README.md b/README.md index ed50e42..ed5cd8a 100644 --- a/README.md +++ b/README.md @@ -304,19 +304,27 @@ Use `tithe web` to launch API + PWA together in the foreground: ```bash tithe web tithe web --mode preview +tithe web --daemon +tithe --json web --status +tithe --json web --stop tithe web --api-port 9797 --pwa-port 5174 tithe --json web --mode dev ``` Runtime notes: -- `--mode dev` is the default. +- `--mode preview` is the default. +- `--daemon` starts a detached supervisor that auto-restarts API/PWA when child processes crash or are killed. +- `--status` returns daemon state from `~/.tithe/web-daemon.state.json` (fallback path when `~/.tithe` is not writable: `/.tithe/web-daemon.state.json`). +- `--stop` requests daemon shutdown (SIGTERM) and waits for exit. - `--mode preview` automatically runs `@tithe/api` and `@tithe/pwa` builds before starting preview services. - `tithe web` preserves configured `VITE_API_BASE` by default. - If `--api-port` is set, `tithe web` rewrites the port in `VITE_API_BASE` when possible and falls back to `http://:/v1`. - `--api-port` overrides API `PORT` for this command. - `--pwa-port` maps to `PWA_PORT` in dev mode and `PWA_PREVIEW_PORT` in preview mode. -- `--json` emits one startup envelope before live prefixed logs are streamed. +- foreground `--json` emits one startup envelope before live prefixed logs are streamed. +- daemon `--json` commands (`--daemon`, `--status`, `--stop`) emit one envelope and exit. +- startup payloads include local and best-effort Tailnet URLs; if Tailscale is unavailable, startup continues with a warning and local URLs. - PWA API requests time out after 10 seconds and surface an error state instead of loading indefinitely. ### Safety gate for destructive operations diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1df30ff..dfca476 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -6,7 +6,7 @@ import { fail, ok } from '@tithe/contracts'; import { runMigrations } from '@tithe/db'; import { AppError, createDomainServices } from '@tithe/domain'; import { loadWorkspaceEnv } from './load-env.js'; -import { runWebCommand } from './web.js'; +import { runWebCommand, runWebSupervisorCommand } from './web.js'; loadWorkspaceEnv(); @@ -876,9 +876,12 @@ monzo.command('status').action(async () => { program .command('web') .description('Run API + PWA web stack') - .option('--mode ', 'dev|preview', 'dev') + .option('--mode ', 'dev|preview') .option('--api-port ', 'override API port (1-65535)') .option('--pwa-port ', 'override PWA port (1-65535)') + .option('--daemon', 'run in background daemon mode with auto-restart', false) + .option('--status', 'show background web daemon status', false) + .option('--stop', 'stop background web daemon', false) .action(async (options) => { const opts = program.opts<{ json: boolean }>(); @@ -888,6 +891,9 @@ program mode: options.mode, apiPort: options.apiPort, pwaPort: options.pwaPort, + daemon: options.daemon, + status: options.status, + stop: options.stop, }, opts.json, ); @@ -903,6 +909,33 @@ program } }); +program + .command('web-supervisor') + .description('Internal daemon supervisor for tithe web --daemon') + .option('--mode ', 'dev|preview') + .option('--api-port ', 'override API port (1-65535)') + .option('--pwa-port ', 'override PWA port (1-65535)') + .option('--run-id ', 'daemon run identifier') + .action(async (options) => { + try { + await runWebSupervisorCommand({ + mode: options.mode, + apiPort: options.apiPort, + pwaPort: options.pwaPort, + runId: options.runId, + }); + } catch (error) { + if (error instanceof AppError) { + emit(fail(error.code, error.message, error.details), true); + process.exitCode = 1; + return; + } + + emit(fail('INTERNAL_ERROR', error instanceof Error ? error.message : String(error)), true); + process.exitCode = 1; + } + }); + if (process.argv.length <= 2) { program.outputHelp(); process.exit(0); diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 3feb825..fdf9fdd 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,4 +1,7 @@ -import { type ChildProcess, spawn } from 'node:child_process'; +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; @@ -7,22 +10,196 @@ import { ok } from '@tithe/contracts'; import { AppError } from '@tithe/domain'; const PNPM_BIN = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const modulePath = fileURLToPath(import.meta.url); +const workspaceRoot = path.resolve(path.dirname(modulePath), '../../..'); + +const daemonShutdownTimeoutMs = 10_000; +const daemonStatusPollMs = 200; +const restartBackoffBaseMs = 1_000; +const restartBackoffMaxMs = 30_000; +const restartStableWindowMs = 60_000; +const tailscaleStatusTimeoutMs = 2_000; type WebMode = 'dev' | 'preview'; +type WebServiceLabel = 'api' | 'pwa'; +type WebScript = 'dev' | 'start' | 'build'; +type DaemonLifecycleStatus = + | 'starting' + | 'running' + | 'degraded' + | 'stopping' + | 'stopped' + | 'crashed'; + +export interface WebCommandOptions { + mode?: string; + apiPort?: string; + pwaPort?: string; + daemon?: boolean; + status?: boolean; + stop?: boolean; +} -interface WebCommandOptions { +export interface WebSupervisorCommandOptions { mode?: string; apiPort?: string; pwaPort?: string; + runId?: string; } interface ManagedProcess { - label: 'api' | 'pwa'; + label: WebServiceLabel; child: ChildProcess; flushLogs: () => void; } +interface ResolvedWebRuntime { + mode: WebMode; + apiScript: 'dev' | 'start'; + pwaScript: 'dev' | 'start'; + apiEnv: NodeJS.ProcessEnv; + pwaEnv: NodeJS.ProcessEnv; + resolvedApiPort: number; + resolvedPwaPort: number; + resolvedApiBase: string; +} + +interface WebAccessInfo { + local: { + apiUrl: string; + pwaUrl: string; + }; + tailnet: { + apiUrl?: string; + pwaUrl?: string; + host?: string; + }; + warning?: string; +} + +interface WebDaemonServiceState { + package: string; + script: 'dev' | 'start'; + port: number; + pid?: number; + restartCount: number; + lastStartAt?: string; + lastExitAt?: string; + lastExit?: { + code: number | null; + signal: NodeJS.Signals | null; + }; +} + +interface WebDaemonState { + version: 1; + runId: string; + pid: number; + mode: WebMode; + lifecycle: 'daemon'; + workspaceRoot: string; + startedAt: string; + updatedAt: string; + status: DaemonLifecycleStatus; + logFile: string; + access: WebAccessInfo; + lastEvent?: string; + services: { + api: WebDaemonServiceState; + pwa: WebDaemonServiceState; + }; +} + +interface WebDaemonStatusResponse { + running: boolean; + pid?: number; + pidFile: string; + logFile: string; + stateFile: string; + state?: WebDaemonState; +} + +interface DaemonPaths { + dir: string; + pidFile: string; + logFile: string; + stateFile: string; +} + +interface SupervisorServiceRuntime { + label: WebServiceLabel; + script: 'dev' | 'start'; + env: NodeJS.ProcessEnv; + restartCount: number; + failureStreak: number; + managed?: ManagedProcess; + startedAtMs?: number; +} + +const nowIso = () => new Date().toISOString(); + +const buildDaemonPaths = (dir: string): DaemonPaths => ({ + dir, + pidFile: path.join(dir, 'web-daemon.pid'), + logFile: path.join(dir, 'web-daemon.log'), + stateFile: path.join(dir, 'web-daemon.state.json'), +}); + +const daemonDirCandidates = (): string[] => { + const overrideDir = process.env.TITHE_WEB_DAEMON_DIR?.trim(); + const candidates = overrideDir + ? [path.resolve(overrideDir)] + : [path.join(os.homedir(), '.tithe'), path.join(workspaceRoot, '.tithe')]; + + return Array.from(new Set(candidates)); +}; + +const hasDaemonStateFile = (paths: DaemonPaths): boolean => + fs.existsSync(paths.pidFile) || fs.existsSync(paths.stateFile); + +const ensureDirWritable = (dir: string): boolean => { + try { + fs.mkdirSync(dir, { recursive: true }); + fs.accessSync(dir, fs.constants.W_OK); + return true; + } catch { + return false; + } +}; + +const resolveDaemonPaths = (mode: 'read' | 'write'): DaemonPaths => { + const candidates = daemonDirCandidates().map((dir) => buildDaemonPaths(dir)); + const existing = candidates.find((candidate) => hasDaemonStateFile(candidate)); + if (existing) { + return existing; + } + + if (mode === 'read') { + return candidates[0]; + } + + const writable = candidates.find((candidate) => ensureDirWritable(candidate.dir)); + return writable ?? candidates[0]; +}; + +let cachedDaemonPaths: DaemonPaths | undefined; + +const getDaemonPaths = (mode: 'read' | 'write' = 'read'): DaemonPaths => { + if (cachedDaemonPaths) { + if (mode === 'read') { + return cachedDaemonPaths; + } + + if (ensureDirWritable(cachedDaemonPaths.dir)) { + return cachedDaemonPaths; + } + } + + const resolved = resolveDaemonPaths(mode); + cachedDaemonPaths = resolved; + return resolved; +}; + const toPortOrFallback = (value: string | undefined, fallback: number): number => { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { @@ -59,7 +236,7 @@ const rewriteApiBasePort = (base: string | undefined, port: number): string | un }; const parseMode = (value?: string): WebMode => { - const mode = value ?? 'dev'; + const mode = value ?? 'preview'; if (mode === 'dev' || mode === 'preview') { return mode; } @@ -134,8 +311,8 @@ const pipeWithPrefix = ( }; const startManagedProcess = ( - label: 'api' | 'pwa', - script: 'dev' | 'start' | 'build', + label: WebServiceLabel, + script: WebScript, env: NodeJS.ProcessEnv, ): ManagedProcess => { const child = spawn(PNPM_BIN, ['--filter', `@tithe/${label}`, script], { @@ -167,7 +344,7 @@ const formatExit = (code: number | null, signal: NodeJS.Signals | null): string return `with code ${code}`; }; -const runBuild = async (label: 'api' | 'pwa', env: NodeJS.ProcessEnv): Promise => { +const runBuild = async (label: WebServiceLabel, env: NodeJS.ProcessEnv): Promise => { const task = startManagedProcess(label, 'build', env); await new Promise((resolve, reject) => { @@ -300,7 +477,11 @@ const runServices = async (processes: ManagedProcess[]): Promise => { }); }; -export const runWebCommand = async (options: WebCommandOptions, json: boolean): Promise => { +const resolveWebRuntime = (options: { + mode?: string; + apiPort?: string; + pwaPort?: string; +}): ResolvedWebRuntime => { const mode = parseMode(options.mode); const apiPort = parsePort(options.apiPort, '--api-port'); const pwaPort = parsePort(options.pwaPort, '--pwa-port'); @@ -329,48 +510,927 @@ export const runWebCommand = async (options: WebCommandOptions, json: boolean): apiPort !== undefined ? (rewriteApiBasePort(pwaEnv.VITE_API_BASE, resolvedApiPort) ?? fallbackApiBase) : (pwaEnv.VITE_API_BASE ?? fallbackApiBase); + pwaEnv.VITE_API_BASE = resolvedApiBase; - if (mode === 'preview') { - await runBuild('api', apiEnv); - await runBuild('pwa', pwaEnv); + const resolvedPwaPort = + mode === 'dev' + ? toPortOrFallback(pwaEnv.PWA_PORT, pwaPortFallback) + : toPortOrFallback(pwaEnv.PWA_PREVIEW_PORT, pwaPortFallback); + + return { + mode, + apiScript, + pwaScript, + apiEnv, + pwaEnv, + resolvedApiPort, + resolvedPwaPort, + resolvedApiBase, + }; +}; + +const trimTrailingDot = (value: string): string => value.replace(/\.$/, ''); + +const resolveWebAccessInfo = (apiPort: number, pwaPort: number): WebAccessInfo => { + const localApiUrl = `http://127.0.0.1:${apiPort}/v1`; + const localPwaUrl = `http://127.0.0.1:${pwaPort}`; + + const status = spawnSync('tailscale', ['status', '--json'], { + encoding: 'utf8', + timeout: tailscaleStatusTimeoutMs, + }); + + if (status.error) { + const error = status.error as NodeJS.ErrnoException; + return { + local: { + apiUrl: localApiUrl, + pwaUrl: localPwaUrl, + }, + tailnet: {}, + warning: + error.code === 'ENOENT' + ? 'Tailscale CLI not found; Tailnet URLs are unavailable.' + : `Failed to query Tailscale status: ${error.message}`, + }; + } + + if (status.signal) { + return { + local: { + apiUrl: localApiUrl, + pwaUrl: localPwaUrl, + }, + tailnet: {}, + warning: `Tailscale status command timed out after ${tailscaleStatusTimeoutMs}ms.`, + }; + } + + if (status.status !== 0) { + const stderr = status.stderr?.trim(); + return { + local: { + apiUrl: localApiUrl, + pwaUrl: localPwaUrl, + }, + tailnet: {}, + warning: stderr + ? `Tailscale status unavailable: ${stderr}` + : 'Tailscale status unavailable; Tailnet URLs are unavailable.', + }; + } + + try { + const parsed = JSON.parse(status.stdout) as Record; + const self = parsed.Self as Record | undefined; + const backendState = + typeof parsed.BackendState === 'string' ? (parsed.BackendState as string) : undefined; + + const dnsName = + typeof self?.DNSName === 'string' ? trimTrailingDot(self.DNSName as string) : undefined; + + const hostName = typeof self?.HostName === 'string' ? (self.HostName as string).trim() : ''; + const magicSuffix = + typeof parsed.MagicDNSSuffix === 'string' + ? trimTrailingDot((parsed.MagicDNSSuffix as string).trim()) + : ''; + + const tailscaleIps = Array.isArray(self?.TailscaleIPs) + ? (self?.TailscaleIPs as unknown[]).filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0, + ) + : []; + + const host = + dnsName || + (hostName && magicSuffix ? `${hostName}.${magicSuffix}` : undefined) || + tailscaleIps[0]; + + let warning: string | undefined; + if (!host) { + warning = 'Tailscale is available, but no Tailnet hostname or IP was detected.'; + } else if (backendState && backendState !== 'Running') { + warning = `Tailscale backend state is ${backendState}; Tailnet access may be unavailable.`; + } + + return { + local: { + apiUrl: localApiUrl, + pwaUrl: localPwaUrl, + }, + tailnet: { + host, + apiUrl: host ? `http://${host}:${apiPort}/v1` : undefined, + pwaUrl: host ? `http://${host}:${pwaPort}` : undefined, + }, + warning, + }; + } catch { + return { + local: { + apiUrl: localApiUrl, + pwaUrl: localPwaUrl, + }, + tailnet: {}, + warning: 'Failed to parse Tailscale status JSON; Tailnet URLs are unavailable.', + }; + } +}; + +const ensureDaemonDir = () => { + const paths = getDaemonPaths('write'); + fs.mkdirSync(paths.dir, { recursive: true }); +}; + +const writeJsonFile = (filePath: string, value: unknown) => { + ensureDaemonDir(); + const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const serialized = JSON.stringify(value, null, 2); + fs.writeFileSync(tempFile, serialized); + + try { + if (process.platform === 'win32' && fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + fs.renameSync(tempFile, filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform === 'win32' && (code === 'EEXIST' || code === 'EPERM')) { + fs.writeFileSync(filePath, serialized); + return; + } + + throw error; + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } +}; + +const readJsonFile = (filePath: string): T | undefined => { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw) as T; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return undefined; + } + return undefined; + } +}; + +const readDaemonPid = (): number | undefined => { + try { + const paths = getDaemonPaths('read'); + const raw = fs.readFileSync(paths.pidFile, 'utf8').trim(); + const pid = Number(raw); + if (!Number.isInteger(pid) || pid <= 0) { + return undefined; + } + return pid; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return undefined; + } + return undefined; + } +}; + +const writeDaemonPid = (pid: number) => { + ensureDaemonDir(); + const paths = getDaemonPaths('write'); + fs.writeFileSync(paths.pidFile, `${pid}\n`); +}; + +const removeDaemonPidFile = () => { + try { + const paths = getDaemonPaths('read'); + fs.unlinkSync(paths.pidFile); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + throw error; + } + } +}; + +const isPidRunning = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + return false; + } + if (code === 'EPERM') { + return true; + } + return false; + } +}; + +const loadDaemonState = (): WebDaemonState | undefined => + readJsonFile(getDaemonPaths('read').stateFile); + +const persistDaemonState = (state: WebDaemonState) => { + state.updatedAt = nowIso(); + writeJsonFile(getDaemonPaths('write').stateFile, state); +}; + +const createDaemonState = ( + runtime: ResolvedWebRuntime, + runId: string, + pid: number, + access: WebAccessInfo, +): WebDaemonState => ({ + version: 1, + runId, + pid, + mode: runtime.mode, + lifecycle: 'daemon', + workspaceRoot, + startedAt: nowIso(), + updatedAt: nowIso(), + status: 'starting', + logFile: getDaemonPaths('write').logFile, + access, + services: { + api: { + package: '@tithe/api', + script: runtime.apiScript, + port: runtime.resolvedApiPort, + restartCount: 0, + }, + pwa: { + package: '@tithe/pwa', + script: runtime.pwaScript, + port: runtime.resolvedPwaPort, + restartCount: 0, + }, + }, +}); + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const resolveCliEntryPath = (): string => { + if (process.argv[1]) { + return process.argv[1]; + } + + const extension = path.extname(modulePath); + return path.resolve(path.dirname(modulePath), extension === '.ts' ? 'index.ts' : 'index.js'); +}; + +const readWebDaemonStatus = (): WebDaemonStatusResponse => { + const paths = getDaemonPaths('read'); + const state = loadDaemonState(); + const pidFromFile = readDaemonPid(); + const pid = pidFromFile ?? state?.pid; + const running = pid !== undefined ? isPidRunning(pid) : false; + + if (pidFromFile !== undefined && !running) { + removeDaemonPidFile(); + } + + return { + running, + pid: running ? pid : undefined, + pidFile: paths.pidFile, + logFile: paths.logFile, + stateFile: paths.stateFile, + state, + }; +}; + +const startWebDaemon = async (runtime: ResolvedWebRuntime, json: boolean): Promise => { + ensureDaemonDir(); + const paths = getDaemonPaths('write'); + + const existingPid = readDaemonPid(); + if (existingPid !== undefined && isPidRunning(existingPid)) { + throw new AppError( + 'VALIDATION_ERROR', + `Web daemon is already running (pid ${existingPid}).`, + 400, + { + pid: existingPid, + }, + ); + } + + if (existingPid !== undefined && !isPidRunning(existingPid)) { + removeDaemonPidFile(); + } + + const runId = randomUUID(); + const access = resolveWebAccessInfo(runtime.resolvedApiPort, runtime.resolvedPwaPort); + + const cliEntryPath = resolveCliEntryPath(); + const logFd = fs.openSync(paths.logFile, 'a'); + + const supervisor = spawn( + process.execPath, + [ + ...process.execArgv, + cliEntryPath, + '--json', + 'web-supervisor', + '--mode', + runtime.mode, + '--api-port', + String(runtime.resolvedApiPort), + '--pwa-port', + String(runtime.resolvedPwaPort), + '--run-id', + runId, + ], + { + cwd: workspaceRoot, + env: { + ...process.env, + VITE_API_BASE: runtime.resolvedApiBase, + TITHE_WEB_DAEMON_DIR: paths.dir, + }, + detached: true, + stdio: ['ignore', logFd, logFd], + }, + ); + + fs.closeSync(logFd); + + if (!supervisor.pid) { + throw new AppError('INTERNAL_ERROR', 'Failed to start web daemon supervisor.', 500); + } + + writeDaemonPid(supervisor.pid); + + const state = createDaemonState(runtime, runId, supervisor.pid, access); + state.lastEvent = 'Daemon supervisor spawned.'; + persistDaemonState(state); + + supervisor.unref(); + + const payload = { + command: 'web', + mode: runtime.mode, + lifecycle: 'daemon', + daemon: { + status: 'starting' as const, + pid: supervisor.pid, + pidFile: paths.pidFile, + logFile: paths.logFile, + stateFile: paths.stateFile, + }, + services: { + api: { + package: '@tithe/api', + script: runtime.apiScript, + port: runtime.resolvedApiPort, + baseUrl: runtime.resolvedApiBase, + }, + pwa: { + package: '@tithe/pwa', + script: runtime.pwaScript, + port: runtime.resolvedPwaPort, + }, + }, + access, + warnings: access.warning ? [access.warning] : [], + }; + + if (json) { + console.log(JSON.stringify(ok(payload), null, 2)); + return; + } + + console.log(`Started web daemon (pid ${supervisor.pid}) in ${runtime.mode} mode.`); + console.log(`PWA local: ${access.local.pwaUrl}`); + if (access.tailnet.pwaUrl) { + console.log(`PWA tailnet: ${access.tailnet.pwaUrl}`); + } + if (access.warning) { + console.warn(`Warning: ${access.warning}`); + } + console.log('Status: tithe --json web --status'); + console.log('Stop: tithe --json web --stop'); +}; + +const stopWebDaemon = async (): Promise<{ + wasRunning: boolean; + stopped: boolean; + pid?: number; + timedOut?: boolean; + permissionDenied?: boolean; +}> => { + const pid = readDaemonPid(); + if (pid === undefined || !isPidRunning(pid)) { + removeDaemonPidFile(); + return { + wasRunning: false, + stopped: true, + }; + } + + try { + process.kill(pid, 'SIGTERM'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + removeDaemonPidFile(); + return { + wasRunning: false, + stopped: true, + }; + } + + if (code === 'EPERM') { + return { + wasRunning: true, + stopped: false, + pid, + permissionDenied: true, + }; + } + + throw error; + } + + const deadline = Date.now() + daemonShutdownTimeoutMs; + while (Date.now() < deadline) { + if (!isPidRunning(pid)) { + removeDaemonPidFile(); + return { + wasRunning: true, + stopped: true, + pid, + }; + } + + await sleep(daemonStatusPollMs); } + return { + wasRunning: true, + stopped: false, + pid, + timedOut: true, + }; +}; + +const runSupervisorLoop = async ( + runtime: ResolvedWebRuntime, + state: WebDaemonState, + services: Record, +): Promise => { + await new Promise((resolve, reject) => { + let settled = false; + let shuttingDown = false; + const restartTimers = new Map(); + + const stopChildren = (signal: NodeJS.Signals = 'SIGTERM') => { + for (const service of Object.values(services)) { + const managed = service.managed; + if (!managed) { + continue; + } + + if (managed.child.exitCode === null && managed.child.signalCode === null) { + managed.child.kill(signal); + } + } + }; + + const persist = () => { + persistDaemonState(state); + }; + + const hasRunningChildren = (): boolean => + Object.values(services).some( + (service) => + service.managed !== undefined && + service.managed.child.exitCode === null && + service.managed.child.signalCode === null, + ); + + const cleanup = () => { + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + for (const timer of restartTimers.values()) { + clearTimeout(timer); + } + restartTimers.clear(); + }; + + const finishResolve = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + + const finishReject = (error: AppError) => { + if (settled) { + return; + } + settled = true; + shuttingDown = true; + stopChildren('SIGTERM'); + cleanup(); + reject(error); + }; + + const refreshOverallStatus = () => { + if (shuttingDown) { + state.status = 'stopping'; + } else if (Object.values(services).every((service) => service.managed !== undefined)) { + state.status = 'running'; + } else if (state.status === 'starting') { + state.status = 'starting'; + } else { + state.status = 'degraded'; + } + persist(); + }; + + const handleSignal = (signal: NodeJS.Signals) => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + state.status = 'stopping'; + state.lastEvent = `Received ${signal}; stopping services.`; + persist(); + + for (const timer of restartTimers.values()) { + clearTimeout(timer); + } + restartTimers.clear(); + + stopChildren('SIGTERM'); + + if (!hasRunningChildren()) { + finishResolve(); + } + }; + + const startService = (label: WebServiceLabel) => { + const service = services[label]; + let managed: ManagedProcess; + + try { + managed = startManagedProcess(label, service.script, service.env); + } catch (error) { + finishReject( + new AppError( + 'INTERNAL_ERROR', + `Failed to spawn ${label} process: ${error instanceof Error ? error.message : String(error)}`, + 500, + ), + ); + return; + } + + service.managed = managed; + service.startedAtMs = Date.now(); + + state.services[label].pid = managed.child.pid ?? undefined; + state.services[label].lastStartAt = nowIso(); + state.lastEvent = `${label} started (pid ${managed.child.pid ?? 'unknown'}).`; + refreshOverallStatus(); + + managed.child.once('error', (error) => { + if (settled || shuttingDown) { + return; + } + + state.status = 'degraded'; + state.lastEvent = `${label} process error: ${error.message}`; + persist(); + }); + + managed.child.once('close', (code, signal) => { + managed.flushLogs(); + service.managed = undefined; + + state.services[label].pid = undefined; + state.services[label].lastExitAt = nowIso(); + state.services[label].lastExit = { + code, + signal, + }; + + if (shuttingDown) { + refreshOverallStatus(); + if (!hasRunningChildren()) { + finishResolve(); + } + return; + } + + const uptimeMs = service.startedAtMs ? Date.now() - service.startedAtMs : 0; + if (uptimeMs > restartStableWindowMs) { + service.failureStreak = 0; + } else { + service.failureStreak += 1; + } + + service.restartCount += 1; + state.services[label].restartCount = service.restartCount; + + const cappedExponent = Math.min(service.failureStreak, 5); + const backoffMs = Math.min(restartBackoffMaxMs, restartBackoffBaseMs * 2 ** cappedExponent); + + state.status = 'degraded'; + state.lastEvent = `${label} exited ${formatExit(code, signal)}. Restarting in ${backoffMs}ms.`; + persist(); + + const existingTimer = restartTimers.get(label); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + restartTimers.delete(label); + + if (shuttingDown || settled) { + return; + } + + startService(label); + }, backoffMs); + + restartTimers.set(label, timer); + }); + }; + + process.on('SIGINT', handleSignal); + process.on('SIGTERM', handleSignal); + + startService('api'); + startService('pwa'); + + state.status = 'running'; + state.lastEvent = 'Supervisor started API and PWA.'; + persist(); + + if (!hasRunningChildren()) { + finishReject(new AppError('INTERNAL_ERROR', 'Supervisor failed to start services.', 500)); + } + }); +}; + +const validateControlOptions = (options: WebCommandOptions) => { + const daemon = options.daemon === true; + const status = options.status === true; + const stop = options.stop === true; + + const enabledControls = [daemon, status, stop].filter(Boolean).length; + if (enabledControls > 1) { + throw new AppError('VALIDATION_ERROR', 'Use only one of: --daemon, --status, --stop.', 400, { + daemon, + status, + stop, + }); + } + + if ( + (status || stop) && + (options.mode !== undefined || options.apiPort !== undefined || options.pwaPort !== undefined) + ) { + throw new AppError( + 'VALIDATION_ERROR', + '--status/--stop cannot be combined with --mode, --api-port, or --pwa-port.', + 400, + ); + } +}; + +export const runWebSupervisorCommand = async ( + options: WebSupervisorCommandOptions, +): Promise => { + const runtime = resolveWebRuntime({ + mode: options.mode, + apiPort: options.apiPort, + pwaPort: options.pwaPort, + }); + + const runId = options.runId ?? randomUUID(); + const access = resolveWebAccessInfo(runtime.resolvedApiPort, runtime.resolvedPwaPort); + + const state = createDaemonState(runtime, runId, process.pid, access); + state.lastEvent = 'Daemon supervisor booting.'; + + writeDaemonPid(process.pid); + persistDaemonState(state); + + try { + if (runtime.mode === 'preview') { + state.status = 'starting'; + state.lastEvent = 'Running preview builds before daemon startup.'; + persistDaemonState(state); + await runBuild('api', runtime.apiEnv); + await runBuild('pwa', runtime.pwaEnv); + } + + const services: Record = { + api: { + label: 'api', + script: runtime.apiScript, + env: runtime.apiEnv, + restartCount: 0, + failureStreak: 0, + }, + pwa: { + label: 'pwa', + script: runtime.pwaScript, + env: runtime.pwaEnv, + restartCount: 0, + failureStreak: 0, + }, + }; + + await runSupervisorLoop(runtime, state, services); + + state.status = 'stopped'; + state.lastEvent = 'Daemon supervisor stopped cleanly.'; + persistDaemonState(state); + } catch (error) { + state.status = 'crashed'; + state.lastEvent = + error instanceof Error + ? `Daemon supervisor crashed: ${error.message}` + : 'Daemon supervisor crashed.'; + persistDaemonState(state); + + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + 'INTERNAL_ERROR', + error instanceof Error ? error.message : String(error), + 500, + ); + } finally { + removeDaemonPidFile(); + } +}; + +export const runWebCommand = async (options: WebCommandOptions, json: boolean): Promise => { + validateControlOptions(options); + + if (options.status) { + const status = readWebDaemonStatus(); + + if (json) { + console.log( + JSON.stringify( + ok({ + command: 'web', + lifecycle: 'daemon', + daemon: status, + }), + null, + 2, + ), + ); + return; + } + + if (!status.running) { + console.log('Web daemon is not running.'); + console.log(`State file: ${status.stateFile}`); + console.log(`Log file: ${status.logFile}`); + return; + } + + console.log(`Web daemon is running (pid ${status.pid}).`); + console.log(`State file: ${status.stateFile}`); + console.log(`Log file: ${status.logFile}`); + if (status.state?.access?.tailnet?.pwaUrl) { + console.log(`PWA tailnet: ${status.state.access.tailnet.pwaUrl}`); + } + if (status.state?.access?.warning) { + console.warn(`Warning: ${status.state.access.warning}`); + } + return; + } + + if (options.stop) { + const result = await stopWebDaemon(); + + if (json) { + console.log( + JSON.stringify( + ok({ + command: 'web', + lifecycle: 'daemon', + daemon: { + stopRequested: true, + wasRunning: result.wasRunning, + stopped: result.stopped, + pid: result.pid, + timedOut: result.timedOut ?? false, + permissionDenied: result.permissionDenied ?? false, + }, + }), + null, + 2, + ), + ); + return; + } + + if (!result.wasRunning) { + console.log('Web daemon was not running.'); + return; + } + + if (result.stopped) { + console.log(`Stopped web daemon (pid ${result.pid}).`); + return; + } + + if (result.permissionDenied) { + console.log(`Permission denied while stopping web daemon (pid ${result.pid}).`); + return; + } + + console.log(`Timed out while stopping web daemon (pid ${result.pid}).`); + return; + } + + const runtime = resolveWebRuntime({ + mode: options.mode, + apiPort: options.apiPort, + pwaPort: options.pwaPort, + }); + + if (options.daemon) { + await startWebDaemon(runtime, json); + return; + } + + if (runtime.mode === 'preview') { + await runBuild('api', runtime.apiEnv); + await runBuild('pwa', runtime.pwaEnv); + } + + const access = resolveWebAccessInfo(runtime.resolvedApiPort, runtime.resolvedPwaPort); + if (json) { console.log( JSON.stringify( ok({ command: 'web', - mode, + mode: runtime.mode, lifecycle: 'foreground', services: { api: { package: '@tithe/api', - script: apiScript, - port: resolvedApiPort, - baseUrl: resolvedApiBase, + script: runtime.apiScript, + port: runtime.resolvedApiPort, + baseUrl: runtime.resolvedApiBase, }, pwa: { package: '@tithe/pwa', - script: pwaScript, - port: - mode === 'dev' - ? toPortOrFallback(pwaEnv.PWA_PORT, pwaPortFallback) - : toPortOrFallback(pwaEnv.PWA_PREVIEW_PORT, pwaPortFallback), + script: runtime.pwaScript, + port: runtime.resolvedPwaPort, }, }, + access, + warnings: access.warning ? [access.warning] : [], }), null, 2, ), ); } else { - console.log(`Starting web stack in ${mode} mode (foreground).`); + console.log(`Starting web stack in ${runtime.mode} mode (foreground).`); + console.log(`PWA local: ${access.local.pwaUrl}`); + if (access.tailnet.pwaUrl) { + console.log(`PWA tailnet: ${access.tailnet.pwaUrl}`); + } + if (access.warning) { + console.warn(`Warning: ${access.warning}`); + } } const processes: ManagedProcess[] = [ - startManagedProcess('api', apiScript, apiEnv), - startManagedProcess('pwa', pwaScript, pwaEnv), + startManagedProcess('api', runtime.apiScript, runtime.apiEnv), + startManagedProcess('pwa', runtime.pwaScript, runtime.pwaEnv), ]; await runServices(processes); diff --git a/tests/cli/web.spec.ts b/tests/cli/web.spec.ts index 4eefea9..ddc520a 100644 --- a/tests/cli/web.spec.ts +++ b/tests/cli/web.spec.ts @@ -58,6 +58,24 @@ describe('CLI web command', () => { expect(payload.error.code).toBe('VALIDATION_ERROR'); }); + it('rejects combining daemon control flags', () => { + const result = runCli(['--json', 'web', '--daemon', '--status']); + const payload = JSON.parse(result.stdout); + + expect(result.status).toBe(1); + expect(payload.ok).toBe(false); + expect(payload.error.code).toBe('VALIDATION_ERROR'); + }); + + it('rejects status/stop mixed with port overrides', () => { + const result = runCli(['--json', 'web', '--stop', '--api-port', '8787']); + const payload = JSON.parse(result.stdout); + + expect(result.status).toBe(1); + expect(payload.ok).toBe(false); + expect(payload.error.code).toBe('VALIDATION_ERROR'); + }); + it('returns validation error for api port lower than range', () => { const result = runCli(['--json', 'web', '--api-port', '0']); const payload = JSON.parse(result.stdout);