@tiuxo/opencode-openrgb 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The lighting controller: owns the OpenRGB connection, the aggregated session
3
+ * state, and everything that decides what the hardware should show.
4
+ *
5
+ * Kept separate from `index.ts` so the logic stays directly importable and
6
+ * testable without going through the plugin entry point.
7
+ */
8
+
9
+ import { OpenRGBClient, describe, findDirectMode, type Logger } from "./client.js"
10
+ import { formatColor, scaleColor } from "./palette.js"
11
+ import type { Device, Mode } from "./protocol.js"
12
+ import { selectDevices, type ResolvedOptions, type StateColor } from "./options.js"
13
+ import { StateTracker, type LightingState, type OpencodeEvent } from "./state.js"
14
+
15
+ const LOG_PREFIX = "[opencode-openrgb]"
16
+
17
+ /** Breathing effect bounds, as a fraction of the state's base brightness. */
18
+ const PULSE_MIN = 0.25
19
+ const PULSE_MAX = 1
20
+ const PULSE_PERIOD_MS = 2_400
21
+
22
+ interface Snapshot {
23
+ activeMode: number
24
+ colors: number[]
25
+ }
26
+
27
+ export class Controller {
28
+ private readonly client: OpenRGBClient
29
+ private readonly tracker: StateTracker
30
+ private readonly log: Logger
31
+
32
+ private targets: Device[] = []
33
+ private readonly snapshots = new Map<number, Snapshot>()
34
+ /** Devices whose active mode we changed, and so must change back. */
35
+ private readonly modeSwitched = new Set<number>()
36
+
37
+ private applied: LightingState | null = null
38
+ private debounceTimer: NodeJS.Timeout | null = null
39
+ private pulseTimer: NodeJS.Timeout | null = null
40
+ private pulseStartedAt = 0
41
+ private disposed = false
42
+
43
+ constructor(private readonly options: ResolvedOptions) {
44
+ this.log = makeLogger(options.debug)
45
+
46
+ this.tracker = new StateTracker({
47
+ onExpire: () => this.schedule(),
48
+ })
49
+
50
+ this.client = new OpenRGBClient({
51
+ host: options.host,
52
+ port: options.port,
53
+ clientName: "opencode",
54
+ logger: this.log,
55
+ onDevicesReloaded: (devices) => this.onDevicesReloaded(devices),
56
+ })
57
+ }
58
+
59
+ start(): void {
60
+ void this.client.ensureReady().then((ok) => {
61
+ if (!ok) {
62
+ this.log.debug("OpenRGB not reachable yet; will keep retrying in the background")
63
+ }
64
+ })
65
+ }
66
+
67
+ handleEvent(event: OpencodeEvent): void {
68
+ if (this.disposed) return
69
+ try {
70
+ if (this.tracker.handle(event)) this.schedule()
71
+ } catch (error) {
72
+ this.log.warn(`event handling failed: ${describe(error)}`)
73
+ }
74
+ }
75
+
76
+ /*-------------------------------------------------------------------------*\
77
+ | Device targeting and snapshots |
78
+ \*-------------------------------------------------------------------------*/
79
+
80
+ private onDevicesReloaded(devices: readonly Device[]): void {
81
+ this.targets = selectDevices(devices, this.options)
82
+ this.snapshots.clear()
83
+ this.modeSwitched.clear()
84
+
85
+ for (const device of this.targets) {
86
+ this.snapshots.set(device.index, {
87
+ activeMode: device.activeMode,
88
+ colors: [...device.colors],
89
+ })
90
+ }
91
+
92
+ if (this.targets.length === 0) {
93
+ this.log.warn(
94
+ `no OpenRGB devices matched. Available: ${
95
+ devices.map((d) => `${d.name} (${d.typeName})`).join(", ") || "none"
96
+ }`,
97
+ )
98
+ } else {
99
+ this.log.debug(`targeting ${this.targets.map((d) => d.name).join(", ")}`)
100
+ this.warnIfSnapshotLooksStale()
101
+ }
102
+
103
+ // The connection is new, so whatever we previously applied is meaningless.
104
+ this.applied = null
105
+ this.schedule(0)
106
+ }
107
+
108
+ /**
109
+ * If the lighting we just captured is uniformly one of our own state colors,
110
+ * a previous run almost certainly failed to restore and we have snapshotted
111
+ * its leftovers as the "baseline". Say so, rather than silently treating a
112
+ * status color as the user's lighting.
113
+ */
114
+ private warnIfSnapshotLooksStale(): void {
115
+ const palette = new Map<number, string>()
116
+ for (const [state, spec] of Object.entries(this.options.colors)) {
117
+ // Only fixed colors are a tell: a baseline state paints the snapshot
118
+ // back, so matching it proves nothing.
119
+ if (spec.kind === "fixed") palette.set(spec.color, state)
120
+ }
121
+
122
+ for (const device of this.targets) {
123
+ const colors = this.snapshots.get(device.index)?.colors ?? []
124
+ if (colors.length === 0) continue
125
+ const first = colors[0]!
126
+ if (!colors.every((c) => c === first)) continue
127
+
128
+ const state = palette.get(first)
129
+ if (state) {
130
+ this.log.warn(
131
+ `"${device.name}" was already showing the ${state} color (${formatColor(first)}) ` +
132
+ `at startup, so that is now the restore baseline. If a previous run exited ` +
133
+ `without restoring, reload your OpenRGB profile and restart opencode.`,
134
+ )
135
+ }
136
+ }
137
+ }
138
+
139
+ /*-------------------------------------------------------------------------*\
140
+ | Scheduling |
141
+ \*-------------------------------------------------------------------------*/
142
+
143
+ private schedule(overrideDelay?: number): void {
144
+ if (this.disposed) return
145
+
146
+ const next = this.tracker.resolve()
147
+ const delay =
148
+ overrideDelay ??
149
+ (next === "idle"
150
+ ? this.options.debounceMs + this.options.restoreDelayMs
151
+ : this.options.debounceMs)
152
+
153
+ if (this.debounceTimer) clearTimeout(this.debounceTimer)
154
+ this.debounceTimer = setTimeout(() => {
155
+ this.debounceTimer = null
156
+ void this.apply()
157
+ }, delay)
158
+ this.debounceTimer.unref?.()
159
+ }
160
+
161
+ private async apply(): Promise<void> {
162
+ if (this.disposed) return
163
+
164
+ const state = this.tracker.resolve()
165
+ if (state === this.applied) return
166
+
167
+ const ready = await this.client.ensureReady()
168
+ if (!ready || this.disposed) return
169
+ if (this.targets.length === 0) return
170
+
171
+ // Re-resolve: awaiting the connection may have taken a while.
172
+ const current = this.tracker.resolve()
173
+
174
+ try {
175
+ if (current === "idle" && this.options.idle.kind === "restore") {
176
+ this.stopPulse()
177
+ this.restore()
178
+ } else {
179
+ const spec: StateColor =
180
+ current === "idle"
181
+ ? { kind: "fixed", color: (this.options.idle as { color: number }).color }
182
+ : this.options.colors[current]
183
+
184
+ if (this.shouldPulse(current, spec)) {
185
+ this.startPulse(spec)
186
+ } else {
187
+ this.stopPulse()
188
+ this.paint(spec, 1)
189
+ }
190
+ }
191
+ this.applied = current
192
+ this.log.debug(
193
+ `state -> ${current}${
194
+ current !== "idle" && this.options.colors[current]?.kind === "baseline"
195
+ ? " (baseline)"
196
+ : ""
197
+ }`,
198
+ )
199
+ } catch (error) {
200
+ this.log.warn(`failed to apply state ${current}: ${describe(error)}`)
201
+ }
202
+ }
203
+
204
+ /*-------------------------------------------------------------------------*\
205
+ | Painting |
206
+ \*-------------------------------------------------------------------------*/
207
+
208
+ /**
209
+ * A state whose color is the baseline must pulse, otherwise it would render
210
+ * identically to idle and convey nothing. Otherwise, pulsing is opt-in and
211
+ * applies to the busy state only.
212
+ */
213
+ private shouldPulse(state: LightingState, spec: StateColor): boolean {
214
+ if (spec.kind === "baseline") return true
215
+ return this.options.pulse && state === "busy"
216
+ }
217
+
218
+ private paint(spec: StateColor, factor: number): void {
219
+ for (const device of this.targets) {
220
+ const colors = this.colorsFor(device, spec, factor)
221
+ if (colors.length === 0) continue
222
+ this.ensureDirectMode(device)
223
+ this.client.updateLeds(device.index, colors)
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Build the per-LED color array for a device.
229
+ *
230
+ * For `baseline` this scales each LED's *own* snapshot color, so a device
231
+ * whose profile uses several colors keeps that pattern and simply breathes.
232
+ */
233
+ private colorsFor(device: Device, spec: StateColor, factor: number): number[] {
234
+ const count = device.colors.length || device.leds.length
235
+ if (count === 0) return []
236
+
237
+ const intensity = this.options.brightness * factor
238
+
239
+ if (spec.kind === "fixed") {
240
+ return new Array<number>(count).fill(scaleColor(spec.color, intensity))
241
+ }
242
+
243
+ const base = this.snapshots.get(device.index)?.colors ?? []
244
+ if (base.length === 0) return []
245
+ return Array.from({ length: count }, (_, i) =>
246
+ scaleColor(base[i] ?? base[base.length - 1]!, intensity),
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Per-LED color only takes effect in a direct/custom mode, so switch the
252
+ * device into one. Most devices are already there (both reference devices
253
+ * report "Direct" as the active mode), in which case this is a no-op.
254
+ */
255
+ private ensureDirectMode(device: Device): void {
256
+ const direct = findDirectMode(device)
257
+ if (!direct) {
258
+ this.client.setCustomMode(device.index)
259
+ this.modeSwitched.add(device.index)
260
+ return
261
+ }
262
+ if (device.activeMode === direct.index) return
263
+ if (this.modeSwitched.has(device.index)) return
264
+
265
+ this.client.updateMode(device.index, direct.index, direct.mode)
266
+ this.modeSwitched.add(device.index)
267
+ }
268
+
269
+ /**
270
+ * @param sync Use synchronous socket writes. Required when restoring from a
271
+ * process `exit` handler, where queued writes never reach the server.
272
+ */
273
+ private restore(sync = false): void {
274
+ for (const device of this.targets) {
275
+ const snapshot = this.snapshots.get(device.index)
276
+ if (!snapshot) continue
277
+
278
+ if (this.modeSwitched.has(device.index)) {
279
+ const mode: Mode | undefined = device.modes[snapshot.activeMode]
280
+ if (mode) this.client.updateMode(device.index, snapshot.activeMode, mode, sync)
281
+ this.modeSwitched.delete(device.index)
282
+ }
283
+
284
+ if (snapshot.colors.length > 0) {
285
+ this.client.updateLeds(device.index, snapshot.colors, sync)
286
+ }
287
+ }
288
+ }
289
+
290
+ /*-------------------------------------------------------------------------*\
291
+ | Pulse |
292
+ \*-------------------------------------------------------------------------*/
293
+
294
+ private startPulse(spec: StateColor): void {
295
+ this.stopPulse()
296
+ this.pulseStartedAt = Date.now()
297
+
298
+ const render = () => {
299
+ const elapsed = Date.now() - this.pulseStartedAt
300
+ const phase = ((elapsed % PULSE_PERIOD_MS) / PULSE_PERIOD_MS) * Math.PI * 2
301
+ // Cosine eased 0..1, mapped into the pulse band.
302
+ const wave = (1 - Math.cos(phase)) / 2
303
+ this.paint(spec, PULSE_MIN + (PULSE_MAX - PULSE_MIN) * wave)
304
+ }
305
+
306
+ // Render the first frame immediately so the transition is not delayed by
307
+ // up to a full frame interval.
308
+ render()
309
+
310
+ const interval = Math.max(16, Math.round(1_000 / this.options.pulseFps))
311
+ this.pulseTimer = setInterval(() => {
312
+ if (this.disposed) return this.stopPulse()
313
+ render()
314
+ }, interval)
315
+ this.pulseTimer.unref?.()
316
+ }
317
+
318
+ private stopPulse(): void {
319
+ if (!this.pulseTimer) return
320
+ clearInterval(this.pulseTimer)
321
+ this.pulseTimer = null
322
+ }
323
+
324
+ /*-------------------------------------------------------------------------*\
325
+ | Shutdown |
326
+ \*-------------------------------------------------------------------------*/
327
+
328
+ /**
329
+ * Best-effort restore on process teardown. Writes are small and go straight
330
+ * into the socket buffer, so this usually lands, but an abrupt kill can still
331
+ * leave the last color showing.
332
+ */
333
+ shutdown(): void {
334
+ if (this.disposed) return
335
+ this.disposed = true
336
+
337
+ this.stopPulse()
338
+ if (this.debounceTimer) clearTimeout(this.debounceTimer)
339
+
340
+ try {
341
+ // Synchronous writes: the event loop will not run again.
342
+ if (this.client.isReady && this.options.idle.kind === "restore") this.restore(true)
343
+ } catch {
344
+ // Nothing useful to do while the process is going away.
345
+ }
346
+
347
+ this.tracker.dispose()
348
+ this.client.dispose()
349
+ }
350
+ }
351
+
352
+ export function makeLogger(debug: boolean): Logger {
353
+ return {
354
+ debug: debug
355
+ ? (message: string) => console.error(`${LOG_PREFIX} ${message}`)
356
+ : () => {},
357
+ // Warnings are always surfaced: they indicate misconfiguration.
358
+ warn: (message: string) => console.error(`${LOG_PREFIX} ${message}`),
359
+ }
360
+ }
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * opencode-openrgb
3
+ *
4
+ * Ambient status lighting for opencode. Mirrors aggregate session state onto
5
+ * OpenRGB-controlled hardware and hands your original lighting back when
6
+ * everything goes idle.
7
+ */
8
+
9
+ import type { Plugin } from "@opencode/plugin"
10
+
11
+ import { Controller } from "./controller.js"
12
+ import { resolveOptions } from "./options.js"
13
+ import { SUBSCRIBED_EVENTS, type OpencodeEvent } from "./state.js"
14
+
15
+ export type { ResolvedOptions, IdleBehaviour, ActiveState, StateColor } from "./options.js"
16
+ export type { LightingState, OpencodeEvent } from "./state.js"
17
+ export type { Device, Mode, Zone, Led } from "./protocol.js"
18
+
19
+ const LOG_PREFIX = "[opencode-openrgb]"
20
+
21
+ /**
22
+ * Process-wide shared controller.
23
+ *
24
+ * A single opencode server loads this plugin once per location, but the RGB
25
+ * hardware is global to the machine, so several controllers would fight over
26
+ * it. Instances share one controller and refcount it: the first caller builds
27
+ * it, the last one to leave shuts it down. Shutting down on the first unload
28
+ * instead would darken the lights for every location still running.
29
+ */
30
+ let shared: { controller: Controller; refs: number } | null = null
31
+
32
+ function acquire(options: unknown): Controller | null {
33
+ if (shared) {
34
+ shared.refs++
35
+ return shared.controller
36
+ }
37
+
38
+ const resolved = resolveOptions(options, (message) =>
39
+ console.error(`${LOG_PREFIX} ${message}`),
40
+ )
41
+ if (!resolved.enabled) return null
42
+
43
+ const controller = new Controller(resolved)
44
+ shared = { controller, refs: 1 }
45
+ controller.start()
46
+ return controller
47
+ }
48
+
49
+ function release(): void {
50
+ if (!shared) return
51
+ shared.refs--
52
+ if (shared.refs > 0) return
53
+ shared.controller.shutdown()
54
+ shared = null
55
+ }
56
+
57
+ /*
58
+ * Declared as a `Plugin` literal rather than via `Plugin.define`, which is an
59
+ * identity function. That keeps `@opencode/plugin` a type-only import, so the
60
+ * published package has no runtime dependencies at all.
61
+ */
62
+ const plugin: Plugin.Plugin = {
63
+ id: "opencode-openrgb",
64
+ setup(ctx) {
65
+ const controller = acquire(ctx.options)
66
+ if (!controller) return
67
+
68
+ const subscription = new AbortController()
69
+
70
+ void (async () => {
71
+ try {
72
+ const events = ctx.event.subscribe({
73
+ types: [...SUBSCRIBED_EVENTS],
74
+ signal: subscription.signal,
75
+ })
76
+ for await (const event of events) {
77
+ controller.handleEvent(event as OpencodeEvent)
78
+ }
79
+ } catch (error) {
80
+ // An abort during cleanup is the normal way this loop ends.
81
+ if (subscription.signal.aborted) return
82
+ console.error(`${LOG_PREFIX} event subscription failed: ${String(error)}`)
83
+ }
84
+ })()
85
+
86
+ return () => {
87
+ subscription.abort()
88
+ release()
89
+ }
90
+ },
91
+ }
92
+
93
+ export default plugin
package/src/options.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Plugin option parsing.
3
+ *
4
+ * Options arrive from user config and are therefore untrusted. Every field is
5
+ * validated and falls back to a default on bad input, with a warning; a typo in
6
+ * `opencode.json` must never prevent the plugin from loading.
7
+ */
8
+
9
+ import { DEFAULT_HOST, DEFAULT_PORT, type Device } from "./protocol.js"
10
+ import { parseColor } from "./palette.js"
11
+ import type { LightingState } from "./state.js"
12
+
13
+ /** Lighting states that map to a solid color. `idle` is handled separately. */
14
+ export type ActiveState = Exclude<LightingState, "idle">
15
+
16
+ export type IdleBehaviour = { kind: "restore" } | { kind: "color"; color: number }
17
+
18
+ /**
19
+ * How a state's color is chosen.
20
+ *
21
+ * `baseline` means "whatever the lighting was when the plugin connected",
22
+ * per LED — so the device keeps its own color identity (your OpenRGB profile)
23
+ * and only its intensity changes. A baseline state always pulses, because a
24
+ * static baseline would be indistinguishable from idle.
25
+ */
26
+ export type StateColor = { kind: "fixed"; color: number } | { kind: "baseline" }
27
+
28
+ /** Config values that select the baseline rather than a literal color. */
29
+ const BASELINE_ALIASES = new Set(["baseline", "current", "profile"])
30
+
31
+ export interface ResolvedOptions {
32
+ enabled: boolean
33
+ host: string
34
+ port: number
35
+ colors: Record<ActiveState, StateColor>
36
+ idle: IdleBehaviour
37
+ brightness: number
38
+ pulse: boolean
39
+ pulseFps: number
40
+ /** Debounce applied to all state transitions, to coalesce event bursts. */
41
+ debounceMs: number
42
+ /** Extra delay before handing lighting back on idle, to avoid thrashing. */
43
+ restoreDelayMs: number
44
+ include: string[]
45
+ exclude: string[]
46
+ debug: boolean
47
+ }
48
+
49
+ const DEFAULT_COLORS: Record<ActiveState, string> = {
50
+ busy: "#ff8c00",
51
+ retry: "#ffd000",
52
+ permission: "#b400ff",
53
+ error: "#ff0000",
54
+ }
55
+
56
+ /**
57
+ * Default targets: ambient/interior lighting only.
58
+ *
59
+ * This deliberately excludes `keyboard`, `mouse`, `keypad` and `accessory`.
60
+ * Those are functional devices whose lighting usually conveys something (an
61
+ * Elgato Stream Deck's button icons, for instance), and hijacking them by
62
+ * default would be obnoxious.
63
+ */
64
+ const DEFAULT_INCLUDE = ["motherboard", "dram", "gpu", "cooler", "case", "ledstrip"]
65
+
66
+ const ACTIVE_STATES: ActiveState[] = ["busy", "retry", "permission", "error"]
67
+
68
+ export function resolveOptions(
69
+ raw: unknown,
70
+ warn: (message: string) => void = () => {},
71
+ ): ResolvedOptions {
72
+ const o = isRecord(raw) ? raw : {}
73
+
74
+ const colors = {} as Record<ActiveState, StateColor>
75
+ const rawColors = isRecord(o["colors"]) ? o["colors"] : {}
76
+ for (const state of ACTIVE_STATES) {
77
+ const fallback: StateColor = { kind: "fixed", color: parseColor(DEFAULT_COLORS[state])! }
78
+ if (!(state in rawColors)) {
79
+ colors[state] = fallback
80
+ continue
81
+ }
82
+ colors[state] = stateColorOption(rawColors[state], fallback, `colors.${state}`, warn)
83
+ }
84
+
85
+ const devices = isRecord(o["devices"]) ? o["devices"] : {}
86
+
87
+ return {
88
+ enabled: boolOption(o["enabled"], true),
89
+ host: stringOption(o["host"], DEFAULT_HOST),
90
+ port: intOption(o["port"], DEFAULT_PORT, 1, 65535, "port", warn),
91
+ colors,
92
+ idle: idleOption(o["idle"], warn),
93
+ brightness: numberOption(o["brightness"], 1, 0, 1, "brightness", warn),
94
+ pulse: boolOption(o["pulse"], false),
95
+ pulseFps: intOption(o["pulseFps"], 15, 1, 60, "pulseFps", warn),
96
+ debounceMs: intOption(o["debounceMs"], 120, 0, 5_000, "debounceMs", warn),
97
+ restoreDelayMs: intOption(o["restoreDelayMs"], 400, 0, 60_000, "restoreDelayMs", warn),
98
+ include: stringArrayOption(devices["include"], DEFAULT_INCLUDE, "devices.include", warn),
99
+ exclude: stringArrayOption(devices["exclude"], [], "devices.exclude", warn),
100
+ debug: boolOption(o["debug"], process.env["OPENCODE_OPENRGB_DEBUG"] === "1"),
101
+ }
102
+ }
103
+
104
+ function stateColorOption(
105
+ value: unknown,
106
+ fallback: StateColor,
107
+ name: string,
108
+ warn: (m: string) => void,
109
+ ): StateColor {
110
+ if (typeof value === "string" && BASELINE_ALIASES.has(value.trim().toLowerCase())) {
111
+ return { kind: "baseline" }
112
+ }
113
+ const parsed = parseColor(value)
114
+ if (parsed === null) {
115
+ warn(`invalid ${name} (${JSON.stringify(value)}); using default`)
116
+ return fallback
117
+ }
118
+ return { kind: "fixed", color: parsed }
119
+ }
120
+
121
+ function idleOption(value: unknown, warn: (m: string) => void): IdleBehaviour {
122
+ if (value === undefined || value === null) return { kind: "restore" }
123
+ if (typeof value === "string" && value.trim().toLowerCase() === "restore") {
124
+ return { kind: "restore" }
125
+ }
126
+ const parsed = parseColor(value)
127
+ if (parsed === null) {
128
+ warn(`invalid idle (${JSON.stringify(value)}); using "restore"`)
129
+ return { kind: "restore" }
130
+ }
131
+ return { kind: "color", color: parsed }
132
+ }
133
+
134
+ /*---------------------------------------------------------------------------*\
135
+ | Device matching |
136
+ \*---------------------------------------------------------------------------*/
137
+
138
+ /**
139
+ * A pattern matches a device if it equals the device's type name
140
+ * (e.g. `motherboard`) or glob-matches its product name (e.g. `ASUS*`).
141
+ * Matching is case-insensitive; `*` and `?` are the supported wildcards.
142
+ */
143
+ export function deviceMatches(device: Device, patterns: readonly string[]): boolean {
144
+ return patterns.some((pattern) => {
145
+ const p = pattern.trim().toLowerCase()
146
+ if (p.length === 0) return false
147
+ if (p === "*") return true
148
+ if (p === device.typeName.toLowerCase()) return true
149
+ return globToRegExp(p).test(device.name.toLowerCase())
150
+ })
151
+ }
152
+
153
+ export function selectDevices(
154
+ devices: readonly Device[],
155
+ options: Pick<ResolvedOptions, "include" | "exclude">,
156
+ ): Device[] {
157
+ return devices.filter(
158
+ (device) =>
159
+ deviceMatches(device, options.include) && !deviceMatches(device, options.exclude),
160
+ )
161
+ }
162
+
163
+ function globToRegExp(glob: string): RegExp {
164
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&")
165
+ const pattern = escaped.replace(/\*/g, ".*").replace(/\?/g, ".")
166
+ return new RegExp(`^${pattern}$`)
167
+ }
168
+
169
+ /*---------------------------------------------------------------------------*\
170
+ | Primitive coercion |
171
+ \*---------------------------------------------------------------------------*/
172
+
173
+ function isRecord(value: unknown): value is Record<string, unknown> {
174
+ return typeof value === "object" && value !== null && !Array.isArray(value)
175
+ }
176
+
177
+ function boolOption(value: unknown, fallback: boolean): boolean {
178
+ return typeof value === "boolean" ? value : fallback
179
+ }
180
+
181
+ function stringOption(value: unknown, fallback: string): string {
182
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback
183
+ }
184
+
185
+ function numberOption(
186
+ value: unknown,
187
+ fallback: number,
188
+ min: number,
189
+ max: number,
190
+ name: string,
191
+ warn: (m: string) => void,
192
+ ): number {
193
+ if (value === undefined) return fallback
194
+ if (typeof value !== "number" || !Number.isFinite(value)) {
195
+ warn(`invalid ${name} (${JSON.stringify(value)}); using ${fallback}`)
196
+ return fallback
197
+ }
198
+ if (value < min || value > max) {
199
+ const clamped = Math.max(min, Math.min(max, value))
200
+ warn(`${name} ${value} out of range [${min}, ${max}]; clamped to ${clamped}`)
201
+ return clamped
202
+ }
203
+ return value
204
+ }
205
+
206
+ function intOption(
207
+ value: unknown,
208
+ fallback: number,
209
+ min: number,
210
+ max: number,
211
+ name: string,
212
+ warn: (m: string) => void,
213
+ ): number {
214
+ return Math.round(numberOption(value, fallback, min, max, name, warn))
215
+ }
216
+
217
+ function stringArrayOption(
218
+ value: unknown,
219
+ fallback: string[],
220
+ name: string,
221
+ warn: (m: string) => void,
222
+ ): string[] {
223
+ if (value === undefined) return [...fallback]
224
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
225
+ warn(`invalid ${name}; expected an array of strings. Using default`)
226
+ return [...fallback]
227
+ }
228
+ return (value as string[]).map((v) => v.trim()).filter((v) => v.length > 0)
229
+ }