sitevision-cli 1.0.0-beta.9 → 1.0.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/CHANGELOG.md +138 -0
- package/LICENSE +21 -0
- package/dist/cli.js +194 -70
- package/dist/commands/TaskScreen.js +32 -0
- package/dist/commands/build.js +6 -185
- package/dist/commands/deploy.js +95 -212
- package/dist/commands/dev.js +87 -416
- package/dist/commands/info.js +1 -1
- package/dist/commands/setup-signing.js +50 -33
- package/dist/commands/sign.js +9 -86
- package/dist/commands/use-finish.js +17 -0
- package/dist/commands/watch.js +5 -31
- package/dist/components/AnimatedLogo.js +8 -2
- package/dist/components/AuthLoginScreen.js +33 -15
- package/dist/components/PasswordInput.js +2 -1
- package/dist/scaffold/inquirer-stub.js +119 -0
- package/dist/scaffold/register.js +15 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/Changelog.js +44 -0
- package/dist/shell/CommandPalette.js +63 -0
- package/dist/shell/ConfigForm.js +703 -0
- package/dist/shell/Frame.js +138 -0
- package/dist/shell/Help.js +96 -0
- package/dist/shell/Popover.js +15 -0
- package/dist/shell/Settings.js +149 -0
- package/dist/shell/Shell.js +953 -0
- package/dist/shell/Tabs.js +96 -0
- package/dist/shell/actions.js +493 -0
- package/dist/utils/config.js +62 -2
- package/dist/utils/create-app.js +231 -0
- package/dist/utils/debug.js +32 -0
- package/dist/utils/environments.js +74 -0
- package/dist/utils/i18n.js +369 -0
- package/dist/utils/jsonc.js +2 -1
- package/dist/utils/keychain.js +54 -6
- package/dist/utils/oauth2-auth.js +146 -43
- package/dist/utils/password-prompt.js +37 -3
- package/dist/utils/process-runner.js +77 -38
- package/dist/utils/project-detection.js +457 -112
- package/dist/utils/session-cookie-auth.js +10 -6
- package/dist/utils/sitevision-api.js +134 -25
- package/dist/utils/sitevision-scripts-runner.js +50 -22
- package/dist/utils/tasks.js +510 -0
- package/dist/utils/version-check.js +3 -3
- package/dist/utils/webpack-runner.js +9 -5
- package/dist/utils/workspace.js +105 -0
- package/dist/utils/zip.js +18 -19
- package/package.json +27 -4
- package/readme.md +113 -141
- package/dist/app.d.ts +0 -6
- package/dist/app.js +0 -307
- package/dist/cli.d.ts +0 -2
- package/dist/commands/build.d.ts +0 -12
- package/dist/commands/deploy.d.ts +0 -16
- package/dist/commands/dev.d.ts +0 -16
- package/dist/commands/index.d.ts +0 -4
- package/dist/commands/info.d.ts +0 -2
- package/dist/commands/setup-signing.d.ts +0 -2
- package/dist/commands/sign.d.ts +0 -14
- package/dist/commands/types.d.ts +0 -18
- package/dist/commands/watch.d.ts +0 -2
- package/dist/components/AnimatedLogo.d.ts +0 -10
- package/dist/components/AuthLoginScreen.d.ts +0 -21
- package/dist/components/DevPropertiesForm.d.ts +0 -10
- package/dist/components/DevPropertiesForm.js +0 -210
- package/dist/components/InfoScreen.d.ts +0 -7
- package/dist/components/InfoScreen.js +0 -14
- package/dist/components/KeychainPasswordChoice.d.ts +0 -8
- package/dist/components/KeychainPasswordChoice.js +0 -30
- package/dist/components/MainMenu.d.ts +0 -7
- package/dist/components/MainMenu.js +0 -83
- package/dist/components/PasswordInput.d.ts +0 -10
- package/dist/components/ProcessOutput.d.ts +0 -6
- package/dist/components/ProcessOutput.js +0 -30
- package/dist/components/SetupFlow.d.ts +0 -8
- package/dist/components/SetupFlow.js +0 -233
- package/dist/components/SigningPropertiesForm.d.ts +0 -7
- package/dist/components/SigningPropertiesForm.js +0 -38
- package/dist/components/StatusIndicator.d.ts +0 -8
- package/dist/components/TextInput.d.ts +0 -10
- package/dist/components/TextInput.js +0 -29
- package/dist/components/WelcomeScreen.d.ts +0 -14
- package/dist/types/index.d.ts +0 -284
- package/dist/utils/branding.d.ts +0 -28
- package/dist/utils/config.d.ts +0 -17
- package/dist/utils/jsonc.d.ts +0 -19
- package/dist/utils/keychain.d.ts +0 -15
- package/dist/utils/oauth2-auth.d.ts +0 -27
- package/dist/utils/password-prompt.d.ts +0 -14
- package/dist/utils/process-runner.d.ts +0 -22
- package/dist/utils/project-detection.d.ts +0 -151
- package/dist/utils/session-cookie-auth.d.ts +0 -34
- package/dist/utils/signing-password.d.ts +0 -12
- package/dist/utils/signing-step.d.ts +0 -15
- package/dist/utils/signing-step.js +0 -16
- package/dist/utils/sitevision-api.d.ts +0 -112
- package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
- package/dist/utils/version-check.d.ts +0 -9
- package/dist/utils/webpack-runner.d.ts +0 -76
- package/dist/utils/zip.d.ts +0 -71
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { addonNameDrift, findDevPropertiesPath, getPackageJsonSyncChanges, hasPackageJson, IMPLICIT_VALUES, normalizeDomain, readAncestorDevProperties, readInheritedDevProperties, readWorkspaceDevProperties, updatePackageJson, writeDevProperties, writeManifestField, } from '../utils/project-detection.js';
|
|
5
|
+
import { setDeployPassword, deleteDeployPassword, getOAuth2ClientSecret, setOAuth2ClientSecret, deleteOAuth2ClientSecret, getSigningPassword, setSigningPassword, deleteSigningPassword, } from '../utils/keychain.js';
|
|
6
|
+
import { DEFAULT_SCOPES, discoverOAuth2Config } from '../utils/oauth2-auth.js';
|
|
7
|
+
import { ACCENT } from './Frame.js';
|
|
8
|
+
import { baseEnvironment, withEnvironmentOverride, } from '../utils/environments.js';
|
|
9
|
+
import { t } from '../utils/i18n.js';
|
|
10
|
+
import { getGlobalSigning, getSettings } from '../utils/config.js';
|
|
11
|
+
const METHODS = ['basic', 'oauth2', 'cookie'];
|
|
12
|
+
const FIELDS = [
|
|
13
|
+
{
|
|
14
|
+
key: 'domain',
|
|
15
|
+
help: "Domain of this environment's site (USE or TSE) without https://, e.g. myorg-use.sitevision-cloud.se. Deploys and version lookups go here.",
|
|
16
|
+
label: 'Domain',
|
|
17
|
+
required: true,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: 'siteName',
|
|
21
|
+
help: "Name of the site's root node in Sitevision, exactly as shown in the site tree. It becomes part of the REST API path.",
|
|
22
|
+
label: 'Site name',
|
|
23
|
+
required: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'addonName',
|
|
27
|
+
help: "Name of the addon (custom module) in the site's Addon Repository that this app is uploaded into. Ctrl+O lists the existing ones. It is separate from the manifest name: to make them match, rename the addon in Sitevision (Addons, General, Settings) and pick it again with Ctrl+O. For a RESTApp the addon name is part of its endpoint URL.",
|
|
28
|
+
label: 'Addon name',
|
|
29
|
+
required: true,
|
|
30
|
+
hint: '^O pick from repo',
|
|
31
|
+
perApp: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: 'username',
|
|
35
|
+
help: 'Sitevision account used for deploys, usually your Sitevision Cloud e-mail. It needs DEVELOPER or MANAGE_ADDONS permission on the site. Required for basic auth; with oauth2 or cookie it only labels the stored credential.',
|
|
36
|
+
label: 'Username',
|
|
37
|
+
required: 'basic',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'authMethod',
|
|
41
|
+
help: "How deploys authenticate: basic = username and password; oauth2 = bearer token from the site's OAuth2 provider (PKCE, opens a browser); cookie = reuse a browser SSO/SAML session.",
|
|
42
|
+
label: 'Auth method',
|
|
43
|
+
kind: 'method',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: 'password',
|
|
47
|
+
help: 'Deploy password for the account above. Stored in the OS keychain, never in a file. Leave empty to be asked on each run.',
|
|
48
|
+
label: 'Password',
|
|
49
|
+
kind: 'secret',
|
|
50
|
+
when: 'basic',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'clientId',
|
|
54
|
+
help: 'Client id of the OAuth2 client registered on the site. Its redirect URI must be http://127.0.0.1:8137/callback.',
|
|
55
|
+
label: 'OAuth2 client id',
|
|
56
|
+
required: true,
|
|
57
|
+
when: 'oauth2',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: 'authorizationEndpoint',
|
|
61
|
+
help: "The provider's authorization URL. Filled in from the site's OpenID configuration when it can be discovered.",
|
|
62
|
+
label: 'Authorization endpoint',
|
|
63
|
+
required: true,
|
|
64
|
+
when: 'oauth2',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: 'tokenEndpoint',
|
|
68
|
+
help: "The provider's token URL. Filled in from the site's OpenID configuration when it can be discovered.",
|
|
69
|
+
label: 'Token endpoint',
|
|
70
|
+
required: true,
|
|
71
|
+
when: 'oauth2',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: 'scopes',
|
|
75
|
+
help: 'Space-separated scopes to request. ALL grants the Sitevision API and offline_access adds a refresh token so later runs log in silently. Match the casing your client expects.',
|
|
76
|
+
label: 'Scopes',
|
|
77
|
+
when: 'oauth2',
|
|
78
|
+
hint: 'ALL offline_access',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: 'clientSecret',
|
|
82
|
+
help: 'Secret of a confidential OAuth2 client, stored in the OS keychain. Leave empty for a public client.',
|
|
83
|
+
label: 'Client secret',
|
|
84
|
+
kind: 'secret',
|
|
85
|
+
when: 'oauth2',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'sessionLoginUrl',
|
|
89
|
+
help: 'Page opened in the browser for the SSO login. Leave empty to use the site root.',
|
|
90
|
+
label: 'Login URL',
|
|
91
|
+
when: 'cookie',
|
|
92
|
+
hint: 'blank = site root',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: 'useHTTPForDevDeploy',
|
|
96
|
+
help: 'Use plain HTTP instead of HTTPS for deploys. Only for local or test servers without TLS.',
|
|
97
|
+
label: 'Use HTTP',
|
|
98
|
+
kind: 'bool',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: 'baseEnvironment',
|
|
102
|
+
label: 'Environment name',
|
|
103
|
+
section: 'ENVIRONMENT',
|
|
104
|
+
hint: 'dev',
|
|
105
|
+
help: 'What this base configuration is: dev, test, prod… Other environments are added on top of it with E or the palette and override only what differs.',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'production',
|
|
109
|
+
label: 'Production',
|
|
110
|
+
kind: 'bool',
|
|
111
|
+
section: 'ENVIRONMENT',
|
|
112
|
+
help: 'Treat deploys to this base environment as production: signed zip, confirmation, activation, and no dev loop. Off by default even when the name says prod, so a repo with only a production site still gets a dev loop.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: 'signingUsername',
|
|
116
|
+
help: 'Your developer.sitevision.se account. Production deploys need the app signed by it.',
|
|
117
|
+
label: 'Signing user',
|
|
118
|
+
section: 'SIGNING',
|
|
119
|
+
hint: 'required for signed deploys',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: 'certificateName',
|
|
123
|
+
help: 'Which certificate to sign with when your developer account has several. Leave empty for the default.',
|
|
124
|
+
label: 'Certificate',
|
|
125
|
+
section: 'SIGNING',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: 'signingPassword',
|
|
129
|
+
help: 'Password for the signing account, stored in the OS keychain. Leave empty to be asked on each run.',
|
|
130
|
+
label: 'Signing password',
|
|
131
|
+
kind: 'secret',
|
|
132
|
+
section: 'SIGNING',
|
|
133
|
+
hint: 'blank = prompt each run',
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const MANIFEST_FIELDS = [
|
|
137
|
+
{
|
|
138
|
+
key: 'id',
|
|
139
|
+
label: 'App id',
|
|
140
|
+
required: true,
|
|
141
|
+
help: 'Identifier of the app in Sitevision; it also names the zip. Changing it makes the next deploy a new app instead of an update.',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'version',
|
|
145
|
+
label: 'Version',
|
|
146
|
+
required: true,
|
|
147
|
+
help: 'Version of the app. Id and version together identify an upload, so bump it to deploy a new version instead of overwriting the current one.',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: 'name',
|
|
151
|
+
label: 'Name',
|
|
152
|
+
required: true,
|
|
153
|
+
help: 'Name shown when importing and administering the app in the Sitevision editor. A multilingual manifest needs at least English.',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: 'description',
|
|
157
|
+
label: 'Description',
|
|
158
|
+
help: 'Short description shown next to the name in the Sitevision editor.',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
key: 'author',
|
|
162
|
+
label: 'Author',
|
|
163
|
+
help: 'Who made the app, shown in the Sitevision editor.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
key: 'helpUrl',
|
|
167
|
+
label: 'Help URL',
|
|
168
|
+
help: "Link to the app's documentation, shown in the Sitevision editor.",
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
/** Rows for manifest.json: one per text field, one per language of a localized one. */
|
|
172
|
+
const LOCALIZABLE = new Set(['name', 'description']);
|
|
173
|
+
export function manifestFields(manifest) {
|
|
174
|
+
if (!manifest)
|
|
175
|
+
return [];
|
|
176
|
+
const raw = manifest;
|
|
177
|
+
return MANIFEST_FIELDS.flatMap(f => {
|
|
178
|
+
const value = raw[f.key];
|
|
179
|
+
const base = { ...f, section: 'MANIFEST', manifestKey: f.key };
|
|
180
|
+
const localized = (lang) => ({
|
|
181
|
+
...base,
|
|
182
|
+
key: `manifest.${f.key}.${lang}`,
|
|
183
|
+
lang,
|
|
184
|
+
required: f.required && lang === 'en',
|
|
185
|
+
});
|
|
186
|
+
if (value && typeof value === 'object') {
|
|
187
|
+
const langs = Object.keys(value);
|
|
188
|
+
if (!langs.includes('sv'))
|
|
189
|
+
langs.push('sv');
|
|
190
|
+
return langs.map(lang => localized(lang));
|
|
191
|
+
}
|
|
192
|
+
const plain = { ...base, key: `manifest.${f.key}` };
|
|
193
|
+
return LOCALIZABLE.has(f.key) ? [plain, localized('sv')] : [plain];
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const labelOf = (f) => t(f.label) + (f.lang ? ` (${f.lang})` : '');
|
|
197
|
+
const ENV_KEYS = new Set([
|
|
198
|
+
'domain',
|
|
199
|
+
'siteName',
|
|
200
|
+
'addonName',
|
|
201
|
+
'username',
|
|
202
|
+
'authMethod',
|
|
203
|
+
'useHTTPForDevDeploy',
|
|
204
|
+
'clientId',
|
|
205
|
+
'authorizationEndpoint',
|
|
206
|
+
'tokenEndpoint',
|
|
207
|
+
'scopes',
|
|
208
|
+
'clientSecret',
|
|
209
|
+
'sessionLoginUrl',
|
|
210
|
+
'password',
|
|
211
|
+
]);
|
|
212
|
+
// Wide enough for the longest source text ("^O pick from repo").
|
|
213
|
+
const SOURCE_WIDTH = 18;
|
|
214
|
+
function fromProject(project) {
|
|
215
|
+
const dev = project.devProperties ?? {};
|
|
216
|
+
return {
|
|
217
|
+
domain: dev.domain ?? '',
|
|
218
|
+
siteName: dev.siteName ?? '',
|
|
219
|
+
addonName: dev.addonName ?? '',
|
|
220
|
+
username: dev.username ?? '',
|
|
221
|
+
authMethod: dev.authMethod ?? 'basic',
|
|
222
|
+
password: '',
|
|
223
|
+
clientId: dev.oauth2?.clientId ?? '',
|
|
224
|
+
authorizationEndpoint: dev.oauth2?.authorizationEndpoint ?? '',
|
|
225
|
+
tokenEndpoint: dev.oauth2?.tokenEndpoint ?? '',
|
|
226
|
+
scopes: (dev.oauth2?.scopes ?? DEFAULT_SCOPES).join(' '),
|
|
227
|
+
clientSecret: '',
|
|
228
|
+
sessionLoginUrl: dev.sessionLoginUrl ?? '',
|
|
229
|
+
useHTTPForDevDeploy: dev.useHTTPForDevDeploy ? 'yes' : 'no',
|
|
230
|
+
baseEnvironment: dev.baseEnvironment ?? '',
|
|
231
|
+
production: dev.production ? 'yes' : 'no',
|
|
232
|
+
signingUsername: dev.signingUsername ?? '',
|
|
233
|
+
certificateName: dev.certificateName ?? '',
|
|
234
|
+
signingPassword: '',
|
|
235
|
+
...Object.fromEntries(manifestFields(project.manifest).map(f => {
|
|
236
|
+
const value = project.manifest[f.manifestKey];
|
|
237
|
+
const text = f.lang && value && typeof value === 'object'
|
|
238
|
+
? value[f.lang]
|
|
239
|
+
: f.lang
|
|
240
|
+
? undefined
|
|
241
|
+
: value;
|
|
242
|
+
return [f.key, typeof text === 'string' ? text : ''];
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function storedSecret(project, key) {
|
|
247
|
+
const dev = project.devProperties;
|
|
248
|
+
if (!dev)
|
|
249
|
+
return false;
|
|
250
|
+
if (key === 'password')
|
|
251
|
+
return Boolean(dev.password);
|
|
252
|
+
if (key === 'clientSecret') {
|
|
253
|
+
return Boolean(dev.domain &&
|
|
254
|
+
dev.oauth2?.clientId &&
|
|
255
|
+
getOAuth2ClientSecret(dev.domain, dev.oauth2.clientId));
|
|
256
|
+
}
|
|
257
|
+
return Boolean(dev.signingUsername && getSigningPassword(dev.signingUsername));
|
|
258
|
+
}
|
|
259
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
260
|
+
const sameValue = (key, a, b) => JSON.stringify(a === '' || a === undefined ? IMPLICIT_VALUES[key] : a) ===
|
|
261
|
+
JSON.stringify(b === '' || b === undefined ? IMPLICIT_VALUES[key] : b);
|
|
262
|
+
/**
|
|
263
|
+
* App mode writes the app's complete file. Workspace mode never creates an
|
|
264
|
+
* app's .dev_properties.json: changes go to the root file, and addon names (the
|
|
265
|
+
* base one and each environment's) to the app's package.json, since the root
|
|
266
|
+
* file is shared by every app. An app that already has its own file keeps it.
|
|
267
|
+
*/
|
|
268
|
+
function writeConfigFile(project, file) {
|
|
269
|
+
const { workspaceRoot } = project;
|
|
270
|
+
if (!workspaceRoot ||
|
|
271
|
+
project.workspace ||
|
|
272
|
+
findDevPropertiesPath(project.root)) {
|
|
273
|
+
writeDevProperties(project.root, file, {
|
|
274
|
+
complete: !workspaceRoot && !project.workspace,
|
|
275
|
+
});
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const before = (project.base ?? project.devProperties ?? {});
|
|
279
|
+
const after = file;
|
|
280
|
+
const root = readWorkspaceDevProperties(workspaceRoot);
|
|
281
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
282
|
+
if (sameValue(key, after[key], before[key]))
|
|
283
|
+
continue;
|
|
284
|
+
if (key === 'addonName') {
|
|
285
|
+
updatePackageJson(project.root, packageJson => {
|
|
286
|
+
packageJson['addonName'] = after[key] || undefined;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
else if (key === 'environments') {
|
|
290
|
+
// `after` is this app's merged view. Only the environment being edited
|
|
291
|
+
// goes to the shared file, or the app's own overrides would reach
|
|
292
|
+
// every other app.
|
|
293
|
+
const environments = Object.entries((after[key] ?? {})).filter(([name]) => !project.environment || name === project.environment);
|
|
294
|
+
root[key] = {
|
|
295
|
+
...root[key],
|
|
296
|
+
...Object.fromEntries(environments.map(([name, { addonName: _addonName, ...shared }]) => [
|
|
297
|
+
name,
|
|
298
|
+
shared,
|
|
299
|
+
])),
|
|
300
|
+
};
|
|
301
|
+
updatePackageJson(project.root, packageJson => {
|
|
302
|
+
const svc = { ...packageJson['svc'] };
|
|
303
|
+
const own = new Map(Object.entries((svc['environments'] ?? {})));
|
|
304
|
+
for (const [name, { addonName }] of environments) {
|
|
305
|
+
const override = {
|
|
306
|
+
...own.get(name),
|
|
307
|
+
addonName: addonName || undefined,
|
|
308
|
+
};
|
|
309
|
+
if (Object.values(override).every(value => value === undefined)) {
|
|
310
|
+
own.delete(name);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
own.set(name, override);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
svc['environments'] =
|
|
317
|
+
own.size > 0 ? Object.fromEntries(own) : undefined;
|
|
318
|
+
packageJson['svc'] = Object.values(svc).some(value => value !== undefined)
|
|
319
|
+
? svc
|
|
320
|
+
: undefined;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
root[key] = after[key];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
writeDevProperties(workspaceRoot, root);
|
|
328
|
+
}
|
|
329
|
+
/** Apply the form to disk and the keychain. Exported for the test. */
|
|
330
|
+
export function saveConfig(project, values, edited) {
|
|
331
|
+
const method = values['authMethod'];
|
|
332
|
+
// Start from what is there: keys the form does not show, and the settings of
|
|
333
|
+
// the auth methods not in use, survive a save.
|
|
334
|
+
const next = {
|
|
335
|
+
...project.devProperties,
|
|
336
|
+
domain: values['domain'],
|
|
337
|
+
siteName: values['siteName'],
|
|
338
|
+
addonName: values['addonName'],
|
|
339
|
+
username: values['username'],
|
|
340
|
+
authMethod: method,
|
|
341
|
+
useHTTPForDevDeploy: values['useHTTPForDevDeploy'] === 'yes',
|
|
342
|
+
baseEnvironment: values['baseEnvironment']?.trim().toLowerCase() || undefined,
|
|
343
|
+
production: values['production'] === 'yes' ? true : undefined,
|
|
344
|
+
signingUsername: values['signingUsername'] || undefined,
|
|
345
|
+
certificateName: values['certificateName'] || undefined,
|
|
346
|
+
};
|
|
347
|
+
if (method === 'oauth2') {
|
|
348
|
+
const scopes = values['scopes'].split(/[\s,]+/).filter(Boolean);
|
|
349
|
+
next.oauth2 = {
|
|
350
|
+
authorizationEndpoint: values['authorizationEndpoint'],
|
|
351
|
+
tokenEndpoint: values['tokenEndpoint'],
|
|
352
|
+
clientId: values['clientId'],
|
|
353
|
+
...(scopes.length > 0 && { scopes }),
|
|
354
|
+
...(project.devProperties?.oauth2?.redirectPort && {
|
|
355
|
+
redirectPort: project.devProperties.oauth2.redirectPort,
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
else if (method === 'cookie') {
|
|
360
|
+
next.sessionLoginUrl = values['sessionLoginUrl'] || undefined;
|
|
361
|
+
}
|
|
362
|
+
const env = project.environment;
|
|
363
|
+
if (env && env !== baseEnvironment(project.base) && project.base) {
|
|
364
|
+
// Non-dev environment: site/auth fields become an override, signing
|
|
365
|
+
// fields still live on the base.
|
|
366
|
+
const base = { ...project.base };
|
|
367
|
+
base.signingUsername = next.signingUsername;
|
|
368
|
+
base.certificateName = next.certificateName;
|
|
369
|
+
base.baseEnvironment = next.baseEnvironment;
|
|
370
|
+
base.production = next.production;
|
|
371
|
+
writeConfigFile(project, withEnvironmentOverride(base, env, {
|
|
372
|
+
domain: next.domain,
|
|
373
|
+
siteName: next.siteName,
|
|
374
|
+
addonName: next.addonName,
|
|
375
|
+
username: next.username,
|
|
376
|
+
authMethod: next.authMethod,
|
|
377
|
+
useHTTPForDevDeploy: next.useHTTPForDevDeploy,
|
|
378
|
+
oauth2: next.oauth2,
|
|
379
|
+
sessionLoginUrl: next.sessionLoginUrl,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
writeConfigFile(project, {
|
|
384
|
+
...next,
|
|
385
|
+
environments: project.base?.environments ?? project.devProperties?.environments,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const secret = (key, set, del) => {
|
|
389
|
+
if (!edited.has(key))
|
|
390
|
+
return;
|
|
391
|
+
const value = values[key] ?? '';
|
|
392
|
+
if (value)
|
|
393
|
+
set(value);
|
|
394
|
+
else
|
|
395
|
+
del();
|
|
396
|
+
};
|
|
397
|
+
secret('password', v => setDeployPassword(next.domain, next.username, v), () => deleteDeployPassword(next.domain, next.username));
|
|
398
|
+
if (next.oauth2) {
|
|
399
|
+
const { clientId } = next.oauth2;
|
|
400
|
+
secret('clientSecret', v => setOAuth2ClientSecret(next.domain, clientId, v), () => deleteOAuth2ClientSecret(next.domain, clientId));
|
|
401
|
+
}
|
|
402
|
+
if (next.signingUsername) {
|
|
403
|
+
const user = next.signingUsername;
|
|
404
|
+
secret('signingPassword', v => setSigningPassword(user, v), () => deleteSigningPassword(user));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
export function visibleFields(method, workspace = false, envMode = false) {
|
|
408
|
+
return FIELDS.filter(f => (!f.when || f.when === method) &&
|
|
409
|
+
!(workspace && f.perApp) &&
|
|
410
|
+
!(envMode && (f.section === 'SIGNING' || f.section === 'ENVIRONMENT')));
|
|
411
|
+
}
|
|
412
|
+
export function ConfigForm({ project, active, width, height, pickAddon, onSaved, onEditingChange, }) {
|
|
413
|
+
const [values, setValues] = useState(() => fromProject(project));
|
|
414
|
+
// What the form holds when an async lookup lands, not when it started.
|
|
415
|
+
const latest = useRef({ values, project });
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
latest.current = { values, project };
|
|
418
|
+
});
|
|
419
|
+
const [cursor, setCursor] = useState(0);
|
|
420
|
+
const [editing, setEditing] = useState(false);
|
|
421
|
+
const [draft, setDraft] = useState('');
|
|
422
|
+
const [caret, setCaret] = useState(0);
|
|
423
|
+
const [note, setNote] = useState('');
|
|
424
|
+
// Values always mirror the project; edits are committed field by field.
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
setValues(fromProject(project));
|
|
427
|
+
}, [project]);
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
onEditingChange(editing);
|
|
430
|
+
return () => {
|
|
431
|
+
onEditingChange(false);
|
|
432
|
+
};
|
|
433
|
+
}, [editing, onEditingChange]);
|
|
434
|
+
const method = values['authMethod'];
|
|
435
|
+
const envMode = Boolean(project.environment &&
|
|
436
|
+
project.environment !==
|
|
437
|
+
baseEnvironment(project.base ?? project.devProperties));
|
|
438
|
+
const fields = [
|
|
439
|
+
...visibleFields(method, project.workspace, envMode),
|
|
440
|
+
...manifestFields(project.manifest),
|
|
441
|
+
];
|
|
442
|
+
const current = fields[Math.min(cursor, fields.length - 1)];
|
|
443
|
+
const inherited = readInheritedDevProperties(project.root);
|
|
444
|
+
const ancestors = readAncestorDevProperties(project.root);
|
|
445
|
+
const globalSigning = getGlobalSigning();
|
|
446
|
+
const changes = getPackageJsonSyncChanges(project.root);
|
|
447
|
+
const packageJsonExists = hasPackageJson(project.root);
|
|
448
|
+
// Write one field to disk (and the keychain for secrets) right away.
|
|
449
|
+
const commit = (key, value, label = labelOf(current)) => {
|
|
450
|
+
const clean = key === 'domain' ? normalizeDomain(value) : value;
|
|
451
|
+
const field = fields.find(f => f.key === key);
|
|
452
|
+
if (field?.manifestKey && field.required && !clean) {
|
|
453
|
+
setNote(t('Not saved: {label} is required.', { label }));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const next = { ...values, [key]: clean };
|
|
457
|
+
setValues(next);
|
|
458
|
+
try {
|
|
459
|
+
if (field?.manifestKey) {
|
|
460
|
+
writeManifestField(project.manifestPath, field.manifestKey, clean, field.lang);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
saveConfig(project, next, new Set([key]));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
setNote(t('Not saved: {error}', { error: errorText(error) }));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
setNote(clean === value
|
|
471
|
+
? t('Saved {label}.', { label })
|
|
472
|
+
: t('Saved {label} as {value} — a domain is a host only.', {
|
|
473
|
+
label,
|
|
474
|
+
value: clean,
|
|
475
|
+
}));
|
|
476
|
+
onSaved();
|
|
477
|
+
};
|
|
478
|
+
// Auto-fill OAuth2 endpoints from the site's OpenID configuration.
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (method !== 'oauth2' || !values['domain'])
|
|
481
|
+
return;
|
|
482
|
+
if (values['authorizationEndpoint'] && values['tokenEndpoint'])
|
|
483
|
+
return;
|
|
484
|
+
let cancelled = false;
|
|
485
|
+
setNote(t('Looking up OAuth2 endpoints…'));
|
|
486
|
+
void discoverOAuth2Config(values['domain'], values['useHTTPForDevDeploy'] === 'yes').then(found => {
|
|
487
|
+
if (cancelled)
|
|
488
|
+
return;
|
|
489
|
+
if (found) {
|
|
490
|
+
const now = latest.current.values;
|
|
491
|
+
const next = {
|
|
492
|
+
...now,
|
|
493
|
+
authorizationEndpoint: now['authorizationEndpoint'] || found.authorizationEndpoint,
|
|
494
|
+
tokenEndpoint: now['tokenEndpoint'] || found.tokenEndpoint,
|
|
495
|
+
};
|
|
496
|
+
setValues(next);
|
|
497
|
+
try {
|
|
498
|
+
saveConfig(latest.current.project, next, new Set());
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
setNote(t('Not saved: {error}', { error: errorText(error) }));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
onSaved();
|
|
505
|
+
setNote(t('Endpoints filled from the site OpenID config.'));
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
setNote(t('Could not discover OAuth2 endpoints; enter them by hand.'));
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return () => {
|
|
512
|
+
cancelled = true;
|
|
513
|
+
};
|
|
514
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
515
|
+
}, [method, values['domain']]);
|
|
516
|
+
// Options for a choice row; the draft holds the highlighted one while editing.
|
|
517
|
+
const options = (f) => f.kind === 'method' ? METHODS : f.kind === 'bool' ? ['yes', 'no'] : [];
|
|
518
|
+
const openPicker = () => {
|
|
519
|
+
void pickAddon().then(name => {
|
|
520
|
+
if (name) {
|
|
521
|
+
setEditing(false);
|
|
522
|
+
commit('addonName', name, t('Addon name'));
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
useInput((input, key) => {
|
|
527
|
+
if (editing) {
|
|
528
|
+
const choices = options(current);
|
|
529
|
+
if (key.escape) {
|
|
530
|
+
setEditing(false);
|
|
531
|
+
}
|
|
532
|
+
else if (key.return) {
|
|
533
|
+
setEditing(false);
|
|
534
|
+
// A secret row always starts empty, so Enter on an empty one means
|
|
535
|
+
// "remove the stored secret", not "no change".
|
|
536
|
+
if (draft !== values[current.key] ||
|
|
537
|
+
(current.kind === 'secret' && storedSecret(project, current.key))) {
|
|
538
|
+
commit(current.key, draft);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else if (choices.length > 0) {
|
|
542
|
+
const step = key.leftArrow || key.upArrow
|
|
543
|
+
? -1
|
|
544
|
+
: key.rightArrow || key.downArrow || input === ' '
|
|
545
|
+
? 1
|
|
546
|
+
: 0;
|
|
547
|
+
if (step !== 0) {
|
|
548
|
+
const i = choices.indexOf(draft);
|
|
549
|
+
setDraft(choices[(i + step + choices.length) % choices.length]);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
553
|
+
openPicker();
|
|
554
|
+
}
|
|
555
|
+
else if (key.leftArrow) {
|
|
556
|
+
setCaret(Math.max(0, caret - 1));
|
|
557
|
+
}
|
|
558
|
+
else if (key.rightArrow) {
|
|
559
|
+
setCaret(Math.min(draft.length, caret + 1));
|
|
560
|
+
}
|
|
561
|
+
else if (key.backspace || key.delete) {
|
|
562
|
+
if (caret > 0) {
|
|
563
|
+
setDraft(draft.slice(0, caret - 1) + draft.slice(caret));
|
|
564
|
+
setCaret(caret - 1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
568
|
+
setDraft(draft.slice(0, caret) + input + draft.slice(caret));
|
|
569
|
+
setCaret(caret + input.length);
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if ((key.tab && !key.shift) || key.downArrow) {
|
|
574
|
+
setCursor(c => (c + 1) % fields.length);
|
|
575
|
+
}
|
|
576
|
+
else if ((key.tab && key.shift) || key.upArrow) {
|
|
577
|
+
setCursor(c => (c - 1 + fields.length) % fields.length);
|
|
578
|
+
}
|
|
579
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
580
|
+
openPicker();
|
|
581
|
+
}
|
|
582
|
+
else if (key.return) {
|
|
583
|
+
const start = current.kind === 'secret' ? '' : (values[current.key] ?? '');
|
|
584
|
+
setDraft(start);
|
|
585
|
+
setCaret(start.length);
|
|
586
|
+
setEditing(true);
|
|
587
|
+
}
|
|
588
|
+
}, { isActive: active });
|
|
589
|
+
// An environment's addon name differs from the app's name on purpose.
|
|
590
|
+
const drift = envMode || !getSettings().addonNameDriftWarning
|
|
591
|
+
? undefined
|
|
592
|
+
: addonNameDrift(values['addonName'], project.manifest);
|
|
593
|
+
const source = (f) => {
|
|
594
|
+
const required = f.required === true || f.required === method;
|
|
595
|
+
if (required && !(values[f.key] ?? ''))
|
|
596
|
+
return { text: t('✗ required'), color: 'red' };
|
|
597
|
+
if (f.key === 'addonName' && drift)
|
|
598
|
+
return { text: t('≠ manifest'), color: 'yellow' };
|
|
599
|
+
if (f.kind === 'secret') {
|
|
600
|
+
return { text: storedSecret(project, f.key) ? t('keychain') : '—' };
|
|
601
|
+
}
|
|
602
|
+
if (f.manifestKey)
|
|
603
|
+
return { text: (values[f.key] ?? '') ? 'manifest.json' : '' };
|
|
604
|
+
const value = f.kind === 'bool' ? values[f.key] === 'yes' : values[f.key];
|
|
605
|
+
if (envMode && ENV_KEYS.has(f.key)) {
|
|
606
|
+
const override = project.base?.environments?.[project.environment];
|
|
607
|
+
const overridden = override &&
|
|
608
|
+
([
|
|
609
|
+
'clientId',
|
|
610
|
+
'authorizationEndpoint',
|
|
611
|
+
'tokenEndpoint',
|
|
612
|
+
'scopes',
|
|
613
|
+
].includes(f.key)
|
|
614
|
+
? Object.hasOwn(override, 'oauth2')
|
|
615
|
+
: Object.hasOwn(override, f.key));
|
|
616
|
+
return {
|
|
617
|
+
text: overridden ? project.environment : t('↑ dev'),
|
|
618
|
+
color: overridden ? 'yellow' : undefined,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const inOAuth2 = [
|
|
622
|
+
'clientId',
|
|
623
|
+
'authorizationEndpoint',
|
|
624
|
+
'tokenEndpoint',
|
|
625
|
+
].includes(f.key);
|
|
626
|
+
const inheritedValue = inOAuth2
|
|
627
|
+
? inherited['oauth2']?.[f.key]
|
|
628
|
+
: inherited[f.key];
|
|
629
|
+
if (inheritedValue !== undefined &&
|
|
630
|
+
JSON.stringify(inheritedValue) === JSON.stringify(value)) {
|
|
631
|
+
// A parent .dev_properties.json outranks package.json when it has the key.
|
|
632
|
+
return {
|
|
633
|
+
text: Object.hasOwn(ancestors, inOAuth2 ? 'oauth2' : f.key)
|
|
634
|
+
? t('↑ root')
|
|
635
|
+
: 'package.json',
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (value && globalSigning[f.key] === value)
|
|
639
|
+
return { text: t('global') };
|
|
640
|
+
return { text: (values[f.key] ?? '') ? t('local') : '' };
|
|
641
|
+
};
|
|
642
|
+
// label column (24) + source column + paddings; never below 20.
|
|
643
|
+
const valueWidth = Math.max(20, width - 24 - SOURCE_WIDTH - 2);
|
|
644
|
+
const rows = [];
|
|
645
|
+
let focusRow = 0;
|
|
646
|
+
let lastSection;
|
|
647
|
+
for (const f of fields) {
|
|
648
|
+
if (f.section && f.section !== lastSection) {
|
|
649
|
+
rows.push(_jsx(Box, { height: 1, flexShrink: 0 }, `g-${f.section}`), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: t(f.section) }) }, `s-${f.section}`));
|
|
650
|
+
lastSection = f.section;
|
|
651
|
+
}
|
|
652
|
+
if (f === current)
|
|
653
|
+
focusRow = rows.length;
|
|
654
|
+
const focused = active && f === current;
|
|
655
|
+
const typing = focused && editing;
|
|
656
|
+
// A value being typed into, scrolled so the caret stays in view and drawn
|
|
657
|
+
// with the caret as an inverted cell.
|
|
658
|
+
const withCaret = (text) => {
|
|
659
|
+
const max = Math.max(1, valueWidth - 1);
|
|
660
|
+
const start = text.length > max
|
|
661
|
+
? Math.min(Math.max(0, caret - max + 1), text.length - max)
|
|
662
|
+
: 0;
|
|
663
|
+
const shown = text.slice(start, start + max);
|
|
664
|
+
const at = caret - start;
|
|
665
|
+
return (_jsxs(Text, { children: [shown.slice(0, at), _jsx(Text, { inverse: true, children: shown[at] ?? ' ' }), shown.slice(at + 1)] }));
|
|
666
|
+
};
|
|
667
|
+
let display;
|
|
668
|
+
if (f.kind === 'method' || f.kind === 'bool') {
|
|
669
|
+
const choices = options(f);
|
|
670
|
+
const chosen = typing ? draft : values[f.key];
|
|
671
|
+
display = choices.map((m, i) => (_jsxs(Text, { children: [_jsx(Text, { bold: m === chosen, color: m === chosen ? ACCENT : undefined, inverse: typing && m === chosen, dimColor: m !== chosen, children: f.kind === 'bool' ? t(m) : m }), i < choices.length - 1 && _jsx(Text, { dimColor: true, children: " \u00B7 " })] }, m)));
|
|
672
|
+
}
|
|
673
|
+
else if (f.kind === 'secret') {
|
|
674
|
+
display = typing ? (withCaret('•'.repeat(draft.length))) : (_jsx(Text, { dimColor: true, children: storedSecret(project, f.key)
|
|
675
|
+
? t('•••••••• keychain')
|
|
676
|
+
: f.hint
|
|
677
|
+
? t(f.hint)
|
|
678
|
+
: '' }));
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
const value = values[f.key];
|
|
682
|
+
display = typing ? (withCaret(draft)) : value ? (_jsx(Text, { children: value })) : (_jsx(Text, { dimColor: true, children: f.hint ? t(f.hint) : '—' }));
|
|
683
|
+
}
|
|
684
|
+
const src = source(f);
|
|
685
|
+
rows.push(_jsxs(Box, { height: 1, flexShrink: 0, children: [_jsx(Text, { color: focused ? ACCENT : undefined, bold: focused, dimColor: !focused, children: (focused ? '▸ ' : ' ') + labelOf(f).padEnd(22) }), _jsx(Box, { width: valueWidth, flexShrink: 0, children: _jsx(Text, { wrap: "truncate", children: display }) }), _jsx(Box, { width: SOURCE_WIDTH, flexShrink: 0, children: _jsx(Text, { dimColor: !src.color, color: src.color, wrap: "truncate", children: focused && f.hint && f.key === 'addonName' ? t(f.hint) : src.text }) })] }, f.key));
|
|
686
|
+
}
|
|
687
|
+
// ponytail: the help panel is assumed to wrap to three lines; measure it if
|
|
688
|
+
// a narrow pane ever clips the note.
|
|
689
|
+
const room = Math.max(3, height - 9 - changes.length - (project.workspace ? 2 : 0));
|
|
690
|
+
const firstRow = Math.max(0, Math.min(focusRow - Math.floor(room / 2), rows.length - room));
|
|
691
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, overflow: "hidden", height: height, children: [_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { dimColor: true, wrap: "truncate", children: [(' ' + t('FIELD')).padEnd(24), t('VALUE').padEnd(valueWidth), t('SOURCE')] }) }), rows.slice(firstRow, firstRow + room), project.workspace && (_jsx(Box, { marginTop: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: t("Shared by every app below {root}. An app's own value wins.", {
|
|
692
|
+
root: project.root,
|
|
693
|
+
}) }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { bold: true, dimColor: true, children: [t('PACKAGE.JSON SYNC'), ' ', _jsx(Text, { color: changes.length > 0 || !packageJsonExists ? 'yellow' : 'green', children: !packageJsonExists
|
|
694
|
+
? t('no workspace package.json · y to set up')
|
|
695
|
+
: changes.length === 0
|
|
696
|
+
? t('in sync')
|
|
697
|
+
: changes.length === 1
|
|
698
|
+
? t('1 diff · y to apply')
|
|
699
|
+
: t('{n} diffs · y to apply', { n: changes.length }) })] }), drift && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: "yellow", children: "\u2260 " }), t('addon name {addon} · manifest name {names}', {
|
|
700
|
+
addon: values['addonName'] ?? '',
|
|
701
|
+
names: drift.join(' / '),
|
|
702
|
+
})] })), changes.map(c => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: c.from === undefined ? 'green' : 'yellow', children: c.from === undefined ? '+ ' : '~ ' }), c.key, ": ", c.from !== undefined && _jsxs(Text, { dimColor: true, children: [c.from, " \u2192 "] }), c.to] }, c.key)))] }), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { bold: true, color: ACCENT, children: labelOf(current) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", t(current.help)] })] }) }), _jsx(Text, { color: "yellow", children: note })] }));
|
|
703
|
+
}
|