@impeccable/reviews 0.1.23 → 0.1.25

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/dist/ui/bridge.js CHANGED
@@ -31,6 +31,10 @@ export async function askPerson(q) {
31
31
  export function reportStatus(line) {
32
32
  mounted?.status(plainText(line));
33
33
  }
34
+ /** What a tool just confirmed it did, a line under the tasks: "Netlify password set on acme" (#416). */
35
+ export function reportDone(line) {
36
+ mounted?.done?.(plainText(line));
37
+ }
34
38
  /** Replaces the task list. */
35
39
  export function reportTasks(tasks) {
36
40
  mounted?.tasks(tasks.map((t) => ({ ...t, title: plainText(t.title) })));
package/dist/ui/look.js CHANGED
@@ -9,7 +9,6 @@ import { fitsTask, taskInProgress, workStarted } from '../stages.js';
9
9
  * the box on the right. Only the middle changes from screen to screen.
10
10
  */
11
11
  export const PRODUCT = 'Impeccable for PRs';
12
- export const FEEDBACK = 'support@renaissance-geek.ai';
13
12
  /** How long the outro waits for a key before it closes itself (screens.ts), which its fine print says. */
14
13
  export const CLOSES_AFTER_MINUTES = 20;
15
14
  /** The box fills the terminal up to this size, then sits in the middle of it. */
@@ -22,6 +21,8 @@ const FIELD_WIDTH = 48;
22
21
  /** The running task's spinner: one glyph every third frame. */
23
22
  const SPINNER = ['◐', '◓', '◑', '◒'];
24
23
  const START = { heading: "Let's set up your app", text: 'This wizard detects your framework and wires up all the essentials.' };
24
+ /** The start's heading on a checkout of several apps (#418). */
25
+ const START_APPS = "Let's set up your apps";
25
26
  /** The sign-in over a run, by why it signs in again. */
26
27
  const AGAIN = {
27
28
  'ran-out': 'Your sign-in ran out. Approve again in your browser to carry on.',
@@ -195,7 +196,7 @@ function questionPage(qv, inner, st) {
195
196
  ways: [[q.confirmLabel ?? 'Continue', 'Enter'], [q.skipLabel ?? 'Skip', 'Esc']],
196
197
  };
197
198
  }
198
- // One test account: both fields under the divider, each in a box with its label in the top edge, the password as dots.
199
+ // One account: its prompt under the title, then the hint and both fields under the divider, each field in a box with its label in the top edge, the second as dots. A test account of the app's sign-in (no fields of its own) leaves its prompt to the web page and its prompt's row blank, so its title stands alone where a one-line prompt would put the divider (#409).
199
200
  if (q.kind === 'account') {
200
201
  const width = Math.min(FIELD_WIDTH, inner);
201
202
  const label = (text) => ` ${st.soft(text)}`;
@@ -204,7 +205,7 @@ function questionPage(qv, inner, st) {
204
205
  const fields = [...fieldBox(first, qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox(second, qv.password?.() ?? '', width, qv.focus === 1, st)];
205
206
  return {
206
207
  heading: q.title ?? 'A test account',
207
- text: said(q.prompt, inner, st),
208
+ text: q.fields ? said(q.prompt, inner, st) : [''],
208
209
  rule: divider(inner, st),
209
210
  action: [...block([...hint, ...fields], inner, width), ...note],
210
211
  at: hint.length + (qv.focus === 1 ? 4 : 1),
@@ -215,7 +216,7 @@ function questionPage(qv, inner, st) {
215
216
  const choices = q.kind !== 'text' && q.options?.length ? q.options : null;
216
217
  const hint = q.hint && choices ? said(q.hint, inner, st) : [];
217
218
  const after = [
218
- ...(choices ? [center(st.faint(q.kind === 'multi' ? 'Move [↑↓] Pick [Space]' : 'Choose [↑↓]'), inner)] : []),
219
+ ...(choices ? ['', center(st.faint(q.kind === 'multi' ? 'Move [↑↓] Pick [Space]' : 'Choose [↑↓]'), inner)] : []),
219
220
  ...note,
220
221
  ];
221
222
  // A field's hint is its label, in the box's top edge when it fits there, else over the box on the same left edge.
@@ -255,8 +256,9 @@ function runPage(scene, f, inner, st) {
255
256
  current ? center(`${f.motion?.at === 'status' ? `${st.full(glyph)} ` : ''}${st.soft(current)}`, inner) : '',
256
257
  ];
257
258
  const around = { ways: f.stopping ? [['Stop the setup', 'Esc']] : undefined };
259
+ const done = scene.done ? ['', center(st.soft(`✓ ${scene.done}`), inner)] : [];
258
260
  if (!workStarted(scene.tasks))
259
- return { heading: 'Understanding your app', rule: loader(inner, f.motion?.frame ?? 0, st), action: status, ...around };
261
+ return { heading: 'Understanding your app', rule: loader(inner, f.motion?.frame ?? 0, st), action: [...status, ...done], ...around };
260
262
  const running = scene.tasks.findIndex((t) => t.status === 'in_progress');
261
263
  const rows = scene.tasks.map((t, i) => {
262
264
  // A task back to pending after it started (Preview method while Test drive runs) keeps a started mark, never ○ (#390).
@@ -277,9 +279,11 @@ function runPage(scene, f, inner, st) {
277
279
  if (i !== running || !scene.wait)
278
280
  return ink(line);
279
281
  // The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
280
- // waiting on a host's build is never still; the time alone where the words would push it off the row.
281
- const waiting = row(`waiting for ${scene.wait.host ? `${scene.wait.host}'s preview` : 'the preview'} · ${scene.wait.timer}`);
282
- if (visibleWidth(waiting) <= room)
282
+ // waiting on a host's build is never still; the host alone where the words would push it off the row (80 columns),
283
+ // and the time alone where even that would.
284
+ const { host, timer } = scene.wait;
285
+ const waiting = (host ? [`${host}'s preview`, host] : ['the preview']).map((what) => row(`waiting for ${what} · ${timer}`)).find((words) => visibleWidth(words) <= room);
286
+ if (waiting)
283
287
  return ink(waiting);
284
288
  // Where even the time alone would pass the box from that edge, the title gives way, never the time.
285
289
  const tail = ` · ${scene.wait.timer}`;
@@ -289,7 +293,7 @@ function runPage(scene, f, inner, st) {
289
293
  return {
290
294
  heading: 'Setting up',
291
295
  rule: divider(inner, st),
292
- action: block(lines, inner, widest),
296
+ action: [...block(lines, inner, widest), ...done],
293
297
  at: running >= 0 ? running : Math.max(0, scene.tasks.findIndex((t) => t.status === 'pending')),
294
298
  ...around,
295
299
  };
@@ -300,7 +304,7 @@ function outroPage(scene, inner, st) {
300
304
  return {
301
305
  heading: outro.title,
302
306
  // A warning is read whole: wrapped, where centered on one line it would be cut.
303
- text: [...said(outro.result, inner, st), ...usedLines(outro, inner, st), ...leftLines(outro.todo, inner, st), ...outro.warnings.flatMap((line) => said(`! ${line}`, inner, st))],
307
+ text: [...said(outro.result, inner, st), ...usedLines(outro, inner, st), ...paragraph([...leftLines(outro.todo, inner, st), ...outro.warnings.flatMap((line) => said(`! ${line}`, inner, st))])],
304
308
  rule: divider(inner, st),
305
309
  action: pullAction(outro.link, scene.copied, inner, st),
306
310
  ways: scene.leaving ? undefined : [['Close', 'Enter']],
@@ -310,28 +314,35 @@ function outroPage(scene, inner, st) {
310
314
  const width = Math.min(TEXT_WIDTH, inner);
311
315
  const notes = [...outro.todo.flatMap((line) => hang(line, width, '→ ').map(st.full)), ...outro.warnings.flatMap((line) => hang(line, width, '! ').map(st.soft))];
312
316
  const links = outro.links.flatMap((l) => linkLines(l.label, l.url, inner, st));
317
+ const action = [...block(notes, inner), ...(notes.length && links.length ? [''] : []), ...links, ...(outro.links.length === 1 ? [scene.copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st)] : [])];
313
318
  return {
314
319
  heading: outro.title,
315
320
  text: [...said(outro.result, inner, st), ...usedLines(outro, inner, st)],
316
321
  rule: divider(inner, st),
317
- action: [...block(notes, inner), ...(notes.length && links.length ? [''] : []), ...links, ...(outro.links.length === 1 ? [scene.copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st)] : [])],
322
+ action,
323
+ // Where the notes leave no room, the window opens on the links, the notes above it a scroll away (#419).
324
+ at: Math.max(0, action.length - 1),
318
325
  ways: scene.leaving ? undefined : [['Close', 'Enter']],
319
- foot: outro.outcome === 'stopped' ? [center(st.faint(`Questions? Write to ${FEEDBACK}`), inner)] : closesItself(scene, inner, st),
326
+ // A stopped run's foot is empty: no line to write to us in the closed beta (#413).
327
+ foot: outro.outcome === 'stopped' ? [] : closesItself(scene, inner, st),
320
328
  };
321
329
  }
322
- /** The outro's fine print that it closes on its own, until it is closing; a stopped run's foot keeps the way to write to us instead, one line either way. */
330
+ /** Lines under the text above them, a blank row between (#416); none when there are none. */
331
+ const paragraph = (lines) => (lines.length ? ['', ...lines] : []);
332
+ /** The outro's fine print that it closes on its own, until it is closing; a stopped run's has none. */
323
333
  const closesItself = (scene, inner, st) => (scene.leaving ? [] : [center(st.faint(`Closes on its own after ${CLOSES_AFTER_MINUTES} minutes.`), inner)]);
324
334
  function pageOf(f, inner) {
325
335
  const st = f.style;
336
+ const start = f.apps ? START_APPS : START.heading;
326
337
  if (f.question)
327
338
  return questionPage(f.question, inner, st);
328
339
  const { scene } = f;
329
340
  switch (scene.kind) {
330
341
  case 'wait':
331
- return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.faint('One moment…'), inner)] };
342
+ return { heading: start, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.faint('One moment…'), inner)] };
332
343
  case 'intro': {
333
344
  const note = scene.note ? ['', ...block(hang(scene.note, Math.min(TEXT_WIDTH, inner), '! ').map(st.soft), inner)] : [];
334
- return { heading: START.heading, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner), ...note], ways: [['Continue', 'Enter']] };
345
+ return { heading: start, text: said(START.text, inner, st), rule: divider(inner, st), action: [center(st.full("Let's start with login"), inner), ...note], ways: [['Continue', 'Enter']] };
335
346
  }
336
347
  case 'signin': {
337
348
  // P still takes a pasted code on a remote shell; --help says so, the screen does not.
@@ -340,7 +351,7 @@ function pageOf(f, inner) {
340
351
  // In the action, which scrolls and keeps every line, so a short terminal still shows which checkout this approves.
341
352
  const checkout = scene.checkout && !scene.again ? [...balanced(scene.checkout.repository, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.full(line), inner)), ...slashRows(`in ${scene.checkout.folder}`, Math.min(TEXT_WIDTH, inner)).map((line) => center(st.soft(line), inner)), ''] : [];
342
353
  return {
343
- heading: scene.again ? 'Sign in again' : START.heading,
354
+ heading: scene.again ? 'Sign in again' : start,
344
355
  text: said(scene.again ? AGAIN[scene.again] : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
345
356
  rule: divider(inner, st),
346
357
  action: [...checkout, ...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied ? [] : ['', copyWay(inner, st)])],
@@ -382,7 +393,10 @@ function stack(page, inner, rows, st, scroll) {
382
393
  if (over() > 0)
383
394
  foot = [];
384
395
  if (over() > 0 && over() < text.length && !page.asks) {
385
- const keep = text.length - over();
396
+ let keep = text.length - over();
397
+ // The ellipsis goes on the last line of words, never on a blank row between paragraphs.
398
+ while (keep > 1 && !text[keep - 1])
399
+ keep--;
386
400
  text = [...text.slice(0, keep - 1), `${text[keep - 1]}${st.soft('…')}`];
387
401
  }
388
402
  if (over() > 0 && !page.asks)
@@ -391,9 +405,9 @@ function stack(page, inner, rows, st, scroll) {
391
405
  mark = [];
392
406
  if (over() > 0)
393
407
  rule = [];
394
- // Past this point a question's prompt scrolls with its field or choices, a blank row under it.
395
- const lead = over() > 0 && page.asks ? [...text, ''] : [];
396
- if (lead.length)
408
+ // Past this point a question's prompt scrolls with its field or choices, a blank row under it; an account's blank prompt row goes.
409
+ const lead = over() > 0 && page.asks && text.some(Boolean) ? [...text, ''] : [];
410
+ if (over() > 0)
397
411
  text = [];
398
412
  const action = windowed([...lead, ...page.action], page.action.length - over(), lead.length + (page.at ?? 0), scroll, inner, st);
399
413
  const parts = [mark, heading, text, rule, action, ways, foot].filter((part) => part.length);
@@ -24,10 +24,14 @@ export class RunState {
24
24
  tasks = [];
25
25
  previous;
26
26
  current;
27
+ /** The last thing a tool confirmed it did, which stays under the tasks until the next. */
28
+ done;
27
29
  /** A bounded wait under way: the current line carries its timer, counting up. */
28
30
  waiting;
29
31
  /** The tasks that have been in progress, done or failed in this run. */
30
32
  started = new Set();
33
+ /** The tasks that have left pending in this run (skipped too), in the order they first did: the list shows them in that order. */
34
+ moved = new Set();
31
35
  /** When something last changed on screen: a status, the task list, an answered question. */
32
36
  changedAt;
33
37
  constructor(now = Date.now, first) {
@@ -64,8 +68,15 @@ export class RunState {
64
68
  for (const t of tasks)
65
69
  if (t.status === 'in_progress' || t.status === 'completed' || t.status === 'failed')
66
70
  this.started.add(t.id);
71
+ for (const t of tasks)
72
+ if (t.status !== 'pending')
73
+ this.moved.add(t.id);
67
74
  // A task that has started never reads as not started again (#390).
68
- this.tasks = tasks.map((t) => ({ ...t, ...(t.status === 'pending' && this.started.has(t.id) ? { started: true } : {}) }));
75
+ const shown = tasks.map((t) => ({ ...t, ...(t.status === 'pending' && this.started.has(t.id) ? { started: true } : {}) }));
76
+ // The tasks that moved on, in the order they did, then those still waiting: one that waits under the task in progress never shows above it unticked (#419).
77
+ const order = [...this.moved];
78
+ const rank = (t) => (this.moved.has(t.id) ? order.indexOf(t.id) : order.length);
79
+ this.tasks = shown.map((t, i) => ({ t, i })).sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i).map(({ t }) => t);
69
80
  this.touch();
70
81
  }
71
82
  /** Something happened that the person saw, so the silence starts again. */
@@ -30,6 +30,8 @@ let frames;
30
30
  class Wizard {
31
31
  state = new RunState();
32
32
  views = [];
33
+ /** The checkout holds several apps, so the start says "apps" (#418). */
34
+ apps = false;
33
35
  questions = [];
34
36
  /** Output from anywhere else while the screen is up, printed once it has gone. */
35
37
  held = [];
@@ -78,6 +80,7 @@ class Wizard {
78
80
  status: (line) => (this.state.status(line), this.redraw()),
79
81
  tasks: (tasks) => (this.state.setTasks(tasks), this.redraw()),
80
82
  wait: (wait) => (this.state.wait(wait), this.redraw()),
83
+ done: (line) => ((this.state.done = line), this.state.touch(), this.redraw()),
81
84
  });
82
85
  }
83
86
  push(view) {
@@ -345,7 +348,7 @@ class Wizard {
345
348
  return { kind: 'pull', url: view.url, ...(view.app ? { app: view.app } : {}), copied: view.copied };
346
349
  if (view.kind === 'outro')
347
350
  return { kind: 'outro', outro: view.outro, leaving: view.leaving, copied: view.copied };
348
- return { kind: 'run', tasks: this.state.tasks, band: this.state.band(!this.questions.length, live), wait: this.state.timer(live) };
351
+ return { kind: 'run', tasks: this.state.tasks, band: this.state.band(!this.questions.length, live), wait: this.state.timer(live), ...(this.state.done ? { done: this.state.done } : {}) };
349
352
  }
350
353
  render(width) {
351
354
  const question = this.questions[0];
@@ -374,6 +377,7 @@ class Wizard {
374
377
  scroll: this.scroll,
375
378
  version: VERSION,
376
379
  style: this.style,
380
+ apps: this.apps,
377
381
  };
378
382
  const lines = drawFrame(frame);
379
383
  if (frames) {
@@ -413,9 +417,11 @@ function host() {
413
417
  export function recordFrames(dir) {
414
418
  frames = createFrameWriter(dir);
415
419
  }
416
- /** The first screen: what the command does, a warning to read before anything runs (`note`), and the way on; without a terminal, a line naming `repository`, the owner/name being set up. Resolves true to go on, false to cancel. */
417
- export function showIntro(repository, note) {
420
+ /** The first screen: what the command does, a warning to read before anything runs (`note`), and the way on, saying "apps" where the checkout holds several (`apps`, which the screens after it keep); without a terminal, a line naming `repository`, the owner/name being set up. Resolves true to go on, false to cancel. */
421
+ export function showIntro(repository, note, apps = false) {
418
422
  const screen = host();
423
+ if (screen)
424
+ screen.apps = apps;
419
425
  if (!screen) {
420
426
  console.log(`${PRODUCT}: setting up ${repository}.`);
421
427
  if (note)
@@ -486,12 +492,12 @@ async function handOver(url, hand) {
486
492
  const copied = hand.copy(url).catch(() => false);
487
493
  return (await Promise.race([copied, new Promise((r) => setTimeout(() => r(false), 1000).unref())])) === true;
488
494
  }
489
- /** 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, or for 20 minutes; on a repository with several apps, each app's in turn, named (impeccable-reviews#397). Without a terminal there is no such screen: the summary has it. */
490
- export async function showPullRequest(url, hand, app) {
495
+ /** 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 (put on the clipboard only when `copy`: a later screen's link would take its place, #418), until a key other than ↑↓ PgUp PgDn is pressed, or for 20 minutes; on a repository with several apps, each app's in turn, named (impeccable-reviews#397). Without a terminal there is no such screen: the summary has it. */
496
+ export async function showPullRequest(url, hand, app, copy = true) {
491
497
  const screen = host();
492
498
  if (!screen)
493
499
  return;
494
- const copied = await handOver(url, hand);
500
+ const copied = copy ? await handOver(url, hand) : (hand.open(url), false);
495
501
  await new Promise((resolve) => {
496
502
  // The screen stays until the end screen takes its place, so no empty screen shows between the two.
497
503
  const done = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,