sitevision-cli 1.0.0-beta.13 → 1.0.0-beta.14
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/cli.js +70 -41
- package/dist/commands/build.js +1 -1
- package/dist/commands/dev.d.ts +8 -10
- package/dist/commands/dev.js +75 -390
- package/dist/commands/watch.js +5 -23
- package/dist/components/AuthLoginScreen.js +2 -1
- package/dist/components/DevPropertiesForm.js +6 -3
- package/dist/components/PasswordInput.js +2 -1
- 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 +472 -0
- package/dist/shell/Frame.d.ts +52 -0
- package/dist/shell/Frame.js +98 -0
- package/dist/shell/Settings.d.ts +6 -0
- package/dist/shell/Settings.js +96 -0
- package/dist/shell/Shell.d.ts +8 -0
- package/dist/shell/Shell.js +520 -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 +12 -2
- 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 +263 -0
- package/dist/utils/oauth2-auth.d.ts +1 -0
- package/dist/utils/oauth2-auth.js +4 -3
- package/dist/utils/project-detection.d.ts +23 -1
- package/dist/utils/project-detection.js +124 -51
- package/dist/utils/sitevision-api.d.ts +35 -0
- package/dist/utils/sitevision-api.js +74 -1
- package/dist/utils/tasks.d.ts +48 -0
- package/dist/utils/tasks.js +371 -0
- package/dist/utils/workspace.d.ts +8 -0
- package/dist/utils/workspace.js +48 -0
- package/package.json +1 -1
- package/readme.md +76 -24
|
@@ -0,0 +1,54 @@
|
|
|
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 Spinner from 'ink-spinner';
|
|
5
|
+
import { ACCENT } from './Frame.js';
|
|
6
|
+
import { fuzzyMatch } from './actions.js';
|
|
7
|
+
import { t } from '../utils/i18n.js';
|
|
8
|
+
export function AddonPicker({ domain, appType, initialQuery, load, onSelect, onClose, height, }) {
|
|
9
|
+
const [query, setQuery] = useState(initialQuery);
|
|
10
|
+
const [index, setIndex] = useState(0);
|
|
11
|
+
const [addons, setAddons] = useState(null);
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
void load().then(result => {
|
|
15
|
+
setAddons(result.addons ?? []);
|
|
16
|
+
setError(result.error ?? '');
|
|
17
|
+
});
|
|
18
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
19
|
+
}, []);
|
|
20
|
+
const matches = (addons ?? [])
|
|
21
|
+
.filter(a => fuzzyMatch(query, a.name))
|
|
22
|
+
.toSorted((a, b) => Number(b.appType === appType) - Number(a.appType === appType) ||
|
|
23
|
+
a.name.localeCompare(b.name));
|
|
24
|
+
useInput((input, key) => {
|
|
25
|
+
if (key.escape) {
|
|
26
|
+
onClose();
|
|
27
|
+
}
|
|
28
|
+
else if (key.return) {
|
|
29
|
+
const pick = matches[index];
|
|
30
|
+
if (pick)
|
|
31
|
+
onSelect(pick.name);
|
|
32
|
+
}
|
|
33
|
+
else if (key.upArrow) {
|
|
34
|
+
setIndex(i => (i > 0 ? i - 1 : Math.max(0, matches.length - 1)));
|
|
35
|
+
}
|
|
36
|
+
else if (key.downArrow) {
|
|
37
|
+
setIndex(i => (i < matches.length - 1 ? i + 1 : 0));
|
|
38
|
+
}
|
|
39
|
+
else if (key.backspace || key.delete) {
|
|
40
|
+
setQuery(q => q.slice(0, -1));
|
|
41
|
+
setIndex(0);
|
|
42
|
+
}
|
|
43
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
44
|
+
setQuery(q => q + input);
|
|
45
|
+
setIndex(0);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
const visible = Math.max(3, height - 6);
|
|
49
|
+
const start = Math.max(0, Math.min(index - visible + 1, matches.length - visible));
|
|
50
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 1, width: 64, height: Math.min(height, matches.length + 6), children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: t('Addon Repository') }), _jsxs(Text, { dimColor: true, children: [" ", domain] })] }), _jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), query, _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { dimColor: true, children: [' ', addons ? t('{n} of {m}', { n: matches.length, m: addons.length }) : ''] })] }), !addons && !error && (_jsxs(Text, { color: ACCENT, children: [_jsx(Spinner, { type: "dots" }), " ", _jsx(Text, { dimColor: true, children: t('fetching addons') })] })), error && _jsx(Text, { color: "red", children: error }), matches.slice(start, start + visible).map((a, i) => {
|
|
51
|
+
const selected = start + i === index;
|
|
52
|
+
return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, marginRight: 1, children: _jsxs(Text, { backgroundColor: selected ? ACCENT : undefined, color: selected ? 'black' : undefined, wrap: "truncate", children: [' ', a.name] }) }), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: a.appType ?? a.type }) })] }, a.id));
|
|
53
|
+
}), _jsx(Text, { dimColor: true, children: t('↑↓ move · Enter select · Esc cancel') })] }));
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ProjectInfo } from '../types/index.js';
|
|
2
|
+
import { type Action } from './actions.js';
|
|
3
|
+
export declare function CommandPalette({ project, onRun, onClose, height, }: {
|
|
4
|
+
project: ProjectInfo;
|
|
5
|
+
onRun: (action: Action) => void;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
height: number;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { ACCENT } from './Frame.js';
|
|
5
|
+
import { actions, fuzzyMatch } from './actions.js';
|
|
6
|
+
import { t } from '../utils/i18n.js';
|
|
7
|
+
const GROUPS = [
|
|
8
|
+
{ id: 'app', label: 'APP' },
|
|
9
|
+
{ id: 'setup', label: 'SETUP' },
|
|
10
|
+
{ id: 'auth', label: 'AUTH' },
|
|
11
|
+
];
|
|
12
|
+
export function CommandPalette({ project, onRun, onClose, height, }) {
|
|
13
|
+
const [query, setQuery] = useState('');
|
|
14
|
+
const [index, setIndex] = useState(0);
|
|
15
|
+
const matches = actions.filter(a => fuzzyMatch(query, t(a.label)));
|
|
16
|
+
const ordered = GROUPS.flatMap(g => matches.filter(a => a.group === g.id));
|
|
17
|
+
useInput((input, key) => {
|
|
18
|
+
if (key.escape) {
|
|
19
|
+
onClose();
|
|
20
|
+
}
|
|
21
|
+
else if (key.return) {
|
|
22
|
+
const action = ordered[index];
|
|
23
|
+
if (action)
|
|
24
|
+
onRun(action);
|
|
25
|
+
}
|
|
26
|
+
else if (key.upArrow) {
|
|
27
|
+
setIndex(i => (i > 0 ? i - 1 : Math.max(0, ordered.length - 1)));
|
|
28
|
+
}
|
|
29
|
+
else if (key.downArrow) {
|
|
30
|
+
setIndex(i => (i < ordered.length - 1 ? i + 1 : 0));
|
|
31
|
+
}
|
|
32
|
+
else if (key.backspace || key.delete) {
|
|
33
|
+
setQuery(q => q.slice(0, -1));
|
|
34
|
+
setIndex(0);
|
|
35
|
+
}
|
|
36
|
+
else if (!key.ctrl && !key.meta && input) {
|
|
37
|
+
setQuery(q => q + input);
|
|
38
|
+
setIndex(0);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
// Rows available inside the box: borders (2), title, query, footer.
|
|
42
|
+
const maxRows = Math.max(3, height - 5);
|
|
43
|
+
const rows = [];
|
|
44
|
+
let lastGroup;
|
|
45
|
+
// Keep the selection visible: skip leading rows when it is far down.
|
|
46
|
+
let skip = 0;
|
|
47
|
+
while (index - skip + 1 + 3 > maxRows)
|
|
48
|
+
skip += 1;
|
|
49
|
+
for (const [i, action] of ordered.entries()) {
|
|
50
|
+
if (i < skip)
|
|
51
|
+
continue;
|
|
52
|
+
if (rows.length >= maxRows)
|
|
53
|
+
break;
|
|
54
|
+
if (action.group !== lastGroup) {
|
|
55
|
+
rows.push(_jsx(Text, { bold: true, dimColor: true, children: t(GROUPS.find(g => g.id === action.group).label) }, `g-${action.group}`));
|
|
56
|
+
lastGroup = action.group;
|
|
57
|
+
}
|
|
58
|
+
const enabled = action.enabled?.(project) ?? true;
|
|
59
|
+
const detail = action.detail?.(project);
|
|
60
|
+
rows.push(_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, marginRight: 1, children: _jsxs(Text, { backgroundColor: i === index ? ACCENT : undefined, color: i === index ? 'black' : undefined, dimColor: !enabled && i !== index, wrap: "truncate", children: [' ', t(action.label), detail && _jsxs(Text, { dimColor: i !== index, children: [" \u00B7 ", detail] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { bold: true, color: ACCENT, children: action.key ?? '—' }) })] }, action.id));
|
|
61
|
+
}
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 1, width: 64, height: rows.length + 5, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: t('Commands') }), _jsxs(Text, { dimColor: true, children: [" ", project.manifest.id] })] }), _jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), query, _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { dimColor: true, children: [" ", t('{n} actions', { n: ordered.length })] })] }), rows, _jsx(Text, { dimColor: true, children: t('type to filter · ↑↓ move · Enter run · Esc close') })] }));
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DevProperties } from '../types/index.js';
|
|
2
|
+
type Method = 'basic' | 'oauth2' | 'cookie';
|
|
3
|
+
interface Field {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
kind?: 'text' | 'method' | 'bool' | 'secret';
|
|
7
|
+
required?: boolean;
|
|
8
|
+
when?: Method;
|
|
9
|
+
section?: string;
|
|
10
|
+
hint?: string;
|
|
11
|
+
perApp?: boolean;
|
|
12
|
+
help: string;
|
|
13
|
+
}
|
|
14
|
+
type Values = Record<string, string>;
|
|
15
|
+
/** What the form edits: an app, or the workspace root (no addon, no package.json). */
|
|
16
|
+
export interface ConfigTarget {
|
|
17
|
+
root: string;
|
|
18
|
+
devProperties?: Partial<DevProperties>;
|
|
19
|
+
base?: Partial<DevProperties>;
|
|
20
|
+
environment?: string;
|
|
21
|
+
workspace?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Apply the form to disk and the keychain. Exported for the test. */
|
|
24
|
+
export declare function saveConfig(project: ConfigTarget, values: Values, edited: Set<string>): void;
|
|
25
|
+
export declare function visibleFields(method: Method, workspace?: boolean, envMode?: boolean): Field[];
|
|
26
|
+
export declare function ConfigForm({ project, active, width, height, pickAddon, onSaved, onEditingChange, }: {
|
|
27
|
+
project: ConfigTarget;
|
|
28
|
+
active: boolean;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
pickAddon: () => Promise<string | null>;
|
|
32
|
+
onSaved: () => void;
|
|
33
|
+
onEditingChange: (editing: boolean) => void;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,472 @@
|
|
|
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, 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 the development environment (USE or TSE) without https://, e.g. myorg-use.sitevision-cloud.se. Deploys and version lookups go here.',
|
|
15
|
+
label: 'Development 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.',
|
|
35
|
+
label: 'Username',
|
|
36
|
+
required: true,
|
|
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 [note, setNote] = useState('');
|
|
278
|
+
// Values always mirror the project; edits are committed field by field.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
setValues(fromProject(project));
|
|
281
|
+
}, [project]);
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
onEditingChange(editing);
|
|
284
|
+
return () => {
|
|
285
|
+
onEditingChange(false);
|
|
286
|
+
};
|
|
287
|
+
}, [editing, onEditingChange]);
|
|
288
|
+
const method = values['authMethod'];
|
|
289
|
+
const envMode = Boolean(project.environment &&
|
|
290
|
+
project.environment !==
|
|
291
|
+
baseEnvironment(project.base ?? project.devProperties));
|
|
292
|
+
const fields = visibleFields(method, project.workspace, envMode);
|
|
293
|
+
const current = fields[Math.min(cursor, fields.length - 1)];
|
|
294
|
+
const inherited = readInheritedDevProperties(project.root);
|
|
295
|
+
const changes = project.devProperties && !project.workspace
|
|
296
|
+
? getPackageJsonSyncChanges(project.root, project.devProperties)
|
|
297
|
+
: [];
|
|
298
|
+
// Write one field to disk (and the keychain for secrets) right away.
|
|
299
|
+
const commit = (key, value, label = current.label) => {
|
|
300
|
+
const next = { ...values, [key]: value };
|
|
301
|
+
setValues(next);
|
|
302
|
+
saveConfig(project, next, new Set([key]));
|
|
303
|
+
setNote(t('Saved {label}.', { label: t(label) }));
|
|
304
|
+
onSaved();
|
|
305
|
+
};
|
|
306
|
+
// Auto-fill OAuth2 endpoints from the site's OpenID configuration.
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (method !== 'oauth2' || !values['domain'])
|
|
309
|
+
return;
|
|
310
|
+
if (values['authorizationEndpoint'] && values['tokenEndpoint'])
|
|
311
|
+
return;
|
|
312
|
+
let cancelled = false;
|
|
313
|
+
setNote(t('Looking up OAuth2 endpoints…'));
|
|
314
|
+
void discoverOAuth2Config(values['domain'], values['useHTTPForDevDeploy'] === 'yes').then(found => {
|
|
315
|
+
if (cancelled)
|
|
316
|
+
return;
|
|
317
|
+
if (found) {
|
|
318
|
+
const next = {
|
|
319
|
+
...values,
|
|
320
|
+
authorizationEndpoint: values['authorizationEndpoint'] || found.authorizationEndpoint,
|
|
321
|
+
tokenEndpoint: values['tokenEndpoint'] || found.tokenEndpoint,
|
|
322
|
+
};
|
|
323
|
+
setValues(next);
|
|
324
|
+
saveConfig(project, next, new Set());
|
|
325
|
+
onSaved();
|
|
326
|
+
setNote(t('Endpoints filled from the site OpenID config.'));
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
setNote(t('Could not discover OAuth2 endpoints; enter them by hand.'));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return () => {
|
|
333
|
+
cancelled = true;
|
|
334
|
+
};
|
|
335
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
336
|
+
}, [method, values['domain']]);
|
|
337
|
+
// Options for a choice row; the draft holds the highlighted one while editing.
|
|
338
|
+
const options = (f) => f.kind === 'method' ? METHODS : f.kind === 'bool' ? ['yes', 'no'] : [];
|
|
339
|
+
const openPicker = () => {
|
|
340
|
+
void pickAddon().then(name => {
|
|
341
|
+
if (name) {
|
|
342
|
+
setEditing(false);
|
|
343
|
+
commit('addonName', name, 'Addon name');
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
useInput((input, key) => {
|
|
348
|
+
if (editing) {
|
|
349
|
+
const choices = options(current);
|
|
350
|
+
if (key.escape) {
|
|
351
|
+
setEditing(false);
|
|
352
|
+
}
|
|
353
|
+
else if (key.return) {
|
|
354
|
+
setEditing(false);
|
|
355
|
+
if (draft !== values[current.key])
|
|
356
|
+
commit(current.key, draft);
|
|
357
|
+
}
|
|
358
|
+
else if (choices.length > 0) {
|
|
359
|
+
const step = key.leftArrow || key.upArrow
|
|
360
|
+
? -1
|
|
361
|
+
: key.rightArrow || key.downArrow || input === ' '
|
|
362
|
+
? 1
|
|
363
|
+
: 0;
|
|
364
|
+
if (step !== 0) {
|
|
365
|
+
const i = choices.indexOf(draft);
|
|
366
|
+
setDraft(choices[(i + step + choices.length) % choices.length]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
370
|
+
openPicker();
|
|
371
|
+
}
|
|
372
|
+
else if (key.backspace || key.delete) {
|
|
373
|
+
setDraft(d => d.slice(0, -1));
|
|
374
|
+
}
|
|
375
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
376
|
+
setDraft(d => d + input);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if ((key.tab && !key.shift) || key.downArrow) {
|
|
381
|
+
setCursor(c => (c + 1) % fields.length);
|
|
382
|
+
}
|
|
383
|
+
else if ((key.tab && key.shift) || key.upArrow) {
|
|
384
|
+
setCursor(c => (c - 1 + fields.length) % fields.length);
|
|
385
|
+
}
|
|
386
|
+
else if (key.ctrl && input === 'o' && current.key === 'addonName') {
|
|
387
|
+
openPicker();
|
|
388
|
+
}
|
|
389
|
+
else if (key.return) {
|
|
390
|
+
setDraft(current.kind === 'secret' ? '' : (values[current.key] ?? ''));
|
|
391
|
+
setEditing(true);
|
|
392
|
+
}
|
|
393
|
+
}, { isActive: active });
|
|
394
|
+
const source = (f) => {
|
|
395
|
+
if (f.required && !(values[f.key] ?? ''))
|
|
396
|
+
return { text: t('✗ required'), color: 'red' };
|
|
397
|
+
if (f.kind === 'secret') {
|
|
398
|
+
return { text: storedSecret(project, f.key) ? t('keychain') : '—' };
|
|
399
|
+
}
|
|
400
|
+
const value = f.kind === 'bool' ? values[f.key] === 'yes' : values[f.key];
|
|
401
|
+
if (envMode && ENV_KEYS.has(f.key)) {
|
|
402
|
+
const override = project.base?.environments?.[project.environment];
|
|
403
|
+
const overridden = override &&
|
|
404
|
+
([
|
|
405
|
+
'clientId',
|
|
406
|
+
'authorizationEndpoint',
|
|
407
|
+
'tokenEndpoint',
|
|
408
|
+
'scopes',
|
|
409
|
+
].includes(f.key)
|
|
410
|
+
? Object.hasOwn(override, 'oauth2')
|
|
411
|
+
: Object.hasOwn(override, f.key));
|
|
412
|
+
return {
|
|
413
|
+
text: overridden ? project.environment : t('↑ dev'),
|
|
414
|
+
color: overridden ? 'yellow' : undefined,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const inheritedValue = [
|
|
418
|
+
'clientId',
|
|
419
|
+
'authorizationEndpoint',
|
|
420
|
+
'tokenEndpoint',
|
|
421
|
+
].includes(f.key)
|
|
422
|
+
? inherited['oauth2']?.[f.key]
|
|
423
|
+
: inherited[f.key];
|
|
424
|
+
if (inheritedValue !== undefined &&
|
|
425
|
+
JSON.stringify(inheritedValue) === JSON.stringify(value)) {
|
|
426
|
+
return { text: t('↑ root') };
|
|
427
|
+
}
|
|
428
|
+
return { text: (values[f.key] ?? '') ? t('local') : '' };
|
|
429
|
+
};
|
|
430
|
+
// label column (24) + source column + paddings; never below 20.
|
|
431
|
+
const valueWidth = Math.max(20, width - 24 - SOURCE_WIDTH - 2);
|
|
432
|
+
const rows = [];
|
|
433
|
+
let lastSection;
|
|
434
|
+
for (const f of fields) {
|
|
435
|
+
if (f.section && f.section !== lastSection) {
|
|
436
|
+
rows.push(_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, dimColor: true, children: t(f.section) }) }, `s-${f.section}`));
|
|
437
|
+
lastSection = f.section;
|
|
438
|
+
}
|
|
439
|
+
const focused = active && f === current;
|
|
440
|
+
const typing = focused && editing;
|
|
441
|
+
// Keep the end of a long value (where the cursor is) visible while typing.
|
|
442
|
+
const tail = (text) => typing && text.length > valueWidth - 1
|
|
443
|
+
? `…${text.slice(-(valueWidth - 2))}`
|
|
444
|
+
: text;
|
|
445
|
+
let display;
|
|
446
|
+
if (f.kind === 'method' || f.kind === 'bool') {
|
|
447
|
+
const choices = options(f);
|
|
448
|
+
const chosen = typing ? draft : values[f.key];
|
|
449
|
+
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)));
|
|
450
|
+
}
|
|
451
|
+
else if (f.kind === 'secret') {
|
|
452
|
+
display = typing ? (_jsx(Text, { children: tail('•'.repeat(draft.length)) })) : (_jsx(Text, { dimColor: true, children: storedSecret(project, f.key)
|
|
453
|
+
? t('•••••••• keychain')
|
|
454
|
+
: f.hint
|
|
455
|
+
? t(f.hint)
|
|
456
|
+
: '' }));
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const value = typing ? draft : values[f.key];
|
|
460
|
+
display = value ? (_jsx(Text, { children: tail(value) })) : (_jsx(Text, { dimColor: true, children: typing ? '' : f.hint ? t(f.hint) : '—' }));
|
|
461
|
+
}
|
|
462
|
+
const src = source(f);
|
|
463
|
+
rows.push(_jsxs(Box, { height: 1, 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: _jsxs(Text, { wrap: "truncate", children: [display, typing && options(f).length === 0 && _jsx(Text, { inverse: true, children: " " })] }) }), _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));
|
|
464
|
+
}
|
|
465
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, overflow: "hidden", height: height, children: [_jsxs(Text, { dimColor: true, children: [(' ' + t('FIELD')).padEnd(24), t('VALUE').padEnd(valueWidth), t('SOURCE')] }), rows, project.workspace && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: t("Shared by every app below {root}. An app's own value wins.", {
|
|
466
|
+
root: project.root,
|
|
467
|
+
}) }) })), !project.workspace && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, dimColor: true, children: [t('PACKAGE.JSON SYNC'), ' ', _jsx(Text, { color: changes.length > 0 ? 'yellow' : 'green', children: changes.length === 0
|
|
468
|
+
? t('in sync')
|
|
469
|
+
: changes.length === 1
|
|
470
|
+
? t('1 diff · y to apply')
|
|
471
|
+
: 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 })] }));
|
|
472
|
+
}
|