The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
514 lines
23 KiB
TypeScript
514 lines
23 KiB
TypeScript
/*
|
|
Config sync for one app across the servers: compare, baseline, local fields, preview, apply.
|
|
|
|
The baseline (kept in the hub) plus a server's local values is that server's effective config.
|
|
Apply brings the selected servers to it: it re-reads the server, refuses if the config changed
|
|
since the preview, saves the old config to the history, writes with the server's ADMIN token and
|
|
reads the config back to check it. Config texts are returned to the caller (it is what they edit)
|
|
but never written to the audit log.
|
|
*/
|
|
import type { HubConfig, HubServer } from './config.ts'
|
|
import {
|
|
MAX_CONFIG,
|
|
acceptServerConfig,
|
|
cleanValues,
|
|
diffConfigs,
|
|
effectiveConfig,
|
|
importLocalValues,
|
|
looksLikeJson,
|
|
parseJson,
|
|
sameConfig,
|
|
textHash,
|
|
validatePaths,
|
|
type Diff,
|
|
} from './apps-sync.ts'
|
|
import { Upstream, UpstreamError } from './upstream.ts'
|
|
import {
|
|
listBaselineApps,
|
|
listHistory,
|
|
readBaseline,
|
|
readHistory,
|
|
readOverrides,
|
|
writeBaseline,
|
|
writeHistory,
|
|
writeOverrides,
|
|
type Baseline,
|
|
type HistoryEntry,
|
|
type Overrides,
|
|
} from './store.ts'
|
|
|
|
export class UserError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'UserError'
|
|
}
|
|
}
|
|
|
|
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
|
|
|
export interface Live {
|
|
installed: boolean
|
|
version?: string
|
|
ok: boolean
|
|
error?: string
|
|
/** The server's config text; null when the app has none. */
|
|
text: string | null
|
|
}
|
|
|
|
export interface ServerView {
|
|
id: string
|
|
ok: boolean
|
|
error?: string
|
|
installed: boolean
|
|
writable: boolean
|
|
version?: string
|
|
text: string | null
|
|
hash: string
|
|
effective?: string
|
|
warnings: string[]
|
|
/** Null without a baseline. */
|
|
inSync: boolean | null
|
|
/** What applying would change: this server's config to its effective config. */
|
|
vsBaseline?: Diff
|
|
/** This server's config relative to each other server's, local fields left out. */
|
|
vsOthers: Record<string, Diff>
|
|
}
|
|
|
|
export interface TargetPreview {
|
|
server: string
|
|
ok: boolean
|
|
error?: string
|
|
writable: boolean
|
|
current: string | null
|
|
currentHash: string
|
|
effective: string
|
|
changes: Diff
|
|
unchanged: boolean
|
|
warnings: string[]
|
|
}
|
|
|
|
export interface TargetResult {
|
|
server: string
|
|
ok: boolean
|
|
message?: string
|
|
}
|
|
|
|
/* 9.10 is newer than 9.9: compared part by part, numerically. */
|
|
export function compareVersions(a: string, b: string): number {
|
|
const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0))
|
|
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
const d = (x[i] ?? 0) - (y[i] ?? 0)
|
|
if (d !== 0) return d
|
|
}
|
|
return 0
|
|
}
|
|
|
|
/* The read token first, the admin token when the server refuses it. */
|
|
export async function readConfigText(upstream: Upstream, server: HubServer, name: string): Promise<string | null> {
|
|
let response: unknown
|
|
try {
|
|
response = await upstream.call(server, 'apps/config/get', { name })
|
|
} catch (e) {
|
|
if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e
|
|
response = await upstream.call(server, 'apps/config/get', { name }, server.adminToken)
|
|
}
|
|
const config = (response as { config?: unknown } | null)?.config
|
|
return typeof config === 'string' ? config : null
|
|
}
|
|
|
|
export class AppsConfig {
|
|
#config: HubConfig
|
|
#upstream: Upstream
|
|
#now: () => number
|
|
|
|
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
|
|
this.#config = config
|
|
this.#upstream = upstream
|
|
this.#now = now
|
|
}
|
|
|
|
#server(id: string): HubServer {
|
|
const found = this.#config.servers.find((s) => s.id === id)
|
|
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
|
|
return found
|
|
}
|
|
|
|
#targets(ids: string[]): HubServer[] {
|
|
const unique = [...new Set(ids)]
|
|
if (unique.length === 0) throw new UserError('Choose at least one server.')
|
|
return unique.map((id) => this.#server(id))
|
|
}
|
|
|
|
async #readConfig(server: HubServer, name: string, fresh: boolean): Promise<string | null> {
|
|
if (fresh) this.#upstream.invalidate(server.id)
|
|
return readConfigText(this.#upstream, server, name)
|
|
}
|
|
|
|
async #live(server: HubServer, name: string, fresh: boolean): Promise<Live> {
|
|
let version: string | undefined
|
|
try {
|
|
if (fresh) this.#upstream.invalidate(server.id)
|
|
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
|
const app = (list?.apps ?? []).find((a) => a.name === name)
|
|
if (app === undefined) return { installed: false, ok: true, text: null }
|
|
version = app.version
|
|
} catch (e) {
|
|
return { installed: false, ok: false, error: reason(e), text: null }
|
|
}
|
|
try {
|
|
return { installed: true, ok: true, version, text: await this.#readConfig(server, name, false) }
|
|
} catch (e) {
|
|
return { installed: true, ok: false, version, error: reason(e), text: null }
|
|
}
|
|
}
|
|
|
|
async #liveAll(name: string, fresh: boolean): Promise<Map<string, Live>> {
|
|
const entries = await Promise.all(this.#config.servers.map(async (s): Promise<[string, Live]> => [s.id, await this.#live(s, name, fresh)]))
|
|
return new Map(entries)
|
|
}
|
|
|
|
getBaseline(name: string): Baseline | null {
|
|
return readBaseline(this.#config.dataDir, name)
|
|
}
|
|
|
|
getOverrides(name: string): Overrides {
|
|
return readOverrides(this.#config.dataDir, name)
|
|
}
|
|
|
|
async compare(name: string): Promise<unknown> {
|
|
const baseline = this.getBaseline(name)
|
|
const overrides = this.getOverrides(name)
|
|
const live = await this.#liveAll(name, false)
|
|
const views: ServerView[] = this.#config.servers.map((s) => {
|
|
const l = live.get(s.id) as Live
|
|
const text = l.text
|
|
const view: ServerView = {
|
|
id: s.id,
|
|
ok: l.ok,
|
|
installed: l.installed,
|
|
writable: s.adminToken !== undefined,
|
|
text,
|
|
hash: textHash(text ?? ''),
|
|
warnings: [],
|
|
inSync: null,
|
|
vsOthers: {},
|
|
}
|
|
if (l.error !== undefined) view.error = l.error
|
|
if (l.version !== undefined) view.version = l.version
|
|
if (baseline !== null && l.installed && l.ok) {
|
|
const effective = effectiveConfig(baseline.text, overrides, s.id)
|
|
view.effective = effective.text
|
|
view.warnings = effective.warnings
|
|
view.inSync = sameConfig(text ?? '', effective.text)
|
|
view.vsBaseline = diffConfigs(text ?? '', effective.text, [])
|
|
}
|
|
return view
|
|
})
|
|
for (const v of views) {
|
|
if (!v.installed || !v.ok) continue
|
|
for (const o of views) {
|
|
if (o.id !== v.id && o.installed && o.ok) v.vsOthers[o.id] = diffConfigs(o.text ?? '', v.text ?? '', overrides.paths)
|
|
}
|
|
}
|
|
return { name, baseline, overrides, servers: views }
|
|
}
|
|
|
|
async importBaseline(name: string, serverId: string, user: string): Promise<{ detail: string }> {
|
|
const server = this.#server(serverId)
|
|
let text: string | null
|
|
try {
|
|
text = await this.#readConfig(server, name, true)
|
|
} catch (e) {
|
|
throw new UserError(`${serverId}: ${reason(e)}`)
|
|
}
|
|
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}" to import.`)
|
|
if (text.length > MAX_CONFIG) throw new UserError('The config is larger than 1 MB.')
|
|
this.#setBaseline(name, text, serverId, user)
|
|
return { detail: `from ${serverId}, ${text.length} bytes, ${textHash(text)}` }
|
|
}
|
|
|
|
/* Writes the baseline; the text it replaces goes to the history first (when it differs). */
|
|
#setBaseline(name: string, text: string, importedFrom: string | undefined, user: string): void {
|
|
const old = this.getBaseline(name)
|
|
if (old !== null && old.text !== text) {
|
|
try {
|
|
writeHistory(this.#config.dataDir, name, 'baseline', old.text, 'before-save', this.#now())
|
|
} catch (e) {
|
|
throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`)
|
|
}
|
|
}
|
|
writeBaseline(this.#config.dataDir, {
|
|
app: name,
|
|
text,
|
|
...(importedFrom === undefined ? {} : { importedFrom }),
|
|
updatedAt: new Date(this.#now()).toISOString(),
|
|
updatedBy: user,
|
|
})
|
|
}
|
|
|
|
saveBaseline(name: string, text: string, user: string): { detail: string } {
|
|
if (text.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.')
|
|
if (looksLikeJson(text)) {
|
|
const parsed = parseJson(text)
|
|
if (!parsed.ok) throw new UserError(`The baseline is not valid JSON: ${parsed.error}`)
|
|
}
|
|
this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user)
|
|
return { detail: `${text.length} bytes, ${textHash(text)}` }
|
|
}
|
|
|
|
saveOverrides(name: string, pathsRaw: unknown, valuesRaw: unknown): { detail: string } {
|
|
let paths: string[]
|
|
let values: Overrides['values']
|
|
try {
|
|
paths = validatePaths(pathsRaw)
|
|
values = cleanValues(valuesRaw ?? {}, paths, this.#config.servers.map((s) => s.id))
|
|
} catch (e) {
|
|
throw new UserError(reason(e))
|
|
}
|
|
const baseline = this.getBaseline(name)
|
|
if (paths.length > 0 && baseline !== null && (!looksLikeJson(baseline.text) || !parseJson(baseline.text).ok)) {
|
|
throw new UserError('The baseline is plain text, so it cannot have local fields.')
|
|
}
|
|
if (JSON.stringify(values).length > MAX_CONFIG) throw new UserError('The local values are larger than 1 MB.')
|
|
writeOverrides(this.#config.dataDir, name, { paths, values })
|
|
return { detail: `${paths.length} local field(s)` }
|
|
}
|
|
|
|
/* Reads each local path from the servers' live configs, for the "fill from current" button. */
|
|
async currentLocalValues(name: string, pathsRaw: unknown): Promise<Overrides['values']> {
|
|
let paths: string[]
|
|
try {
|
|
paths = validatePaths(pathsRaw)
|
|
} catch (e) {
|
|
throw new UserError(reason(e))
|
|
}
|
|
const live = await this.#liveAll(name, true)
|
|
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
|
|
}
|
|
|
|
async preview(name: string, ids: string[], overrideText?: string): Promise<{ targets: TargetPreview[] }> {
|
|
const targets = this.#targets(ids)
|
|
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
|
|
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
|
const overrides = this.getOverrides(name)
|
|
const live = await this.#liveAll(name, true)
|
|
const versions = [...live].filter(([, l]) => l.installed && l.version !== undefined).map(([id, l]) => ({ id, version: l.version as string }))
|
|
const newest = versions.reduce<{ id: string; version: string } | null>((best, v) => (best === null || compareVersions(v.version, best.version) > 0 ? v : best), null)
|
|
|
|
return {
|
|
targets: targets.map((s): TargetPreview => {
|
|
const l = live.get(s.id) as Live
|
|
const effective = effectiveConfig(baseline.text, overrides, s.id)
|
|
const warnings = [...effective.warnings]
|
|
if (s.adminToken === undefined) warnings.push('The hub has no admin token for this server, so it cannot write to it.')
|
|
if (l.installed && l.version !== undefined && newest !== null && compareVersions(l.version, newest.version) < 0) {
|
|
warnings.push(`Version ${l.version} here, ${newest.version} on ${newest.id}: versions differ, align versions first.`)
|
|
}
|
|
const current = l.text
|
|
const base = {
|
|
server: s.id,
|
|
writable: s.adminToken !== undefined,
|
|
current,
|
|
currentHash: textHash(current ?? ''),
|
|
effective: effective.text,
|
|
warnings,
|
|
}
|
|
if (!l.ok) return { ...base, ok: false, error: l.error ?? 'did not answer', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
|
|
if (!l.installed) return { ...base, ok: false, error: 'The app is not installed on this server.', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false }
|
|
const changes = diffConfigs(current ?? '', effective.text, [])
|
|
return { ...base, ok: true, changes, unchanged: sameConfig(current ?? '', effective.text) }
|
|
}),
|
|
}
|
|
}
|
|
|
|
/*
|
|
Brings one server to `desired`: re-read, refuse if it changed since `expectHash`, save the old
|
|
config to the history, write with the admin token, read back and check.
|
|
*/
|
|
async #applyText(s: HubServer, name: string, desired: (current: string) => string, expectHash: string | undefined): Promise<{ result: TargetResult; detail?: string }> {
|
|
if (s.adminToken === undefined) return { result: { server: s.id, ok: false, message: 'no admin token configured' } }
|
|
try {
|
|
const l = await this.#live(s, name, true)
|
|
if (!l.ok) return { result: { server: s.id, ok: false, message: l.error ?? 'did not answer' } }
|
|
if (!l.installed) return { result: { server: s.id, ok: false, message: 'the app is not installed on this server' } }
|
|
const current = l.text ?? ''
|
|
if (expectHash !== undefined && expectHash !== textHash(current)) {
|
|
return { result: { server: s.id, ok: false, message: 'the config changed on the server since the preview; run the preview again' } }
|
|
}
|
|
const text = desired(current)
|
|
if (sameConfig(current, text)) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
|
|
if (text.length > MAX_CONFIG) return { result: { server: s.id, ok: false, message: 'the config is larger than 1 MB' } }
|
|
try {
|
|
writeHistory(this.#config.dataDir, name, s.id, current, 'before-apply', this.#now())
|
|
} catch (e) {
|
|
return { result: { server: s.id, ok: false, message: `the old config could not be saved first (${reason(e)}); nothing was changed` } }
|
|
}
|
|
await this.#upstream.write(s, 'apps/config/set', {}, new URLSearchParams({ name, config: text }))
|
|
const after = await this.#readConfig(s, name, true)
|
|
const detail = `${textHash(current)} to ${textHash(text)}, ${text.length} bytes`
|
|
if (after === null || !sameConfig(after, text)) {
|
|
return { result: { server: s.id, ok: false, message: 'the server accepted the config but reads back a different one' }, detail }
|
|
}
|
|
return { result: { server: s.id, ok: true, message: 'Applied and verified.' }, detail }
|
|
} catch (e) {
|
|
return { result: { server: s.id, ok: false, message: reason(e) } }
|
|
}
|
|
}
|
|
|
|
async #applyMany(
|
|
name: string,
|
|
ids: string[],
|
|
desiredFor: (s: HubServer) => (current: string) => string,
|
|
expect: Record<string, string>,
|
|
): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
|
const targets = this.#targets(ids)
|
|
const done = await Promise.all(targets.map((s) => this.#applyText(s, name, desiredFor(s), expect[s.id])))
|
|
const details: Record<string, string> = {}
|
|
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
|
|
return { results: done.map((d) => d.result), details }
|
|
}
|
|
|
|
/* `overrideText` is a config that is not the hub's baseline (a profile's). The server's local values still apply. */
|
|
async apply(name: string, ids: string[], expect: Record<string, string>, overrideText?: string): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
|
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
|
|
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
|
const overrides = this.getOverrides(name)
|
|
return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect)
|
|
}
|
|
|
|
/* ---- History ---- */
|
|
|
|
historyList(name: string): HistoryEntry[] {
|
|
return listHistory(this.#config.dataDir, name)
|
|
}
|
|
|
|
#entry(name: string, id: string): { entry: HistoryEntry; text: string } {
|
|
const entry = this.historyList(name).find((e) => e.id === id)
|
|
const text = entry === undefined ? null : readHistory(this.#config.dataDir, name, id)
|
|
if (entry === undefined || text === null) throw new UserError('There is no such history entry.')
|
|
return { entry, text }
|
|
}
|
|
|
|
historyGet(name: string, id: string): { entry: HistoryEntry; text: string } {
|
|
return this.#entry(name, id)
|
|
}
|
|
|
|
/* A saved version against a server's config now: what rolling back would change. */
|
|
async historyDiff(name: string, id: string, serverId: string): Promise<unknown> {
|
|
const { entry, text } = this.#entry(name, id)
|
|
const server = this.#server(serverId)
|
|
const l = await this.#live(server, name, true)
|
|
if (!l.ok || !l.installed) throw new UserError(l.ok ? `The app is not installed on ${serverId}.` : `${serverId}: ${l.error ?? 'did not answer'}`)
|
|
const current = l.text ?? ''
|
|
return {
|
|
entry,
|
|
server: serverId,
|
|
writable: server.adminToken !== undefined,
|
|
current,
|
|
currentHash: textHash(current),
|
|
version: text,
|
|
changes: diffConfigs(current, text, []),
|
|
unchanged: sameConfig(current, text),
|
|
}
|
|
}
|
|
|
|
async rollback(name: string, id: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
|
const { entry, text } = this.#entry(name, id)
|
|
if (entry.kind === 'before-save') throw new UserError('That entry is an old baseline. Restore it as the baseline instead.')
|
|
return this.#applyMany(name, ids, () => () => text, expect)
|
|
}
|
|
|
|
restoreBaseline(name: string, id: string, user: string): { detail: string } {
|
|
const { entry, text } = this.#entry(name, id)
|
|
if (entry.kind !== 'before-save') throw new UserError('Only an old baseline can be restored as the baseline.')
|
|
this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user)
|
|
return { detail: `entry ${entry.hash}, ${text.length} bytes` }
|
|
}
|
|
|
|
/*
|
|
"Accept as baseline" for a drifted server: its config becomes the baseline, except that local
|
|
fields keep the baseline's values and the server's own values go into the overrides.
|
|
*/
|
|
async acceptDrift(name: string, serverId: string, user: string): Promise<{ detail: string }> {
|
|
const server = this.#server(serverId)
|
|
let text: string | null
|
|
try {
|
|
text = await this.#readConfig(server, name, true)
|
|
} catch (e) {
|
|
throw new UserError(`${serverId}: ${reason(e)}`)
|
|
}
|
|
if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}".`)
|
|
const overrides = this.getOverrides(name)
|
|
let next
|
|
try {
|
|
next = acceptServerConfig(text, this.getBaseline(name)?.text ?? null, overrides, serverId)
|
|
} catch (e) {
|
|
throw new UserError(reason(e))
|
|
}
|
|
this.#setBaseline(name, next.baselineText, serverId, user)
|
|
if (overrides.paths.length > 0) writeOverrides(this.#config.dataDir, name, next.overrides)
|
|
return { detail: `from ${serverId}, ${next.baselineText.length} bytes, ${textHash(next.baselineText)}` }
|
|
}
|
|
|
|
/* ---- Export and import of all baselines ---- */
|
|
|
|
exportAll(): { hub: string; version: number; exportedAt: string; apps: { app: string; baseline: Baseline; overrides: Overrides }[] } {
|
|
const apps = listBaselineApps(this.#config.dataDir).flatMap((app) => {
|
|
const baseline = this.getBaseline(app)
|
|
return baseline === null ? [] : [{ app, baseline, overrides: this.getOverrides(app) }]
|
|
})
|
|
return { hub: 'technitium-hub', version: 1, exportedAt: new Date(this.#now()).toISOString(), apps }
|
|
}
|
|
|
|
/* Validates a file and says what it would change; with `apply` also writes baselines and overrides (never to a server). */
|
|
importAll(raw: string, apply: boolean, user: string): { apps: { app: string; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[]; applied: boolean } {
|
|
const parsed = parseJson(raw)
|
|
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
|
|
const file = parsed.value as { hub?: unknown; version?: unknown; apps?: unknown }
|
|
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub') throw new UserError('This is not a baselines file of the hub.')
|
|
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
|
|
if (!Array.isArray(file.apps) || file.apps.length > 500) throw new UserError('The file has no list of apps, or has more than 500.')
|
|
const known = this.#config.servers.map((s) => s.id)
|
|
const plan: { app: string; text: string; overrides: Overrides; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[] = []
|
|
const seen = new Set<string>()
|
|
for (const item of file.apps as Record<string, unknown>[]) {
|
|
const app = item?.app
|
|
const baseline = item?.baseline as { text?: unknown } | null | undefined
|
|
if (typeof app !== 'string' || app === '' || app.length > 200 || seen.has(app)) throw new UserError('An app in the file has no valid, unique name.')
|
|
seen.add(app)
|
|
if (typeof baseline?.text !== 'string') throw new UserError(`"${app}" has no baseline text.`)
|
|
if (baseline.text.length > MAX_CONFIG) throw new UserError(`The baseline of "${app}" is larger than 1 MB.`)
|
|
if (looksLikeJson(baseline.text) && !parseJson(baseline.text).ok) throw new UserError(`The baseline of "${app}" is not valid JSON.`)
|
|
const rawOverrides = (item.overrides ?? { paths: [], values: {} }) as { paths?: unknown; values?: unknown }
|
|
let paths: string[]
|
|
try {
|
|
paths = validatePaths(rawOverrides.paths ?? [])
|
|
} catch (e) {
|
|
throw new UserError(`"${app}": ${reason(e)}`)
|
|
}
|
|
if (paths.length > 0 && !looksLikeJson(baseline.text)) throw new UserError(`"${app}": a plain text baseline cannot have local fields.`)
|
|
const values = rawOverrides.values !== null && typeof rawOverrides.values === 'object' && !Array.isArray(rawOverrides.values) ? (rawOverrides.values as Record<string, unknown>) : {}
|
|
const ignoredServers = Object.keys(values).filter((id) => !known.includes(id))
|
|
let cleaned: Overrides['values']
|
|
try {
|
|
cleaned = cleanValues(Object.fromEntries(Object.entries(values).filter(([id]) => known.includes(id))), paths, known)
|
|
} catch (e) {
|
|
throw new UserError(`"${app}": ${reason(e)}`)
|
|
}
|
|
const overrides: Overrides = { paths, values: cleaned }
|
|
const old = this.getBaseline(app)
|
|
const oldOverrides = this.getOverrides(app)
|
|
const status = old === null ? 'new' : old.text === baseline.text && JSON.stringify(oldOverrides) === JSON.stringify(overrides) ? 'same' : 'changed'
|
|
plan.push({ app, text: baseline.text, overrides, status, ignoredServers })
|
|
}
|
|
if (apply) {
|
|
for (const p of plan) {
|
|
if (p.status === 'same') continue
|
|
this.#setBaseline(p.app, p.text, this.getBaseline(p.app)?.importedFrom, user)
|
|
writeOverrides(this.#config.dataDir, p.app, p.overrides)
|
|
}
|
|
}
|
|
return { apps: plan.map(({ app, status, ignoredServers }) => ({ app, status, ignoredServers })), applied: apply }
|
|
}
|
|
}
|