@impeccable/reviews 0.1.19 → 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 +14 -1
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/preview-protection.md +1 -1
- package/dist/tools.js +91 -25
- 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,6 +228,19 @@ 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. 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}`;
|
|
234
|
+
return {
|
|
235
|
+
id: 'netlify-password-same',
|
|
236
|
+
kind: 'confirm',
|
|
237
|
+
title: 'Your Netlify previews are protected',
|
|
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}?`,
|
|
240
|
+
confirmLabel: 'Use it',
|
|
241
|
+
skipLabel: 'Type another',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
231
244
|
/** The consent to a Vercel bypass secret, naming the project that holds it and, on a repository with several apps, the app whose previews it opens (#358). */
|
|
232
245
|
export function vercelBypassConsent(project, app) {
|
|
233
246
|
const screen = HOST_SCREENS['vercel-bypass'];
|
|
@@ -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
|
|
|
@@ -13,7 +13,7 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
|
|
|
13
13
|
- Any other `refused` on Netlify whose `reason` says to prove or try a page a visitor opens, or that the page asks for the app's own sign-in past the lock: the site password (Netlify's own, or the one typed) got past the lock to the app's own sign-in on the page you proved, or Netlify's form did not take it, or Netlify did not answer. Prove a page a visitor opens without signing in, as netlify-previews' step 1 says, and call the tool again; the consent is not asked a second time. Refused again, step 3.
|
|
14
14
|
- Any other `skipped` or `refused`: nothing was saved; `reason` says what stopped it. Step 3.
|
|
15
15
|
- `save-failed`: the setup API did not take it all; `reason` says how far it got. Call the tool once more; when it fails again, step 3, with "Save the Vercel bypass secret on the setup page", "Type the Netlify site password in the app's Settings, under "A way past your host's lock"" or "Save the Cloudflare service token on the setup page" in `left`.
|
|
16
|
-
2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}` (on a repository with several apps, the app's own key: the tool saved the key for that app), and report what it answered. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
|
|
16
|
+
2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}` (on a repository with several apps, the app's own key: the tool saved the key for that app), and report what it answered. A check that answers `access: "auth-wall"` met the lock all the same: say so under "What is not verified", with its `detail`, and never as verified; the last screen does not call the app set up while it stands. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
|
|
17
17
|
3. **When nothing was saved**, save no method and set up no other preview, whatever the checkup's `next` says, and open no test drive (pull-requests' "No preview, no test drive"). Say under "What is not verified" that the previews are behind the host's lock, with the tool's `reason`, and name the settings page where the person makes the key, or makes the Netlify site public (the report's bullet below); put the person's one step in `left`, the settings action alone and never running the setup again (for Netlify's site password: "Type the Netlify site password in the app's Settings, under "A way past your host's lock"", and, with the draft test drive open, "Then mark draft #<n> ready", never a bypass key, which Netlify has none of; for Netlify's team login, none: the person was asked in this run, so `left` stays empty and the report names the Visitor access page); end the report's "Not part of this setup" line with the lock the tool met: "Vercel's Deployment Protection in front of the previews", "Netlify's password in front of the previews", "Netlify's team login in front of the previews", or "Cloudflare Access in front of the previews". Done when the report says so.
|
|
18
18
|
|
|
19
19
|
### Reference
|
package/dist/tools.js
CHANGED
|
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifySamePassword, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
@@ -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';
|
|
@@ -142,7 +142,8 @@ function stageError(status, tasks) {
|
|
|
142
142
|
}
|
|
143
143
|
/** The line under the last screen's heading, about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. Without a test drive, what stopped it, from what the run saved and met (#321). */
|
|
144
144
|
export function resultLine(report) {
|
|
145
|
-
const lead =
|
|
145
|
+
const lead = report.walled ? `Impeccable's own check of the preview met a wall (${report.walled}), so the test drive's review can't see its pages yet. The report, printed here when this screen closes, says what the run found.`
|
|
146
|
+
: !report.testPr ? `${noTestDrive(report)} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
146
147
|
return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
|
|
147
148
|
}
|
|
148
149
|
/** Why a run opened no test drive, as far as what it saved and met tells. */
|
|
@@ -159,9 +160,12 @@ export function ending(report) {
|
|
|
159
160
|
// On a repository with several apps, each locked app's own line says it.
|
|
160
161
|
const step = !report.apps && !report.methodSaved && report.lock ? [LOCKS[report.lock].left] : [];
|
|
161
162
|
const todo = [...new Set([...step, ...apps.todo, ...report.left])];
|
|
162
|
-
if (report.testPr && !apps.notReady.length)
|
|
163
|
+
if (report.testPr && !apps.notReady.length && !report.walled)
|
|
163
164
|
return { outcome: 'done', title: "You're all set up", todo, code: 0 };
|
|
164
165
|
const outcome = report.methodSaved ? 'partial' : 'stopped';
|
|
166
|
+
// Impeccable's own check said it can't get in: whatever the run saved, reviews will meet the same wall (#383).
|
|
167
|
+
if (report.walled)
|
|
168
|
+
return { outcome, title: "Not set up: Impeccable's check can't get past the preview's wall", todo: [...new Set([`Open the app's Settings: Impeccable's check met ${report.walled}`, ...todo])], code: 1 };
|
|
165
169
|
if (apps.notReady.length)
|
|
166
170
|
return { outcome, title: `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up`, todo, code: 1 };
|
|
167
171
|
const title = report.methodSaved ? 'No test drive opened' : `Not set up: ${report.lock ? `the previews are behind ${LOCKS[report.lock].lock}` : 'no preview method was saved'}`;
|
|
@@ -1692,15 +1696,16 @@ export function createTools(ctx) {
|
|
|
1692
1696
|
const bypassOutcomes = new Map();
|
|
1693
1697
|
/** The stores whose consent was given before the test drive's push, each with the project it named (`<store>/<projectId>`): the call once the gate is met asks no second time for that project alone. */
|
|
1694
1698
|
const bypassConsented = new Set();
|
|
1695
|
-
/**
|
|
1696
|
-
|
|
1697
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* The Netlify site passwords saved in this run, by site, for the proof of that site's addresses to send; never for the
|
|
1701
|
+
* model. Each is one site's, so it goes only to that site's deploy previews, and a monorepo's sites keep theirs side by
|
|
1702
|
+
* side (#384): with what the tool answered, whether the person typed it, and the apps it is saved for.
|
|
1703
|
+
*/
|
|
1704
|
+
const sitePasswords = new Map();
|
|
1698
1705
|
/** What impeccable_netlify_password answered for a site, less the password, where the answer stands for the run: a skip is asked once, the team login kept after the retries stays, and a site made public stays public. Another site of the repository is read on its own. */
|
|
1699
1706
|
const passwordStands = new Map();
|
|
1700
1707
|
/** The sites whose password the person said yes to storing: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
|
|
1701
1708
|
const passwordConsented = new Set();
|
|
1702
|
-
/** The saved answer, what an address the password opens later gets. */
|
|
1703
|
-
let passwordSaved;
|
|
1704
1709
|
/** The sites whose last typed password did not open the preview: the paste screen, asked again, says so first (#375). */
|
|
1705
1710
|
const passwordRefused = new Set();
|
|
1706
1711
|
/** The address impeccable_prove_preview met Cloudflare Access's login at, of a Pages preview impeccable_cloudflare_previews listed, and the words it looked for there. */
|
|
@@ -1808,6 +1813,8 @@ export function createTools(ctx) {
|
|
|
1808
1813
|
const provenApps = new Set();
|
|
1809
1814
|
/** The apps whose preview check (connection-check or latest-pull-check) the setup API answered in this run ('' for the repository): a ready app missing here is named on the last screen (#338). */
|
|
1810
1815
|
const checkedApps = new Set();
|
|
1816
|
+
/** What Impeccable's own last check of each app's preview met where it did not get in ('' for the repository's), until a later check gets in (#383). */
|
|
1817
|
+
const checkWalls = new Map();
|
|
1811
1818
|
/** On a repository with several apps (#286): the method each app's preview write saved; the host's lock a proof of each app's preview met; and the apps whose lock was met with no key tool called for them since, which holds every other app's steps until it is. */
|
|
1812
1819
|
const appMethods = new Map();
|
|
1813
1820
|
const lockMet = new Map();
|
|
@@ -2221,6 +2228,11 @@ export function createTools(ctx) {
|
|
|
2221
2228
|
}
|
|
2222
2229
|
return null;
|
|
2223
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.`;
|
|
2224
2236
|
/** How this run left each app the state lists (#286), for the report and the last screen. */
|
|
2225
2237
|
const appOutcomes = async (pi) => {
|
|
2226
2238
|
const apps = knownApps ?? (await stateApps(pi)) ?? [];
|
|
@@ -2230,6 +2242,9 @@ export function createTools(ctx) {
|
|
|
2230
2242
|
const proven = provenApps.has(key);
|
|
2231
2243
|
if (!pickedApps?.has(key))
|
|
2232
2244
|
return { key, name, folder, state: 'unpicked' };
|
|
2245
|
+
// Impeccable's own last check of its preview met a wall: its reviews will meet it too (#383).
|
|
2246
|
+
if (method !== undefined && checkWalls.has(key))
|
|
2247
|
+
return { key, name, folder, state: 'walled', left: `${name}: Impeccable's check of its preview met ${checkWalls.get(key)}; open its Settings.` };
|
|
2233
2248
|
if (method !== undefined && (method !== 'preview-deploy' || proven)) {
|
|
2234
2249
|
// With a test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
|
|
2235
2250
|
return ctx.sync.payload.test_pr && !checkedApps.has(key)
|
|
@@ -2251,6 +2266,8 @@ export function createTools(ctx) {
|
|
|
2251
2266
|
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2252
2267
|
if (a.state === 'unset')
|
|
2253
2268
|
return `- ${where}: not set up. No preview method was saved for it.`;
|
|
2269
|
+
if (a.state === 'walled')
|
|
2270
|
+
return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but Impeccable's own check of its preview met ${checkWalls.get(a.key)}.`;
|
|
2254
2271
|
return `- ${where}: not set up this run.`;
|
|
2255
2272
|
}).join('\n')}`;
|
|
2256
2273
|
/** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
|
|
@@ -2444,6 +2461,16 @@ export function createTools(ctx) {
|
|
|
2444
2461
|
// A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
|
|
2445
2462
|
if (check && (response.ok || response.status === 409))
|
|
2446
2463
|
checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
|
|
2464
|
+
if (check && response.ok) {
|
|
2465
|
+
// A login wall the check met is the one the review meets: the run is not called set up while it stands (#383).
|
|
2466
|
+
const key = typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '';
|
|
2467
|
+
const result = object(object(response.data)?.result), pulled = object(object(response.data)?.check);
|
|
2468
|
+
const walled = result?.access === 'auth-wall' || pulled?.outcome === 'sign-in';
|
|
2469
|
+
if (walled)
|
|
2470
|
+
checkWalls.set(key, typeof result?.detail === 'string' && plainText(result.detail) === result.detail && result.detail.length <= 120 ? `a ${result.detail}` : 'a login wall');
|
|
2471
|
+
else if (result?.access === 'ok' || pulled?.outcome === 'reached')
|
|
2472
|
+
checkWalls.delete(key);
|
|
2473
|
+
}
|
|
2447
2474
|
if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
|
|
2448
2475
|
confirmedApps = true;
|
|
2449
2476
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
@@ -2673,7 +2700,7 @@ export function createTools(ctx) {
|
|
|
2673
2700
|
}
|
|
2674
2701
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2675
2702
|
const hosts = hostLines(hostsUsed());
|
|
2676
|
-
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2703
|
+
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}) });
|
|
2677
2704
|
pi.shutdown();
|
|
2678
2705
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2679
2706
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2869,6 +2896,8 @@ export function createTools(ctx) {
|
|
|
2869
2896
|
const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
|
|
2870
2897
|
if (accountName && label && !evidence.unmatched)
|
|
2871
2898
|
accountOf.set(`cloudflare ${label}`, accountName);
|
|
2899
|
+
if (accountName && evidence.account)
|
|
2900
|
+
accountOf.set(`cloudflare account ${evidence.account}`, accountName);
|
|
2872
2901
|
if (!subject)
|
|
2873
2902
|
cloudflareEvidence = evidence;
|
|
2874
2903
|
if (!subject && appKey !== undefined && !evidence.unmatched && evidence.project) {
|
|
@@ -2912,7 +2941,7 @@ export function createTools(ctx) {
|
|
|
2912
2941
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
2913
2942
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
2914
2943
|
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
2915
|
-
...(
|
|
2944
|
+
...(site && sitePasswords.has(site) ? { password: sitePasswords.get(site).value } : {}),
|
|
2916
2945
|
...(token ? { access: token } : {}),
|
|
2917
2946
|
});
|
|
2918
2947
|
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
@@ -2923,6 +2952,14 @@ export function createTools(ctx) {
|
|
|
2923
2952
|
}
|
|
2924
2953
|
// On a repository with several apps, the app the address was listed for: its proof, its lock and its key are that app's alone (#281).
|
|
2925
2954
|
const app = listedApps.get(origin);
|
|
2955
|
+
// A site's password opened the preview of an app it was not saved for (another app built from the same site): it is saved as that app's own before the proof stands, since that app's reviews read its own (#384).
|
|
2956
|
+
const held = site ? sitePasswords.get(site) : undefined;
|
|
2957
|
+
if (proof.proven && held && app && !held.apps.has(app.appKey)) {
|
|
2958
|
+
const saved = await saveLock(pi, 'password', { method: 'password', site: site }, held.value, app.appKey);
|
|
2959
|
+
if (saved.failed)
|
|
2960
|
+
return JSON.stringify({ proven: false, status: proof.status, appKey: app.appKey, reason: `The site's password opened the preview, but it is not saved for this app: ${saved.failed} Prove this address again to save it.` });
|
|
2961
|
+
held.apps.add(app.appKey);
|
|
2962
|
+
}
|
|
2926
2963
|
hostedPreviewProven ||= proof.proven;
|
|
2927
2964
|
if (proof.proven)
|
|
2928
2965
|
provenHost = previewHostName(url) ?? provenHost;
|
|
@@ -3096,6 +3133,9 @@ export function createTools(ctx) {
|
|
|
3096
3133
|
}),
|
|
3097
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) => {
|
|
3098
3135
|
const before = p.beforePush === true;
|
|
3136
|
+
const reading = readingRefusal('impeccable_vercel_bypass');
|
|
3137
|
+
if (reading)
|
|
3138
|
+
return reading;
|
|
3099
3139
|
if (before) {
|
|
3100
3140
|
const refused = appRefusal(p.appKey, 'asked');
|
|
3101
3141
|
if (refused)
|
|
@@ -3104,6 +3144,9 @@ export function createTools(ctx) {
|
|
|
3104
3144
|
else if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
3105
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.';
|
|
3106
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");
|
|
3107
3150
|
if (at?.app)
|
|
3108
3151
|
lockPending.delete(at.app.appKey);
|
|
3109
3152
|
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
@@ -3279,11 +3322,15 @@ export function createTools(ctx) {
|
|
|
3279
3322
|
}
|
|
3280
3323
|
return JSON.stringify(outcome);
|
|
3281
3324
|
}),
|
|
3282
|
-
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3325
|
+
tool('impeccable_netlify_password', "Netlify's password", 'Get past the lock that impeccable_prove_preview met (gate "netlify-protection") in front of a Netlify deploy preview, signed in with Netlify\'s site password on Netlify\'s own form, as reviews sign in (no request header carries it). It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one (after one offering the password the person typed for another site of this run, where there is one); opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3283
3326
|
const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
|
|
3284
3327
|
if (!gated || !site)
|
|
3285
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;
|
|
3286
3332
|
const at = gated;
|
|
3333
|
+
said("Getting past Netlify's password");
|
|
3287
3334
|
if (at.app)
|
|
3288
3335
|
lockPending.delete(at.app.appKey);
|
|
3289
3336
|
/** The address pattern the proof with the password answers, saved as the method once the password is. */
|
|
@@ -3307,12 +3354,20 @@ export function createTools(ctx) {
|
|
|
3307
3354
|
}
|
|
3308
3355
|
else if (stands)
|
|
3309
3356
|
return JSON.stringify(stands);
|
|
3310
|
-
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
3311
|
-
|
|
3357
|
+
// A password in hand already: another deploy preview of the same site opens with it, and another app built from the same site gets it saved as its own. Another site's lock is read for that site.
|
|
3358
|
+
const held = sitePasswords.get(site);
|
|
3359
|
+
if (held && (await prove(held.value)).proven) {
|
|
3360
|
+
const store = at.app?.appKey ?? '';
|
|
3361
|
+
if (!held.apps.has(store)) {
|
|
3362
|
+
const saved = await saveLock(pi, 'password', { method: 'password', site }, held.value, at.app?.appKey);
|
|
3363
|
+
if (saved.failed)
|
|
3364
|
+
return JSON.stringify({ status: 'save-failed', site, protection: 'password', reason: saved.failed });
|
|
3365
|
+
held.apps.add(store);
|
|
3366
|
+
}
|
|
3312
3367
|
provePattern(pattern, at.app?.appKey);
|
|
3313
3368
|
if (at.app)
|
|
3314
3369
|
proveApp(at.app.appKey, at.url);
|
|
3315
|
-
return JSON.stringify(
|
|
3370
|
+
return JSON.stringify(held.saved);
|
|
3316
3371
|
}
|
|
3317
3372
|
/** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
|
|
3318
3373
|
let stored = false;
|
|
@@ -3342,6 +3397,13 @@ export function createTools(ctx) {
|
|
|
3342
3397
|
return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
|
|
3343
3398
|
},
|
|
3344
3399
|
paste: async () => {
|
|
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).
|
|
3401
|
+
const other = passwordRefused.has(site) ? undefined : [...sitePasswords].find(([name, h]) => name !== site && h.typed);
|
|
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) {
|
|
3404
|
+
typed = true;
|
|
3405
|
+
return other[1].value;
|
|
3406
|
+
}
|
|
3345
3407
|
const screen = sentTo(HOST_SCREENS['netlify-password-key'], at.url);
|
|
3346
3408
|
const answer = await ask(passwordRefused.has(site) ? { ...screen, prompt: `The password typed last didn't open the preview on Netlify's form, so nothing was saved. Check it and type it again. ${screen.prompt}` } : screen);
|
|
3347
3409
|
typed = !answer.skipped;
|
|
@@ -3378,30 +3440,32 @@ export function createTools(ctx) {
|
|
|
3378
3440
|
provePattern(pattern, at.app?.appKey);
|
|
3379
3441
|
}
|
|
3380
3442
|
if (value) {
|
|
3381
|
-
//
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
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.`);
|
|
3446
|
+
// What is in hand follows the store: the password saved now is this site's, beside every other site's.
|
|
3447
|
+
sitePasswords.set(site, { value, saved: passwordOutcome, typed: told.password === 'typed', apps: new Set([at.app?.appKey ?? '']) });
|
|
3385
3448
|
hostedPreviewProven = true;
|
|
3386
3449
|
provenHost = 'Netlify';
|
|
3387
3450
|
usedPreviews.add(at.url);
|
|
3388
3451
|
if (at.app)
|
|
3389
3452
|
proveApp(at.app.appKey, at.url);
|
|
3390
3453
|
provePattern(pattern, at.app?.appKey);
|
|
3391
|
-
passwordSaved = passwordOutcome;
|
|
3392
3454
|
}
|
|
3393
3455
|
else if (told.status === 'save-failed' && stored) {
|
|
3394
|
-
// The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through.
|
|
3395
|
-
|
|
3396
|
-
passwordSites.clear();
|
|
3397
|
-
passwordSaved = undefined;
|
|
3456
|
+
// The store holds the password this attempt wrote without the setting that sends it: nothing in hand stands for this site until a save goes through.
|
|
3457
|
+
sitePasswords.delete(site);
|
|
3398
3458
|
}
|
|
3399
3459
|
return JSON.stringify(passwordOutcome);
|
|
3400
3460
|
}),
|
|
3401
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) => {
|
|
3402
3462
|
if (!accessGated)
|
|
3403
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;
|
|
3404
3467
|
const at = accessGated;
|
|
3468
|
+
said('Getting past Cloudflare Access');
|
|
3405
3469
|
// The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
|
|
3406
3470
|
if (at.app)
|
|
3407
3471
|
lockPending.delete(at.app.appKey);
|
|
@@ -3440,7 +3504,9 @@ export function createTools(ctx) {
|
|
|
3440
3504
|
consent: async (found) => {
|
|
3441
3505
|
if (found && accessConsented.has(found.id))
|
|
3442
3506
|
return true;
|
|
3443
|
-
|
|
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;
|
|
3444
3510
|
if (yes && found)
|
|
3445
3511
|
accessConsented.add(found.id);
|
|
3446
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. */
|