sitevision-cli 1.0.0-beta.9 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -0
- package/LICENSE +21 -0
- package/dist/cli.js +194 -70
- package/dist/commands/TaskScreen.js +32 -0
- package/dist/commands/build.js +6 -185
- package/dist/commands/deploy.js +95 -212
- package/dist/commands/dev.js +87 -416
- package/dist/commands/info.js +1 -1
- package/dist/commands/setup-signing.js +50 -33
- package/dist/commands/sign.js +9 -86
- package/dist/commands/use-finish.js +17 -0
- package/dist/commands/watch.js +5 -31
- package/dist/components/AnimatedLogo.js +8 -2
- package/dist/components/AuthLoginScreen.js +33 -15
- package/dist/components/PasswordInput.js +2 -1
- package/dist/scaffold/inquirer-stub.js +119 -0
- package/dist/scaffold/register.js +15 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/Changelog.js +50 -0
- package/dist/shell/CommandPalette.js +63 -0
- package/dist/shell/ConfigForm.js +703 -0
- package/dist/shell/Frame.js +138 -0
- package/dist/shell/Help.js +96 -0
- package/dist/shell/Popover.js +15 -0
- package/dist/shell/Settings.js +149 -0
- package/dist/shell/Shell.js +953 -0
- package/dist/shell/Tabs.js +96 -0
- package/dist/shell/actions.js +493 -0
- package/dist/utils/config.js +62 -2
- package/dist/utils/create-app.js +231 -0
- package/dist/utils/debug.js +32 -0
- package/dist/utils/environments.js +74 -0
- package/dist/utils/i18n.js +369 -0
- package/dist/utils/jsonc.js +2 -1
- package/dist/utils/keychain.js +54 -6
- package/dist/utils/oauth2-auth.js +146 -43
- package/dist/utils/password-prompt.js +37 -3
- package/dist/utils/process-runner.js +77 -38
- package/dist/utils/project-detection.js +457 -112
- package/dist/utils/session-cookie-auth.js +10 -6
- package/dist/utils/sitevision-api.js +134 -25
- package/dist/utils/sitevision-scripts-runner.js +50 -22
- package/dist/utils/tasks.js +510 -0
- package/dist/utils/version-check.js +3 -3
- package/dist/utils/webpack-runner.js +9 -5
- package/dist/utils/workspace.js +105 -0
- package/dist/utils/zip.js +18 -19
- package/package.json +29 -5
- package/readme.md +117 -141
- package/dist/app.d.ts +0 -6
- package/dist/app.js +0 -307
- package/dist/cli.d.ts +0 -2
- package/dist/commands/build.d.ts +0 -12
- package/dist/commands/deploy.d.ts +0 -16
- package/dist/commands/dev.d.ts +0 -16
- package/dist/commands/index.d.ts +0 -4
- package/dist/commands/info.d.ts +0 -2
- package/dist/commands/setup-signing.d.ts +0 -2
- package/dist/commands/sign.d.ts +0 -14
- package/dist/commands/types.d.ts +0 -18
- package/dist/commands/watch.d.ts +0 -2
- package/dist/components/AnimatedLogo.d.ts +0 -10
- package/dist/components/AuthLoginScreen.d.ts +0 -21
- package/dist/components/DevPropertiesForm.d.ts +0 -10
- package/dist/components/DevPropertiesForm.js +0 -210
- package/dist/components/InfoScreen.d.ts +0 -7
- package/dist/components/InfoScreen.js +0 -14
- package/dist/components/KeychainPasswordChoice.d.ts +0 -8
- package/dist/components/KeychainPasswordChoice.js +0 -30
- package/dist/components/MainMenu.d.ts +0 -7
- package/dist/components/MainMenu.js +0 -83
- package/dist/components/PasswordInput.d.ts +0 -10
- package/dist/components/ProcessOutput.d.ts +0 -6
- package/dist/components/ProcessOutput.js +0 -30
- package/dist/components/SetupFlow.d.ts +0 -8
- package/dist/components/SetupFlow.js +0 -233
- package/dist/components/SigningPropertiesForm.d.ts +0 -7
- package/dist/components/SigningPropertiesForm.js +0 -38
- package/dist/components/StatusIndicator.d.ts +0 -8
- package/dist/components/TextInput.d.ts +0 -10
- package/dist/components/TextInput.js +0 -29
- package/dist/components/WelcomeScreen.d.ts +0 -14
- package/dist/types/index.d.ts +0 -284
- package/dist/utils/branding.d.ts +0 -28
- package/dist/utils/config.d.ts +0 -17
- package/dist/utils/jsonc.d.ts +0 -19
- package/dist/utils/keychain.d.ts +0 -15
- package/dist/utils/oauth2-auth.d.ts +0 -27
- package/dist/utils/password-prompt.d.ts +0 -14
- package/dist/utils/process-runner.d.ts +0 -22
- package/dist/utils/project-detection.d.ts +0 -151
- package/dist/utils/session-cookie-auth.d.ts +0 -34
- package/dist/utils/signing-password.d.ts +0 -12
- package/dist/utils/signing-step.d.ts +0 -15
- package/dist/utils/signing-step.js +0 -16
- package/dist/utils/sitevision-api.d.ts +0 -112
- package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
- package/dist/utils/version-check.d.ts +0 -9
- package/dist/utils/webpack-runner.d.ts +0 -76
- package/dist/utils/zip.d.ts +0 -71
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
|
-
import
|
|
4
|
-
import { makeRequest } from './sitevision-api.js';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { makeRequest, summarizeErrorBody } from './sitevision-api.js';
|
|
5
5
|
import { getOAuth2RefreshToken, setOAuth2RefreshToken, deleteOAuth2RefreshToken, getOAuth2ClientSecret, } from './keychain.js';
|
|
6
|
+
import { debug } from './debug.js';
|
|
6
7
|
/** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
|
|
7
8
|
export const DEFAULT_REDIRECT_PORT = 8137;
|
|
9
|
+
// Sitevision's provider grants everything under ALL; offline_access adds the
|
|
10
|
+
// refresh token so later runs log in silently.
|
|
11
|
+
export const DEFAULT_SCOPES = ['ALL', 'offline_access'];
|
|
8
12
|
/** Seconds to wait for the user to finish logging in before giving up. */
|
|
9
13
|
const LOGIN_TIMEOUT_MS = 300_000;
|
|
10
14
|
function base64url(buffer) {
|
|
@@ -20,8 +24,32 @@ export function createPkcePair() {
|
|
|
20
24
|
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
21
25
|
return { verifier, challenge };
|
|
22
26
|
}
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Why this project's OAuth2 settings cannot be used, if they cannot. The
|
|
29
|
+
* endpoints may come from a committed package.json, and the keychain secrets
|
|
30
|
+
* are stored per site, so they only ever go to that site, over TLS.
|
|
31
|
+
*/
|
|
32
|
+
export function oauth2ConfigProblem(dev) {
|
|
33
|
+
const config = dev.oauth2;
|
|
34
|
+
if (!config?.authorizationEndpoint ||
|
|
35
|
+
!config.tokenEndpoint ||
|
|
36
|
+
!config.clientId) {
|
|
37
|
+
return 'OAuth2 is not fully configured (authorization/token endpoint or client ID missing).';
|
|
38
|
+
}
|
|
39
|
+
if (!dev.domain)
|
|
40
|
+
return 'OAuth2 needs the site domain to be set.';
|
|
41
|
+
const schemes = dev.useHTTPForDevDeploy ? ['https:', 'http:'] : ['https:'];
|
|
42
|
+
for (const endpoint of [config.authorizationEndpoint, config.tokenEndpoint]) {
|
|
43
|
+
const url = URL.canParse(endpoint) ? new URL(endpoint) : undefined;
|
|
44
|
+
if (!url || !schemes.includes(url.protocol)) {
|
|
45
|
+
return `OAuth2 endpoint ${endpoint} must be an https URL.`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const tokenHost = new URL(config.tokenEndpoint).host.toLowerCase();
|
|
49
|
+
if (tokenHost !== dev.domain.toLowerCase()) {
|
|
50
|
+
return `OAuth2 token endpoint is on ${tokenHost}, not on ${dev.domain}. Stored secrets are only sent to the site they belong to.`;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
25
53
|
}
|
|
26
54
|
async function postToken(config, params, secret) {
|
|
27
55
|
const body = Buffer.from(new URLSearchParams(params).toString());
|
|
@@ -36,34 +64,90 @@ async function postToken(config, params, secret) {
|
|
|
36
64
|
// client_secret_basic when confidential; public+PKCE clients omit it.
|
|
37
65
|
auth: secret ? { username: config.clientId, password: secret } : undefined,
|
|
38
66
|
});
|
|
67
|
+
if (response.statusCode !== 200) {
|
|
68
|
+
return {
|
|
69
|
+
status: response.statusCode,
|
|
70
|
+
error: `Token endpoint returned ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { tokens: JSON.parse(response.body.toString()) };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return {
|
|
77
|
+
error: `Token request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** OpenID configuration path (published at the issuer root once the provider is saved). */
|
|
82
|
+
const DISCOVERY_PATH = '/.well-known/openid-configuration';
|
|
83
|
+
/**
|
|
84
|
+
* Fetch the site's OpenID configuration (unauthenticated) to auto-fill the
|
|
85
|
+
* authorization/token endpoints. Returns null if it isn't published (provider
|
|
86
|
+
* not enabled) or the response isn't a valid config, so callers fall back to
|
|
87
|
+
* manual entry.
|
|
88
|
+
*/
|
|
89
|
+
export async function discoverOAuth2Config(domain, useHTTP = false) {
|
|
90
|
+
if (!domain)
|
|
91
|
+
return null;
|
|
92
|
+
const protocol = useHTTP ? 'http' : 'https';
|
|
93
|
+
try {
|
|
94
|
+
const response = await makeRequest(`${protocol}://${domain}${DISCOVERY_PATH}`, { method: 'GET' });
|
|
39
95
|
if (response.statusCode !== 200)
|
|
40
96
|
return null;
|
|
41
|
-
|
|
97
|
+
const doc = JSON.parse(response.body.toString());
|
|
98
|
+
if (!doc.authorization_endpoint || !doc.token_endpoint)
|
|
99
|
+
return null;
|
|
100
|
+
return {
|
|
101
|
+
authorizationEndpoint: doc.authorization_endpoint,
|
|
102
|
+
tokenEndpoint: doc.token_endpoint,
|
|
103
|
+
scopesSupported: Array.isArray(doc.scopes_supported)
|
|
104
|
+
? doc.scopes_supported
|
|
105
|
+
: undefined,
|
|
106
|
+
};
|
|
42
107
|
}
|
|
43
108
|
catch {
|
|
44
109
|
return null;
|
|
45
110
|
}
|
|
46
111
|
}
|
|
47
112
|
export function openBrowser(url) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
113
|
+
// Preserve the full OAuth URL, including & and percent-encoded parameters,
|
|
114
|
+
// through Windows shell parsing. `open` handles platform-specific escaping.
|
|
115
|
+
void open(url).catch(() => {
|
|
55
116
|
// Fall back to the printed URL.
|
|
56
|
-
}
|
|
117
|
+
});
|
|
57
118
|
}
|
|
58
119
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* a retry doesn't hit an EADDRINUSE on the fixed redirect port.
|
|
120
|
+
* Turn a redirect's query params into a result, prioritizing the provider's own
|
|
121
|
+
* error (the most useful reason) over a generic "no code". Exported for testing.
|
|
62
122
|
*/
|
|
123
|
+
export function classifyRedirect(params) {
|
|
124
|
+
if (params.error) {
|
|
125
|
+
return {
|
|
126
|
+
error: `The OAuth2 provider rejected the login: ${params.errorDescription
|
|
127
|
+
? `${params.error} — ${params.errorDescription}`
|
|
128
|
+
: params.error}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (params.state !== params.expectedState) {
|
|
132
|
+
return {
|
|
133
|
+
error: 'State mismatch — the login response did not match this request (a stale browser tab, or the wrong window).',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (params.code) {
|
|
137
|
+
return { code: params.code };
|
|
138
|
+
}
|
|
139
|
+
return { error: 'No authorization code was returned by the provider.' };
|
|
140
|
+
}
|
|
141
|
+
function escapeHtml(text) {
|
|
142
|
+
return text
|
|
143
|
+
.replaceAll('&', '&')
|
|
144
|
+
.replaceAll('<', '<')
|
|
145
|
+
.replaceAll('>', '>');
|
|
146
|
+
}
|
|
63
147
|
function startLoopback(port, state) {
|
|
64
148
|
let finish;
|
|
65
149
|
let settled = false;
|
|
66
|
-
const
|
|
150
|
+
const result = new Promise(resolve => {
|
|
67
151
|
finish = (value) => {
|
|
68
152
|
if (settled)
|
|
69
153
|
return;
|
|
@@ -79,19 +163,29 @@ function startLoopback(port, state) {
|
|
|
79
163
|
res.writeHead(404).end();
|
|
80
164
|
return;
|
|
81
165
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
166
|
+
const outcome = classifyRedirect({
|
|
167
|
+
expectedState: state,
|
|
168
|
+
state: url.searchParams.get('state'),
|
|
169
|
+
error: url.searchParams.get('error'),
|
|
170
|
+
errorDescription: url.searchParams.get('error_description'),
|
|
171
|
+
code: url.searchParams.get('code'),
|
|
172
|
+
});
|
|
173
|
+
const message = outcome.code
|
|
85
174
|
? 'Login complete. You can close this window and return to the terminal.'
|
|
86
|
-
:
|
|
175
|
+
: `Login failed: ${outcome.error}`;
|
|
87
176
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
88
|
-
res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
|
|
89
|
-
finish(
|
|
177
|
+
res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
|
|
178
|
+
finish(outcome);
|
|
90
179
|
});
|
|
91
|
-
const timer = setTimeout(() => finish(
|
|
92
|
-
server.on('error',
|
|
180
|
+
const timer = setTimeout(() => finish({ error: 'Timed out waiting for the login to complete.' }), LOGIN_TIMEOUT_MS);
|
|
181
|
+
server.on('error', error => finish({
|
|
182
|
+
error: `Local login server error: ${error instanceof Error ? error.message : String(error)}`,
|
|
183
|
+
}));
|
|
93
184
|
server.listen(port, '127.0.0.1');
|
|
94
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
result,
|
|
187
|
+
close: (reason) => finish({ error: reason ?? 'Login cancelled.' }),
|
|
188
|
+
};
|
|
95
189
|
}
|
|
96
190
|
/**
|
|
97
191
|
* Start an interactive OAuth2 login. Returns the authorize URL to open and a
|
|
@@ -101,7 +195,7 @@ function startLoopback(port, state) {
|
|
|
101
195
|
*/
|
|
102
196
|
export function beginOAuth2Login(dev) {
|
|
103
197
|
const config = dev.oauth2;
|
|
104
|
-
if (!
|
|
198
|
+
if (!config || oauth2ConfigProblem(dev))
|
|
105
199
|
return null;
|
|
106
200
|
const { domain } = dev;
|
|
107
201
|
const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
|
|
@@ -116,29 +210,34 @@ export function beginOAuth2Login(dev) {
|
|
|
116
210
|
url.searchParams.set('state', state);
|
|
117
211
|
url.searchParams.set('code_challenge', challenge);
|
|
118
212
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
119
|
-
|
|
120
|
-
url.searchParams.set('scope', config.scopes.join(' '));
|
|
121
|
-
}
|
|
213
|
+
url.searchParams.set('scope', (config.scopes ?? DEFAULT_SCOPES).join(' '));
|
|
122
214
|
const loopback = startLoopback(port, state);
|
|
215
|
+
debug('auth', `oauth2 login ${domain} client ${config.clientId} port ${port}`);
|
|
123
216
|
const complete = async () => {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
217
|
+
const redirect = await loopback.result;
|
|
218
|
+
debug('auth', `oauth2 redirect ${redirect.error ?? (redirect.code ? 'code received' : 'no code')}`);
|
|
219
|
+
if (redirect.error || !redirect.code) {
|
|
220
|
+
return { error: redirect.error ?? 'Login failed.' };
|
|
221
|
+
}
|
|
222
|
+
const { tokens, error } = await postToken(config, {
|
|
128
223
|
grant_type: 'authorization_code',
|
|
129
|
-
code,
|
|
224
|
+
code: redirect.code,
|
|
130
225
|
redirect_uri: redirectUri,
|
|
131
226
|
client_id: config.clientId,
|
|
132
227
|
code_verifier: verifier,
|
|
133
228
|
}, secret);
|
|
134
|
-
|
|
135
|
-
|
|
229
|
+
debug('auth', `oauth2 token ${error ?? (tokens?.access_token ? 'ok' : 'missing')}${tokens?.refresh_token ? ' + refresh' : ''}`);
|
|
230
|
+
if (error)
|
|
231
|
+
return { error };
|
|
232
|
+
if (!tokens?.access_token) {
|
|
233
|
+
return { error: 'The token endpoint did not return an access token.' };
|
|
234
|
+
}
|
|
136
235
|
if (tokens.refresh_token) {
|
|
137
236
|
setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
|
|
138
237
|
}
|
|
139
|
-
return tokens.access_token;
|
|
238
|
+
return { token: tokens.access_token };
|
|
140
239
|
};
|
|
141
|
-
return { authUrl: url.href, complete, cancel: loopback.close };
|
|
240
|
+
return { authUrl: url.href, complete, cancel: () => loopback.close() };
|
|
142
241
|
}
|
|
143
242
|
/**
|
|
144
243
|
* Silently resolve an access token by refreshing the keychain refresh token.
|
|
@@ -148,25 +247,29 @@ export function beginOAuth2Login(dev) {
|
|
|
148
247
|
*/
|
|
149
248
|
export async function resolveOAuth2AccessToken(dev) {
|
|
150
249
|
const config = dev.oauth2;
|
|
151
|
-
if (!
|
|
250
|
+
if (!config || oauth2ConfigProblem(dev))
|
|
152
251
|
return null;
|
|
153
252
|
const { domain } = dev;
|
|
154
253
|
const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
|
|
155
254
|
const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
|
|
156
255
|
if (!storedRefresh)
|
|
157
256
|
return null;
|
|
158
|
-
const tokens = await postToken(config, {
|
|
257
|
+
const { tokens, status } = await postToken(config, {
|
|
159
258
|
grant_type: 'refresh_token',
|
|
160
259
|
refresh_token: storedRefresh,
|
|
161
260
|
client_id: config.clientId,
|
|
162
261
|
}, secret);
|
|
262
|
+
debug('auth', `oauth2 refresh ${domain}: ${tokens?.access_token ? 'ok' : `failed (${status})`}`);
|
|
163
263
|
if (tokens?.access_token) {
|
|
164
264
|
if (tokens.refresh_token) {
|
|
165
265
|
setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
|
|
166
266
|
}
|
|
167
267
|
return tokens.access_token;
|
|
168
268
|
}
|
|
169
|
-
//
|
|
170
|
-
|
|
269
|
+
// Only a rejection (invalid_grant) means the token is dead. Offline, a
|
|
270
|
+
// timeout or a 5xx says nothing about it, so it stays.
|
|
271
|
+
if (status === 400 || status === 401) {
|
|
272
|
+
deleteOAuth2RefreshToken(domain, config.clientId);
|
|
273
|
+
}
|
|
171
274
|
return null;
|
|
172
275
|
}
|
|
@@ -1,9 +1,24 @@
|
|
|
1
|
+
import { debug } from './debug.js';
|
|
2
|
+
// Without a terminal nobody can answer: prompts resolve to their "no answer"
|
|
3
|
+
// value (default, nothing, empty password) and the caller reports what is missing.
|
|
4
|
+
const interactive = () => Boolean(process.stdin.isTTY);
|
|
5
|
+
// 130 = 128 + SIGINT, so `svc sign && svc deploy` stops at a cancelled prompt.
|
|
6
|
+
const cancelled = () => {
|
|
7
|
+
debug('prompt', 'cancelled');
|
|
8
|
+
process.exit(130);
|
|
9
|
+
};
|
|
1
10
|
/**
|
|
2
11
|
* Prompt for a yes/no answer. Returns true for y/Y.
|
|
3
12
|
* Pressing Enter (empty answer) returns `defaultYes` (default: false).
|
|
4
13
|
*/
|
|
5
14
|
export function promptYesNo(prompt, defaultYes = false) {
|
|
6
15
|
return new Promise(resolve => {
|
|
16
|
+
if (!interactive()) {
|
|
17
|
+
debug('prompt', `${prompt.trim()} (no terminal, ${defaultYes ? 'yes' : 'no'})`);
|
|
18
|
+
resolve(defaultYes);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
debug('prompt', prompt.trim());
|
|
7
22
|
process.stdout.write(prompt);
|
|
8
23
|
const stdin = process.stdin;
|
|
9
24
|
stdin.setRawMode(true);
|
|
@@ -17,13 +32,15 @@ export function promptYesNo(prompt, defaultYes = false) {
|
|
|
17
32
|
process.stdout.write(`${char}\n`);
|
|
18
33
|
const charCode = char.charCodeAt(0);
|
|
19
34
|
if (charCode === 3) {
|
|
20
|
-
|
|
35
|
+
cancelled();
|
|
21
36
|
}
|
|
22
37
|
// Enter (CR/LF) or empty input → use the default
|
|
23
38
|
if (char === '' || charCode === 13 || charCode === 10) {
|
|
39
|
+
debug('prompt', `answered default (${defaultYes ? 'yes' : 'no'})`);
|
|
24
40
|
resolve(defaultYes);
|
|
25
41
|
return;
|
|
26
42
|
}
|
|
43
|
+
debug('prompt', `answered ${char === 'y' || char === 'Y' ? 'yes' : 'no'}`);
|
|
27
44
|
resolve(char === 'y' || char === 'Y');
|
|
28
45
|
};
|
|
29
46
|
stdin.on('data', onData);
|
|
@@ -35,6 +52,12 @@ export function promptYesNo(prompt, defaultYes = false) {
|
|
|
35
52
|
*/
|
|
36
53
|
export function promptEnter(prompt) {
|
|
37
54
|
return new Promise(resolve => {
|
|
55
|
+
if (!interactive()) {
|
|
56
|
+
debug('prompt', `${prompt.trim()} (no terminal, continuing)`);
|
|
57
|
+
resolve();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
debug('prompt', prompt.trim());
|
|
38
61
|
process.stdout.write(prompt);
|
|
39
62
|
const stdin = process.stdin;
|
|
40
63
|
stdin.setRawMode(true);
|
|
@@ -44,13 +67,14 @@ export function promptEnter(prompt) {
|
|
|
44
67
|
const char = data[0] || '';
|
|
45
68
|
const charCode = char.charCodeAt(0);
|
|
46
69
|
if (charCode === 3) {
|
|
47
|
-
|
|
70
|
+
cancelled();
|
|
48
71
|
}
|
|
49
72
|
if (char === '' || charCode === 13 || charCode === 10) {
|
|
50
73
|
stdin.setRawMode(false);
|
|
51
74
|
stdin.removeListener('data', onData);
|
|
52
75
|
stdin.pause();
|
|
53
76
|
process.stdout.write('\n');
|
|
77
|
+
debug('prompt', 'continued');
|
|
54
78
|
resolve();
|
|
55
79
|
}
|
|
56
80
|
};
|
|
@@ -62,6 +86,12 @@ export function promptEnter(prompt) {
|
|
|
62
86
|
*/
|
|
63
87
|
export function promptPassword(prompt) {
|
|
64
88
|
return new Promise(resolve => {
|
|
89
|
+
if (!interactive()) {
|
|
90
|
+
debug('prompt', `${prompt.trim()} (no terminal, empty)`);
|
|
91
|
+
resolve('');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
debug('prompt', prompt.trim());
|
|
65
95
|
process.stdout.write(prompt);
|
|
66
96
|
const stdin = process.stdin;
|
|
67
97
|
stdin.setRawMode(true);
|
|
@@ -69,6 +99,9 @@ export function promptPassword(prompt) {
|
|
|
69
99
|
stdin.setEncoding('utf8');
|
|
70
100
|
let password = '';
|
|
71
101
|
const onData = (data) => {
|
|
102
|
+
// Arrow keys and the like arrive as escape sequences, not as text.
|
|
103
|
+
if (data.startsWith('\u001B'))
|
|
104
|
+
return;
|
|
72
105
|
// Handle each character in the input (supports paste)
|
|
73
106
|
for (const char of data) {
|
|
74
107
|
const charCode = char.charCodeAt(0);
|
|
@@ -78,6 +111,7 @@ export function promptPassword(prompt) {
|
|
|
78
111
|
stdin.removeListener('data', onData);
|
|
79
112
|
stdin.pause();
|
|
80
113
|
process.stdout.write('\n');
|
|
114
|
+
debug('prompt', password ? 'password entered' : 'left empty');
|
|
81
115
|
resolve(password);
|
|
82
116
|
return;
|
|
83
117
|
}
|
|
@@ -90,7 +124,7 @@ export function promptPassword(prompt) {
|
|
|
90
124
|
}
|
|
91
125
|
else if (charCode === 3) {
|
|
92
126
|
// Ctrl+C
|
|
93
|
-
|
|
127
|
+
cancelled();
|
|
94
128
|
}
|
|
95
129
|
else if (charCode >= 32) {
|
|
96
130
|
// Printable characters
|
|
@@ -1,60 +1,100 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
1
|
+
import { spawn, spawnSync, } from 'child_process';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
|
+
import { debug } from './debug.js';
|
|
4
|
+
const children = new Set();
|
|
5
|
+
/**
|
|
6
|
+
* Spawn a child in its own process group, so stopping it also stops whatever it
|
|
7
|
+
* starts (npm, webpack workers), and track it so the CLI can kill leftovers on
|
|
8
|
+
* exit.
|
|
9
|
+
*/
|
|
10
|
+
export function spawnChild(command, args, options = {}) {
|
|
11
|
+
const child = spawn(command, args, {
|
|
12
|
+
...options,
|
|
13
|
+
detached: process.platform !== 'win32',
|
|
14
|
+
});
|
|
15
|
+
children.add(child);
|
|
16
|
+
debug('spawn', `${command} ${args.join(' ')} (cwd ${String(options.cwd ?? process.cwd())})`);
|
|
17
|
+
const forget = () => children.delete(child);
|
|
18
|
+
child.on('close', (code, signal) => {
|
|
19
|
+
debug('spawn', `${command} exited ${signal ?? code}`);
|
|
20
|
+
forget();
|
|
21
|
+
});
|
|
22
|
+
child.on('error', error => {
|
|
23
|
+
debug('spawn', `${command} failed: ${error.message}`);
|
|
24
|
+
forget();
|
|
25
|
+
});
|
|
26
|
+
return child;
|
|
27
|
+
}
|
|
28
|
+
/** Stop a child and everything it started. */
|
|
29
|
+
export function killChild(child) {
|
|
30
|
+
if (child.pid === undefined ||
|
|
31
|
+
child.exitCode !== null ||
|
|
32
|
+
child.signalCode !== null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
// No process groups on Windows: taskkill /T takes the whole tree.
|
|
38
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
39
|
+
stdio: 'ignore',
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Already gone.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Kill every child still running; returns how many were signalled. */
|
|
52
|
+
export function killAllChildren() {
|
|
53
|
+
const running = [...children];
|
|
54
|
+
for (const child of running)
|
|
55
|
+
killChild(child);
|
|
56
|
+
return running.length;
|
|
57
|
+
}
|
|
3
58
|
export class ProcessRunner extends EventEmitter {
|
|
4
59
|
process = null;
|
|
5
60
|
output = [];
|
|
6
61
|
command;
|
|
7
62
|
args;
|
|
8
63
|
cwd;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
constructor(command, args = [], cwd, interactive = false, customEnv) {
|
|
64
|
+
env;
|
|
65
|
+
constructor(command, args = [], cwd, env) {
|
|
12
66
|
super();
|
|
13
67
|
this.command = command;
|
|
14
68
|
this.args = args;
|
|
15
69
|
this.cwd = cwd;
|
|
16
|
-
this.
|
|
17
|
-
this.customEnv = customEnv;
|
|
70
|
+
this.env = env;
|
|
18
71
|
}
|
|
19
72
|
run() {
|
|
20
73
|
return new Promise((resolve, reject) => {
|
|
21
|
-
|
|
74
|
+
// One pre-joined string: the shell resolves npm.cmd on Windows, and Node
|
|
75
|
+
// deprecates shell + args (DEP0190). Callers pass trusted args only.
|
|
76
|
+
this.process = spawnChild([this.command, ...this.args].join(' '), [], {
|
|
22
77
|
cwd: this.cwd || process.cwd(),
|
|
23
|
-
env:
|
|
78
|
+
env: this.env,
|
|
24
79
|
shell: true,
|
|
25
|
-
stdio:
|
|
80
|
+
stdio: 'pipe',
|
|
26
81
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
this.emit('output', output);
|
|
39
|
-
});
|
|
40
|
-
this.process.stderr?.on('data', data => {
|
|
41
|
-
const output = {
|
|
42
|
-
type: 'stderr',
|
|
43
|
-
data: data.toString(),
|
|
44
|
-
};
|
|
45
|
-
this.output.push(output);
|
|
46
|
-
if (this.output.length > 1000) {
|
|
47
|
-
this.output.shift();
|
|
48
|
-
}
|
|
49
|
-
this.emit('output', output);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
82
|
+
const capture = (type) => (data) => {
|
|
83
|
+
const output = { type, data: data.toString() };
|
|
84
|
+
this.output.push(output);
|
|
85
|
+
if (this.output.length > 1000) {
|
|
86
|
+
this.output.shift();
|
|
87
|
+
}
|
|
88
|
+
this.emit('output', output);
|
|
89
|
+
};
|
|
90
|
+
this.process.stdout?.on('data', capture('stdout'));
|
|
91
|
+
this.process.stderr?.on('data', capture('stderr'));
|
|
52
92
|
this.process.on('error', error => {
|
|
53
93
|
this.emit('error', error);
|
|
54
94
|
reject(error);
|
|
55
95
|
});
|
|
56
96
|
this.process.on('close', code => {
|
|
57
|
-
const exitCode = code ??
|
|
97
|
+
const exitCode = code ?? 1;
|
|
58
98
|
this.emit('exit', exitCode);
|
|
59
99
|
resolve({
|
|
60
100
|
exitCode,
|
|
@@ -64,9 +104,8 @@ export class ProcessRunner extends EventEmitter {
|
|
|
64
104
|
});
|
|
65
105
|
}
|
|
66
106
|
kill() {
|
|
67
|
-
if (this.process)
|
|
68
|
-
this.process
|
|
69
|
-
}
|
|
107
|
+
if (this.process)
|
|
108
|
+
killChild(this.process);
|
|
70
109
|
}
|
|
71
110
|
getOutput() {
|
|
72
111
|
return this.output;
|