@lamalibre/create-lamaste 4.4.1 → 4.6.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 +234 -18
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- 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 +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- 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 +5 -5
- 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/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/02-guides/static-sites.md +4 -0
- 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 +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
- package/vendor/docs/04-api-reference/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +87 -47
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +24 -6
- package/vendor/docs/06-reference/config-files.md +100 -202
- 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 +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/index.d.ts +4 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +4 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +49 -56
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +156 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/sites.js +15 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +186 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.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 +34 -5
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +546 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +95 -7
- package/vendor/serverd/src/lib/registry-credential.js +9 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +36 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +754 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
- package/vendor/serverd/src/lib/web-surface.js +766 -0
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +2 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +109 -22
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +6 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `lamaste-server plugins hooks|update|approve-hooks`: why an update waits
|
|
3
|
-
* for approval,
|
|
3
|
+
* for approval, what an identity block lets a plugin learn and how an
|
|
4
|
+
* update changes it, the web tree a version brings and how an update
|
|
5
|
+
* changes it, the command that approves it, and the arguments that
|
|
4
6
|
* command takes. Pure — no output — so they are tested as they are.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { terminalText } from './plugin-settings.js';
|
|
10
|
+
import { bytesText, shortDigest } from './plugin-web-surface.js';
|
|
8
11
|
|
|
9
12
|
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
10
13
|
const VERSION_RE = /^[0-9A-Za-z.+-]{1,64}$/;
|
|
11
14
|
/** The reason an update turns secret settings into plain ones. */
|
|
12
15
|
const SETTINGS_SECRET_TO_PLAIN = 'settings.secret-to-plain';
|
|
16
|
+
/** The reason an update adds or removes roles, or widens the attributes its users' records carry. */
|
|
17
|
+
const IDENTITY_CHANGED = 'identity.changed';
|
|
13
18
|
|
|
14
19
|
/** How `approve-hooks` is called. */
|
|
15
20
|
export const APPROVE_HOOKS_USAGE =
|
|
@@ -18,12 +23,13 @@ export const APPROVE_HOOKS_USAGE =
|
|
|
18
23
|
/**
|
|
19
24
|
* Why a pending update waits, as the terminal shows it: each reason of
|
|
20
25
|
* `approvalRequired` (`[{ code, reason }]`; a secret turned plain is a
|
|
21
|
-
* warning
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* warning; an identity change carries a note that auto-approve never
|
|
27
|
+
* covers it), and the sentences of `autoApproveWithheld` that do not
|
|
28
|
+
* repeat one of them — what auto-approve, when on, could not cover beyond
|
|
29
|
+
* that. Every text as {@link terminalText}.
|
|
24
30
|
*
|
|
25
31
|
* @param {{ approvalRequired?: unknown, autoApproveWithheld?: unknown }} update
|
|
26
|
-
* @returns {{ required: Array<{ text: string, warning: boolean }>, notCovered: string[] }}
|
|
32
|
+
* @returns {{ required: Array<{ text: string, warning: boolean, note: string | null }>, notCovered: string[] }}
|
|
27
33
|
*/
|
|
28
34
|
export function pendingUpdateReasons(update) {
|
|
29
35
|
const required = (Array.isArray(update?.approvalRequired) ? update.approvalRequired : [])
|
|
@@ -31,6 +37,10 @@ export function pendingUpdateReasons(update) {
|
|
|
31
37
|
.map((entry) => ({
|
|
32
38
|
text: terminalText(entry.reason),
|
|
33
39
|
warning: entry.code === SETTINGS_SECRET_TO_PLAIN,
|
|
40
|
+
note:
|
|
41
|
+
entry.code === IDENTITY_CHANGED
|
|
42
|
+
? 'who its users are and what it learns about them — auto-approve never covers it'
|
|
43
|
+
: null,
|
|
34
44
|
}));
|
|
35
45
|
const said = new Set(required.map((line) => line.text));
|
|
36
46
|
const notCovered = (Array.isArray(update?.autoApproveWithheld) ? update.autoApproveWithheld : [])
|
|
@@ -39,6 +49,177 @@ export function pendingUpdateReasons(update) {
|
|
|
39
49
|
return { required, notCovered };
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
/** What a plugin may declare to receive about its users, in words. */
|
|
53
|
+
const ATTRIBUTE_WORDS = { displayName: 'display name', email: 'email address' };
|
|
54
|
+
|
|
55
|
+
/** An identity attribute in words (`displayName` → display name). */
|
|
56
|
+
function attributeWords(attribute) {
|
|
57
|
+
return ATTRIBUTE_WORDS[attribute] ?? terminalText(attribute);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* An identity block (normalized: `{ roles: [{ id, label, description? }],
|
|
62
|
+
* attributes }`) as two lines — its roles, and what it receives about each
|
|
63
|
+
* member. Empty for anything that is not a block. Every line as
|
|
64
|
+
* {@link terminalText}.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} block
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
export function identityBlockLines(block) {
|
|
70
|
+
if (block === null || typeof block !== 'object' || !Array.isArray(block.roles)) return [];
|
|
71
|
+
const roles = block.roles
|
|
72
|
+
.filter((role) => role !== null && typeof role === 'object')
|
|
73
|
+
.map((role) => `${role.id} (${role.label}${role.description ? ` — ${role.description}` : ''})`);
|
|
74
|
+
const attributes = Array.isArray(block.attributes) ? block.attributes.map(attributeWords) : [];
|
|
75
|
+
return [
|
|
76
|
+
terminalText(`roles: ${roles.length > 0 ? roles.join(', ') : 'none'}`),
|
|
77
|
+
terminalText(
|
|
78
|
+
`receives: ${['user id', 'username', 'roles', 'status', ...attributes].join(', ')} — of the users entitled to it`,
|
|
79
|
+
),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How an update changes a plugin's identity block (`pendingUpdate.
|
|
85
|
+
* identity`: `{ before, after, rolesAdded, rolesRemoved, attributesAdded,
|
|
86
|
+
* attributesRemoved, labelsChanged, grantsAffected? }` — `grantsAffected`:
|
|
87
|
+
* how many role grants approving it rewrites or revokes, null when unknown):
|
|
88
|
+
* a headline and a line per kind of
|
|
89
|
+
* change — roles added or removed and attributes widened wait for an
|
|
90
|
+
* administrator (`approval`), narrowing and label edits only change the
|
|
91
|
+
* digest. Null when neither version declares a block, or it is unchanged.
|
|
92
|
+
* Every text as {@link terminalText}.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} diff
|
|
95
|
+
* @returns {{ headline: string, lines: Array<{ text: string, approval: boolean }> } | null}
|
|
96
|
+
*/
|
|
97
|
+
export function identityDiffLines(diff) {
|
|
98
|
+
if (diff === null || typeof diff !== 'object') return null;
|
|
99
|
+
const list = (value) => (Array.isArray(value) ? value.map(String) : []);
|
|
100
|
+
const labelOf = (block, id) =>
|
|
101
|
+
Array.isArray(block?.roles) ? (block.roles.find((r) => r?.id === id)?.label ?? null) : null;
|
|
102
|
+
const role = (block) => (id) => {
|
|
103
|
+
const label = labelOf(block, id);
|
|
104
|
+
return label ? `${id} (${label})` : id;
|
|
105
|
+
};
|
|
106
|
+
const lines = [];
|
|
107
|
+
const add = (label, entries, approval) => {
|
|
108
|
+
if (entries.length === 0) return;
|
|
109
|
+
lines.push({
|
|
110
|
+
text: terminalText(
|
|
111
|
+
`${label}: ${entries.join(', ')}${approval ? ' — waits for approval' : ''}`,
|
|
112
|
+
),
|
|
113
|
+
approval,
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
add('roles added', list(diff.rolesAdded).map(role(diff.after)), true);
|
|
117
|
+
add('roles removed', list(diff.rolesRemoved).map(role(diff.before)), true);
|
|
118
|
+
add('receives more about its users', list(diff.attributesAdded).map(attributeWords), true);
|
|
119
|
+
add('receives less about its users', list(diff.attributesRemoved).map(attributeWords), false);
|
|
120
|
+
add('role labels changed', list(diff.labelsChanged).map(role(diff.after)), false);
|
|
121
|
+
// A removed role stays removed: approving revokes it from the grants that hold it.
|
|
122
|
+
if (list(diff.rolesRemoved).length > 0 && 'grantsAffected' in diff) {
|
|
123
|
+
const affected = diff.grantsAffected;
|
|
124
|
+
const parts =
|
|
125
|
+
affected === null || typeof affected !== 'object'
|
|
126
|
+
? ['not known (Gatekeeper could not be read)']
|
|
127
|
+
: [
|
|
128
|
+
...(affected.revoked > 0 ? [`${affected.revoked} revoked`] : []),
|
|
129
|
+
...(affected.rewritten > 0
|
|
130
|
+
? [`${affected.rewritten} narrowed to the roles that stay`]
|
|
131
|
+
: []),
|
|
132
|
+
];
|
|
133
|
+
add('role grants affected', parts.length > 0 ? parts : ['none'], true);
|
|
134
|
+
}
|
|
135
|
+
const before = diff.before != null;
|
|
136
|
+
const after = diff.after != null;
|
|
137
|
+
if (lines.length === 0 && before === after) return null;
|
|
138
|
+
const headline = !before
|
|
139
|
+
? 'declares identity for the first time'
|
|
140
|
+
: !after
|
|
141
|
+
? 'no longer declares identity — it is sent no users, its role grants are revoked and its user surface is no longer served'
|
|
142
|
+
: 'changes its identity';
|
|
143
|
+
return { headline, lines };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What the installed version's web surface brings (the hook status's
|
|
148
|
+
* `web`: `{ declared, approved, treeSha256, surface }`) as lines — the tree
|
|
149
|
+
* the approval covers, and the site Lamaste offers for it. Empty when it
|
|
150
|
+
* declares none.
|
|
151
|
+
*
|
|
152
|
+
* @param {unknown} web
|
|
153
|
+
* @returns {string[]}
|
|
154
|
+
*/
|
|
155
|
+
export function webSurfaceBlockLines(web) {
|
|
156
|
+
if (web === null || typeof web !== 'object' || !web.declared) return [];
|
|
157
|
+
const surface = web.surface !== null && typeof web.surface === 'object' ? web.surface : null;
|
|
158
|
+
const lines = [terminalText(`tree: ${web.treeSha256 ?? 'unknown'}`)];
|
|
159
|
+
if (surface) {
|
|
160
|
+
lines.push(
|
|
161
|
+
terminalText(
|
|
162
|
+
`site: offered on ${surface.defaultSubdomain} (${surface.accessMode}), the shell from ${surface.root}/ of its server package`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* How an update changes the plugin's web tree (`pendingUpdate.web`:
|
|
171
|
+
* `{ before: sha256 | null, after: { sha256, files, totalBytes } | null }`
|
|
172
|
+
* — part of the digest): a headline and its lines. Null when neither
|
|
173
|
+
* version declares a web surface.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} web
|
|
176
|
+
* @returns {{ headline: string, lines: string[], changed: boolean } | null}
|
|
177
|
+
*/
|
|
178
|
+
export function webTreeDiffLines(web) {
|
|
179
|
+
if (web === null || typeof web !== 'object') return null;
|
|
180
|
+
const before = typeof web.before === 'string' ? web.before : null;
|
|
181
|
+
const after =
|
|
182
|
+
web.after !== null && typeof web.after === 'object' && typeof web.after.sha256 === 'string'
|
|
183
|
+
? web.after
|
|
184
|
+
: null;
|
|
185
|
+
if (before === null && after === null) return null;
|
|
186
|
+
const contents = (tree) => `${tree.files} file(s), ${bytesText(tree.totalBytes)}`;
|
|
187
|
+
if (after === null) {
|
|
188
|
+
return {
|
|
189
|
+
headline:
|
|
190
|
+
'no longer declares a web surface — its site answers 503 until a version that does is approved',
|
|
191
|
+
lines: [terminalText(`tree: ${shortDigest(before)} → none`)],
|
|
192
|
+
changed: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (before === null) {
|
|
196
|
+
return {
|
|
197
|
+
headline: 'declares a web surface for the first time',
|
|
198
|
+
lines: [
|
|
199
|
+
terminalText(`tree: ${after.sha256}`),
|
|
200
|
+
terminalText(`contents: ${contents(after)}`),
|
|
201
|
+
],
|
|
202
|
+
changed: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (before === after.sha256) {
|
|
206
|
+
return {
|
|
207
|
+
headline: 'keeps its web tree',
|
|
208
|
+
lines: [terminalText(`tree: ${after.sha256} (${contents(after)})`)],
|
|
209
|
+
changed: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
headline: 'changes its web tree — its site serves the new shell once approved',
|
|
214
|
+
lines: [
|
|
215
|
+
terminalText(`tree: ${shortDigest(before)} → ${shortDigest(after.sha256)}`),
|
|
216
|
+
terminalText(`new tree: ${after.sha256}`),
|
|
217
|
+
terminalText(`contents: ${contents(after)}`),
|
|
218
|
+
],
|
|
219
|
+
changed: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
42
223
|
/**
|
|
43
224
|
* The command that approves what plugin `name` brings: the installed
|
|
44
225
|
* version by its digest (`{ digest }`), or a pending update by its version
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Users and identity in requests at the terminal — the arguments of
|
|
3
|
+
* `lamaste-server users …`, `plugins entitlements|identity-sync` and
|
|
4
|
+
* `tunnels bind-user-surface|unbind-user-surface`, and the words their
|
|
5
|
+
* output is made of. Pure — no output, no panel — so they are tested as
|
|
6
|
+
* they are. Every string from the panel goes through `terminalText` before
|
|
7
|
+
* it is printed; `--json` carries the data as it came.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
IDENTITY_ROLE_ID_REGEX,
|
|
12
|
+
IDENTITY_ROLES_MAX,
|
|
13
|
+
isIdentityUuid,
|
|
14
|
+
isLamasteUsername,
|
|
15
|
+
} from '@lamalibre/lamaste';
|
|
16
|
+
import { formatMoment, terminalText } from './plugin-settings.js';
|
|
17
|
+
|
|
18
|
+
/** A Lamaste (Gatekeeper) group name — Gatekeeper's rule. */
|
|
19
|
+
const GROUP_NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Arguments
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** How `plugins entitlements` is called. */
|
|
26
|
+
export const ENTITLEMENTS_USAGE =
|
|
27
|
+
'Usage: lamaste-server plugins entitlements <name> [--user <username> | --group <group> --roles <a,b>] [--revoke --user <username> | --group <group>]';
|
|
28
|
+
|
|
29
|
+
/** The value after `flag` (at `args[index]`): `{ value }`, or `{ error }` when it has none. */
|
|
30
|
+
function flagValue(args, index, flag, usage) {
|
|
31
|
+
const value = args[index + 1];
|
|
32
|
+
if (value === undefined || value.startsWith('--')) {
|
|
33
|
+
return { error: `${flag} needs a value — ${usage}` };
|
|
34
|
+
}
|
|
35
|
+
return { value };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The arguments of `plugins entitlements <name> …` after the subcommand:
|
|
40
|
+
* the plugin and, to change something, a principal — `--user <username>`
|
|
41
|
+
* or `--group <group>` — with `--roles a,b` (give exactly these roles) or
|
|
42
|
+
* `--revoke` (take every role away). Without a principal it lists them.
|
|
43
|
+
*
|
|
44
|
+
* @param {string[]} args
|
|
45
|
+
* @returns {{ ok: true, name: string, action: 'list' }
|
|
46
|
+
* | { ok: true, name: string, action: 'set', principalType: 'user' | 'group', principalId: string, roles: string[] }
|
|
47
|
+
* | { ok: true, name: string, action: 'revoke', principalType: 'user' | 'group', principalId: string }
|
|
48
|
+
* | { ok: false, error: string }}
|
|
49
|
+
*/
|
|
50
|
+
export function parseEntitlementsArgs(args) {
|
|
51
|
+
const usage = ENTITLEMENTS_USAGE;
|
|
52
|
+
let name = null;
|
|
53
|
+
let principal = null;
|
|
54
|
+
let roles = null;
|
|
55
|
+
let revoke = false;
|
|
56
|
+
for (let i = 0; i < args.length; i++) {
|
|
57
|
+
const arg = args[i];
|
|
58
|
+
if (arg === '--user' || arg === '--group') {
|
|
59
|
+
const read = flagValue(args, i, arg, usage);
|
|
60
|
+
if (read.error) return { ok: false, error: read.error };
|
|
61
|
+
if (principal !== null) {
|
|
62
|
+
return { ok: false, error: `Name one principal: --user or --group, once — ${usage}` };
|
|
63
|
+
}
|
|
64
|
+
principal = { principalType: arg === '--user' ? 'user' : 'group', principalId: read.value };
|
|
65
|
+
i++;
|
|
66
|
+
} else if (arg === '--roles') {
|
|
67
|
+
const read = flagValue(args, i, arg, usage);
|
|
68
|
+
if (read.error) return { ok: false, error: read.error };
|
|
69
|
+
roles = read.value.split(',').map((role) => role.trim());
|
|
70
|
+
i++;
|
|
71
|
+
} else if (arg === '--revoke') {
|
|
72
|
+
revoke = true;
|
|
73
|
+
} else if (arg.startsWith('--')) {
|
|
74
|
+
return { ok: false, error: `Unknown option ${arg} — ${usage}` };
|
|
75
|
+
} else if (name === null) {
|
|
76
|
+
name = arg;
|
|
77
|
+
} else {
|
|
78
|
+
return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (name === null) return { ok: false, error: usage };
|
|
82
|
+
if (principal === null) {
|
|
83
|
+
if (roles !== null || revoke) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `Name whose roles: --user <username> or --group <group> — ${usage}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, name, action: 'list' };
|
|
90
|
+
}
|
|
91
|
+
if (principal.principalType === 'user' && !isLamasteUsername(principal.principalId)) {
|
|
92
|
+
return { ok: false, error: `"${principal.principalId}" is not a username` };
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
principal.principalType === 'group' &&
|
|
96
|
+
!(
|
|
97
|
+
principal.principalId.length >= 2 &&
|
|
98
|
+
principal.principalId.length <= 63 &&
|
|
99
|
+
GROUP_NAME_RE.test(principal.principalId)
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
return { ok: false, error: `"${principal.principalId}" is not a group name` };
|
|
103
|
+
}
|
|
104
|
+
if (revoke) {
|
|
105
|
+
if (roles !== null) {
|
|
106
|
+
return { ok: false, error: `--revoke takes every role away; it takes no --roles — ${usage}` };
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, name, action: 'revoke', ...principal };
|
|
109
|
+
}
|
|
110
|
+
if (roles === null) {
|
|
111
|
+
return { ok: false, error: `Give --roles <a,b>, or --revoke — ${usage}` };
|
|
112
|
+
}
|
|
113
|
+
const unique = [...new Set(roles)].sort();
|
|
114
|
+
if (unique.length === 0 || unique.length > IDENTITY_ROLES_MAX || unique.includes('')) {
|
|
115
|
+
return { ok: false, error: `--roles is a comma-separated list of role ids — ${usage}` };
|
|
116
|
+
}
|
|
117
|
+
const bad = unique.find((role) => !IDENTITY_ROLE_ID_REGEX.test(role));
|
|
118
|
+
if (bad !== undefined) return { ok: false, error: `"${bad}" is not a role id` };
|
|
119
|
+
return { ok: true, name, action: 'set', ...principal, roles: unique };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The arguments of `plugins identity-sync <name> [--reconcile]`.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} args
|
|
126
|
+
* @returns {{ ok: true, name: string, reconcile: boolean } | { ok: false, error: string }}
|
|
127
|
+
*/
|
|
128
|
+
export function parseIdentitySyncArgs(args) {
|
|
129
|
+
const usage = 'Usage: lamaste-server plugins identity-sync <name> [--reconcile]';
|
|
130
|
+
let name = null;
|
|
131
|
+
let reconcile = false;
|
|
132
|
+
for (const arg of args) {
|
|
133
|
+
if (arg === '--reconcile') reconcile = true;
|
|
134
|
+
else if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg} — ${usage}` };
|
|
135
|
+
else if (name === null) name = arg;
|
|
136
|
+
else return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
|
|
137
|
+
}
|
|
138
|
+
if (name === null) return { ok: false, error: usage };
|
|
139
|
+
return { ok: true, name, reconcile };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The arguments of `users disable|enable|delete <username>` (`--erase` for
|
|
144
|
+
* delete) and `users erase <userId>`.
|
|
145
|
+
*
|
|
146
|
+
* @param {'disable' | 'enable' | 'delete' | 'erase'} sub
|
|
147
|
+
* @param {string[]} args what follows the subcommand
|
|
148
|
+
* @returns {{ ok: true, username: string, erase: boolean } | { ok: true, userId: string } | { ok: false, error: string }}
|
|
149
|
+
*/
|
|
150
|
+
export function parseUserArgs(sub, args) {
|
|
151
|
+
const usage =
|
|
152
|
+
sub === 'erase'
|
|
153
|
+
? 'Usage: lamaste-server users erase <userId> (a deleted user, as `users directory` lists them)'
|
|
154
|
+
: sub === 'delete'
|
|
155
|
+
? 'Usage: lamaste-server users delete <username> [--erase]'
|
|
156
|
+
: `Usage: lamaste-server users ${sub} <username>`;
|
|
157
|
+
let target = null;
|
|
158
|
+
let erase = false;
|
|
159
|
+
for (const arg of args) {
|
|
160
|
+
if (arg === '--erase' && sub === 'delete') erase = true;
|
|
161
|
+
else if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg} — ${usage}` };
|
|
162
|
+
else if (target === null) target = arg;
|
|
163
|
+
else return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
|
|
164
|
+
}
|
|
165
|
+
if (target === null) return { ok: false, error: usage };
|
|
166
|
+
if (sub === 'erase') {
|
|
167
|
+
if (!isIdentityUuid(target)) {
|
|
168
|
+
return { ok: false, error: `"${target}" is not a user id — ${usage}` };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, userId: target };
|
|
171
|
+
}
|
|
172
|
+
if (!isLamasteUsername(target)) return { ok: false, error: `"${target}" is not a username` };
|
|
173
|
+
return { ok: true, username: target, erase };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The arguments of `tunnels bind-user-surface|unbind-user-surface <tunnel>
|
|
178
|
+
* <plugin>`: the tunnel (its id, subdomain or FQDN) and the plugin (its name
|
|
179
|
+
* or route).
|
|
180
|
+
*
|
|
181
|
+
* @param {'bind-user-surface' | 'unbind-user-surface'} sub
|
|
182
|
+
* @param {string[]} args
|
|
183
|
+
* @returns {{ ok: true, tunnel: string, plugin: string } | { ok: false, error: string }}
|
|
184
|
+
*/
|
|
185
|
+
export function parseUserSurfaceArgs(sub, args) {
|
|
186
|
+
const usage = `Usage: lamaste-server tunnels ${sub} <tunnel id | subdomain | fqdn> <plugin>`;
|
|
187
|
+
const positional = [];
|
|
188
|
+
for (const arg of args) {
|
|
189
|
+
if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg} — ${usage}` };
|
|
190
|
+
positional.push(arg);
|
|
191
|
+
}
|
|
192
|
+
if (positional.length !== 2) return { ok: false, error: usage };
|
|
193
|
+
return { ok: true, tunnel: positional[0], plugin: positional[1] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The tunnel `ref` names among `tunnels` (as `tunnels.json` holds them): by
|
|
198
|
+
* id, subdomain or FQDN — a host, never an internal plugin tunnel.
|
|
199
|
+
*
|
|
200
|
+
* @param {Array<Record<string, unknown>>} tunnels
|
|
201
|
+
* @param {string} ref
|
|
202
|
+
* @returns {{ tunnel: Record<string, unknown> } | { error: string }}
|
|
203
|
+
*/
|
|
204
|
+
export function findHostTunnel(tunnels, ref) {
|
|
205
|
+
const hosts = tunnels.filter((t) => typeof t.subdomain === 'string' && t.subdomain.length > 0);
|
|
206
|
+
const tunnel =
|
|
207
|
+
hosts.find((t) => t.id === ref) ??
|
|
208
|
+
hosts.find((t) => t.fqdn === ref) ??
|
|
209
|
+
hosts.find((t) => t.subdomain === ref);
|
|
210
|
+
if (!tunnel) return { error: `No tunnel with the id, subdomain or FQDN "${ref}"` };
|
|
211
|
+
return { tunnel };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The installed agent-hosted plugin `ref` names (its name or route) among
|
|
216
|
+
* the panel's plugin list.
|
|
217
|
+
*
|
|
218
|
+
* @param {Array<Record<string, unknown>>} plugins
|
|
219
|
+
* @param {string} ref
|
|
220
|
+
* @returns {{ plugin: Record<string, unknown> } | { error: string }}
|
|
221
|
+
*/
|
|
222
|
+
export function findPluginByNameOrRoute(plugins, ref) {
|
|
223
|
+
const plugin = plugins.find((p) => p.name === ref) ?? plugins.find((p) => p.route === ref);
|
|
224
|
+
if (!plugin) return { error: `No installed plugin named "${ref}" or holding that route` };
|
|
225
|
+
if (typeof plugin.route !== 'string' || plugin.route.length === 0) {
|
|
226
|
+
return { error: `Plugin "${ref}" has no route` };
|
|
227
|
+
}
|
|
228
|
+
return { plugin };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Output
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Why users cannot reach a plugin's `/app`, or that they can — the identity
|
|
237
|
+
* view's `readiness` in words.
|
|
238
|
+
*
|
|
239
|
+
* @param {{ ready?: boolean, reason?: string | null, version?: string | null } | null | undefined} readiness
|
|
240
|
+
* @returns {{ ready: boolean, text: string }}
|
|
241
|
+
*/
|
|
242
|
+
export function readinessText(readiness) {
|
|
243
|
+
if (!readiness) return { ready: false, text: 'unknown' };
|
|
244
|
+
if (readiness.ready) return { ready: true, text: 'ready — users reach its application' };
|
|
245
|
+
const version = readiness.version ? `version ${terminalText(readiness.version)}` : 'it';
|
|
246
|
+
switch (readiness.reason) {
|
|
247
|
+
case 'not_installed':
|
|
248
|
+
return { ready: false, text: 'not installed' };
|
|
249
|
+
case 'no_identity':
|
|
250
|
+
return { ready: false, text: `${version} declares no identity` };
|
|
251
|
+
case 'not_approved':
|
|
252
|
+
return { ready: false, text: `${version} waits for approval (plugins hooks <name>)` };
|
|
253
|
+
case 'not_hosted':
|
|
254
|
+
return { ready: false, text: 'not hosted on an agent, or its hosting is not active' };
|
|
255
|
+
case 'not_answered':
|
|
256
|
+
return {
|
|
257
|
+
ready: false,
|
|
258
|
+
text: `its agent half has not answered the identity feed for ${version} yet`,
|
|
259
|
+
};
|
|
260
|
+
default:
|
|
261
|
+
return { ready: false, text: terminalText(readiness.reason ?? 'not ready') };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Why a route bound to a tunnel is not served there (`userSurfaceStatus`'s reason), in words. */
|
|
266
|
+
export function userSurfaceStatusText(status) {
|
|
267
|
+
if (!status) return 'status unknown';
|
|
268
|
+
if (status.served) return 'served';
|
|
269
|
+
switch (status.reason) {
|
|
270
|
+
case 'not_installed':
|
|
271
|
+
return 'not served — no installed plugin holds the route';
|
|
272
|
+
case 'no_identity':
|
|
273
|
+
return 'not served yet — its installed version declares no identity (requests go down the tunnel)';
|
|
274
|
+
case 'not_approved':
|
|
275
|
+
return 'not served yet — its installed version waits for approval (requests go down the tunnel)';
|
|
276
|
+
case 'not_hosting_agent':
|
|
277
|
+
return 'not served — the agent carrying this tunnel does not host the plugin (moved, revoked, or hosted elsewhere); requests go down the tunnel';
|
|
278
|
+
default:
|
|
279
|
+
return `not served (${terminalText(status.reason)})`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Why a user left an audience, as the terminal says it. */
|
|
284
|
+
const REMOVED_STATUS = new Map([
|
|
285
|
+
['unentitled', 'removed (no longer entitled)'],
|
|
286
|
+
['deleted', 'removed (deleted)'],
|
|
287
|
+
['erased', 'erased'],
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* An audience row (`{ userId, seq, version, updatedAt, change }`) as the
|
|
292
|
+
* terminal lists it: who (the member's, or the last known, name), the roles
|
|
293
|
+
* held and where they stand.
|
|
294
|
+
*
|
|
295
|
+
* @param {{ userId: string, seq: number, change: Record<string, any> | null }} row
|
|
296
|
+
* @returns {{ userId: string, seq: number, who: string, roles: string, status: string, member: boolean }}
|
|
297
|
+
*/
|
|
298
|
+
export function audienceRowText(row) {
|
|
299
|
+
const change = row.change ?? {};
|
|
300
|
+
const name = (user) =>
|
|
301
|
+
user?.displayName && user?.username
|
|
302
|
+
? `${user.displayName} (${user.username})`
|
|
303
|
+
: (user?.username ?? '—');
|
|
304
|
+
// A row the panel cannot read.
|
|
305
|
+
const text = {
|
|
306
|
+
userId: terminalText(row.userId),
|
|
307
|
+
seq: row.seq,
|
|
308
|
+
who: '—',
|
|
309
|
+
roles: '',
|
|
310
|
+
status: 'unreadable',
|
|
311
|
+
member: false,
|
|
312
|
+
};
|
|
313
|
+
if (change.kind === 'upsert' && change.member) {
|
|
314
|
+
return {
|
|
315
|
+
...text,
|
|
316
|
+
who: terminalText(name(change.member)),
|
|
317
|
+
roles: terminalText((change.member.roles ?? []).join(', ')),
|
|
318
|
+
status: change.member.status === 'disabled' ? 'disabled' : 'active',
|
|
319
|
+
member: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (change.kind === 'remove') {
|
|
323
|
+
return {
|
|
324
|
+
...text,
|
|
325
|
+
who: change.reason === 'erased' ? '—' : terminalText(name(change.last)),
|
|
326
|
+
status: REMOVED_STATUS.get(change.reason) ?? 'removed',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return text;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The feed's state (the identity view's `sync`) as `[label, value]` rows:
|
|
334
|
+
* the directory, what the agent half follows, its cursor against the head,
|
|
335
|
+
* the version that answered, the last sync and reconcile with what it
|
|
336
|
+
* found, the last error.
|
|
337
|
+
*
|
|
338
|
+
* @param {Record<string, any>} sync
|
|
339
|
+
* @param {number} now epoch ms
|
|
340
|
+
* @returns {Array<[string, string]>}
|
|
341
|
+
*/
|
|
342
|
+
export function syncRows(sync, now) {
|
|
343
|
+
const follows =
|
|
344
|
+
sync.halfDirectoryId === null || sync.halfDirectoryId === undefined
|
|
345
|
+
? 'nothing yet'
|
|
346
|
+
: sync.halfDirectoryId === sync.directoryId
|
|
347
|
+
? 'this directory'
|
|
348
|
+
: `another directory (${terminalText(sync.halfDirectoryId)}) — the next sync replays`;
|
|
349
|
+
const rows = [
|
|
350
|
+
['directory', terminalText(sync.directoryId)],
|
|
351
|
+
['half follows', follows],
|
|
352
|
+
[
|
|
353
|
+
'position',
|
|
354
|
+
`cursor ${sync.cursor ?? '—'} of head ${sync.head}${sync.behind > 0 ? ` — ${sync.behind} change(s) behind` : ' — up to date'}`,
|
|
355
|
+
],
|
|
356
|
+
['answered for', sync.readyVersion ? `version ${terminalText(sync.readyVersion)}` : '—'],
|
|
357
|
+
['last sync', sync.lastSyncAt ? formatMoment(sync.lastSyncAt, now) : '—'],
|
|
358
|
+
[
|
|
359
|
+
'last reconcile',
|
|
360
|
+
sync.lastReconcileAt
|
|
361
|
+
? `${formatMoment(sync.lastReconcileAt, now)}${sync.lastMismatches != null ? ` — ${sync.lastMismatches} out of step` : ''}`
|
|
362
|
+
: '—',
|
|
363
|
+
],
|
|
364
|
+
];
|
|
365
|
+
if (sync.lastError) rows.push(['last error', terminalText(sync.lastError)]);
|
|
366
|
+
return rows;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A warning a users or entitlement change came back with — a sentence, or
|
|
371
|
+
* `{ step, message }` — as one line.
|
|
372
|
+
*
|
|
373
|
+
* @param {unknown} warning
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
export function warningLine(warning) {
|
|
377
|
+
if (typeof warning === 'string') return terminalText(warning);
|
|
378
|
+
if (warning !== null && typeof warning === 'object' && typeof warning.message === 'string') {
|
|
379
|
+
return terminalText(warning.step ? `${warning.step}: ${warning.message}` : warning.message);
|
|
380
|
+
}
|
|
381
|
+
return terminalText(warning);
|
|
382
|
+
}
|