omp-conductor 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -4
- package/REFERENCE.md +20 -11
- package/package.json +3 -1
- package/src/briefs/orchestrator.md +21 -6
- package/src/briefs/policy.md +13 -5
- package/src/clack-ui.ts +83 -0
- package/src/cli.ts +110 -381
- package/src/command-help.ts +220 -0
- package/src/command-manifest.ts +480 -0
- package/src/commands/arm.ts +7 -2
- package/src/commands/complete.ts +93 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/setup.ts +128 -11
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/fleet.ts +55 -15
- package/src/orchestrator-tick.ts +46 -7
- package/src/privileged.ts +3 -0
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +29 -11
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +130 -38
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade-verify.ts +25 -3
- package/src/upgrade.ts +61 -2
- package/src/wizard-ui.ts +14 -5
- package/systemd/omp-conductor-recover.sh +1 -1
- package/systemd/recover-unit-test.sh +2 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import type { PromptOptions, SelectOptions, TerminalUi, WizardUi } from "./wizard-ui.ts";
|
|
5
|
+
|
|
6
|
+
const answerFileSchema = z.record(z.string(), z.union([z.string(), z.boolean()]));
|
|
7
|
+
|
|
8
|
+
export type SetupAnswerValues = Record<string, string | boolean>;
|
|
9
|
+
|
|
10
|
+
function promptName(title: string, options: PromptOptions): string {
|
|
11
|
+
return `answer "${options.key}" for "${title}"`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function requiredAnswer(answers: SetupAnswerValues, title: string, options: PromptOptions): string | boolean {
|
|
15
|
+
if (!Object.hasOwn(answers, options.key)) {
|
|
16
|
+
throw new Error(`missing ${promptName(title, options)}; add it to the --answers file`);
|
|
17
|
+
}
|
|
18
|
+
return answers[options.key]!;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A prompt-free WizardUi backed only by validated answers. */
|
|
22
|
+
export function answersUi(
|
|
23
|
+
answers: SetupAnswerValues,
|
|
24
|
+
notify: WizardUi["notify"] = (message) => process.stdout.write(`${message}\n`),
|
|
25
|
+
): TerminalUi {
|
|
26
|
+
return {
|
|
27
|
+
notify,
|
|
28
|
+
close: () => {},
|
|
29
|
+
async confirm(title, _message, options) {
|
|
30
|
+
const answer = requiredAnswer(answers, title, options);
|
|
31
|
+
if (typeof answer !== "boolean") {
|
|
32
|
+
throw new Error(`${promptName(title, options)} must be a boolean`);
|
|
33
|
+
}
|
|
34
|
+
return answer;
|
|
35
|
+
},
|
|
36
|
+
async input(title, _placeholder, options) {
|
|
37
|
+
const answer = requiredAnswer(answers, title, options);
|
|
38
|
+
if (typeof answer !== "string") {
|
|
39
|
+
throw new Error(`${promptName(title, options)} must be a string`);
|
|
40
|
+
}
|
|
41
|
+
return answer;
|
|
42
|
+
},
|
|
43
|
+
async select(title, choices, options) {
|
|
44
|
+
const answer = requiredAnswer(answers, title, options);
|
|
45
|
+
if (typeof answer !== "string") {
|
|
46
|
+
throw new Error(`${promptName(title, options)} must be a listed label`);
|
|
47
|
+
}
|
|
48
|
+
if (!choices.some((choice) => choice.label === answer)) {
|
|
49
|
+
throw new Error(`${promptName(title, options)} must be a listed label`);
|
|
50
|
+
}
|
|
51
|
+
return answer;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadAnswersFile(path: string): SetupAnswerValues {
|
|
57
|
+
let raw: string;
|
|
58
|
+
try {
|
|
59
|
+
raw = readFileSync(path, "utf8");
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error(`could not read answers file "${path}": ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let decoded: unknown;
|
|
65
|
+
try {
|
|
66
|
+
decoded = JSON.parse(raw);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(`answers file "${path}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
const parsed = answerFileSchema.safeParse(decoded);
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
throw new Error(`answers file "${path}" must be a JSON object whose values are strings or booleans`);
|
|
73
|
+
}
|
|
74
|
+
return parsed.data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RecordedAnswersUi {
|
|
78
|
+
ui: TerminalUi;
|
|
79
|
+
answers: SetupAnswerValues;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ClosableWizardUi = WizardUi & Partial<Pick<TerminalUi, "close">>;
|
|
83
|
+
|
|
84
|
+
/** Decorates any driver and records only answers that were actually returned. */
|
|
85
|
+
export function recordingAnswersUi(inner: ClosableWizardUi): RecordedAnswersUi {
|
|
86
|
+
const answers: SetupAnswerValues = {};
|
|
87
|
+
return {
|
|
88
|
+
answers,
|
|
89
|
+
ui: {
|
|
90
|
+
notify: (message, type) => inner.notify(message, type),
|
|
91
|
+
close: (message) => inner.close?.(message),
|
|
92
|
+
async confirm(title, message, options) {
|
|
93
|
+
const answer = await inner.confirm(title, message, options);
|
|
94
|
+
if (answer !== undefined) answers[options.key] = answer;
|
|
95
|
+
return answer;
|
|
96
|
+
},
|
|
97
|
+
async input(title, placeholder, options) {
|
|
98
|
+
const answer = await inner.input(title, placeholder, options);
|
|
99
|
+
if (answer !== undefined) answers[options.key] = answer;
|
|
100
|
+
return answer;
|
|
101
|
+
},
|
|
102
|
+
async select(title, choices, options) {
|
|
103
|
+
const answer = await inner.select(title, choices, options);
|
|
104
|
+
if (answer !== undefined) answers[options.key] = answer;
|
|
105
|
+
return answer;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function saveAnswersFile(path: string, answers: SetupAnswerValues): void {
|
|
112
|
+
writeFileSync(path, `${JSON.stringify(answers, null, 2)}\n`, "utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Turns exhausted scripted stdin into an actionable non-interactive failure. */
|
|
116
|
+
export function guardNonInteractiveUi(inner: TerminalUi): TerminalUi {
|
|
117
|
+
const exhausted = (title: string, options: PromptOptions): never => {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`stdin ended before ${promptName(title, options)}; provide more piped answers or use setup --answers FILE`,
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
notify: (message, type) => inner.notify(message, type),
|
|
124
|
+
close: (message) => inner.close(message),
|
|
125
|
+
async confirm(title, message, options) {
|
|
126
|
+
return (await inner.confirm(title, message, options)) ?? exhausted(title, options);
|
|
127
|
+
},
|
|
128
|
+
async input(title, placeholder, options) {
|
|
129
|
+
return (await inner.input(title, placeholder, options)) ?? exhausted(title, options);
|
|
130
|
+
},
|
|
131
|
+
async select(title, choices, options: SelectOptions) {
|
|
132
|
+
return (await inner.select(title, choices, options)) ?? exhausted(title, options);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
package/src/setup-host.ts
CHANGED
|
@@ -211,6 +211,14 @@ export interface HostRuntimePlan {
|
|
|
211
211
|
* the installed unit matched.
|
|
212
212
|
*/
|
|
213
213
|
installedAction: PlannedWrite<string>["action"];
|
|
214
|
+
/**
|
|
215
|
+
* The installed destinations whose live bytes differ from this version's
|
|
216
|
+
* render — exactly the files the privileged steps would rewrite. `upgrade`
|
|
217
|
+
* names them after a release that re-rendered a template, and
|
|
218
|
+
* {@link currentInstall} is `true` iff this list is empty, so the two can
|
|
219
|
+
* never disagree (#598).
|
|
220
|
+
*/
|
|
221
|
+
drift: readonly string[];
|
|
214
222
|
/**
|
|
215
223
|
* True when every file the privileged install steps would write is already
|
|
216
224
|
* at its destination with the current bytes. `runHostInstall` uses it to
|
|
@@ -1285,18 +1293,27 @@ export function planHostRuntime(
|
|
|
1285
1293
|
// nothing to restart. The herdr unit is absent on a host without herdr, and
|
|
1286
1294
|
// an absent unit that would not be provisioned is nothing to do.
|
|
1287
1295
|
const installedAction = actionFor(installedPath, serviceContent);
|
|
1296
|
+
// The same comparison as a destination list, so `upgrade` can name the
|
|
1297
|
+
// drifted files and `currentInstall` can never disagree with that list: a
|
|
1298
|
+
// plan is current exactly when no installed destination differs from the
|
|
1299
|
+
// render.
|
|
1300
|
+
const drift: string[] = [];
|
|
1301
|
+
const noteDrift = (path: string, content: string | undefined): void => {
|
|
1302
|
+
if (content !== undefined && actionFor(path, content) !== "keep") drift.push(path);
|
|
1303
|
+
};
|
|
1304
|
+
noteDrift(installedPath, serviceContent);
|
|
1305
|
+
noteDrift(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent);
|
|
1306
|
+
noteDrift(recoverScriptInstallPath, recoverScriptContent);
|
|
1307
|
+
if (herdrUnit !== undefined) noteDrift(installedHerdr, herdrUnit.content);
|
|
1308
|
+
// The pane-shell file is a destination like the units: a plan with all
|
|
1309
|
+
// units current but the config merge still pending must not report
|
|
1310
|
+
// "nothing to install" and skip the very write it exists to make.
|
|
1311
|
+
if (herdrConfig !== undefined) noteDrift(herdrConfigPath, herdrConfig.content);
|
|
1312
|
+
// Same for the herdr-conductor config.env: a pending env merge is pending
|
|
1313
|
+
// work, not an already-current install.
|
|
1314
|
+
if (herdrEnv !== undefined) noteDrift(herdrEnvTarget, herdrEnv.content);
|
|
1288
1315
|
const currentInstall =
|
|
1289
|
-
|
|
1290
|
-
actionFor(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent) === "keep" &&
|
|
1291
|
-
actionFor(recoverScriptInstallPath, recoverScriptContent) === "keep" &&
|
|
1292
|
-
(herdrUnit === undefined || actionFor(installedHerdr, herdrUnit.content) === "keep") &&
|
|
1293
|
-
// The pane-shell file is a destination like the units: a re-run with all
|
|
1294
|
-
// units current but the config merge still pending must not report
|
|
1295
|
-
// "nothing to install" and skip the very write the plan exists to make.
|
|
1296
|
-
(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep") &&
|
|
1297
|
-
// Same for the herdr-conductor config.env: pending env merge is pending
|
|
1298
|
-
// work, not an already-current install.
|
|
1299
|
-
(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep");
|
|
1316
|
+
drift.length === 0;
|
|
1300
1317
|
return {
|
|
1301
1318
|
service,
|
|
1302
1319
|
// The herdr unit and pane-shell config stand and fall together: no herdr, no
|
|
@@ -1349,6 +1366,7 @@ export function planHostRuntime(
|
|
|
1349
1366
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
1350
1367
|
installedPath,
|
|
1351
1368
|
installedAction,
|
|
1369
|
+
drift,
|
|
1352
1370
|
currentInstall,
|
|
1353
1371
|
};
|
|
1354
1372
|
}
|
package/src/setup-install.ts
CHANGED
|
@@ -275,6 +275,7 @@ export async function runHostInstall(
|
|
|
275
275
|
const outcome = await runPrivileged(plan.steps, ui, {
|
|
276
276
|
...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
|
|
277
277
|
title: "Install and start the supervised session?",
|
|
278
|
+
answerKey: "install-host",
|
|
278
279
|
preamble: [
|
|
279
280
|
`Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
|
|
280
281
|
...(plan.herdrUnit === undefined
|
|
@@ -547,6 +548,7 @@ export async function runGraphInstall(
|
|
|
547
548
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
548
549
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
549
550
|
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
551
|
+
answerKey: `install-code-graph.${project.name}`,
|
|
550
552
|
preamble: [
|
|
551
553
|
`Staged: ${staged.written.join(", ")}.`,
|
|
552
554
|
...(clones.length === 0
|
package/src/setup-probe.ts
CHANGED
|
@@ -402,6 +402,7 @@ export async function probeProse(
|
|
|
402
402
|
const keep = await ui.confirm(
|
|
403
403
|
`Keep this ${heading}?`,
|
|
404
404
|
"It becomes part of POLICY.md, which the orchestrator re-reads on every tick. You can edit that file afterwards. Declining keeps the stub.",
|
|
405
|
+
{ key: `keep-probe.${name}` },
|
|
405
406
|
);
|
|
406
407
|
// A dismissed prompt is not a yes. `undefined` means the operator walked away.
|
|
407
408
|
if (keep !== true) {
|