@impeccable/reviews 0.1.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 (46) hide show
  1. package/README.md +68 -0
  2. package/dist/agent.js +183 -0
  3. package/dist/bin.js +11 -0
  4. package/dist/fence.js +1071 -0
  5. package/dist/focus.js +98 -0
  6. package/dist/host-questions.js +71 -0
  7. package/dist/hosted.js +195 -0
  8. package/dist/http.js +36 -0
  9. package/dist/log.js +38 -0
  10. package/dist/login.js +276 -0
  11. package/dist/main.js +252 -0
  12. package/dist/netlify.js +333 -0
  13. package/dist/playbook/cases/ci-tunnel.md +21 -0
  14. package/dist/playbook/cases/first-preview-failure.md +28 -0
  15. package/dist/playbook/cases/netlify-previews.md +17 -0
  16. package/dist/playbook/cases/netlify-setup.md +24 -0
  17. package/dist/playbook/cases/preview-protection.md +21 -0
  18. package/dist/playbook/cases/pull-requests.md +31 -0
  19. package/dist/playbook/cases/sign-in.md +21 -0
  20. package/dist/playbook/cases/static-site.md +16 -0
  21. package/dist/playbook/cases/vercel-previews.md +16 -0
  22. package/dist/playbook/cases/vercel-setup.md +27 -0
  23. package/dist/playbook/cases/workflow-as-person.md +23 -0
  24. package/dist/playbook/github-previews.js +101 -0
  25. package/dist/playbook/netlify-pattern.js +24 -0
  26. package/dist/playbook/netlify-previews.js +144 -0
  27. package/dist/playbook/probe-preview.js +67 -0
  28. package/dist/playbook/protection-secrets.js +15 -0
  29. package/dist/playbook/public-preview-request.js +96 -0
  30. package/dist/playbook/publication-wait.js +46 -0
  31. package/dist/playbook/vercel-protection.js +121 -0
  32. package/dist/playbook.js +98 -0
  33. package/dist/preflight.js +211 -0
  34. package/dist/stages.js +34 -0
  35. package/dist/sync.js +142 -0
  36. package/dist/tools.js +2015 -0
  37. package/dist/transcript.js +139 -0
  38. package/dist/ui/bridge.js +145 -0
  39. package/dist/ui/frames.js +62 -0
  40. package/dist/ui/look.js +368 -0
  41. package/dist/ui/run-state.js +91 -0
  42. package/dist/ui/screens.js +544 -0
  43. package/dist/ui/sensitive.js +14 -0
  44. package/dist/vercel-bypass.js +136 -0
  45. package/dist/vercel.js +70 -0
  46. package/package.json +39 -0
@@ -0,0 +1,544 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { format } from 'node:util';
3
+ import { CURSOR_MARKER, getCapabilities, Input, KeybindingsManager, matchesKey, ProcessTerminal, setKeybindings, TUI_KEYBINDINGS, TuiAltScreen } from '@earendil-works/pi-tui';
4
+ import { readCheckout } from '../preflight.js';
5
+ import { copyText } from '../login.js';
6
+ import { workStarted } from '../stages.js';
7
+ import { mountUi } from './bridge.js';
8
+ import { createFrameWriter, frameState } from './frames.js';
9
+ import { drawFrame, OUTCOME, PRODUCT, palette } from './look.js';
10
+ import { RunState } from './run-state.js';
11
+ /** How often what moves on screen moves: the loader, the spinner. */
12
+ const TICK_MS = 80;
13
+ /** The run screen's first line, before the agent has said anything. */
14
+ const FIRST_STATUS = 'Reading the repository';
15
+ /** A first Esc on the run screen only asks for a second one within this long. */
16
+ const STOP_WINDOW_MS = 3000;
17
+ /** This package's version, for the corner of the frame. */
18
+ const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
19
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
20
+ let wizard = null;
21
+ /** --frames: where each screen goes as it changes. */
22
+ let frames;
23
+ class Wizard {
24
+ state = new RunState();
25
+ views = [];
26
+ questions = [];
27
+ /** Output from anywhere else while the screen is up, printed once it has gone. */
28
+ held = [];
29
+ frame = 0;
30
+ lastClock = '';
31
+ /** When Esc was first pressed on the run screen; a second press within the window stops the run. */
32
+ stopAsked = 0;
33
+ stopped = false;
34
+ /** How the mark is drawn here: plain text on a dumb terminal. */
35
+ drawing = process.env.TERM === 'dumb' ? 'plain' : 'blocks';
36
+ /** Bold, normal and dim until the terminal says what its background is; greys from then on. */
37
+ style = palette(undefined, this.drawing);
38
+ /** The window over a screen too tall for the terminal, and the screen it is for: each screen opens its own. */
39
+ scroll = { by: 0, page: 1, hidden: false };
40
+ scrollOf;
41
+ tui = new TuiAltScreen(new ProcessTerminal(), false, undefined, { mouse: false });
42
+ ticker;
43
+ restoreConsole;
44
+ onExit = () => this.stop();
45
+ onSignal = (signal) => {
46
+ this.stop();
47
+ process.kill(process.pid, signal);
48
+ };
49
+ constructor() {
50
+ // The alternate screen's own scrolling and search keys stay off: the wizard draws every row, and a typed answer keeps Home and End.
51
+ const off = Object.keys(TUI_KEYBINDINGS).filter((id) => id.startsWith('tui.altScreen.'));
52
+ setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, Object.fromEntries(off.map((id) => [id, []]))));
53
+ this.tui.setLayoutRoot(this);
54
+ this.tui.setFocus(this);
55
+ this.tui.start();
56
+ if (!process.env.NO_COLOR && getCapabilities().trueColor) {
57
+ void this.tui.queryTerminalBackgroundColor({ timeoutMs: 300 }).then((background) => {
58
+ if (!background)
59
+ return;
60
+ this.style = palette(background, this.drawing);
61
+ this.redraw();
62
+ });
63
+ }
64
+ this.ticker = setInterval(() => this.tick(), TICK_MS).unref();
65
+ this.restoreConsole = captureConsole((line) => void (this.held = [...this.held, line].slice(-50)));
66
+ process.on('exit', this.onExit);
67
+ process.once('SIGTERM', this.onSignal);
68
+ process.once('SIGHUP', this.onSignal);
69
+ mountUi({
70
+ ask: (q) => this.ask(q),
71
+ status: (line) => (this.state.status(line), this.redraw()),
72
+ tasks: (tasks) => (this.state.setTasks(tasks), this.redraw()),
73
+ wait: (wait) => (this.state.wait(wait), this.redraw()),
74
+ });
75
+ }
76
+ push(view) {
77
+ this.views.push(view);
78
+ this.redraw();
79
+ }
80
+ remove(view) {
81
+ this.views = this.views.filter((v) => v !== view);
82
+ this.redraw();
83
+ }
84
+ /** Puts the sign-in link on the clipboard, and says so once it is there. */
85
+ copy(view) {
86
+ void view.opts
87
+ .onCopy()
88
+ .catch(() => false)
89
+ .then((copied) => {
90
+ view.copied ||= copied;
91
+ this.redraw();
92
+ });
93
+ }
94
+ /** Gives the terminal back as it was before the first screen, once the keys already pressed are read. */
95
+ async close() {
96
+ if (this.stopped)
97
+ return;
98
+ await this.tui.terminal.drainInput(250);
99
+ this.stop();
100
+ }
101
+ stop() {
102
+ if (this.stopped)
103
+ return;
104
+ this.stopped = true;
105
+ if (wizard === this)
106
+ wizard = null;
107
+ clearInterval(this.ticker);
108
+ process.off('exit', this.onExit);
109
+ process.off('SIGTERM', this.onSignal);
110
+ process.off('SIGHUP', this.onSignal);
111
+ mountUi(null);
112
+ for (const question of this.questions.splice(0))
113
+ question.resolve({ skipped: true });
114
+ // Leaves the alternate screen without copying the last frame out of it: the scrollback gets the outro's summary only.
115
+ this.tui.stop({ preserveScreen: true });
116
+ this.restoreConsole();
117
+ for (const line of this.held.splice(0))
118
+ console.log(line);
119
+ }
120
+ redraw() {
121
+ if (!this.stopped)
122
+ this.tui.requestRender();
123
+ }
124
+ /** What moves on the run screen, if anything: the loader until the work after reading starts, then the running task's spinner or the status line's. Nothing moves while the person is being asked. */
125
+ motion() {
126
+ if (this.questions.length || this.views.at(-1)?.kind !== 'run')
127
+ return undefined;
128
+ const { tasks } = this.state;
129
+ const at = !workStarted(tasks) ? 'loader' : tasks.some((t) => t.status === 'in_progress') ? 'task' : 'status';
130
+ return { at, frame: this.frame };
131
+ }
132
+ stopArmed() {
133
+ return Date.now() - this.stopAsked < STOP_WINDOW_MS;
134
+ }
135
+ /** Redraws when something on screen moves with time: the loader or the spinner, the silence note, the way to stop. */
136
+ tick() {
137
+ const clock = JSON.stringify([this.state.band(!this.questions.length), this.stopArmed()]);
138
+ const moving = this.motion() !== undefined;
139
+ if (!moving && clock === this.lastClock)
140
+ return;
141
+ this.lastClock = clock;
142
+ if (moving)
143
+ this.frame++;
144
+ this.redraw();
145
+ }
146
+ ask(q) {
147
+ return new Promise((resolve) => {
148
+ const field = new Input({ prompt: '› ' });
149
+ field.focused = true;
150
+ // Pasted in, so the cursor waits at its end.
151
+ if (q.prefill)
152
+ field.handleInput(`\x1b[200~${q.prefill}\x1b[201~`);
153
+ const answered = (answer) => {
154
+ // The time the person took is not the run going quiet.
155
+ this.state.touch();
156
+ resolve(answer);
157
+ };
158
+ this.questions.push({ q, resolve: answered, field, password: q.kind === 'account' ? new Input() : undefined, focus: 0, cursor: 0, picked: new Set() });
159
+ this.redraw();
160
+ });
161
+ }
162
+ handleInput(data) {
163
+ if (matchesKey(data, 'ctrl+c'))
164
+ return this.interrupt();
165
+ const question = this.questions[0];
166
+ if (question)
167
+ return this.answer(question, data);
168
+ if (this.scrolls(data))
169
+ return this.redraw();
170
+ const view = this.views.at(-1);
171
+ const escape = matchesKey(data, 'escape');
172
+ if (view?.kind === 'intro') {
173
+ if (matchesKey(data, 'enter'))
174
+ view.done(true);
175
+ else if (escape)
176
+ view.done(false);
177
+ }
178
+ else if (view?.kind === 'signin') {
179
+ if (isLetter(data, 'c')) {
180
+ view.note = undefined;
181
+ this.copy(view);
182
+ }
183
+ else if (isLetter(data, 'p') && !view.pasting) {
184
+ this.paste(view);
185
+ }
186
+ else if (escape) {
187
+ view.opts.onCancel?.();
188
+ }
189
+ }
190
+ else if (view?.kind === 'run' && escape) {
191
+ if (this.stopArmed())
192
+ return this.interrupt();
193
+ this.stopAsked = Date.now();
194
+ }
195
+ else if ((view?.kind === 'pull' || (view?.kind === 'outro' && !view.leaving)) && view.copy && isLetter(data, 'c')) {
196
+ // C puts the screen's link on the clipboard, and says so once it is there; any other key goes on.
197
+ void view
198
+ .copy()
199
+ .catch(() => false)
200
+ .then((copied) => {
201
+ view.copied ||= copied;
202
+ this.redraw();
203
+ });
204
+ }
205
+ else if (view?.kind === 'pull' || (view?.kind === 'outro' && !view.leaving)) {
206
+ view.done();
207
+ }
208
+ this.redraw();
209
+ }
210
+ invalidate() { }
211
+ /** ↑↓ move the window over a screen too tall for the terminal a row, PgUp PgDn a page; true when `data` is one of them. */
212
+ scrolls(data) {
213
+ const by = matchesKey(data, 'up') ? -1 : matchesKey(data, 'down') ? 1 : matchesKey(data, 'pageUp') ? -this.scroll.page : matchesKey(data, 'pageDown') ? this.scroll.page : 0;
214
+ this.scroll.by += by;
215
+ return by !== 0;
216
+ }
217
+ /** Ctrl+C, or Esc twice on the run screen: the intro cancels, the test drive goes on to the outro, the outro closes, a run winds down through its cancel, and anything else stops here. */
218
+ interrupt() {
219
+ const view = this.views.at(-1);
220
+ if (view?.kind === 'intro')
221
+ return view.done(false);
222
+ if (view?.kind === 'pull')
223
+ return view.done();
224
+ if (view?.kind === 'outro')
225
+ return view.leaving ? undefined : view.done();
226
+ const run = this.views.find((v) => v.kind === 'run');
227
+ if (!run) {
228
+ this.stop();
229
+ process.exit(130);
230
+ }
231
+ this.stopAsked = 0;
232
+ for (const question of this.questions.splice(0))
233
+ question.resolve({ skipped: true });
234
+ this.state.status('Stopping the setup');
235
+ this.redraw();
236
+ run.cancel();
237
+ }
238
+ paste(view) {
239
+ view.pasting = true;
240
+ view.note = undefined;
241
+ void view.opts
242
+ .onPaste()
243
+ .catch(() => null)
244
+ .then((code) => {
245
+ view.pasting = false;
246
+ view.note = code ? 'Signing you in…' : 'That is not a code from this sign-in. Press P to try again.';
247
+ this.redraw();
248
+ });
249
+ }
250
+ answer(question, data) {
251
+ const { q } = question;
252
+ const choices = q.kind !== 'text' && q.options?.length ? q.options : null;
253
+ // PgUp PgDn scroll a question too tall for the terminal, as ↑↓ do unless they move among choices or an account's fields. Any other key brings its field or cursor back into view; while that is out of view, Enter, and Space on choices, only bring it back, so nothing is picked or sent unseen.
254
+ if (!((choices || question.password) && (matchesKey(data, 'up') || matchesKey(data, 'down'))) && this.scrolls(data))
255
+ return this.redraw();
256
+ const hidden = this.scroll.hidden;
257
+ this.scroll.by = 0;
258
+ if (hidden && (matchesKey(data, 'enter') || (choices && matchesKey(data, 'space'))))
259
+ return this.redraw();
260
+ const done = (answer) => {
261
+ this.questions.shift();
262
+ question.resolve(answer);
263
+ this.redraw();
264
+ };
265
+ question.note = undefined;
266
+ if (matchesKey(data, 'escape')) {
267
+ if (q.skippable)
268
+ return done({ skipped: true });
269
+ question.note = 'This one needs an answer.';
270
+ }
271
+ else if (q.kind === 'confirm') {
272
+ if (matchesKey(data, 'enter'))
273
+ return done({ skipped: false });
274
+ }
275
+ else if (question.password) {
276
+ // Tab or ↑↓ move between the two fields; Enter sends both once both are filled, else goes to the empty one.
277
+ const [username, password] = [question.field.getValue().trim(), question.password.getValue()];
278
+ if (matchesKey(data, 'enter') && username && password)
279
+ return done({ skipped: false, username, password });
280
+ if (matchesKey(data, 'enter'))
281
+ question.focus = username ? 1 : 0;
282
+ else if (matchesKey(data, 'tab') || matchesKey(data, 'shift+tab'))
283
+ question.focus = question.focus ? 0 : 1;
284
+ else if (matchesKey(data, 'up') || matchesKey(data, 'down'))
285
+ question.focus = matchesKey(data, 'up') ? 0 : 1;
286
+ else
287
+ (question.focus ? question.password : question.field).handleInput(data);
288
+ }
289
+ else if (matchesKey(data, 'enter')) {
290
+ if (choices && q.kind === 'single')
291
+ return done({ skipped: false, value: choices[question.cursor].value });
292
+ if (choices)
293
+ return done({ skipped: false, values: choices.filter((_, i) => question.picked.has(i)).map((o) => o.value) });
294
+ const value = question.field.getValue().trim();
295
+ if (value)
296
+ return done({ skipped: false, value });
297
+ if (q.skippable)
298
+ return done({ skipped: true });
299
+ question.note = 'Type an answer first.';
300
+ }
301
+ else if (!choices) {
302
+ question.field.handleInput(data);
303
+ }
304
+ else if (matchesKey(data, 'up')) {
305
+ question.cursor = (question.cursor + choices.length - 1) % choices.length;
306
+ }
307
+ else if (matchesKey(data, 'down') || matchesKey(data, 'tab')) {
308
+ question.cursor = (question.cursor + 1) % choices.length;
309
+ }
310
+ else if (/^[1-9]$/.test(data) && Number(data) <= choices.length) {
311
+ question.cursor = Number(data) - 1;
312
+ if (q.kind === 'multi')
313
+ toggle(question.picked, question.cursor);
314
+ }
315
+ else if (q.kind === 'multi' && matchesKey(data, 'space')) {
316
+ toggle(question.picked, question.cursor);
317
+ }
318
+ this.redraw();
319
+ }
320
+ /** The screen up now; `live` false holds what only time moves (a wait's timer) at its start, for the drawing that tells a change. */
321
+ scene(live = true) {
322
+ const view = this.views.at(-1);
323
+ if (!view)
324
+ return { kind: 'wait' };
325
+ if (view.kind === 'intro')
326
+ return { kind: 'intro' };
327
+ if (view.kind === 'signin')
328
+ return { kind: 'signin', link: view.link, copied: view.copied, again: view.again, note: view.pasting ? undefined : view.note };
329
+ if (view.kind === 'pull')
330
+ return { kind: 'pull', url: view.url, copied: view.copied };
331
+ if (view.kind === 'outro')
332
+ return { kind: 'outro', outro: view.outro, leaving: view.leaving, copied: view.copied };
333
+ return { kind: 'run', tasks: this.state.tasks, band: this.state.band(!this.questions.length, live), wait: this.state.timer(live) };
334
+ }
335
+ render(width) {
336
+ const question = this.questions[0];
337
+ const view = this.views.at(-1);
338
+ // The run's loader and tasks are two screens.
339
+ const screen = question ?? (view?.kind === 'run' ? (workStarted(this.state.tasks) ? 'tasks' : 'loader') : view);
340
+ if (screen !== this.scrollOf)
341
+ [this.scrollOf, this.scroll] = [screen, { by: 0, page: 1, hidden: false }];
342
+ const scene = this.scene();
343
+ const frame = {
344
+ width,
345
+ height: this.tui.terminal.rows,
346
+ scene,
347
+ question: question && {
348
+ q: question.q,
349
+ // A sensitive answer shows as dots; the field still does the editing. Only the field with the cursor shows it.
350
+ field: (w) => (question.q.sensitive ? dots(question.field, true) : question.focus ? `› ${question.field.getValue()}` : (question.field.render(w)[0] ?? '')),
351
+ password: question.password && (() => dots(question.password, question.focus === 1)),
352
+ focus: question.focus,
353
+ cursor: question.cursor,
354
+ picked: question.picked,
355
+ note: question.note,
356
+ },
357
+ motion: this.motion(),
358
+ stopping: this.views.at(-1)?.kind === 'run' && !question && this.stopArmed(),
359
+ scroll: this.scroll,
360
+ version: VERSION,
361
+ style: this.style,
362
+ };
363
+ const lines = drawFrame(frame);
364
+ if (frames) {
365
+ // Drawn again with what moves stopped: a screen that only moved its loader, its spinner or a wait's timer has not changed; a wait beginning or ending has.
366
+ const still = drawFrame({ ...frame, scene: this.scene(false), motion: frame.motion && { ...frame.motion, frame: 0 } });
367
+ frames.record(frameState(scene, question?.q, frames.last), lines, { question: question?.q, still });
368
+ }
369
+ return lines;
370
+ }
371
+ }
372
+ /** A hidden answer's field: a dot for each character typed, and the cursor after them when it has it. */
373
+ const dots = (field, focused) => `› ${'•'.repeat(field.getValue().length)}${focused ? `${CURSOR_MARKER}\x1b[7m \x1b[27m` : ''}`;
374
+ const isLetter = (data, letter) => matchesKey(data, letter) || matchesKey(data, `shift+${letter}`);
375
+ function toggle(picked, i) {
376
+ if (!picked.delete(i))
377
+ picked.add(i);
378
+ }
379
+ /** Routes console output into `show` until the returned function puts it back. */
380
+ function captureConsole(show) {
381
+ const saved = { log: console.log, info: console.info, warn: console.warn, error: console.error };
382
+ const capture = (...args) => format(...args).split('\n').forEach(show);
383
+ Object.assign(console, { log: capture, info: capture, warn: capture, error: capture });
384
+ return () => void Object.assign(console, saved);
385
+ }
386
+ /** The mark over --help, as the terminal prints it: plain text on a dumb terminal. */
387
+ export function helpMark() {
388
+ return palette(undefined, process.env.TERM === 'dumb' ? 'plain' : 'blocks').mark(80).map((row) => row.trimStart());
389
+ }
390
+ /** The wizard when there is a terminal to draw it on. */
391
+ function host() {
392
+ if (!interactive())
393
+ return null;
394
+ wizard ??= new Wizard();
395
+ return wizard;
396
+ }
397
+ function repository() {
398
+ try {
399
+ const checkout = readCheckout(process.cwd());
400
+ return `${checkout.owner}/${checkout.repo}`;
401
+ }
402
+ catch {
403
+ return undefined;
404
+ }
405
+ }
406
+ /** Writes every screen into `dir` as it changes (--frames), for the eval. */
407
+ export function recordFrames(dir) {
408
+ frames = createFrameWriter(dir);
409
+ }
410
+ /** The first screen: what the command does, and the way on. Resolves true to go on, false to cancel. */
411
+ export function showIntro() {
412
+ const screen = host();
413
+ if (!screen) {
414
+ console.log(`${PRODUCT}: setting up ${repository() ?? 'this repository'}.`);
415
+ console.log();
416
+ return Promise.resolve(true);
417
+ }
418
+ return new Promise((resolve) => {
419
+ const view = {
420
+ kind: 'intro',
421
+ done: (ok) => {
422
+ screen.remove(view);
423
+ if (ok)
424
+ resolve(true);
425
+ else
426
+ void screen.close().then(() => resolve(false));
427
+ },
428
+ };
429
+ screen.push(view);
430
+ });
431
+ }
432
+ /**
433
+ * The sign-in screen, in place of whatever is showing, until `close`: the
434
+ * link, put on the clipboard as it opens (`onCopy`, and again on C), and a way
435
+ * to paste the code from a remote shell (P calls `onPaste`, and a null from it
436
+ * says the code was wrong); Esc calls `onCancel`. Over a run it asks to sign
437
+ * in again.
438
+ */
439
+ export function showSignIn(link, opts) {
440
+ const screen = host();
441
+ if (!screen) {
442
+ console.log(`Sign in to ${PRODUCT} in your browser. If it did not open, open this link:`);
443
+ console.log(link);
444
+ return { close() { } };
445
+ }
446
+ const view = { kind: 'signin', link, opts, pasting: false, again: screen.views.some((v) => v.kind === 'run') };
447
+ screen.push(view);
448
+ screen.copy(view);
449
+ return { close: () => screen.remove(view) };
450
+ }
451
+ /**
452
+ * The run screen: understanding the app with the loader until the work after
453
+ * reading starts, then the tasks, each with the status line, and questions in
454
+ * their place. It opens at once, so the wait for the agent's first words has
455
+ * something to show; calling it again while it is up only hands it a new
456
+ * `onCancel`. `close` gives the terminal back.
457
+ */
458
+ export function showRun(opts) {
459
+ const screen = host();
460
+ if (!screen)
461
+ return { close: async () => { } };
462
+ const up = screen.views.find((v) => v.kind === 'run');
463
+ if (up)
464
+ up.cancel = opts.onCancel;
465
+ else {
466
+ screen.state = new RunState(Date.now, FIRST_STATUS);
467
+ screen.push({ kind: 'run', cancel: opts.onCancel });
468
+ }
469
+ return { close: () => screen.close() };
470
+ }
471
+ /** The outro in plain lines, for the scrollback: what stays once the screen has gone. */
472
+ function summary(o) {
473
+ return ['', `${OUTCOME[o.outcome]} ${o.title}. ${o.result}`, ...o.links.map((l) => `${l.label}: ${l.url}`), ...o.todo.map((line) => `→ ${line}`), ...o.warnings.map((warning) => `! ${warning}`)];
474
+ }
475
+ /** Opens `url` in the browser and puts it on the clipboard, true once it is there. Neither can fail the screen, which shows the link either way. */
476
+ async function handOver(url, hand) {
477
+ hand.open(url);
478
+ const copied = hand.copy(url).catch(() => false);
479
+ return (await Promise.race([copied, new Promise((r) => setTimeout(() => r(false), 1000).unref())])) === true;
480
+ }
481
+ /** The test drive on a screen of its own once the run has ended, before the last screen shows the setup pull request, handed over as it appears, until a key other than ↑↓ PgUp PgDn is pressed. Without a terminal there is no such screen: the summary has it. */
482
+ export async function showPullRequest(url, hand) {
483
+ const screen = host();
484
+ if (!screen)
485
+ return;
486
+ const copied = await handOver(url, hand);
487
+ await new Promise((resolve) => {
488
+ const view = {
489
+ kind: 'pull',
490
+ url,
491
+ copied,
492
+ copy: () => hand.copy(url),
493
+ done: () => {
494
+ screen.remove(view);
495
+ resolve();
496
+ },
497
+ };
498
+ screen.views = [];
499
+ screen.push(view);
500
+ });
501
+ }
502
+ /** The last screen: what happened, what is left, any warnings and its pull request (handed over as it appears) or the links, until a key other than ↑↓ PgUp PgDn is pressed. Its summary then stays in the scrollback. */
503
+ export async function showOutro(o, hand) {
504
+ const screen = host();
505
+ if (screen) {
506
+ const copied = o.link && hand ? await handOver(o.link, hand) : false;
507
+ // The link C copies: the pull request, or the one link a screen without it lists.
508
+ const link = o.link ?? (o.links.length === 1 ? o.links[0].url : undefined);
509
+ await new Promise((resolve) => {
510
+ const view = {
511
+ kind: 'outro',
512
+ outro: o,
513
+ leaving: false,
514
+ copied,
515
+ copy: link ? () => (hand?.copy ?? copyText)(link) : undefined,
516
+ done: () => {
517
+ view.leaving = true;
518
+ void screen.close().then(resolve);
519
+ },
520
+ };
521
+ screen.views = [];
522
+ screen.push(view);
523
+ });
524
+ }
525
+ for (const line of summary(o))
526
+ console.log(line);
527
+ }
528
+ /** Takes the screen down, if one is up, so what is printed next lands in the terminal as it was. */
529
+ export async function closeScreens() {
530
+ await wizard?.close();
531
+ }
532
+ const exitNotes = [];
533
+ /** Lines for the scrollback when the command exits, after the screen has gone and the outro's summary: where the logs are. */
534
+ export function atExit(lines) {
535
+ if (!exitNotes.length) {
536
+ process.once('exit', () => {
537
+ wizard?.stop();
538
+ for (const note of exitNotes)
539
+ for (const line of note())
540
+ console.log(line);
541
+ });
542
+ }
543
+ exitNotes.push(lines);
544
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Sensitive answers (a password, a key) as the person gives them, for the
3
+ * debug log and the transcript to redact wherever the value turns up later.
4
+ * The bridge reports every one, with a screen or without.
5
+ */
6
+ const listeners = new Set();
7
+ export function onSensitiveAnswer(listener) {
8
+ listeners.add(listener);
9
+ return () => void listeners.delete(listener);
10
+ }
11
+ export function noteSensitiveAnswer(value) {
12
+ for (const listener of listeners)
13
+ listener(value);
14
+ }