@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.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +13 -8
- package/src/index.js +4 -0
- package/src/lib/panel-pin.js +41 -0
- 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 +50 -2
- 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 +4 -4
- package/vendor/docs/02-guides/desktop-app-setup.md +12 -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/registry-and-oidc.md +1 -1
- package/vendor/docs/04-api-reference/tunnels.md +14 -10
- package/vendor/docs/05-operations/backup-and-restore.md +4 -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 +94 -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/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +11 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/panel-pin.js +40 -0
- 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 +281 -34
- package/vendor/server/src/panel-identity.js +165 -0
- 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-Bc5NCMSv.css +0 -1
- 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
|
+
}
|