@impeccable/reviews 0.1.20 → 0.1.21
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 +11 -0
- package/dist/host-questions.js +6 -5
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/tools.js +30 -3
- package/dist/ui/look.js +35 -9
- package/dist/ui/run-state.js +7 -1
- package/package.json +1 -1
package/dist/fence.js
CHANGED
|
@@ -365,6 +365,15 @@ function listsOrigin(args) {
|
|
|
365
365
|
const words = args.filter((a) => !a.startsWith('-'));
|
|
366
366
|
return options.every((a) => LS_REMOTE_FLAGS.test(a) || LS_REMOTE_LONG.has(a) || a.startsWith('--sort=')) && words[0] === 'origin';
|
|
367
367
|
}
|
|
368
|
+
/** git ls-files's options that pick which tracked or untracked files it names, and how it prints them; never --ignored, --stage, --format or one that reads a file (--exclude-from). */
|
|
369
|
+
const LS_FILES_OPTIONS = new Set(['-c', '--cached', '-o', '--others', '-m', '--modified', '-d', '--deleted', '--exclude-standard', '-z', '--full-name', '--directory', '--no-empty-directory']);
|
|
370
|
+
/** Whether `git ls-files` with these words only names the checkout's files: the options above, and paths inside the checkout (#390). */
|
|
371
|
+
function listsFiles(args, where) {
|
|
372
|
+
const end = args.indexOf('--');
|
|
373
|
+
const options = (end < 0 ? args : args.slice(0, end)).filter((a) => a.startsWith('-'));
|
|
374
|
+
const paths = end < 0 ? args.filter((a) => !a.startsWith('-')) : [...args.slice(0, end).filter((a) => !a.startsWith('-')), ...args.slice(end + 1)];
|
|
375
|
+
return options.every((a) => LS_FILES_OPTIONS.has(a)) && !paths.some((a) => outside(a, where));
|
|
376
|
+
}
|
|
368
377
|
/** The branch a push's refspec updates on the remote: its destination, else its source, without `+` and `refs/heads/` (or `heads/`, which git reads the same). */
|
|
369
378
|
const pushedBranch = (refspec) => refspec.replace(/^\+/, '').split(':').pop().replace(/^(?:refs\/)?heads\//, '');
|
|
370
379
|
const isFile = (path) => {
|
|
@@ -483,6 +492,8 @@ function git(args, where, protectedBranches, held = false) {
|
|
|
483
492
|
return listsBranches(rest) ? allow() : deny('git branch lists only: -a, -r, -v, --show-current, --list <pattern>, and --contains, --merged, --points-at, --sort and --format with their value after =; git checkout -b or git switch -c makes a branch');
|
|
484
493
|
if (sub === 'remote')
|
|
485
494
|
return listsRemotes(rest) ? allow() : deny('git remote lists only: on its own, -v, or get-url <name>; nothing that adds, removes, renames, points or prunes a remote, and no show, which asks the remote');
|
|
495
|
+
if (sub === 'ls-files')
|
|
496
|
+
return listsFiles(rest, where) ? allow() : deny('git ls-files names files only: -c, -o, -m, -d, --exclude-standard, -z, --full-name, --directory and paths inside the checkout');
|
|
486
497
|
if (sub === 'ls-remote')
|
|
487
498
|
return listsOrigin(rest) ? allow() : deny('git ls-remote asks origin only, named: git ls-remote --heads origin, with --tags, --refs, --symref, --get-url and patterns after origin; no other remote, no URL, no --upload-pack, and not without origin, which asks the current branch\'s own remote');
|
|
488
499
|
if (sub === 'push' && rest.length === 3 && (rest[0] === '-u' || rest[0] === '--set-upstream') && rest[1] === 'origin') {
|
package/dist/host-questions.js
CHANGED
|
@@ -82,7 +82,7 @@ const previewsOff = (id, name, where) => ({
|
|
|
82
82
|
},
|
|
83
83
|
});
|
|
84
84
|
/** The consent to a Vercel bypass secret, saying whose previews Vercel's login keeps and where the secret is revoked. */
|
|
85
|
-
const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection.`;
|
|
85
|
+
const bypassHint = (whose, where) => `Impeccable reviews each pull request on its preview,\nand Vercel keeps ${whose} behind a login.\n\nVercel's way in for a tool like Impeccable is via a bypass secret. It is stored encrypted, used only to open previews and you can revoke it any time in ${where} Settings → Deployment Protection. Vercel also hands it to the project's builds, as VERCEL_AUTOMATION_BYPASS_SECRET.`;
|
|
86
86
|
/** The consent to a Cloudflare service token, saying which Access application keeps whose previews, and whose policy allows the token. */
|
|
87
87
|
const accessHint = (keeps, policy) => `Impeccable reviews each pull request on its preview, and ${keeps} behind a login.\n\nCloudflare's way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in ${policy} policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.`;
|
|
88
88
|
/** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, the consent to turning a host's pull-request previews on, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one, and to making a site behind Netlify's team login public. */
|
|
@@ -228,14 +228,15 @@ export function sentTo(screen, url) {
|
|
|
228
228
|
const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
|
|
229
229
|
return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
|
|
230
230
|
}
|
|
231
|
-
/** On a second Netlify site behind a password, the offer to use the one the person typed for another site of this run (#384); on Skip, the paste screen. */
|
|
232
|
-
export function netlifySamePassword(site, other) {
|
|
231
|
+
/** On a second Netlify site behind a password, the offer to use the one the person typed for another site of this run (#384); on Skip, the paste screen. On a repository with several apps, each site is named after its app first, as every other screen names them (#390). */
|
|
232
|
+
export function netlifySamePassword(site, other, apps = {}) {
|
|
233
|
+
const whose = apps.app ? `${apps.app}'s previews, built by the Netlify site ${site},` : `The previews of the Netlify site ${site}`;
|
|
233
234
|
return {
|
|
234
235
|
id: 'netlify-password-same',
|
|
235
236
|
kind: 'confirm',
|
|
236
237
|
title: 'Your Netlify previews are protected',
|
|
237
|
-
hint:
|
|
238
|
-
prompt: `Use the same password for ${site}?`,
|
|
238
|
+
hint: `${apps.app ? whose[0].toUpperCase() + whose.slice(1) : whose} are behind its site password too. The run tries the password you typed for ${apps.other ?? other} on ${site}'s preview first, and saves it for ${apps.app ?? site} only if it opens.`,
|
|
239
|
+
prompt: `Use the same password for ${apps.app ? `${apps.app} (site ${site})` : site}?`,
|
|
239
240
|
confirmLabel: 'Use it',
|
|
240
241
|
skipLabel: 'Type another',
|
|
241
242
|
};
|
|
@@ -11,7 +11,7 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
|
|
|
11
11
|
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when no host deploys it, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
|
|
12
12
|
6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` once every chosen app's method and build settings are saved, or its preview skipped as server-previews says; the test drive adds its page to every chosen app with a preview (an app whose preview was skipped gets none, and goes under "What was skipped, and why"), in that app's own framework and linked from that app's own public page, so one review renders each of them on its own preview; that case's step 3 builds each chosen app as its host's case says; its step 4 proves the test drive's page on each chosen app's own preview, with that app's key where its lock stands in front; its step 6 checks each chosen app with a preview, with its `appKey`, one call each. Done when pull-requests' steps are done.
|
|
13
13
|
- **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
|
|
14
|
-
7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`, on already after a skip), its method, its sign-in, what was verified; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
|
|
14
|
+
7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`, on already after a skip), its method, its sign-in, what was verified; an app the question did not offer and the state shows off (`enabled` false) was turned off on the setup page, and its line says so, never that it was left off in the question; after `named`, an app still on that this run did not set up is outside this run, not turned off; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
|
|
15
15
|
|
|
16
16
|
### Reference
|
|
17
17
|
|
package/dist/tools.js
CHANGED
|
@@ -19,7 +19,7 @@ import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeam
|
|
|
19
19
|
import { passNetlifyLock } from './netlify-password.js';
|
|
20
20
|
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
21
21
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
22
|
-
import { fitsTask, stagesOf } from './stages.js';
|
|
22
|
+
import { fitsTask, stagesOf, workStarted } from './stages.js';
|
|
23
23
|
import { redact } from './log.js';
|
|
24
24
|
import { scan } from './scan.js';
|
|
25
25
|
import { gitEnvironment } from './sandbox.js';
|
|
@@ -2228,6 +2228,11 @@ export function createTools(ctx) {
|
|
|
2228
2228
|
}
|
|
2229
2229
|
return null;
|
|
2230
2230
|
};
|
|
2231
|
+
/**
|
|
2232
|
+
* Why a host's lock tool may not run yet: the list still shows the repository being read, so its consent and the push
|
|
2233
|
+
* after it would go by on "Understanding your app" (#390). Null once that task is done.
|
|
2234
|
+
*/
|
|
2235
|
+
const readingRefusal = (tool) => workStarted(ctx.sync.payload.tasks) ? null : `Refused: the task list shows no work past reading the repository yet, so the screen still says "Understanding your app". Call impeccable_progress first with Read the repository completed and Preview method in progress, then ${tool} again. Nothing was asked or sent.`;
|
|
2231
2236
|
/** How this run left each app the state lists (#286), for the report and the last screen. */
|
|
2232
2237
|
const appOutcomes = async (pi) => {
|
|
2233
2238
|
const apps = knownApps ?? (await stateApps(pi)) ?? [];
|
|
@@ -2891,6 +2896,8 @@ export function createTools(ctx) {
|
|
|
2891
2896
|
const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
|
|
2892
2897
|
if (accountName && label && !evidence.unmatched)
|
|
2893
2898
|
accountOf.set(`cloudflare ${label}`, accountName);
|
|
2899
|
+
if (accountName && evidence.account)
|
|
2900
|
+
accountOf.set(`cloudflare account ${evidence.account}`, accountName);
|
|
2894
2901
|
if (!subject)
|
|
2895
2902
|
cloudflareEvidence = evidence;
|
|
2896
2903
|
if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
|
|
@@ -3126,6 +3133,9 @@ export function createTools(ctx) {
|
|
|
3126
3133
|
}),
|
|
3127
3134
|
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back. With beforePush (and the app\'s appKey on a repository with several apps), before the test drive\'s push, where no preview met Vercel\'s login yet (#356): it reads the project the same way, and where its settings show Deployment Protection in front of the previews and it holds no secret labelled "Impeccable reviews", shows the consent screen now, naming the project and the app, so the person answers before anything is pushed; the secret is made on the yes once the test drive\'s preview meets the login, with no second question. Answers {"protected": false} (no lock: push), {"protected": true, "consent": "given" or "not-needed"} (push, and call this again without beforePush once the proof meets the gate), {"protected": "unknown", "reason": …} (the project could not be read: push, and the consent comes when the gate is met) or {"status": "skipped", …} (push no test drive; reason says why).', VERCEL_BYPASS, async (p, pi, signal) => {
|
|
3128
3135
|
const before = p.beforePush === true;
|
|
3136
|
+
const reading = readingRefusal('impeccable_vercel_bypass');
|
|
3137
|
+
if (reading)
|
|
3138
|
+
return reading;
|
|
3129
3139
|
if (before) {
|
|
3130
3140
|
const refused = appRefusal(p.appKey, 'asked');
|
|
3131
3141
|
if (refused)
|
|
@@ -3134,6 +3144,9 @@ export function createTools(ctx) {
|
|
|
3134
3144
|
else if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
3135
3145
|
return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first, or call it with beforePush before the test drive\'s push.';
|
|
3136
3146
|
const at = before ? undefined : gated;
|
|
3147
|
+
// The work after the 302 takes a while (the project's read, the secret, its proof): the screen says what it is (#390).
|
|
3148
|
+
if (at)
|
|
3149
|
+
said("Getting past Vercel's login");
|
|
3137
3150
|
if (at?.app)
|
|
3138
3151
|
lockPending.delete(at.app.appKey);
|
|
3139
3152
|
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
@@ -3313,7 +3326,11 @@ export function createTools(ctx) {
|
|
|
3313
3326
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
3314
3327
|
if (!gated || !site)
|
|
3315
3328
|
return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
|
|
3329
|
+
const reading = readingRefusal('impeccable_netlify_password');
|
|
3330
|
+
if (reading)
|
|
3331
|
+
return reading;
|
|
3316
3332
|
const at = gated;
|
|
3333
|
+
said("Getting past Netlify's password");
|
|
3317
3334
|
if (at.app)
|
|
3318
3335
|
lockPending.delete(at.app.appKey);
|
|
3319
3336
|
/** The address pattern the proof with the password answers, saved as the method once the password is. */
|
|
@@ -3382,7 +3399,8 @@ export function createTools(ctx) {
|
|
|
3382
3399
|
paste: async () => {
|
|
3383
3400
|
// One password for several sites is common: the one typed for another site of this run is offered first, once, never after it was refused here (#384).
|
|
3384
3401
|
const other = passwordRefused.has(site) ? undefined : [...sitePasswords].find(([name, h]) => name !== site && h.typed);
|
|
3385
|
-
|
|
3402
|
+
const otherApp = other && [...other[1].apps].find(Boolean);
|
|
3403
|
+
if (other && !(await ask(netlifySamePassword(site, other[0], { app: at.app && appName(at.app.appKey), other: otherApp && appName(otherApp) }))).skipped) {
|
|
3386
3404
|
typed = true;
|
|
3387
3405
|
return other[1].value;
|
|
3388
3406
|
}
|
|
@@ -3422,6 +3440,9 @@ export function createTools(ctx) {
|
|
|
3422
3440
|
provePattern(pattern, at.app?.appKey);
|
|
3423
3441
|
}
|
|
3424
3442
|
if (value) {
|
|
3443
|
+
// Said once it opened the preview and was saved, so the screen does not go straight back to the tasks (#390); the end screen names it and where it changes.
|
|
3444
|
+
said(`Password works: saved for ${at.app ? appName(at.app.appKey) : site}`);
|
|
3445
|
+
ctx.onKeyCreated?.(`Impeccable saved the Netlify site password of ${site} for reviews, so its previews stay password-protected. A new password set in Netlify (${NETLIFY_PASSWORD_WHERE}) goes in the app's Settings too, under "A way past your host's lock", or reviews stop.`);
|
|
3425
3446
|
// What is in hand follows the store: the password saved now is this site's, beside every other site's.
|
|
3426
3447
|
sitePasswords.set(site, { value, saved: passwordOutcome, typed: told.password === 'typed', apps: new Set([at.app?.appKey ?? '']) });
|
|
3427
3448
|
hostedPreviewProven = true;
|
|
@@ -3440,7 +3461,11 @@ export function createTools(ctx) {
|
|
|
3440
3461
|
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, naming the Access application in front of the preview and its Cloudflare account once cf, signed in here, found it (none found: refused, and nothing asked), Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview (an application whose policies are its own gets one screen asking to make them reusable first, and on its skip the paste screen says how to make the token by hand); one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3441
3462
|
if (!accessGated)
|
|
3442
3463
|
return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
|
|
3464
|
+
const reading = readingRefusal('impeccable_cloudflare_access');
|
|
3465
|
+
if (reading)
|
|
3466
|
+
return reading;
|
|
3443
3467
|
const at = accessGated;
|
|
3468
|
+
said('Getting past Cloudflare Access');
|
|
3444
3469
|
// The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
|
|
3445
3470
|
if (at.app)
|
|
3446
3471
|
lockPending.delete(at.app.appKey);
|
|
@@ -3479,7 +3504,9 @@ export function createTools(ctx) {
|
|
|
3479
3504
|
consent: async (found) => {
|
|
3480
3505
|
if (found && accessConsented.has(found.id))
|
|
3481
3506
|
return true;
|
|
3482
|
-
|
|
3507
|
+
// The account by the name the last screen gives it, where a read found it; else its id (#390).
|
|
3508
|
+
const named = found && { ...found, account: accountOf.get(`cloudflare account ${found.account}`) ?? found.account };
|
|
3509
|
+
const yes = !(await ask(sentTo(named ? cloudflareAccessConsent(named, new URL(at.url).host, at.app && appName(at.app.appKey)) : HOST_SCREENS['cloudflare-access'], at.url))).skipped;
|
|
3483
3510
|
if (yes && found)
|
|
3484
3511
|
accessConsented.add(found.id);
|
|
3485
3512
|
return yes;
|
package/dist/ui/look.js
CHANGED
|
@@ -63,13 +63,30 @@ function block(lines, width, size = Math.max(0, ...lines.map((line) => visibleWi
|
|
|
63
63
|
const left = ' '.repeat(Math.max(0, Math.floor((width - Math.min(size, width)) / 2)));
|
|
64
64
|
return lines.map((line) => left + truncateToWidth(line, width - left.length, '…'));
|
|
65
65
|
}
|
|
66
|
+
/** A word wider than `max` columns (a host name, an address) cut after a dot or a slash in each row's last two thirds, so it breaks where a reader expects (#390); a word with none stays whole. */
|
|
67
|
+
function dotRows(word, max) {
|
|
68
|
+
const rows = [];
|
|
69
|
+
let rest = word;
|
|
70
|
+
while (visibleWidth(rest) > max) {
|
|
71
|
+
const head = rest.slice(0, max);
|
|
72
|
+
const cut = Math.max(head.lastIndexOf('.'), head.lastIndexOf('/')) + 1;
|
|
73
|
+
if (cut < max / 3)
|
|
74
|
+
break;
|
|
75
|
+
rows.push(rest.slice(0, cut));
|
|
76
|
+
rest = rest.slice(cut);
|
|
77
|
+
}
|
|
78
|
+
return [...rows, rest];
|
|
79
|
+
}
|
|
66
80
|
/** A paragraph of plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
|
|
67
|
-
function balanced(
|
|
81
|
+
function balanced(given, max) {
|
|
82
|
+
const text = given.split(' ').map((word) => dotRows(word, max).join('\n')).join(' ');
|
|
68
83
|
const sentences = text.split(/(?<=[.!?])\s+/);
|
|
69
84
|
if (sentences.length > 1 && sentences.every((line) => visibleWidth(line) <= max))
|
|
70
85
|
return sentences;
|
|
71
86
|
const lines = wrapTextWithAnsi(text, Math.max(10, max));
|
|
72
|
-
|
|
87
|
+
// Never narrower than its longest word, which would then break mid-word.
|
|
88
|
+
const longest = Math.max(0, ...text.split(/\s+/).map((word) => visibleWidth(word)));
|
|
89
|
+
for (let w = Math.max(longest, Math.ceil(visibleWidth(text) / lines.length)); lines.length > 1 && w < max; w++) {
|
|
73
90
|
const even = wrapTextWithAnsi(text, w);
|
|
74
91
|
if (even.length === lines.length)
|
|
75
92
|
return even;
|
|
@@ -240,24 +257,33 @@ function runPage(scene, f, inner, st) {
|
|
|
240
257
|
if (!workStarted(scene.tasks))
|
|
241
258
|
return { heading: 'Understanding your app', rule: loader(inner, f.motion?.frame ?? 0, st), action: status, ...around };
|
|
242
259
|
const running = scene.tasks.findIndex((t) => t.status === 'in_progress');
|
|
243
|
-
const
|
|
260
|
+
const rows = scene.tasks.map((t, i) => {
|
|
261
|
+
// A task back to pending after it started (Preview method while Test drive runs) keeps a started mark, never ○ (#390).
|
|
244
262
|
const mark = t.status === 'completed' ? '✓'
|
|
245
263
|
: t.status === 'in_progress' ? (i === running && f.motion?.at === 'task' ? glyph : '•')
|
|
246
264
|
: t.status === 'failed' ? '✗'
|
|
247
265
|
: t.status === 'skipped' ? '–'
|
|
248
|
-
: '
|
|
266
|
+
: t.started ? '•'
|
|
267
|
+
: '○';
|
|
249
268
|
const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
|
|
250
269
|
const row = (detail) => `${mark} ${t.title}${detail ? ` · ${detail}` : ''}`;
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
return { ink, row, line: t.status === 'skipped' || t.status === 'failed' ? row(t.status) : row() };
|
|
271
|
+
});
|
|
272
|
+
// The list's left edge is set by the rows without the wait's words, so it stays put while a wait comes and goes (#390).
|
|
273
|
+
const widest = Math.max(...rows.map(({ line }) => visibleWidth(line)));
|
|
274
|
+
const room = inner - Math.max(0, Math.floor((inner - Math.min(widest, inner)) / 2));
|
|
275
|
+
const lines = rows.map(({ ink, row, line }, i) => {
|
|
253
276
|
if (i !== running || !scene.wait)
|
|
254
|
-
return ink(
|
|
277
|
+
return ink(line);
|
|
255
278
|
// The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
|
|
256
279
|
// waiting on a host's build is never still; the time alone where the words would push it off the row.
|
|
257
280
|
const waiting = row(`waiting for ${scene.wait.host ? `${scene.wait.host}'s preview` : 'the preview'} · ${scene.wait.timer}`);
|
|
258
|
-
|
|
281
|
+
if (visibleWidth(waiting) <= room)
|
|
282
|
+
return ink(waiting);
|
|
283
|
+
// Where even the time alone would pass the box from that edge, the title gives way, never the time.
|
|
284
|
+
const tail = ` · ${scene.wait.timer}`;
|
|
285
|
+
return ink(visibleWidth(row(scene.wait.timer)) <= room ? row(scene.wait.timer) : truncateToWidth(row(), Math.max(1, room - visibleWidth(tail)), '…') + tail);
|
|
259
286
|
});
|
|
260
|
-
const widest = Math.max(...lines.map((line) => visibleWidth(line)));
|
|
261
287
|
// No count over the list: the list shows each task's state itself.
|
|
262
288
|
return {
|
|
263
289
|
heading: 'Setting up',
|
package/dist/ui/run-state.js
CHANGED
|
@@ -26,6 +26,8 @@ export class RunState {
|
|
|
26
26
|
current;
|
|
27
27
|
/** A bounded wait under way: the current line carries its timer, counting up. */
|
|
28
28
|
waiting;
|
|
29
|
+
/** The tasks that have been in progress, done or failed in this run. */
|
|
30
|
+
started = new Set();
|
|
29
31
|
/** When something last changed on screen: a status, the task list, an answered question. */
|
|
30
32
|
changedAt;
|
|
31
33
|
constructor(now = Date.now, first) {
|
|
@@ -59,7 +61,11 @@ export class RunState {
|
|
|
59
61
|
return this.waiting && { host: this.waiting.host, timer: timer(live ? this.now() - this.waiting.since : 0) };
|
|
60
62
|
}
|
|
61
63
|
setTasks(tasks) {
|
|
62
|
-
|
|
64
|
+
for (const t of tasks)
|
|
65
|
+
if (t.status === 'in_progress' || t.status === 'completed' || t.status === 'failed')
|
|
66
|
+
this.started.add(t.id);
|
|
67
|
+
// 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 } : {}) }));
|
|
63
69
|
this.touch();
|
|
64
70
|
}
|
|
65
71
|
/** Something happened that the person saw, so the silence starts again. */
|