sitevision-cli 1.0.0-beta.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.js +194 -70
  4. package/dist/commands/TaskScreen.js +32 -0
  5. package/dist/commands/build.js +6 -185
  6. package/dist/commands/deploy.js +95 -212
  7. package/dist/commands/dev.js +87 -416
  8. package/dist/commands/info.js +1 -1
  9. package/dist/commands/setup-signing.js +50 -33
  10. package/dist/commands/sign.js +9 -86
  11. package/dist/commands/use-finish.js +17 -0
  12. package/dist/commands/watch.js +5 -31
  13. package/dist/components/AnimatedLogo.js +8 -2
  14. package/dist/components/AuthLoginScreen.js +33 -15
  15. package/dist/components/PasswordInput.js +2 -1
  16. package/dist/scaffold/inquirer-stub.js +119 -0
  17. package/dist/scaffold/register.js +15 -0
  18. package/dist/shell/AddonPicker.js +54 -0
  19. package/dist/shell/Changelog.js +44 -0
  20. package/dist/shell/CommandPalette.js +63 -0
  21. package/dist/shell/ConfigForm.js +703 -0
  22. package/dist/shell/Frame.js +138 -0
  23. package/dist/shell/Help.js +96 -0
  24. package/dist/shell/Popover.js +15 -0
  25. package/dist/shell/Settings.js +149 -0
  26. package/dist/shell/Shell.js +953 -0
  27. package/dist/shell/Tabs.js +96 -0
  28. package/dist/shell/actions.js +493 -0
  29. package/dist/utils/config.js +62 -2
  30. package/dist/utils/create-app.js +231 -0
  31. package/dist/utils/debug.js +32 -0
  32. package/dist/utils/environments.js +74 -0
  33. package/dist/utils/i18n.js +369 -0
  34. package/dist/utils/jsonc.js +2 -1
  35. package/dist/utils/keychain.js +54 -6
  36. package/dist/utils/oauth2-auth.js +146 -43
  37. package/dist/utils/password-prompt.js +37 -3
  38. package/dist/utils/process-runner.js +77 -38
  39. package/dist/utils/project-detection.js +457 -112
  40. package/dist/utils/session-cookie-auth.js +10 -6
  41. package/dist/utils/sitevision-api.js +134 -25
  42. package/dist/utils/sitevision-scripts-runner.js +50 -22
  43. package/dist/utils/tasks.js +510 -0
  44. package/dist/utils/version-check.js +3 -3
  45. package/dist/utils/webpack-runner.js +9 -5
  46. package/dist/utils/workspace.js +105 -0
  47. package/dist/utils/zip.js +18 -19
  48. package/package.json +27 -4
  49. package/readme.md +113 -141
  50. package/dist/app.d.ts +0 -6
  51. package/dist/app.js +0 -307
  52. package/dist/cli.d.ts +0 -2
  53. package/dist/commands/build.d.ts +0 -12
  54. package/dist/commands/deploy.d.ts +0 -16
  55. package/dist/commands/dev.d.ts +0 -16
  56. package/dist/commands/index.d.ts +0 -4
  57. package/dist/commands/info.d.ts +0 -2
  58. package/dist/commands/setup-signing.d.ts +0 -2
  59. package/dist/commands/sign.d.ts +0 -14
  60. package/dist/commands/types.d.ts +0 -18
  61. package/dist/commands/watch.d.ts +0 -2
  62. package/dist/components/AnimatedLogo.d.ts +0 -10
  63. package/dist/components/AuthLoginScreen.d.ts +0 -21
  64. package/dist/components/DevPropertiesForm.d.ts +0 -10
  65. package/dist/components/DevPropertiesForm.js +0 -210
  66. package/dist/components/InfoScreen.d.ts +0 -7
  67. package/dist/components/InfoScreen.js +0 -14
  68. package/dist/components/KeychainPasswordChoice.d.ts +0 -8
  69. package/dist/components/KeychainPasswordChoice.js +0 -30
  70. package/dist/components/MainMenu.d.ts +0 -7
  71. package/dist/components/MainMenu.js +0 -83
  72. package/dist/components/PasswordInput.d.ts +0 -10
  73. package/dist/components/ProcessOutput.d.ts +0 -6
  74. package/dist/components/ProcessOutput.js +0 -30
  75. package/dist/components/SetupFlow.d.ts +0 -8
  76. package/dist/components/SetupFlow.js +0 -233
  77. package/dist/components/SigningPropertiesForm.d.ts +0 -7
  78. package/dist/components/SigningPropertiesForm.js +0 -38
  79. package/dist/components/StatusIndicator.d.ts +0 -8
  80. package/dist/components/TextInput.d.ts +0 -10
  81. package/dist/components/TextInput.js +0 -29
  82. package/dist/components/WelcomeScreen.d.ts +0 -14
  83. package/dist/types/index.d.ts +0 -284
  84. package/dist/utils/branding.d.ts +0 -28
  85. package/dist/utils/config.d.ts +0 -17
  86. package/dist/utils/jsonc.d.ts +0 -19
  87. package/dist/utils/keychain.d.ts +0 -15
  88. package/dist/utils/oauth2-auth.d.ts +0 -27
  89. package/dist/utils/password-prompt.d.ts +0 -14
  90. package/dist/utils/process-runner.d.ts +0 -22
  91. package/dist/utils/project-detection.d.ts +0 -151
  92. package/dist/utils/session-cookie-auth.d.ts +0 -34
  93. package/dist/utils/signing-password.d.ts +0 -12
  94. package/dist/utils/signing-step.d.ts +0 -15
  95. package/dist/utils/signing-step.js +0 -16
  96. package/dist/utils/sitevision-api.d.ts +0 -112
  97. package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
  98. package/dist/utils/version-check.d.ts +0 -9
  99. package/dist/utils/webpack-runner.d.ts +0 -76
  100. 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 { spawn } from 'child_process';
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
- function hasOAuth2Config(config) {
24
- return Boolean(config?.authorizationEndpoint && config.tokenEndpoint && config.clientId);
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
- return JSON.parse(response.body.toString());
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
- const isWin = process.platform === 'win32';
49
- const cmd = process.platform === 'darwin' ? 'open' : isWin ? 'cmd' : 'xdg-open';
50
- const args = isWin ? ['/c', 'start', '', url] : [url];
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
- * Serve the loopback redirect once. Returns the awaited code and a `close()`
60
- * that shuts the server down (freeing the port) if the login is cancelled — so
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('<', '&lt;')
145
+ .replaceAll('>', '&gt;');
146
+ }
63
147
  function startLoopback(port, state) {
64
148
  let finish;
65
149
  let settled = false;
66
- const code = new Promise(resolve => {
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 ok = url.searchParams.get('state') === state;
83
- const authCode = url.searchParams.get('code');
84
- const message = ok && authCode
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
- : 'Login failed. Check the terminal.';
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(ok ? authCode : null);
177
+ res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
178
+ finish(outcome);
90
179
  });
91
- const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
92
- server.on('error', () => finish(null));
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 { code, close: () => finish(null) };
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 (!hasOAuth2Config(config))
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
- if (config.scopes?.length) {
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 code = await loopback.code;
125
- if (!code)
126
- return null;
127
- const tokens = await postToken(config, {
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
- if (!tokens?.access_token)
135
- return null;
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 (!hasOAuth2Config(config))
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
- // Stale/expired refresh token — drop it so the next run logs in fresh.
170
- deleteOAuth2RefreshToken(domain, config.clientId);
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
- process.exit();
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
- process.exit();
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
- process.exit();
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
- interactive;
10
- customEnv;
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.interactive = interactive;
17
- this.customEnv = customEnv;
70
+ this.env = env;
18
71
  }
19
72
  run() {
20
73
  return new Promise((resolve, reject) => {
21
- this.process = spawn(this.command, this.args, {
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: { ...process.env, ...this.customEnv },
78
+ env: this.env,
24
79
  shell: true,
25
- stdio: this.interactive ? 'inherit' : 'pipe',
80
+ stdio: 'pipe',
26
81
  });
27
- // Only capture output if not in interactive mode
28
- if (!this.interactive) {
29
- this.process.stdout?.on('data', data => {
30
- const output = {
31
- type: 'stdout',
32
- data: data.toString(),
33
- };
34
- this.output.push(output);
35
- if (this.output.length > 1000) {
36
- this.output.shift();
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 ?? 0;
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.kill('SIGTERM');
69
- }
107
+ if (this.process)
108
+ killChild(this.process);
70
109
  }
71
110
  getOutput() {
72
111
  return this.output;