/** * `reconcile-units` — install the host unit files this version renders, and * nothing else (#905). * * Scope: `"host"`. The units are host-global (one daemon serves every * configured project), so this verb takes no project and reconciles the shared * destinations. The per-project tail — tick config, brief link — stays * `setup host`'s. * * It exists as its own verb because the render is code, not data: the upgrade * transaction replaces the package underneath itself, so only a freshly * spawned process can render what the new release ships. `upgrade` runs this * verb with `--yes` after its install phase; an operator can run it by hand * any time `doctor` reports `systemd-unit` drift. */ import type { CommandContext } from "./context.ts"; import { findProject, loadConfig, resolveCaps } from "../config.ts"; import { telegramStateDir } from "../fleet.ts"; import { planHostRuntime, RECOVER_SCRIPT_INSTALL_PATH, SYSTEMD_UNIT_DIR, totalConfiguredWorkers, } from "../setup-host.ts"; import { formatUnitReconcile, planUnitReconcile, reconcileUnits, UNIT_RECONCILE_ANSWER_KEY, } from "../unit-reconcile.ts"; import { answersUi } from "../setup-answers.ts"; import { terminalUi } from "../wizard-ui.ts"; /** Repeatable `--protect PATH`: destinations this run must not overwrite. */ function protectedPaths(argv: readonly string[]): string[] { const paths: string[] = []; for (const [index, arg] of argv.entries()) { if (arg === "--protect") { const value = argv[index + 1]; if (value !== undefined && !value.startsWith("--")) paths.push(value); } else if (arg.startsWith("--protect=")) { paths.push(arg.slice("--protect=".length)); } } return paths; } export async function reconcileUnitsCommand(ctx: CommandContext): Promise { const cfg = loadConfig(); // The units are host-global. A `--project` narrows only the per-project tail // this verb deliberately does not write, so it is accepted (the recovery // unit encodes one project's name on a single-project host) and ignored // otherwise. const project = ctx.projectFlag === undefined && cfg.projects.length !== 1 ? undefined : findProject(cfg, ctx.projectFlag); const plan = planHostRuntime( project, project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults), telegramStateDir(), undefined, totalConfiguredWorkers(cfg), SYSTEMD_UNIT_DIR, RECOVER_SCRIPT_INSTALL_PATH, cfg.projects.length > 1, ); const protect = protectedPaths(ctx.argv); // `--no-refresh` retires without refreshing (#895). The upgrade transaction // uses it when it could not read a pre-install drift baseline: there a // refresh cannot tell the operator's own edits from the upgrade's, but the // retirement is not a comparison against any render and must still happen — // otherwise a host that cannot be baselined keeps its obsolete mount forever. const refresh = ctx.argv.includes("--no-refresh") === false; if (ctx.argv.includes("--dry-run")) { const preview = planUnitReconcile(plan, protect, refresh); process.stdout.write( preview.steps.length === 0 ? `${formatUnitReconcile({ kind: "current", protectedDrift: preview.protectedDrift, outOfScope: preview.outOfScope })}\n` : `${[ "would reconcile:", ...preview.retire.map((path) => ` retire ${path}`), ...preview.refresh.map((d) => ` ${d.installed} (from ${d.source})`), ...preview.steps.map((s) => ` $ ${s.argv.join(" ")}`), ].join("\n")}\n`, ); return; } // `--yes` is the non-interactive consent the upgrade transaction carries: // its operator already authorised the upgrade, and the detached installer // has no terminal to prompt on. Interactive runs get the real confirm. const ui = ctx.argv.includes("--yes") ? answersUi({ [UNIT_RECONCILE_ANSWER_KEY]: true }) : terminalUi(); try { const outcome = await reconcileUnits(plan, ui, { protect, refresh }); process.stdout.write(`${formatUnitReconcile(outcome)}\n`); if (outcome.kind === "failed") process.exitCode = 1; } finally { ui.close(); } }