Files
web-terminal/src/config.ts
Yaojia Wang 03612323c0 feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
2026-07-01 04:25:57 +02:00

440 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* src/config.ts — T4 / T-config: environment config loader
*
* Reads env vars once at startup, validates them, derives allowedOrigins from
* local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
* and returns a frozen Config object.
*
* v0.7 adds 21 new env vars for the Walk-away Workbench features (A1A5, B1B4).
* The returned object is a superset of Config (all existing fields + 21 new v0.7
* fields). Callers that use `const cfg = loadConfig(env)` (no explicit `: Config`
* annotation) get the full type with all new fields accessible. Callers that
* narrow to `Config` (e.g. function params typed as Config) still work because
* the wider type is structurally assignable to Config.
*
* NOTE(T-config): The Config interface in src/types.ts should be extended with the
* 21 new fields listed below so downstream tasks (T-server-wire, N-push, etc.) can
* access them type-safely via the Config type. This is a coordination point for the
* orchestrator — the impl is here, the interface declaration belongs in T-types.
*/
import os from 'node:os'
import path from 'node:path'
import type { Config, EnvLike, PermissionMode } from './types.js'
import { tmuxAvailable } from './session/tmux.js'
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
function resolveUseTmux(raw: string | undefined): boolean {
const v = raw?.trim().toLowerCase()
if (v === '1' || v === 'true' || v === 'on') return true
if (v === '0' || v === 'false' || v === 'off') return false
return tmuxAvailable() // unset / 'auto'
}
// ── constants ─────────────────────────────────────────────────────────────────
const DEFAULT_PORT = 3000
const DEFAULT_BIND_HOST = '0.0.0.0'
const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
const DEFAULT_WS_PATH = '/term'
const DEFAULT_MAX_SESSIONS = 50
const DEFAULT_MAX_MSGS_PER_SEC = 2000
const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min
const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git
const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL
const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host
// v0.7 Walk-away Workbench defaults
// A1 push notifications
const DEFAULT_PUSH_MAX_SUBS = 50
// A4 timeline
const DEFAULT_TIMELINE_MAX = 200
// A5 stuck detection
const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
// B1 git diff
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_DIFF_MAX_FILES = 300
// B2 statusline telemetry
const DEFAULT_STATUSLINE_TTL_MS = 30_000
// B3 worktrees
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */
const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
// ── helpers ───────────────────────────────────────────────────────────────────
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
function parseNonNegativeInt(
raw: string | undefined,
label: string,
fallback: number,
): number {
if (raw === undefined) return fallback
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer`,
)
}
return n
}
/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */
function parseBool(raw: string | undefined, fallback: boolean): boolean {
const v = raw?.trim().toLowerCase()
if (v === undefined || v === '') return fallback
if (v === '1' || v === 'true' || v === 'on') return true
if (v === '0' || v === 'false' || v === 'off') return false
return fallback
}
/** Parse a seconds-valued env var and return the equivalent milliseconds, or fallback (ms). */
function parseSecondsToMs(
raw: string | undefined,
label: string,
fallbackMs: number,
): number {
if (raw === undefined) return fallbackMs
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
)
}
return n * 1000
}
/** Parse and validate a PermissionMode env var; throws for unknown values (including empty string). */
function parsePermissionMode(raw: string | undefined, label: string): PermissionMode {
if (raw === undefined) return 'default'
if ((PERMISSION_MODES as readonly string[]).includes(raw)) return raw as PermissionMode
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be one of ${PERMISSION_MODES.join('|')}`,
)
}
/** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir.
* Empty / unset → [homeDir]. */
function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] {
if (raw === undefined || raw.trim() === '') return Object.freeze([homeDir])
const roots = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '')
.map((s) => (s === '~' ? homeDir : s.startsWith('~/') ? homeDir + s.slice(1) : s))
// Fail fast on relative roots: silently resolving them against the server's
// CWD makes the scan target depend on where the process was launched (M1-style
// "no surprising defaults"). Require absolute paths (after '~' expansion).
for (const r of roots) {
if (!path.isAbsolute(r)) {
throw new Error(
`Invalid config: PROJECT_ROOTS entry ${JSON.stringify(r)} — must be an absolute path (or '~' / '~/...').`,
)
}
}
return Object.freeze(roots.length > 0 ? roots : [homeDir])
}
function parsePort(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_PORT
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
throw new Error(
`Invalid config: PORT=${JSON.stringify(raw)} — must be an integer 165535`,
)
}
return n
}
function parseIdleTtl(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_IDLE_TTL_SEC * 1000
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: IDLE_TTL=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
)
}
return n * 1000
}
/**
* Derive allowedOrigins from host NIC IPs (M1).
*
* Rules:
* - Enumerate os.networkInterfaces(), pick IPv4 entries.
* - Skip internal (loopback) entries — 127.0.0.1 / localhost are added explicitly.
* - For every qualifying address, produce both http:// and https:// origins.
* - Append ALLOWED_ORIGINS env-var items (comma-separated).
* - Deduplicate.
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
*/
/** True iff `value` parses as a URL with an http: or https: scheme (M1). */
function isHttpOrigin(value: string): boolean {
try {
const u = new URL(value)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
const set = new Set<string>()
// 1. Always include localhost / loopback
const loopbackHosts = ['localhost', '127.0.0.1']
for (const host of loopbackHosts) {
set.add(`http://${host}:${port}`)
set.add(`https://${host}:${port}`)
}
// 2. Enumerate NIC IPv4 addresses — skip internal (loopback already handled above)
const ifaces = os.networkInterfaces()
for (const ifaceList of Object.values(ifaces)) {
if (!ifaceList) continue
for (const entry of ifaceList) {
// Only IPv4, non-internal entries; never 0.0.0.0
if (
entry.family === 'IPv4' &&
!entry.internal &&
entry.address !== '0.0.0.0'
) {
set.add(`http://${entry.address}:${port}`)
set.add(`https://${entry.address}:${port}`)
}
}
}
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings).
// Reject anything that is not a well-formed http(s) origin so an exotic
// scheme (file:, data:, javascript:) can't slip into the whitelist (M1).
if (extraEnv) {
for (const raw of extraEnv.split(',')) {
const trimmed = raw.trim()
if (trimmed === '') continue
if (isHttpOrigin(trimmed)) set.add(trimmed)
}
}
return Object.freeze([...set])
}
// ── loadConfig ────────────────────────────────────────────────────────────────
/**
* Read and validate environment variables; fill in defaults for missing keys.
* Invalid values (port out of range, TTL non-integer, etc.) throw immediately
* so the server never starts in a misconfigured state (fail-fast).
*
* The returned object is Object.freeze'd — immutable after construction. It
* contains all existing Config fields plus 21 new v0.7 Walk-away Workbench
* fields. The return type is inferred (wider than Config) so callers can access
* the new fields without explicit type assertions. Functions accepting Config
* still work because the wider type is structurally assignable to Config.
*
* L5 (SEC-M4): permTimeoutMs must be > 0 — it is used to compute the
* --max-time flag in setup-hooks.mjs (T-hooks-installer). Zero would make
* --max-time=0 which disables the curl timeout entirely.
*/
export function loadConfig(env: EnvLike): Config {
const port = parsePort(env['PORT'])
const bindHost = env['BIND_HOST'] ?? DEFAULT_BIND_HOST
const shellPath =
env['SHELL_PATH'] ?? (process.env['SHELL'] as string | undefined) ?? '/bin/zsh'
const homeDir = os.homedir()
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
const scrollbackBytes = parseNonNegativeInt(
env['SCROLLBACK_BYTES'],
'SCROLLBACK_BYTES',
DEFAULT_SCROLLBACK_BYTES,
)
const maxPayloadBytes = parseNonNegativeInt(
env['MAX_PAYLOAD_BYTES'],
'MAX_PAYLOAD_BYTES',
DEFAULT_MAX_PAYLOAD_BYTES,
)
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
const maxSessions = parseNonNegativeInt(env['MAX_SESSIONS'], 'MAX_SESSIONS', DEFAULT_MAX_SESSIONS)
const maxMsgsPerSec = parseNonNegativeInt(
env['MAX_MSGS_PER_SEC'],
'MAX_MSGS_PER_SEC',
DEFAULT_MAX_MSGS_PER_SEC,
)
const permTimeoutMs = parseNonNegativeInt(
env['PERM_TIMEOUT_MS'],
'PERM_TIMEOUT_MS',
DEFAULT_PERM_TIMEOUT_MS,
)
// L5 / SEC-M4: permTimeoutMs must be > 0 (T-hooks-installer uses it for --max-time)
if (permTimeoutMs === 0) {
throw new Error(
'Invalid config: PERM_TIMEOUT_MS must be > 0 (used to calculate --max-time for setup-hooks)',
)
}
const reapIntervalMs = parseNonNegativeInt(
env['REAP_INTERVAL_MS'],
'REAP_INTERVAL_MS',
DEFAULT_REAP_INTERVAL_MS,
)
const previewBytes = parseNonNegativeInt(env['PREVIEW_BYTES'], 'PREVIEW_BYTES', DEFAULT_PREVIEW_BYTES)
const useTmux = resolveUseTmux(env['USE_TMUX'])
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
// v0.6 Project Manager — discovery config
const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir)
const projectScanDepth = parseNonNegativeInt(
env['PROJECT_SCAN_DEPTH'],
'PROJECT_SCAN_DEPTH',
DEFAULT_PROJECT_SCAN_DEPTH,
)
const projectScanTtlMs = parseNonNegativeInt(
env['PROJECT_SCAN_TTL'],
'PROJECT_SCAN_TTL',
DEFAULT_PROJECT_SCAN_TTL_MS,
)
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD
// ── v0.7 Walk-away Workbench — 21 new env vars ─────────────────────────────
// A1 push notifications (SEC-C5: vapidPrivateKey never logged by this module)
const vapidPublicKey = env['VAPID_PUBLIC_KEY'] || undefined
const vapidPrivateKey = env['VAPID_PRIVATE_KEY'] || undefined // SECRET — never log
const vapidSubject = env['VAPID_SUBJECT'] || undefined
const pushStorePath =
env['PUSH_STORE_PATH'] || path.join(homeDir, '.web-terminal-push-subs.json')
const pushMaxSubs = parseNonNegativeInt(env['PUSH_MAX_SUBS'], 'PUSH_MAX_SUBS', DEFAULT_PUSH_MAX_SUBS)
const prefsStorePath =
env['PREFS_STORE_PATH'] || path.join(homeDir, '.web-terminal-prefs.json')
const notifyDone = parseBool(env['NOTIFY_DONE'], true) // default on
const notifyDnd = parseBool(env['NOTIFY_DND'], false) // default off (do not disturb)
// DECISION_TOKEN_TTL_MS defaults to permTimeoutMs (parsed after it, order matters)
const decisionTokenTtlMs = parseNonNegativeInt(
env['DECISION_TOKEN_TTL_MS'],
'DECISION_TOKEN_TTL_MS',
permTimeoutMs,
)
// A4 activity timeline
const timelineMax = parseNonNegativeInt(env['TIMELINE_MAX'], 'TIMELINE_MAX', DEFAULT_TIMELINE_MAX)
const timelineEnabled = parseBool(env['TIMELINE_ENABLED'], true)
// A5 stuck detection (STUCK_TTL env var is in seconds; stored in ms)
const stuckTtlMs = parseSecondsToMs(env['STUCK_TTL'], 'STUCK_TTL', DEFAULT_STUCK_TTL_SEC * 1000)
const stuckAlert = parseBool(env['STUCK_ALERT'], true)
// B1 git diff viewer
const diffTimeoutMs = parseNonNegativeInt(
env['DIFF_TIMEOUT_MS'],
'DIFF_TIMEOUT_MS',
DEFAULT_DIFF_TIMEOUT_MS,
)
const diffMaxBytes = parseNonNegativeInt(
env['DIFF_MAX_BYTES'],
'DIFF_MAX_BYTES',
DEFAULT_DIFF_MAX_BYTES,
)
const diffMaxFiles = parseNonNegativeInt(
env['DIFF_MAX_FILES'],
'DIFF_MAX_FILES',
DEFAULT_DIFF_MAX_FILES,
)
// B2 statusLine telemetry
const statuslineTtlMs = parseNonNegativeInt(
env['STATUSLINE_TTL_MS'],
'STATUSLINE_TTL_MS',
DEFAULT_STATUSLINE_TTL_MS,
)
// B3 git worktree creation
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
const worktreeTimeoutMs = parseNonNegativeInt(
env['WORKTREE_TIMEOUT_MS'],
'WORKTREE_TIMEOUT_MS',
DEFAULT_WORKTREE_TIMEOUT_MS,
)
// B4 permission mode relay
const defaultPermissionMode = parsePermissionMode(
env['DEFAULT_PERMISSION_MODE'],
'DEFAULT_PERMISSION_MODE',
)
const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false) // default off (SEC-M5)
// ── assemble + freeze ───────────────────────────────────────────────────────
// `satisfies Config` on the base object verifies all existing Config fields
// are present without triggering excess-property checks on the v0.7 additions.
const base = {
port,
bindHost,
shellPath,
homeDir,
idleTtlMs,
scrollbackBytes,
maxPayloadBytes,
wsPath,
maxSessions,
maxMsgsPerSec,
permTimeoutMs,
reapIntervalMs,
previewBytes,
useTmux,
allowedOrigins,
projectRoots,
projectScanDepth,
projectScanTtlMs,
projectDirtyCheck,
editorCmd,
}
return Object.freeze({
...base,
// v0.7 additions (21 new fields)
vapidPublicKey,
vapidPrivateKey,
vapidSubject,
pushStorePath,
pushMaxSubs,
prefsStorePath,
notifyDone,
notifyDnd,
decisionTokenTtlMs,
timelineMax,
timelineEnabled,
stuckTtlMs,
stuckAlert,
diffTimeoutMs,
diffMaxBytes,
diffMaxFiles,
statuslineTtlMs,
worktreeEnabled,
worktreeRoot,
worktreeTimeoutMs,
defaultPermissionMode,
allowAutoMode,
})
}