@lamalibre/create-lamaste 4.3.1 → 4.4.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.
Files changed (72) hide show
  1. package/package.json +2 -2
  2. package/src/lib/service-config.js +6 -0
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +38 -1
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +6 -6
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +11 -11
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  19. package/vendor/docs/03-architecture/installer.md +4 -4
  20. package/vendor/docs/03-architecture/management-flow.md +1 -1
  21. package/vendor/docs/03-architecture/overview.md +5 -5
  22. package/vendor/docs/03-architecture/panel-client.md +7 -7
  23. package/vendor/docs/03-architecture/panel-server.md +2 -2
  24. package/vendor/docs/03-architecture/state-management.md +2 -2
  25. package/vendor/docs/03-architecture/system-overview.md +1 -1
  26. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  27. package/vendor/docs/04-api-reference/app-host.md +87 -38
  28. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  29. package/vendor/docs/04-api-reference/certificates.md +22 -19
  30. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  31. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  32. package/vendor/docs/04-api-reference/overview.md +137 -135
  33. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  34. package/vendor/docs/05-operations/backup-and-restore.md +2 -1
  35. package/vendor/docs/05-operations/uninstalling.md +1 -1
  36. package/vendor/docs/05-operations/upgrades.md +6 -6
  37. package/vendor/docs/06-reference/config-files.md +89 -84
  38. package/vendor/docs/06-reference/glossary.md +3 -3
  39. package/vendor/docs/06-reference/installer-flags.md +13 -13
  40. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  41. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  42. package/vendor/docs/gatekeeper-design.md +8 -8
  43. package/vendor/docs/identity-directory-design.md +2 -2
  44. package/vendor/docs/package.json +1 -1
  45. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  46. package/vendor/gatekeeper/package.json +2 -2
  47. package/vendor/server/bin/lamaste-server.js +4 -1
  48. package/vendor/server/package.json +4 -3
  49. package/vendor/server/src/commands/plugin-settings.js +541 -0
  50. package/vendor/server/src/commands/plugins.js +94 -27
  51. package/vendor/server/src/panel-api.js +36 -4
  52. package/vendor/server/src/plugin-approval.js +78 -0
  53. package/vendor/server/src/plugin-settings.js +699 -0
  54. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  55. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  56. package/vendor/server-ui/dist/index.html +2 -2
  57. package/vendor/serverd/package.json +2 -2
  58. package/vendor/serverd/src/index.js +9 -0
  59. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  60. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  61. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  62. package/vendor/serverd/src/lib/mtls.js +44 -4
  63. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  64. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  65. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  66. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  67. package/vendor/serverd/src/lib/plugins.js +73 -22
  68. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  69. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  70. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  71. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +0 -84
  72. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
@@ -0,0 +1,699 @@
1
+ /**
2
+ * `lamaste-server plugins settings|configure` as pure functions (no I/O, no
3
+ * colour): the arguments of `configure`, the secrets it reads on stdin, the
4
+ * partial update (`PUT /api/plugins/:name/settings` `{ revision, values?,
5
+ * secrets? }`) the flags or the prompts make, and the sentences that
6
+ * describe a settings view (`GET /api/plugins/:name/settings`).
7
+ *
8
+ * The view carries the field definitions, each non-secret field's
9
+ * effective value (what the agent half stores, or the default) and, for a
10
+ * secret, only `{ set, setAt }` — never a value. A secret value appears in
11
+ * nothing these functions return but the update's `secrets`: messages name
12
+ * keys only, and an argument that could be a mistyped secret is never
13
+ * repeated.
14
+ *
15
+ * Values are checked with the core library's validator without the
16
+ * manifest's `pattern`: the CLI runs as root on the relay, which never runs
17
+ * a plugin's regular expression; the agent half tests it and answers per
18
+ * field (422).
19
+ *
20
+ * Labels, descriptions, options, defaults and values come from a plugin's
21
+ * manifest or its agent half, and the panel's messages may quote them: the
22
+ * text these functions build passes them through {@link terminalText}, so
23
+ * none can drive the administrator's terminal or reorder what it shows.
24
+ */
25
+
26
+ import {
27
+ AGENT_HALF_SETTINGS_MAX_BYTES,
28
+ SETTINGS_KEY_RE,
29
+ validateSettingsUpdate,
30
+ } from '@lamalibre/lamaste';
31
+
32
+ export const CONFIGURE_USAGE =
33
+ 'Usage: lamaste-server plugins configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]';
34
+
35
+ /** The most `--secrets-stdin` reads: the panel takes no larger settings change. */
36
+ export const SECRETS_STDIN_MAX_BYTES = AGENT_HALF_SETTINGS_MAX_BYTES;
37
+
38
+ const INTEGER_RE = /^[+-]?\d+$/;
39
+ const NUMBER_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
40
+ const TRUE_WORDS = new Set(['true', 'yes', 'on']);
41
+ const FALSE_WORDS = new Set(['false', 'no', 'off']);
42
+
43
+ /** Whether a field must have a value: required, with no default to fall back to. */
44
+ export function needsValue(field) {
45
+ return field.required === true && field.default === undefined;
46
+ }
47
+
48
+ /** Whether an update may clear a field (`null`): a secret when optional, another field unless it must have a value. */
49
+ export function canClear(field) {
50
+ return field.type === 'secret' ? field.required !== true : !needsValue(field);
51
+ }
52
+
53
+ /**
54
+ * The arguments of `configure <name> [--set key=value]... [--clear key]...
55
+ * [--secrets-stdin]` (also `--set=key=value`, `--clear=key`). `--set` and
56
+ * `--clear` take a key a setting can have; neither the value of a `--set` nor
57
+ * an argument that is not a known option is repeated in an error — it could
58
+ * be a secret typed in the wrong place.
59
+ *
60
+ * @param {string[]} args what follows `configure`
61
+ * @returns {{ ok: true, name: string, sets: Array<{ key: string, raw: string }>, clears: string[], secretsStdin: boolean } | { ok: false, error: string }}
62
+ */
63
+ export function parseConfigureArgs(args) {
64
+ let name = null;
65
+ const sets = [];
66
+ const clears = [];
67
+ let secretsStdin = false;
68
+ for (let i = 0; i < args.length; i++) {
69
+ const arg = args[i];
70
+ if (arg === '--set' || arg.startsWith('--set=')) {
71
+ const pair = arg === '--set' ? args[++i] : arg.slice('--set='.length);
72
+ const eq = typeof pair === 'string' ? pair.indexOf('=') : -1;
73
+ if (eq <= 0 || !SETTINGS_KEY_RE.test(pair.slice(0, eq))) {
74
+ return {
75
+ ok: false,
76
+ error: `--set takes key=value, the key a setting name (letters and digits, starting with a lowercase letter) — ${CONFIGURE_USAGE}`,
77
+ };
78
+ }
79
+ sets.push({ key: pair.slice(0, eq), raw: pair.slice(eq + 1) });
80
+ } else if (arg === '--clear' || arg.startsWith('--clear=')) {
81
+ const key = arg === '--clear' ? args[++i] : arg.slice('--clear='.length);
82
+ if (typeof key !== 'string' || !SETTINGS_KEY_RE.test(key)) {
83
+ return { ok: false, error: `--clear takes a setting name — ${CONFIGURE_USAGE}` };
84
+ }
85
+ clears.push(key);
86
+ } else if (arg === '--secrets-stdin') {
87
+ secretsStdin = true;
88
+ } else if (arg.startsWith('-')) {
89
+ return {
90
+ ok: false,
91
+ error: /^--?[a-z][a-z-]*$/.test(arg)
92
+ ? `Unknown option ${arg} — ${CONFIGURE_USAGE}`
93
+ : `Unknown option — ${CONFIGURE_USAGE}`,
94
+ };
95
+ } else if (name === null) {
96
+ name = arg;
97
+ } else {
98
+ return { ok: false, error: `One plugin name only — ${CONFIGURE_USAGE}` };
99
+ }
100
+ }
101
+ if (name === null) return { ok: false, error: CONFIGURE_USAGE };
102
+ return { ok: true, name, sets, clears, secretsStdin };
103
+ }
104
+
105
+ /**
106
+ * What `--secrets-stdin` read: one JSON object, `{ key: "value" | null }`.
107
+ * Its entries are checked by {@link buildSettingsUpdate}, per key. A parse
108
+ * failure is reported in words of its own — `JSON.parse`'s message quotes
109
+ * the text it could not read.
110
+ *
111
+ * @param {string} text
112
+ * @returns {{ ok: true, secrets: Record<string, unknown> } | { ok: false, error: string }}
113
+ */
114
+ export function parseSecretsJson(text) {
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(text);
118
+ } catch {
119
+ return {
120
+ ok: false,
121
+ error: 'stdin is not valid JSON — give one object: { "key": "value" | null }',
122
+ };
123
+ }
124
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
125
+ return { ok: false, error: 'stdin must hold one JSON object: { "key": "value" | null }' };
126
+ }
127
+ return { ok: true, secrets: parsed };
128
+ }
129
+
130
+ /**
131
+ * The value of `--set key=<raw>` for a non-secret field, parsed by the
132
+ * field's type; the range, the length and (for an enum) the option are
133
+ * checked here or by the core validator. An empty value is refused — clear
134
+ * a field with `--clear`. Messages never contain the value.
135
+ *
136
+ * @returns {{ ok: true, value: string | number | boolean } | { ok: false, message: string }}
137
+ */
138
+ function parseSetValue(field, raw) {
139
+ if (raw === '') {
140
+ return {
141
+ ok: false,
142
+ message: canClear(field)
143
+ ? 'Is empty — use --clear to put it back to its default'
144
+ : 'Is empty — this setting needs a value',
145
+ };
146
+ }
147
+ switch (field.type) {
148
+ case 'string':
149
+ return { ok: true, value: raw };
150
+ case 'integer':
151
+ return INTEGER_RE.test(raw.trim())
152
+ ? { ok: true, value: Number(raw.trim()) }
153
+ : { ok: false, message: 'Must be an integer' };
154
+ case 'number':
155
+ return NUMBER_RE.test(raw.trim())
156
+ ? { ok: true, value: Number(raw.trim()) }
157
+ : { ok: false, message: 'Must be a number' };
158
+ case 'boolean': {
159
+ const word = raw.trim().toLowerCase();
160
+ if (TRUE_WORDS.has(word)) return { ok: true, value: true };
161
+ if (FALSE_WORDS.has(word)) return { ok: true, value: false };
162
+ return { ok: false, message: 'Must be true or false' };
163
+ }
164
+ case 'enum': {
165
+ const options = field.options ?? [];
166
+ return options.some((o) => o.value === raw)
167
+ ? { ok: true, value: raw }
168
+ : { ok: false, message: `Must be one of: ${options.map((o) => o.value).join(', ')}` };
169
+ }
170
+ default:
171
+ return { ok: false, message: 'Cannot be set with --set' };
172
+ }
173
+ }
174
+
175
+ /** Why `--set` refuses a secret. */
176
+ const SECRET_ON_COMMAND_LINE =
177
+ 'Is a secret — never on the command line: pipe it in with --secrets-stdin, or run configure without flags to be asked for it';
178
+
179
+ /** What follows when the refused `--set` carried a value: where it is now, and what to do. */
180
+ export const SECRET_LEAKED =
181
+ "The value is now in your shell history and in sudo's log — rotate that secret.";
182
+
183
+ /** The settings block of a view, for the core validator. */
184
+ function blockOf(view) {
185
+ return { schemaVersion: view.schemaVersion, fields: view.fields };
186
+ }
187
+
188
+ /**
189
+ * The core library's verdict on one change (`values` or `secrets`, by key)
190
+ * without testing a pattern: `{ key: message }` for what it refuses.
191
+ *
192
+ * @returns {Record<string, string>}
193
+ */
194
+ function coreErrors(view, input) {
195
+ const checked = validateSettingsUpdate(blockOf(view), input, { checkPattern: false });
196
+ return checked.ok ? {} : checked.errors;
197
+ }
198
+
199
+ /**
200
+ * Why one answer of the interactive mode is not a valid value of its field,
201
+ * or null — for a prompt's `validate`. `value` is what the answer parsed to
202
+ * (`null` clears the field). The message never contains the value.
203
+ *
204
+ * @param {object} view
205
+ * @param {object} field
206
+ * @param {string | number | boolean | null} value
207
+ * @returns {string | null}
208
+ */
209
+ export function answerError(view, field, value) {
210
+ const input =
211
+ field.type === 'secret'
212
+ ? { secrets: { [field.key]: value } }
213
+ : { values: { [field.key]: value } };
214
+ const errors = coreErrors(view, input);
215
+ return errors[field.key] ?? errors._ ?? null;
216
+ }
217
+
218
+ /**
219
+ * A text answer of the interactive mode for a string, integer or number
220
+ * field: empty clears the field (back to its default), anything else is
221
+ * parsed by the field's type and checked like a `--set` value.
222
+ *
223
+ * @returns {{ ok: true, value: string | number | null } | { ok: false, message: string }}
224
+ */
225
+ export function parseTextAnswer(view, field, text) {
226
+ if (text === '') {
227
+ return canClear(field) ? { ok: true, value: null } : { ok: false, message: 'Needs a value' };
228
+ }
229
+ const parsed = parseSetValue(field, text);
230
+ if (!parsed.ok) return parsed;
231
+ const message = answerError(view, field, parsed.value);
232
+ return message ? { ok: false, message } : parsed;
233
+ }
234
+
235
+ /** `1 unknown setting`, `3 unknown settings` — core's words for names the schema does not know. */
236
+ function unknownSettingsMessage(count) {
237
+ return count === 1 ? '1 unknown setting' : `${count} unknown settings`;
238
+ }
239
+
240
+ /**
241
+ * The partial update the flags of `configure` make: `--set` values parsed
242
+ * by their field's type (refused for a secret — those come on stdin or from
243
+ * the interactive mode), `--clear` as `null` (in `values` or `secrets` by the
244
+ * field's type), the secrets read with `--secrets-stdin`; then the core
245
+ * library's checks (required fields, length, range, option). Errors are
246
+ * keyed by setting and never contain a value. A name the schema does not
247
+ * know — on the command line or on stdin — is counted under `_` ("2 unknown
248
+ * settings"), as the core library and the panel count them, and never
249
+ * named: it is text the administrator typed, and a token pasted where a key
250
+ * belongs must not come back on the terminal.
251
+ *
252
+ * @param {{ schemaVersion: number, fields: Array<object>, revision: number | null }} view
253
+ * @param {{ sets?: Array<{ key: string, raw: string }>, clears?: string[], secrets?: Record<string, unknown> }} flags
254
+ * @returns {{ ok: true, body: { revision: number | null, values?: Record<string, unknown>, secrets?: Record<string, string | null> } } | { ok: false, errors: Record<string, string> }}
255
+ */
256
+ export function buildSettingsUpdate(view, { sets = [], clears = [], secrets = {} }) {
257
+ const fields = new Map(view.fields.map((field) => [field.key, field]));
258
+ const errors = new Map();
259
+ const fail = (key, message) => {
260
+ if (!errors.has(key)) errors.set(key, message);
261
+ };
262
+ const seen = new Set();
263
+ const firstMention = (key) => {
264
+ if (!seen.has(key)) {
265
+ seen.add(key);
266
+ return true;
267
+ }
268
+ fail(key, 'Given more than once');
269
+ return false;
270
+ };
271
+ // Keys are setting names (SETTINGS_KEY_RE), so `__proto__` never is one.
272
+ const values = {};
273
+ const secretValues = {};
274
+ let unknown = 0;
275
+
276
+ for (const { key, raw } of sets) {
277
+ const field = fields.get(key);
278
+ if (!field) {
279
+ unknown += 1;
280
+ continue;
281
+ }
282
+ if (!firstMention(key)) continue;
283
+ if (field.type === 'secret') {
284
+ // Refused, but too late for the value itself: it was typed on the command line.
285
+ fail(
286
+ key,
287
+ raw === '' ? SECRET_ON_COMMAND_LINE : `${SECRET_ON_COMMAND_LINE}. ${SECRET_LEAKED}`,
288
+ );
289
+ } else {
290
+ const parsed = parseSetValue(field, raw);
291
+ if (parsed.ok) values[key] = parsed.value;
292
+ else fail(key, parsed.message);
293
+ }
294
+ }
295
+
296
+ for (const key of clears) {
297
+ const field = fields.get(key);
298
+ if (!field) {
299
+ unknown += 1;
300
+ continue;
301
+ }
302
+ if (!firstMention(key)) continue;
303
+ if (field.type === 'secret') secretValues[key] = null;
304
+ else values[key] = null;
305
+ }
306
+
307
+ for (const [key, value] of Object.entries(secrets)) {
308
+ const field = SETTINGS_KEY_RE.test(key) ? fields.get(key) : undefined;
309
+ if (!field) {
310
+ unknown += 1;
311
+ continue;
312
+ }
313
+ if (!firstMention(key)) continue;
314
+ if (field.type !== 'secret') fail(key, 'Is not a secret — give it with --set');
315
+ else if (value !== null && typeof value !== 'string') {
316
+ fail(key, 'Must be a string, or null to clear it');
317
+ } else secretValues[key] = value;
318
+ }
319
+ if (unknown > 0) fail('_', unknownSettingsMessage(unknown));
320
+
321
+ for (const [key, message] of Object.entries(
322
+ coreErrors(view, { values, secrets: secretValues }),
323
+ )) {
324
+ fail(key, message);
325
+ }
326
+ if (errors.size > 0) return { ok: false, errors: Object.fromEntries(errors) };
327
+ return { ok: true, body: updateBody(view.revision, values, secretValues) };
328
+ }
329
+
330
+ /**
331
+ * `{ revision, values?, secrets? }` with only the parts that change something.
332
+ *
333
+ * @param {number | null} revision
334
+ * @param {Record<string, unknown>} values
335
+ * @param {Record<string, string | null>} secrets
336
+ */
337
+ export function updateBody(revision, values, secrets) {
338
+ return {
339
+ revision,
340
+ ...(Object.keys(values).length > 0 ? { values } : {}),
341
+ ...(Object.keys(secrets).length > 0 ? { secrets } : {}),
342
+ };
343
+ }
344
+
345
+ /**
346
+ * The key names an update changes, as the panel's audit entry lists them:
347
+ * values set, values cleared (back to the default), secrets set, secrets
348
+ * cleared. Never a value.
349
+ *
350
+ * @param {{ values?: Record<string, unknown>, secrets?: Record<string, unknown> }} body
351
+ */
352
+ export function updateSummary(body) {
353
+ const values = Object.entries(body.values ?? {});
354
+ const secrets = Object.entries(body.secrets ?? {});
355
+ return {
356
+ changed: values.filter(([, v]) => v !== null).map(([k]) => k),
357
+ cleared: values.filter(([, v]) => v === null).map(([k]) => k),
358
+ secretsSet: secrets.filter(([, v]) => v !== null).map(([k]) => k),
359
+ secretsCleared: secrets.filter(([, v]) => v === null).map(([k]) => k),
360
+ };
361
+ }
362
+
363
+ /** Whether an update changes anything. */
364
+ export function updateChangesSomething(body) {
365
+ return Object.keys(body.values ?? {}).length + Object.keys(body.secrets ?? {}).length > 0;
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Words
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * Control characters — C0 and C1, DEL among them: ESC and CSI start the
374
+ * sequences that move the cursor, recolour or rewrite the screen, set the
375
+ * window title — and the bidirectional formatting characters (U+061C,
376
+ * U+200E, U+200F, U+202A–U+202E, U+2066–U+2069), which reorder what is
377
+ * shown around them.
378
+ */
379
+ const UNPRINTABLE_RE = /[\p{Cc}\p{Bidi_Control}]/gu;
380
+
381
+ /**
382
+ * `value` as text for the terminal: without control or bidirectional
383
+ * formatting characters. Every string from a manifest, an agent half or the
384
+ * panel goes through this before it is printed or shown in a prompt; the
385
+ * NDJSON of `--json` carries the data as it is.
386
+ *
387
+ * @param {unknown} value
388
+ * @returns {string}
389
+ */
390
+ export function terminalText(value) {
391
+ return String(value ?? '').replace(UNPRINTABLE_RE, '');
392
+ }
393
+
394
+ /** "12m ago" — how long before `now` (epoch ms) an ISO moment was. */
395
+ export function relativeTime(iso, now) {
396
+ const diffMs = now - Date.parse(iso);
397
+ if (!Number.isFinite(diffMs)) return '';
398
+ if (diffMs < 0) return 'just now';
399
+ const seconds = Math.floor(diffMs / 1000);
400
+ if (seconds < 60) return `${seconds}s ago`;
401
+ const minutes = Math.floor(seconds / 60);
402
+ if (minutes < 60) return `${minutes}m ago`;
403
+ const hours = Math.floor(minutes / 60);
404
+ if (hours < 24) return `${hours}h ago`;
405
+ return `${Math.floor(hours / 24)}d ago`;
406
+ }
407
+
408
+ /** `2026-10-16T14:05:12.000Z (12m ago)`, or "an unknown time". */
409
+ export function formatMoment(iso, now) {
410
+ if (typeof iso !== 'string' || !Number.isFinite(Date.parse(iso))) return 'an unknown time';
411
+ return `${terminalText(iso)} (${relativeTime(iso, now)})`;
412
+ }
413
+
414
+ /**
415
+ * The hosting of a settings view (`{ agentLabel, phase, state, reason,
416
+ * since, agentLastSeenAt }` or null) as a sentence, in the panel's words —
417
+ * "Hosting agent edge-1 is offline since … — last sync …" — and whether it
418
+ * is offline (health down).
419
+ *
420
+ * @param {object | null} hosting
421
+ * @param {number} now
422
+ * @returns {{ offline: boolean, text: string }}
423
+ */
424
+ export function hostingText(hosting, now) {
425
+ if (!hosting) return { offline: false, text: 'Not hosted on an agent yet' };
426
+ const agent = terminalText(hosting.agentLabel);
427
+ if (hosting.phase === 'installing') {
428
+ return { offline: false, text: `The hosting on ${agent} is being set up` };
429
+ }
430
+ if (hosting.phase === 'removing') {
431
+ return { offline: false, text: `The hosting on ${agent} is being removed` };
432
+ }
433
+ const since = formatMoment(hosting.since, now);
434
+ if (hosting.state === 'down') {
435
+ if (hosting.reason === 'app') {
436
+ return { offline: true, text: `The agent half is not responding since ${since}` };
437
+ }
438
+ if (hosting.reason === 'tunnel') {
439
+ const lastSync = hosting.agentLastSeenAt
440
+ ? `last sync ${formatMoment(hosting.agentLastSeenAt, now)}`
441
+ : 'no sync recorded';
442
+ return {
443
+ offline: true,
444
+ text: `Hosting agent ${agent} is offline since ${since} — ${lastSync}`,
445
+ };
446
+ }
447
+ return { offline: true, text: `The hosting on ${agent} is down since ${since}` };
448
+ }
449
+ if (hosting.state === 'up') {
450
+ return {
451
+ offline: false,
452
+ text: `Hosted on agent ${agent}, up${hosting.since ? ` since ${since}` : ''}`,
453
+ };
454
+ }
455
+ return {
456
+ offline: false,
457
+ text: `Hosted on agent ${agent}, health ${terminalText(hosting.state ?? 'unknown')}`,
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Why the settings of a view cannot be changed now, or null when a change
463
+ * can be sent: the panel writes them to the agent half through the tunnel,
464
+ * so only while the plugin is hosted, its hosting active and not down
465
+ * (`hosting_offline`, as the panel refuses it) — and a change names the
466
+ * revision it was made on, so not before the relay has read them once
467
+ * (`settings_not_read`: `revision` null).
468
+ *
469
+ * @param {{ hosting: object | null, revision: number | null }} view
470
+ * @param {number} now
471
+ * @returns {{ code: 'hosting_offline' | 'settings_not_read', message: string } | null}
472
+ */
473
+ export function settingsReadOnlyReason(view, now) {
474
+ const hosting = view.hosting;
475
+ const offline = (message) => ({ code: 'hosting_offline', message });
476
+ if (!hosting) {
477
+ return offline(
478
+ 'Not hosted on an agent yet — its agent half keeps the settings, so they can be changed once it is hosted',
479
+ );
480
+ }
481
+ if (hosting.phase === 'installing' || hosting.phase === 'removing') {
482
+ return offline(
483
+ `${hostingText(hosting, now).text} — the settings can be changed once it is active`,
484
+ );
485
+ }
486
+ if (hosting.state === 'down') {
487
+ return offline(
488
+ `${hostingText(hosting, now).text} — the settings can be changed when it is back`,
489
+ );
490
+ }
491
+ if (!Number.isSafeInteger(view.revision)) {
492
+ return {
493
+ code: 'settings_not_read',
494
+ message: 'The settings could not be read from the agent half yet — try again in a moment',
495
+ };
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /**
501
+ * Where a view's values come from, as a sentence: the agent half itself
502
+ * (`live`), the relay's copy (`cache`, with when it was synced), or nowhere
503
+ * yet (`none`).
504
+ *
505
+ * @param {{ source: string, syncedAt: string | null }} view
506
+ * @param {number} now
507
+ */
508
+ export function settingsSourceText(view, now) {
509
+ if (view.source === 'live') return 'Read from the agent half';
510
+ if (view.source === 'cache') {
511
+ return view.syncedAt
512
+ ? `From the relay's copy, synced from the agent half ${formatMoment(view.syncedAt, now)}`
513
+ : "From the relay's copy";
514
+ }
515
+ return 'Never synced from the agent half — showing the defaults';
516
+ }
517
+
518
+ /** Whether a view's entry for a secret (`{ set, setAt }`) says it is set. */
519
+ export function secretIsSet(value) {
520
+ return value !== null && typeof value === 'object' && value.set === true;
521
+ }
522
+
523
+ /**
524
+ * A value as the CLI shows it: strings quoted (JSON escapes C0 controls
525
+ * visibly; the rest of what {@link terminalText} drops goes), numbers and
526
+ * booleans as they are.
527
+ */
528
+ function valueText(field, value) {
529
+ if (value === undefined || value === null) return 'not set';
530
+ if (field.type === 'string') return terminalText(JSON.stringify(value));
531
+ if (field.type === 'enum') {
532
+ const option = (field.options ?? []).find((o) => o.value === value);
533
+ return terminalText(
534
+ option && option.label !== value ? `${value} (${option.label})` : String(value),
535
+ );
536
+ }
537
+ return terminalText(value);
538
+ }
539
+
540
+ /**
541
+ * A field's value in a view as the CLI shows it — a secret as "set <when>"
542
+ * or "not set", never more.
543
+ *
544
+ * @param {object} field
545
+ * @param {unknown} value the view's `values[field.key]`
546
+ */
547
+ export function fieldValueText(field, value) {
548
+ if (field.type === 'secret') {
549
+ if (!secretIsSet(value)) return 'not set';
550
+ return typeof value.setAt === 'string' ? `set ${terminalText(value.setAt)}` : 'set';
551
+ }
552
+ return valueText(field, value);
553
+ }
554
+
555
+ /**
556
+ * What a field is, in a few words: its label, whether it is required, its
557
+ * range, options and default.
558
+ *
559
+ * @param {object} field
560
+ */
561
+ export function fieldNote(field) {
562
+ const parts = [field.label];
563
+ if (field.required === true) parts.push('required');
564
+ if (field.min !== undefined && field.max !== undefined) parts.push(`${field.min}..${field.max}`);
565
+ else if (field.min !== undefined) parts.push(`at least ${field.min}`);
566
+ else if (field.max !== undefined) parts.push(`at most ${field.max}`);
567
+ if (field.type === 'enum') {
568
+ const values = (field.options ?? []).map((o) => o.value);
569
+ parts.push(values.length > 6 ? `${values.slice(0, 6).join(' | ')} | …` : values.join(' | '));
570
+ }
571
+ if (field.default !== undefined) parts.push(`default ${valueText(field, field.default)}`);
572
+ return terminalText(parts.join(' · '));
573
+ }
574
+
575
+ /**
576
+ * The lines of a change summary — which settings an update changes, with
577
+ * the old and new value of a non-secret one; a secret only as set,
578
+ * replaced or cleared.
579
+ *
580
+ * @param {object} view
581
+ * @param {{ values?: Record<string, unknown>, secrets?: Record<string, unknown> }} body
582
+ * @returns {string[]}
583
+ */
584
+ export function describeChanges(view, body) {
585
+ const lines = [];
586
+ for (const field of view.fields) {
587
+ const key = field.key;
588
+ if (field.type === 'secret') {
589
+ if (!Object.hasOwn(body.secrets ?? {}, key)) continue;
590
+ const wasSet = secretIsSet(view.values?.[key]);
591
+ lines.push(
592
+ body.secrets[key] === null ? `${key}: cleared` : `${key}: ${wasSet ? 'replaced' : 'set'}`,
593
+ );
594
+ continue;
595
+ }
596
+ if (!Object.hasOwn(body.values ?? {}, key)) continue;
597
+ const before = valueText(field, view.values?.[key]);
598
+ const next = body.values[key];
599
+ lines.push(
600
+ next === null
601
+ ? `${key}: ${before} → ${field.default !== undefined ? `the default, ${valueText(field, field.default)}` : 'not set'}`
602
+ : `${key}: ${before} → ${valueText(field, next)}`,
603
+ );
604
+ }
605
+ return lines.map(terminalText);
606
+ }
607
+
608
+ /**
609
+ * A refused settings read or change in words: the headline and the lines
610
+ * that say what to do — the panel's per-field messages, and for a stale
611
+ * revision, an offline hosting, a running operation or no answer in time,
612
+ * what comes next. Never a value of the change (the panel's messages hold
613
+ * none), and every line as {@link terminalText}.
614
+ *
615
+ * @param {Error & { code?: string, errors?: Record<string, string>, hosting?: object | null, operationId?: string }} err
616
+ * @param {string} name
617
+ * @param {number} now
618
+ * @returns {{ message: string, details: string[] }}
619
+ */
620
+ export function settingsRefusal(err, name, now) {
621
+ const details = Object.entries(err.errors ?? {}).map(([key, message]) =>
622
+ key === '_' ? String(message) : `${key}: ${message}`,
623
+ );
624
+ switch (err.code) {
625
+ case 'revision_mismatch':
626
+ details.push(
627
+ 'The settings changed since they were read; nothing was saved. Run the command again to change them on the current values.',
628
+ );
629
+ break;
630
+ case 'hosting_offline':
631
+ if (err.hosting !== undefined) details.push(`${hostingText(err.hosting, now).text}.`);
632
+ break;
633
+ case 'operation_running':
634
+ details.push(`Follow it with: lamaste-server plugins operations ${name}`);
635
+ break;
636
+ case 'timeout':
637
+ details.push(`See what the agent half stored with: lamaste-server plugins settings ${name}`);
638
+ break;
639
+ case 'unauthenticated':
640
+ details.push(
641
+ "Something other than the agent half may hold the plugin's internal tunnel port, or its agent half was built against an older @lamalibre/lamaste and must be rebuilt.",
642
+ `See what the agent half stored with: lamaste-server plugins settings ${name}`,
643
+ );
644
+ break;
645
+ case 'settings_unsupported':
646
+ details.push(
647
+ `Update the plugin to a version whose agent half keeps settings: lamaste-server plugins update ${name}`,
648
+ );
649
+ break;
650
+ default:
651
+ break;
652
+ }
653
+ return { message: terminalText(err.message), details: details.map(terminalText) };
654
+ }
655
+
656
+ /**
657
+ * The lines of an update's settings schema diff (`pendingUpdate.settings`:
658
+ * `{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key,
659
+ * from, to }], newRequired: [key], secretToPlain: [key] }`) — added and
660
+ * removed fields with their types, retyped ones `from → to`, newly required
661
+ * ones, and a warning line for secrets that become plain values. Empty when
662
+ * nothing changes. Every line as {@link terminalText}.
663
+ *
664
+ * @param {object | null | undefined} diff
665
+ * @returns {Array<{ text: string, warning: boolean }>}
666
+ */
667
+ export function settingsDiffLines(diff) {
668
+ if (!diff || typeof diff !== 'object') return [];
669
+ const list = (value) => (Array.isArray(value) ? value : []);
670
+ const ref = (entry) =>
671
+ entry !== null && typeof entry === 'object' ? `${entry.key} (${entry.type})` : String(entry);
672
+ const lines = [];
673
+ const add = (label, entries, note = null, warning = false) => {
674
+ if (entries.length === 0) return;
675
+ lines.push({
676
+ text: terminalText(`${label}: ${entries.join(', ')}${note ? ` — ${note}` : ''}`),
677
+ warning,
678
+ });
679
+ };
680
+ add('added', list(diff.added).map(ref));
681
+ add('removed', list(diff.removed).map(ref), 'their values are dropped');
682
+ add(
683
+ 'type changed',
684
+ list(diff.retyped).map((entry) =>
685
+ entry !== null && typeof entry === 'object'
686
+ ? `${entry.key} (${entry.from} → ${entry.to})`
687
+ : String(entry),
688
+ ),
689
+ 'their values are dropped, not converted',
690
+ );
691
+ add('newly required', list(diff.newRequired).map(String), 'to be set after the update');
692
+ add(
693
+ 'secrets become plain values',
694
+ list(diff.secretToPlain).map(String),
695
+ 'what is typed into them next is stored on the relay and shown in clear',
696
+ true,
697
+ );
698
+ return lines;
699
+ }