@edspencer/paddock 0.71.2 → 0.72.1

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.
Files changed (106) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +27 -0
  3. package/packages/server/dist/cli/args.d.ts +56 -5
  4. package/packages/server/dist/cli/args.js +193 -31
  5. package/packages/server/dist/cli/config.d.ts +157 -0
  6. package/packages/server/dist/cli/config.js +462 -0
  7. package/packages/server/dist/cli/paddock.js +53 -3
  8. package/packages/server/dist/config.d.ts +36 -1
  9. package/packages/server/dist/config.js +59 -44
  10. package/packages/server/dist/herdctl.d.ts +45 -0
  11. package/packages/server/dist/herdctl.js +58 -4
  12. package/packages/server/dist/instance-config.d.ts +149 -21
  13. package/packages/server/dist/instance-config.js +228 -5
  14. package/packages/server/dist/last-activity.d.ts +21 -0
  15. package/packages/server/dist/last-activity.js +7 -2
  16. package/packages/server/dist/profiles.d.ts +154 -0
  17. package/packages/server/dist/profiles.js +214 -0
  18. package/packages/server/dist/routes/transcripts.d.ts +61 -0
  19. package/packages/server/dist/routes/transcripts.js +676 -0
  20. package/packages/server/dist/routes.d.ts +3 -0
  21. package/packages/server/dist/routes.js +2 -0
  22. package/packages/server/dist/transcript-move.d.ts +62 -0
  23. package/packages/server/dist/transcript-move.js +179 -0
  24. package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
  25. package/packages/server/dist/transcripts-migration-execute.js +630 -0
  26. package/packages/server/dist/transcripts-migration.d.ts +336 -0
  27. package/packages/server/dist/transcripts-migration.js +917 -0
  28. package/packages/server/dist/transcripts.d.ts +46 -2
  29. package/packages/server/dist/transcripts.js +126 -3
  30. package/packages/server/package.json +1 -1
  31. package/packages/web/dist/assets/DiscoverPage-DhrXvgkl.js +2 -0
  32. package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +2 -0
  33. package/packages/web/dist/assets/InstanceConfigPage-aOgiBFLq.js +2 -0
  34. package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-BjrB0TDO.js} +1 -1
  35. package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +2 -0
  36. package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-N4g7WrZm.js} +1 -1
  37. package/packages/web/dist/assets/ProjectView-B-7AIt--.js +13 -0
  38. package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-DZF5VHJF.js} +1 -1
  39. package/packages/web/dist/assets/RootHome-C9wEtWHu.js +2 -0
  40. package/packages/web/dist/assets/Surfaces-BidCkENw.js +2 -0
  41. package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-BctfAqLY.js} +1 -1
  42. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BJgwSqOO.js +37 -0
  43. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-BD2skmST.js} +1 -1
  44. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-DckMrhMv.js} +1 -1
  45. package/packages/web/dist/assets/channel-CsZOz8jm.js +2 -0
  46. package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-CQDUH7rp.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-Bae1J961.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-CXxS5wOa.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-DSNFkPB3.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-B2mqFyB7.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BfqEy3lu.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-Bm6CBVK7.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-NmnLtgYc.js} +1 -1
  54. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +2 -0
  55. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +2 -0
  56. package/packages/web/dist/assets/{core-DnpcT0s6.js → core-YKeFSK1q.js} +1 -1
  57. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-qf_mXpa9.js +2 -0
  58. package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-C31v-j3s.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-C_TiPHdS.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-BC8gUFK-.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-vO_QYWxi.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-vxJ-YZOg.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-BNIfJG6l.js} +1 -1
  64. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-B37HsOFM.js} +1 -1
  65. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DqA5X81H.js} +1 -1
  66. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BMP0zpIg.js +293 -0
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-P0nXaCzx.js} +1 -1
  68. package/packages/web/dist/assets/index-BkY_Nc_i.css +1 -0
  69. package/packages/web/dist/assets/index-C3MdluUb.js +71 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-DpKYQ7VK.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BZeRMg3C.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-Cg2O0qzv.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-DBwVxUvI.js} +1 -1
  74. package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-R8qRwYqx.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-jctTs2en.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-CtmsNfpb.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C-UiLFDv.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-BS4SWa01.js} +1 -1
  79. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-LPPAC6Q1.js} +1 -1
  80. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-tjF8KyS3.js} +1 -1
  81. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-kmddkdk2.js} +1 -1
  82. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-B07mykTY.js} +1 -1
  83. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +2 -0
  84. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-DrUgi0cT.js} +1 -1
  85. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-Dz6p0m7l.js} +1 -1
  86. package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DM16lA5x.js} +1 -1
  87. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CCFQkFGB.js} +1 -1
  88. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DiUI5Dos.js} +1 -1
  89. package/packages/web/dist/index.html +2 -2
  90. package/packages/web/dist/sw.js +1 -1
  91. package/packages/web/dist/assets/Chip-Cp32dDmz.js +0 -2
  92. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +0 -2
  93. package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +0 -2
  94. package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +0 -2
  95. package/packages/web/dist/assets/ProjectMenu-DXXXtXeh.js +0 -2
  96. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +0 -13
  97. package/packages/web/dist/assets/RootHome-rWodzOC8.js +0 -2
  98. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-UzIlxaCQ.js +0 -37
  99. package/packages/web/dist/assets/channel-C-sodtn7.js +0 -2
  100. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +0 -2
  101. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +0 -2
  102. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-B-4NadKI.js +0 -2
  103. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BxliloOH.js +0 -293
  104. package/packages/web/dist/assets/index-Cx9y6c3f.js +0 -71
  105. package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
  106. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +0 -2
@@ -0,0 +1,462 @@
1
+ /**
2
+ * `paddock config show` — what this instance's config actually resolved to (#878).
3
+ *
4
+ * ## Why the command exists
5
+ *
6
+ * #878 ships a `profile:` key so an operator's config file can stay thin: a
7
+ * posture name plus the handful of levers they disagree with. The objection to a
8
+ * thin file is real — "I want it all clearly written down" — and the answer is
9
+ * not to write it all down. A materialised file is a snapshot that goes stale the
10
+ * moment a default improves or a lever is added, and it cannot show you the env
11
+ * vars your container sets on top. Printing the resolution can't drift, because
12
+ * it is computed by the same loader the server boots with.
13
+ *
14
+ * ## Provenance is the deliverable, not the values
15
+ *
16
+ * A list of effective values is a thing you can already get by reading the Config
17
+ * screen. What you cannot get anywhere is the LAYER each one came from — and the
18
+ * layer is what tells you where to go to change it. So every row names one of
19
+ * `env` / `file` / `profile` / `default`, and `profile` is kept distinct from
20
+ * `default` because for the twelve posture keys there is no code default any more
21
+ * (see `ConfigSource` in instance-config.ts).
22
+ *
23
+ * ## What plain `config show` does
24
+ *
25
+ * It prints the DECISIONS — the profile, the keys your file sets, the variables
26
+ * your environment sets — and nothing else. That mirrors the shape #878 argues
27
+ * for: thin by default, explicit on demand. Two failure modes it avoids: an
28
+ * argument-less command that errors at you is rude, and one that dumps forty rows
29
+ * makes `--resolved` pointless. The summary is a reading of the same report
30
+ * `--resolved` prints in full, so the two can never disagree.
31
+ *
32
+ * The formatting functions here are pure and unit-tested; {@link runConfig} is
33
+ * the only part that touches the filesystem, and it is exercised by running the
34
+ * built binary.
35
+ */
36
+ import { CliError } from "./args.js";
37
+ /**
38
+ * Longest value printed in the table before it is elided. The environment prompt
39
+ * is ~600 characters of paragraph text and would own the terminal; `--json`
40
+ * prints every value in full for anyone who needs one.
41
+ */
42
+ const MAX_VALUE_CHARS = 52;
43
+ /**
44
+ * Stand-in for a `sensitive` field's value. Provenance is still reported, so the
45
+ * row still answers "where would I change this?".
46
+ *
47
+ * Redaction is uniform on the flag rather than judged per field, and that is the
48
+ * point. `FIELDS` documents itself as never carrying a secret value — but that is
49
+ * an intention maintained by hand, not an enforced invariant, and one of the
50
+ * three fields it already marks is `transcription.endpoint`: a URL an operator
51
+ * typed, which can perfectly well read `https://user:token@host`. The output of
52
+ * this command is designed to be pasted into an issue. So the safe reading of the
53
+ * flag wins by default and `--show-sensitive` is one keystroke away — which also
54
+ * means a field added with `sensitive: true` in two years is redacted here
55
+ * without anyone having to remember this file exists.
56
+ */
57
+ const HIDDEN = "(hidden)";
58
+ /** Render one value as a single line of terminal text. */
59
+ function renderValue(v) {
60
+ if (v === null || v === undefined)
61
+ return "(unset)";
62
+ if (Array.isArray(v))
63
+ return v.length > 0 ? v.join(", ") : "(empty)";
64
+ if (typeof v === "string")
65
+ return v === "" ? '""' : v;
66
+ return String(v);
67
+ }
68
+ /** Collapse to one line and elide. Returns whether anything was lost. */
69
+ function oneLine(text) {
70
+ const flat = text.replace(/\s+/g, " ").trim();
71
+ return flat.length > MAX_VALUE_CHARS
72
+ ? { text: `${flat.slice(0, MAX_VALUE_CHARS - 1)}…`, elided: true }
73
+ : { text: flat, elided: false };
74
+ }
75
+ /** The source column: the layer, and enough of the origin to act on it. */
76
+ function renderSource(f) {
77
+ switch (f.source) {
78
+ case "env":
79
+ return `env ${f.origin}`;
80
+ case "profile":
81
+ return `profile (${f.origin})`;
82
+ case "file":
83
+ return "file";
84
+ default:
85
+ return "default";
86
+ }
87
+ }
88
+ function pad(s, width) {
89
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
90
+ }
91
+ /** The three header lines both views share. */
92
+ function header(report) {
93
+ const p = report.profile;
94
+ const from = p.source === "env"
95
+ ? `${p.origin}`
96
+ : p.source === "file"
97
+ ? "config file"
98
+ : "built-in default";
99
+ const lines = [
100
+ ` Data dir ${report.dataDir}`,
101
+ ` Config file ${report.configPath}${report.configFileExists ? "" : " (not present)"}`,
102
+ ` Profile ${p.name} (${from})`,
103
+ ];
104
+ if (p.unrecognised) {
105
+ lines.push(` ${p.origin ?? p.unrecognised.origin} names "${p.unrecognised.value}", which is not a known profile — ` +
106
+ `fell back to ${p.name}`);
107
+ }
108
+ if (report.configFileError)
109
+ lines.push(` ⚠ ${report.configFileError}`);
110
+ return lines;
111
+ }
112
+ /**
113
+ * File values a higher layer beat. Rendered by both views because the operator
114
+ * who edited a file and saw nothing change is precisely who runs this command,
115
+ * and today nothing anywhere tells them why.
116
+ */
117
+ function shadowedSection(report) {
118
+ const shadowed = report.fields.filter((f) => f.shadowedFileValue !== undefined);
119
+ if (shadowed.length === 0)
120
+ return [];
121
+ const width = Math.max(...shadowed.map((f) => f.key.length));
122
+ return [
123
+ "",
124
+ "Set in the config file but NOT in effect",
125
+ ...shadowed.map((f) => {
126
+ const wrote = renderValue(f.shadowedFileValue);
127
+ const why = f.source === "env"
128
+ ? `${f.origin} wins for the same key`
129
+ : `the resolved value is ${renderValue(f.value)}`;
130
+ return ` ${pad(f.key, width)} file says ${wrote} — ${why}`;
131
+ }),
132
+ ];
133
+ }
134
+ /**
135
+ * The default view: the decisions, not the resolution. Everything named here is
136
+ * something a human typed somewhere.
137
+ */
138
+ export function formatSummary(report) {
139
+ const fromFile = report.fields.filter((f) => f.source === "file");
140
+ const fromEnv = report.fields.filter((f) => f.source === "env");
141
+ const lines = ["Paddock config", ...header(report), "", "Set in the config file"];
142
+ if (fromFile.length === 0) {
143
+ lines.push(report.configFileExists ? " (nothing this instance reads)" : " (no config file)");
144
+ }
145
+ else {
146
+ const width = Math.max(...fromFile.map((f) => f.key.length));
147
+ for (const f of fromFile) {
148
+ lines.push(` ${pad(f.key, width)} ${oneLine(renderValue(f.sensitive ? HIDDEN : f.value)).text}`);
149
+ }
150
+ }
151
+ lines.push("", "Set in the environment");
152
+ if (fromEnv.length === 0) {
153
+ lines.push(" (nothing)");
154
+ }
155
+ else {
156
+ const width = Math.max(...fromEnv.map((f) => (f.origin ?? "").length));
157
+ for (const f of fromEnv) {
158
+ lines.push(` ${pad(f.origin ?? "", width)} ${f.key} = ${oneLine(renderValue(f.sensitive ? HIDDEN : f.value)).text}`);
159
+ }
160
+ }
161
+ lines.push(...shadowedSection(report));
162
+ lines.push("", `Everything else follows the ${report.profile.name} profile and Paddock's built-in defaults.`, "Run `paddock config show --resolved` to print every effective value and where it came from.");
163
+ return lines.join("\n");
164
+ }
165
+ /** `--resolved`: every field in the settings surface, grouped, with its layer. */
166
+ export function formatResolved(report, groups, showSensitive) {
167
+ const keyWidth = Math.max(...report.fields.map((f) => f.key.length));
168
+ const rendered = report.fields.map((f) => {
169
+ const hide = f.sensitive && !showSensitive && f.value !== null;
170
+ return { field: f, ...oneLine(renderValue(hide ? HIDDEN : f.value)), hidden: hide };
171
+ });
172
+ const valueWidth = Math.max(...rendered.map((r) => r.text.length));
173
+ const lines = ["Paddock config — every effective value, and the layer it came from", ...header(report)];
174
+ for (const g of groups) {
175
+ const rows = rendered.filter((r) => r.field.group === g.id);
176
+ if (rows.length === 0)
177
+ continue;
178
+ lines.push("", g.label);
179
+ for (const r of rows) {
180
+ lines.push(` ${pad(r.field.key, keyWidth)} ${pad(r.text, valueWidth)} ${renderSource(r.field)}`);
181
+ }
182
+ }
183
+ lines.push(...shadowedSection(report));
184
+ // A legend, listing only the layers actually present — a reader should not
185
+ // have to learn what `profile (yolo)` means on an instance that has no
186
+ // profile-sourced values left.
187
+ const present = new Set(report.fields.map((f) => f.source));
188
+ const legend = [
189
+ ["env", "env NAME", "that variable, which beats the config file for the same key"],
190
+ ["file", "file", report.configPath],
191
+ [
192
+ "profile",
193
+ `profile (${report.profile.name})`,
194
+ "the profile — these levers have no code default of their own",
195
+ ],
196
+ ["default", "default", "Paddock's built-in default"],
197
+ ];
198
+ const shown = legend.filter(([source]) => present.has(source));
199
+ if (shown.length > 0) {
200
+ const width = Math.max(...shown.map(([, term]) => term.length));
201
+ lines.push("", "Where the values came from", ...shown.map(([, term, meaning]) => ` ${pad(term, width)} ${meaning}`));
202
+ }
203
+ const notes = [];
204
+ if (rendered.some((r) => r.hidden)) {
205
+ notes.push(
206
+ // NOT "--json prints it": --json redacts too, and pointing at it as an
207
+ // escape hatch would be a false claim in the one place a reader is
208
+ // deciding whether their secret just went to a terminal.
209
+ `${HIDDEN} — a field marked sensitive. Pass --show-sensitive to print it.`);
210
+ }
211
+ if (rendered.some((r) => r.elided)) {
212
+ notes.push(`Values longer than ${MAX_VALUE_CHARS} characters are elided; --json prints them in full.`);
213
+ }
214
+ if (notes.length > 0)
215
+ lines.push("", ...notes.map((n) => n));
216
+ return lines.join("\n");
217
+ }
218
+ /** The report as JSON, with `sensitive` values replaced unless asked for. */
219
+ export function toJson(report, showSensitive) {
220
+ return JSON.stringify({
221
+ ...report,
222
+ fields: report.fields.map((f) => f.sensitive && !showSensitive && f.value !== null
223
+ ? { ...f, value: null, redacted: true }
224
+ : f),
225
+ }, null, 2);
226
+ }
227
+ /**
228
+ * Decide what ejecting would write. Pure — the caller does the IO — so every
229
+ * policy decision above is unit-testable against a hand-built report.
230
+ */
231
+ export function buildEjectPlan(report, includeEnv = false) {
232
+ const entries = [];
233
+ const skipped = [];
234
+ const keptFromFile = [];
235
+ for (const f of report.fields) {
236
+ if (!f.ejectable) {
237
+ // `sensitive` and the machine-specific bindings are the only two ways a
238
+ // field can be non-ejectable, and `sensitive` is on the field, so the
239
+ // reason is recoverable without duplicating the policy out of the catalog.
240
+ skipped.push({ key: f.key, reason: f.sensitive ? "sensitive" : "binding" });
241
+ continue;
242
+ }
243
+ if (f.source === "env" && !includeEnv) {
244
+ skipped.push({ key: f.key, reason: "env", ...(f.origin ? { origin: f.origin } : {}) });
245
+ continue;
246
+ }
247
+ if (f.value === null) {
248
+ skipped.push({ key: f.key, reason: "unset" });
249
+ continue;
250
+ }
251
+ // Already in the file and already winning: leave the operator's own line
252
+ // exactly as they wrote it, comments, quoting and all. The key is present,
253
+ // which is all an ejected file needs; rewriting it would only churn.
254
+ if (f.source === "file") {
255
+ keptFromFile.push(f.key);
256
+ continue;
257
+ }
258
+ entries.push({
259
+ key: f.key,
260
+ group: f.group,
261
+ value: f.value,
262
+ source: f.source,
263
+ ...(f.origin ? { origin: f.origin } : {}),
264
+ // The file naming a key that lost — to an env var, or to another key's
265
+ // cascade (`selfMcpEnabled: false` collapsing `selfMcpWriteEnabled`) — is
266
+ // the one case where eject overwrites something an operator typed. It is
267
+ // also the case where the file currently asserts something untrue, so
268
+ // replacing it with the effective value is the point rather than a cost.
269
+ ...(f.shadowedFileValue !== undefined
270
+ ? { action: "change", fileValue: f.shadowedFileValue }
271
+ : { action: "add" }),
272
+ });
273
+ }
274
+ const writeProfile = report.profile.source !== "file";
275
+ const pairs = entries.map(({ key, value }) => ({ key, value }));
276
+ // Written FIRST so it lands at the top of a fresh document, where a reader
277
+ // meets the posture before the forty keys expanding it.
278
+ if (writeProfile)
279
+ pairs.unshift({ key: "profile", value: report.profile.name });
280
+ return {
281
+ report,
282
+ entries,
283
+ skipped,
284
+ keptFromFile,
285
+ profile: { name: report.profile.name, write: writeProfile },
286
+ pairs,
287
+ };
288
+ }
289
+ /** `n item` / `n items`. */
290
+ function plural(n, one, many = `${one}s`) {
291
+ return `${n} ${n === 1 ? one : many}`;
292
+ }
293
+ /**
294
+ * The cost of ejecting, stated in terms of THIS plan's keys.
295
+ *
296
+ * #878 requires the staleness tradeoff to be surfaced where someone can act on
297
+ * it, not just documented. A generic "you will stop getting improved defaults"
298
+ * is easy to read past, so the two keys where that bites hardest and most
299
+ * concretely are named with their actual contents when the plan contains them.
300
+ */
301
+ function costSection(plan) {
302
+ const has = (key) => plan.entries.find((e) => e.key === key);
303
+ const lines = [
304
+ "",
305
+ "What you give up",
306
+ " Every key below stops following Paddock's defaults and becomes yours to",
307
+ " maintain. Upgrades will no longer change any of them.",
308
+ ];
309
+ const models = has("models");
310
+ if (Array.isArray(models?.value)) {
311
+ lines.push(` models pinned to today's ${plural(models.value.length, "model")}. A model added in a`, " later release will not be offered until you re-run this.");
312
+ }
313
+ if (has("environmentPrompt")) {
314
+ lines.push(" environmentPrompt pinned to the current text. Improvements to what agents", " are told about running in Paddock stop reaching you.");
315
+ }
316
+ lines.push("", " A lever added in a later release will not be in this file at all, so the", " file stops being the complete record you ejected it to be. `profile:` keeps", " such a lever on your posture rather than the built-in default — but keeping", " the file complete means re-running eject after upgrades.", "", " None of this is one-way. Delete the keys you would rather inherit again,", " leave `profile:` in place, and they go back to following it —", " `paddock config show` will tell you which layer each one is coming from.");
317
+ return lines;
318
+ }
319
+ /**
320
+ * Render a plan. `written` switches the tense — the two views are one function
321
+ * so a preview cannot promise something the write does not do.
322
+ */
323
+ export function formatEjectPlan(plan, groups, written) {
324
+ const { report } = plan;
325
+ const lines = [
326
+ written ? "Paddock config — ejected" : "Paddock config — eject preview",
327
+ ...header(report),
328
+ ];
329
+ const total = plan.pairs.length;
330
+ // Re-running eject on an already-ejected instance is the normal way to find
331
+ // out whether an upgrade added a lever, so it should read as an answer rather
332
+ // than as a write that did nothing.
333
+ if (total === 0) {
334
+ lines.push("", `Nothing to write — every key eject manages is already in ${report.configPath}.`, ...(plan.skipped.length > 0 ? notWrittenSection(plan) : []));
335
+ return lines.join("\n");
336
+ }
337
+ lines.push("", written
338
+ ? `Wrote ${plural(total, "key")} to ${report.configPath}`
339
+ : `Would write ${plural(total, "key")} to ${report.configPath}` +
340
+ (report.configFileExists ? "" : " (creating it)"));
341
+ if (plan.profile.write) {
342
+ lines.push(` profile: ${plan.profile.name} — so a lever added in a later release still`, " follows this posture rather than the built-in default");
343
+ }
344
+ const keyWidth = Math.max(8, ...plan.entries.map((e) => e.key.length));
345
+ const rendered = plan.entries.map((e) => ({ entry: e, ...oneLine(renderValue(e.value)) }));
346
+ const valueWidth = Math.max(1, ...rendered.map((r) => r.text.length));
347
+ for (const g of groups) {
348
+ const rows = rendered.filter((r) => r.entry.group === g.id);
349
+ if (rows.length === 0)
350
+ continue;
351
+ lines.push("", g.label);
352
+ for (const r of rows) {
353
+ const from = r.entry.action === "change"
354
+ ? `replaces ${oneLine(renderValue(r.entry.fileValue)).text}, which is not in effect`
355
+ : `from ${renderSource({ source: r.entry.source, origin: r.entry.origin })}`;
356
+ lines.push(` ${pad(r.entry.key, keyWidth)} ${pad(r.text, valueWidth)} ${from}`);
357
+ }
358
+ }
359
+ if (plan.keptFromFile.length > 0) {
360
+ lines.push("", `Already in the file, left untouched (${plan.keptFromFile.length})`, ` ${plan.keptFromFile.join(", ")}`);
361
+ }
362
+ lines.push(...notWrittenSection(plan));
363
+ lines.push(...costSection(plan));
364
+ lines.push("", written
365
+ ? "`paddock config show --resolved` now reports these as `file`."
366
+ : "Nothing was written. Re-run with --write to apply.");
367
+ return lines.join("\n");
368
+ }
369
+ /** The deliberate omissions, grouped by reason so the list stays readable. */
370
+ function notWrittenSection(plan) {
371
+ const by = (r) => plan.skipped.filter((s) => s.reason === r);
372
+ const env = by("env");
373
+ const sensitive = by("sensitive");
374
+ const binding = by("binding");
375
+ const unset = by("unset");
376
+ if (plan.skipped.length === 0)
377
+ return [];
378
+ const lines = ["", "Not written"];
379
+ if (env.length > 0) {
380
+ lines.push(` ${plural(env.length, "key")} the environment supplies — an environment variable beats the`, " file, so freezing its value here would change nothing now and change this", " instance the day the variable goes away. Pass --include-env to write them.", ...(() => {
381
+ const w = Math.max(...env.map((s) => s.key.length));
382
+ return env.map((s) => ` ${pad(s.key, w)} ${s.origin ?? "env"}`);
383
+ })());
384
+ }
385
+ if (sensitive.length > 0) {
386
+ lines.push(` ${plural(sensitive.length, "key")} marked sensitive — never bulk-written to disk, because one of`, " them (transcription.endpoint) can carry a credential in its URL. Set one by", " hand if you want it in the file.", ` ${sensitive.map((s) => s.key).join(", ")}`);
387
+ }
388
+ if (binding.length > 0) {
389
+ lines.push(" Process and filesystem bindings — absolute paths and a port specific to", " this machine, which would make the file unportable:", ` ${binding.map((s) => s.key).join(", ")}`);
390
+ }
391
+ if (unset.length > 0) {
392
+ lines.push(` ${plural(unset.length, "optional key")} nothing has set, so there is no value to freeze:`, ` ${unset.map((s) => s.key).join(", ")}`);
393
+ }
394
+ return lines;
395
+ }
396
+ /**
397
+ * Run a `config` action against the data dir the caller has already resolved
398
+ * into `PADDOCK_DATA_DIR`.
399
+ *
400
+ * The two imports are dynamic for the same reason `paddock.ts` defers
401
+ * `start.js`: config resolution reads the environment, so nothing that resolves
402
+ * it may be pulled in before the caller has finished setting it.
403
+ *
404
+ * `createDataDir: false` is the one behavioural request this command makes of
405
+ * the loader, and it holds for `eject` too. Inspecting an instance must not
406
+ * CREATE one — otherwise `paddock config show` on a machine that has never run
407
+ * Paddock leaves an empty `~/.paddock` behind, and the first-run welcome that
408
+ * keys on its absence never prints. `eject --write` does create the directory,
409
+ * because it has to put a file in it, but only on the path that writes.
410
+ */
411
+ export async function runConfig(action, opts) {
412
+ const { loadPaddockConfig } = await import("../config.js");
413
+ const { resolveConfigReport, writeInstanceConfig, GROUPS } = await import("../instance-config.js");
414
+ let cfg;
415
+ try {
416
+ cfg = loadPaddockConfig({ createDataDir: false });
417
+ }
418
+ catch (err) {
419
+ // An unparseable file, a `PADDOCK_CONFIG` pointing at nothing, a file from a
420
+ // newer schema, a Claude home that resolves somewhere refused — the loader
421
+ // rejects all of these, and rejecting them here is the RIGHT answer: the
422
+ // honest reply to "what is my config?" when it will not load is "your
423
+ // instance would not boot". On its own it reads as though the inspection tool
424
+ // is broken, so say whose problem it is.
425
+ //
426
+ // Scoped to the LOAD deliberately, and worded about the configuration rather
427
+ // than about a file. A failure below this line is a bug in this command, and
428
+ // claiming `paddock start` would fail too would be a false statement at the
429
+ // moment someone most needs a true one — while a `CLAUDE_CONFIG_DIR` refusal
430
+ // is a real boot failure with no config file involved at all.
431
+ throw new CliError(`${err.message}\nThis is what \`paddock start\` would fail with too.`);
432
+ }
433
+ const report = resolveConfigReport(cfg);
434
+ if (action === "eject") {
435
+ const plan = buildEjectPlan(report, opts.includeEnv === true);
436
+ if (opts.write === true) {
437
+ try {
438
+ writeInstanceConfig(report.configPath, plan.pairs);
439
+ }
440
+ catch (err) {
441
+ // An unwritable path, a read-only mount, a directory where the file
442
+ // should be. Raised as a CliError so `paddock.ts` reports it as this
443
+ // command failing rather than appending its "`paddock start` would fail
444
+ // on the same file" note, which is true of a malformed config and a
445
+ // lie about a permissions problem.
446
+ throw new CliError(`could not write ${report.configPath}: ${err.message}\n` +
447
+ `Nothing was changed. Check the path is writable, then re-run.`);
448
+ }
449
+ }
450
+ console.log(formatEjectPlan(plan, GROUPS, opts.write === true));
451
+ return;
452
+ }
453
+ if (action !== "show")
454
+ throw new Error(`unhandled config action: ${String(action)}`);
455
+ if (opts.json) {
456
+ console.log(toJson(report, opts.showSensitive === true));
457
+ return;
458
+ }
459
+ console.log(opts.resolved
460
+ ? formatResolved(report, GROUPS, opts.showSensitive === true)
461
+ : formatSummary(report));
462
+ }
@@ -23,7 +23,7 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { CliError, USAGE, SERVICE_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
26
+ import { CliError, USAGE, SERVICE_USAGE, CONFIG_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
27
27
  import { runService, safeHomeDir } from "./service/index.js";
28
28
  /**
29
29
  * This module's own file and directory: `<…>/packages/server/dist/cli/`.
@@ -187,6 +187,52 @@ async function service(command) {
187
187
  throw err;
188
188
  }
189
189
  }
190
+ /**
191
+ * Which instance this invocation is about: an explicit flag, else the
192
+ * environment, else `~/.paddock`.
193
+ *
194
+ * Shared by `start` and `config` deliberately. A `config show` that resolved its
195
+ * data dir even slightly differently would report on an instance other than the
196
+ * one `paddock start` runs — and would do it convincingly, since every value it
197
+ * printed would be real. One function, so the two cannot diverge.
198
+ */
199
+ function resolveDataDir(opts) {
200
+ return path.resolve(opts.dataDir ?? process.env.PADDOCK_DATA_DIR ?? path.join(os.homedir(), ".paddock"));
201
+ }
202
+ /**
203
+ * `paddock config …` — print the resolved configuration (#878).
204
+ *
205
+ * Sets `PADDOCK_DATA_DIR` before the dynamic import for the same reason the
206
+ * start path does: `loadPaddockConfig` reads the environment, so the environment
207
+ * has to be finished first. Nothing else `main` does on the way to `start()` —
208
+ * the PATH edit, the first-run notice, the log-level defaults — applies to a
209
+ * command that only reads.
210
+ */
211
+ async function config(command) {
212
+ const { action, opts } = command;
213
+ // Unreachable: `parseCommand` only omits the action when `--help` was given,
214
+ // which `main` has already handled. Typed rather than asserted.
215
+ if (action === undefined) {
216
+ console.log(CONFIG_USAGE);
217
+ return;
218
+ }
219
+ process.env.PADDOCK_DATA_DIR = resolveDataDir(opts);
220
+ const { runConfig } = await import("./config.js");
221
+ try {
222
+ await runConfig(action, opts);
223
+ }
224
+ catch (err) {
225
+ // A config that will not load reaches here as a CliError already carrying
226
+ // the "start would fail too" note; anything else is reported as itself,
227
+ // because it is a bug in this command rather than in the configuration.
228
+ // Same shape as `service` above.
229
+ if (err instanceof CliError)
230
+ fail(err.message);
231
+ if (err instanceof Error)
232
+ fail(err.message);
233
+ throw err;
234
+ }
235
+ }
190
236
  async function main() {
191
237
  let command;
192
238
  try {
@@ -199,7 +245,7 @@ async function main() {
199
245
  }
200
246
  const opts = command.opts;
201
247
  if (opts.help) {
202
- console.log(command.verb === "service" ? SERVICE_USAGE : USAGE);
248
+ console.log(command.verb === "service" ? SERVICE_USAGE : command.verb === "config" ? CONFIG_USAGE : USAGE);
203
249
  return;
204
250
  }
205
251
  if (opts.version) {
@@ -213,6 +259,10 @@ async function main() {
213
259
  await service(command);
214
260
  return;
215
261
  }
262
+ if (command.verb === "config") {
263
+ await config(command);
264
+ return;
265
+ }
216
266
  addBundledBinsToPath();
217
267
  // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
218
268
  // used to make the current directory the workspace by setting
@@ -235,7 +285,7 @@ async function main() {
235
285
  // server resolves config inside `buildApp()`, so anything set before the
236
286
  // dynamic import below is picked up with no special-casing in config.ts.
237
287
  // Precedence is explicit flag > existing env > our default.
238
- const dataDir = path.resolve(opts.dataDir ?? process.env.PADDOCK_DATA_DIR ?? path.join(os.homedir(), ".paddock"));
288
+ const dataDir = resolveDataDir(opts);
239
289
  noteFirstRun(dataDir);
240
290
  process.env.PADDOCK_DATA_DIR = dataDir;
241
291
  if (opts.port)
@@ -1,4 +1,5 @@
1
1
  import { type DriveMode } from "./models.js";
2
+ import { type ProfileName } from "./profiles.js";
2
3
  import { type TranscriptsMode } from "./transcripts.js";
3
4
  import { type CredentialsMode } from "./claude-credentials.js";
4
5
  import { type InstructionsMode } from "./claude-instructions.js";
@@ -145,6 +146,17 @@ export interface OpenApiConfig {
145
146
  path: string;
146
147
  }
147
148
  export interface PaddockConfig {
149
+ /**
150
+ * The resolved posture profile (#878) — `paranoid` | `balanced` | `yolo`.
151
+ *
152
+ * Reported for display and provenance only; nothing reads it to make a
153
+ * decision. It has already been expanded into the individual posture fields
154
+ * below by the time this object exists, and any of those the operator set
155
+ * explicitly (file key or env var) has overridden the profile's value — so the
156
+ * profile name tells you where the UNSET levers came from, not what every
157
+ * lever is. See `profiles.ts` for the precedence chain.
158
+ */
159
+ profile: ProfileName;
148
160
  /** HTTP/WS port. */
149
161
  port: number;
150
162
  /** Bind host. Defaults to loopback (`127.0.0.1`) — safe by default (#435). */
@@ -485,6 +497,13 @@ export interface PaddockConfigFile {
485
497
  * lenient-parsed. See `schema-version.ts` for when to bump it.
486
498
  */
487
499
  schemaVersion?: number | string;
500
+ /**
501
+ * Posture profile (#878): `paranoid` | `balanced` | `yolo`. Expands to
502
+ * defaults for the `claude:` modes, `maxSpawnDepth` and the capability
503
+ * toggles; any of those set explicitly here or in the env still wins.
504
+ * `PADDOCK_PROFILE` overrides this key. Absent ⇒ `balanced`.
505
+ */
506
+ profile?: string;
488
507
  port?: number | string;
489
508
  host?: string;
490
509
  dataDir?: string;
@@ -680,7 +699,23 @@ export declare function loadConfigFile(dataDir: string): PaddockConfigFile;
680
699
  * installed under an arbitrary user directory (`npx`, global install).
681
700
  */
682
701
  export declare function resolveDefaultWebDist(moduleUrl: string): string;
683
- export declare function loadPaddockConfig(): PaddockConfig;
702
+ /** Options for {@link loadPaddockConfig}. */
703
+ export interface LoadConfigOptions {
704
+ /**
705
+ * Create the data root if it is missing. Default `true` — every caller that
706
+ * goes on to RUN something needs it, and creating it early is what makes the
707
+ * symlink resolution below consistent.
708
+ *
709
+ * `paddock config show` (#878) passes `false`. Inspecting an instance must not
710
+ * bring one into being: otherwise running it on a machine that has never
711
+ * started Paddock leaves an empty `~/.paddock` behind, and the first-run
712
+ * welcome — which keys on that directory's absence — never prints again.
713
+ * Skipping the mkdir costs nothing else, because `canonical()` already handles
714
+ * a path whose tail does not exist yet.
715
+ */
716
+ createDataDir?: boolean;
717
+ }
718
+ export declare function loadPaddockConfig(opts?: LoadConfigOptions): PaddockConfig;
684
719
  /**
685
720
  * The USER's own Claude home — `~/.claude`, always. A read-only source for
686
721
  * adoption and the user-config bridge, never a destination (#620).