- Remove unused ParsedChunkTarget type alias (lint error) - Increase setImmediate timing in history storage compat test - Mock warmPythonEnvironment in python tool tests - Fix tlaplus LSP test to spy on $which instead of Bun.which
702 lines
23 KiB
TypeScript
702 lines
23 KiB
TypeScript
/**
|
|
* Settings singleton with sync get/set and background persistence.
|
|
*
|
|
* Usage:
|
|
* import { settings } from "./settings";
|
|
*
|
|
* const enabled = settings.get("compaction.enabled"); // sync read
|
|
* settings.set("theme.dark", "titanium"); // sync write, saves in background
|
|
*
|
|
* For tests:
|
|
* const isolated = Settings.isolated({ "compaction.enabled": false });
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { setDefaultTabWidth } from "@oh-my-pi/pi-natives";
|
|
import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
|
|
import { YAML } from "bun";
|
|
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
|
|
import type { ModelRole } from "../config/model-registry";
|
|
import { loadCapability } from "../discovery";
|
|
import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
|
|
import { AgentStorage } from "../session/agent-storage";
|
|
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
import { withFileLock } from "./file-lock";
|
|
import {
|
|
type BashInterceptorRule,
|
|
type GroupPrefix,
|
|
type GroupTypeMap,
|
|
getDefault,
|
|
SETTINGS_SCHEMA,
|
|
type SettingPath,
|
|
type SettingValue,
|
|
} from "./settings-schema";
|
|
|
|
// Re-export types that callers need
|
|
export type * from "./settings-schema";
|
|
export * from "./settings-schema";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Types
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Raw settings object as stored in YAML */
|
|
export interface RawSettings {
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface SettingsOptions {
|
|
/** Current working directory for project settings discovery */
|
|
cwd?: string;
|
|
/** Agent directory for config.yml storage */
|
|
agentDir?: string;
|
|
/** Don't persist to disk (for tests) */
|
|
inMemory?: boolean;
|
|
/** Initial overrides */
|
|
overrides?: Partial<Record<SettingPath, unknown>>;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Path Utilities
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Get a nested value from an object by path segments.
|
|
*/
|
|
function getByPath(obj: RawSettings, segments: string[]): unknown {
|
|
let current: unknown = obj;
|
|
for (const segment of segments) {
|
|
if (current === null || current === undefined || typeof current !== "object") {
|
|
return undefined;
|
|
}
|
|
current = (current as Record<string, unknown>)[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Set a nested value in an object by path segments.
|
|
* Creates intermediate objects as needed.
|
|
*/
|
|
function setByPath(obj: RawSettings, segments: string[], value: unknown): void {
|
|
let current = obj;
|
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
const segment = segments[i];
|
|
if (!(segment in current) || typeof current[segment] !== "object" || current[segment] === null) {
|
|
current[segment] = {};
|
|
}
|
|
current = current[segment] as RawSettings;
|
|
}
|
|
current[segments[segments.length - 1]] = value;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Settings Class
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
export class Settings {
|
|
#configPath: string | null;
|
|
#cwd: string;
|
|
#agentDir: string;
|
|
#storage: AgentStorage | null = null;
|
|
|
|
/** Global settings from config.yml */
|
|
#global: RawSettings = {};
|
|
/** Project settings from .claude/settings.yml etc */
|
|
#project: RawSettings = {};
|
|
/** Runtime overrides (not persisted) */
|
|
#overrides: RawSettings = {};
|
|
/** Merged view (global + project + overrides) */
|
|
#merged: RawSettings = {};
|
|
|
|
/** Paths modified during this session (for partial save) */
|
|
#modified = new Set<string>();
|
|
|
|
/** Pending save (debounced) */
|
|
#saveTimer?: NodeJS.Timeout;
|
|
#savePromise?: Promise<void>;
|
|
|
|
/** Whether to persist changes */
|
|
#persist: boolean;
|
|
|
|
private constructor(options: SettingsOptions = {}) {
|
|
this.#cwd = path.normalize(options.cwd ?? getProjectDir());
|
|
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
|
|
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
|
|
this.#persist = !options.inMemory;
|
|
|
|
if (options.overrides) {
|
|
for (const [key, value] of Object.entries(options.overrides)) {
|
|
setByPath(this.#overrides, key.split("."), value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Factory Methods
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Initialize the global singleton.
|
|
* Call once at startup before accessing `settings`.
|
|
*/
|
|
static init(options: SettingsOptions = {}): Promise<Settings> {
|
|
if (globalInstancePromise) return globalInstancePromise;
|
|
|
|
const instance = new Settings(options);
|
|
const promise = instance.#load();
|
|
globalInstancePromise = promise;
|
|
|
|
return promise.then(
|
|
instance => {
|
|
globalInstance = instance;
|
|
globalInstancePromise = Promise.resolve(instance);
|
|
return instance;
|
|
},
|
|
error => {
|
|
globalInstance = null;
|
|
throw error;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create an isolated instance for testing.
|
|
* Does not affect the global singleton.
|
|
*/
|
|
static isolated(overrides: Partial<Record<SettingPath, unknown>> = {}): Settings {
|
|
const instance = new Settings({ inMemory: true, overrides });
|
|
instance.#rebuildMerged();
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Get the global singleton.
|
|
* Throws if not initialized.
|
|
*/
|
|
static get instance(): Settings {
|
|
if (!globalInstance) {
|
|
throw new Error("Settings not initialized. Call Settings.init() first.");
|
|
}
|
|
return globalInstance;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Core API
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get a setting value (sync).
|
|
* Returns the merged value from global + project + overrides, or the default.
|
|
*/
|
|
get<P extends SettingPath>(path: P): SettingValue<P> {
|
|
const segments = path.split(".");
|
|
const value = getByPath(this.#merged, segments);
|
|
if (value !== undefined) {
|
|
return value as SettingValue<P>;
|
|
}
|
|
return getDefault(path);
|
|
}
|
|
|
|
/**
|
|
* Set a setting value (sync).
|
|
* Updates global settings and queues a background save.
|
|
* Triggers hooks for settings that have side effects.
|
|
*/
|
|
set<P extends SettingPath>(path: P, value: SettingValue<P>): void {
|
|
const prev = this.get(path);
|
|
const segments = path.split(".");
|
|
setByPath(this.#global, segments, value);
|
|
this.#modified.add(path);
|
|
this.#rebuildMerged();
|
|
this.#queueSave();
|
|
|
|
// Trigger hook if exists
|
|
const hook = SETTING_HOOKS[path];
|
|
if (hook) {
|
|
hook(value, prev);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply runtime overrides (not persisted).
|
|
*/
|
|
override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
|
|
const segments = path.split(".");
|
|
setByPath(this.#overrides, segments, value);
|
|
this.#rebuildMerged();
|
|
}
|
|
|
|
/**
|
|
* Clear a runtime override.
|
|
*/
|
|
clearOverride(path: SettingPath): void {
|
|
const segments = path.split(".");
|
|
let current = this.#overrides;
|
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
const segment = segments[i];
|
|
if (!(segment in current)) return;
|
|
current = current[segment] as RawSettings;
|
|
}
|
|
delete current[segments[segments.length - 1]];
|
|
this.#rebuildMerged();
|
|
}
|
|
|
|
/**
|
|
* Flush any pending saves to disk.
|
|
* Call before exit to ensure all changes are persisted.
|
|
*/
|
|
async flush(): Promise<void> {
|
|
if (this.#saveTimer) {
|
|
clearTimeout(this.#saveTimer);
|
|
this.#saveTimer = undefined;
|
|
}
|
|
if (this.#savePromise) {
|
|
await this.#savePromise;
|
|
}
|
|
if (this.#modified.size > 0) {
|
|
await this.#saveNow();
|
|
}
|
|
}
|
|
|
|
async cloneForCwd(cwd: string): Promise<Settings> {
|
|
const cloned = new Settings({
|
|
cwd,
|
|
agentDir: this.#agentDir,
|
|
inMemory: !this.#persist,
|
|
});
|
|
cloned.#storage = this.#storage;
|
|
cloned.#global = structuredClone(this.#global);
|
|
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
|
|
cloned.#overrides = structuredClone(this.#overrides);
|
|
cloned.#rebuildMerged();
|
|
cloned.#fireAllHooks();
|
|
return cloned;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Accessors
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
getStorage(): AgentStorage | null {
|
|
return this.#storage;
|
|
}
|
|
|
|
getCwd(): string {
|
|
return this.#cwd;
|
|
}
|
|
|
|
getAgentDir(): string {
|
|
return this.#agentDir;
|
|
}
|
|
|
|
getPlansDirectory(): string {
|
|
return path.join(this.#agentDir, "plans");
|
|
}
|
|
|
|
/**
|
|
* Get shell configuration based on settings.
|
|
*/
|
|
getShellConfig() {
|
|
const shell = this.get("shellPath");
|
|
return procmgr.getShellConfig(shell);
|
|
}
|
|
|
|
/**
|
|
* Get all settings in a group with full type safety.
|
|
*/
|
|
getGroup<G extends GroupPrefix>(prefix: G): GroupTypeMap[G] {
|
|
const result: Record<string, unknown> = {};
|
|
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
|
|
if (key.startsWith(`${prefix}.`)) {
|
|
const suffix = key.slice(prefix.length + 1);
|
|
result[suffix] = this.get(key);
|
|
}
|
|
}
|
|
return result as unknown as GroupTypeMap[G];
|
|
}
|
|
|
|
/**
|
|
* Get the edit variant for a specific model.
|
|
* Returns "patch", "replace", "hashline", "chunk", or null (use global default).
|
|
*/
|
|
getEditVariantForModel(model: string | undefined): EditMode | null {
|
|
if (!model) return null;
|
|
const variants = (this.#merged.edit as { modelVariants?: Record<string, string> })?.modelVariants;
|
|
if (!variants) return null;
|
|
for (const pattern in variants) {
|
|
if (model.includes(pattern)) {
|
|
const value = normalizeEditMode(variants[pattern]);
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get bash interceptor rules (typed accessor for complex array config).
|
|
*/
|
|
getBashInterceptorRules(): BashInterceptorRule[] {
|
|
return this.get("bashInterceptor.patterns");
|
|
}
|
|
|
|
/**
|
|
* Set a model role (helper for modelRoles record).
|
|
*/
|
|
setModelRole(role: ModelRole | string, modelId: string): void {
|
|
const current = this.get("modelRoles");
|
|
this.set("modelRoles", { ...current, [role]: modelId });
|
|
}
|
|
|
|
/**
|
|
* Get a model role (helper for modelRoles record).
|
|
*/
|
|
getModelRole(role: ModelRole | string): string | undefined {
|
|
const roles = this.get("modelRoles");
|
|
return roles[role];
|
|
}
|
|
|
|
/**
|
|
* Get all model roles (helper for modelRoles record).
|
|
*/
|
|
getModelRoles(): ReadOnlyDict<string> {
|
|
return this.get("modelRoles");
|
|
}
|
|
|
|
/*
|
|
* Override model roles (helper for modelRoles record).
|
|
*/
|
|
overrideModelRoles(roles: ReadOnlyDict<string>): void {
|
|
const prev = this.get("modelRoles");
|
|
for (const [role, modelId] of Object.entries(roles)) {
|
|
if (modelId) {
|
|
prev[role] = modelId;
|
|
}
|
|
}
|
|
this.override("modelRoles", prev);
|
|
}
|
|
|
|
/**
|
|
* Set disabled providers (for compatibility with discovery system).
|
|
*/
|
|
setDisabledProviders(ids: string[]): void {
|
|
this.set("disabledProviders", ids);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Loading
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
async #load(): Promise<Settings> {
|
|
if (this.#persist) {
|
|
// Open storage
|
|
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
|
|
|
|
// Migrate from legacy formats if needed
|
|
await this.#migrateFromLegacy();
|
|
|
|
// Load global settings from config.yml
|
|
this.#global = await this.#loadYaml(this.#configPath!);
|
|
}
|
|
|
|
// Load project settings
|
|
this.#project = await this.#loadProjectSettings();
|
|
|
|
// Build merged view
|
|
this.#rebuildMerged();
|
|
this.#fireAllHooks();
|
|
return this;
|
|
}
|
|
|
|
async #loadYaml(filePath: string): Promise<RawSettings> {
|
|
try {
|
|
const content = await Bun.file(filePath).text();
|
|
const parsed = YAML.parse(content);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
return this.#migrateRawSettings(parsed as RawSettings);
|
|
} catch (error) {
|
|
if (isEnoent(error)) return {};
|
|
logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async #loadProjectSettings(): Promise<RawSettings> {
|
|
try {
|
|
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
|
|
let merged: RawSettings = {};
|
|
for (const item of result.items as SettingsCapabilityItem[]) {
|
|
if (item.level === "project") {
|
|
merged = this.#deepMerge(merged, item.data as RawSettings);
|
|
}
|
|
}
|
|
return this.#migrateRawSettings(merged);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async #migrateFromLegacy(): Promise<void> {
|
|
if (!this.#configPath) return;
|
|
|
|
// Check if config.yml already exists
|
|
try {
|
|
await Bun.file(this.#configPath).text();
|
|
return; // Already exists, no migration needed
|
|
} catch (err) {
|
|
if (!isEnoent(err)) return;
|
|
}
|
|
|
|
let settings: RawSettings = {};
|
|
let migrated = false;
|
|
|
|
// 1. Migrate from settings.json
|
|
const settingsJsonPath = path.join(this.#agentDir, "settings.json");
|
|
try {
|
|
const parsed = JSON.parse(await Bun.file(settingsJsonPath).text());
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed));
|
|
migrated = true;
|
|
try {
|
|
fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
// 2. Migrate from agent.db
|
|
try {
|
|
const dbSettings = this.#storage?.getSettings();
|
|
if (dbSettings) {
|
|
settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings as RawSettings));
|
|
migrated = true;
|
|
}
|
|
} catch {}
|
|
|
|
// 3. Write merged settings
|
|
if (migrated && Object.keys(settings).length > 0) {
|
|
try {
|
|
await Bun.write(this.#configPath, YAML.stringify(settings, null, 2));
|
|
logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
/** Apply schema migrations to raw settings */
|
|
#migrateRawSettings(raw: RawSettings): RawSettings {
|
|
// queueMode -> steeringMode
|
|
if ("queueMode" in raw && !("steeringMode" in raw)) {
|
|
raw.steeringMode = raw.queueMode;
|
|
delete raw.queueMode;
|
|
}
|
|
|
|
// ask.timeout: ms -> seconds (if value > 1000, it's old ms format)
|
|
if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
|
|
const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
|
|
if (oldValue > 1000) {
|
|
(raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
|
|
}
|
|
}
|
|
|
|
// Migrate old flat "theme" string to nested theme.dark/theme.light
|
|
if (typeof raw.theme === "string") {
|
|
const oldTheme = raw.theme;
|
|
if (oldTheme === "light" || oldTheme === "dark") {
|
|
// Built-in defaults — just remove, let new defaults apply
|
|
delete raw.theme;
|
|
} else {
|
|
// Custom theme — detect luminance to place in correct slot
|
|
const slot = isLightTheme(oldTheme) ? "light" : "dark";
|
|
raw.theme = { [slot]: oldTheme };
|
|
}
|
|
}
|
|
|
|
// task.isolation.enabled (boolean) -> task.isolation.mode (enum)
|
|
const taskObj = raw.task as Record<string, unknown> | undefined;
|
|
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
|
|
if (isolationObj && "enabled" in isolationObj) {
|
|
if (typeof isolationObj.enabled === "boolean") {
|
|
isolationObj.mode = isolationObj.enabled ? "worktree" : "none";
|
|
}
|
|
delete isolationObj.enabled;
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Saving
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
#queueSave(): void {
|
|
if (!this.#persist || !this.#configPath) return;
|
|
|
|
// Debounce: wait 100ms for more changes
|
|
if (this.#saveTimer) {
|
|
clearTimeout(this.#saveTimer);
|
|
}
|
|
this.#saveTimer = setTimeout(() => {
|
|
this.#saveTimer = undefined;
|
|
this.#saveNow().catch(err => {
|
|
logger.warn("Settings: background save failed", { error: String(err) });
|
|
});
|
|
}, 100);
|
|
}
|
|
|
|
async #saveNow(): Promise<void> {
|
|
if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
|
|
|
|
const configPath = this.#configPath;
|
|
const modifiedPaths = [...this.#modified];
|
|
this.#modified.clear();
|
|
|
|
try {
|
|
await withFileLock(configPath, async () => {
|
|
// Re-read to preserve external changes
|
|
const current = await this.#loadYaml(configPath);
|
|
|
|
// Apply only our modified paths
|
|
for (const modPath of modifiedPaths) {
|
|
const segments = modPath.split(".");
|
|
const value = getByPath(this.#global, segments);
|
|
setByPath(current, segments, value);
|
|
}
|
|
|
|
// Update our global with any external changes we preserved
|
|
this.#global = current;
|
|
await Bun.write(configPath, YAML.stringify(this.#global, null, 2));
|
|
});
|
|
} catch (error) {
|
|
logger.warn("Settings: save failed", { error: String(error) });
|
|
// Re-add failed paths for retry
|
|
for (const p of modifiedPaths) {
|
|
this.#modified.add(p);
|
|
}
|
|
}
|
|
|
|
this.#rebuildMerged();
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Utilities
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
#rebuildMerged(): void {
|
|
this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
|
|
this.#merged = this.#deepMerge(this.#merged, this.#overrides);
|
|
}
|
|
|
|
#fireAllHooks(): void {
|
|
for (const key of Object.keys(SETTING_HOOKS) as SettingPath[]) {
|
|
const hook = SETTING_HOOKS[key];
|
|
if (hook) {
|
|
const value = this.get(key);
|
|
hook(value, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
#deepMerge(base: RawSettings, overrides: RawSettings): RawSettings {
|
|
const result = { ...base };
|
|
for (const key of Object.keys(overrides)) {
|
|
const override = overrides[key];
|
|
const baseVal = base[key];
|
|
|
|
if (override === undefined) continue;
|
|
|
|
if (
|
|
typeof override === "object" &&
|
|
override !== null &&
|
|
!Array.isArray(override) &&
|
|
typeof baseVal === "object" &&
|
|
baseVal !== null &&
|
|
!Array.isArray(baseVal)
|
|
) {
|
|
result[key] = this.#deepMerge(baseVal as RawSettings, override as RawSettings);
|
|
} else {
|
|
result[key] = override;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Setting Hooks
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
type SettingHook<P extends SettingPath> = (value: SettingValue<P>, prev: SettingValue<P>) => void;
|
|
|
|
const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
|
|
"theme.dark": value => {
|
|
if (typeof value === "string") {
|
|
setAutoThemeMapping("dark", value);
|
|
}
|
|
},
|
|
"theme.light": value => {
|
|
if (typeof value === "string") {
|
|
setAutoThemeMapping("light", value);
|
|
}
|
|
},
|
|
symbolPreset: value => {
|
|
if (typeof value === "string" && (value === "unicode" || value === "nerd" || value === "ascii")) {
|
|
setSymbolPreset(value).catch(err => {
|
|
logger.warn("Settings: symbolPreset hook failed", { preset: value, error: String(err) });
|
|
});
|
|
}
|
|
},
|
|
colorBlindMode: value => {
|
|
if (typeof value === "boolean") {
|
|
setColorBlindMode(value).catch(err => {
|
|
logger.warn("Settings: colorBlindMode hook failed", { enabled: value, error: String(err) });
|
|
});
|
|
}
|
|
},
|
|
"display.tabWidth": value => {
|
|
if (typeof value === "number") {
|
|
setDefaultTabWidth(value);
|
|
}
|
|
},
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Global Singleton
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
let globalInstance: Settings | null = null;
|
|
let globalInstancePromise: Promise<Settings> | null = null;
|
|
|
|
/**
|
|
* Reset the global singleton for testing.
|
|
* @internal
|
|
*/
|
|
export function _resetSettingsForTest(): void {
|
|
globalInstance = null;
|
|
globalInstancePromise = null;
|
|
}
|
|
|
|
/**
|
|
* The global settings singleton.
|
|
* Must call `Settings.init()` before using.
|
|
*/
|
|
export const settings = new Proxy({} as Settings, {
|
|
get(_target, prop) {
|
|
if (!globalInstance) {
|
|
throw new Error("Settings not initialized. Call Settings.init() first.");
|
|
}
|
|
const value = (globalInstance as unknown as Record<string | symbol, unknown>)[prop];
|
|
if (typeof value === "function") {
|
|
return value.bind(globalInstance);
|
|
}
|
|
return value;
|
|
},
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Helpers
|
|
// ═══════════════════════════════════════════════════════════════════════════
|