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.
@@ -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
- installedAction === "keep" &&
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
  }
@@ -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
@@ -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) {