@lamalibre/create-lamaste 4.3.0 → 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 (79) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/src/index.js +4 -0
  4. package/src/lib/panel-pin.js +41 -0
  5. package/src/lib/service-config.js +6 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/certificates.md +3 -3
  10. package/vendor/docs/01-concepts/mtls.md +2 -2
  11. package/vendor/docs/01-concepts/security-model.md +50 -2
  12. package/vendor/docs/01-concepts/tickets.md +2 -2
  13. package/vendor/docs/01-concepts/tunneling.md +4 -4
  14. package/vendor/docs/02-guides/agent-setup.md +6 -6
  15. package/vendor/docs/02-guides/certificate-management.md +2 -2
  16. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  17. package/vendor/docs/02-guides/desktop-app-setup.md +12 -11
  18. package/vendor/docs/02-guides/installation.md +3 -3
  19. package/vendor/docs/02-guides/onboarding.md +2 -2
  20. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  21. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  22. package/vendor/docs/03-architecture/installer.md +4 -4
  23. package/vendor/docs/03-architecture/management-flow.md +1 -1
  24. package/vendor/docs/03-architecture/overview.md +5 -5
  25. package/vendor/docs/03-architecture/panel-client.md +7 -7
  26. package/vendor/docs/03-architecture/panel-server.md +2 -2
  27. package/vendor/docs/03-architecture/state-management.md +2 -2
  28. package/vendor/docs/03-architecture/system-overview.md +1 -1
  29. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  30. package/vendor/docs/04-api-reference/app-host.md +87 -38
  31. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  32. package/vendor/docs/04-api-reference/certificates.md +22 -19
  33. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  34. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  35. package/vendor/docs/04-api-reference/overview.md +137 -135
  36. package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
  37. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  38. package/vendor/docs/05-operations/backup-and-restore.md +4 -1
  39. package/vendor/docs/05-operations/uninstalling.md +1 -1
  40. package/vendor/docs/05-operations/upgrades.md +6 -6
  41. package/vendor/docs/06-reference/config-files.md +94 -84
  42. package/vendor/docs/06-reference/glossary.md +3 -3
  43. package/vendor/docs/06-reference/installer-flags.md +13 -13
  44. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  45. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  46. package/vendor/docs/gatekeeper-design.md +8 -8
  47. package/vendor/docs/identity-directory-design.md +2 -2
  48. package/vendor/docs/package.json +1 -1
  49. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  50. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  51. package/vendor/gatekeeper/package.json +2 -2
  52. package/vendor/server/bin/lamaste-server.js +11 -1
  53. package/vendor/server/package.json +4 -3
  54. package/vendor/server/src/commands/panel-pin.js +40 -0
  55. package/vendor/server/src/commands/plugin-settings.js +541 -0
  56. package/vendor/server/src/commands/plugins.js +94 -27
  57. package/vendor/server/src/panel-api.js +281 -34
  58. package/vendor/server/src/panel-identity.js +165 -0
  59. package/vendor/server/src/plugin-approval.js +78 -0
  60. package/vendor/server/src/plugin-settings.js +699 -0
  61. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  62. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  63. package/vendor/server-ui/dist/index.html +2 -2
  64. package/vendor/serverd/package.json +2 -2
  65. package/vendor/serverd/src/index.js +9 -0
  66. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  67. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  68. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  69. package/vendor/serverd/src/lib/mtls.js +44 -4
  70. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  71. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  72. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  73. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  74. package/vendor/serverd/src/lib/plugins.js +73 -22
  75. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  76. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  77. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  78. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
  79. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
@@ -0,0 +1,541 @@
1
+ /**
2
+ * lamaste-server plugins settings|configure — the settings of an
3
+ * agent-hosted plugin. Its agent half keeps them (the system of record);
4
+ * the panel reads and changes them through the internal tunnel and keeps a
5
+ * copy without any secret value.
6
+ *
7
+ * settings <name> Each field with its effective value (a secret only as
8
+ * set or not set), whether the plugin still needs
9
+ * configuration, where the values come from, the hosting
10
+ * configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]
11
+ * A partial change (`PUT /api/plugins/:name/settings`):
12
+ * --set parses the value by the field's type, --clear puts a
13
+ * field back to its default (clears a secret), --secrets-stdin
14
+ * reads `{ "key": "value" | null }` from stdin
15
+ * configure <name> In a terminal, without flags: asked field by field
16
+ *
17
+ * A secret never travels on a command line: --set refuses one; it is typed
18
+ * into a password prompt or piped in with --secrets-stdin, and reaches the
19
+ * panel only in the request body, which curl reads on stdin. It is never
20
+ * printed, echoed or put into an error.
21
+ *
22
+ * What a manifest, an agent half or the panel says is printed and prompted
23
+ * only as `terminalText` — no control or bidirectional formatting
24
+ * character reaches the terminal; `--json` emits the data as it is.
25
+ */
26
+
27
+ import chalk from 'chalk';
28
+ import * as prompts from '@clack/prompts';
29
+ import { panelRequest } from '../panel-api.js';
30
+ import { emit, emitComplete, emitError } from '../ndjson.js';
31
+ import {
32
+ SECRETS_STDIN_MAX_BYTES,
33
+ answerError,
34
+ buildSettingsUpdate,
35
+ canClear,
36
+ describeChanges,
37
+ fieldNote,
38
+ fieldValueText,
39
+ hostingText,
40
+ parseConfigureArgs,
41
+ parseSecretsJson,
42
+ parseTextAnswer,
43
+ secretIsSet,
44
+ settingsReadOnlyReason,
45
+ settingsRefusal,
46
+ settingsSourceText,
47
+ terminalText,
48
+ updateBody,
49
+ updateChangesSomething,
50
+ updateSummary,
51
+ } from '../plugin-settings.js';
52
+
53
+ /** `/api/plugins/<name>/settings`. */
54
+ function settingsPath(name) {
55
+ return `/api/plugins/${encodeURIComponent(name)}/settings`;
56
+ }
57
+
58
+ /**
59
+ * Exit with a usage or local error, as text or NDJSON.
60
+ *
61
+ * @param {string} message
62
+ * @param {boolean} json
63
+ * @param {Record<string, unknown>} [details] NDJSON fields beyond the message
64
+ * @returns {never}
65
+ */
66
+ function fail(message, json, details = {}) {
67
+ if (json) emitError(message, false, details);
68
+ else console.error(`\n ${chalk.red(terminalText(message))}\n`);
69
+ process.exit(1);
70
+ }
71
+
72
+ /**
73
+ * Exit with a refused settings read or change: the panel's message and
74
+ * what to do about it (each field's message, a stale revision, an offline
75
+ * hosting in the panel's words). NDJSON carries `code`, `errors`,
76
+ * `revision`, `operationId` and `hosting`.
77
+ *
78
+ * @param {Error & { code?: string, errors?: Record<string, string>, revision?: number, operationId?: string, hosting?: object | null }} err
79
+ * @param {string} name
80
+ * @param {boolean} json
81
+ * @returns {never}
82
+ */
83
+ function failRefused(err, name, json) {
84
+ if (json) {
85
+ emitError(err.message, false, {
86
+ ...(err.code ? { code: err.code } : {}),
87
+ ...(err.errors ? { errors: err.errors } : {}),
88
+ ...(err.revision !== undefined ? { revision: err.revision } : {}),
89
+ ...(err.operationId ? { operationId: err.operationId } : {}),
90
+ ...(err.hosting !== undefined ? { hosting: err.hosting } : {}),
91
+ });
92
+ process.exit(1);
93
+ }
94
+ const { message, details } = settingsRefusal(err, name, Date.now());
95
+ console.error(`\n ${chalk.red(message)}`);
96
+ for (const line of details) console.error(` ${line}`);
97
+ console.error('');
98
+ process.exit(1);
99
+ }
100
+
101
+ /**
102
+ * Print a settings view: each field, its type, its value and what it is;
103
+ * whether the plugin still needs configuration; where the values come
104
+ * from; the hosting.
105
+ *
106
+ * @param {string} name
107
+ * @param {object} view
108
+ * @param {number} now
109
+ */
110
+ export function printSettingsView(name, view, now) {
111
+ const d = chalk.dim;
112
+ const revision = Number.isSafeInteger(view.revision) ? `, revision ${view.revision}` : '';
113
+ console.log('');
114
+ console.log(
115
+ chalk.bold(` Settings of ${terminalText(name)}`) +
116
+ d(` (schema ${terminalText(view.schemaVersion)}${revision})`),
117
+ );
118
+ console.log(d(' ' + '─'.repeat(40)));
119
+ const rows = view.fields.map((field) => ({
120
+ field,
121
+ key: terminalText(field.key),
122
+ type: terminalText(field.type),
123
+ value: fieldValueText(field, view.values?.[field.key]),
124
+ }));
125
+ const keyWidth = Math.max(0, ...rows.map((r) => r.key.length));
126
+ const typeWidth = Math.max(0, ...rows.map((r) => r.type.length));
127
+ const valueWidth = Math.min(40, Math.max(0, ...rows.map((r) => r.value.length)));
128
+ for (const { field, key, type, value } of rows) {
129
+ const missing = view.missing.includes(field.key);
130
+ const shown = value.padEnd(valueWidth);
131
+ const coloured = missing ? chalk.yellow(shown) : value === 'not set' ? d(shown) : shown;
132
+ console.log(
133
+ ` ${chalk.cyan(key.padEnd(keyWidth))} ${d(type.padEnd(typeWidth))} ${coloured} ${d(fieldNote(field))}`,
134
+ );
135
+ }
136
+ console.log('');
137
+ console.log(
138
+ view.configured
139
+ ? ` ${chalk.green('Configured')}`
140
+ : ` ${chalk.yellow('Needs configuration')} — missing: ${terminalText(view.missing.join(', '))}`,
141
+ );
142
+ console.log(` ${d(settingsSourceText(view, now))}`);
143
+ const hosting = hostingText(view.hosting, now);
144
+ console.log(` ${hosting.offline ? chalk.red(hosting.text) : hosting.text}`);
145
+ console.log('');
146
+ }
147
+
148
+ /**
149
+ * `settings <name>`: the settings view, read from the agent half when its
150
+ * hosting is up (else the relay's copy).
151
+ *
152
+ * @param {string[]} args what follows `settings`
153
+ * @param {{ json: boolean }} options
154
+ */
155
+ export async function settingsCmd(args, { json }) {
156
+ if (args.length !== 1 || args[0].startsWith('-')) {
157
+ fail('Usage: lamaste-server plugins settings <name>', json);
158
+ }
159
+ const name = args[0];
160
+ let view;
161
+ try {
162
+ view = await panelRequest('GET', settingsPath(name));
163
+ } catch (err) {
164
+ failRefused(err, name, json);
165
+ }
166
+ if (json) {
167
+ emit(view);
168
+ return;
169
+ }
170
+ const now = Date.now();
171
+ printSettingsView(name, view, now);
172
+ if (settingsReadOnlyReason(view, now) === null) {
173
+ console.log(
174
+ chalk.dim(` Change them with: lamaste-server plugins configure ${terminalText(name)}\n`),
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Read stdin in full — at most `maxBytes`, refused beyond that without
181
+ * repeating anything it holds. The chunks are zeroed once decoded.
182
+ *
183
+ * @param {AsyncIterable<Buffer | string>} stream
184
+ * @param {number} maxBytes
185
+ * @returns {Promise<string>}
186
+ */
187
+ export async function readStdinText(stream, maxBytes) {
188
+ const chunks = [];
189
+ let size = 0;
190
+ try {
191
+ for await (const chunk of stream) {
192
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk;
193
+ chunks.push(buffer);
194
+ size += buffer.length;
195
+ if (size > maxBytes) {
196
+ throw new Error(`stdin holds more than ${Math.floor(maxBytes / 1024)} KiB`);
197
+ }
198
+ }
199
+ return Buffer.concat(chunks).toString('utf-8');
200
+ } finally {
201
+ for (const buffer of chunks) buffer.fill(0);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Change plugin `name`'s settings with the flags of `configure`: read the
207
+ * settings (for the schema and the revision to base the change on), build
208
+ * the partial update and send it. Resolves with what happened —
209
+ * `{ state: 'saved', before, body, after }`, `{ state: 'unchanged', before }`
210
+ * (the flags change nothing), `{ state: 'invalid', errors }` (refused here,
211
+ * before anything is sent) or `{ state: 'read-only', code, message, hosting }`
212
+ * (the settings cannot be changed now); rejects with the panel's refusal
213
+ * (from `request`). `request` is `panelRequest` or a stand-in.
214
+ *
215
+ * @param {string} name
216
+ * @param {{ sets: Array<{ key: string, raw: string }>, clears: string[], secrets: Record<string, unknown> }} flags
217
+ * @param {{ request?: typeof panelRequest, now?: () => number }} [deps]
218
+ */
219
+ export async function configureWithFlags(
220
+ name,
221
+ flags,
222
+ { request = panelRequest, now = Date.now } = {},
223
+ ) {
224
+ const before = await request('GET', settingsPath(name));
225
+ const readOnly = settingsReadOnlyReason(before, now());
226
+ if (readOnly) return { state: 'read-only', ...readOnly, hosting: before.hosting };
227
+ const built = buildSettingsUpdate(before, flags);
228
+ if (!built.ok) return { state: 'invalid', errors: built.errors };
229
+ if (!updateChangesSomething(built.body)) return { state: 'unchanged', before };
230
+ const after = await request('PUT', settingsPath(name), built.body);
231
+ return { state: 'saved', before, body: built.body, after };
232
+ }
233
+
234
+ /** "set retries, schedule; cleared region; secret apiKey set" — key names only. */
235
+ function summaryText(body) {
236
+ const { changed, cleared, secretsSet, secretsCleared } = updateSummary(body);
237
+ return [
238
+ changed.length > 0 ? `set ${changed.join(', ')}` : null,
239
+ cleared.length > 0 ? `back to the default: ${cleared.join(', ')}` : null,
240
+ secretsSet.length > 0 ? `secret set: ${secretsSet.join(', ')}` : null,
241
+ secretsCleared.length > 0 ? `secret cleared: ${secretsCleared.join(', ')}` : null,
242
+ ]
243
+ .filter(Boolean)
244
+ .join('; ');
245
+ }
246
+
247
+ /**
248
+ * `configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]`,
249
+ * or — in a terminal, without flags — field by field.
250
+ *
251
+ * @param {string[]} args what follows `configure`
252
+ * @param {{ json: boolean }} options
253
+ */
254
+ export async function configureCmd(args, { json }) {
255
+ const parsed = parseConfigureArgs(args);
256
+ if (!parsed.ok) fail(parsed.error, json);
257
+ const { name, sets, clears, secretsStdin } = parsed;
258
+
259
+ if (sets.length === 0 && clears.length === 0 && !secretsStdin) {
260
+ if (json) {
261
+ fail(
262
+ 'Nothing to change: give --set key=value, --clear key or --secrets-stdin (the interactive mode does not run with --json)',
263
+ json,
264
+ );
265
+ }
266
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
267
+ fail(
268
+ 'Nothing to change: give --set key=value, --clear key or --secrets-stdin, or run it in a terminal to be asked for each setting',
269
+ json,
270
+ );
271
+ }
272
+ return configureInteractively(name);
273
+ }
274
+
275
+ let secrets = {};
276
+ if (secretsStdin) {
277
+ if (process.stdin.isTTY) {
278
+ fail(
279
+ `--secrets-stdin reads a JSON object piped in or redirected from a file ` +
280
+ `(lamaste-server plugins configure ${name} --secrets-stdin < secrets.json), not from the terminal — ` +
281
+ `to type a secret, run configure without flags`,
282
+ json,
283
+ );
284
+ }
285
+ let read;
286
+ try {
287
+ read = parseSecretsJson(await readStdinText(process.stdin, SECRETS_STDIN_MAX_BYTES));
288
+ } catch (err) {
289
+ fail(err.message, json);
290
+ }
291
+ if (!read.ok) fail(read.error, json);
292
+ secrets = read.secrets;
293
+ }
294
+
295
+ let result;
296
+ try {
297
+ result = await configureWithFlags(name, { sets, clears, secrets });
298
+ } catch (err) {
299
+ failRefused(err, name, json);
300
+ } finally {
301
+ // Nothing keeps the secrets once the request is sent.
302
+ secrets = null;
303
+ }
304
+
305
+ switch (result.state) {
306
+ case 'read-only':
307
+ fail(result.message, json, { code: result.code, hosting: result.hosting });
308
+ break;
309
+ case 'invalid':
310
+ failRefused(
311
+ Object.assign(new Error('Invalid settings — nothing was sent'), { errors: result.errors }),
312
+ name,
313
+ json,
314
+ );
315
+ break;
316
+ case 'unchanged':
317
+ if (json) emitComplete({ name, ...updateSummary({}), settings: result.before });
318
+ else
319
+ console.log(
320
+ `\n Nothing to change — the settings of ${chalk.cyan(terminalText(name))} stay as they are.\n`,
321
+ );
322
+ break;
323
+ default:
324
+ if (json) {
325
+ emitComplete({ name, ...updateSummary(result.body), settings: result.after });
326
+ break;
327
+ }
328
+ console.log(
329
+ `\n ${chalk.green('Saved')} the settings of ${chalk.cyan(terminalText(name))} (revision ${terminalText(result.after.revision)}): ${terminalText(summaryText(result.body))}`,
330
+ );
331
+ printSettingsView(name, result.after, Date.now());
332
+ }
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Interactive mode
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /** The answer that leaves a field as it is. */
340
+ const UNCHANGED = Object.freeze({ change: false });
341
+
342
+ /** The select option that puts an enum field back to its default (or unsets it). */
343
+ const CLEAR_CHOICE = Symbol('clear');
344
+
345
+ /** A prompt's answer, or — when the administrator cancelled (Ctrl-C, Escape) — the end. */
346
+ function answerOf(result) {
347
+ if (prompts.isCancel(result)) {
348
+ prompts.cancel('Nothing was saved.');
349
+ process.exit(1);
350
+ }
351
+ return result;
352
+ }
353
+
354
+ /** A field's prompt: its label and key, and its description below. */
355
+ function promptMessage(field) {
356
+ const title = `${terminalText(field.label)} ${chalk.dim(`(${terminalText(field.key)})`)}`;
357
+ return field.description ? `${title}\n${chalk.dim(terminalText(field.description))}` : title;
358
+ }
359
+
360
+ /** A prompt's `validate` answer: the message, as the terminal may show it, or none. */
361
+ function shownError(message) {
362
+ return message ? terminalText(message) : undefined;
363
+ }
364
+
365
+ /**
366
+ * Ask for one field: a secret is kept, replaced (typed into a password
367
+ * prompt, never shown) or cleared; a boolean is a yes/no; an enum a choice
368
+ * among its options; anything else a text prefilled with the current value
369
+ * (empty puts it back to its default). Resolves with whether it changes and
370
+ * to what (`null` clears it). `ui` asks (`@clack/prompts`, or a stand-in).
371
+ *
372
+ * @param {object} view
373
+ * @param {object} field
374
+ * @param {Pick<typeof prompts, 'select' | 'password' | 'confirm' | 'text'>} [ui]
375
+ * @returns {Promise<{ change: false } | { change: true, value: string | number | boolean | null }>}
376
+ */
377
+ export async function askField(view, field, ui = prompts) {
378
+ const message = promptMessage(field);
379
+ const current = view.values?.[field.key];
380
+
381
+ switch (field.type) {
382
+ case 'secret': {
383
+ const set = secretIsSet(current);
384
+ const options = set
385
+ ? [
386
+ { value: 'keep', label: 'Keep it', hint: fieldValueText(field, current) },
387
+ { value: 'replace', label: 'Replace it' },
388
+ ...(canClear(field) ? [{ value: 'clear', label: 'Clear it' }] : []),
389
+ ]
390
+ : [
391
+ { value: 'replace', label: 'Set it' },
392
+ {
393
+ value: 'keep',
394
+ label: 'Leave it unset',
395
+ ...(field.required === true
396
+ ? { hint: 'required — the plugin needs configuration until it is set' }
397
+ : {}),
398
+ },
399
+ ];
400
+ const action = answerOf(
401
+ await ui.select({ message, options, initialValue: set ? 'keep' : 'replace' }),
402
+ );
403
+ if (action === 'keep') return UNCHANGED;
404
+ if (action === 'clear') return { change: true, value: null };
405
+ const secret = answerOf(
406
+ await ui.password({
407
+ message: `${terminalText(field.label)} — the new value`,
408
+ clearOnError: true,
409
+ validate: (value) => shownError(answerError(view, field, value ?? '')),
410
+ }),
411
+ );
412
+ return { change: true, value: secret };
413
+ }
414
+
415
+ case 'boolean': {
416
+ const answer = answerOf(
417
+ await ui.confirm({
418
+ message,
419
+ initialValue: typeof current === 'boolean' ? current : false,
420
+ }),
421
+ );
422
+ return answer === current ? UNCHANGED : { change: true, value: answer };
423
+ }
424
+
425
+ case 'enum': {
426
+ // An option's value is what goes back to the panel; only what is shown is made safe.
427
+ const options = (field.options ?? []).map((o) => ({
428
+ value: o.value,
429
+ label: terminalText(o.label),
430
+ ...(o.label !== o.value ? { hint: terminalText(o.value) } : {}),
431
+ }));
432
+ if (canClear(field) && current !== undefined && current !== field.default) {
433
+ options.push({
434
+ value: CLEAR_CHOICE,
435
+ label:
436
+ field.default !== undefined
437
+ ? `Back to the default (${terminalText(field.default)})`
438
+ : 'Unset it',
439
+ });
440
+ }
441
+ const answer = answerOf(
442
+ await ui.select({
443
+ message,
444
+ options,
445
+ ...(current !== undefined ? { initialValue: current } : {}),
446
+ }),
447
+ );
448
+ if (answer === CLEAR_CHOICE) return { change: true, value: null };
449
+ return answer === current ? UNCHANGED : { change: true, value: answer };
450
+ }
451
+
452
+ default: {
453
+ // string, integer, number
454
+ const placeholder = canClear(field)
455
+ ? field.default !== undefined
456
+ ? `empty: back to the default, ${terminalText(field.default)}`
457
+ : 'empty: not set'
458
+ : undefined;
459
+ // The current value as the terminal may show it. When that dropped a
460
+ // character, the value cannot be typed back: answered as shown, it stays.
461
+ const shown = current === undefined ? '' : terminalText(current);
462
+ const keptAsShown = (answer) =>
463
+ current !== undefined && shown !== String(current) && answer === shown;
464
+ const text = answerOf(
465
+ await ui.text({
466
+ message,
467
+ initialValue: shown,
468
+ ...(placeholder ? { placeholder } : {}),
469
+ validate: (value) => {
470
+ if (keptAsShown(value ?? '')) return undefined;
471
+ const parsed = parseTextAnswer(view, field, value ?? '');
472
+ return parsed.ok ? undefined : shownError(parsed.message);
473
+ },
474
+ }),
475
+ );
476
+ if (keptAsShown(text ?? '')) return UNCHANGED;
477
+ const parsed = parseTextAnswer(view, field, text ?? '');
478
+ // The prompt accepted only an answer that parses.
479
+ if (!parsed.ok) return UNCHANGED;
480
+ if (parsed.value === null) {
481
+ return current === undefined || current === field.default
482
+ ? UNCHANGED
483
+ : { change: true, value: null };
484
+ }
485
+ return parsed.value === current ? UNCHANGED : { change: true, value: parsed.value };
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * `configure <name>` in a terminal without flags: each field in turn, then
492
+ * which settings change (a secret only as set, replaced or cleared) and a
493
+ * confirmation before anything is sent.
494
+ *
495
+ * @param {string} name
496
+ */
497
+ async function configureInteractively(name) {
498
+ let view;
499
+ try {
500
+ view = await panelRequest('GET', settingsPath(name));
501
+ } catch (err) {
502
+ failRefused(err, name, false);
503
+ }
504
+ const readOnly = settingsReadOnlyReason(view, Date.now());
505
+ if (readOnly) fail(readOnly.message, false);
506
+
507
+ prompts.intro(`Settings of ${terminalText(name)}`);
508
+ let values = {};
509
+ let secrets = {};
510
+ for (const field of view.fields) {
511
+ const answer = await askField(view, field);
512
+ if (answer.change) (field.type === 'secret' ? secrets : values)[field.key] = answer.value;
513
+ }
514
+ let body = updateBody(view.revision, values, secrets);
515
+ values = null;
516
+ secrets = null;
517
+ if (!updateChangesSomething(body)) {
518
+ prompts.outro('Nothing changed.');
519
+ return;
520
+ }
521
+
522
+ prompts.note(describeChanges(view, body).join('\n'), 'Changes');
523
+ const save = answerOf(await prompts.confirm({ message: 'Save these changes?' }));
524
+ if (!save) {
525
+ body = null;
526
+ prompts.outro('Nothing was saved.');
527
+ return;
528
+ }
529
+
530
+ let after;
531
+ try {
532
+ after = await panelRequest('PUT', settingsPath(name), body);
533
+ } catch (err) {
534
+ prompts.cancel('Not saved.');
535
+ failRefused(err, name, false);
536
+ } finally {
537
+ body = null;
538
+ }
539
+ prompts.outro(`Saved (revision ${terminalText(after.revision)})`);
540
+ printSettingsView(name, after, Date.now());
541
+ }