sitevision-cli 1.0.0-beta.2 → 1.0.0-beta.20
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 +35 -0
- package/dist/shell/ConfigForm.js +499 -0
- package/dist/shell/Frame.d.ts +59 -0
- package/dist/shell/Frame.js +136 -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 +576 -0
- package/dist/shell/Tabs.d.ts +36 -0
- package/dist/shell/Tabs.js +85 -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 +277 -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 +81 -4
- package/dist/utils/project-detection.js +298 -51
- 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 +99 -24
|
@@ -0,0 +1,499 @@
|
|
|
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 { getPackageJsonSyncChanges, normalizeDomain, readInheritedDevProperties, 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
|
+
/** Apply the form to disk and the keychain. Exported for the test. */
|
|
189
|
+
export function saveConfig(project, values, edited) {
|
|
190
|
+
const method = values['authMethod'];
|
|
191
|
+
const next = {
|
|
192
|
+
domain: values['domain'],
|
|
193
|
+
siteName: values['siteName'],
|
|
194
|
+
addonName: values['addonName'],
|
|
195
|
+
username: values['username'],
|
|
196
|
+
authMethod: method,
|
|
197
|
+
useHTTPForDevDeploy: values['useHTTPForDevDeploy'] === 'yes',
|
|
198
|
+
};
|
|
199
|
+
if (values['baseEnvironment'])
|
|
200
|
+
next.baseEnvironment = values['baseEnvironment'].trim().toLowerCase();
|
|
201
|
+
if (values['production'] === 'yes')
|
|
202
|
+
next.production = true;
|
|
203
|
+
if (values['signingUsername'])
|
|
204
|
+
next.signingUsername = values['signingUsername'];
|
|
205
|
+
if (values['certificateName'])
|
|
206
|
+
next.certificateName = values['certificateName'];
|
|
207
|
+
if (method === 'oauth2') {
|
|
208
|
+
const scopes = values['scopes'].split(/[\s,]+/).filter(Boolean);
|
|
209
|
+
next.oauth2 = {
|
|
210
|
+
authorizationEndpoint: values['authorizationEndpoint'],
|
|
211
|
+
tokenEndpoint: values['tokenEndpoint'],
|
|
212
|
+
clientId: values['clientId'],
|
|
213
|
+
...(scopes.length > 0 && { scopes }),
|
|
214
|
+
...(project.devProperties?.oauth2?.redirectPort && {
|
|
215
|
+
redirectPort: project.devProperties.oauth2.redirectPort,
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
else if (method === 'cookie' && values['sessionLoginUrl']) {
|
|
220
|
+
next.sessionLoginUrl = values['sessionLoginUrl'];
|
|
221
|
+
}
|
|
222
|
+
const env = project.environment;
|
|
223
|
+
if (env && env !== baseEnvironment(project.base) && project.base) {
|
|
224
|
+
// Non-dev environment: site/auth fields become an override, signing
|
|
225
|
+
// fields still live on the base.
|
|
226
|
+
const base = { ...project.base };
|
|
227
|
+
base.signingUsername = next.signingUsername;
|
|
228
|
+
base.certificateName = next.certificateName;
|
|
229
|
+
base.baseEnvironment = next.baseEnvironment;
|
|
230
|
+
base.production = next.production;
|
|
231
|
+
writeDevProperties(project.root, withEnvironmentOverride(base, env, {
|
|
232
|
+
domain: next.domain,
|
|
233
|
+
siteName: next.siteName,
|
|
234
|
+
addonName: next.addonName,
|
|
235
|
+
username: next.username,
|
|
236
|
+
authMethod: next.authMethod,
|
|
237
|
+
useHTTPForDevDeploy: next.useHTTPForDevDeploy,
|
|
238
|
+
oauth2: next.oauth2,
|
|
239
|
+
sessionLoginUrl: next.sessionLoginUrl,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
writeDevProperties(project.root, {
|
|
244
|
+
...next,
|
|
245
|
+
environments: project.base?.environments ?? project.devProperties?.environments,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const secret = (key, set, del) => {
|
|
249
|
+
if (!edited.has(key))
|
|
250
|
+
return;
|
|
251
|
+
const value = values[key] ?? '';
|
|
252
|
+
if (value)
|
|
253
|
+
set(value);
|
|
254
|
+
else
|
|
255
|
+
del();
|
|
256
|
+
};
|
|
257
|
+
secret('password', v => setDeployPassword(next.domain, next.username, v), () => deleteDeployPassword(next.domain, next.username));
|
|
258
|
+
if (next.oauth2) {
|
|
259
|
+
const { clientId } = next.oauth2;
|
|
260
|
+
secret('clientSecret', v => setOAuth2ClientSecret(next.domain, clientId, v), () => deleteOAuth2ClientSecret(next.domain, clientId));
|
|
261
|
+
}
|
|
262
|
+
if (next.signingUsername) {
|
|
263
|
+
const user = next.signingUsername;
|
|
264
|
+
secret('signingPassword', v => setSigningPassword(user, v), () => deleteSigningPassword(user));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export function visibleFields(method, workspace = false, envMode = false) {
|
|
268
|
+
return FIELDS.filter(f => (!f.when || f.when === method) &&
|
|
269
|
+
!(workspace && f.perApp) &&
|
|
270
|
+
!(envMode && (f.section === 'SIGNING' || f.section === 'ENVIRONMENT')));
|
|
271
|
+
}
|
|
272
|
+
export function ConfigForm({ project, active, width, height, pickAddon, onSaved, onEditingChange, }) {
|
|
273
|
+
const [values, setValues] = useState(() => fromProject(project));
|
|
274
|
+
const [cursor, setCursor] = useState(0);
|
|
275
|
+
const [editing, setEditing] = useState(false);
|
|
276
|
+
const [draft, setDraft] = useState('');
|
|
277
|
+
const [caret, setCaret] = useState(0);
|
|
278
|
+
const [note, setNote] = useState('');
|
|
279
|
+
// Values always mirror the project; edits are committed field by field.
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
setValues(fromProject(project));
|
|
282
|
+
}, [project]);
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
onEditingChange(editing);
|
|
285
|
+
return () => {
|
|
286
|
+
onEditingChange(false);
|
|
287
|
+
};
|
|
288
|
+
}, [editing, onEditingChange]);
|
|
289
|
+
const method = values['authMethod'];
|
|
290
|
+
const envMode = Boolean(project.environment &&
|
|
291
|
+
project.environment !==
|
|
292
|
+
baseEnvironment(project.base ?? project.devProperties));
|
|
293
|
+
const fields = visibleFields(method, project.workspace, envMode);
|
|
294
|
+
const current = fields[Math.min(cursor, fields.length - 1)];
|
|
295
|
+
const inherited = readInheritedDevProperties(project.root);
|
|
296
|
+
const changes = project.devProperties && !project.workspace
|
|
297
|
+
? getPackageJsonSyncChanges(project.root, project.devProperties)
|
|
298
|
+
: [];
|
|
299
|
+
// Write one field to disk (and the keychain for secrets) right away.
|
|
300
|
+
const commit = (key, value, label = current.label) => {
|
|
301
|
+
const clean = key === 'domain' ? normalizeDomain(value) : value;
|
|
302
|
+
const next = { ...values, [key]: clean };
|
|
303
|
+
setValues(next);
|
|
304
|
+
saveConfig(project, next, new Set([key]));
|
|
305
|
+
setNote(clean === value
|
|
306
|
+
? t('Saved {label}.', { label: t(label) })
|
|
307
|
+
: t('Saved {label} as {value} — a domain is a host only.', {
|
|
308
|
+
label: t(label),
|
|
309
|
+
value: clean,
|
|
310
|
+
}));
|
|
311
|
+
onSaved();
|
|
312
|
+
};
|
|
313
|
+
// Auto-fill OAuth2 endpoints from the site's OpenID configuration.
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
if (method !== 'oauth2' || !values['domain'])
|
|
316
|
+
return;
|
|
317
|
+
if (values['authorizationEndpoint'] && values['tokenEndpoint'])
|
|
318
|
+
return;
|
|
319
|
+
let cancelled = false;
|
|
320
|
+
setNote(t('Looking up OAuth2 endpoints…'));
|
|
321
|
+
void discoverOAuth2Config(values['domain'], values['useHTTPForDevDeploy'] === 'yes').then(found => {
|
|
322
|
+
if (cancelled)
|
|
323
|
+
return;
|
|
324
|
+
if (found) {
|
|
325
|
+
const next = {
|
|
326
|
+
...values,
|
|
327
|
+
authorizationEndpoint: values['authorizationEndpoint'] || found.authorizationEndpoint,
|
|
328
|
+
tokenEndpoint: values['tokenEndpoint'] || found.tokenEndpoint,
|
|
329
|
+
};
|
|
330
|
+
setValues(next);
|
|
331
|
+
saveConfig(project, next, new Set());
|
|
332
|
+
onSaved();
|
|
333
|
+
setNote(t('Endpoints filled from the site OpenID config.'));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
setNote(t('Could not discover OAuth2 endpoints; enter them by hand.'));
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
return () => {
|
|
340
|
+
cancelled = true;
|
|
341
|
+
};
|
|
342
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
343
|
+
}, [method, values['domain']]);
|
|
344
|
+
// Options for a choice row; the draft holds the highlighted one while editing.
|
|
345
|
+
const options = (f) => f.kind === 'method' ? METHODS : f.kind === 'bool' ? ['yes', 'no'] : [];
|
|
346
|
+
const openPicker = () => {
|
|
347
|
+
void pickAddon().then(name => {
|
|
348
|
+
if (name) {
|
|
349
|
+
setEditing(false);
|
|
350
|
+
commit('addonName', name, 'Addon name');
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
useInput((input, key) => {
|
|
355
|
+
if (editing) {
|
|
356
|
+
const choices = options(current);
|
|
357
|
+
if (key.escape) {
|
|
358
|
+
setEditing(false);
|
|
359
|
+
}
|
|
360
|
+
else if (key.return) {
|
|
361
|
+
setEditing(false);
|
|
362
|
+
if (draft !== values[current.key])
|
|
363
|
+
commit(current.key, draft);
|
|
364
|
+
}
|
|
365
|
+
else if (choices.length > 0) {
|
|
366
|
+
const step = key.leftArrow || key.upArrow
|
|
367
|
+
? -1
|
|
368
|
+
: key.rightArrow || key.downArrow || input === ' '
|
|
369
|
+
? 1
|
|
370
|
+
: 0;
|
|
371
|
+
if (step !== 0) {
|
|
372
|
+
const i = choices.indexOf(draft);
|
|
373
|
+
setDraft(choices[(i + step + choices.length) % choices.length]);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
377
|
+
openPicker();
|
|
378
|
+
}
|
|
379
|
+
else if (key.leftArrow) {
|
|
380
|
+
setCaret(Math.max(0, caret - 1));
|
|
381
|
+
}
|
|
382
|
+
else if (key.rightArrow) {
|
|
383
|
+
setCaret(Math.min(draft.length, caret + 1));
|
|
384
|
+
}
|
|
385
|
+
else if (key.backspace || key.delete) {
|
|
386
|
+
if (caret > 0) {
|
|
387
|
+
setDraft(draft.slice(0, caret - 1) + draft.slice(caret));
|
|
388
|
+
setCaret(caret - 1);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
392
|
+
setDraft(draft.slice(0, caret) + input + draft.slice(caret));
|
|
393
|
+
setCaret(caret + input.length);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if ((key.tab && !key.shift) || key.downArrow) {
|
|
398
|
+
setCursor(c => (c + 1) % fields.length);
|
|
399
|
+
}
|
|
400
|
+
else if ((key.tab && key.shift) || key.upArrow) {
|
|
401
|
+
setCursor(c => (c - 1 + fields.length) % fields.length);
|
|
402
|
+
}
|
|
403
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
404
|
+
openPicker();
|
|
405
|
+
}
|
|
406
|
+
else if (key.return) {
|
|
407
|
+
const start = current.kind === 'secret' ? '' : (values[current.key] ?? '');
|
|
408
|
+
setDraft(start);
|
|
409
|
+
setCaret(start.length);
|
|
410
|
+
setEditing(true);
|
|
411
|
+
}
|
|
412
|
+
}, { isActive: active });
|
|
413
|
+
const source = (f) => {
|
|
414
|
+
const required = f.required === true || f.required === method;
|
|
415
|
+
if (required && !(values[f.key] ?? ''))
|
|
416
|
+
return { text: t('✗ required'), color: 'red' };
|
|
417
|
+
if (f.kind === 'secret') {
|
|
418
|
+
return { text: storedSecret(project, f.key) ? t('keychain') : '—' };
|
|
419
|
+
}
|
|
420
|
+
const value = f.kind === 'bool' ? values[f.key] === 'yes' : values[f.key];
|
|
421
|
+
if (envMode && ENV_KEYS.has(f.key)) {
|
|
422
|
+
const override = project.base?.environments?.[project.environment];
|
|
423
|
+
const overridden = override &&
|
|
424
|
+
([
|
|
425
|
+
'clientId',
|
|
426
|
+
'authorizationEndpoint',
|
|
427
|
+
'tokenEndpoint',
|
|
428
|
+
'scopes',
|
|
429
|
+
].includes(f.key)
|
|
430
|
+
? Object.hasOwn(override, 'oauth2')
|
|
431
|
+
: Object.hasOwn(override, f.key));
|
|
432
|
+
return {
|
|
433
|
+
text: overridden ? project.environment : t('↑ dev'),
|
|
434
|
+
color: overridden ? 'yellow' : undefined,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const inheritedValue = [
|
|
438
|
+
'clientId',
|
|
439
|
+
'authorizationEndpoint',
|
|
440
|
+
'tokenEndpoint',
|
|
441
|
+
].includes(f.key)
|
|
442
|
+
? inherited['oauth2']?.[f.key]
|
|
443
|
+
: inherited[f.key];
|
|
444
|
+
if (inheritedValue !== undefined &&
|
|
445
|
+
JSON.stringify(inheritedValue) === JSON.stringify(value)) {
|
|
446
|
+
return { text: t('↑ root') };
|
|
447
|
+
}
|
|
448
|
+
return { text: (values[f.key] ?? '') ? t('local') : '' };
|
|
449
|
+
};
|
|
450
|
+
// label column (24) + source column + paddings; never below 20.
|
|
451
|
+
const valueWidth = Math.max(20, width - 24 - SOURCE_WIDTH - 2);
|
|
452
|
+
const rows = [];
|
|
453
|
+
let lastSection;
|
|
454
|
+
for (const f of fields) {
|
|
455
|
+
if (f.section && f.section !== lastSection) {
|
|
456
|
+
rows.push(_jsx(Box, { marginTop: 1, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: t(f.section) }) }, `s-${f.section}`));
|
|
457
|
+
lastSection = f.section;
|
|
458
|
+
}
|
|
459
|
+
const focused = active && f === current;
|
|
460
|
+
const typing = focused && editing;
|
|
461
|
+
// A value being typed into, scrolled so the caret stays in view and drawn
|
|
462
|
+
// with the caret as an inverted cell.
|
|
463
|
+
const withCaret = (text) => {
|
|
464
|
+
const max = Math.max(1, valueWidth - 1);
|
|
465
|
+
const start = text.length > max
|
|
466
|
+
? Math.min(Math.max(0, caret - max + 1), text.length - max)
|
|
467
|
+
: 0;
|
|
468
|
+
const shown = text.slice(start, start + max);
|
|
469
|
+
const at = caret - start;
|
|
470
|
+
return (_jsxs(Text, { children: [shown.slice(0, at), _jsx(Text, { inverse: true, children: shown[at] ?? ' ' }), shown.slice(at + 1)] }));
|
|
471
|
+
};
|
|
472
|
+
let display;
|
|
473
|
+
if (f.kind === 'method' || f.kind === 'bool') {
|
|
474
|
+
const choices = options(f);
|
|
475
|
+
const chosen = typing ? draft : values[f.key];
|
|
476
|
+
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)));
|
|
477
|
+
}
|
|
478
|
+
else if (f.kind === 'secret') {
|
|
479
|
+
display = typing ? (withCaret('•'.repeat(draft.length))) : (_jsx(Text, { dimColor: true, children: storedSecret(project, f.key)
|
|
480
|
+
? t('•••••••• keychain')
|
|
481
|
+
: f.hint
|
|
482
|
+
? t(f.hint)
|
|
483
|
+
: '' }));
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
const value = values[f.key];
|
|
487
|
+
display = typing ? (withCaret(draft)) : value ? (_jsx(Text, { children: value })) : (_jsx(Text, { dimColor: true, children: f.hint ? t(f.hint) : '—' }));
|
|
488
|
+
}
|
|
489
|
+
const src = source(f);
|
|
490
|
+
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));
|
|
491
|
+
}
|
|
492
|
+
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.", {
|
|
493
|
+
root: project.root,
|
|
494
|
+
}) }) })), !project.workspace && (_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 ? 'yellow' : 'green', children: changes.length === 0
|
|
495
|
+
? t('in sync')
|
|
496
|
+
: changes.length === 1
|
|
497
|
+
? t('1 diff · y to apply')
|
|
498
|
+
: 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 })] }));
|
|
499
|
+
}
|
|
@@ -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;
|