@commenthol/app-config
Version:
set values in global app configuration
148 lines (133 loc) • 3.38 kB
JavaScript
import { homedir } from 'node:os'
import * as path from 'node:path'
import fsp from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { get, set } from 'mergee'
import { parse as vParse } from 'valibot'
import { v, StringSchema } from '#validate.js'
/** @typedef {import('valibot').BaseSchema} ValibotSchema */
/**
* @param {string|URL|undefined} urlOrString
* @returns {string|undefined}
*/
const toFilename = (urlOrString) =>
urlOrString instanceof URL ? fileURLToPath(urlOrString) : urlOrString
/**
* @param {string} appName application name
* @param {string} [filename] config filename with extension
* @returns {string}
*/
export const getGlobalConfigPathname = (appName, filename) => {
const _filename = filename || 'config.json'
switch (process.platform) {
case 'win32':
return path.resolve(
homedir(),
'AppData',
'Local',
'Packages',
appName,
_filename
)
case 'darwin':
return path.resolve(
homedir(),
'Library',
'Preferences',
appName,
_filename
)
case 'linux':
default:
return path.resolve(homedir(), '.config', appName, _filename)
}
}
export class AppConfig {
/**
* @param {{
* appName: string
* schema?: Record<string, ValibotSchema>
* filename?: string|URL
* defaults?: Record<string, any>
* }} options
*/
constructor(options) {
const { appName, schema, filename, defaults = {} } = options
if (!appName) {
throw new Error('appName is missing')
}
this.schema = schema
const _filename = toFilename(filename)
this.filename =
_filename && path.isAbsolute(_filename)
? _filename
: getGlobalConfigPathname(appName, _filename)
this.config = structuredClone(defaults)
}
/**
* @param {string} key
* @param {string|null|undefined} value
*/
set(key, value) {
const schema = this.schema?.[key]
if (this.schema && !schema) {
throw new Error(`Unsupported key "${key}"`)
}
if (['', null, undefined].includes(value)) {
// delete entry
set(this.config, key, null)
} else {
// validate and set new value
const output = schema
? vParse(v.pipe(StringSchema, schema), value)
: value
set(this.config, key, output)
}
}
/**
* @param {string|string[]} key
* @returns {any}
*/
get(key) {
return get(this.config, key)
}
/**
* @private
* creates or overwrites file after failures
*/
async _create() {
const dirname = path.dirname(this.filename)
await fsp.mkdir(dirname, { recursive: true })
await this.write()
}
async read() {
try {
const data = await fsp.readFile(this.filename, 'utf-8')
this.config = JSON.parse(data)
if (typeof this.config !== 'object') this.config = {}
} catch (/** @type {Error|any} */ err) {
if (err.code === 'ENOENT') {
return
}
await this._create()
}
}
/**
* @returns {Promise<void>}
*/
async write() {
try {
await fsp.writeFile(
this.filename,
JSON.stringify(this.config, null, 2),
'utf-8'
)
} catch (/** @type {Error|any} */ err) {
if (err.code === 'ENOENT') {
await this._create()
} else {
throw err
}
}
}
}