@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/fence.js +88 -20
- package/dist/focus.js +17 -0
- package/dist/leftovers.js +271 -0
- package/dist/main.js +42 -26
- package/dist/playbook/cases/cloudflare-previews.md +1 -1
- package/dist/playbook/cases/monorepo.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +3 -3
- package/dist/playbook/cases/preview-protection.md +3 -2
- package/dist/playbook/cases/server-previews.md +3 -3
- package/dist/playbook/cases/sign-in.md +8 -8
- package/dist/playbook/cases/vercel-setup.md +3 -3
- package/dist/playbook.js +19 -3
- package/dist/preflight.js +13 -5
- package/dist/tools.js +235 -36
- package/dist/ui/bridge.js +4 -0
- package/dist/ui/look.js +34 -20
- package/dist/ui/run-state.js +12 -1
- package/dist/ui/screens.js +12 -6
- package/package.json +1 -1
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
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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:
|
|
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' :
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/dist/ui/run-state.js
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/ui/screens.js
CHANGED
|
@@ -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 = () => {
|