@lamalibre/create-lamaste 4.2.0 → 4.3.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 (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -6,10 +6,45 @@
6
6
  * install <package> Install a plugin by npm package name
7
7
  * enable <name> Enable a plugin
8
8
  * disable <name> Disable a plugin
9
- * uninstall <name> Remove a plugin (must be disabled first)
9
+ * uninstall <name> [--force] [--purge]
10
+ * Remove a plugin (must be disabled first); a hosted one
11
+ * through its uninstall pipeline (an operation); --purge also
12
+ * deletes the data of the agent half Lamaste installed
10
13
  * oidc-clients <name> The OIDC clients a plugin declares on Authelia
11
14
  * rotate-secret <name> <clientId>
12
15
  * New secret for a confidential OIDC client (shown once)
16
+ * hosting <name> Which agent hosts an agent-hosted plugin's agent half
17
+ * eligibility <name> Which enrolled agents can host the plugin, and why the others cannot
18
+ * host <name> --agent <label>
19
+ * Host the plugin on an agent: the install pipeline (its hooks,
20
+ * the agent half Lamaste installs, internal tunnel, secret,
21
+ * capability), followed to its end
22
+ * unhost <name> [--force] [--purge]
23
+ * Remove the hosting: the uninstall pipeline; --purge also deletes
24
+ * the agent half's data directory and service account
25
+ * rotate-hosting-secret <name>
26
+ * New installation secret; the agent picks it up on its next sync
27
+ * hooks <name> The plugin's lifecycle hooks and what needs approval: the relay
28
+ * hook set (stages, routes, fail-open hooks, effects), the agent
29
+ * half Lamaste installs, the approval digest; agent hooks
30
+ * approve-hooks <name> --digest <sha256>
31
+ * Approve with the approval digest `hooks` shows (or a pending update's)
32
+ * auto-approve <name> on|off
33
+ * Approve what this plugin's later versions bring automatically (audited)
34
+ * update <name> [--version <v>]
35
+ * Update an agent-hosted plugin in place (default: latest)
36
+ * discard-update <name>
37
+ * Drop an update that waits for approval
38
+ * operations <name> Recent hosting / update / uninstall operations and their steps
39
+ * retained-data [<name>]
40
+ * What agents keep of agent halves removed without purge — of every
41
+ * plugin (uninstalled ones too), or of one
42
+ * purge-data <name> --agent <label>
43
+ * Delete what an agent keeps of the plugin's agent half (its data
44
+ * directory and account) through its app host helper — an operation,
45
+ * followed to its end
46
+ * forget-data <name> --agent <label>
47
+ * Drop only the relay's record of it; the data stays on the agent
13
48
  *
14
49
  * Every subcommand goes through the running panel (`/api/plugins/*`, admin
15
50
  * certificate): the panel owns the plugin registry (SQLite `state.db`), runs
@@ -22,6 +57,7 @@
22
57
 
23
58
  import chalk from 'chalk';
24
59
  import { execa } from 'execa';
60
+ import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
25
61
  import { PANEL_SERVICE } from '../config.js';
26
62
  import { panelRequest } from '../panel-api.js';
27
63
  import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
@@ -46,11 +82,39 @@ export async function runPlugins(args, { json }) {
46
82
  case 'disable':
47
83
  return disablePluginCmd(args[1], { json });
48
84
  case 'uninstall':
49
- return uninstallPluginCmd(args[1], { json });
85
+ return uninstallPluginCmd(args[1], args.slice(2), { json });
50
86
  case 'oidc-clients':
51
87
  return oidcClientsCmd(args[1], { json });
52
88
  case 'rotate-secret':
53
89
  return rotateSecretCmd(args[1], args[2], { json });
90
+ case 'hosting':
91
+ return hostingCmd(args[1], { json });
92
+ case 'eligibility':
93
+ return eligibilityCmd(args[1], { json });
94
+ case 'host':
95
+ return hostCmd(args[1], args.slice(2), { json });
96
+ case 'unhost':
97
+ return unhostCmd(args[1], args.slice(2), { json });
98
+ case 'rotate-hosting-secret':
99
+ return rotateHostingSecretCmd(args[1], { json });
100
+ case 'hooks':
101
+ return hooksCmd(args[1], { json });
102
+ case 'approve-hooks':
103
+ return approveHooksCmd(args[1], args.slice(2), { json });
104
+ case 'auto-approve':
105
+ return autoApproveCmd(args[1], args[2], { json });
106
+ case 'update':
107
+ return updateCmd(args[1], args.slice(2), { json });
108
+ case 'discard-update':
109
+ return discardUpdateCmd(args[1], { json });
110
+ case 'operations':
111
+ return operationsCmd(args[1], { json });
112
+ case 'retained-data':
113
+ return retainedDataCmd(args.slice(1), { json });
114
+ case 'purge-data':
115
+ return purgeDataCmd(args.slice(1), { json });
116
+ case 'forget-data':
117
+ return forgetDataCmd(args.slice(1), { json });
54
118
  default:
55
119
  printPluginUsage();
56
120
  process.exit(sub ? 1 : 0);
@@ -68,10 +132,38 @@ ${b('Subcommands:')}
68
132
  ${c('install <package>')} Install a plugin (e.g. @lamalibre/herd-server)
69
133
  ${c('enable <name>')} Enable a plugin
70
134
  ${c('disable <name>')} Disable a plugin
71
- ${c('uninstall <name>')} Remove a disabled plugin
135
+ ${c('uninstall <name> [--force] [--purge]')}
136
+ Remove a disabled plugin (a hosted one through its uninstall pipeline;
137
+ --purge deletes the data of the agent half Lamaste installed)
72
138
  ${c('oidc-clients <name>')} OIDC clients the plugin declares on Authelia (no secrets)
73
139
  ${c('rotate-secret <name> <clientId>')}
74
140
  Replace a confidential client's secret — printed once
141
+ ${c('hosting <name>')} Which agent hosts the plugin's agent half, port, health
142
+ ${c('eligibility <name>')} Which agents can host the plugin, and why the others cannot
143
+ ${c('host <name> --agent <label>')}
144
+ Host an agent-hosted plugin on an agent (install pipeline)
145
+ ${c('unhost <name> [--force] [--purge]')}
146
+ Remove the hosting (uninstall pipeline; --force passes failing hooks,
147
+ --purge deletes the agent half's data and service account)
148
+ ${c('rotate-hosting-secret <name>')}
149
+ New installation secret (the agent fetches it on its next sync)
150
+ ${c('hooks <name>')} Lifecycle hooks and what needs approval: relay hooks, agent half
151
+ ${c('approve-hooks <name> --digest <sha256>')}
152
+ Approve with the approval digest "hooks" showed (or a pending update's)
153
+ ${c('auto-approve <name> on|off')}
154
+ Approve what this plugin's later versions bring automatically
155
+ ${c('update <name> [--version <v>]')}
156
+ Update an agent-hosted plugin in place (default: latest)
157
+ ${c('discard-update <name>')}
158
+ Drop an update that waits for approval
159
+ ${c('operations <name>')} Recent operations of the plugin and their steps
160
+ ${c('retained-data [<name>]')}
161
+ What agents keep of agent halves removed without purge (every
162
+ plugin's, uninstalled ones too, or one plugin's)
163
+ ${c('purge-data <name> --agent <label>')}
164
+ Delete it on the agent — its data directory and account (a push)
165
+ ${c('forget-data <name> --agent <label>')}
166
+ Drop only the relay's record; the data stays on the agent
75
167
  `);
76
168
  }
77
169
 
@@ -107,11 +199,754 @@ async function listPlugins({ json }) {
107
199
  for (const p of plugins) {
108
200
  const status = p.status === 'enabled' ? g('enabled') : r('disabled');
109
201
  const hosting = p.hosting === 'agent' ? chalk.yellow('agent-hosted') : d('server-hosted');
110
- console.log(` ${c(p.name)} ${d(p.version)} ${status} ${hosting} ${d(p.packageName)}`);
202
+ const hostedOn = p.agentHosting
203
+ ? ` ${d('on')} ${c(p.agentHosting.agentLabel)}:${p.agentHosting.port} ${healthWord(p.agentHosting.health)}` +
204
+ (p.agentHosting.agentHalf ? d(` agent half ${p.agentHosting.agentHalf.version}`) : '')
205
+ : '';
206
+ console.log(
207
+ ` ${c(p.name)} ${d(p.version)} ${status} ${hosting}${hostedOn} ${d(p.packageName)}`,
208
+ );
209
+ }
210
+ console.log('');
211
+ }
212
+
213
+ /** One coloured word for a hosting's health. */
214
+ function healthWord(health) {
215
+ if (!health || health.state === 'unknown') return chalk.dim('(health unknown)');
216
+ if (health.state === 'up') return chalk.green('up');
217
+ return chalk.red(`down (${health.reason ?? 'unknown'})`);
218
+ }
219
+
220
+ /**
221
+ * Print a hosting record.
222
+ * @param {Record<string, unknown>} hosting
223
+ */
224
+ function printHosting(hosting) {
225
+ const d = chalk.dim;
226
+ console.log('');
227
+ console.log(chalk.bold(` Hosting of ${hosting.plugin}`));
228
+ console.log(d(' ' + '\u2500'.repeat(40)));
229
+ console.log(` agent: ${chalk.cyan(hosting.agentLabel)}`);
230
+ console.log(
231
+ ` port: ${hosting.port} ${d('(relay loopback, internal tunnel ' + hosting.tunnelId + ')')}`,
232
+ );
233
+ console.log(
234
+ ` secret: issued ${hosting.secretIssuedAt}${hosting.secretAcknowledged ? '' : d(' — not yet fetched by the agent')}${hosting.rotationPending ? chalk.yellow(' (rotation pending: previous secret still accepted)') : ''}`,
235
+ );
236
+ const h = hosting.health ?? {};
237
+ console.log(
238
+ ` health: ${healthWord(h)}${h.since ? d(` since ${h.since}`) : ''}${h.lastError && h.state !== 'up' ? d(` — ${h.lastError}`) : ''}`,
239
+ );
240
+ if (hosting.agentHalf) {
241
+ console.log(
242
+ ` agent half: ${hosting.agentHalf.version} ${d(`installed by Lamaste ${hosting.agentHalf.installedAt}`)}`,
243
+ );
244
+ }
245
+ console.log('');
246
+ }
247
+
248
+ /**
249
+ * @param {string | undefined} name
250
+ * @param {{ json: boolean }} options
251
+ */
252
+ async function hostingCmd(name, { json }) {
253
+ if (!name) fail('Usage: lamaste-server plugins hosting <name>', json);
254
+ let result;
255
+ try {
256
+ result = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hosting`);
257
+ } catch (err) {
258
+ fail(err.message, json);
259
+ }
260
+ if (json) emit(result);
261
+ else printHosting(result.hosting);
262
+ }
263
+
264
+ /**
265
+ * Which enrolled machine agents can host the plugin, and why the others
266
+ * cannot: its `agent.requires` and, when Lamaste installs the agent half,
267
+ * what installing it needs, against what each agent reported on its sync.
268
+ *
269
+ * @param {string | undefined} name
270
+ * @param {{ json: boolean }} options
271
+ */
272
+ async function eligibilityCmd(name, { json }) {
273
+ if (!name) fail('Usage: lamaste-server plugins eligibility <name>', json);
274
+ let result;
275
+ try {
276
+ result = await panelRequest(
277
+ 'GET',
278
+ `/api/plugins/${encodeURIComponent(name)}/hosting/eligibility`,
279
+ );
280
+ } catch (err) {
281
+ fail(err.message, json);
282
+ }
283
+ if (json) {
284
+ emit(result);
285
+ return;
286
+ }
287
+ const { agents } = result;
288
+ if (agents.length === 0) {
289
+ console.log(`\n No enrolled machine agent — enroll one first.\n`);
290
+ return;
291
+ }
292
+ const d = chalk.dim;
293
+ const width = Math.max('AGENT'.length, ...agents.map((a) => a.label.length));
294
+ const column = 'ELIGIBLE'.length;
295
+ console.log('');
296
+ console.log(chalk.bold(` Agents that can host ${name}`));
297
+ console.log(d(' ' + '\u2500'.repeat(40)));
298
+ console.log(d(` ${'AGENT'.padEnd(width)} ${'ELIGIBLE'.padEnd(column)} REASONS`));
299
+ for (const agent of agents) {
300
+ const word = agent.eligible
301
+ ? chalk.green('yes'.padEnd(column))
302
+ : chalk.red('no'.padEnd(column));
303
+ const [first, ...more] = agent.reasons;
304
+ console.log(` ${chalk.cyan(agent.label.padEnd(width))} ${word} ${first ?? ''}`);
305
+ for (const reason of more) console.log(` ${' '.repeat(width + column + 2)} ${reason}`);
306
+ }
307
+ console.log('');
308
+ }
309
+
310
+ /**
311
+ * @param {string | undefined} name
312
+ * @param {string[]} rest
313
+ * @param {{ json: boolean }} options
314
+ */
315
+ async function hostCmd(name, rest, { json }) {
316
+ const agentIdx = rest.indexOf('--agent');
317
+ const agentLabel = agentIdx !== -1 ? rest[agentIdx + 1] : undefined;
318
+ if (!name || !agentLabel) fail('Usage: lamaste-server plugins host <name> --agent <label>', json);
319
+ if (!json) console.log(`\n Hosting ${chalk.cyan(name)} on ${chalk.cyan(agentLabel)}`);
320
+ let result;
321
+ try {
322
+ result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/hosting`, {
323
+ agentLabel,
324
+ });
325
+ } catch (err) {
326
+ failRefused(err, json, { step: 'host', name, agentLabel, action: 'install' });
327
+ }
328
+ const operation = await followOperation(name, result.operation, { json });
329
+ if (operation.status !== 'succeeded') process.exit(1);
330
+ const { hosting } = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hosting`);
331
+ if (json) {
332
+ emitComplete({ operation, hosting });
333
+ return;
334
+ }
335
+ printHosting(hosting);
336
+ }
337
+
338
+ /**
339
+ * @param {string | undefined} name
340
+ * @param {string[]} rest
341
+ * @param {{ json: boolean }} options
342
+ */
343
+ async function unhostCmd(name, rest, { json }) {
344
+ if (!name) fail('Usage: lamaste-server plugins unhost <name> [--force] [--purge]', json);
345
+ const force = rest.includes('--force');
346
+ const purge = rest.includes('--purge');
347
+ let result;
348
+ try {
349
+ result = await panelRequest(
350
+ 'DELETE',
351
+ `/api/plugins/${encodeURIComponent(name)}/hosting${removalQuery({ force, purge })}`,
352
+ );
353
+ } catch (err) {
354
+ failRefused(err, json, { name, action: 'uninstall' });
355
+ }
356
+ if (!json) {
357
+ const how = [force ? 'forced' : null, purge ? `deleting ${await agentHalfDataDir(name)}` : null]
358
+ .filter(Boolean)
359
+ .join(', ');
360
+ console.log(`\n Removing the hosting of ${chalk.cyan(name)}${how ? ` (${how})` : ''}`);
361
+ }
362
+ await finishOperation(name, result.operation, { json });
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Operations
367
+ // ---------------------------------------------------------------------------
368
+
369
+ const STEP_MARK = {
370
+ done: chalk.green('✓'),
371
+ failed: chalk.red('✗'),
372
+ skipped: chalk.dim('–'),
373
+ running: chalk.cyan('…'),
374
+ };
375
+
376
+ /** One finished step of an operation, with why it failed or what it did. */
377
+ function printStep(step) {
378
+ const why = step.error ?? step.detail;
379
+ console.log(
380
+ ` ${STEP_MARK[step.status] ?? ' '} ${step.name}${why ? chalk.dim(` — ${why}`) : ''}`,
381
+ );
382
+ }
383
+
384
+ /** An operation's warnings. */
385
+ function printWarnings(warnings) {
386
+ for (const warning of warnings ?? []) console.log(` ${chalk.yellow('!')} ${warning}`);
387
+ }
388
+
389
+ /**
390
+ * Follow an operation until it ends, printing each step as it finishes
391
+ * (NDJSON steps with --json). Hosting waits on the agent's sync, so this
392
+ * can take a few minutes.
393
+ *
394
+ * @param {string} name
395
+ * @param {{ id: string }} operation
396
+ * @param {{ json: boolean }} options
397
+ */
398
+ async function followOperation(name, operation, { json }) {
399
+ const printed = new Set();
400
+ let current = operation;
401
+ for (;;) {
402
+ for (const [i, step] of (current.steps ?? []).entries()) {
403
+ if (step.status === 'running' || printed.has(i)) continue;
404
+ printed.add(i);
405
+ if (json) {
406
+ emitStep(
407
+ step.name,
408
+ step.status === 'done' ? 'complete' : step.status,
409
+ step.error ?? step.detail,
410
+ );
411
+ } else {
412
+ printStep(step);
413
+ }
414
+ }
415
+ if (current.status !== 'running') break;
416
+ await new Promise((r) => setTimeout(r, 2000));
417
+ ({ operation: current } = await panelRequest(
418
+ 'GET',
419
+ `/api/plugins/${encodeURIComponent(name)}/operations/${encodeURIComponent(operation.id)}`,
420
+ ));
421
+ }
422
+ if (!json) {
423
+ printWarnings(current.warnings);
424
+ const word =
425
+ current.status === 'succeeded'
426
+ ? chalk.green('succeeded')
427
+ : current.status === 'rolled-back'
428
+ ? chalk.yellow('rolled back')
429
+ : chalk.red(current.status);
430
+ console.log(`\n ${current.kind} ${word}${current.error ? `: ${current.error}` : ''}\n`);
431
+ // An update that added a confidential OIDC client: its secret, shown once.
432
+ printCreatedSecrets(current.oidcClients);
433
+ }
434
+ return current;
435
+ }
436
+
437
+ /**
438
+ * Follow an operation the panel started to its end and exit non-zero
439
+ * unless it succeeded (with --json, its outcome as the complete event).
440
+ *
441
+ * @param {string} name
442
+ * @param {{ id: string }} operation
443
+ * @param {{ json: boolean }} options
444
+ */
445
+ async function finishOperation(name, operation, { json }) {
446
+ const finished = await followOperation(name, operation, { json });
447
+ if (json) emitComplete({ name, operation: finished });
448
+ if (finished.status !== 'succeeded') process.exit(1);
449
+ }
450
+
451
+ /**
452
+ * @param {string | undefined} name
453
+ * @param {{ json: boolean }} options
454
+ */
455
+ async function operationsCmd(name, { json }) {
456
+ if (!name) fail('Usage: lamaste-server plugins operations <name>', json);
457
+ const { operations } = await panelRequest(
458
+ 'GET',
459
+ `/api/plugins/${encodeURIComponent(name)}/operations`,
460
+ );
461
+ if (json) {
462
+ emit({ operations });
463
+ return;
464
+ }
465
+ if (operations.length === 0) {
466
+ console.log(`\n No operations for ${chalk.cyan(name)}.\n`);
467
+ return;
468
+ }
469
+ console.log('');
470
+ for (const op of operations) {
471
+ console.log(
472
+ ` ${chalk.bold(op.kind)} ${chalk.dim(op.startedAt)} by ${op.actor} — ${op.status}${op.error ? chalk.dim(`: ${op.error}`) : ''}`,
473
+ );
474
+ for (const step of op.steps) printStep(step);
475
+ printWarnings(op.warnings);
476
+ }
477
+ console.log('');
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Retained data
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * The arguments of `purge-data|forget-data <name> --agent <label>`. Pure,
486
+ * exported for tests.
487
+ *
488
+ * @param {'purge-data' | 'forget-data'} sub
489
+ * @param {string[]} args what follows the subcommand
490
+ * @returns {{ ok: true, name: string, agentLabel: string } | { ok: false, error: string }}
491
+ */
492
+ export function parseRetainedDataArgs(sub, args) {
493
+ const usage = `Usage: lamaste-server plugins ${sub} <name> --agent <label>`;
494
+ let name = null;
495
+ let agentLabel = null;
496
+ for (let i = 0; i < args.length; i++) {
497
+ const arg = args[i];
498
+ if (arg === '--agent') {
499
+ const value = args[i + 1];
500
+ if (value === undefined || value.startsWith('--')) {
501
+ return { ok: false, error: `--agent needs a value — ${usage}` };
502
+ }
503
+ agentLabel = value;
504
+ i++;
505
+ } else if (arg.startsWith('--')) {
506
+ return { ok: false, error: `Unknown option ${arg} — ${usage}` };
507
+ } else if (name === null) {
508
+ name = arg;
509
+ } else {
510
+ return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
511
+ }
512
+ }
513
+ if (name === null || agentLabel === null) return { ok: false, error: usage };
514
+ if (!AGENT_LABEL_REGEX.test(agentLabel)) {
515
+ return { ok: false, error: `"${agentLabel}" is not an agent label` };
516
+ }
517
+ return { ok: true, name, agentLabel };
518
+ }
519
+
520
+ /**
521
+ * Each record of what agents keep, marked with whether its plugin is still
522
+ * installed — a record outlives its plugin's uninstall. Pure, exported for
523
+ * tests.
524
+ *
525
+ * @param {Array<Record<string, unknown>>} records
526
+ * @param {ReadonlySet<string>} installed the names of the installed plugins
527
+ */
528
+ export function retainedDataRows(records, installed) {
529
+ return records.map((record) => ({ ...record, installed: installed.has(record.plugin) }));
530
+ }
531
+
532
+ /**
533
+ * `/api/plugins/<name>/retained-data`, with `query` as its query string.
534
+ *
535
+ * @param {string} name
536
+ * @param {Record<string, string>} [query]
537
+ */
538
+ function retainedDataPath(name, query) {
539
+ const base = `/api/plugins/${encodeURIComponent(name)}/retained-data`;
540
+ return query ? `${base}?${new URLSearchParams(query)}` : base;
541
+ }
542
+
543
+ /**
544
+ * `retained-data [<name>]`: every plugin's records (with whether the plugin
545
+ * is still installed), or one plugin's, by the name it had.
546
+ *
547
+ * @param {string[]} args
548
+ * @param {{ json: boolean }} options
549
+ */
550
+ async function retainedDataCmd(args, { json }) {
551
+ if (args.length > 1 || args.some((a) => a.startsWith('--'))) {
552
+ fail('Usage: lamaste-server plugins retained-data [<name>]', json);
553
+ }
554
+ const name = args[0];
555
+ let rows;
556
+ try {
557
+ if (name) {
558
+ ({ retainedData: rows } = await panelRequest('GET', retainedDataPath(name)));
559
+ } else {
560
+ const { plugins = [], retainedData = [] } = await panelRequest('GET', '/api/plugins');
561
+ rows = retainedDataRows(retainedData, new Set(plugins.map((p) => p.name)));
562
+ }
563
+ } catch (err) {
564
+ fail(err.message, json);
565
+ }
566
+ if (json) {
567
+ emit({ retainedData: rows });
568
+ return;
569
+ }
570
+ if (rows.length === 0) {
571
+ console.log(
572
+ name
573
+ ? `\n No agent keeps data of ${chalk.cyan(name)}'s agent half.\n`
574
+ : '\n No agent keeps data of a removed agent half.\n',
575
+ );
576
+ return;
577
+ }
578
+ const d = chalk.dim;
579
+ console.log('');
580
+ console.log(chalk.bold(name ? ` Retained data of ${name}` : ' Retained data'));
581
+ console.log(d(' ' + '\u2500'.repeat(40)));
582
+ for (const r of rows) {
583
+ const uninstalled = r.installed === false ? chalk.yellow(' (uninstalled)') : '';
584
+ console.log(
585
+ ` ${chalk.cyan(r.plugin)}${uninstalled} ${d('on')} ${chalk.cyan(r.agentLabel)} ` +
586
+ `${d('route')} ${r.route} ${r.package} ${d(`since ${r.since}`)}`,
587
+ );
588
+ console.log(d(` ${r.dataDir}, account ${r.serviceUser}`));
589
+ }
590
+ console.log('');
591
+ console.log(
592
+ d(' Purge it on the agent: lamaste-server plugins purge-data <name> --agent <label>'),
593
+ );
594
+ console.log(
595
+ d(' Forget the record only: lamaste-server plugins forget-data <name> --agent <label>'),
596
+ );
597
+ console.log('');
598
+ }
599
+
600
+ /**
601
+ * The record of what agent `agentLabel` keeps of plugin `name`, or null —
602
+ * read before a purge or a forget to say what it concerns.
603
+ */
604
+ async function findRetainedRecord(name, agentLabel) {
605
+ const { retainedData } = await panelRequest('GET', retainedDataPath(name));
606
+ return retainedData.find((r) => r.agentLabel === agentLabel) ?? null;
607
+ }
608
+
609
+ /**
610
+ * `purge-data <name> --agent <label>`: delete what the agent keeps — its
611
+ * data directory and account — through its app host helper. Once the panel
612
+ * has started the operation it says what is being deleted, follows the
613
+ * operation to its end and exits non-zero unless it succeeded.
614
+ *
615
+ * @param {string[]} args
616
+ * @param {{ json: boolean }} options
617
+ */
618
+ async function purgeDataCmd(args, { json }) {
619
+ const parsed = parseRetainedDataArgs('purge-data', args);
620
+ if (!parsed.ok) fail(parsed.error, json);
621
+ const { name, agentLabel } = parsed;
622
+ let record;
623
+ try {
624
+ record = await findRetainedRecord(name, agentLabel);
625
+ } catch (err) {
626
+ fail(err.message, json);
627
+ }
628
+ if (!record) fail(`Agent "${agentLabel}" keeps no data of plugin "${name}"`, json);
629
+ let result;
630
+ try {
631
+ result = await panelRequest('DELETE', retainedDataPath(name, { agentLabel }));
632
+ } catch (err) {
633
+ failRefused(err, json, { name, agentLabel, action: 'uninstall' });
634
+ }
635
+ if (!json) {
636
+ console.log(
637
+ `\n Purging what ${chalk.cyan(agentLabel)} keeps of ${chalk.cyan(name)} (route ${record.route}): ` +
638
+ `deleting ${record.dataDir} and the account ${record.serviceUser}`,
639
+ );
640
+ }
641
+ await finishOperation(name, result.operation, { json });
642
+ }
643
+
644
+ /**
645
+ * `forget-data <name> --agent <label>`: drop the relay's record only. The
646
+ * agent is not touched — said, with how to remove the data there.
647
+ *
648
+ * @param {string[]} args
649
+ * @param {{ json: boolean }} options
650
+ */
651
+ async function forgetDataCmd(args, { json }) {
652
+ const parsed = parseRetainedDataArgs('forget-data', args);
653
+ if (!parsed.ok) fail(parsed.error, json);
654
+ const { name, agentLabel } = parsed;
655
+ let result;
656
+ try {
657
+ result = await panelRequest('DELETE', retainedDataPath(name, { agentLabel, forget: 'true' }));
658
+ } catch (err) {
659
+ fail(err.message, json);
660
+ }
661
+ const { forgotten } = result;
662
+ if (json) {
663
+ emitComplete({ name, forgotten });
664
+ return;
111
665
  }
666
+ console.log(
667
+ `\n Forgot the relay's record of what ${chalk.cyan(agentLabel)} keeps of ${chalk.cyan(name)}.`,
668
+ );
669
+ console.log(
670
+ chalk.yellow(
671
+ ` Nothing on ${agentLabel} changed: ${forgotten.dataDir} and the account ${forgotten.serviceUser}` +
672
+ `\n stay there — remove them on the machine with` +
673
+ ` sudo lamaste-agent apphost remove ${forgotten.route} --purge`,
674
+ ),
675
+ );
112
676
  console.log('');
113
677
  }
114
678
 
679
+ // ---------------------------------------------------------------------------
680
+ // Lifecycle hooks
681
+ // ---------------------------------------------------------------------------
682
+
683
+ /** Print a relay hook set preview — the relay half of what an approval covers. */
684
+ function printRelayHookSet(relay, title) {
685
+ const d = chalk.dim;
686
+ console.log(chalk.bold(` ${title}`));
687
+ console.log(
688
+ ` module: ${relay.entry ?? d('(none — shared hooks only)')}${relay.entrySha256 ? d(` sha256 ${relay.entrySha256}`) : ''}`,
689
+ );
690
+ for (const stage of relay.stages) {
691
+ console.log(` stage: ${stage.stage} ${d(`(${stage.timeoutMs} ms)`)}`);
692
+ }
693
+ for (const hook of relay.request) {
694
+ const who = hook.use ?? 'plugin module';
695
+ const failOpen =
696
+ hook.failOpen.length > 0 ? chalk.yellow(` FAIL-OPEN: ${hook.failOpen.join(', ')}`) : '';
697
+ console.log(
698
+ ` request: ${hook.stage} ${who} on ${hook.routes.join(', ')}${failOpen}` +
699
+ d(` (body ${hook.bodyMaxBytes ?? 0} B, ${hook.timeoutMs} ms, onError ${hook.onError})`),
700
+ );
701
+ if (hook.inactiveRoutes.length > 0) {
702
+ console.log(
703
+ d(` not active until the user surface: ${hook.inactiveRoutes.join(', ')}`),
704
+ );
705
+ }
706
+ }
707
+ console.log(` effects: ${relay.effects.length > 0 ? relay.effects.join(', ') : d('none')}`);
708
+ if (relay.packs.length > 0) console.log(` packs: ${relay.packs.join(', ')}`);
709
+ }
710
+
711
+ /**
712
+ * Print an agent half preview — what Lamaste installs on the hosting agent
713
+ * once approved: the package at the version and tarball integrity the relay
714
+ * pinned, its service and the system packages installed for it.
715
+ */
716
+ function printAgentHalf(agentHalf, title) {
717
+ const d = chalk.dim;
718
+ const line = (label, value) => console.log(` ${`${label}:`.padEnd(11)}${value}`);
719
+ console.log(chalk.bold(` ${title}`));
720
+ line('package', chalk.cyan(`${agentHalf.package}@${agentHalf.version}`));
721
+ line('integrity', agentHalf.integrity);
722
+ line('entry', `${agentHalf.entry} ${d(`memory ${agentHalf.memoryMax ?? 'not limited'}`)}`);
723
+ line('runs as', `${agentHalf.serviceUser} ${d(`unit ${agentHalf.unitName}`)}`);
724
+ line('data', `${agentHalf.dataDir} ${d('(kept when it is removed, unless purged)')}`);
725
+ line(
726
+ 'packages',
727
+ agentHalf.systemPackages.length > 0
728
+ ? `${agentHalf.systemPackages.join(', ')} ${d('(system packages, apt-get once approved)')}`
729
+ : d('none'),
730
+ );
731
+ }
732
+
733
+ /**
734
+ * Print what a version brings that needs approval — the approval digest
735
+ * (the one value `approve-hooks` takes), its relay hook set and the agent
736
+ * half Lamaste installs.
737
+ */
738
+ function printApprovalContents({ digest, relay, agentHalf }) {
739
+ console.log(` approval digest: ${chalk.cyan(digest)}`);
740
+ if (relay) {
741
+ console.log('');
742
+ printRelayHookSet(relay, 'Relay hooks — run in a sandbox on the relay');
743
+ }
744
+ if (agentHalf) {
745
+ console.log('');
746
+ printAgentHalf(agentHalf, 'Agent half — Lamaste installs it on the hosting agent');
747
+ }
748
+ }
749
+
750
+ /** The command that approves `digest` for plugin `name`. */
751
+ function approveCommand(name, digest) {
752
+ return chalk.cyan(`lamaste-server plugins approve-hooks ${name} --digest ${digest}`);
753
+ }
754
+
755
+ /**
756
+ * @param {string | undefined} name
757
+ * @param {{ json: boolean }} options
758
+ */
759
+ async function hooksCmd(name, { json }) {
760
+ if (!name) fail('Usage: lamaste-server plugins hooks <name>', json);
761
+ let result;
762
+ try {
763
+ result = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hooks`);
764
+ } catch (err) {
765
+ fail(err.message, json);
766
+ }
767
+ if (json) {
768
+ emit(result);
769
+ return;
770
+ }
771
+ const { hooks } = result;
772
+ const { approval } = hooks;
773
+ console.log('');
774
+ if (!approval.required && !hooks.agent && !hooks.pendingUpdate) {
775
+ console.log(
776
+ ` Plugin ${chalk.cyan(name)} declares no lifecycle hooks and no agent half for Lamaste to install.\n`,
777
+ );
778
+ return;
779
+ }
780
+ if (approval.required) {
781
+ const state =
782
+ approval.state === 'approved'
783
+ ? chalk.green(
784
+ `approved by ${approval.approvedBy} at ${approval.approvedAt} (version ${approval.approvedVersion})`,
785
+ )
786
+ : chalk.yellow('waiting for approval');
787
+ console.log(chalk.bold(` What ${name} brings — `) + state);
788
+ printApprovalContents({
789
+ digest: approval.digest,
790
+ relay: hooks.relay,
791
+ agentHalf: hooks.agentHalf,
792
+ });
793
+ console.log(
794
+ `\n auto-approve for later versions: ${hooks.autoApprove ? chalk.yellow('on') : 'off'}`,
795
+ );
796
+ if (approval.state !== 'approved') {
797
+ console.log(`\n Approve with: ${approveCommand(name, approval.digest)}`);
798
+ }
799
+ }
800
+ if (hooks.agent) {
801
+ console.log(chalk.bold(`\n Agent hooks (run by the agent half; no approval)`));
802
+ for (const stage of hooks.agent.stages) {
803
+ console.log(` ${stage.stage} ${chalk.dim(`(${stage.timeoutMs} ms)`)}`);
804
+ }
805
+ }
806
+ if (hooks.pendingUpdate) {
807
+ const update = hooks.pendingUpdate;
808
+ console.log('');
809
+ console.log(chalk.bold(` Update to ${update.version} — waits for approval`));
810
+ printApprovalContents(update);
811
+ console.log(`\n Approve and update with: ${approveCommand(name, update.digest)}`);
812
+ }
813
+ console.log('');
814
+ }
815
+
816
+ /**
817
+ * @param {string | undefined} name
818
+ * @param {string[]} rest
819
+ * @param {{ json: boolean }} options
820
+ */
821
+ async function approveHooksCmd(name, rest, { json }) {
822
+ const idx = rest.indexOf('--digest');
823
+ const digest = idx !== -1 ? rest[idx + 1] : undefined;
824
+ if (!name || !digest || !/^[a-f0-9]{64}$/.test(digest)) {
825
+ fail(
826
+ 'Usage: lamaste-server plugins approve-hooks <name> --digest <sha256> (the approval digest `hooks <name>` shows)',
827
+ json,
828
+ );
829
+ }
830
+ let result;
831
+ try {
832
+ result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/hooks/approve`, {
833
+ digest,
834
+ });
835
+ } catch (err) {
836
+ // Approving a pending update starts it: its agent half is a push.
837
+ failRefused(err, json, { name, action: 'update' });
838
+ }
839
+ if (result.operation) {
840
+ if (!json) console.log(`\n Approved; updating ${chalk.cyan(name)}`);
841
+ await finishOperation(name, result.operation, { json });
842
+ return;
843
+ }
844
+ if (json) emitComplete({ name, approved: digest });
845
+ else console.log(`\n What ${chalk.cyan(name)} brings is ${chalk.green('approved')}.\n`);
846
+ }
847
+
848
+ /**
849
+ * @param {string | undefined} name
850
+ * @param {string | undefined} value
851
+ * @param {{ json: boolean }} options
852
+ */
853
+ async function autoApproveCmd(name, value, { json }) {
854
+ if (!name || (value !== 'on' && value !== 'off')) {
855
+ fail('Usage: lamaste-server plugins auto-approve <name> on|off', json);
856
+ }
857
+ try {
858
+ await panelRequest('PUT', `/api/plugins/${encodeURIComponent(name)}/hooks/auto-approve`, {
859
+ enabled: value === 'on',
860
+ });
861
+ } catch (err) {
862
+ fail(err.message, json);
863
+ }
864
+ if (json) emitComplete({ name, autoApprove: value === 'on' });
865
+ else
866
+ console.log(
867
+ `\n Auto-approve of hook-set changes for ${chalk.cyan(name)}: ${value === 'on' ? chalk.yellow('on') : 'off'}\n`,
868
+ );
869
+ }
870
+
871
+ /**
872
+ * @param {string | undefined} name
873
+ * @param {string[]} rest
874
+ * @param {{ json: boolean }} options
875
+ */
876
+ async function updateCmd(name, rest, { json }) {
877
+ if (!name) fail('Usage: lamaste-server plugins update <name> [--version <v>]', json);
878
+ const idx = rest.indexOf('--version');
879
+ const version = idx !== -1 ? rest[idx + 1] : undefined;
880
+ let result;
881
+ try {
882
+ result = await panelRequest(
883
+ 'POST',
884
+ `/api/plugins/${encodeURIComponent(name)}/update`,
885
+ version ? { version } : {},
886
+ { timeoutMs: NPM_CALL_TIMEOUT_MS },
887
+ );
888
+ } catch (err) {
889
+ failRefused(err, json, { name, action: 'update' });
890
+ }
891
+ if (result.upToDate) {
892
+ if (json) emitComplete({ name, upToDate: true, version: result.version });
893
+ else console.log(`\n ${chalk.cyan(name)} is already at ${result.version}.\n`);
894
+ return;
895
+ }
896
+ if (result.pendingUpdate) {
897
+ if (json) {
898
+ emitComplete({ name, pendingUpdate: result.pendingUpdate });
899
+ return;
900
+ }
901
+ const update = result.pendingUpdate;
902
+ console.log('');
903
+ console.log(chalk.bold(` ${name} ${update.version} waits for approval`));
904
+ printApprovalContents(update);
905
+ console.log(`\n Approve and update with: ${approveCommand(name, update.digest)}\n`);
906
+ return;
907
+ }
908
+ if (!json) console.log(`\n Updating ${chalk.cyan(name)}`);
909
+ await finishOperation(name, result.operation, { json });
910
+ }
911
+
912
+ /**
913
+ * @param {string | undefined} name
914
+ * @param {{ json: boolean }} options
915
+ */
916
+ async function discardUpdateCmd(name, { json }) {
917
+ if (!name) fail('Usage: lamaste-server plugins discard-update <name>', json);
918
+ try {
919
+ await panelRequest('DELETE', `/api/plugins/${encodeURIComponent(name)}/update`);
920
+ } catch (err) {
921
+ fail(err.message, json);
922
+ }
923
+ if (json) emitComplete({ name, pendingUpdate: null });
924
+ else console.log(`\n The pending update of ${chalk.cyan(name)} was discarded.\n`);
925
+ }
926
+
927
+ /**
928
+ * @param {string | undefined} name
929
+ * @param {{ json: boolean }} options
930
+ */
931
+ async function rotateHostingSecretCmd(name, { json }) {
932
+ if (!name) fail('Usage: lamaste-server plugins rotate-hosting-secret <name>', json);
933
+ let result;
934
+ try {
935
+ result = await panelRequest(
936
+ 'POST',
937
+ `/api/plugins/${encodeURIComponent(name)}/hosting/rotate-secret`,
938
+ );
939
+ } catch (err) {
940
+ fail(err.message, json);
941
+ }
942
+ if (json) emitComplete({ hosting: result.hosting });
943
+ else {
944
+ console.log(`\n New installation secret issued for ${chalk.cyan(name)}.`);
945
+ console.log(` The previous secret stays accepted until the agent acknowledges the new one.`);
946
+ printHosting(result.hosting);
947
+ }
948
+ }
949
+
115
950
  /**
116
951
  * Print the secrets of confidential OIDC clients an install or enable created.
117
952
  * They are shown once — the panel stores only their digests.
@@ -143,6 +978,92 @@ function fail(message, json, step) {
143
978
  process.exit(1);
144
979
  }
145
980
 
981
+ /** What Lamaste does with the agent half for each push-install action. */
982
+ const PUSH_VERB = { install: 'installs', update: 'updates', uninstall: 'removes' };
983
+
984
+ /**
985
+ * Exit with a refused plugin operation (hosting, removing a hosting,
986
+ * uninstalling, updating): the panel's message, the reasons an agent
987
+ * cannot host the plugin, and what lifts a refusal the administrator can
988
+ * lift. NDJSON carries the refusal's `code` and `reasons`.
989
+ *
990
+ * @param {Error & { code?: string, reasons?: string[] }} err from panelRequest
991
+ * @param {boolean} json
992
+ * @param {{ step?: string, name: string, agentLabel?: string, action: 'install' | 'update' | 'uninstall' }} context
993
+ * @returns {never}
994
+ */
995
+ function failRefused(err, json, { step, name, agentLabel, action }) {
996
+ const reasons = err.reasons ?? [];
997
+ if (json) {
998
+ if (step) emitStep(step, 'failed', err.message);
999
+ emitError(err.message, false, {
1000
+ ...(err.code ? { code: err.code } : {}),
1001
+ ...(err.reasons ? { reasons } : {}),
1002
+ });
1003
+ process.exit(1);
1004
+ }
1005
+ const d = chalk.dim;
1006
+ console.error(`\n ${chalk.red(err.message)}`);
1007
+ if (err.code === 'agent_not_eligible') {
1008
+ for (const reason of reasons) console.error(` - ${reason}`);
1009
+ console.error(
1010
+ d(
1011
+ `\n Every agent and why: lamaste-server plugins eligibility ${name}` +
1012
+ `\n An agent reports its host on its sync (about every 30 seconds).`,
1013
+ ),
1014
+ );
1015
+ } else if (err.code === 'push_install_refused') {
1016
+ const agent = agentLabel ? `agent "${agentLabel}"` : 'the hosting agent';
1017
+ console.error(
1018
+ d(
1019
+ `\n Lamaste ${PUSH_VERB[action]} the agent half by push install: push install must be on for` +
1020
+ `\n this relay and enabled for ${agent} with its window open, and the agent's policy must` +
1021
+ `\n allow "${action}" of the agent package from this address (the CLI calls the panel` +
1022
+ `\n from 127.0.0.1).`,
1023
+ ),
1024
+ );
1025
+ } else if (err.code === 'hooks_not_approved') {
1026
+ console.error(
1027
+ d(`\n Review what it brings with: lamaste-server plugins hooks ${name}, then approve it.`),
1028
+ );
1029
+ }
1030
+ console.error('');
1031
+ process.exit(1);
1032
+ }
1033
+
1034
+ /**
1035
+ * The data directory of the agent half Lamaste installed for plugin `name`
1036
+ * (from its preview), or a description of it when the panel does not name
1037
+ * one — what a purge deletes, said before it runs.
1038
+ *
1039
+ * @param {string} name
1040
+ * @returns {Promise<string>}
1041
+ */
1042
+ async function agentHalfDataDir(name) {
1043
+ const unnamed = "the agent half's data directory";
1044
+ try {
1045
+ const { hooks } = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hooks`);
1046
+ return hooks.agentHalf?.dataDir ?? unnamed;
1047
+ } catch {
1048
+ return unnamed;
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * `?force=true&purge=true` for removing a plugin's hosting, with only the
1054
+ * flags that are set: `force` lets failing pre-teardown hooks pass, `purge`
1055
+ * deletes the data and service account of the agent half Lamaste installed.
1056
+ *
1057
+ * @param {{ force: boolean, purge: boolean }} flags
1058
+ */
1059
+ function removalQuery({ force, purge }) {
1060
+ const params = new URLSearchParams();
1061
+ if (force) params.set('force', 'true');
1062
+ if (purge) params.set('purge', 'true');
1063
+ const text = params.toString();
1064
+ return text ? `?${text}` : '';
1065
+ }
1066
+
146
1067
  /**
147
1068
  * @param {string | undefined} packageName
148
1069
  * @param {{ json: boolean }} options
@@ -173,7 +1094,7 @@ async function installPluginCmd(packageName, { json }) {
173
1094
  const { plugin, oidcClients } = result;
174
1095
  if (json) {
175
1096
  emitStep('install', 'complete');
176
- emitComplete({ plugin, oidcClients });
1097
+ emitComplete({ plugin, oidcClients, hooks: result.hooks });
177
1098
  return;
178
1099
  }
179
1100
  const hosting = plugin.hosting === 'agent' ? ', agent-hosted' : '';
@@ -181,6 +1102,19 @@ async function installPluginCmd(packageName, { json }) {
181
1102
  console.log(` Plugin ${chalk.cyan(plugin.name)} installed (${chalk.dim(`disabled${hosting}`)})`);
182
1103
  console.log(` Run ${chalk.cyan(`lamaste-server plugins enable ${plugin.name}`)} to activate.`);
183
1104
  console.log('');
1105
+ const approval = result.hooks?.approval;
1106
+ if (approval?.required) {
1107
+ console.log(chalk.bold(` What it brings — review before hosting`));
1108
+ printApprovalContents({
1109
+ digest: approval.digest,
1110
+ relay: result.hooks.relay,
1111
+ agentHalf: result.hooks.agentHalf,
1112
+ });
1113
+ if (approval.state === 'pending') {
1114
+ console.log(`\n Approve with: ${approveCommand(plugin.name, approval.digest)}`);
1115
+ }
1116
+ console.log('');
1117
+ }
184
1118
  printCreatedSecrets(oidcClients);
185
1119
  }
186
1120
 
@@ -241,23 +1175,40 @@ async function disablePluginCmd(name, { json }) {
241
1175
  * @param {string | undefined} name
242
1176
  * @param {{ json: boolean }} options
243
1177
  */
244
- async function uninstallPluginCmd(name, { json }) {
245
- if (!name) fail('Usage: lamaste-server plugins uninstall <name>', json);
1178
+ async function uninstallPluginCmd(name, rest, { json }) {
1179
+ if (!name) fail('Usage: lamaste-server plugins uninstall <name> [--force] [--purge]', json);
1180
+ const force = rest.includes('--force');
1181
+ const purge = rest.includes('--purge');
246
1182
 
247
1183
  if (json) emitStep('uninstall', 'running', `Uninstalling ${name}`);
248
- else process.stderr.write(` Uninstalling ${chalk.cyan(name)}...`);
1184
+ else {
1185
+ const deleting = purge ? ` (deleting ${await agentHalfDataDir(name)})` : '';
1186
+ process.stderr.write(` Uninstalling ${chalk.cyan(name)}${deleting}...`);
1187
+ }
249
1188
 
1189
+ let result;
250
1190
  try {
251
1191
  // The panel refuses an enabled plugin (409): nothing of it is mounted
252
1192
  // once it is disabled, so no restart follows.
253
- await panelRequest('DELETE', `/api/plugins/${encodeURIComponent(name)}`, null, {
254
- timeoutMs: NPM_CALL_TIMEOUT_MS,
255
- });
1193
+ result = await panelRequest(
1194
+ 'DELETE',
1195
+ `/api/plugins/${encodeURIComponent(name)}${removalQuery({ force, purge })}`,
1196
+ null,
1197
+ { timeoutMs: NPM_CALL_TIMEOUT_MS },
1198
+ );
256
1199
  } catch (err) {
257
1200
  if (!json) console.log(` ${chalk.red('failed')}`);
258
- fail(err.message, json, 'uninstall');
1201
+ failRefused(err, json, { step: 'uninstall', name, action: 'uninstall' });
259
1202
  }
260
1203
 
1204
+ if (result.operation) {
1205
+ // Hosted: its uninstall pipeline runs first.
1206
+ if (!json) console.log('');
1207
+ const operation = await followOperation(name, result.operation, { json });
1208
+ if (json) emitComplete({ name, removed: operation.status === 'succeeded', operation });
1209
+ if (operation.status !== 'succeeded') process.exit(1);
1210
+ return;
1211
+ }
261
1212
  if (json) {
262
1213
  emitStep('uninstall', 'complete');
263
1214
  emitComplete({ name, removed: true });