/** * User initd config on the personal drive: disabled units and optional drop-in ordering. * Paths are logical VFS paths under $HOME. */ import b4a from 'b4a' /** One unit per line; blank lines and `#…` comments ignored. */ export const BARE_INITD_DISABLED_FILE = '~/.config/bare-os/initd/disabled.txt' /** * Optional `name.unit` files under this directory. Supports `[Unit]` keys: * After=, Requires=, Wants=, TimeoutStartSec=, TimeoutStopSec=, Restart=, RestartSec=, ExecStartPost=, SocketActivationIpc=, * IdleSec= (with SocketActivationIpc: stop after idle seconds; requires unit `stop`), * ReadinessPath= (VFS path until exists), or `exec:` (bounded by ReadinessTimeoutSec=), * ConditionPathExists= (skip unit start if path missing), AssertPathExists= (fail start if path missing), * ConditionPathIsDirectory= / AssertPathIsDirectory= (same semantics for directories), * ReadinessTimeoutSec=, * ExecHealthCmd=, HealthIntervalSec=, HealthFailureThreshold=, BareMaxExecDepth=, * Before= (reverse edge: listed units start after this one), OnFailure= (execLine after restart exhausted), * FailureAction=exec|none (default exec when OnFailure is set), * RestartMaxAttempts= (1–32; overrides default 3 for Restart=on-failure|always). */ export const BARE_INITD_UNITS_DIR = '~/.config/bare-os/units' /** `systemctl --user` style overrides (merged on top of {@link BARE_INITD_UNITS_DIR}). */ export const BARE_INITD_SYSTEMCTL_USER_UNITS_DIR = '~/.config/bare-init/units' /** * Optional fragments: `~/.config/bare-os/units.d//*.conf` (sorted), merged after the main `.unit` and before {@link BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}. */ export const BARE_INITD_UNITS_D_BASE = '~/.config/bare-os/units.d' /** * Timer drop-ins: `[Timer]` with OnCalendar= (five cron fields), EveryMs=, or OnInactiveSec= * (seconds between end of last run and next start), optional Persistent=false (run once), ExecLine=. */ export const BARE_INITD_TIMERS_DIR = '~/.config/bare-os/timers' /** Built-in ordering when no drop-in file exists (kernel-logger before cron). */ export const BARE_INITD_DEFAULT_AFTER = Object.freeze({ 'bare-cron': ['kernel-logger'] }) /** * @typedef {{ * after: string[], * requires: string[], * wants: string[], * timeoutStartSec: number | null, * timeoutStopSec: number | null, * restart: string | null, * restartSec: number | null, * execStartPost: string | null, * socketActivationIpc: string | null, * idleSec: number | null, * readinessPath: string | null, * readinessTimeoutSec: number | null, * execHealthCmd: string | null, * healthIntervalSec: number | null, * healthFailureThreshold: number | null, * bareMaxExecDepth: number | null, * before: string[], * onFailure: string | null, * failureAction: 'exec' | 'none', * restartMaxAttempts: number | null, * conditionPathExists: string | null, * assertPathExists: string | null, * conditionPathIsDirectory: string | null, * assertPathIsDirectory: string | null * }} BareInitdUnitDropIn */ /** @returns {BareInitdUnitDropIn} */ export function emptyUnitDropIn() { return { after: [], requires: [], wants: [], timeoutStartSec: null, timeoutStopSec: null, restart: null, restartSec: null, execStartPost: null, socketActivationIpc: null, idleSec: null, readinessPath: null, readinessTimeoutSec: null, execHealthCmd: null, healthIntervalSec: null, healthFailureThreshold: null, bareMaxExecDepth: null, before: [], onFailure: null, failureAction: 'exec', restartMaxAttempts: null, conditionPathExists: null, assertPathExists: null, conditionPathIsDirectory: null, assertPathIsDirectory: null } } /** * @param {string} val * @returns {string[]} */ function parseList(val) { return val .split(/[\s,]+/) .map((s) => s.trim()) .filter(Boolean) } /** * Parse a systemd-inspired `.unit` snippet (only `[Unit]` keys are honored). * @param {string} text * @returns {BareInitdUnitDropIn} */ export function parseUnitDropInText(text) { const out = emptyUnitDropIn() let section = '' for (const raw of text.split(/\r?\n/)) { const line = raw.trim() if (!line || line.startsWith('#')) continue if (/^\[[^\]]+\]$/.test(line)) { section = line.slice(1, -1).toLowerCase() continue } if (section !== 'unit') continue const eq = line.indexOf('=') if (eq < 1) continue const key = line.slice(0, eq).trim().toLowerCase() const val = line.slice(eq + 1).trim() if (key === 'after') out.after.push(...parseList(val)) else if (key === 'requires') out.requires.push(...parseList(val)) else if (key === 'wants') out.wants.push(...parseList(val)) else if (key === 'timeoutstartsec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0) out.timeoutStartSec = n } else if (key === 'timeoutstopsec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0) out.timeoutStopSec = n } else if (key === 'restart') { out.restart = val.toLowerCase() } else if (key === 'restartsec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n >= 0) out.restartSec = n } else if (key === 'execstartpost') { out.execStartPost = val } else if (key === 'socketactivationipc') { if (/^[a-zA-Z0-9._-]+$/.test(val)) out.socketActivationIpc = val } else if (key === 'idlesec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0 && n <= 86400) out.idleSec = n } else if (key === 'readinesspath') { if (val.trim()) out.readinessPath = val.trim() } else if (key === 'readinesstimeoutsec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0) out.readinessTimeoutSec = n } else if (key === 'exechealthcmd') { if (val.trim()) out.execHealthCmd = val.trim() } else if (key === 'healthintervalsec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0) out.healthIntervalSec = n } else if (key === 'healthfailurethreshold') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0) out.healthFailureThreshold = n } else if (key === 'baremaxexecdepth') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n > 0) out.bareMaxExecDepth = n } else if (key === 'before') { out.before.push(...parseList(val)) } else if (key === 'onfailure') { if (val.trim()) out.onFailure = val } else if (key === 'failureaction') { const v = val.toLowerCase() if (v === 'none') out.failureAction = 'none' else out.failureAction = 'exec' } else if (key === 'restartmaxattempts') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n >= 1 && n <= 32) out.restartMaxAttempts = n } else if (key === 'conditionpathexists') { const t = val.trim() if (t.length > 0 && t.length <= 512) out.conditionPathExists = t } else if (key === 'assertpathexists') { const t = val.trim() if (t.length > 0 && t.length <= 512) out.assertPathExists = t } else if (key === 'conditionpathisdirectory') { const t = val.trim() if (t.length > 0 && t.length <= 512) out.conditionPathIsDirectory = t } else if (key === 'assertpathisdirectory') { const t = val.trim() if (t.length > 0 && t.length <= 512) out.assertPathIsDirectory = t } } out.after = [...new Set(out.after)] out.requires = [...new Set(out.requires)] out.wants = [...new Set(out.wants)] out.before = [...new Set(out.before)] return out } /** * @param {BareInitdUnitDropIn} base * @param {BareInitdUnitDropIn} user * @returns {BareInitdUnitDropIn} */ export function mergeUnitDropIns(base, user) { const pickArr = (u, b) => u.length ? [...new Set(u)] : [...new Set(b)] return { after: pickArr(user.after, base.after), requires: pickArr(user.requires, base.requires), wants: pickArr(user.wants, base.wants), timeoutStartSec: user.timeoutStartSec ?? base.timeoutStartSec, timeoutStopSec: user.timeoutStopSec ?? base.timeoutStopSec, restart: user.restart ?? base.restart, restartSec: user.restartSec ?? base.restartSec, execStartPost: user.execStartPost ?? base.execStartPost, socketActivationIpc: user.socketActivationIpc ?? base.socketActivationIpc, idleSec: user.idleSec ?? base.idleSec, readinessPath: user.readinessPath ?? base.readinessPath, readinessTimeoutSec: user.readinessTimeoutSec ?? base.readinessTimeoutSec, execHealthCmd: user.execHealthCmd ?? base.execHealthCmd, healthIntervalSec: user.healthIntervalSec ?? base.healthIntervalSec, healthFailureThreshold: user.healthFailureThreshold ?? base.healthFailureThreshold, bareMaxExecDepth: user.bareMaxExecDepth ?? base.bareMaxExecDepth, before: pickArr(user.before, base.before), onFailure: user.onFailure ?? base.onFailure, failureAction: user.failureAction ?? base.failureAction, restartMaxAttempts: user.restartMaxAttempts ?? base.restartMaxAttempts, conditionPathExists: user.conditionPathExists ?? base.conditionPathExists, assertPathExists: user.assertPathExists ?? base.assertPathExists, conditionPathIsDirectory: user.conditionPathIsDirectory ?? base.conditionPathIsDirectory, assertPathIsDirectory: user.assertPathIsDirectory ?? base.assertPathIsDirectory } } /** * @param {{ readFile: (p: string) => Promise }} vfs * @param {string} logicalPath * @returns {Promise} */ export async function readUnitDropInFromPath(vfs, logicalPath) { try { const buf = await vfs.readFile(logicalPath) if (!buf) return emptyUnitDropIn() return parseUnitDropInText(b4a.toString(buf, 'utf8')) } catch { return emptyUnitDropIn() } } /** * @param {{ readFile: (p: string) => Promise }} vfs * @returns {Promise>} */ export async function readInitdDisabledSet(vfs) { const out = new Set() try { const buf = await vfs.readFile(BARE_INITD_DISABLED_FILE) if (!buf) return out const text = b4a.toString(buf, 'utf8') for (const line of text.split(/\r?\n/)) { const t = line.trim() if (!t || t.startsWith('#')) continue out.add(t) } } catch { /* missing file */ } return out } /** * @param {{ * mkdir: (p: string, opts?: { recursive?: boolean }) => Promise, * writeFile: (p: string, buf: Uint8Array) => Promise, * unlink: (p: string) => Promise * }} vfs * @param {Set} disabled */ export async function writeInitdDisabledSet(vfs, disabled) { await vfs.mkdir('~/.config/bare-os/initd', { recursive: true }) if (disabled.size === 0) { try { await vfs.unlink(BARE_INITD_DISABLED_FILE) } catch { /* absent */ } return } const body = [...disabled].sort().join('\n') + '\n' await vfs.writeFile(BARE_INITD_DISABLED_FILE, b4a.from(body, 'utf8')) } /** * @param {{ readFile: (p: string) => Promise, readdir?: (p: string) => Promise }} vfs * @param {string} unitName * @returns {Promise} */ export async function readUnitDropIn(vfs, unitName) { if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return emptyUnitDropIn() let merged = await readUnitDropInFromPath( vfs, `${BARE_INITD_UNITS_DIR}/${unitName}.unit` ) const fragDir = `${BARE_INITD_UNITS_D_BASE}/${unitName}` if (typeof vfs.readdir === 'function') { try { const names = await vfs.readdir(fragDir) const confs = names .filter((n) => typeof n === 'string' && n.endsWith('.conf')) .sort() for (const n of confs) { const frag = await readUnitDropInFromPath(vfs, `${fragDir}/${n}`) merged = mergeUnitDropIns(merged, frag) } } catch { /* missing dir */ } } const usr = await readUnitDropInFromPath( vfs, `${BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}/${unitName}.unit` ) return mergeUnitDropIns(merged, usr) } /** * @param {{ readFile: (p: string) => Promise }} vfs * @param {string} unitName * @returns {Promise} */ export async function readUnitAfterFromDropIn(vfs, unitName) { const d = await readUnitDropIn(vfs, unitName) return d.after } /** * @param {{ readFile: (p: string) => Promise }} vfs * @param {{ name: string }[]} services * @param {Readonly>} defaultAfter * @returns {Promise>} */ export async function loadInitdUnitDropIns(vfs, services, defaultAfter) { /** @type {Map} */ const map = new Map() for (const s of services) { const fromFile = await readUnitDropIn(vfs, s.name) const def = defaultAfter[s.name] || [] fromFile.after = [...new Set([...def, ...fromFile.after])] map.set(s.name, fromFile) } for (const [name, di] of map) { for (const b of di.before) { if (!/^[a-zA-Z0-9._-]+$/.test(b)) continue const target = map.get(b) if (target) { target.after = [...new Set([...target.after, name])] } } } return map } /** * @param {{ readFile: (p: string) => Promise }} vfs * @param {{ name: string }[]} services * @param {Readonly>} defaultAfter * @returns {Promise>} */ export async function loadInitdAfterMap(vfs, services, defaultAfter) { const full = await loadInitdUnitDropIns(vfs, services, defaultAfter) const map = new Map() for (const [k, v] of full) map.set(k, v.after) return map } /** * @template T extends { name: string } * @param {T[]} services * @param {Set} disabled * @param {Map} requiresMap */ function filterSatisfiedRequires(services, disabled, requiresMap) { const names = new Set(services.map((s) => s.name)) return services.filter((s) => { for (const r of requiresMap.get(s.name) || []) { if (!names.has(r)) return false if (disabled.has(r)) return false } return true }) } /** * Topological sort; cycles fall back to registration order for the remainder. * @template T extends { name: string } * @param {T[]} services * @param {Set} disabled * @param {Map} afterMap * @param {Map} [requiresMap] * @param {Map} [wantsMap] * @returns {T[]} */ export function sortServicesForBoot( services, disabled, afterMap, requiresMap, wantsMap ) { const reqM = requiresMap || new Map() const wanM = wantsMap || new Map() let active = services.filter((s) => !disabled.has(s.name)) active = filterSatisfiedRequires(active, disabled, reqM) const nameSet = new Set(active.map((s) => s.name)) /** @type {Map>} */ const prereq = new Map() for (const s of active) { const inc = new Set() for (const a of afterMap.get(s.name) || []) { if (nameSet.has(a)) inc.add(a) } for (const r of reqM.get(s.name) || []) { if (nameSet.has(r)) inc.add(r) } for (const w of wanM.get(s.name) || []) { if (nameSet.has(w)) inc.add(w) } prereq.set(s.name, inc) } const remaining = new Set(active.map((s) => s.name)) /** @type {T[]} */ const ordered = [] while (remaining.size) { const ready = [...remaining].filter((n) => { for (const p of prereq.get(n) || []) { if (remaining.has(p)) return false } return true }) if (!ready.length) { const regOrder = active.map((s) => s.name).filter((n) => remaining.has(n)) for (const n of regOrder) { const svc = active.find((s) => s.name === n) if (svc) ordered.push(svc) remaining.delete(n) } break } ready.sort() for (const n of ready) { const svc = active.find((s) => s.name === n) if (svc) ordered.push(svc) remaining.delete(n) } } return ordered } /** * @typedef {{ * kind: 'calendar', * onCalendar: string, * execLine: string, * jitterSec: number * } | { * kind: 'everyMs', * everyMs: number, * execLine: string, * jitterSec: number * } | { * kind: 'onInactiveSec', * inactiveSec: number, * execLine: string, * jitterSec: number, * persistent: boolean * }} BareOsTimerDropIn */ /** * Parse `~/.config/bare-os/timers/*.timer` — `[Timer]` with OnCalendar=, EveryMs=, or OnInactiveSec= and ExecLine=. * @param {string} text * @returns {BareOsTimerDropIn | null} */ export function parseBareOsTimerFile(text) { let onCalendar = '' /** @type {number | null} */ let everyMs = null /** @type {number | null} */ let inactiveSec = null let persistent = true /** @type {number | null} */ let jitterSec = null let execLine = '' let section = '' for (const raw of text.split(/\r?\n/)) { const line = raw.trim() if (!line || line.startsWith('#')) continue if (/^\[[^\]]+\]$/.test(line)) { section = line.slice(1, -1).toLowerCase() continue } if (section !== 'timer') continue const eq = line.indexOf('=') if (eq < 1) continue const key = line.slice(0, eq).trim().toLowerCase() const val = line.slice(eq + 1).trim() if (key === 'oncalendar') onCalendar = val else if (key === 'everyms') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n >= 1000 && n <= 86400000) everyMs = n } else if (key === 'oninactivesec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n >= 1 && n <= 86400) inactiveSec = n } else if (key === 'persistent') { const v = val.toLowerCase() persistent = !( v === '0' || v === 'false' || v === 'no' || v === 'off' ) } else if (key === 'jittersec') { const n = Number.parseInt(val, 10) if (Number.isFinite(n) && n >= 0 && n <= 86400) jitterSec = n } else if (key === 'execline') execLine = val } const el = execLine.trim() if (!el) return null const j = jitterSec != null ? jitterSec : 0 if (everyMs != null) return { kind: 'everyMs', everyMs, execLine: el, jitterSec: j } if (inactiveSec != null) return { kind: 'onInactiveSec', inactiveSec, execLine: el, jitterSec: j, persistent } if (!onCalendar.trim()) return null return { kind: 'calendar', onCalendar: onCalendar.trim(), execLine: el, jitterSec: j } }