sitevision-cli 1.0.0-beta.2 → 1.0.0-beta.21
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/dist/app.d.ts +1 -1
- package/dist/app.js +59 -8
- package/dist/cli.js +96 -39
- package/dist/commands/build.js +1 -1
- package/dist/commands/deploy.d.ts +2 -2
- package/dist/commands/deploy.js +135 -25
- package/dist/commands/dev.d.ts +8 -10
- package/dist/commands/dev.js +77 -366
- package/dist/commands/info.js +2 -2
- package/dist/commands/watch.js +5 -23
- package/dist/components/AnimatedLogo.js +8 -2
- package/dist/components/AuthLoginScreen.d.ts +21 -0
- package/dist/components/AuthLoginScreen.js +90 -0
- package/dist/components/DevPropertiesForm.d.ts +2 -1
- package/dist/components/DevPropertiesForm.js +198 -33
- package/dist/components/InfoScreen.js +2 -2
- package/dist/components/MainMenu.js +7 -2
- package/dist/components/PasswordInput.js +2 -1
- package/dist/components/SetupFlow.d.ts +2 -1
- package/dist/components/SetupFlow.js +100 -11
- package/dist/shell/AddonPicker.d.ts +14 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/CommandPalette.d.ts +8 -0
- package/dist/shell/CommandPalette.js +63 -0
- package/dist/shell/ConfigForm.d.ts +36 -0
- package/dist/shell/ConfigForm.js +558 -0
- package/dist/shell/Frame.d.ts +59 -0
- package/dist/shell/Frame.js +134 -0
- package/dist/shell/Settings.d.ts +6 -0
- package/dist/shell/Settings.js +96 -0
- package/dist/shell/Shell.d.ts +9 -0
- package/dist/shell/Shell.js +586 -0
- package/dist/shell/Tabs.d.ts +36 -0
- package/dist/shell/Tabs.js +90 -0
- package/dist/shell/actions.d.ts +45 -0
- package/dist/shell/actions.js +0 -0
- package/dist/types/index.d.ts +44 -5
- package/dist/utils/config.d.ts +10 -0
- package/dist/utils/config.js +14 -0
- package/dist/utils/environments.d.ts +20 -0
- package/dist/utils/environments.js +74 -0
- package/dist/utils/i18n.d.ts +12 -0
- package/dist/utils/i18n.js +279 -0
- package/dist/utils/jsonc.d.ts +19 -0
- package/dist/utils/jsonc.js +74 -0
- package/dist/utils/keychain.d.ts +9 -0
- package/dist/utils/keychain.js +54 -0
- package/dist/utils/oauth2-auth.d.ts +64 -0
- package/dist/utils/oauth2-auth.js +242 -0
- package/dist/utils/password-prompt.d.ts +5 -0
- package/dist/utils/password-prompt.js +28 -0
- package/dist/utils/project-detection.d.ts +105 -6
- package/dist/utils/project-detection.js +411 -54
- package/dist/utils/session-cookie-auth.d.ts +35 -0
- package/dist/utils/session-cookie-auth.js +99 -0
- package/dist/utils/sitevision-api.d.ts +64 -5
- package/dist/utils/sitevision-api.js +195 -33
- package/dist/utils/tasks.d.ts +48 -0
- package/dist/utils/tasks.js +371 -0
- package/dist/utils/workspace.d.ts +17 -0
- package/dist/utils/workspace.js +67 -0
- package/package.json +3 -1
- package/readme.md +102 -121
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { findDevPropertiesPath, getPackageJsonSyncChanges, hasPackageJson, IMPLICIT_VALUES, normalizeDomain, readAncestorDevProperties, readInheritedDevProperties, readWorkspaceDevProperties, updatePackageJson, writeDevProperties, } 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
|
+
const METHODS = ['basic', 'oauth2', 'cookie'];
|
|
11
|
+
const FIELDS = [
|
|
12
|
+
{
|
|
13
|
+
key: 'domain',
|
|
14
|
+
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.",
|
|
15
|
+
label: 'Domain',
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
key: 'siteName',
|
|
20
|
+
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.",
|
|
21
|
+
label: 'Site name',
|
|
22
|
+
required: true,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
key: 'addonName',
|
|
26
|
+
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.",
|
|
27
|
+
label: 'Addon name',
|
|
28
|
+
required: true,
|
|
29
|
+
hint: '^O pick from repo',
|
|
30
|
+
perApp: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'username',
|
|
34
|
+
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.',
|
|
35
|
+
label: 'Username',
|
|
36
|
+
required: 'basic',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: 'authMethod',
|
|
40
|
+
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.",
|
|
41
|
+
label: 'Auth method',
|
|
42
|
+
kind: 'method',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'password',
|
|
46
|
+
help: 'Deploy password for the account above. Stored in the OS keychain, never in a file. Leave empty to be asked on each run.',
|
|
47
|
+
label: 'Password',
|
|
48
|
+
kind: 'secret',
|
|
49
|
+
when: 'basic',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: 'clientId',
|
|
53
|
+
help: 'Client id of the OAuth2 client registered on the site. Its redirect URI must be http://127.0.0.1:8137/callback.',
|
|
54
|
+
label: 'OAuth2 client id',
|
|
55
|
+
required: true,
|
|
56
|
+
when: 'oauth2',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'authorizationEndpoint',
|
|
60
|
+
help: "The provider's authorization URL. Filled in from the site's OpenID configuration when it can be discovered.",
|
|
61
|
+
label: 'Authorization endpoint',
|
|
62
|
+
required: true,
|
|
63
|
+
when: 'oauth2',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'tokenEndpoint',
|
|
67
|
+
help: "The provider's token URL. Filled in from the site's OpenID configuration when it can be discovered.",
|
|
68
|
+
label: 'Token endpoint',
|
|
69
|
+
required: true,
|
|
70
|
+
when: 'oauth2',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: 'scopes',
|
|
74
|
+
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.',
|
|
75
|
+
label: 'Scopes',
|
|
76
|
+
when: 'oauth2',
|
|
77
|
+
hint: 'ALL offline_access',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: 'clientSecret',
|
|
81
|
+
help: 'Secret of a confidential OAuth2 client, stored in the OS keychain. Leave empty for a public client.',
|
|
82
|
+
label: 'Client secret',
|
|
83
|
+
kind: 'secret',
|
|
84
|
+
when: 'oauth2',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'sessionLoginUrl',
|
|
88
|
+
help: 'Page opened in the browser for the SSO login. Leave empty to use the site root.',
|
|
89
|
+
label: 'Login URL',
|
|
90
|
+
when: 'cookie',
|
|
91
|
+
hint: 'blank = site root',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'useHTTPForDevDeploy',
|
|
95
|
+
help: 'Use plain HTTP instead of HTTPS for deploys. Only for local or test servers without TLS.',
|
|
96
|
+
label: 'Use HTTP',
|
|
97
|
+
kind: 'bool',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: 'baseEnvironment',
|
|
101
|
+
label: 'Environment name',
|
|
102
|
+
section: 'ENVIRONMENT',
|
|
103
|
+
hint: 'dev',
|
|
104
|
+
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.',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: 'production',
|
|
108
|
+
label: 'Production',
|
|
109
|
+
kind: 'bool',
|
|
110
|
+
section: 'ENVIRONMENT',
|
|
111
|
+
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.',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: 'signingUsername',
|
|
115
|
+
help: 'Your developer.sitevision.se account. Production deploys need the app signed by it.',
|
|
116
|
+
label: 'Signing user',
|
|
117
|
+
section: 'SIGNING',
|
|
118
|
+
hint: 'required for signed deploys',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: 'certificateName',
|
|
122
|
+
help: 'Which certificate to sign with when your developer account has several. Leave empty for the default.',
|
|
123
|
+
label: 'Certificate',
|
|
124
|
+
section: 'SIGNING',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: 'signingPassword',
|
|
128
|
+
help: 'Password for the signing account, stored in the OS keychain. Leave empty to be asked on each run.',
|
|
129
|
+
label: 'Signing password',
|
|
130
|
+
kind: 'secret',
|
|
131
|
+
section: 'SIGNING',
|
|
132
|
+
hint: 'blank = prompt each run',
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
const ENV_KEYS = new Set([
|
|
136
|
+
'domain',
|
|
137
|
+
'siteName',
|
|
138
|
+
'addonName',
|
|
139
|
+
'username',
|
|
140
|
+
'authMethod',
|
|
141
|
+
'useHTTPForDevDeploy',
|
|
142
|
+
'clientId',
|
|
143
|
+
'authorizationEndpoint',
|
|
144
|
+
'tokenEndpoint',
|
|
145
|
+
'scopes',
|
|
146
|
+
'clientSecret',
|
|
147
|
+
'sessionLoginUrl',
|
|
148
|
+
'password',
|
|
149
|
+
]);
|
|
150
|
+
// Wide enough for the longest source text ("^O pick from repo").
|
|
151
|
+
const SOURCE_WIDTH = 18;
|
|
152
|
+
function fromProject(project) {
|
|
153
|
+
const dev = project.devProperties ?? {};
|
|
154
|
+
return {
|
|
155
|
+
domain: dev.domain ?? '',
|
|
156
|
+
siteName: dev.siteName ?? '',
|
|
157
|
+
addonName: dev.addonName ?? '',
|
|
158
|
+
username: dev.username ?? '',
|
|
159
|
+
authMethod: dev.authMethod ?? 'basic',
|
|
160
|
+
password: '',
|
|
161
|
+
clientId: dev.oauth2?.clientId ?? '',
|
|
162
|
+
authorizationEndpoint: dev.oauth2?.authorizationEndpoint ?? '',
|
|
163
|
+
tokenEndpoint: dev.oauth2?.tokenEndpoint ?? '',
|
|
164
|
+
scopes: (dev.oauth2?.scopes ?? DEFAULT_SCOPES).join(' '),
|
|
165
|
+
clientSecret: '',
|
|
166
|
+
sessionLoginUrl: dev.sessionLoginUrl ?? '',
|
|
167
|
+
useHTTPForDevDeploy: dev.useHTTPForDevDeploy ? 'yes' : 'no',
|
|
168
|
+
baseEnvironment: dev.baseEnvironment ?? '',
|
|
169
|
+
production: dev.production ? 'yes' : 'no',
|
|
170
|
+
signingUsername: dev.signingUsername ?? '',
|
|
171
|
+
certificateName: dev.certificateName ?? '',
|
|
172
|
+
signingPassword: '',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function storedSecret(project, key) {
|
|
176
|
+
const dev = project.devProperties;
|
|
177
|
+
if (!dev)
|
|
178
|
+
return false;
|
|
179
|
+
if (key === 'password')
|
|
180
|
+
return Boolean(dev.password);
|
|
181
|
+
if (key === 'clientSecret') {
|
|
182
|
+
return Boolean(dev.domain &&
|
|
183
|
+
dev.oauth2?.clientId &&
|
|
184
|
+
getOAuth2ClientSecret(dev.domain, dev.oauth2.clientId));
|
|
185
|
+
}
|
|
186
|
+
return Boolean(dev.signingUsername && getSigningPassword(dev.signingUsername));
|
|
187
|
+
}
|
|
188
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
189
|
+
const sameValue = (key, a, b) => JSON.stringify(a === '' || a === undefined ? IMPLICIT_VALUES[key] : a) ===
|
|
190
|
+
JSON.stringify(b === '' || b === undefined ? IMPLICIT_VALUES[key] : b);
|
|
191
|
+
/**
|
|
192
|
+
* App mode writes the app's complete file. Workspace mode never creates an
|
|
193
|
+
* app's .dev_properties.json: changes go to the root file, and the addon name
|
|
194
|
+
* to the app's package.json. An app that already has its own file keeps it.
|
|
195
|
+
*/
|
|
196
|
+
function writeConfigFile(project, file) {
|
|
197
|
+
const { workspaceRoot } = project;
|
|
198
|
+
if (!workspaceRoot ||
|
|
199
|
+
project.workspace ||
|
|
200
|
+
findDevPropertiesPath(project.root)) {
|
|
201
|
+
writeDevProperties(project.root, file, {
|
|
202
|
+
complete: !workspaceRoot && !project.workspace,
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const before = (project.base ?? project.devProperties ?? {});
|
|
207
|
+
const after = file;
|
|
208
|
+
const root = readWorkspaceDevProperties(workspaceRoot);
|
|
209
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
210
|
+
if (sameValue(key, after[key], before[key]))
|
|
211
|
+
continue;
|
|
212
|
+
if (key === 'addonName') {
|
|
213
|
+
updatePackageJson(project.root, packageJson => {
|
|
214
|
+
packageJson['addonName'] = after[key] || undefined;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
root[key] = after[key];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
writeDevProperties(workspaceRoot, root);
|
|
222
|
+
}
|
|
223
|
+
/** Apply the form to disk and the keychain. Exported for the test. */
|
|
224
|
+
export function saveConfig(project, values, edited) {
|
|
225
|
+
const method = values['authMethod'];
|
|
226
|
+
const next = {
|
|
227
|
+
domain: values['domain'],
|
|
228
|
+
siteName: values['siteName'],
|
|
229
|
+
addonName: values['addonName'],
|
|
230
|
+
username: values['username'],
|
|
231
|
+
authMethod: method,
|
|
232
|
+
useHTTPForDevDeploy: values['useHTTPForDevDeploy'] === 'yes',
|
|
233
|
+
};
|
|
234
|
+
if (values['baseEnvironment'])
|
|
235
|
+
next.baseEnvironment = values['baseEnvironment'].trim().toLowerCase();
|
|
236
|
+
if (values['production'] === 'yes')
|
|
237
|
+
next.production = true;
|
|
238
|
+
if (values['signingUsername'])
|
|
239
|
+
next.signingUsername = values['signingUsername'];
|
|
240
|
+
if (values['certificateName'])
|
|
241
|
+
next.certificateName = values['certificateName'];
|
|
242
|
+
if (method === 'oauth2') {
|
|
243
|
+
const scopes = values['scopes'].split(/[\s,]+/).filter(Boolean);
|
|
244
|
+
next.oauth2 = {
|
|
245
|
+
authorizationEndpoint: values['authorizationEndpoint'],
|
|
246
|
+
tokenEndpoint: values['tokenEndpoint'],
|
|
247
|
+
clientId: values['clientId'],
|
|
248
|
+
...(scopes.length > 0 && { scopes }),
|
|
249
|
+
...(project.devProperties?.oauth2?.redirectPort && {
|
|
250
|
+
redirectPort: project.devProperties.oauth2.redirectPort,
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
else if (method === 'cookie' && values['sessionLoginUrl']) {
|
|
255
|
+
next.sessionLoginUrl = values['sessionLoginUrl'];
|
|
256
|
+
}
|
|
257
|
+
const env = project.environment;
|
|
258
|
+
if (env && env !== baseEnvironment(project.base) && project.base) {
|
|
259
|
+
// Non-dev environment: site/auth fields become an override, signing
|
|
260
|
+
// fields still live on the base.
|
|
261
|
+
const base = { ...project.base };
|
|
262
|
+
base.signingUsername = next.signingUsername;
|
|
263
|
+
base.certificateName = next.certificateName;
|
|
264
|
+
base.baseEnvironment = next.baseEnvironment;
|
|
265
|
+
base.production = next.production;
|
|
266
|
+
writeConfigFile(project, withEnvironmentOverride(base, env, {
|
|
267
|
+
domain: next.domain,
|
|
268
|
+
siteName: next.siteName,
|
|
269
|
+
addonName: next.addonName,
|
|
270
|
+
username: next.username,
|
|
271
|
+
authMethod: next.authMethod,
|
|
272
|
+
useHTTPForDevDeploy: next.useHTTPForDevDeploy,
|
|
273
|
+
oauth2: next.oauth2,
|
|
274
|
+
sessionLoginUrl: next.sessionLoginUrl,
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
writeConfigFile(project, {
|
|
279
|
+
...next,
|
|
280
|
+
environments: project.base?.environments ?? project.devProperties?.environments,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const secret = (key, set, del) => {
|
|
284
|
+
if (!edited.has(key))
|
|
285
|
+
return;
|
|
286
|
+
const value = values[key] ?? '';
|
|
287
|
+
if (value)
|
|
288
|
+
set(value);
|
|
289
|
+
else
|
|
290
|
+
del();
|
|
291
|
+
};
|
|
292
|
+
secret('password', v => setDeployPassword(next.domain, next.username, v), () => deleteDeployPassword(next.domain, next.username));
|
|
293
|
+
if (next.oauth2) {
|
|
294
|
+
const { clientId } = next.oauth2;
|
|
295
|
+
secret('clientSecret', v => setOAuth2ClientSecret(next.domain, clientId, v), () => deleteOAuth2ClientSecret(next.domain, clientId));
|
|
296
|
+
}
|
|
297
|
+
if (next.signingUsername) {
|
|
298
|
+
const user = next.signingUsername;
|
|
299
|
+
secret('signingPassword', v => setSigningPassword(user, v), () => deleteSigningPassword(user));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
export function visibleFields(method, workspace = false, envMode = false) {
|
|
303
|
+
return FIELDS.filter(f => (!f.when || f.when === method) &&
|
|
304
|
+
!(workspace && f.perApp) &&
|
|
305
|
+
!(envMode && (f.section === 'SIGNING' || f.section === 'ENVIRONMENT')));
|
|
306
|
+
}
|
|
307
|
+
export function ConfigForm({ project, active, width, height, pickAddon, onSaved, onEditingChange, }) {
|
|
308
|
+
const [values, setValues] = useState(() => fromProject(project));
|
|
309
|
+
const [cursor, setCursor] = useState(0);
|
|
310
|
+
const [editing, setEditing] = useState(false);
|
|
311
|
+
const [draft, setDraft] = useState('');
|
|
312
|
+
const [caret, setCaret] = useState(0);
|
|
313
|
+
const [note, setNote] = useState('');
|
|
314
|
+
// Values always mirror the project; edits are committed field by field.
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
setValues(fromProject(project));
|
|
317
|
+
}, [project]);
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
onEditingChange(editing);
|
|
320
|
+
return () => {
|
|
321
|
+
onEditingChange(false);
|
|
322
|
+
};
|
|
323
|
+
}, [editing, onEditingChange]);
|
|
324
|
+
const method = values['authMethod'];
|
|
325
|
+
const envMode = Boolean(project.environment &&
|
|
326
|
+
project.environment !==
|
|
327
|
+
baseEnvironment(project.base ?? project.devProperties));
|
|
328
|
+
const fields = visibleFields(method, project.workspace, envMode);
|
|
329
|
+
const current = fields[Math.min(cursor, fields.length - 1)];
|
|
330
|
+
const inherited = readInheritedDevProperties(project.root);
|
|
331
|
+
const ancestors = readAncestorDevProperties(project.root);
|
|
332
|
+
const changes = getPackageJsonSyncChanges(project.root);
|
|
333
|
+
const packageJsonExists = hasPackageJson(project.root);
|
|
334
|
+
// Write one field to disk (and the keychain for secrets) right away.
|
|
335
|
+
const commit = (key, value, label = current.label) => {
|
|
336
|
+
const clean = key === 'domain' ? normalizeDomain(value) : value;
|
|
337
|
+
const next = { ...values, [key]: clean };
|
|
338
|
+
setValues(next);
|
|
339
|
+
try {
|
|
340
|
+
saveConfig(project, next, new Set([key]));
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
setNote(t('Not saved: {error}', { error: errorText(error) }));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
setNote(clean === value
|
|
347
|
+
? t('Saved {label}.', { label: t(label) })
|
|
348
|
+
: t('Saved {label} as {value} — a domain is a host only.', {
|
|
349
|
+
label: t(label),
|
|
350
|
+
value: clean,
|
|
351
|
+
}));
|
|
352
|
+
onSaved();
|
|
353
|
+
};
|
|
354
|
+
// Auto-fill OAuth2 endpoints from the site's OpenID configuration.
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
if (method !== 'oauth2' || !values['domain'])
|
|
357
|
+
return;
|
|
358
|
+
if (values['authorizationEndpoint'] && values['tokenEndpoint'])
|
|
359
|
+
return;
|
|
360
|
+
let cancelled = false;
|
|
361
|
+
setNote(t('Looking up OAuth2 endpoints…'));
|
|
362
|
+
void discoverOAuth2Config(values['domain'], values['useHTTPForDevDeploy'] === 'yes').then(found => {
|
|
363
|
+
if (cancelled)
|
|
364
|
+
return;
|
|
365
|
+
if (found) {
|
|
366
|
+
const next = {
|
|
367
|
+
...values,
|
|
368
|
+
authorizationEndpoint: values['authorizationEndpoint'] || found.authorizationEndpoint,
|
|
369
|
+
tokenEndpoint: values['tokenEndpoint'] || found.tokenEndpoint,
|
|
370
|
+
};
|
|
371
|
+
setValues(next);
|
|
372
|
+
try {
|
|
373
|
+
saveConfig(project, next, new Set());
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
setNote(t('Not saved: {error}', { error: errorText(error) }));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
onSaved();
|
|
380
|
+
setNote(t('Endpoints filled from the site OpenID config.'));
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
setNote(t('Could not discover OAuth2 endpoints; enter them by hand.'));
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return () => {
|
|
387
|
+
cancelled = true;
|
|
388
|
+
};
|
|
389
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
390
|
+
}, [method, values['domain']]);
|
|
391
|
+
// Options for a choice row; the draft holds the highlighted one while editing.
|
|
392
|
+
const options = (f) => f.kind === 'method' ? METHODS : f.kind === 'bool' ? ['yes', 'no'] : [];
|
|
393
|
+
const openPicker = () => {
|
|
394
|
+
void pickAddon().then(name => {
|
|
395
|
+
if (name) {
|
|
396
|
+
setEditing(false);
|
|
397
|
+
commit('addonName', name, 'Addon name');
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
useInput((input, key) => {
|
|
402
|
+
if (editing) {
|
|
403
|
+
const choices = options(current);
|
|
404
|
+
if (key.escape) {
|
|
405
|
+
setEditing(false);
|
|
406
|
+
}
|
|
407
|
+
else if (key.return) {
|
|
408
|
+
setEditing(false);
|
|
409
|
+
// A secret row always starts empty, so Enter on an empty one means
|
|
410
|
+
// "remove the stored secret", not "no change".
|
|
411
|
+
if (draft !== values[current.key] ||
|
|
412
|
+
(current.kind === 'secret' && storedSecret(project, current.key))) {
|
|
413
|
+
commit(current.key, draft);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else if (choices.length > 0) {
|
|
417
|
+
const step = key.leftArrow || key.upArrow
|
|
418
|
+
? -1
|
|
419
|
+
: key.rightArrow || key.downArrow || input === ' '
|
|
420
|
+
? 1
|
|
421
|
+
: 0;
|
|
422
|
+
if (step !== 0) {
|
|
423
|
+
const i = choices.indexOf(draft);
|
|
424
|
+
setDraft(choices[(i + step + choices.length) % choices.length]);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
428
|
+
openPicker();
|
|
429
|
+
}
|
|
430
|
+
else if (key.leftArrow) {
|
|
431
|
+
setCaret(Math.max(0, caret - 1));
|
|
432
|
+
}
|
|
433
|
+
else if (key.rightArrow) {
|
|
434
|
+
setCaret(Math.min(draft.length, caret + 1));
|
|
435
|
+
}
|
|
436
|
+
else if (key.backspace || key.delete) {
|
|
437
|
+
if (caret > 0) {
|
|
438
|
+
setDraft(draft.slice(0, caret - 1) + draft.slice(caret));
|
|
439
|
+
setCaret(caret - 1);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
443
|
+
setDraft(draft.slice(0, caret) + input + draft.slice(caret));
|
|
444
|
+
setCaret(caret + input.length);
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if ((key.tab && !key.shift) || key.downArrow) {
|
|
449
|
+
setCursor(c => (c + 1) % fields.length);
|
|
450
|
+
}
|
|
451
|
+
else if ((key.tab && key.shift) || key.upArrow) {
|
|
452
|
+
setCursor(c => (c - 1 + fields.length) % fields.length);
|
|
453
|
+
}
|
|
454
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
455
|
+
openPicker();
|
|
456
|
+
}
|
|
457
|
+
else if (key.return) {
|
|
458
|
+
const start = current.kind === 'secret' ? '' : (values[current.key] ?? '');
|
|
459
|
+
setDraft(start);
|
|
460
|
+
setCaret(start.length);
|
|
461
|
+
setEditing(true);
|
|
462
|
+
}
|
|
463
|
+
}, { isActive: active });
|
|
464
|
+
const source = (f) => {
|
|
465
|
+
const required = f.required === true || f.required === method;
|
|
466
|
+
if (required && !(values[f.key] ?? ''))
|
|
467
|
+
return { text: t('✗ required'), color: 'red' };
|
|
468
|
+
if (f.kind === 'secret') {
|
|
469
|
+
return { text: storedSecret(project, f.key) ? t('keychain') : '—' };
|
|
470
|
+
}
|
|
471
|
+
const value = f.kind === 'bool' ? values[f.key] === 'yes' : values[f.key];
|
|
472
|
+
if (envMode && ENV_KEYS.has(f.key)) {
|
|
473
|
+
const override = project.base?.environments?.[project.environment];
|
|
474
|
+
const overridden = override &&
|
|
475
|
+
([
|
|
476
|
+
'clientId',
|
|
477
|
+
'authorizationEndpoint',
|
|
478
|
+
'tokenEndpoint',
|
|
479
|
+
'scopes',
|
|
480
|
+
].includes(f.key)
|
|
481
|
+
? Object.hasOwn(override, 'oauth2')
|
|
482
|
+
: Object.hasOwn(override, f.key));
|
|
483
|
+
return {
|
|
484
|
+
text: overridden ? project.environment : t('↑ dev'),
|
|
485
|
+
color: overridden ? 'yellow' : undefined,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const inOAuth2 = [
|
|
489
|
+
'clientId',
|
|
490
|
+
'authorizationEndpoint',
|
|
491
|
+
'tokenEndpoint',
|
|
492
|
+
].includes(f.key);
|
|
493
|
+
const inheritedValue = inOAuth2
|
|
494
|
+
? inherited['oauth2']?.[f.key]
|
|
495
|
+
: inherited[f.key];
|
|
496
|
+
if (inheritedValue !== undefined &&
|
|
497
|
+
JSON.stringify(inheritedValue) === JSON.stringify(value)) {
|
|
498
|
+
// A parent .dev_properties.json outranks package.json when it has the key.
|
|
499
|
+
return {
|
|
500
|
+
text: Object.hasOwn(ancestors, inOAuth2 ? 'oauth2' : f.key)
|
|
501
|
+
? t('↑ root')
|
|
502
|
+
: 'package.json',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return { text: (values[f.key] ?? '') ? t('local') : '' };
|
|
506
|
+
};
|
|
507
|
+
// label column (24) + source column + paddings; never below 20.
|
|
508
|
+
const valueWidth = Math.max(20, width - 24 - SOURCE_WIDTH - 2);
|
|
509
|
+
const rows = [];
|
|
510
|
+
let lastSection;
|
|
511
|
+
for (const f of fields) {
|
|
512
|
+
if (f.section && f.section !== lastSection) {
|
|
513
|
+
rows.push(_jsx(Box, { marginTop: 1, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: t(f.section) }) }, `s-${f.section}`));
|
|
514
|
+
lastSection = f.section;
|
|
515
|
+
}
|
|
516
|
+
const focused = active && f === current;
|
|
517
|
+
const typing = focused && editing;
|
|
518
|
+
// A value being typed into, scrolled so the caret stays in view and drawn
|
|
519
|
+
// with the caret as an inverted cell.
|
|
520
|
+
const withCaret = (text) => {
|
|
521
|
+
const max = Math.max(1, valueWidth - 1);
|
|
522
|
+
const start = text.length > max
|
|
523
|
+
? Math.min(Math.max(0, caret - max + 1), text.length - max)
|
|
524
|
+
: 0;
|
|
525
|
+
const shown = text.slice(start, start + max);
|
|
526
|
+
const at = caret - start;
|
|
527
|
+
return (_jsxs(Text, { children: [shown.slice(0, at), _jsx(Text, { inverse: true, children: shown[at] ?? ' ' }), shown.slice(at + 1)] }));
|
|
528
|
+
};
|
|
529
|
+
let display;
|
|
530
|
+
if (f.kind === 'method' || f.kind === 'bool') {
|
|
531
|
+
const choices = options(f);
|
|
532
|
+
const chosen = typing ? draft : values[f.key];
|
|
533
|
+
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)));
|
|
534
|
+
}
|
|
535
|
+
else if (f.kind === 'secret') {
|
|
536
|
+
display = typing ? (withCaret('•'.repeat(draft.length))) : (_jsx(Text, { dimColor: true, children: storedSecret(project, f.key)
|
|
537
|
+
? t('•••••••• keychain')
|
|
538
|
+
: f.hint
|
|
539
|
+
? t(f.hint)
|
|
540
|
+
: '' }));
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
const value = values[f.key];
|
|
544
|
+
display = typing ? (withCaret(draft)) : value ? (_jsx(Text, { children: value })) : (_jsx(Text, { dimColor: true, children: f.hint ? t(f.hint) : '—' }));
|
|
545
|
+
}
|
|
546
|
+
const src = source(f);
|
|
547
|
+
rows.push(_jsxs(Box, { height: 1, flexShrink: 0, children: [_jsx(Text, { color: focused ? ACCENT : undefined, bold: focused, dimColor: !focused, children: (focused ? '▸ ' : ' ') + t(f.label).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));
|
|
548
|
+
}
|
|
549
|
+
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, 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.", {
|
|
550
|
+
root: project.root,
|
|
551
|
+
}) }) })), _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
|
|
552
|
+
? t('no workspace package.json · y to set up')
|
|
553
|
+
: changes.length === 0
|
|
554
|
+
? t('in sync')
|
|
555
|
+
: changes.length === 1
|
|
556
|
+
? t('1 diff · y to apply')
|
|
557
|
+
: t('{n} diffs · y to apply', { n: changes.length }) })] }), 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: t(current.label) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", t(current.help)] })] }) }), _jsx(Text, { color: "yellow", children: note })] }));
|
|
558
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ProjectInfo } from '../types/index.js';
|
|
3
|
+
import { type Task } from '../utils/tasks.js';
|
|
4
|
+
export declare const ACCENT = "cyan";
|
|
5
|
+
export declare const NARROW_BELOW = 100;
|
|
6
|
+
/** Navigator width for a terminal: a quarter of the columns, within 32..48. */
|
|
7
|
+
export declare function navWidth(columns: number): number;
|
|
8
|
+
export interface TopBarProps {
|
|
9
|
+
context: string;
|
|
10
|
+
domain?: string;
|
|
11
|
+
auth: {
|
|
12
|
+
ready: boolean;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
version: string;
|
|
16
|
+
environment?: {
|
|
17
|
+
name: string;
|
|
18
|
+
color: 'green' | 'yellow' | 'red';
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function TopBar({ context, domain, auth, version, environment, }: TopBarProps): import("react").JSX.Element;
|
|
22
|
+
/** Three-letter type marker; unknown manifest types show as "???". */
|
|
23
|
+
export declare function typeGlyph(manifest: ProjectInfo['manifest']): string;
|
|
24
|
+
export declare function appStatus(project: ProjectInfo): {
|
|
25
|
+
deps: boolean;
|
|
26
|
+
config: boolean;
|
|
27
|
+
sync: number;
|
|
28
|
+
signing: boolean;
|
|
29
|
+
scriptsWarning: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
/** The name shown for an app in the navigator, and what the filter matches. */
|
|
32
|
+
export declare function appLabel(app: ProjectInfo): string;
|
|
33
|
+
/** Indices into `apps` whose label fuzzy-matches the filter. */
|
|
34
|
+
export declare function navMatches(apps: ProjectInfo[], filter: string): number[];
|
|
35
|
+
/** Next selectable index when moving by `delta`, wrapping at both ends. */
|
|
36
|
+
export declare function navMove(ring: number[], selected: number, delta: number): number;
|
|
37
|
+
export interface NavigatorProps {
|
|
38
|
+
apps: ProjectInfo[];
|
|
39
|
+
groupOf: (app: ProjectInfo) => string;
|
|
40
|
+
selected: number;
|
|
41
|
+
focused: boolean;
|
|
42
|
+
tasks: Task[];
|
|
43
|
+
height: number;
|
|
44
|
+
single: boolean;
|
|
45
|
+
settingsSelected?: boolean;
|
|
46
|
+
width: number;
|
|
47
|
+
filter?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function Navigator({ apps, groupOf, selected, focused, tasks, height, single, settingsSelected, width, filter, }: NavigatorProps): import("react").JSX.Element;
|
|
50
|
+
export declare function NavigatorStrip({ apps, selected, focused, width, filter, }: Pick<NavigatorProps, 'apps' | 'selected' | 'focused' | 'width' | 'filter'>): import("react").JSX.Element;
|
|
51
|
+
export declare function elapsed(task: Task): string;
|
|
52
|
+
export interface Hint {
|
|
53
|
+
key: string;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function BottomBar({ hints, right }: {
|
|
57
|
+
hints: Hint[];
|
|
58
|
+
right: ReactNode;
|
|
59
|
+
}): import("react").JSX.Element;
|