@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 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') {
@@ -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 = !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;
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
- /** The Netlify site password saved in this run, for the proof of that site's addresses to send; never for the model. It is one site's, so it goes only to that site's deploy previews. */
1696
- let sitePassword;
1697
- const passwordSites = new Set();
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
- ...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
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
- if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
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(passwordSaved);
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
- // What is in hand follows the store: a new password is the one saved now, and the site the last one opened no longer stands.
3382
- sitePassword = value;
3383
- passwordSites.clear();
3384
- passwordSites.add(site);
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
- sitePassword = undefined;
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
- const yes = !(await ask(sentTo(found ? cloudflareAccessConsent(found, new URL(at.url).host, at.app && appName(at.app.appKey)) : HOST_SCREENS['cloudflare-access'], at.url))).skipped;
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(text, max) {
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
- for (let w = Math.ceil(visibleWidth(text) / lines.length); lines.length > 1 && w < max; w++) {
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 lines = scene.tasks.map((t, i) => {
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
- if (t.status === 'skipped' || t.status === 'failed')
252
- return ink(row(t.status));
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(row());
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
- return ink(visibleWidth(waiting) <= inner ? waiting : row(scene.wait.timer));
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',
@@ -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
- this.tasks = tasks.map((t) => ({ ...t }));
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,