@vizzly-testing/cli 0.38.1 → 0.39.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.
@@ -1,9 +1,11 @@
1
1
  import { createApiClient as defaultCreateApiClient } from '../api/client.js';
2
2
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
3
3
  import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
4
+ import { preflightChanges } from '../utils/file-changes.js';
4
5
  import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js';
5
6
  import * as defaultOutput from '../utils/output.js';
6
- import { saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js';
7
+ import { planProjectBinding as defaultPlanProjectBinding, writeProjectBinding as defaultWriteProjectBinding } from '../utils/project-binding.js';
8
+ import { getProjectLink as defaultGetProjectLink, saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js';
7
9
  export function parseProjectSelector(selector, options = {}) {
8
10
  let organizationSlug = options.org || null;
9
11
  let projectSlug = options.project || null;
@@ -42,7 +44,10 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
42
44
  getApiUrl = defaultGetApiUrl,
43
45
  loadConfig = defaultLoadConfig,
44
46
  output = defaultOutput,
47
+ getProjectLink = defaultGetProjectLink,
48
+ planProjectBinding = defaultPlanProjectBinding,
45
49
  saveProjectLink = defaultSaveProjectLink,
50
+ writeProjectBinding = defaultWriteProjectBinding,
46
51
  exit = code => process.exit(code)
47
52
  } = deps;
48
53
  output.configure({
@@ -55,66 +60,145 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
55
60
  projectSlug
56
61
  } = parseProjectSelector(selector, options);
57
62
  try {
58
- let config = await loadConfig(globalOptions.config, globalOptions);
59
- let userToken = config.userToken || globalOptions.token || (await getAccessToken());
60
- if (!userToken) {
63
+ let config;
64
+ let configError;
65
+ try {
66
+ config = await loadConfig(globalOptions.config, globalOptions);
67
+ } catch (error) {
68
+ configError = error;
69
+ }
70
+ let apiUrl = config?.apiUrl || getApiUrl();
71
+ let project = {
72
+ organizationSlug,
73
+ projectSlug
74
+ };
75
+ let binding = await planProjectBinding({
76
+ configPath: globalOptions.config || config?._configPath,
77
+ project,
78
+ apiUrl,
79
+ currentProject: config?.project,
80
+ currentApiUrl: config?._configApiUrl
81
+ });
82
+ if (binding.status === 'manual') {
83
+ let result = {
84
+ linked: false,
85
+ organizationSlug,
86
+ projectSlug,
87
+ binding: {
88
+ status: 'manual',
89
+ configPath: binding.configPath,
90
+ instructions: binding.instructions
91
+ }
92
+ };
93
+ if (globalOptions.json) output.data(result);else {
94
+ output.error('Project configuration needs an explicit edit');
95
+ output.hint(binding.instructions);
96
+ }
97
+ output.cleanup();
98
+ exit(2);
99
+ return result;
100
+ }
101
+ if (configError) throw configError;
102
+ if (binding.after !== null) {
103
+ await preflightChanges([{
104
+ path: binding.configPath,
105
+ before: binding.before,
106
+ after: binding.after
107
+ }]);
108
+ }
109
+ let linkedProject = !options.name && !options.expiresAt ? await getProjectLink({
110
+ apiUrl,
111
+ ...project
112
+ }) : null;
113
+ let reused = Boolean(linkedProject);
114
+ let userToken = linkedProject ? null : config.userToken || globalOptions.token || (await getAccessToken());
115
+ if (!linkedProject && !userToken) {
61
116
  output.error('Login required before linking a project');
62
117
  output.hint('Run "vizzly login" first, then try project link again');
63
118
  output.cleanup();
64
119
  exit(1);
65
- return;
120
+ return {
121
+ linked: false,
122
+ reason: 'auth_required'
123
+ };
66
124
  }
67
- output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`);
68
- let apiUrl = config.apiUrl || getApiUrl();
69
- let client = createApiClient({
70
- baseUrl: apiUrl,
71
- token: userToken,
72
- command: 'project-link'
73
- });
74
- let response = await client.request(`/api/cli/${projectSlug}/link-token`, {
75
- method: 'POST',
76
- headers: {
77
- 'Content-Type': 'application/json',
78
- 'X-Organization': organizationSlug
79
- },
80
- body: JSON.stringify({
81
- name: options.name,
82
- expiresAt: options.expiresAt
83
- })
84
- });
85
- let linkedProject = await saveProjectLink({
86
- apiUrl,
87
- organizationSlug: response.organization?.slug || organizationSlug,
88
- organizationName: response.organization?.name,
89
- projectSlug: response.project?.slug || projectSlug,
90
- projectName: response.project?.name,
91
- token: response.token.token,
92
- tokenId: response.token.id,
93
- tokenPrefix: response.token.token_prefix,
94
- expiresAt: response.token.expires_at,
95
- createdAt: response.token.created_at
96
- });
125
+ if (!linkedProject) {
126
+ output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`);
127
+ let client = createApiClient({
128
+ baseUrl: apiUrl,
129
+ token: userToken,
130
+ command: 'project-link'
131
+ });
132
+ let response = await client.request(`/api/cli/${projectSlug}/link-token`, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'X-Organization': organizationSlug
137
+ },
138
+ body: JSON.stringify({
139
+ name: options.name,
140
+ expiresAt: options.expiresAt
141
+ })
142
+ });
143
+ linkedProject = await saveProjectLink({
144
+ apiUrl,
145
+ organizationSlug: response.organization?.slug || organizationSlug,
146
+ organizationName: response.organization?.name,
147
+ projectSlug: response.project?.slug || projectSlug,
148
+ projectName: response.project?.name,
149
+ token: response.token.token,
150
+ tokenId: response.token.id,
151
+ tokenPrefix: response.token.token_prefix,
152
+ expiresAt: response.token.expires_at,
153
+ createdAt: response.token.created_at
154
+ });
155
+ }
156
+ if (linkedProject.organizationSlug !== organizationSlug || linkedProject.projectSlug !== projectSlug) {
157
+ binding = await planProjectBinding({
158
+ configPath: binding.configPath,
159
+ project: {
160
+ organizationSlug: linkedProject.organizationSlug,
161
+ projectSlug: linkedProject.projectSlug
162
+ },
163
+ apiUrl,
164
+ currentProject: config?.project,
165
+ currentApiUrl: config?._configApiUrl
166
+ });
167
+ if (binding.status === 'manual') throw new Error(binding.instructions);
168
+ }
169
+ await writeProjectBinding(binding);
97
170
  output.stopSpinner();
171
+ let result = {
172
+ linked: true,
173
+ organizationSlug: linkedProject.organizationSlug,
174
+ projectSlug: linkedProject.projectSlug,
175
+ tokenPrefix: linkedProject.tokenPrefix,
176
+ storage: linkedProject.storage,
177
+ reused,
178
+ binding: {
179
+ status: 'bound',
180
+ configPath: binding.configPath
181
+ }
182
+ };
98
183
  if (globalOptions.json) {
99
- output.data({
100
- linked: true,
101
- organizationSlug: linkedProject.organizationSlug,
102
- projectSlug: linkedProject.projectSlug,
103
- tokenPrefix: linkedProject.tokenPrefix,
104
- storage: linkedProject.storage
105
- });
184
+ output.data(result);
106
185
  output.cleanup();
107
- return;
186
+ return result;
108
187
  }
109
188
  output.complete(`Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
110
- output.hint(`Active project: ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
189
+ output.hint(`Repository project: ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
111
190
  output.hint(`Cloud uploads and project-scoped context will use ${linkedProject.tokenPrefix}...`);
112
191
  output.hint(`Credential storage: ${linkedProject.storage}`);
113
192
  output.cleanup();
193
+ return result;
114
194
  } catch (error) {
115
195
  output.stopSpinner();
116
196
  output.error('Failed to link project', error);
117
197
  output.cleanup();
118
198
  exit(1);
199
+ return {
200
+ linked: false,
201
+ reason: 'link_failed'
202
+ };
119
203
  }
120
204
  }
@@ -274,8 +274,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
274
274
  commitAuthorName: commitAuthor.name,
275
275
  commitAuthorEmail: commitAuthor.email,
276
276
  environment: config.build.environment,
277
- threshold: config.comparison.threshold,
278
- minClusterSize: config.comparison.minClusterSize,
277
+ threshold: config._comparisonOverrides?.threshold,
278
+ minClusterSize: config._comparisonOverrides?.minClusterSize,
279
279
  eager: config.eager || false,
280
280
  allowNoToken: config.allowNoToken || false,
281
281
  wait: config.wait || options.wait || false,
@@ -12,7 +12,10 @@ import { createAuthService as defaultCreateAuthService } from '../services/auth-
12
12
  import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
13
13
  import { createConfigService as defaultCreateConfigService } from '../services/config-service.js';
14
14
  import { createProjectService as defaultCreateProjectService } from '../services/project-service.js';
15
+ import { generateStaticReport as defaultGenerateStaticReport, getReportFileUrl } from '../services/static-report-generator.js';
16
+ import { getServerRegistry } from '../tdd/server-registry.js';
15
17
  import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js';
18
+ import { openBrowser as defaultOpenBrowser } from '../utils/browser.js';
16
19
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
17
20
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitAuthor as defaultDetectCommitAuthor, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
18
21
  import * as defaultOutput from '../utils/output.js';
@@ -63,7 +66,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
63
66
  detectPullRequestNumber = defaultDetectPullRequestNumber,
64
67
  generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
65
68
  spawn = defaultSpawn,
66
- output = defaultOutput
69
+ output = defaultOutput,
70
+ generateStaticReport = defaultGenerateStaticReport,
71
+ openBrowser = defaultOpenBrowser
67
72
  } = deps;
68
73
  output.configure({
69
74
  json: globalOptions.json,
@@ -93,6 +98,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
93
98
  ...options
94
99
  };
95
100
  let config = await loadConfig(globalOptions.config, allOptions);
101
+ if (options.autoPort) {
102
+ config.server.port = await getServerRegistry().findAvailablePort(config.server.port);
103
+ }
96
104
 
97
105
  // Dev mode works locally by default - only needs token for baseline download
98
106
  let needsToken = options.baselineBuild || options.baselineComparison || config.baselineBuildId || config.baselineComparisonId;
@@ -207,38 +215,57 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
207
215
 
208
216
  // Normal dev mode - run tests
209
217
  output.debug('run', testCommand);
210
- let runResult = await runTests({
211
- runOptions,
212
- config: configWithVerbose,
213
- deps: {
214
- serverManager,
215
- buildManager,
216
- spawn: (command, spawnOptions) => {
217
- let proc = spawn(command, spawnOptions);
218
- testProcess = proc;
219
- return proc;
220
- },
221
- createApiClient,
222
- createApiBuild,
223
- getBuild,
224
- finalizeApiBuild,
225
- createError: (msg, code) => new VizzlyError(msg, code),
226
- output,
227
- onBuildCreated: data => {
228
- output.debug('build', `created ${data.buildId?.substring(0, 8)}`);
229
- },
230
- onServerReady: data => {
231
- output.debug('server', `ready on :${data.port}`);
232
- },
233
- onFinalizeFailed: data => {
234
- output.warn(`Failed to finalize build: ${data.error}`);
218
+ let runError = null;
219
+ let runResult;
220
+ try {
221
+ runResult = await runTests({
222
+ runOptions,
223
+ config: configWithVerbose,
224
+ deps: {
225
+ serverManager,
226
+ buildManager,
227
+ spawn: (command, spawnOptions) => {
228
+ let proc = spawn(command, spawnOptions);
229
+ testProcess = proc;
230
+ return proc;
231
+ },
232
+ createApiClient,
233
+ createApiBuild,
234
+ getBuild,
235
+ finalizeApiBuild,
236
+ createError: (msg, code) => new VizzlyError(msg, code),
237
+ output,
238
+ onBuildCreated: data => {
239
+ output.debug('build', `created ${data.buildId?.substring(0, 8)}`);
240
+ },
241
+ onServerReady: data => {
242
+ output.debug('server', `ready on :${data.port}`);
243
+ },
244
+ onFinalizeFailed: data => {
245
+ output.warn(`Failed to finalize build: ${data.error}`);
246
+ }
235
247
  }
248
+ });
249
+ } catch (error) {
250
+ if (!error.runResult) throw error;
251
+ runResult = error.runResult;
252
+ runError = error;
253
+ }
254
+ let reportPath = null;
255
+ let reportError = null;
256
+ if (runResult.screenshotsCaptured > 0 || runResult.comparisons?.length > 0) {
257
+ let report = await generateStaticReport(process.cwd());
258
+ if (report.success) {
259
+ reportPath = report.reportPath;
260
+ } else {
261
+ reportError = report.error;
262
+ output.warn(`Failed to generate static report: ${report.error}`);
236
263
  }
237
- });
264
+ }
238
265
 
239
266
  // Determine success based on comparison results
240
267
  // (Summary is printed by printResults() in tdd-service.js, called from getTddResults)
241
- let hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status === 'failed');
268
+ let hasFailures = runResult.failed || runResult.testsFailed > 0 || runResult.capture?.setup === 'disabled' || runResult.capture?.rejected > 0 || runResult.capture?.errors > 0 || Boolean(reportError) || runResult.comparisons?.some(c => ['failed', 'error'].includes(c.status));
242
269
  let exitCode = hasFailures ? 1 : 0;
243
270
 
244
271
  // JSON output mode
@@ -251,9 +278,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
251
278
  diffPercentage: c.diffPercentage ?? c.diff_percentage ?? null,
252
279
  threshold: c.threshold ?? config.comparison.threshold,
253
280
  paths: {
254
- baseline: c.baselinePath || c.baseline_path || null,
255
- current: c.currentPath || c.current_path || null,
256
- diff: c.diffPath || c.diff_path || null
281
+ baseline: c.baseline || c.baselinePath || c.baseline_path || null,
282
+ current: c.current || c.currentPath || c.current_path || null,
283
+ diff: c.diff || c.diffPath || c.diff_path || null
257
284
  },
258
285
  viewport: c.viewport || {
259
286
  width: c.viewportWidth || c.viewport_width,
@@ -267,29 +294,50 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
267
294
  total: comparisons.length,
268
295
  passed: comparisons.filter(c => c.status === 'passed').length,
269
296
  failed: comparisons.filter(c => c.status === 'failed').length,
270
- new: comparisons.filter(c => c.status === 'new').length
297
+ new: comparisons.filter(c => c.status === 'new').length,
298
+ errors: comparisons.filter(c => c.status === 'error').length
271
299
  };
272
300
  output.data({
273
301
  status: hasFailures ? 'failed' : 'completed',
274
302
  exitCode,
275
303
  comparisons,
276
304
  summary,
277
- reportPath: runResult.reportPath || '.vizzly/report/index.html',
305
+ capture: runResult.capture,
306
+ reportPath,
307
+ reportError,
308
+ ...(runError ? {
309
+ error: {
310
+ message: runError.message
311
+ }
312
+ } : {}),
278
313
  contextCommand: buildLocalContextCommand({
279
314
  structured: true
280
315
  })
281
316
  });
282
317
  output.cleanup();
283
318
  }
284
- if (!globalOptions.json && !options.daemon) {
319
+ if (!globalOptions.json) {
320
+ if (reportPath) output.hint(`Report: ${getReportFileUrl(reportPath)}`);
285
321
  output.blank?.();
286
322
  output.print(` Context ${buildLocalContextCommand()}`);
287
323
  }
324
+ if (reportPath && options.open !== false) {
325
+ try {
326
+ await openBrowser(getReportFileUrl(reportPath));
327
+ } catch (error) {
328
+ output.warn(`Could not open the report: ${error.message}`);
329
+ }
330
+ }
288
331
  return {
289
332
  result: {
333
+ ...runResult,
290
334
  success: !hasFailures,
291
335
  exitCode,
292
- ...runResult
336
+ reportPath,
337
+ reportError,
338
+ ...(runError ? {
339
+ error: runError.message
340
+ } : {})
293
341
  },
294
342
  cleanup
295
343
  };
@@ -308,8 +356,10 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
308
356
  total: 0,
309
357
  passed: 0,
310
358
  failed: 0,
311
- new: 0
312
- }
359
+ new: 0,
360
+ errors: 0
361
+ },
362
+ reportPath: null
313
363
  });
314
364
  output.cleanup();
315
365
  } else {
@@ -319,7 +369,8 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
319
369
  result: {
320
370
  success: false,
321
371
  exitCode: 1,
322
- error: error.message
372
+ error: error.message,
373
+ reportPath: null
323
374
  },
324
375
  cleanup
325
376
  };
@@ -132,8 +132,8 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
132
132
  commitAuthorName: commitAuthor.name,
133
133
  commitAuthorEmail: commitAuthor.email,
134
134
  environment: config.build.environment,
135
- threshold: config.comparison.threshold,
136
- minClusterSize: config.comparison.minClusterSize,
135
+ threshold: config._comparisonOverrides?.threshold,
136
+ minClusterSize: config._comparisonOverrides?.minClusterSize,
137
137
  uploadAll: options.uploadAll || false,
138
138
  metadata: options.metadata ? JSON.parse(options.metadata) : {},
139
139
  pullRequestNumber,
@@ -0,0 +1,99 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readOptionalFile } from '../utils/config-discovery.js';
5
+ import { fileChange } from '../utils/file-changes.js';
6
+ let sourcePath = fileURLToPath(new URL('../../skills/vizzly/', import.meta.url));
7
+ let start = '<!-- vizzly-agent-guidance -->';
8
+ let end = '<!-- /vizzly-agent-guidance -->';
9
+ function guidanceContent() {
10
+ return `${start}
11
+ ## Visual testing with Vizzly
12
+
13
+ For user-facing changes, use the repo-local Vizzly skill at
14
+ \`.agents/skills/vizzly/SKILL.md\`.
15
+
16
+ - Check existing screenshots and diffs before and after the change.
17
+ - Use the repository's existing visual test command.
18
+ - For cloud reviews, discover supported requests with
19
+ \`vizzly api schema --json\` and inspect baseline, current, and diff images
20
+ together.
21
+ - For local results, use \`vizzly context ... --json\`.
22
+
23
+ Don't approve or reject changes, or replace baselines, unless the task asks
24
+ for it.
25
+ ${end}
26
+ `;
27
+ }
28
+ function addGuidance(source) {
29
+ let content = source || '';
30
+ let starts = content.split(start).length - 1;
31
+ let ends = content.split(end).length - 1;
32
+ let begin = content.indexOf(start);
33
+ let finish = content.indexOf(end);
34
+ if (starts !== ends || starts > 1 || starts && finish < begin) {
35
+ throw new Error('Repair the incomplete or duplicate Vizzly guidance markers in AGENTS.md before running init.');
36
+ }
37
+ let block = guidanceContent();
38
+ if (starts) return content;
39
+ return content.trim() ? `${content.trimEnd()}\n\n${block}` : block;
40
+ }
41
+ async function skillFiles(directory, prefix = '') {
42
+ let files = [];
43
+ for (let entry of await readdir(directory, {
44
+ withFileTypes: true
45
+ })) {
46
+ let relative = path.join(prefix, entry.name);
47
+ if (entry.isDirectory()) {
48
+ files.push(...(await skillFiles(path.join(directory, entry.name), relative)));
49
+ } else if (entry.isFile()) {
50
+ files.push(relative);
51
+ }
52
+ }
53
+ return files;
54
+ }
55
+ export async function planAgentSetup(cwd, {
56
+ skill,
57
+ guidance
58
+ }) {
59
+ if (!skill && !guidance) return {
60
+ changes: []
61
+ };
62
+ let targetPath = path.join(cwd, '.agents', 'skills', 'vizzly');
63
+ let existing = await readOptionalFile(path.join(targetPath, 'SKILL.md'));
64
+ let changes = [];
65
+ if (!existing) {
66
+ for (let filename of await skillFiles(sourcePath)) {
67
+ changes.push(await fileChange(path.join(targetPath, filename), await readOptionalFile(path.join(sourcePath, filename))));
68
+ }
69
+ }
70
+ let agentSkill = {
71
+ status: existing ? 'exists' : 'installed',
72
+ sourcePath,
73
+ targetPath
74
+ };
75
+ let agentGuidance;
76
+ if (guidance) {
77
+ let agentsPath = path.join(cwd, 'AGENTS.md');
78
+ let before = await readOptionalFile(agentsPath);
79
+ let after = addGuidance(before);
80
+ changes.push(before === after ? null : {
81
+ path: agentsPath,
82
+ before,
83
+ after
84
+ });
85
+ let status = 'exists';
86
+ if (before !== after) {
87
+ status = before === null ? 'created' : 'updated';
88
+ }
89
+ agentGuidance = {
90
+ status,
91
+ agentsPath
92
+ };
93
+ }
94
+ return {
95
+ changes: changes.filter(Boolean),
96
+ agentSkill,
97
+ agentGuidance
98
+ };
99
+ }
@@ -0,0 +1,31 @@
1
+ function createConfigContent(format) {
2
+ if (format === 'json' || format === 'yaml') return '{}\n';
3
+ if (!['javascript', 'typescript'].includes(format)) {
4
+ throw new Error(`Cannot generate ${format} config. Use a .js, .ts or .json file.`);
5
+ }
6
+ return `// Vizzly supplies defaults. Uncomment only settings you want to change.
7
+ // Configuration: https://docs.vizzly.dev/integration/cli/
8
+ export default {
9
+ // comparison: { threshold: 2, minClusterSize: 2 },
10
+ // upload: { screenshotsDir: './screenshots' },
11
+ };
12
+ `;
13
+ }
14
+ export function planConfig(config) {
15
+ let after;
16
+ if (config.format === 'package') {
17
+ let pkg = {
18
+ ...config.packageConfig,
19
+ vizzly: {}
20
+ };
21
+ let indent = config.source?.match(/\n([ \t]+)\S/)?.[1] || ' ';
22
+ after = `${JSON.stringify(pkg, null, indent)}\n`;
23
+ } else {
24
+ after = createConfigContent(config.format);
25
+ }
26
+ return {
27
+ path: config.configPath,
28
+ before: config.source,
29
+ after
30
+ };
31
+ }
@@ -0,0 +1,44 @@
1
+ import { createInterface } from 'node:readline';
2
+ export function createPrompts({
3
+ input = process.stdin,
4
+ output = process.stderr
5
+ } = {}) {
6
+ let reader = createInterface({
7
+ input,
8
+ crlfDelay: Infinity,
9
+ terminal: false
10
+ });
11
+ let lines = reader[Symbol.asyncIterator]();
12
+ let cancelled = () => Object.assign(new Error('Setup cancelled.'), {
13
+ code: 'INIT_CANCELLED'
14
+ });
15
+ let interrupted = false;
16
+ let onInterrupt = () => {
17
+ interrupted = true;
18
+ reader.close();
19
+ };
20
+ process.on('SIGINT', onInterrupt);
21
+ async function ask(question, fallback = '') {
22
+ output.write(`${question}${fallback ? ` [${fallback}]` : ''}: `);
23
+ let line = await lines.next();
24
+ if (line.done || interrupted) throw cancelled();
25
+ return line.value.trim() || fallback;
26
+ }
27
+ async function confirm(question, defaultYes = false) {
28
+ while (true) {
29
+ let answer = (await ask(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'}`)).toLowerCase();
30
+ if (!answer) return defaultYes;
31
+ if (['y', 'yes'].includes(answer)) return true;
32
+ if (['n', 'no'].includes(answer)) return false;
33
+ output.write('Enter yes or no.\n');
34
+ }
35
+ }
36
+ return {
37
+ ask,
38
+ confirm,
39
+ close() {
40
+ process.off('SIGINT', onInterrupt);
41
+ reader.close();
42
+ }
43
+ };
44
+ }