@impeccable/reviews 0.1.21 → 0.1.22

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/tools.js CHANGED
@@ -7,22 +7,22 @@ import { request } from 'node:http';
7
7
  import { createServer } from 'node:net';
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
9
  import { defineTool } from '@earendil-works/pi-coding-agent';
10
- import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
10
+ import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, 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, netlifySamePassword, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
13
+ import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, 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';
17
17
  import { passCloudflareAccess } from './cloudflare-access.js';
18
18
  import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
19
- import { passNetlifyLock } from './netlify-password.js';
19
+ import { netlifyToken, passNetlifyLock, setSitePassword } 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
22
  import { fitsTask, stagesOf, workStarted } from './stages.js';
23
23
  import { redact } from './log.js';
24
24
  import { scan } from './scan.js';
25
- import { gitEnvironment } from './sandbox.js';
25
+ import { gitEnvironment, installEnvironment, ownFolder } from './sandbox.js';
26
26
  import { openBrowser } from './login.js';
27
27
  import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
28
28
  import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
@@ -245,7 +245,11 @@ const PULL_REQUEST = {
245
245
  };
246
246
  const TEST_PR = {
247
247
  ...PULL_REQUEST,
248
- properties: { ...PULL_REQUEST.properties, setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' } },
248
+ properties: {
249
+ ...PULL_REQUEST.properties,
250
+ appKey: { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app whose own test drive this is.' },
251
+ setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' },
252
+ },
249
253
  };
250
254
  /** An app's key as the setup state gives it. */
251
255
  const APP_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
@@ -1547,8 +1551,10 @@ export function createTools(ctx) {
1547
1551
  const text = (t, terminate = false) => ({ content: [{ type: 'text', text: t }], details: undefined, ...(terminate ? { terminate } : {}) });
1548
1552
  const retry = previewRetryState(ctx.log);
1549
1553
  retry.repository ??= `${ctx.owner}/${ctx.repo}`;
1550
- /** The branches of the recorded test drive and setup pull request, as GitHub named them. */
1554
+ /** The branches of the recorded test drives (by app key on a repository with several apps, '' on one) and setup pull request, as GitHub named them. */
1551
1555
  let recordedBranches;
1556
+ /** On a repository with several apps, each app's recorded test drive, in the order they were recorded (impeccable-reviews#397). */
1557
+ const appDrives = new Map();
1552
1558
  retry.recordedPr ??= ctx.sync.payload.setup_pr;
1553
1559
  let memory = previewMemories.get(ctx.log);
1554
1560
  if (!memory) {
@@ -1699,15 +1705,14 @@ export function createTools(ctx) {
1699
1705
  /**
1700
1706
  * The Netlify site passwords saved in this run, by site, for the proof of that site's addresses to send; never for the
1701
1707
  * 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.
1708
+ * side (#384): with what the tool answered, and the apps it is saved for.
1703
1709
  */
1704
1710
  const sitePasswords = new Map();
1705
1711
  /** 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. */
1706
1712
  const passwordStands = new Map();
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. */
1713
+ /** The sites whose password the person said yes to storing, and those whose password they said yes to replacing, each its own: a later call for the same site, after a page the app's own sign-in answered, asks no second time. */
1708
1714
  const passwordConsented = new Set();
1709
- /** The sites whose last typed password did not open the preview: the paste screen, asked again, says so first (#375). */
1710
- const passwordRefused = new Set();
1715
+ const passwordReplaceConsented = new Set();
1711
1716
  /** 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. */
1712
1717
  let accessGated;
1713
1718
  /** The service token that opened a preview in this run, what the tool said of it, the Pages projects it opened and whether the store holds it: once it does, the proofs of those projects' previews send it; never the model. */
@@ -1799,7 +1804,7 @@ export function createTools(ctx) {
1799
1804
  teamChoices.delete(host);
1800
1805
  endHostSetup(ctx.log, host);
1801
1806
  };
1802
- const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1807
+ const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel', cloudflare: 'Cloudflare' };
1803
1808
  /** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
1804
1809
  let vercelProject;
1805
1810
  /** The repository a Vercel project the link file names must be connected to before the run changes it or sends its secret anywhere. */
@@ -1876,6 +1881,93 @@ export function createTools(ctx) {
1876
1881
  team: async () => ({ choices: teams.length > 1 ? await netlifyTeams(cli) : [] }),
1877
1882
  };
1878
1883
  };
1884
+ /** Each host's CLI by its npm package, as its install screens name it (#396). */
1885
+ const CLI_PACKAGES = { vercel: 'vercel', netlify: 'netlify-cli', cloudflare: 'cf' };
1886
+ /** What the person chose on a host's CLI install screen, by host: the run's install, or their own. */
1887
+ const cliInstalls = new Map();
1888
+ /**
1889
+ * A host's CLI that is not on this machine (#396): one screen naming the package, the pinned version and the global
1890
+ * install with npm, before anything installs it. On Install, `install` runs it and answers whether it completed (cf,
1891
+ * which only the tools run), or, where the install is the agent's (Vercel's and Netlify's, through the fence's pinned
1892
+ * rule), this opens that install and answers `agent`. On the person's own install, the command to run in another
1893
+ * terminal, the CLI looked for on their yes, and the steps again, saying so, while it is still missing; an install that
1894
+ * did not complete, the run's or the agent's, shows those steps too. Answers `installed` once `present` finds the CLI;
1895
+ * a stopped run throws.
1896
+ */
1897
+ async function installHostCli(host, present, install, signal) {
1898
+ const pkg = CLI_PACKAGES[host];
1899
+ const cli = { name: HOST_NAMES[host], pkg, version: HOST_CLI_VERSIONS[pkg], command: hostCliInstallCommand(pkg) };
1900
+ const FAILED = "The install didn't complete.";
1901
+ const MISSING = `The ${cli.name} CLI still isn't on this machine.`;
1902
+ const runInstall = async () => {
1903
+ cliInstalls.set(host, 'run');
1904
+ if (install === 'agent') {
1905
+ agreeToCliInstall(ctx.log, host);
1906
+ return 'agent';
1907
+ }
1908
+ said(`Installing ${pkg} ${cli.version}`);
1909
+ const done = (await install()) && (await present());
1910
+ signal?.throwIfAborted();
1911
+ return done ? 'installed' : undefined;
1912
+ };
1913
+ // No answer at all (the input ended, or the run stopped) is neither way on: nothing is installed, and nothing more asked.
1914
+ const answered = (answer) => {
1915
+ signal?.throwIfAborted();
1916
+ if (answer.closed)
1917
+ throw new Error(`No answer came on the ${cli.name} CLI's screen, so nothing was installed.`);
1918
+ return answer;
1919
+ };
1920
+ let note;
1921
+ const chose = cliInstalls.get(host);
1922
+ if (!chose) {
1923
+ const answer = answered(await ask(cliInstallScreen(host, cli)));
1924
+ if (answer.skipped)
1925
+ cliInstalls.set(host, 'person');
1926
+ else {
1927
+ const done = await runInstall();
1928
+ if (done)
1929
+ return done;
1930
+ note = FAILED;
1931
+ }
1932
+ }
1933
+ else
1934
+ note = chose === 'run' ? FAILED : MISSING;
1935
+ const wait = ctx.hostWait?.cli ?? { totalMs: 30_000, everyMs: 2_000 };
1936
+ for (;;) {
1937
+ const answer = answered(await ask(cliManualScreen(host, cli, note)));
1938
+ if (answer.skipped) {
1939
+ const done = await runInstall();
1940
+ if (done)
1941
+ return done;
1942
+ note = FAILED;
1943
+ continue;
1944
+ }
1945
+ // Looked for at once, then every so often for a while: an install in the other terminal may still be finishing.
1946
+ said(`Looking for the ${cli.name} CLI`);
1947
+ for (const end = Date.now() + wait.totalMs;; await sleep(wait.everyMs, undefined, { signal }).catch(() => undefined)) {
1948
+ signal?.throwIfAborted();
1949
+ if (await present())
1950
+ return 'installed';
1951
+ if (Date.now() >= end)
1952
+ break;
1953
+ }
1954
+ cliInstalls.set(host, 'person');
1955
+ note = MISSING;
1956
+ }
1957
+ }
1958
+ /** The run's own install of a host CLI's package, for installHostCli: npm's pinned global install, from the command's own folder and npm cache, as the agent's install of one runs; whether it completed. */
1959
+ const npmInstall = (pkg, signal) => async () => {
1960
+ try {
1961
+ await (ctx.hostRunner ?? commandRunner)('npm', ownFolder(), { signal, timeoutMs: 600_000, env: installEnvironment() })(hostCliInstallArgs(pkg));
1962
+ return true;
1963
+ }
1964
+ catch {
1965
+ return false;
1966
+ }
1967
+ };
1968
+ /** cf, Cloudflare's CLI, which only the tools run: where it is missing here, its install is asked first (#396). */
1969
+ const cfPresent = async (pi, signal) => (await cfStatus(cfRunner({ checkout: pi.cwd }, ctx.hostRunner), signal)).cli !== 'missing';
1970
+ const installCf = (pi, signal) => installHostCli('cloudflare', () => cfPresent(pi, signal), npmInstall('cf', signal), signal);
1879
1971
  /**
1880
1972
  * A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
1881
1973
  * already (the yes opens the setup with that sign-in, and no login runs) or for one signed out, and on its yes the
@@ -1906,10 +1998,14 @@ export function createTools(ctx) {
1906
1998
  }
1907
1999
  if (status.cli === 'signed-in')
1908
2000
  return signedIn();
1909
- // The CLI is installed through the fence's package-manager rule, which this yes opens; then this is asked again.
2001
+ // The CLI's own screen first (#396). On Install the agent installs it through the fence's pinned rule, which that yes opens, and this is asked again; the person's own install, once found, goes on to the sign-in.
1910
2002
  if (status.cli === 'missing') {
1911
2003
  agreeToHostSetup(ctx.log, `${host}-setup`);
1912
- return { confirmed: true, cli: 'missing' };
2004
+ if ((await installHostCli(host, async () => (await cli.status()).cli !== 'missing', 'agent', signal)) === 'agent')
2005
+ return { confirmed: true, cli: 'missing' };
2006
+ status = await cli.status();
2007
+ if (status.cli === 'signed-in')
2008
+ return signedIn();
1913
2009
  }
1914
2010
  for (let attempt = 0;; attempt++) {
1915
2011
  said(`Opening the browser for ${HOST_NAMES[host]}'s login`);
@@ -2131,8 +2227,12 @@ export function createTools(ctx) {
2131
2227
  /** What the run did for each picked app, as its own tool calls found it, sent with the session whenever it changes (#2307). */
2132
2228
  const appSummaries = new Map();
2133
2229
  const sendApps = () => ctx.sync.update({ apps: [...appSummaries.values()].slice(0, 50) });
2230
+ /** Whether this run gives each app a test drive of its own: it sets up more than one app of a repository with several (impeccable-reviews#397). */
2231
+ const perAppDrives = () => appsRepo && (pickedApps?.size ?? 0) > 1;
2134
2232
  const pick = (keys) => {
2135
2233
  pickedApps = keys;
2234
+ // Each app this run sets up gets a test drive of its own (impeccable-reviews#397).
2235
+ retry.drivesAllowed = Math.max(1, keys.size);
2136
2236
  const before = new Map(appSummaries);
2137
2237
  appSummaries.clear();
2138
2238
  for (const key of keys)
@@ -2234,7 +2334,7 @@ export function createTools(ctx) {
2234
2334
  */
2235
2335
  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.`;
2236
2336
  /** How this run left each app the state lists (#286), for the report and the last screen. */
2237
- const appOutcomes = async (pi) => {
2337
+ const appOutcomes = async (pi, stale = new Set()) => {
2238
2338
  const apps = knownApps ?? (await stateApps(pi)) ?? [];
2239
2339
  return apps.map(({ key, name, folder }) => {
2240
2340
  const method = appMethods.get(key);
@@ -2245,9 +2345,12 @@ export function createTools(ctx) {
2245
2345
  // Impeccable's own last check of its preview met a wall: its reviews will meet it too (#383).
2246
2346
  if (method !== undefined && checkWalls.has(key))
2247
2347
  return { key, name, folder, state: 'walled', left: `${name}: Impeccable's check of its preview met ${checkWalls.get(key)}; open its Settings.` };
2348
+ // Its test drive ran on the setup branch before its last change, so it shows nothing of what is merged (#397).
2349
+ if (method !== undefined && stale.has(key))
2350
+ return { key, name, folder, state: 'stale', left: `${name}: its test drive predates the setup pull request's last change; run impeccable-reviews again.` };
2248
2351
  if (method !== undefined && (method !== 'preview-deploy' || proven)) {
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).
2250
- return ctx.sync.payload.test_pr && !checkedApps.has(key)
2352
+ // With its test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
2353
+ return appDrives.has(key) && !checkedApps.has(key)
2251
2354
  ? { key, name, folder, state: 'ready', unchecked: true, left: `${name}: Impeccable's check of its preview did not run here; the test drive's review shows whether it reaches it.` }
2252
2355
  : { key, name, folder, state: 'ready' };
2253
2356
  }
@@ -2266,6 +2369,8 @@ export function createTools(ctx) {
2266
2369
  return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
2267
2370
  if (a.state === 'unset')
2268
2371
  return `- ${where}: not set up. No preview method was saved for it.`;
2372
+ if (a.state === 'stale')
2373
+ return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but its test drive was branched before the setup branch's last change, so it was left out.`;
2269
2374
  if (a.state === 'walled')
2270
2375
  return `- ${where}: not set up. Method: ${appMethods.get(a.key)}, but Impeccable's own check of its preview met ${checkWalls.get(a.key)}.`;
2271
2376
  return `- ${where}: not set up this run.`;
@@ -2590,28 +2695,49 @@ export function createTools(ctx) {
2590
2695
  reportTasks(tasks);
2591
2696
  return 'Saved.';
2592
2697
  }),
2593
- tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
2698
+ tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open; on a repository with several apps, each app\'s own test drive with its appKey, as it opens. Each must be an open pull request from this repository\'s own impeccable/ branch, the test drive\'s also changing the existing page that links to it; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, pi, signal) => {
2594
2699
  const drive = { number: p.number, url: p.url.trim() };
2595
2700
  const given = p.setup_pr;
2596
2701
  const setup = given && { number: given.number, url: given.url.trim() };
2597
2702
  if (setup?.number === drive.number)
2598
2703
  return `Refused: #${drive.number} cannot be both the test drive and the setup pull request.`;
2704
+ // A run that sets up several apps of a repository gives each a test drive of its own (impeccable-reviews#397); one that sets up one app, its one test drive.
2705
+ const several = perAppDrives();
2706
+ const sole = appsRepo && pickedApps?.size === 1 ? [...pickedApps][0] : undefined;
2707
+ const appKey = several || p.appKey !== undefined ? p.appKey : sole;
2708
+ if (appsRepo) {
2709
+ // The apps question comes first; then the app named, which only a run that sets up one app may leave out.
2710
+ const refused = appRefusal(appKey, 'recorded');
2711
+ if (refused)
2712
+ return refused;
2713
+ const other = [...appDrives].find(([key, pr]) => key !== appKey && pr.number === drive.number);
2714
+ if (other)
2715
+ return `Refused: #${drive.number} is ${appName(other[0])}'s test drive. Each app gets a test drive of its own, as the monorepo case says. Nothing was recorded.`;
2716
+ }
2717
+ const key = typeof appKey === 'string' ? appKey : '';
2599
2718
  const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
2600
2719
  if ('problem' in found)
2601
2720
  return `Refused: ${found.problem}.`;
2602
2721
  drive.url = found.url;
2722
+ if (several && [...(recordedBranches?.drives ?? [])].some(([other, branch]) => other !== key && branch === found.branch)) {
2723
+ return `Refused: ${found.branch} is another app's test drive. Each app's test drive comes from a branch of its own (impeccable/test-drive-<appKey>), as the monorepo case says. Nothing was recorded.`;
2724
+ }
2603
2725
  // The page that links to the test drive is an existing file the branch changes; files under dot folders (.github, .netlify) and dotfiles never link to it (#241).
2604
- const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
2726
+ const changes = recordedBranches?.drives.get(key) !== found.branch && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
2605
2727
  const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
2606
2728
  if (files.length && files.every(({ status }) => status === 'A')) {
2607
2729
  return `Refused: ${found.branch} only adds ${files.map(({ path }) => path).join(', ')} and changes no existing page, so nothing links to the test drive. Commit the page that links to it and push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
2608
2730
  }
2609
- // On a repository with several apps, the test drive's page goes into a picked app's own folder, and no other app is touched (#286): a file it adds anywhere else is refused, whatever its name. The setup branch's own files (a build fix at the root) are not in `files`, as pushedChanges leaves them out.
2731
+ // On a repository with several apps, an app's test drive adds its page to that app's own folder, and no other app is touched (#286, #397): a file it adds anywhere else, or changes in another app's (a root app's included), is refused, whatever its name. A file belongs to the app whose folder holds it most closely; one no app's folder holds (a shared root file) is no app's. The setup branch's own files (a build fix at the root) are not in `files`, as pushedChanges leaves them out.
2610
2732
  if (appsRepo && pickedApps) {
2611
- const folders = (await Promise.all([...pickedApps].map((key) => appFolder(pi, key)))).filter((folder) => folder !== undefined);
2612
- const outside = files.filter(({ status, path }) => status === 'A' && !folders.some((folder) => !folder || path.startsWith(`${folder}/`))).map(({ path }) => path);
2733
+ const apps = knownApps ?? (await stateApps(pi)) ?? [];
2734
+ const ownerOf = (path) => apps.filter((a) => !a.folder || path.startsWith(`${a.folder}/`)).sort((a, b) => b.folder.length - a.folder.length)[0];
2735
+ const outside = files.filter(({ status, path }) => {
2736
+ const owner = ownerOf(path);
2737
+ return status === 'A' ? owner?.key !== key : owner !== undefined && owner.key !== key;
2738
+ }).map(({ path }) => path);
2613
2739
  if (outside.length)
2614
- return `Refused: ${found.branch} adds ${outside.join(', ')}, outside the folders of the apps this run sets up (${folders.map((folder) => folder || '/').join(', ')}). The test drive's page goes into each picked app's own folder, as the monorepo case says, and no other app is touched: move it, push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
2740
+ return `Refused: ${found.branch} changes ${outside.join(', ')}, outside the folder of ${appName(key)} (${(await appFolder(pi, key)) || '/'}). Each app's test drive adds its page to its own app and touches no other, as the monorepo case says: move it, push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
2615
2741
  }
2616
2742
  let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
2617
2743
  if (setup) {
@@ -2626,10 +2752,14 @@ export function createTools(ctx) {
2626
2752
  setupBranch = head.branch;
2627
2753
  setupBase = head.base;
2628
2754
  }
2629
- ctx.sync.update({ test_pr: drive, ...(setup ? { setup_pr: setup } : {}) });
2630
- recordedBranches = { drive: found.branch, setup: setupBranch, setupBase };
2755
+ if (appsRepo)
2756
+ appDrives.set(key, drive);
2757
+ // The first app's test drive stays the one the web page's status follows; each app's goes beside it, by its key.
2758
+ const first = several ? appDrives.values().next().value : drive;
2759
+ ctx.sync.update({ test_pr: first, ...(several ? { test_prs: Object.fromEntries(appDrives) } : {}), ...(setup ? { setup_pr: setup } : {}) });
2760
+ recordedBranches = { drives: new Map(recordedBranches?.drives).set(key, found.branch), setup: setupBranch, setupBase };
2631
2761
  const res = await ctx.sync.flush();
2632
- const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
2762
+ const what = `${several ? `${appName(key)}'s test drive` : 'the test drive'} #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
2633
2763
  return res?.ok
2634
2764
  ? `Recorded ${what}. The web page follows the test drive's review from now on.`
2635
2765
  : `Recorded ${what} here, but the web page did not take it yet (${res?.error ?? 'no answer'}). The report links them too.`;
@@ -2658,8 +2788,22 @@ export function createTools(ctx) {
2658
2788
  const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
2659
2789
  markdown += `\n\n## What the setup pull request changes\n\n${changes ? changes.map(({ status, path }) => `- ${shown(path)} (${verbs[status] ?? status})`).join('\n') || 'Nothing: it carries no file.' : 'This checkout could not list its files: read them on the pull request before merging.'}`;
2660
2790
  }
2661
- // On a repository with several apps, how this run left each app, from what it saved and proved (#286).
2662
- const apps = appsRepo && pickedApps ? await appOutcomes(pi) : undefined;
2791
+ // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one. On a repository with several apps, each app's is judged on its own.
2792
+ const covers = async (key) => {
2793
+ const branch = recordedBranches?.drives.get(key);
2794
+ // An app whose host builds its previews needs nothing of the setup branch: its test drive (from the default branch, in its host's setup case) stands (#397).
2795
+ if (appsRepo && appMethods.get(key) === 'preview-deploy')
2796
+ return true;
2797
+ return !recordedBranches?.setup || !branch || (await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, branch)) !== false;
2798
+ };
2799
+ const kept = [];
2800
+ for (const [key, pr] of appDrives)
2801
+ if (await covers(key))
2802
+ kept.push([key, pr]);
2803
+ const several = perAppDrives();
2804
+ const drive = several ? kept[0]?.[1] : ctx.sync.payload.test_pr && (await covers(appsRepo ? [...appDrives.keys()][0] ?? '' : '')) ? ctx.sync.payload.test_pr : undefined;
2805
+ // On a repository with several apps, how this run left each app, from what it saved and proved (#286); one whose test drive was left out is not set up (#397).
2806
+ const apps = appsRepo && pickedApps ? await appOutcomes(pi, new Set([...appDrives.keys()].filter((key) => !kept.some(([k]) => k === key)))) : undefined;
2663
2807
  if (apps)
2664
2808
  markdown += appsSection(apps);
2665
2809
  if (previews.active && retry.pr) {
@@ -2671,11 +2815,7 @@ export function createTools(ctx) {
2671
2815
  if (!left.includes('Watch the review on the test drive'))
2672
2816
  left.push('Watch the review on the test drive');
2673
2817
  }
2674
- const { phase, status, question, report_markdown, test_pr, left: leftBefore } = ctx.sync.payload;
2675
- // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one.
2676
- const covered = test_pr && recordedBranches?.setup
2677
- ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
2678
- const drive = covered === false ? undefined : test_pr;
2818
+ const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
2679
2819
  // Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
2680
2820
  const appsBefore = new Map(appSummaries);
2681
2821
  for (const { appKey, reason } of p.apps ?? []) {
@@ -2685,11 +2825,11 @@ export function createTools(ctx) {
2685
2825
  noteApp(appKey, { reason: plain });
2686
2826
  }
2687
2827
  // The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
2688
- ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, left: left.slice(0, 20) });
2828
+ ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
2689
2829
  const res = await ctx.sync.flush();
2690
2830
  if (res && res.status >= 400 && res.status < 500) {
2691
2831
  // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
2692
- ctx.sync.update({ phase, status, question, report_markdown, test_pr, left: leftBefore });
2832
+ ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore });
2693
2833
  // Its reasons go with it: the report sent again gives its own.
2694
2834
  if (appsBefore.size) {
2695
2835
  for (const [key, was] of appsBefore)
@@ -2700,7 +2840,7 @@ export function createTools(ctx) {
2700
2840
  }
2701
2841
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2702
2842
  const hosts = hostLines(hostsUsed());
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 } : {}) });
2843
+ ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), 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 } : {}) });
2704
2844
  pi.shutdown();
2705
2845
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2706
2846
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
@@ -2859,7 +2999,7 @@ export function createTools(ctx) {
2859
2999
  noteApp(appKey, { host_project: site });
2860
3000
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
2861
3001
  }),
2862
- tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
3002
+ tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds), which gives pull requests no preview; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2863
3003
  const pull = p.pull;
2864
3004
  const branch = p.branch;
2865
3005
  if (pull !== undefined && branch !== undefined)
@@ -2890,7 +3030,12 @@ export function createTools(ctx) {
2890
3030
  // unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
2891
3031
  const app = cloudflareEvidence?.githubApp;
2892
3032
  const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false } : undefined;
2893
- const waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
3033
+ let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
3034
+ // cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
3035
+ if (!subject && waited.cli === 'missing') {
3036
+ await installCf(pi, signal);
3037
+ waited = await read();
3038
+ }
2894
3039
  const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2895
3040
  // By the label the project's addresses carry.
2896
3041
  const label = (evidence.production && pagesProject(evidence.production)) || evidence.project;
@@ -3036,6 +3181,7 @@ export function createTools(ctx) {
3036
3181
  if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
3037
3182
  return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
3038
3183
  said(`Turning on pull-request previews on ${name}`);
3184
+ // cf is installed here: the fact this acts on is one a read through cf found.
3039
3185
  const turnOn = async () => {
3040
3186
  if (host === 'netlify')
3041
3187
  return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
@@ -3088,6 +3234,11 @@ export function createTools(ctx) {
3088
3234
  const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner);
3089
3235
  const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
3090
3236
  let status = await cfStatus(cf, signal);
3237
+ // cf not on this machine (#396): its install is asked first.
3238
+ if (status.cli === 'missing') {
3239
+ await installCf(pi, signal);
3240
+ status = await cfStatus(cf, signal);
3241
+ }
3091
3242
  if (!writes(status)) {
3092
3243
  if (status.cli === 'missing' || status.cli === 'unknown')
3093
3244
  return answer('refused', { reason: "Cloudflare's CLI did not run here." });
@@ -3322,7 +3473,7 @@ export function createTools(ctx) {
3322
3473
  }
3323
3474
  return JSON.stringify(outcome);
3324
3475
  }),
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) => {
3476
+ 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 asking to set a new one, which replaces the site\'s current password, and on that yes sets a long random one on the site as the person with the Netlify CLI\'s sign-in (it never comes back, and nothing is pasted); 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 the site\'s existing one or a new one the run set, and site which Netlify site it is; replaced, on any answer, says the run set a new site password, which replaced the one before, saved or not), 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) => {
3326
3477
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
3327
3478
  if (!gated || !site)
3328
3479
  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.';
@@ -3371,63 +3522,85 @@ export function createTools(ctx) {
3371
3522
  }
3372
3523
  /** The store took this attempt's password: what was in hand no longer matches it, unless the setting followed. */
3373
3524
  let stored = false;
3374
- /** The person typed a password on the paste screen in this call. */
3375
- let typed = false;
3376
- const { value, ...told } = await passNetlifyLock({
3377
- run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
3378
- site,
3379
- consent: async () => {
3380
- if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
3381
- return passwordConsented.add(site), true;
3382
- return false;
3383
- },
3384
- makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
3385
- ...(at.teamLogin ? { teamLogin: true } : {}),
3386
- // The yes to making the site public stands for the sign-in it needs: no second question before Netlify's login opens in the browser.
3387
- signIn: async () => {
3388
- if (!hostSignIns.has('netlify'))
3389
- hostSignIns.set('netlify', 'confirmed');
3390
- return (await hostSignIn('netlify', pi, signal)).signedIn === true;
3391
- },
3392
- retry: async (page, first) => {
3393
- if (first)
3394
- (ctx.openBrowser ?? openBrowser)(page);
3395
- // The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
3396
- const screen = HOST_SCREENS['netlify-visibility-retry'];
3397
- return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
3398
- },
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
- }
3407
- const screen = sentTo(HOST_SCREENS['netlify-password-key'], at.url);
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);
3409
- typed = !answer.skipped;
3410
- return answer.skipped ? undefined : answer.value?.trim();
3411
- },
3412
- prove,
3413
- save: async (password) => {
3414
- // Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
3415
- const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
3416
- stored = saved.stored;
3417
- return saved.failed;
3418
- },
3419
- remember: noteSensitiveAnswer,
3420
- });
3525
+ /** Netlify took a new password the run set in this call: the end screen says so, saved or not, and on a cancel too. */
3526
+ let replaced = false;
3527
+ let answer;
3528
+ const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
3529
+ // The Netlify CLI not on this machine (#396): its install is asked first, the run's own since no agent's turn comes in between, and the command runs again.
3530
+ const run = async (args) => {
3531
+ try {
3532
+ return await netlify(args);
3533
+ }
3534
+ catch (err) {
3535
+ if (!err.missing)
3536
+ throw err;
3537
+ await installHostCli('netlify', async () => (await netlifyStatus(netlify)).cli !== 'missing', npmInstall('netlify-cli', signal), signal);
3538
+ return netlify(args);
3539
+ }
3540
+ };
3541
+ try {
3542
+ answer = await passNetlifyLock({
3543
+ run,
3544
+ site,
3545
+ consent: async () => {
3546
+ if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
3547
+ return passwordConsented.add(site), true;
3548
+ return false;
3549
+ },
3550
+ makePublic: async () => !(await ask(HOST_SCREENS['netlify-visibility'])).skipped,
3551
+ ...(at.teamLogin ? { teamLogin: true } : {}),
3552
+ // The yes to making the site public stands for the sign-in it needs: no second question before Netlify's login opens in the browser.
3553
+ signIn: async () => {
3554
+ if (!hostSignIns.has('netlify'))
3555
+ hostSignIns.set('netlify', 'confirmed');
3556
+ return (await hostSignIn('netlify', pi, signal)).signedIn === true;
3557
+ },
3558
+ retry: async (page, first) => {
3559
+ if (first)
3560
+ (ctx.openBrowser ?? openBrowser)(page);
3561
+ // The browser may not have opened (BROWSER=none, no opener): the address is on the screen as well.
3562
+ const screen = HOST_SCREENS['netlify-visibility-retry'];
3563
+ return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
3564
+ },
3565
+ replace: async (live) => {
3566
+ if (passwordReplaceConsented.has(site) || !(await ask(netlifyPasswordReplace(site, { ...(at.app ? { app: appName(at.app.appKey) } : {}), live }))).skipped)
3567
+ return passwordReplaceConsented.add(site), true;
3568
+ return false;
3569
+ },
3570
+ // Netlify's API, called from this process with the token the CLI signed in with: the password never goes on a command line (#395).
3571
+ setPassword: (id, password) => {
3572
+ const token = (ctx.netlifyToken ?? netlifyToken)();
3573
+ if (token)
3574
+ noteSensitiveAnswer(token);
3575
+ return setSitePassword(id, password, { token, fetch: ctx.netlifyFetch ?? fetch, signal });
3576
+ },
3577
+ changed: () => void (replaced = true),
3578
+ ...(ctx.hostWait?.password ? { wait: ctx.hostWait.password } : {}),
3579
+ signal,
3580
+ prove,
3581
+ save: async (password) => {
3582
+ // Bound to the site the CLI read as the person's: reviews send it to no other site's addresses.
3583
+ const saved = await saveLock(pi, 'password', { method: 'password', site }, password, at.app?.appKey);
3584
+ stored = saved.stored;
3585
+ return saved.failed;
3586
+ },
3587
+ remember: noteSensitiveAnswer,
3588
+ });
3589
+ }
3590
+ finally {
3591
+ if (replaced) {
3592
+ ctx.onKeyCreated?.(answer?.status === 'saved'
3593
+ ? `Impeccable set a new Netlify site password on ${site} as you and saved it for reviews, so its previews stay password-protected. It replaced the one before, which no longer opens them: set one people know in Netlify (${NETLIFY_PASSWORD_WHERE}) any time, and put it in the app's Settings too, under "A way past your host's lock", or reviews stop.`
3594
+ : `Impeccable set a new Netlify site password on ${site} as you, which replaced the one before, but it was not saved for reviews. Set a password people know in Netlify (${NETLIFY_PASSWORD_WHERE}), and put it in the app's Settings too, under "A way past your host's lock".`, `netlify-password:${site}`);
3595
+ }
3596
+ }
3597
+ const { value, ...told } = answer;
3421
3598
  const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
3422
3599
  // Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321). Else they say which lock it is.
3423
3600
  if (told.protection === 'none' && lastLock === 'netlify-protection')
3424
3601
  lastLock = undefined;
3425
3602
  if (told.protection === 'password' || told.protection === 'team-login')
3426
3603
  lastTeamLogin = told.protection === 'team-login';
3427
- if (told.status === 'refused' && typed)
3428
- passwordRefused.add(site);
3429
- else if (told.status === 'saved')
3430
- passwordRefused.delete(site);
3431
3604
  if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
3432
3605
  passwordStands.set(site, passwordOutcome);
3433
3606
  if (told.status === 'opened') {
@@ -3442,9 +3615,11 @@ export function createTools(ctx) {
3442
3615
  if (value) {
3443
3616
  // 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
3617
  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.`);
3618
+ // A password the run set is named on the end screen as it was set, above. Either line takes the place of an earlier one for the site, such as a replaced password not saved.
3619
+ if (told.password !== 'set')
3620
+ 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.`, `netlify-password:${site}`);
3446
3621
  // 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 ?? '']) });
3622
+ sitePasswords.set(site, { value, saved: passwordOutcome, apps: new Set([at.app?.appKey ?? '']) });
3448
3623
  hostedPreviewProven = true;
3449
3624
  provenHost = 'Netlify';
3450
3625
  usedPreviews.add(at.url);
@@ -3514,6 +3689,7 @@ export function createTools(ctx) {
3514
3689
  cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
3515
3690
  login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
3516
3691
  retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
3692
+ install: async () => void (await installCf(pi, signal)),
3517
3693
  status: said,
3518
3694
  apiToken: async () => {
3519
3695
  const typed = await ask(HOST_SCREENS['cloudflare-access-api']);