memoir-cli 3.17.1 → 3.17.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.17.1",
3
+ "version": "3.17.3",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
6
6
  "main": "src/index.js",
package/src/cloud/auth.js CHANGED
@@ -11,7 +11,7 @@ const AUTH_FILE = path.join(configDir, 'auth.json');
11
11
 
12
12
  async function supaFetch(endpoint, options = {}) {
13
13
  const url = `${SUPABASE_URL}${endpoint}`;
14
- const res = await fetch(url, {
14
+ return fetch(url, {
15
15
  ...options,
16
16
  headers: {
17
17
  'apikey': SUPABASE_ANON_KEY,
@@ -19,42 +19,27 @@ async function supaFetch(endpoint, options = {}) {
19
19
  ...options.headers,
20
20
  },
21
21
  });
22
- return res;
23
22
  }
24
23
 
25
- // GoTrue's REST API takes the post-email landing URL as a `redirect_to`
26
- // QUERY PARAM. `options.emailRedirectTo` / `options.redirectTo` in the JSON
27
- // body is supabase-js's client shape — sent raw it is silently ignored and
28
- // the email links fall back to the project's Site URL. Both URLs below must
29
- // also be on the Auth → URL Configuration → Redirect URLs allow-list.
30
24
  const CONFIRMED_URL = 'https://memoir.sh/confirmed';
31
25
  const RESET_URL = 'https://memoir.sh/reset-password';
32
26
 
33
27
  export async function signUp(email, password) {
34
- const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, {
35
- method: 'POST',
36
- body: JSON.stringify({ email, password }),
37
- });
28
+ const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, { method: 'POST', body: JSON.stringify({ email, password }) });
38
29
  const data = await res.json();
39
30
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign up failed');
40
31
  return data;
41
32
  }
42
33
 
43
34
  export async function signIn(email, password) {
44
- const res = await supaFetch('/auth/v1/token?grant_type=password', {
45
- method: 'POST',
46
- body: JSON.stringify({ email, password }),
47
- });
35
+ const res = await supaFetch('/auth/v1/token?grant_type=password', { method: 'POST', body: JSON.stringify({ email, password }) });
48
36
  const data = await res.json();
49
37
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign in failed');
50
38
  return data;
51
39
  }
52
40
 
53
41
  export async function refreshSession(refreshToken) {
54
- const res = await supaFetch('/auth/v1/token?grant_type=refresh_token', {
55
- method: 'POST',
56
- body: JSON.stringify({ refresh_token: refreshToken }),
57
- });
42
+ const res = await supaFetch('/auth/v1/token?grant_type=refresh_token', { method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }) });
58
43
  const data = await res.json();
59
44
  if (!res.ok) throw new Error(data.error_description || data.msg || 'Token refresh failed');
60
45
  return data;
@@ -62,127 +47,73 @@ export async function refreshSession(refreshToken) {
62
47
 
63
48
  export async function saveSession(session) {
64
49
  await fs.ensureDir(configDir);
65
- const payload = {
66
- access_token: session.access_token,
67
- refresh_token: session.refresh_token,
68
- expires_at: Date.now() + (session.expires_in * 1000),
69
- user: {
70
- id: session.user.id,
71
- email: session.user.email,
72
- },
73
- };
50
+ const payload = { access_token: session.access_token, refresh_token: session.refresh_token, expires_at: Date.now() + (session.expires_in * 1000), user: { id: session.user.id, email: session.user.email } };
74
51
  await fs.writeFile(AUTH_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
75
52
  return payload;
76
53
  }
77
54
 
78
55
  export async function getSession() {
79
56
  if (!await fs.pathExists(AUTH_FILE)) return null;
80
-
81
57
  const stored = await fs.readJson(AUTH_FILE);
82
-
83
- // If token expires within 60 seconds, refresh
84
58
  if (stored.expires_at < Date.now() + 60000) {
85
- try {
86
- const refreshed = await refreshSession(stored.refresh_token);
87
- return await saveSession(refreshed);
88
- } catch {
89
- // Refresh failed — session is dead
90
- await fs.remove(AUTH_FILE);
91
- return null;
92
- }
59
+ try { return await saveSession(await refreshSession(stored.refresh_token)); }
60
+ catch { await fs.remove(AUTH_FILE); return null; }
93
61
  }
94
-
95
62
  return stored;
96
63
  }
97
64
 
98
- export async function logout() {
99
- if (await fs.pathExists(AUTH_FILE)) {
100
- await fs.remove(AUTH_FILE);
101
- }
102
- }
103
-
104
- export async function isLoggedIn() {
105
- const session = await getSession();
106
- return !!session;
107
- }
65
+ export async function logout() { if (await fs.pathExists(AUTH_FILE)) await fs.remove(AUTH_FILE); }
66
+ export async function isLoggedIn() { return !!(await getSession()); }
108
67
 
68
+ // A failed plan lookup is not evidence that a user is on Free. Callers use
69
+ // this value to choose destructive retention limits, so fail closed instead of
70
+ // silently downgrading a Pro account and pruning its history.
109
71
  export async function getSubscription(session) {
110
- const res = await supaFetch('/rest/v1/subscriptions?select=*&user_id=eq.' + session.user.id, {
111
- headers: {
112
- 'Authorization': `Bearer ${session.access_token}`,
113
- },
72
+ const res = await supaFetch('/rest/v1/subscriptions?select=*&user_id=eq.' + encodeURIComponent(session.user.id), {
73
+ headers: { 'Authorization': `Bearer ${session.access_token}` },
114
74
  });
75
+ if (!res.ok) throw new Error(`Could not verify subscription (${res.status}). Existing backups were not pruned.`);
115
76
  const data = await res.json();
116
- if (!res.ok || !data.length) return { status: 'free' };
77
+ if (!Array.isArray(data)) throw new Error('Could not verify subscription. Existing backups were not pruned.');
78
+ if (!data.length) return { status: 'free' };
117
79
  return data[0];
118
80
  }
119
81
 
120
82
  export async function resetPassword(email) {
121
- const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, {
122
- method: 'POST',
123
- body: JSON.stringify({ email }),
124
- });
125
- if (!res.ok) {
126
- const data = await res.json();
127
- throw new Error(data.error_description || data.msg || 'Password reset failed');
128
- }
83
+ const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, { method: 'POST', body: JSON.stringify({ email }) });
84
+ if (!res.ok) { const data = await res.json(); throw new Error(data.error_description || data.msg || 'Password reset failed'); }
85
+ }
86
+
87
+ async function requireDelete(res, label) {
88
+ if (res.ok || res.status === 404) return;
89
+ let detail = '';
90
+ try { detail = (await res.text()).trim(); } catch {}
91
+ throw new Error(`${label} failed (${res.status})${detail ? ': ' + detail : ''}. Nothing else was reported as deleted; retry account deletion.`);
129
92
  }
130
93
 
94
+ // Deletes the data the current user is authorized to delete. Auth-user removal
95
+ // itself requires a privileged server-side endpoint and must never be attempted
96
+ // with a service-role key from this CLI.
131
97
  export async function deleteAccount(session) {
132
- // 1. Delete all backups (storage files + metadata rows)
133
- const backupsRes = await fetch(
134
- `${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${session.user.id}`,
135
- {
136
- headers: {
137
- 'Authorization': `Bearer ${session.access_token}`,
138
- 'apikey': SUPABASE_ANON_KEY,
139
- },
140
- }
141
- );
142
-
143
- if (backupsRes.ok) {
144
- const backups = await backupsRes.json();
145
- for (const backup of backups) {
146
- // Delete storage object
147
- await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${backup.storage_path}`, {
148
- method: 'DELETE',
149
- headers: {
150
- 'Authorization': `Bearer ${session.access_token}`,
151
- 'apikey': SUPABASE_ANON_KEY,
152
- },
153
- });
154
-
155
- // Delete metadata row
156
- await fetch(`${SUPABASE_URL}/rest/v1/backups?id=eq.${backup.id}`, {
157
- method: 'DELETE',
158
- headers: {
159
- 'Authorization': `Bearer ${session.access_token}`,
160
- 'apikey': SUPABASE_ANON_KEY,
161
- },
162
- });
163
- }
98
+ const headers = { 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY };
99
+ const backupsRes = await fetch(`${SUPABASE_URL}/rest/v1/backups?select=*&user_id=eq.${encodeURIComponent(session.user.id)}`, { headers });
100
+ if (!backupsRes.ok) throw new Error(`Could not enumerate backups (${backupsRes.status}); account deletion stopped safely.`);
101
+ const backups = await backupsRes.json();
102
+
103
+ for (const backup of backups) {
104
+ if (typeof backup.storage_path !== 'string' || !backup.storage_path.startsWith(session.user.id + '/')) throw new Error('Unsafe backup path; account deletion stopped.');
105
+ const object = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}`, {
106
+ method: 'DELETE', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ prefixes: [backup.storage_path] }),
107
+ });
108
+ await requireDelete(object, 'Backup object deletion');
109
+ const row = await fetch(`${SUPABASE_URL}/rest/v1/backups?id=eq.${encodeURIComponent(backup.id)}`, { method: 'DELETE', headers });
110
+ await requireDelete(row, 'Backup metadata deletion');
164
111
  }
165
112
 
166
- // 2. Delete shared links
167
- await fetch(`${SUPABASE_URL}/rest/v1/shared_links?user_id=eq.${session.user.id}`, {
168
- method: 'DELETE',
169
- headers: {
170
- 'Authorization': `Bearer ${session.access_token}`,
171
- 'apikey': SUPABASE_ANON_KEY,
172
- },
173
- });
174
-
175
- // 3. Delete subscription
176
- await fetch(`${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${session.user.id}`, {
177
- method: 'DELETE',
178
- headers: {
179
- 'Authorization': `Bearer ${session.access_token}`,
180
- 'apikey': SUPABASE_ANON_KEY,
181
- },
182
- });
183
-
184
- // 4. Sign out locally
113
+ await requireDelete(await fetch(`${SUPABASE_URL}/rest/v1/shared_links?user_id=eq.${encodeURIComponent(session.user.id)}`, { method: 'DELETE', headers }), 'Shared-link deletion');
114
+ await requireDelete(await fetch(`${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${encodeURIComponent(session.user.id)}`, { method: 'DELETE', headers }), 'Subscription deletion');
185
115
  await logout();
116
+ return { localDataDeleted: true, authUserDeleted: false };
186
117
  }
187
118
 
188
119
  export { AUTH_FILE, supaFetch };
@@ -5,169 +5,58 @@ import inquirer from 'inquirer';
5
5
  import { signIn, signUp, saveSession, getSession, logout, getSubscription, resetPassword, deleteAccount } from '../cloud/auth.js';
6
6
 
7
7
  export async function loginCommand(options = {}) {
8
- // Check if already logged in
9
8
  const existing = await getSession();
10
9
  if (existing) {
11
- const sub = await getSubscription(existing);
12
- console.log('\n' + boxen(
13
- gradient.pastel(' memoir cloud ') + '\n\n' +
14
- chalk.green('✔ Already logged in as ') + chalk.cyan(existing.user.email) + '\n' +
15
- chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')),
16
- { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
17
- ) + '\n');
10
+ try {
11
+ const sub = await getSubscription(existing);
12
+ console.log('\n' + boxen(gradient.pastel(' memoir cloud ') + '\n\n' + chalk.green('✔ Already logged in as ') + chalk.cyan(existing.user.email) + '\n' + chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n');
13
+ } catch (error) {
14
+ console.log('\n' + boxen(chalk.green('✔ Logged in as ') + chalk.cyan(existing.user.email) + '\n' + chalk.yellow('Plan could not be verified right now. No backup history will be pruned until it can be verified.') + '\n' + chalk.gray(error.message), { padding: 1, borderStyle: 'round', borderColor: 'yellow' }) + '\n');
15
+ }
18
16
  return;
19
17
  }
20
18
 
21
19
  let action, email, password;
22
-
23
- // Support non-interactive login via flags
24
- if (options.email && options.password) {
25
- action = options.signup ? 'signup' : 'signin';
26
- email = options.email;
27
- password = options.password;
28
- } else {
20
+ if (options.email && options.password) { action = options.signup ? 'signup' : 'signin'; email = options.email; password = options.password; }
21
+ else {
29
22
  console.log();
30
-
31
- const actionAnswer = await inquirer.prompt([{
32
- type: 'list',
33
- name: 'action',
34
- message: 'Sign in or create account?',
35
- choices: [
36
- { name: 'Sign in (existing account)', value: 'signin' },
37
- { name: 'Create account', value: 'signup' },
38
- ],
39
- }]);
40
- action = actionAnswer.action;
41
-
42
- const emailAnswer = await inquirer.prompt([{
43
- type: 'input',
44
- name: 'email',
45
- message: 'Email:',
46
- validate: v => v.includes('@') ? true : 'Enter a valid email',
47
- }]);
48
- email = emailAnswer.email;
49
-
50
- const passwordAnswer = await inquirer.prompt([{
51
- type: 'password',
52
- name: 'password',
53
- message: 'Password:',
54
- mask: '*',
55
- validate: v => v.length >= 6 ? true : 'Password must be at least 6 characters',
56
- }]);
57
- password = passwordAnswer.password;
23
+ action = (await inquirer.prompt([{ type: 'list', name: 'action', message: 'Sign in or create account?', choices: [{ name: 'Sign in (existing account)', value: 'signin' }, { name: 'Create account', value: 'signup' }] }])).action;
24
+ email = (await inquirer.prompt([{ type: 'input', name: 'email', message: 'Email:', validate: v => v.includes('@') ? true : 'Enter a valid email' }])).email;
25
+ password = (await inquirer.prompt([{ type: 'password', name: 'password', message: 'Password:', mask: '*', validate: v => v.length >= 6 ? true : 'Password must be at least 6 characters' }])).password;
58
26
  }
59
27
 
60
28
  try {
61
29
  let session;
62
-
63
30
  if (action === 'signup') {
64
31
  const result = await signUp(email, password);
65
- if (result.access_token) {
66
- session = await saveSession(result);
67
- } else {
68
- // Email confirmation required
69
- console.log('\n' + boxen(
70
- chalk.green('✔ Account created!') + '\n\n' +
71
- chalk.white('Check your email to confirm, then run ') + chalk.cyan('memoir login') + chalk.white(' again.'),
72
- { padding: 1, borderStyle: 'round', borderColor: 'green' }
73
- ) + '\n');
74
- return;
75
- }
76
- } else {
77
- const result = await signIn(email, password);
78
- session = await saveSession(result);
79
- }
80
-
81
- const sub = await getSubscription(session);
82
-
83
- console.log('\n' + boxen(
84
- gradient.pastel(' memoir cloud ') + '\n\n' +
85
- chalk.green('✔ Logged in as ') + chalk.cyan(session.user.email) + '\n' +
86
- chalk.gray('Plan: ') + (sub.status === 'pro' ? chalk.green('Pro') : chalk.yellow('Free')) + '\n\n' +
87
- chalk.gray('Try: ') + chalk.cyan('memoir cloud push') + chalk.gray(' to back up to the cloud'),
88
- { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
89
- ) + '\n');
90
-
91
- } catch (error) {
92
- console.log('\n' + boxen(
93
- chalk.red('✖ ' + error.message),
94
- { padding: 1, borderStyle: 'round', borderColor: 'red' }
95
- ) + '\n');
96
- }
32
+ if (result.access_token) session = await saveSession(result);
33
+ else { console.log('\n' + boxen(chalk.green('✔ Account created!') + '\n\n' + chalk.white('Check your email to confirm, then run ') + chalk.cyan('memoir login') + chalk.white(' again.'), { padding: 1, borderStyle: 'round', borderColor: 'green' }) + '\n'); return; }
34
+ } else session = await saveSession(await signIn(email, password));
35
+
36
+ let plan = 'Unknown';
37
+ try { const sub = await getSubscription(session); plan = sub.status === 'pro' ? 'Pro' : 'Free'; } catch {}
38
+ console.log('\n' + boxen(gradient.pastel(' memoir cloud ') + '\n\n' + chalk.green('✔ Logged in as ') + chalk.cyan(session.user.email) + '\n' + chalk.gray('Plan: ') + (plan === 'Pro' ? chalk.green(plan) : plan === 'Free' ? chalk.yellow(plan) : chalk.yellow(plan + ' — will retry before retention cleanup')) + '\n\n' + chalk.gray('Try: ') + chalk.cyan('memoir cloud push') + chalk.gray(' to back up to the cloud'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n');
39
+ } catch (error) { console.log('\n' + boxen(chalk.red('✖ ' + error.message), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); }
97
40
  }
98
41
 
99
42
  export async function forgotPasswordCommand(options = {}) {
100
43
  let email = options.email;
101
-
102
- if (!email) {
103
- const emailAnswer = await inquirer.prompt([{
104
- type: 'input',
105
- name: 'email',
106
- message: 'Email:',
107
- validate: v => v.includes('@') ? true : 'Enter a valid email',
108
- }]);
109
- email = emailAnswer.email;
110
- }
111
-
112
- try {
113
- await resetPassword(email);
114
- console.log('\n' + boxen(
115
- chalk.green('✔ Password reset email sent!') + '\n\n' +
116
- chalk.white('Check ') + chalk.cyan(email) + chalk.white(' for a reset link.'),
117
- { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
118
- ) + '\n');
119
- } catch (error) {
120
- console.log('\n' + boxen(
121
- chalk.red('✖ ' + error.message),
122
- { padding: 1, borderStyle: 'round', borderColor: 'red' }
123
- ) + '\n');
124
- }
44
+ if (!email) email = (await inquirer.prompt([{ type: 'input', name: 'email', message: 'Email:', validate: v => v.includes('@') ? true : 'Enter a valid email' }])).email;
45
+ try { await resetPassword(email); console.log('\n' + boxen(chalk.green('✔ Password reset email sent!') + '\n\n' + chalk.white('Check ') + chalk.cyan(email) + chalk.white(' for a reset link.'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n'); }
46
+ catch (error) { console.log('\n' + boxen(chalk.red('✖ ' + error.message), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); }
125
47
  }
126
48
 
127
- export async function logoutCommand() {
128
- await logout();
129
- console.log('\n' + boxen(
130
- chalk.green('✔ Logged out'),
131
- { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
132
- ) + '\n');
133
- }
49
+ export async function logoutCommand() { await logout(); console.log('\n' + boxen(chalk.green('✔ Logged out'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n'); }
134
50
 
135
51
  export async function deleteAccountCommand(options = {}) {
136
52
  const session = await getSession();
137
- if (!session) {
138
- console.log('\n' + boxen(
139
- chalk.red('✖ Not logged in. Run ') + chalk.cyan('memoir login') + chalk.red(' first.'),
140
- { padding: 1, borderStyle: 'round', borderColor: 'red' }
141
- ) + '\n');
142
- return;
143
- }
144
-
53
+ if (!session) { console.log('\n' + boxen(chalk.red('✖ Not logged in. Run ') + chalk.cyan('memoir login') + chalk.red(' first.'), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); return; }
145
54
  if (!options.confirm) {
146
- const answer = await inquirer.prompt([{
147
- type: 'input',
148
- name: 'confirmation',
149
- message: 'Type DELETE to confirm account deletion:',
150
- }]);
151
-
152
- if (answer.confirmation !== 'DELETE') {
153
- console.log('\n' + chalk.gray(' Account deletion cancelled.') + '\n');
154
- return;
155
- }
55
+ const answer = await inquirer.prompt([{ type: 'input', name: 'confirmation', message: 'Type DELETE to remove your Memoir cloud data and sign out:' }]);
56
+ if (answer.confirmation !== 'DELETE') { console.log('\n' + chalk.gray(' Account-data deletion cancelled.') + '\n'); return; }
156
57
  }
157
-
158
58
  try {
159
59
  await deleteAccount(session);
160
-
161
- console.log('\n' + boxen(
162
- gradient.pastel(' memoir cloud ') + '\n\n' +
163
- chalk.green('✔ Account deleted.') + '\n' +
164
- chalk.gray('All backups, shared links, and data have been removed.'),
165
- { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
166
- ) + '\n');
167
- } catch (error) {
168
- console.log('\n' + boxen(
169
- chalk.red('✖ ' + error.message),
170
- { padding: 1, borderStyle: 'round', borderColor: 'red' }
171
- ) + '\n');
172
- }
60
+ console.log('\n' + boxen(gradient.pastel(' memoir cloud ') + '\n\n' + chalk.green('✔ Memoir cloud data deleted and this device signed out.') + '\n' + chalk.gray('Backups, shared links, and subscription data accessible to this account were removed.') + '\n\n' + chalk.yellow('Your authentication identity may still exist. Full auth-account removal requires the hosted account-deletion service.'), { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }) + '\n');
61
+ } catch (error) { console.log('\n' + boxen(chalk.red('✖ Deletion incomplete: ' + error.message) + '\n' + chalk.gray('Retry the command. Memoir will not report success for an incomplete deletion.'), { padding: 1, borderStyle: 'round', borderColor: 'red' }) + '\n'); }
173
62
  }
@@ -1,3 +1,4 @@
1
+ import { execFile } from 'child_process';
1
2
  import chalk from 'chalk';
2
3
  import boxen from 'boxen';
3
4
  import gradient from 'gradient-string';
@@ -18,13 +19,10 @@ async function createCheckoutSession(session) {
18
19
  return data.url;
19
20
  }
20
21
 
21
- // No shell. The URL comes from our own API, but `exec(\`open "${url}"\`)`
22
- // is still a shell string built from data — flagged (correctly) by the
23
- // agentscores.xyz scan as command injection, and one bad quote away from
24
- // being one. execFile passes the URL as a single argument; on Windows
25
- // rundll32's URL handler avoids cmd.exe parsing `&` inside the query string.
22
+ // No shell interpolation: execFile passes the URL as a discrete argument
23
+ // array; on Windows rundll32's URL handler avoids cmd.exe parsing `&` inside
24
+ // the query string.
26
25
  function openUrl(url) {
27
- const { execFile } = require('child_process');
28
26
  const platform = process.platform;
29
27
  const [cmd, args] = platform === 'darwin' ? ['open', [url]]
30
28
  : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]