Files
xo-cli/src/services/settings.ts

195 lines
5.1 KiB
TypeScript

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { EventEmitter } from "../utils/event-emitter.js";
import { getSettingsPath } from "../utils/paths.js";
/**
* Supported persisted settings keys.
*/
export type SettingsData = {
"default-mnemonic"?: string;
currency: string;
};
/**
* Event payloads emitted by {@link SettingsService}.
*/
export type SettingsServiceEventMap = {
"settings-updated": {
key: keyof SettingsData;
value: string | undefined;
settings: SettingsData;
};
};
/**
* Runtime defaults for settings that should always exist in memory.
*/
const DEFAULT_SETTINGS: SettingsData = {
currency: "USD",
};
/**
* Handles loading, migrating, and persisting wallet settings.
*
* The backing file is `<XO_CONFIG_DIR>/.wallet`. Historically it stored a raw
* mnemonic reference string. This service migrates that legacy format to JSON:
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
*/
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
private readonly settingsPath: string;
private settings: SettingsData;
/**
* Creates a new settings service instance.
*
* @param settingsPath - Optional custom settings file path (useful for tests)
*/
constructor(settingsPath: string = getSettingsPath()) {
super();
this.settingsPath = settingsPath;
this.settings = this.loadSettings();
}
/**
* Returns the current settings snapshot.
*/
public getSettings(): SettingsData {
return { ...this.settings };
}
/**
* Returns the currently selected default mnemonic reference.
*/
public getDefaultMnemonic(): string | undefined {
return this.settings["default-mnemonic"];
}
/**
* Updates the default mnemonic reference and persists it to disk.
*/
public setDefaultMnemonic(mnemonicRef: string): void {
const normalizedMnemonicRef = mnemonicRef.trim();
if (normalizedMnemonicRef.length === 0) {
throw new Error("default-mnemonic cannot be empty");
}
this.settings["default-mnemonic"] = normalizedMnemonicRef;
this.persistSettings();
this.emit("settings-updated", {
key: "default-mnemonic",
value: normalizedMnemonicRef,
settings: this.getSettings(),
});
}
/**
* Returns the selected fiat currency code (ISO-like uppercase).
*/
public getCurrency(): string {
return this.settings.currency;
}
/**
* Updates the selected fiat currency and persists it to disk.
*/
public setCurrency(currencyCode: string): void {
const normalizedCurrency = this.normalizeCurrency(currencyCode);
if (this.settings.currency === normalizedCurrency) {
return;
}
this.settings.currency = normalizedCurrency;
this.persistSettings();
this.emit("settings-updated", {
key: "currency",
value: normalizedCurrency,
settings: this.getSettings(),
});
}
/**
* Reads and normalizes the settings file from disk.
*
* If the file contains the old legacy format (raw mnemonic string), the
* migrated JSON shape is written back immediately.
*/
private loadSettings(): SettingsData {
if (!existsSync(this.settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
if (rawContents.length === 0) {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(rawContents);
const normalized = this.normalizeSettings(parsed);
return normalized;
} catch {
const migrated = this.normalizeSettings({
"default-mnemonic": rawContents,
});
this.persistSettings(migrated);
return migrated;
}
}
/**
* Writes the given settings object to disk as pretty JSON.
*
* @param nextSettings - Optional explicit value, defaults to in-memory state
*/
private persistSettings(nextSettings?: SettingsData): void {
if (nextSettings) {
this.settings = nextSettings;
}
writeFileSync(
this.settingsPath,
`${JSON.stringify(this.settings, null, 2)}\n`,
"utf8",
);
}
/**
* Coerces unknown input into a safe settings object.
*/
private normalizeSettings(input: unknown): SettingsData {
const normalized: SettingsData = {
...DEFAULT_SETTINGS,
};
if (!input || typeof input !== "object") {
return normalized;
}
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
normalized["default-mnemonic"] = maybeMnemonic.trim();
}
const maybeCurrency = (input as Record<string, unknown>).currency;
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
normalized.currency = this.normalizeCurrency(maybeCurrency);
}
return normalized;
}
/**
* Ensures currency values stay uppercase and non-empty.
*/
private normalizeCurrency(currencyCode: string): string {
const normalizedCurrency = currencyCode.trim().toUpperCase();
if (normalizedCurrency.length === 0) {
throw new Error("currency cannot be empty");
}
return normalizedCurrency;
}
}