@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.
- package/package.json +2 -2
- package/src/lib/service-config.js +6 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +38 -1
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +6 -6
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +11 -11
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +7 -7
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
- package/vendor/docs/04-api-reference/app-host.md +87 -38
- package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
- package/vendor/docs/04-api-reference/certificates.md +22 -19
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -135
- package/vendor/docs/04-api-reference/tunnels.md +14 -10
- package/vendor/docs/05-operations/backup-and-restore.md +2 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +89 -84
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +2 -2
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +4 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/plugin-settings.js +541 -0
- package/vendor/server/src/commands/plugins.js +94 -27
- package/vendor/server/src/panel-api.js +36 -4
- package/vendor/server/src/plugin-approval.js +78 -0
- package/vendor/server/src/plugin-settings.js +699 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +9 -0
- package/vendor/serverd/src/lib/agent-presence.js +106 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
- package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
- package/vendor/serverd/src/lib/mtls.js +44 -4
- package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
- package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
- package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
- package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
- package/vendor/serverd/src/lib/plugins.js +73 -22
- package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
- package/vendor/serverd/src/routes/management/plugins.js +166 -19
- package/vendor/serverd/src/routes/management/tunnels.js +21 -1
- package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +0 -84
- 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
|
+
}
|