@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.
- package/package.json +2 -2
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +4 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/relay-services.js +15 -1
- package/src/lib/service-config.js +113 -0
- package/src/tasks/gatekeeper.js +8 -3
- package/src/tasks/node.js +34 -75
- package/src/tasks/redeploy.js +36 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +6 -3
- package/vendor/docs/01-concepts/authorization.md +29 -17
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
- package/vendor/docs/01-concepts/security-model.md +99 -35
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +34 -16
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
- package/vendor/docs/03-architecture/overview.md +8 -8
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
- package/vendor/docs/04-api-reference/app-host.md +360 -0
- package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
- package/vendor/docs/04-api-reference/certificates.md +95 -38
- package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -111
- package/vendor/docs/04-api-reference/registry-and-oidc.md +32 -3
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +25 -20
- package/vendor/docs/06-reference/config-files.md +190 -97
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +33 -8
- package/vendor/docs/_index.json +20 -0
- package/vendor/docs/gatekeeper-design.md +22 -19
- package/vendor/docs/identity-directory-design.md +57 -56
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +47 -8
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +4 -3
- package/vendor/server/bin/lamaste-server.js +18 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/plugins.js +1067 -161
- package/vendor/server/src/commands/push-install.js +317 -0
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/uninstall.js +28 -1
- package/vendor/server/src/ndjson.js +4 -2
- package/vendor/server/src/panel-api.js +36 -6
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +56 -4
- package/vendor/serverd/src/lib/actor.js +24 -0
- package/vendor/serverd/src/lib/agent-jobs.js +330 -0
- package/vendor/serverd/src/lib/app-error.js +10 -0
- package/vendor/serverd/src/lib/audit.js +145 -0
- package/vendor/serverd/src/lib/hook-effects.js +123 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
- package/vendor/serverd/src/lib/migrations/state/0005_plugin_hosting.sql +3 -0
- package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
- package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
- package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
- package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +89 -8
- package/vendor/serverd/src/lib/nginx.js +298 -44
- package/vendor/serverd/src/lib/notifications.js +107 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
- package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +33 -16
- package/vendor/serverd/src/lib/plugin-operations.js +295 -0
- package/vendor/serverd/src/lib/plugin-packages.js +249 -0
- package/vendor/serverd/src/lib/plugins.js +306 -68
- package/vendor/serverd/src/lib/push-install.js +88 -2
- package/vendor/serverd/src/lib/relay-hooks.js +100 -0
- package/vendor/serverd/src/lib/relay-signing.js +43 -0
- package/vendor/serverd/src/lib/request-hooks.js +79 -0
- package/vendor/serverd/src/lib/retained-data.js +99 -0
- package/vendor/serverd/src/lib/trust-proxy.js +34 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
- package/vendor/serverd/src/middleware/twofa-session.js +11 -1
- package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
- package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
- package/vendor/serverd/src/routes/management/agents.js +262 -1
- package/vendor/serverd/src/routes/management/audit.js +81 -0
- package/vendor/serverd/src/routes/management/certs.js +1 -15
- package/vendor/serverd/src/routes/management/plugins.js +569 -54
- package/vendor/serverd/src/routes/management/storage.js +10 -2
- package/vendor/serverd/src/routes/management/tunnels.js +26 -0
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +0 -257
- 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>
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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')}
|
|
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>')}
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
121
|
-
* @param {
|
|
221
|
+
* Print a hosting record.
|
|
222
|
+
* @param {Record<string, unknown>} hosting
|
|
122
223
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
191
|
-
|
|
192
|
-
console.log(`\n Plugin ${chalk.cyan(name)} ${chalk.green('enabled')}.`);
|
|
898
|
+
emitComplete({ name, pendingUpdate: result.pendingUpdate });
|
|
899
|
+
return;
|
|
193
900
|
}
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
if (!name)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
236
|
-
if (!name)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
388
|
-
*
|
|
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) {
|