Skip to content
74 changes: 74 additions & 0 deletions docs/launchpad-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,80 @@ status with Open to the loopback URL, Stop, focus on the action, and no action o
module that cannot run. A real systemd user manager and journal (Ubuntu 24.04) were
**not** exercised by this slice; that is C.5.

## Doctor

Slice P8 of the Launchpad parity (shaping `docs/launchpad-parity.md` B9): the
healthy-product readback of an Environment, the machine-readable preflight of C.2 step 4
and the readback of C.3 item 4. One core, `collectDoctor` in `src/doctor/doctor.ts`;
the terminal surface is `src/doctor/cli.ts`. `lazurio recover` stays the
broken-product path with its evidence, prompt and issue; doctor points to it.

**CLI.** `lazurio doctor [--folder <absolute Folder>] [--sign-in] [--json]`. The Folder
is `--folder`, the supervised unit's, or on a hosted Machine the declared operator's
(as for `lazurio update`). Exit status: 0 `ok`, 10 `attention` (a `warn`), 3 `broken`
(a `fail`), 2 usage, 1 the command itself failed.

**Read-only.** Doctor adds no check of its own: every answer comes from a reader the
product already has, and none of them writes. The Folder's tool selection is read under
the Folder's read lock (`toolsOverview`) where its state is recognized, and without a
lock otherwise, since taking the lock of a state without one would create it; nothing
is fetched (`update-available` is the
last verified check on disk); nothing restarts. `--sign-in` additionally runs each
installed tool's sign-in probe, which may contact its provider, exactly as `tools list
--sign-in`. The heavy reader is the active executable's `self-check`, one process, as in
`recover`. A test snapshots the whole temporary tree (paths, modes, sizes, modification
times, the SHA-256 of every file and the target of every link) before and after and finds
it byte-identical. A tool's version is reported only as numeric segments
(`2.63.0`); a suffix is free text and is omitted.

**Answer.** `{kind: "doctor", verdict: ok|attention|broken, locale, checks}`; every
check is `{id, outcome: ok|warn|fail|skipped, reason?, context?}` in group order. Ids
and reasons are enumerated (`doctorCheckIds`, `doctorReasons`); a check about one tool,
Organization or module names it in `context` (`tool`, `organization`, `module`). The
context passes one allowlist, recover's tier 1 (`contextRules`) extended by doctor's
keys (`doctorContextRules`): enumerated values, releases, counts, template revisions
and identifier-shaped catalog names. A catalog name that is not an identifier is
`invalid` (`lazurio organization list` shows it); no path, digest, account or message
is ever printed. The human form groups the checks in the catalog's columns (control
characters escaped), Czech or English by the Folder's locale.

| Group | Id | Source | Outcomes |
| --- | --- | --- | --- |
| product | `update-state` | `collectRecovery` R2 | `fail state-invalid` with the relative state path |
| product | `product-version` | `readStatus` running vs active | `warn running-not-active`, `warn not-installed` |
| product | `self-check` | `collectRecovery` R5 | `fail self-check-failed` with its reason |
| product | `update-available` | `readStatus` (`last-check.json`) | `warn update-available`; `skipped never-checked` |
| product | `folder-refresh` | `readStatus.folderRefresh` | `warn folder-refresh-needed`; `skipped not-active` when the running executable is not the active one |
| product | `template-revision` | `observeFolder` facts | `warn folder-newer` (a newer product rendered it), `warn revision-unknown`; an older one is `folder-refresh` |
| Folder | `folder-state` | `collectRecovery` Folder state; preset and Machine kind from `observeFolder` | `fail folder-state-pending`, `-absent`, `-unrecognized`, `-unreadable` |
| Folder | `machine-binding` | recorded `preferences.machine` vs `machineBinding` of the live handover | `warn machine-identity-changed`, `warn handover-changed` (digest; `machine folder-refresh`), `warn binding-absent`, `warn handover-unreadable`; `skipped not-hosted` |
| tools | `tool` | `toolsOverview` (tiers, enabled), else `toolsStatus` over the catalog | required missing `fail required-missing`; `warn recommended-missing`, `warn enabled-missing`, `warn version-unreadable`; `skipped not-enabled`; `signIn`/`ssh` ids with `--sign-in` |
| organizations | `catalog` | `readFolderCatalog` | counts; `warn catalog-unreadable` |
| organizations | `organization` | catalog entry | `warn` with the Organization reason; a template `skipped template-not-runtime` |
| organizations | `module` | catalog entry | `warn` with the module or Organization reason |
| launchpad | `launchpad-unit` | `collectRecovery` unit (Linux, supervised) | `fail unit-*`; `skipped no-user-manager`, `not-supervised` |
| launchpad | `launchpad-health` | `collectRecovery` health socket, `answer` normal/recovery/none/unexpected | `fail launchpad-recovery-mode`, `launchpad-not-answering`, `launchpad-version-mismatch` |
| machine | `machine-entry` | the recorded binding's `entry` on a hosted preset | `warn entry-not-recorded`; `skipped not-hosted` |

**C.2 step 4.** The apply reads `lazurio doctor --json` and stops on any `fail`; for
modules it keeps reading `lazurio module list --json`, since only the apply knows which
modules ran under the resident. A module that is not executable is `warn` here because
doctor does not know that.

**Not in this slice.** The Launchpad surface `/settings/diagnostics` (B9) and child
doctors of Organizations or modules.

**Verification 2026-09-28.** `tests/doctor.test.ts` against a temporary install base,
HOME and Folder with stand-in tools on a temporary PATH: a healthy fixture `ok` with
nothing written, a pending transaction `broken`, a missing required tool `fail` and a
missing recommended one `warn`, a module with an invalid default app and an unreadable
Organization under a non-identifier name `warn`, the hosted binding against the same, a
rewritten and a foreign handover and none, Recovery mode on the health socket, a
verified newer release in Czech, usage, and the source CLI in a child process. Every
JSON answer is checked against the tier-1 rules (ids, reasons, context rules, no string
that is not an identifier, no path of the fixture) and every human answer row by row
against the JSON.

## Gateway `ensure`

Slice P6 of the Launchpad parity (shaping `docs/launchpad-parity.md` B5, F22 point 3).
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseArgs } from "node:util";
import { doctorHelp, runDoctorCommand } from "./doctor/cli";
import { FolderAdoptionError } from "./folder/handover-layout";
import { initializeFolder } from "./folder/initialize-folder";
import { inspectLegacyPaths } from "./folder/inspect-legacy-paths";
Expand Down Expand Up @@ -178,6 +179,8 @@ export async function runCli(args: string[]): Promise<number> {
// Like the update commands: its output is the answer, with no notice.
if (args[0] === "recover")
return emit(await runRecoverCommand(args.slice(1)));
// Its answer already says whether an update is available.
if (args[0] === "doctor") return emit(await runDoctorCommand(args.slice(1)));
const code = await runOtherCommand(args);
// Every other command ends with the one-line notice (docs/update.md
// "Surfaces"): from `last-check.json` only, on stderr, never the network.
Expand Down Expand Up @@ -407,6 +410,7 @@ This is not a migration writer or authority to apply the draft. Exit 0 draft, 2
console.log(moduleHelp);
console.log(updateHelp);
console.log(recoverHelp);
console.log(doctorHelp);
console.log(machineHelp);
return 0;
}
Expand Down
212 changes: 212 additions & 0 deletions src/doctor/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { isAbsolute, resolve } from "node:path";
import { parseArgs } from "node:util";
import { columns } from "../organizations/cli";
import {
exitBroken,
type RecoverContext,
recoveryEnvironment,
} from "../recover/cli";
import { runTool, type ToolRunner, xdgOf } from "../tools/status";
import { type CommandOutput, installBase, processContext } from "../update/cli";
import {
exitFailure,
exitOk,
exitUpdateAvailable,
exitUsage,
} from "../update/errors";
import {
collectDoctor,
type DoctorCheck,
type DoctorGroup,
type DoctorResult,
type DoctorVerdict,
doctorGroupOf,
doctorGroups,
} from "./doctor";

/** `lazurio doctor`: the terminal surface of the doctor use case
* (launchpad-parity B9, P8). It reads, prints and changes nothing. */
export const doctorHelp = `doctor [--folder <absolute Folder>] [--sign-in] [--json]
The Environment's state in one read-only answer, every check one the
product already makes: product (update state, running and active version,
the active executable's self-check, a verified newer release, a needed
Folder refresh, the Folder's template revision), Folder (its state as this
version reads it, preset, a pending transaction; on a hosted Machine the
recorded binding against the live handover), tools (required missing is a
failure, recommended or enabled missing needs attention), Organizations and
modules (every catalog entry, executable or the typed reason), Launchpad
(the supervised unit and its health socket) and Machine (on a hosted
Machine whether the entry is recorded). Reads only: the Folder's tool
selection under the read lock, no write, no restart, no network; --sign-in
also runs each installed tool's sign-in probe, which may contact its
provider. --json prints {kind, verdict, locale, checks}; every check is
{id, outcome ok|warn|fail|skipped, reason?, context?} of enumerated ids,
without paths or text. The Folder is --folder, the supervised unit's, or on
a hosted Machine the declared operator's. A broken product is repaired
through lazurio recover. Exit status: 0 ok, 10 attention, 3 broken,
2 usage, 1 failure.`;

export const exitAttention = exitUpdateAvailable;

export type DoctorContext = RecoverContext &
Readonly<{
/** Tests only: the runner of the tools' version and sign-in commands. */
toolRun?: ToolRunner | undefined;
}>;

const synopsis = "doctor [--folder <absolute Folder>] [--sign-in] [--json]";

const exitCodes: Readonly<Record<DoctorVerdict, number>> = {
ok: exitOk,
attention: exitAttention,
broken: exitBroken,
};

const texts = {
en: {
verdict: {
ok: "Lazurio doctor: ok.",
attention: "Lazurio doctor: needs attention.",
broken: "Lazurio doctor: broken; lazurio recover prepares the repair.",
},
groups: {
product: "Product",
folder: "Folder",
tools: "Tools",
organizations: "Organizations and modules",
launchpad: "Launchpad",
machine: "Machine",
},
footer: "Read only; nothing was changed.",
},
cs: {
verdict: {
ok: "Lazurio doctor: v pořádku.",
attention: "Lazurio doctor: vyžaduje pozornost.",
broken: "Lazurio doctor: rozbité; opravu připraví lazurio recover.",
},
groups: {
product: "Produkt",
folder: "Folder",
tools: "Nástroje",
organizations: "Organizace a moduly",
launchpad: "Launchpad",
machine: "Mašina",
},
footer: "Jen čtení; nic se nezměnilo.",
},
} as const satisfies Record<
"cs" | "en",
{
verdict: Record<DoctorVerdict, string>;
groups: Record<DoctorGroup, string>;
footer: string;
}
>;

// The subject of a check about one tool, Organization or module.
const subjectKeys = ["tool", "organization", "module"] as const;
const subject = (entry: DoctorCheck) => {
const context = entry.context ?? {};
if (context.tool !== undefined) return String(context.tool);
if (context.module !== undefined)
return `${context.organization}/${context.module}`;
return context.organization === undefined ? "" : String(context.organization);
};

/** One row per check: outcome, id, subject, reason, the rest of its context. */
export const checkRow = (entry: DoctorCheck): string[] => [
entry.outcome,
entry.id,
subject(entry),
entry.reason ?? "",
Object.entries(entry.context ?? {})
.filter(([key]) => !(subjectKeys as readonly string[]).includes(key))
.map(([key, value]) => `${key}=${value}`)
.join(" "),
];

export function doctorText(result: DoctorResult): string {
const text = texts[result.locale];
const lines: string[] = [text.verdict[result.verdict]];
for (const group of doctorGroups) {
const checks = result.checks.filter(
(entry) => doctorGroupOf[entry.id] === group,
);
if (checks.length === 0) continue;
lines.push(
"",
text.groups[group],
...columns(checks.map(checkRow)).map((line) => ` ${line}`.trimEnd()),
);
}
lines.push("", text.footer);
return lines.join("\n");
}

export async function runDoctorCommand(
args: readonly string[],
context: DoctorContext = processContext(),
): Promise<CommandOutput> {
const usage = Object.freeze({
code: exitUsage,
stderr: `Usage: ${synopsis}`,
});
let values: {
json?: boolean | undefined;
folder?: string | undefined;
base?: string | undefined;
"sign-in"?: boolean | undefined;
};
let base: string;
try {
const parsed = parseArgs({
args: [...args],
strict: true,
allowPositionals: true,
tokens: true,
options: {
json: { type: "boolean" },
folder: { type: "string" },
base: { type: "string" },
"sign-in": { type: "boolean" },
},
});
values = parsed.values;
const names = parsed.tokens.flatMap((token) =>
token.kind === "option" ? [token.name] : [],
);
if (
parsed.positionals.length > 0 ||
new Set(names).size !== names.length ||
(values.folder !== undefined &&
(!isAbsolute(values.folder) ||
resolve(values.folder) !== values.folder))
)
return usage;
base = installBase(context, values.base);
} catch {
return usage;
}
try {
const result = await collectDoctor({
...recoveryEnvironment(context, { base, folder: values.folder }),
tools: {
path: context.env.PATH,
home: context.env.HOME,
xdg: xdgOf(context.env),
platform: context.platform,
run: context.toolRun ?? runTool,
},
signIn: values["sign-in"] === true,
});
return Object.freeze({
code: exitCodes[result.verdict],
stdout:
values.json === true ? JSON.stringify(result) : doctorText(result),
});
} catch {
// No reason is printed: it could quote a private path.
return Object.freeze({ code: exitFailure, stderr: "Doctor failed" });
}
}
Loading
Loading