@lamalibre/create-lamaste 4.1.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 (126) 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 +99 -35
  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 +32 -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 +1067 -161
  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 +36 -6
  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/0005_plugin_hosting.sql +3 -0
  91. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  92. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  93. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  94. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  95. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  96. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  97. package/vendor/serverd/src/lib/mtls.js +89 -8
  98. package/vendor/serverd/src/lib/nginx.js +298 -44
  99. package/vendor/serverd/src/lib/notifications.js +107 -0
  100. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  101. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  102. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  103. package/vendor/serverd/src/lib/plugin-oidc.js +33 -16
  104. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  105. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  106. package/vendor/serverd/src/lib/plugins.js +306 -68
  107. package/vendor/serverd/src/lib/push-install.js +88 -2
  108. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  109. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  110. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  111. package/vendor/serverd/src/lib/retained-data.js +99 -0
  112. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  113. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  114. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  115. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  116. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  117. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  118. package/vendor/serverd/src/routes/management/agents.js +262 -1
  119. package/vendor/serverd/src/routes/management/audit.js +81 -0
  120. package/vendor/serverd/src/routes/management/certs.js +1 -15
  121. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  122. package/vendor/serverd/src/routes/management/storage.js +10 -2
  123. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  124. package/vendor/serverd/src/routes/management.js +4 -0
  125. package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +0 -257
  126. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -2,34 +2,69 @@
2
2
  * lamaste-server plugins — Manage server-side plugins.
3
3
  *
4
4
  * Subcommands:
5
- * list List installed plugins with status
5
+ * list List installed plugins with status and hosting
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
- * The OIDC subcommands go through the running panel: writing a client to
15
- * Authelia is the panel's job.
49
+ * Every subcommand goes through the running panel (`/api/plugins/*`, admin
50
+ * certificate): the panel owns the plugin registry (SQLite `state.db`), runs
51
+ * npm as the `lamaste` account, and writes the plugins' OIDC clients to
52
+ * Authelia. The CLI keeps no plugin state of its own, so a plugin it installs
53
+ * is the plugin the panel serves. The panel must be running — a plugin that
54
+ * fails to load is logged and skipped by the plugin host, so the panel stays
55
+ * reachable to disable it.
16
56
  */
17
57
 
18
58
  import chalk from 'chalk';
19
59
  import { execa } from 'execa';
20
- import {
21
- readPlugins,
22
- installPlugin,
23
- enablePlugin,
24
- disablePlugin,
25
- uninstallPlugin,
26
- } from '@lamalibre/lamaste/server';
27
- import { STATE_DIR, PANEL_SERVICE } from '../config.js';
28
- import { exec } from '../exec.js';
60
+ import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
61
+ import { PANEL_SERVICE } from '../config.js';
29
62
  import { panelRequest } from '../panel-api.js';
30
- import { createLogger } from '../logger.js';
31
63
  import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
32
64
 
65
+ /** npm runs inside the panel for install and uninstall (its own limit is 120 s). */
66
+ const NPM_CALL_TIMEOUT_MS = 180_000;
67
+
33
68
  /**
34
69
  * @param {string[]} args Subcommand args: ['list'] | ['install', '<pkg>'] | ...
35
70
  * @param {{ json: boolean }} options
@@ -37,12 +72,6 @@ import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
37
72
  export async function runPlugins(args, { json }) {
38
73
  const sub = args[0];
39
74
 
40
- if (MUTATING.has(sub) && process.getuid?.() === 0) {
41
- await runAsLamaste(args, { json });
42
- await restartPanel(json);
43
- return;
44
- }
45
-
46
75
  switch (sub) {
47
76
  case 'list':
48
77
  return listPlugins({ json });
@@ -53,11 +82,39 @@ export async function runPlugins(args, { json }) {
53
82
  case 'disable':
54
83
  return disablePluginCmd(args[1], { json });
55
84
  case 'uninstall':
56
- return uninstallPluginCmd(args[1], { json });
85
+ return uninstallPluginCmd(args[1], args.slice(2), { json });
57
86
  case 'oidc-clients':
58
87
  return oidcClientsCmd(args[1], { json });
59
88
  case 'rotate-secret':
60
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 });
61
118
  default:
62
119
  printPluginUsage();
63
120
  process.exit(sub ? 1 : 0);
@@ -71,23 +128,53 @@ function printPluginUsage() {
71
128
  ${b('Usage:')} lamaste-server plugins <subcommand>
72
129
 
73
130
  ${b('Subcommands:')}
74
- ${c('list')} List installed plugins with status
131
+ ${c('list')} Installed plugins: status, hosting (server or agent)
75
132
  ${c('install <package>')} Install a plugin (e.g. @lamalibre/herd-server)
76
133
  ${c('enable <name>')} Enable a plugin
77
134
  ${c('disable <name>')} Disable a plugin
78
- ${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)
79
138
  ${c('oidc-clients <name>')} OIDC clients the plugin declares on Authelia (no secrets)
80
139
  ${c('rotate-secret <name> <clientId>')}
81
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
82
167
  `);
83
168
  }
84
169
 
85
170
  /**
171
+ * The plugins the running panel serves, from the panel itself: its registry
172
+ * (SQLite `state.db`) is the one the plugin host and the OIDC sync read.
173
+ *
86
174
  * @param {{ json: boolean }} options
87
175
  */
88
176
  async function listPlugins({ json }) {
89
- const registry = await readPlugins(STATE_DIR);
90
- const plugins = registry.plugins;
177
+ const { plugins = [] } = await panelRequest('GET', '/api/plugins');
91
178
 
92
179
  if (json) {
93
180
  emit({ plugins });
@@ -111,158 +198,1022 @@ async function listPlugins({ json }) {
111
198
 
112
199
  for (const p of plugins) {
113
200
  const status = p.status === 'enabled' ? g('enabled') : r('disabled');
114
- console.log(` ${c(p.name)} ${d(p.version)} ${status} ${d(p.packageName)}`);
201
+ const hosting = p.hosting === 'agent' ? chalk.yellow('agent-hosted') : d('server-hosted');
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
+ );
115
209
  }
116
210
  console.log('');
117
211
  }
118
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
+
119
220
  /**
120
- * @param {string | undefined} packageName
121
- * @param {{ json: boolean }} options
221
+ * Print a hosting record.
222
+ * @param {Record<string, unknown>} hosting
122
223
  */
123
- async function installPluginCmd(packageName, { json }) {
124
- if (!packageName) {
125
- const msg = 'Usage: lamaste-server plugins install <package>';
126
- if (json) emitError(msg);
127
- else console.error(`\n ${msg}\n`);
128
- process.exit(1);
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
+ );
129
244
  }
245
+ console.log('');
246
+ }
130
247
 
131
- const logger = createLogger({ json });
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
+ }
132
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
+ }
133
283
  if (json) {
134
- emitStep('install', 'running', `Installing ${packageName}`);
135
- emitLog('info', `Fetching ${packageName} from the npm registry — this may take a moment.`);
136
- } else {
137
- process.stderr.write(` Installing ${chalk.cyan(packageName)}...`);
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}`);
138
306
  }
307
+ console.log('');
308
+ }
139
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;
140
321
  try {
141
- const entry = await installPlugin({
142
- packageName,
143
- stateDir: STATE_DIR,
144
- exec,
145
- logger,
322
+ result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/hosting`, {
323
+ agentLabel,
146
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
+ }
147
337
 
148
- if (json) {
149
- emitStep('install', 'complete');
150
- emitComplete({ plugin: entry });
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;
151
509
  } else {
152
- console.log(` ${chalk.green('ok')}`);
153
- console.log(` Plugin ${chalk.cyan(entry.name)} installed (${chalk.dim('disabled')})`);
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;
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
+ );
676
+ console.log('');
677
+ }
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) {
154
702
  console.log(
155
- ` Run ${chalk.cyan(`lamaste-server plugins enable ${entry.name}`)} to activate.`,
703
+ d(` not active until the user surface: ${hook.inactiveRoutes.join(', ')}`),
156
704
  );
157
- console.log('');
158
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
+ }
159
749
 
160
- await restartPanel(json);
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`);
161
764
  } catch (err) {
162
- if (json) {
163
- emitStep('install', 'failed', err.message);
164
- emitError(err.message);
165
- } else {
166
- console.log(` ${chalk.red('failed')}`);
167
- console.error(` ${chalk.red(err.message)}`);
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)}`);
168
798
  }
169
- process.exit(1);
170
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('');
171
814
  }
172
815
 
173
816
  /**
174
817
  * @param {string | undefined} name
818
+ * @param {string[]} rest
175
819
  * @param {{ json: boolean }} options
176
820
  */
177
- async function enablePluginCmd(name, { json }) {
178
- if (!name) {
179
- const msg = 'Usage: lamaste-server plugins enable <name>';
180
- if (json) emitError(msg);
181
- else console.error(`\n ${msg}\n`);
182
- process.exit(1);
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' });
183
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
+ }
184
847
 
185
- const logger = createLogger({ json });
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
+ }
186
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;
187
881
  try {
188
- await enablePlugin({ name, stateDir: STATE_DIR, logger });
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) {
189
897
  if (json) {
190
- emitComplete({ name, status: 'enabled' });
191
- } else {
192
- console.log(`\n Plugin ${chalk.cyan(name)} ${chalk.green('enabled')}.`);
898
+ emitComplete({ name, pendingUpdate: result.pendingUpdate });
899
+ return;
193
900
  }
194
- await restartPanel(json);
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`);
195
920
  } catch (err) {
196
- if (json) emitError(err.message);
197
- else console.error(`\n ${chalk.red(err.message)}\n`);
198
- process.exit(1);
921
+ fail(err.message, json);
199
922
  }
923
+ if (json) emitComplete({ name, pendingUpdate: null });
924
+ else console.log(`\n The pending update of ${chalk.cyan(name)} was discarded.\n`);
200
925
  }
201
926
 
202
927
  /**
203
928
  * @param {string | undefined} name
204
929
  * @param {{ json: boolean }} options
205
930
  */
206
- async function disablePluginCmd(name, { json }) {
207
- if (!name) {
208
- const msg = 'Usage: lamaste-server plugins disable <name>';
209
- if (json) emitError(msg);
210
- else console.error(`\n ${msg}\n`);
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
+
950
+ /**
951
+ * Print the secrets of confidential OIDC clients an install or enable created.
952
+ * They are shown once — the panel stores only their digests.
953
+ *
954
+ * @param {Array<{ id: string, secret: string }> | undefined} clients
955
+ */
956
+ function printCreatedSecrets(clients) {
957
+ if (!clients || clients.length === 0) return;
958
+ console.log(` ${chalk.bold('Confidential OIDC client secrets (shown once):')}`);
959
+ for (const c of clients) console.log(` ${chalk.cyan(c.id)} ${c.secret}`);
960
+ console.log('');
961
+ }
962
+
963
+ /**
964
+ * Exit with a usage or panel error, as text or NDJSON.
965
+ *
966
+ * @param {string} message
967
+ * @param {boolean} json
968
+ * @param {string} [step] NDJSON step that failed
969
+ * @returns {never}
970
+ */
971
+ function fail(message, json, step) {
972
+ if (json) {
973
+ if (step) emitStep(step, 'failed', message);
974
+ emitError(message);
975
+ } else {
976
+ console.error(`\n ${chalk.red(message)}\n`);
977
+ }
978
+ process.exit(1);
979
+ }
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
+ });
211
1003
  process.exit(1);
212
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
+ }
213
1051
 
214
- const logger = createLogger({ json });
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
+ }
215
1066
 
1067
+ /**
1068
+ * @param {string | undefined} packageName
1069
+ * @param {{ json: boolean }} options
1070
+ */
1071
+ async function installPluginCmd(packageName, { json }) {
1072
+ if (!packageName) fail('Usage: lamaste-server plugins install <package>', json);
1073
+
1074
+ if (json) {
1075
+ emitStep('install', 'running', `Installing ${packageName}`);
1076
+ emitLog('info', `The panel is fetching ${packageName} from the npm registry.`);
1077
+ } else {
1078
+ process.stderr.write(` Installing ${chalk.cyan(packageName)}...`);
1079
+ }
1080
+
1081
+ let result;
216
1082
  try {
217
- await disablePlugin({ name, stateDir: STATE_DIR, logger });
218
- if (json) {
219
- emitComplete({ name, status: 'disabled' });
220
- } else {
221
- console.log(`\n Plugin ${chalk.cyan(name)} ${chalk.red('disabled')}.`);
1083
+ result = await panelRequest(
1084
+ 'POST',
1085
+ '/api/plugins/install',
1086
+ { packageName },
1087
+ { timeoutMs: NPM_CALL_TIMEOUT_MS },
1088
+ );
1089
+ } catch (err) {
1090
+ if (!json) console.log(` ${chalk.red('failed')}`);
1091
+ fail(err.message, json, 'install');
1092
+ }
1093
+
1094
+ const { plugin, oidcClients } = result;
1095
+ if (json) {
1096
+ emitStep('install', 'complete');
1097
+ emitComplete({ plugin, oidcClients, hooks: result.hooks });
1098
+ return;
1099
+ }
1100
+ const hosting = plugin.hosting === 'agent' ? ', agent-hosted' : '';
1101
+ console.log(` ${chalk.green('ok')}`);
1102
+ console.log(` Plugin ${chalk.cyan(plugin.name)} installed (${chalk.dim(`disabled${hosting}`)})`);
1103
+ console.log(` Run ${chalk.cyan(`lamaste-server plugins enable ${plugin.name}`)} to activate.`);
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)}`);
222
1115
  }
223
- await restartPanel(json);
1116
+ console.log('');
1117
+ }
1118
+ printCreatedSecrets(oidcClients);
1119
+ }
1120
+
1121
+ /**
1122
+ * The panel mounts a server-hosted plugin's routes at startup, so enabling or
1123
+ * disabling one takes effect after a restart; an agent-hosted plugin has
1124
+ * nothing mounted and its OIDC clients are already applied.
1125
+ *
1126
+ * @param {string} name
1127
+ * @returns {Promise<boolean>}
1128
+ */
1129
+ async function needsRestart(name) {
1130
+ const { plugin } = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}`);
1131
+ return plugin.hosting !== 'agent';
1132
+ }
1133
+
1134
+ /**
1135
+ * @param {string | undefined} name
1136
+ * @param {{ json: boolean }} options
1137
+ */
1138
+ async function enablePluginCmd(name, { json }) {
1139
+ if (!name) fail('Usage: lamaste-server plugins enable <name>', json);
1140
+
1141
+ let result;
1142
+ try {
1143
+ result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/enable`);
224
1144
  } catch (err) {
225
- if (json) emitError(err.message);
226
- else console.error(`\n ${chalk.red(err.message)}\n`);
227
- process.exit(1);
1145
+ fail(err.message, json);
1146
+ }
1147
+
1148
+ if (json) emitComplete({ name, status: 'enabled', oidcClients: result.oidcClients });
1149
+ else {
1150
+ console.log(`\n Plugin ${chalk.cyan(name)} ${chalk.green('enabled')}.`);
1151
+ printCreatedSecrets(result.oidcClients);
228
1152
  }
1153
+ if (await needsRestart(name)) await restartPanel(json);
229
1154
  }
230
1155
 
231
1156
  /**
232
1157
  * @param {string | undefined} name
233
1158
  * @param {{ json: boolean }} options
234
1159
  */
235
- async function uninstallPluginCmd(name, { json }) {
236
- if (!name) {
237
- const msg = 'Usage: lamaste-server plugins uninstall <name>';
238
- if (json) emitError(msg);
239
- else console.error(`\n ${msg}\n`);
240
- process.exit(1);
1160
+ async function disablePluginCmd(name, { json }) {
1161
+ if (!name) fail('Usage: lamaste-server plugins disable <name>', json);
1162
+
1163
+ try {
1164
+ await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/disable`);
1165
+ } catch (err) {
1166
+ fail(err.message, json);
241
1167
  }
242
1168
 
243
- const logger = createLogger({ json });
1169
+ if (json) emitComplete({ name, status: 'disabled' });
1170
+ else console.log(`\n Plugin ${chalk.cyan(name)} ${chalk.red('disabled')}.`);
1171
+ if (await needsRestart(name)) await restartPanel(json);
1172
+ }
1173
+
1174
+ /**
1175
+ * @param {string | undefined} name
1176
+ * @param {{ json: boolean }} options
1177
+ */
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');
244
1182
 
245
1183
  if (json) emitStep('uninstall', 'running', `Uninstalling ${name}`);
246
- 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
+ }
247
1188
 
1189
+ let result;
248
1190
  try {
249
- await uninstallPlugin({ name, stateDir: STATE_DIR, exec, logger });
250
- if (json) {
251
- emitStep('uninstall', 'complete');
252
- emitComplete({ name, removed: true });
253
- } else {
254
- console.log(` ${chalk.green('ok')}`);
255
- }
256
- await restartPanel(json);
1191
+ // The panel refuses an enabled plugin (409): nothing of it is mounted
1192
+ // once it is disabled, so no restart follows.
1193
+ result = await panelRequest(
1194
+ 'DELETE',
1195
+ `/api/plugins/${encodeURIComponent(name)}${removalQuery({ force, purge })}`,
1196
+ null,
1197
+ { timeoutMs: NPM_CALL_TIMEOUT_MS },
1198
+ );
257
1199
  } catch (err) {
258
- if (json) {
259
- emitStep('uninstall', 'failed', err.message);
260
- emitError(err.message);
261
- } else {
262
- console.log(` ${chalk.red('failed')}`);
263
- console.error(` ${chalk.red(err.message)}`);
264
- }
265
- process.exit(1);
1200
+ if (!json) console.log(` ${chalk.red('failed')}`);
1201
+ failRefused(err, json, { step: 'uninstall', name, action: 'uninstall' });
1202
+ }
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
+ }
1212
+ if (json) {
1213
+ emitStep('uninstall', 'complete');
1214
+ emitComplete({ name, removed: true });
1215
+ } else {
1216
+ console.log(` ${chalk.green('ok')}`);
266
1217
  }
267
1218
  }
268
1219
 
@@ -339,58 +1290,13 @@ async function rotateSecretCmd(name, clientId, { json }) {
339
1290
  console.log(` ${chalk.bold('New secret (shown once):')} ${result.secret}\n`);
340
1291
  }
341
1292
 
342
- const MUTATING = new Set(['install', 'enable', 'disable', 'uninstall']);
343
-
344
- /** Set on the child run as lamaste: the root parent restarts the panel. */
345
- const CHILD_ENV = 'LAMALIBRE_LAMASTE_CLI_PLUGINS_CHILD';
346
-
347
- /**
348
- * Run a plugin subcommand as the lamaste user and wait for it.
349
- *
350
- * Plugin state (the npm tree, plugins.json, plugin data) lives in the
351
- * lamaste-owned state directory. Running npm or writing files there as root
352
- * would let whatever the panel left in that directory — a `.npmrc`, a
353
- * symlink — steer root. As lamaste, it can only reach what the panel could
354
- * already reach. Stays usable while the panel is down (disabling a plugin
355
- * that keeps it from starting).
356
- *
357
- * @param {string[]} args
358
- * @param {{ json: boolean }} options
359
- */
360
- async function runAsLamaste(args, { json }) {
361
- const uid = Number.parseInt((await execa('id', ['-u', 'lamaste'])).stdout.trim(), 10);
362
- const gid = Number.parseInt((await execa('id', ['-g', 'lamaste'])).stdout.trim(), 10);
363
- const { exitCode } = await execa(
364
- process.execPath,
365
- [process.argv[1], 'plugins', ...args, ...(json ? ['--json'] : [])],
366
- {
367
- uid,
368
- gid,
369
- cwd: STATE_DIR,
370
- stdio: 'inherit',
371
- reject: false,
372
- env: {
373
- ...process.env,
374
- [CHILD_ENV]: '1',
375
- HOME: STATE_DIR,
376
- USER: 'lamaste',
377
- LOGNAME: 'lamaste',
378
- npm_config_cache: `${STATE_DIR}/.npm`,
379
- },
380
- extendEnv: false,
381
- },
382
- );
383
- if (exitCode !== 0) process.exit(exitCode ?? 1);
384
- }
385
-
386
1293
  /**
387
- * Restart the panel service after plugin changes.
388
- * Plugin enable/disable/install/uninstall requires a panel restart to take effect.
1294
+ * Restart the panel so it mounts (or unmounts) a server-hosted plugin's
1295
+ * routes. Needs root, like every `lamaste-server` command that reads the
1296
+ * admin certificate.
389
1297
  * @param {boolean} json
390
1298
  */
391
1299
  async function restartPanel(json) {
392
- // The child run as lamaste cannot; its root parent does.
393
- if (process.env[CHILD_ENV] === '1') return;
394
1300
  try {
395
1301
  await execa('systemctl', ['restart', PANEL_SERVICE]);
396
1302
  if (!json) {