@impeccable/reviews 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -7,7 +7,7 @@ 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 { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
10
+ import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endAppSetup, 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
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';
@@ -25,7 +25,7 @@ import { scan } from './scan.js';
25
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
- import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
28
+ import { askPerson, reportDone, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
29
29
  import { SEE_THE_REVIEW } from './ui/look.js';
30
30
  import { noteSensitiveAnswer, plainText } from './ui/sensitive.js';
31
31
  export { STATUS_MAX, statusVoiceError } from './sync.js';
@@ -108,6 +108,24 @@ export function resolveWrite(instructions, setupApi, repo, path) {
108
108
  }
109
109
  return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
110
110
  }
111
+ /**
112
+ * A "no" that leaves an app with no preview Impeccable can open ends the setup for it (#413): no-previews, to setting
113
+ * previews up (a host's sign-in, connecting the repository, turning pull-request previews on, creating the project, the
114
+ * host question); no-access, to giving access to protected ones (a host's key). The last screen's heading and line,
115
+ * when no app is left, and an app's own line where other apps carried on.
116
+ */
117
+ export const SETUP_ENDS = {
118
+ 'no-previews': {
119
+ title: 'Impeccable needs previews',
120
+ line: "Impeccable reviews each pull request by opening its preview. Apps without previews aren't supported for now. Run the setup again when you're ready to set previews up.",
121
+ said: 'setting previews up',
122
+ },
123
+ 'no-access': {
124
+ title: 'Impeccable needs to open your previews',
125
+ line: "Your previews are behind a login, and Impeccable reviews each pull request by opening its preview. Previews it can't open aren't supported for now. Run the setup again when you're ready to give access.",
126
+ said: 'giving access to protected previews',
127
+ },
128
+ };
111
129
  /** How many times a preview check the setup API turned away for its rate (one a repository every 30 seconds) is tried again (#338). */
112
130
  const CHECK_RETRIES = 3;
113
131
  /** The last screen's words for a run on several apps: the picked apps not set up, by name, the line naming which are and which are not, and a line for each that is not. */
@@ -142,9 +160,13 @@ function stageError(status, tasks) {
142
160
  }
143
161
  /** 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
162
  export function resultLine(report) {
163
+ if (report.ended)
164
+ return SETUP_ENDS[report.ended].line;
145
165
  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;
147
- return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
166
+ : !report.testPr ? `${report.unhonoured ?? 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;
167
+ // --signed-in-test-drive left an app without a test drive: said beside another app's drive or a wall too (#410).
168
+ const said = report.unhonoured && (report.walled || report.testPr) ? `${lead} ${report.unhonoured}` : lead;
169
+ return report.setupPr || !report.hostedBy ? said : `${said} ${nothingToMerge(report.hostedBy)}`;
148
170
  }
149
171
  /** Why a run opened no test drive, as far as what it saved and met tells. */
150
172
  const noTestDrive = ({ methodSaved, lock }) => methodSaved ? 'A preview method is saved, but no test drive opened, so nothing shows Impeccable reviewing yet.'
@@ -162,6 +184,9 @@ export function ending(report) {
162
184
  const todo = [...new Set([...step, ...apps.todo, ...report.left])];
163
185
  if (report.testPr && !apps.notReady.length && !report.walled)
164
186
  return { outcome: 'done', title: "You're all set up", todo, code: 0 };
187
+ // The person's no left no app with a preview Impeccable can open: the setup ended, and the screen says why alone (#413).
188
+ if (report.ended)
189
+ return { outcome: 'stopped', title: SETUP_ENDS[report.ended].title, todo: [], code: 1 };
165
190
  const outcome = report.methodSaved ? 'partial' : 'stopped';
166
191
  // Impeccable's own check said it can't get in: whatever the run saved, reviews will meet the same wall (#383).
167
192
  if (report.walled)
@@ -173,6 +198,30 @@ export function ending(report) {
173
198
  }
174
199
  /** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
175
200
  export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
201
+ /** Names as a reader lists them: "a", "a and b", "a, b and c". */
202
+ const andList = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''));
203
+ /** The end screen's one line for the Netlify sites whose password the run set and saved, set but did not save, or saved as it was: every site in one line (#419). */
204
+ const PASSWORD_NOTES = {
205
+ set: (sites) => sites.length === 1
206
+ ? `Impeccable set a new Netlify site password on ${sites[0]} 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.`
207
+ : `Impeccable set a new Netlify site password on each of ${andList(sites)} as you and saved them for reviews, so their previews stay password-protected. They replaced the ones before, which no longer open them: set ones people know in Netlify (${NETLIFY_PASSWORD_WHERE}) any time, and put each in its app's Settings too, under "A way past your host's lock", or reviews stop.`,
208
+ unsaved: (sites) => sites.length === 1
209
+ ? `Impeccable set a new Netlify site password on ${sites[0]} 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".`
210
+ : `Impeccable set a new Netlify site password on each of ${andList(sites)} as you, which replaced the ones before, but they were not saved for reviews. Set passwords people know in Netlify (${NETLIFY_PASSWORD_WHERE}), and put each in its app's Settings too, under "A way past your host's lock".`,
211
+ stored: (sites) => sites.length === 1
212
+ ? `Impeccable saved the Netlify site password of ${sites[0]} 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.`
213
+ : `Impeccable saved the Netlify site passwords of ${andList(sites)} for reviews, so their previews stay password-protected. A new password set in Netlify (${NETLIFY_PASSWORD_WHERE}) goes in its app's Settings too, under "A way past your host's lock", or reviews stop.`,
214
+ };
215
+ /** What the save's sign-in check confirms on screen: "Signed in as Admin and Manager", the roles that did not after it; none when no account signed in (#417). */
216
+ export function signedInLine(saved, app) {
217
+ const accounts = saved?.signInCheck?.accounts ?? [];
218
+ const role = (a) => String(a.role).charAt(0).toUpperCase() + String(a.role).slice(1);
219
+ const signed = accounts.filter((a) => a.outcome === 'signed-in' && typeof a.role === 'string').map(role);
220
+ const not = accounts.filter((a) => a.outcome !== 'signed-in' && typeof a.role === 'string').map(role);
221
+ if (!signed.length)
222
+ return undefined;
223
+ return `${app ? `${app}: signed` : 'Signed'} in as ${andList(signed)}${not.length ? `; ${andList(not)} did not` : ''}`;
224
+ }
176
225
  /** The last screen's line for each host, in the order first met: "Cloudflare: Acme's Account · project crewline", "Netlify: team Acme · site crewline", "Vercel: team acme · project crewline" (#360). */
177
226
  export function hostLines(used) {
178
227
  const byHost = new Map();
@@ -302,10 +351,11 @@ const ASK = {
302
351
  additionalProperties: false,
303
352
  required: ['id', 'prompt', 'kind'],
304
353
  properties: {
354
+ appKey: { type: 'string', minLength: 1, description: 'On a repository with several apps, the app the question is for: the host question (id "preview-host") names it always, and a host\'s sign-in or team question names the app being set up, so a skip ends the setup for that app alone.' },
305
355
  id: { type: 'string', minLength: 1, description: 'A short id, such as "admin-password".' },
306
- title: { type: 'string', minLength: 1, description: 'The dialog\'s title: the subject in a few words, such as "Admin account". A confirm\'s heading.' },
307
- prompt: { type: 'string', minLength: 1, description: 'The question. A confirm\'s action line; an account\'s line under its title, such as "Reviews sign in with this account as admin."' },
308
- hint: { type: 'string', minLength: 1, description: 'One line under the question that helps answer it. A confirm\'s text under its title, two or three short lines.' },
356
+ title: { type: 'string', minLength: 1, description: 'The dialog\'s title: the subject in a few words, such as "Admin account · 1 of 3". A confirm\'s heading.' },
357
+ prompt: { type: 'string', minLength: 1, description: 'The question. A confirm\'s action line; a test account\'s line on the web page while it waits, which its screen leaves out, such as "Add the admin test account."' },
358
+ hint: { type: 'string', minLength: 1, description: 'One line under the question that helps answer it. A confirm\'s text under its title, two or three short lines; an account\'s line over its fields, such as "Signs in at /admin. Email found in supabase/seed.sql."' },
309
359
  kind: {
310
360
  type: 'string',
311
361
  enum: ['text', 'single', 'multi', 'confirm', 'account'],
@@ -334,16 +384,19 @@ const namesCredential = (q) => {
334
384
  };
335
385
  /** Under the field of every text question typed in the open, where the model reads the answer. */
336
386
  const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
387
+ /** The app a screen is for, on a repository with several apps: the person's no on it ends the setup for that app alone (#413). */
388
+ const FOR_APP = { type: 'string', minLength: 1, description: 'On a repository with several apps, the key of the app this is for: a skip ends the setup for that app alone.' };
337
389
  const ENABLE_PREVIEWS = {
338
390
  type: 'object',
339
391
  additionalProperties: false,
340
392
  required: ['host'],
341
- properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." } },
393
+ properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." }, appKey: FOR_APP },
342
394
  };
343
395
  const CLOUDFLARE_CONNECT = {
344
396
  type: 'object',
345
397
  additionalProperties: false,
346
398
  properties: {
399
+ appKey: FOR_APP,
347
400
  command: { type: 'string', minLength: 1, description: "The build command Cloudflare runs for a project the run creates: the package's build script; none for plain HTML." },
348
401
  outputDir: { type: 'string', minLength: 1, description: 'The folder the build writes the pages to, relative to the checkout, for a project the run creates; "." for plain HTML at the root.' },
349
402
  },
@@ -1551,6 +1604,8 @@ export function createTools(ctx) {
1551
1604
  const text = (t, terminate = false) => ({ content: [{ type: 'text', text: t }], details: undefined, ...(terminate ? { terminate } : {}) });
1552
1605
  const retry = previewRetryState(ctx.log);
1553
1606
  retry.repository ??= `${ctx.owner}/${ctx.repo}`;
1607
+ // Once the list is up: the agent's first call sets it, with the reading in progress.
1608
+ retry.reading = () => ctx.sync.payload.tasks.length > 0 && !workStarted(ctx.sync.payload.tasks);
1554
1609
  /** 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. */
1555
1610
  let recordedBranches;
1556
1611
  /** On a repository with several apps, each app's recorded test drive, in the order they were recorded (impeccable-reviews#397). */
@@ -1622,6 +1677,10 @@ export function createTools(ctx) {
1622
1677
  let lastLock;
1623
1678
  /** Netlify's gate, as last met, was its team login rather than the site password: the last screen names that one. */
1624
1679
  let lastTeamLogin = false;
1680
+ /** The person's no to setting previews up or to giving access to protected ones, by the app it was for ('' where the screen named none: a host's sign-in, connection or setting, the host question, or a repository of one app) (#413). */
1681
+ const declined = new Map();
1682
+ /** The hosts whose sign-in or team the person said no to: asked again for the next app, the answer is the same no. */
1683
+ const hostsDeclined = new Set();
1625
1684
  /** The host preview addresses a proof or a host's key opened, and the Cloudflare account and Netlify team of each project or site the host tools read, by host and project: the last screen names them (#360), and no tool's answer does. */
1626
1685
  const usedPreviews = new Set();
1627
1686
  const accountOf = new Map();
@@ -1670,6 +1729,7 @@ export function createTools(ctx) {
1670
1729
  if (access) {
1671
1730
  return onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
1672
1731
  skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
1732
+ declines: { app: access.app },
1673
1733
  still: `${host}'s GitHub app still cannot see the repository: no build came.`,
1674
1734
  stopped: `The run stopped while ${host}'s build was waited for.`,
1675
1735
  }, signal);
@@ -1726,6 +1786,8 @@ export function createTools(ctx) {
1726
1786
  const accessConsented = new Set();
1727
1787
  /** What each host's settings last said of its pull-request previews, as the evidence tools read them: the one fact impeccable_enable_previews acts on. */
1728
1788
  const previewFacts = new Map();
1789
+ /** On a repository with several apps, the project or site each app's own read named (`<host>/<appKey>`): a no on that project ends the setup of the apps on it alone (#413). */
1790
+ const appProjects = new Map();
1729
1791
  /** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
1730
1792
  const enableOutcomes = new Map();
1731
1793
  /** Cloudflare's plain evidence as last read: the project, the Worker and the GitHub app facts impeccable_cloudflare_connect and the wait for a build act on. */
@@ -2035,8 +2097,12 @@ export function createTools(ctx) {
2035
2097
  */
2036
2098
  async function onHostPage(screen, page, pending, result, again, words, signal) {
2037
2099
  for (let retries = 0; pending(result) && !signal?.aborted; retries++) {
2038
- if ((await ask(HOST_SCREENS[retries ? `${screen}-retry` : screen])).skipped)
2100
+ if ((await ask(HOST_SCREENS[retries ? `${screen}-retry` : screen])).skipped) {
2101
+ // A no to the host's GitHub app or to the lock before the first push leaves no preview: the setup ends there (#413).
2102
+ if (words.declines)
2103
+ decline('no-previews', words.declines.app);
2039
2104
  return { ...result, reason: words.skipped };
2105
+ }
2040
2106
  const at = page(result);
2041
2107
  if (at)
2042
2108
  (ctx.openBrowser ?? openBrowser)(at);
@@ -2051,17 +2117,18 @@ export function createTools(ctx) {
2051
2117
  return result;
2052
2118
  }
2053
2119
  /** The one-time access a host's GitHub app needs when it cannot reach the repository, given on the app's page on GitHub while `reaches` tries again. */
2054
- function hostAccess(host, result, reaches, signal) {
2120
+ function hostAccess(host, result, reaches, signal, app) {
2055
2121
  const name = HOST_NAMES[host];
2056
2122
  return onHostPage(`${host}-access`, (r) => r.access, (r) => Boolean(r.access), result, reaches, {
2057
2123
  waiting: `Waiting for ${name} to reach the repository`,
2058
2124
  skipped: `The person skipped giving ${name}'s GitHub app access to the repository.`,
2125
+ declines: { app },
2059
2126
  still: `${name}'s GitHub app still cannot reach the repository.`,
2060
2127
  stopped: `The run stopped while ${name} was waited for.`,
2061
2128
  }, signal);
2062
2129
  }
2063
2130
  /** The connection of the linked Netlify site with this repository, through Netlify's site settings and its GitHub app, with the app's access asked when it cannot read the repository. */
2064
- async function netlifyConnect(pi, settings, signal, directory, previewing = false) {
2131
+ async function netlifyConnect(pi, settings, signal, directory, previewing = false, app) {
2065
2132
  // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI. A site built from this repository with Deploy Previews on needs no setup (#311): the connection changes nothing on it, and only checks that Netlify's GitHub app still reads the repository, asking for its access when it does not.
2066
2133
  if (!hostSetupAgreed(ctx.log, 'netlify') && !previewing)
2067
2134
  return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
@@ -2072,9 +2139,11 @@ export function createTools(ctx) {
2072
2139
  netlifySite = result.site?.name ?? netlifySite;
2073
2140
  if (result.pullRequestPreviews)
2074
2141
  previewFacts.set('netlify', result.pullRequestPreviews);
2142
+ if (app !== undefined && result.pullRequestPreviews)
2143
+ appProjects.set(`netlify/${app}`, result.pullRequestPreviews.project);
2075
2144
  // A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
2076
2145
  return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
2077
- ? { connected: true, site: result.site, how: 'github-app', ...(result.pullRequestPreviews ? { pullRequestPreviews: result.pullRequestPreviews } : {}) } : result), signal);
2146
+ ? { connected: true, site: result.site, how: 'github-app', ...(result.pullRequestPreviews ? { pullRequestPreviews: result.pullRequestPreviews } : {}) } : result), signal, app);
2078
2147
  }
2079
2148
  /**
2080
2149
  * The connection of the linked Vercel project with this repository, `vercel git connect`, for a project this run's own
@@ -2128,7 +2197,7 @@ export function createTools(ctx) {
2128
2197
  }
2129
2198
  }
2130
2199
  // Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
2131
- const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
2200
+ const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal, connectedApp);
2132
2201
  // The fence holds pushes and deploys from the sign-in until the linked project's first deployment is settled: Vercel's app cannot reach the repository (a push builds nothing), production is deployed already, or the project is locked at every address.
2133
2202
  if (!result.connected) {
2134
2203
  if (result.access !== undefined)
@@ -2143,6 +2212,7 @@ export function createTools(ctx) {
2143
2212
  first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, repository, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
2144
2213
  waiting: "Waiting for Vercel's login on the project",
2145
2214
  skipped: 'The person skipped locking the Vercel project.',
2215
+ declines: { app: connectedApp },
2146
2216
  still: "Vercel's login is still not in front of the project's addresses.",
2147
2217
  stopped: 'The run stopped while the lock was waited for.',
2148
2218
  }, signal);
@@ -2216,6 +2286,9 @@ export function createTools(ctx) {
2216
2286
  let renewedForApps = false;
2217
2287
  /** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
2218
2288
  let appsRepo = ctx.severalApps === true;
2289
+ // --signed-in-test-drive: the fence pushes and opens a test drive only for an app with a test account saved (#410).
2290
+ if (ctx.signedInTestDrive)
2291
+ retry.signedIn ??= { apps: appsRepo, saved: new Set(ctx.signedInTestDrive.accountsSaved) };
2219
2292
  /**
2220
2293
  * The apps this run sets up, as the apps question answered: undefined until it was answered.
2221
2294
  * On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
@@ -2233,6 +2306,9 @@ export function createTools(ctx) {
2233
2306
  pickedApps = keys;
2234
2307
  // Each app this run sets up gets a test drive of its own (impeccable-reviews#397).
2235
2308
  retry.drivesAllowed = Math.max(1, keys.size);
2309
+ // The fence reads the app from its test drive's branch, impeccable/test-drive-<key> or, for a run that sets up one app, impeccable/test-drive (#410).
2310
+ if (retry.signedIn)
2311
+ retry.signedIn.picked = [...keys];
2236
2312
  const before = new Map(appSummaries);
2237
2313
  appSummaries.clear();
2238
2314
  for (const key of keys)
@@ -2277,6 +2353,8 @@ export function createTools(ctx) {
2277
2353
  }
2278
2354
  if (res.ok && severalApps(res.data))
2279
2355
  appsRepo = true;
2356
+ if (retry.signedIn)
2357
+ retry.signedIn.apps = appsRepo;
2280
2358
  if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
2281
2359
  instructions = focusInstructions(ctx.allInstructions, { apps: true });
2282
2360
  return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
@@ -2328,10 +2406,43 @@ export function createTools(ctx) {
2328
2406
  }
2329
2407
  return null;
2330
2408
  };
2409
+ /** Why the setup ended for an app with no method saved: the person's no on a screen for it (#413). Undefined while it may go on. */
2410
+ const endedFor = (key) => ((appsRepo ? appMethods.has(key) : methodSaved) ? undefined : declined.get(key));
2411
+ /** The person's no for an app: '' on a repository of one app; on several, the app the screen was for, and none where no app was named. Once its setup ended, the fence pushes nothing of it. */
2412
+ const decline = (kind, appKey) => {
2413
+ const key = appsRepo ? (typeof appKey === 'string' ? appKey : undefined) : '';
2414
+ if (key === undefined)
2415
+ return;
2416
+ declined.set(key, kind);
2417
+ if (endedFor(key))
2418
+ endAppSetup(ctx.log, key);
2419
+ };
2420
+ /** Whether the app asking is on the project a host's answer was for, as its own read named it: on a repository of one app, always. */
2421
+ const onProject = (host, appKey, project) => !appsRepo || (typeof appKey === 'string' && appProjects.get(`${host}/${appKey}`) === project);
2422
+ /** Why a step for `appKey` may not run: the setup ended for that app on the person's no, so nothing more is saved or asked for it (#413). Null where it may. */
2423
+ const endRefusal = (appKey, what) => {
2424
+ const key = appsRepo ? appKey : '';
2425
+ const why = typeof key === 'string' ? endedFor(key) : undefined;
2426
+ if (!why)
2427
+ return null;
2428
+ const app = appsRepo ? appName(key) : undefined;
2429
+ return `Refused: the person said no to ${SETUP_ENDS[why].said}${app ? ` for ${app}` : ''}, which leaves ${app ?? 'the app'} with no preview Impeccable can open, so the setup ended there${app ? ` for ${app}: go on with the other apps, as the monorepo case says` : ": nothing more is asked or saved. Call impeccable_progress with every task's final status, then impeccable_report"}. Nothing was ${what}.`;
2430
+ };
2331
2431
  /**
2332
2432
  * Why a host's lock tool may not run yet: the list still shows the repository being read, so its consent and the push
2333
2433
  * after it would go by on "Understanding your app" (#390). Null once that task is done.
2334
2434
  */
2435
+ /** What the end screen says of each Netlify site's password, by site: one line for every site that shares what was done (#419). */
2436
+ const passwordNotes = new Map();
2437
+ /** Notes what was done with a site's password: the line for that, and for what it was before, now name every site they hold; a line with none left is taken off. */
2438
+ const notePassword = (site, kind) => {
2439
+ const was = passwordNotes.get(site);
2440
+ passwordNotes.set(site, kind);
2441
+ for (const k of new Set([kind, ...(was ? [was] : [])])) {
2442
+ const sites = [...passwordNotes].filter(([, v]) => v === k).map(([name]) => name);
2443
+ ctx.onKeyCreated?.(sites.length ? PASSWORD_NOTES[k](sites) : '', `netlify-password:${k}`);
2444
+ }
2445
+ };
2335
2446
  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.`;
2336
2447
  /** How this run left each app the state lists (#286), for the report and the last screen. */
2337
2448
  const appOutcomes = async (pi, stale = new Set()) => {
@@ -2354,9 +2465,14 @@ export function createTools(ctx) {
2354
2465
  ? { 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.` }
2355
2466
  : { key, name, folder, state: 'ready' };
2356
2467
  }
2468
+ const state = gate && !proven ? 'locked' : 'unset';
2469
+ // The person's no left it with no preview Impeccable can open: the setup ended for it, and its line says so (#413).
2470
+ const ended = endedFor(key);
2471
+ if (ended)
2472
+ return { key, name, folder, state, ended, left: `${name}: the setup ended for it, as you said no to ${SETUP_ENDS[ended].said}.` };
2357
2473
  if (gate && !proven)
2358
- return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
2359
- return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
2474
+ return { key, name, folder, state, left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
2475
+ return { key, name, folder, state, left: `${name}: no preview method was saved; the report says why.` };
2360
2476
  });
2361
2477
  };
2362
2478
  /** The report's section on the apps, one line each, from what this run saved and proved rather than from the model's words. */
@@ -2365,6 +2481,8 @@ export function createTools(ctx) {
2365
2481
  const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
2366
2482
  if (a.state === 'ready')
2367
2483
  return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}.${a.unchecked ? " Impeccable's check of its preview did not run." : ''}`;
2484
+ if (a.ended)
2485
+ return `- ${where}: not set up. The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.`;
2368
2486
  if (a.state === 'locked')
2369
2487
  return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
2370
2488
  if (a.state === 'unset')
@@ -2525,7 +2643,7 @@ export function createTools(ctx) {
2525
2643
  // On a repository with several apps, nothing but the apps write goes before the apps question, and an app's setting or check names a picked app.
2526
2644
  const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
2527
2645
  if (!target.url.endsWith('/apps')) {
2528
- const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp) ?? (target.url.endsWith('/preview') ? lockRefusal(object(p.body)?.appKey, 'saved') : null);
2646
+ const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp) ?? (forApp ? endRefusal(object(p.body)?.appKey, 'saved') : null) ?? (target.url.endsWith('/preview') ? lockRefusal(object(p.body)?.appKey, 'saved') : null);
2529
2647
  if (refused)
2530
2648
  return refused;
2531
2649
  }
@@ -2581,6 +2699,12 @@ export function createTools(ctx) {
2581
2699
  break;
2582
2700
  response = await call(pi, target.url, { method: target.method, body });
2583
2701
  }
2702
+ if (testAccounts && response.ok)
2703
+ retry.signedIn?.saved.add(appsRepo ? String(object(p.body)?.appKey) : '');
2704
+ // The accounts the save signed in with, said on screen: the tasks show no status line (#417).
2705
+ const signedIn = testAccounts && response.ok ? signedInLine(response.data, appsRepo ? appName(String(object(p.body)?.appKey)) : undefined) : undefined;
2706
+ if (signedIn)
2707
+ reportDone(signedIn);
2584
2708
  // A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
2585
2709
  if (check && (response.ok || response.status === 409))
2586
2710
  checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
@@ -2634,9 +2758,19 @@ export function createTools(ctx) {
2634
2758
  }),
2635
2759
  tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2636
2760
  // A skip never stops the run, so every question offers one, but the apps question, which needs at least one app (#405).
2637
- const asked = p;
2761
+ const { appKey: forApp, ...asked } = p;
2638
2762
  if (asked.id === APPS_QUESTION.id && appsRepo)
2639
2763
  return appsQuestion(pi, signal);
2764
+ // On several apps, the host question is one app's, so its skip ends that app's setup alone (#413).
2765
+ if (asked.id === 'preview-host' && appsRepo) {
2766
+ const refused = appRefusal(forApp, 'asked');
2767
+ if (refused)
2768
+ return refused;
2769
+ }
2770
+ // On a repository of one app, the person's no ended the setup: nothing more is asked, the test accounts neither (#413).
2771
+ const ended = appsRepo ? null : endRefusal('', 'asked');
2772
+ if (ended)
2773
+ return ended;
2640
2774
  const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
2641
2775
  // The Vercel setup is for a repository Vercel has never deployed: one it deploys already (every app this run sets up, on a repository with several), as impeccable_hosted_previews read it, gets its previews from the first pull request on (#286). An app with no project of its own still gets the screen, and a setup under way (asked again once the CLI was installed) is not stopped here.
2642
2776
  const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
@@ -2649,8 +2783,16 @@ export function createTools(ctx) {
2649
2783
  if (asked.id === 'netlify-setup' && previewing && !hostSignIns.has('netlify')) {
2650
2784
  return `Refused: Netlify builds this repository already (${[...new Set(netlifyPreviewing.values())].join(', ')}), with Deploy Previews on, so its deploy previews start with the first pull request, and there is no Netlify sign-in, site or connection to make. Go on as netlify-previews says before the first pull request: impeccable_netlify_connect${appsRepo ? ' with the app\'s appKey' : ''} first, which changes nothing on the site and checks that Netlify's GitHub app still reads the repository (asking the person for its access when it does not), then build the test drive once with the build Netlify runs, push it and open it as a draft as pull-requests' step 4 says, wait for its deploy preview with impeccable_netlify_previews and pull, prove the test drive's page there, save the method, and mark the draft ready.`;
2651
2785
  }
2652
- if (host?.[2] === 'setup')
2653
- return JSON.stringify(await hostSignIn(host[1], pi, signal));
2786
+ if (host?.[2] === 'setup') {
2787
+ const was = hostSignIns.get(host[1]);
2788
+ const signedIn = await hostSignIn(host[1], pi, signal);
2789
+ // A skipped sign-in leaves the host's previews unset: the setup ends there for the app it was for, and for the next app asked it again (#413). A sign-in that did not complete is not a no.
2790
+ if (signedIn.skipped && !signedIn.reason && (was !== 'skipped' || hostsDeclined.has(host[1]))) {
2791
+ hostsDeclined.add(host[1]);
2792
+ decline('no-previews', forApp);
2793
+ }
2794
+ return JSON.stringify(signedIn);
2795
+ }
2654
2796
  if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
2655
2797
  return `Invalid input: ${asked.id} is asked only when the ${HOST_NAMES[host[1]]} sign-in answered teams to choose from.`;
2656
2798
  const fixed = Object.hasOwn(HOST_QUESTIONS, asked.id) ? HOST_QUESTIONS[asked.id] : undefined;
@@ -2676,6 +2818,11 @@ export function createTools(ctx) {
2676
2818
  // Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
2677
2819
  if (host?.[1] === 'vercel')
2678
2820
  endSetup('vercel');
2821
+ // No to the Vercel team, or to every host on the host question, leaves no preview: the setup ends there (#413).
2822
+ if (host?.[1] === 'vercel')
2823
+ hostsDeclined.add('vercel');
2824
+ if (host?.[1] === 'vercel' || q.id === 'preview-host')
2825
+ decline('no-previews', forApp);
2679
2826
  return JSON.stringify({ skipped: true });
2680
2827
  }
2681
2828
  if (q.kind === 'multi')
@@ -2735,6 +2882,11 @@ export function createTools(ctx) {
2735
2882
  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.`;
2736
2883
  }
2737
2884
  const key = typeof appKey === 'string' ? appKey : '';
2885
+ const ended = endRefusal(key, 'recorded');
2886
+ if (ended)
2887
+ return ended;
2888
+ if (retry.signedIn && !retry.signedIn.saved.has(key))
2889
+ return `Refused: the run was started with --signed-in-test-drive, and no test account is saved${appsRepo ? ` for ${appName(key)}` : ''}, so its test drive cannot change a page behind the sign-in. Nothing was recorded: close #${drive.number}, and say in the report that --signed-in-test-drive could not be honoured, and why.`;
2738
2890
  const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
2739
2891
  if ('problem' in found)
2740
2892
  return `Refused: ${found.problem}.`;
@@ -2800,6 +2952,11 @@ export function createTools(ctx) {
2800
2952
  if (open.length)
2801
2953
  return `Refused: the task list still shows ${open.map((t) => `"${t.title}" as ${t.status.replace('_', ' ')}`).join(', ')}. Call impeccable_progress with every task's final status (completed, skipped or failed), then impeccable_report again. Nothing was saved.`;
2802
2954
  let markdown = p.markdown;
2955
+ // --signed-in-test-drive: an app with no test account saved got no test drive, and the report and the last screen say why (#410).
2956
+ const unsigned = retry.signedIn ? (appsRepo ? [...(pickedApps ?? [])] : ['']).filter((key) => !retry.signedIn.saved.has(key)) : [];
2957
+ const unhonoured = unsigned.length ? `--signed-in-test-drive could not be honoured${appsRepo ? ` for ${unsigned.map(appName).join(' and ')}` : ''}: no test account is saved, so no test drive opened behind the sign-in.` : undefined;
2958
+ if (unhonoured)
2959
+ markdown = `**${unhonoured}**\n\n${markdown}`;
2803
2960
  // The files the setup pull request carries, so one the setup never meant to change stands out: a sandboxed script writes in the checkout too.
2804
2961
  if (recordedBranches?.setup) {
2805
2962
  const changes = recordedBranches.setupBase ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, recordedBranches.setupBase, recordedBranches.setup) : null;
@@ -2826,6 +2983,12 @@ export function createTools(ctx) {
2826
2983
  const apps = appsRepo && pickedApps ? await appOutcomes(pi, new Set([...appDrives.keys()].filter((key) => !kept.some(([k]) => k === key)))) : undefined;
2827
2984
  if (apps)
2828
2985
  markdown += appsSection(apps);
2986
+ // The person's no left no app this run set up with a preview Impeccable can open: the setup ended, and the report and the web page open with why (#413).
2987
+ const picked = apps?.filter((a) => a.state !== 'unpicked');
2988
+ const endedBy = picked ? (picked.length && picked.every((a) => a.ended) ? picked.map((a) => a.ended) : []) : drive ? [] : [endedFor('')].filter((why) => why !== undefined);
2989
+ const ended = endedBy.length ? (endedBy.includes('no-previews') ? 'no-previews' : 'no-access') : undefined;
2990
+ if (ended)
2991
+ markdown = `**${SETUP_ENDS[ended].title}.** ${SETUP_ENDS[ended].line}\n\n${markdown}`;
2829
2992
  if (previews.active && retry.pr) {
2830
2993
  await observe(signal);
2831
2994
  const lines = attempts().map((a, i) => `${i + 1}. [Attempt ${i + 1}](${a.url}) (run attempt ${a.attempt}): ${a.status}. ${a.diagnostics.map((d) => `${d.appKey}: step ${d.stage ?? 'unverified'}. ${d.reason}`).join(' ')}${a.changes.length ? ` Changed settings: ${a.changes.join(', ')}.` : ''}`);
@@ -2838,6 +3001,10 @@ export function createTools(ctx) {
2838
3001
  const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
2839
3002
  // 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.
2840
3003
  const appsBefore = new Map(appSummaries);
3004
+ // Each app whose setup ended on the person's no says so on the web page, unless the report gives its own words below.
3005
+ for (const a of apps ?? [])
3006
+ if (a.ended)
3007
+ noteApp(a.key, { reason: `The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.` });
2841
3008
  for (const { appKey, reason } of p.apps ?? []) {
2842
3009
  const plain = reason.trim();
2843
3010
  const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
@@ -2845,7 +3012,7 @@ export function createTools(ctx) {
2845
3012
  noteApp(appKey, { reason: plain });
2846
3013
  }
2847
3014
  // 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).
2848
- 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) });
3015
+ ctx.sync.update({ phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
2849
3016
  const res = await ctx.sync.flush();
2850
3017
  if (res && res.status >= 400 && res.status < 500) {
2851
3018
  // 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.
@@ -2860,7 +3027,7 @@ export function createTools(ctx) {
2860
3027
  }
2861
3028
  // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
2862
3029
  const hosts = hostLines(hostsUsed());
2863
- 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 } : {}) });
3030
+ ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(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 } : {}), ...(ended ? { ended } : {}) });
2864
3031
  pi.shutdown();
2865
3032
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2866
3033
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
@@ -3006,6 +3173,8 @@ export function createTools(ctx) {
3006
3173
  }
3007
3174
  if (evidence.pullRequestPreviews)
3008
3175
  previewFacts.set('netlify', evidence.pullRequestPreviews);
3176
+ if (appKey !== undefined && evidence.pullRequestPreviews)
3177
+ appProjects.set(`netlify/${appKey}`, evidence.pullRequestPreviews.project);
3009
3178
  // A site built from this repository (this app's, read by its folder) with Deploy Previews on previews the next pull request, listed or not (#311).
3010
3179
  if (pull === undefined) {
3011
3180
  if (!evidence.unmatched && evidence.pullRequestPreviews?.enabled === true)
@@ -3049,7 +3218,7 @@ export function createTools(ctx) {
3049
3218
  // A pushed branch of a project connected with previews on that gets no build: Cloudflare's GitHub app cannot see the repository (its installation covers selected ones, or none),
3050
3219
  // unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
3051
3220
  const app = cloudflareEvidence?.githubApp;
3052
- 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;
3221
+ 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, ...(typeof p.appKey === 'string' ? { app: p.appKey } : {}) } : undefined;
3053
3222
  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();
3054
3223
  // cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
3055
3224
  if (!subject && waited.cli === 'missing') {
@@ -3083,6 +3252,9 @@ export function createTools(ctx) {
3083
3252
  }
3084
3253
  if (evidence.pullRequestPreviews)
3085
3254
  previewFacts.set('cloudflare', evidence.pullRequestPreviews);
3255
+ const named = evidence.pullRequestPreviews?.project || evidence.project;
3256
+ if (appKey !== undefined && named)
3257
+ appProjects.set(`cloudflare/${appKey}`, named);
3086
3258
  noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
3087
3259
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
3088
3260
  }),
@@ -3176,7 +3348,7 @@ export function createTools(ctx) {
3176
3348
  const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
3177
3349
  if (appKey !== undefined && directory === undefined)
3178
3350
  return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
3179
- const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''));
3351
+ const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''), appKey);
3180
3352
  if (result.how && result.site)
3181
3353
  noteApp(appKey, { host_project: result.site.name });
3182
3354
  return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
@@ -3184,6 +3356,9 @@ export function createTools(ctx) {
3184
3356
  tool('impeccable_enable_previews', 'Pull-request previews', "Turn on previews of pull requests on the host that builds this repository but not its pull requests, as pullRequestPreviews in the answer of impeccable_netlify_previews, impeccable_netlify_connect or impeccable_cloudflare_previews said (enabled false). It does the rest as the person: one screen asking to turn them on, then the host's CLI changes that one setting (Netlify: Deploy Previews on; Cloudflare Pages: preview branch control set to every non-production branch, or the test drive's branch added to the custom includes) and reads it back; where the CLI cannot (the role, a cf sign-in without pages:write, builds stopped altogether), it opens the host's settings page and asks the person to try again once they changed it there. Answers {\"status\": …}: enabled (pull-request previews are on now; by says whether the run or the person turned them on, and project which project or site), skipped (the person skipped a screen; reason says what came before) or refused (still off after the retries; reason says why, and page where it is changed). Vercel builds every branch once Git is connected, so its fact is reported and never changed here.", ENABLE_PREVIEWS, async (p, pi, signal) => {
3185
3357
  const host = p.host;
3186
3358
  const standing = enableOutcomes.get(host);
3359
+ // The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
3360
+ if (standing?.status === 'skipped' && onProject(host, p.appKey, standing.project))
3361
+ decline('no-previews', p.appKey);
3187
3362
  if (standing)
3188
3363
  return JSON.stringify(standing);
3189
3364
  const fact = previewFacts.get(host);
@@ -3195,6 +3370,8 @@ export function createTools(ctx) {
3195
3370
  const answer = (status, more) => {
3196
3371
  const told = { status, project: fact.project, ...more };
3197
3372
  enableOutcomes.set(host, told);
3373
+ if (status === 'skipped')
3374
+ decline('no-previews', p.appKey);
3198
3375
  return JSON.stringify(told);
3199
3376
  };
3200
3377
  const screen = HOST_SCREENS[`${host}-previews`];
@@ -3227,6 +3404,9 @@ export function createTools(ctx) {
3227
3404
  }
3228
3405
  }),
3229
3406
  tool('impeccable_cloudflare_connect', 'Connect the repository to Cloudflare Pages', "Connect this repository to a Cloudflare Pages project, so that Cloudflare builds every pull request as a preview, where impeccable_cloudflare_previews found no Pages project building them: the project the checkout names when it exists with no Git source (pullRequestPreviews with cause source), else a new one named after the repository where Cloudflare builds a Worker from it (worker in the answer) or the checkout is set up for a Pages project that does not exist yet. It does the rest as the person: one screen asking to connect or to create (Cloudflare's sign-in in the browser first, with a retry, where cf is not signed in here with pages:write), then cf creates the project with the build the checkout runs (command and outputDir, for a new project) and connects the repository as GitHub identifies it, every branch but the default a preview, and reads it back. Answers {\"status\": …}: connected (project names the Pages project, created whether the run made it, subdomain its own address under pages.dev, productionBranch the branch Cloudflare builds as production; pullRequestPreviews is on now), skipped (the person skipped a screen; reason says which) or refused (cf could not, or Cloudflare refused; reason says why). Cloudflare's GitHub app then has to see the repository for a push to build: the wait for the test drive's build asks for its access when none comes.", CLOUDFLARE_CONNECT, async (p, pi, signal) => {
3407
+ // The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
3408
+ if (connectOutcome?.status === 'skipped' && onProject('cloudflare', p.appKey, connectOutcome.project))
3409
+ decline('no-previews', p.appKey);
3230
3410
  if (connectOutcome)
3231
3411
  return JSON.stringify(connectOutcome);
3232
3412
  const evidence = cloudflareEvidence;
@@ -3243,6 +3423,8 @@ export function createTools(ctx) {
3243
3423
  const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
3244
3424
  const answer = (status, more) => {
3245
3425
  connectOutcome = { status, project, created: create, ...more };
3426
+ if (status === 'skipped')
3427
+ decline('no-previews', p.appKey);
3246
3428
  return JSON.stringify(connectOutcome);
3247
3429
  };
3248
3430
  // The one screen: the connection of the project named, or the new project for the repository.
@@ -3334,6 +3516,12 @@ export function createTools(ctx) {
3334
3516
  const asked = bypassOutcomes.get(store);
3335
3517
  if (asked?.status === 'skipped')
3336
3518
  return JSON.stringify(asked);
3519
+ /** The person's no to the secret leaves the previews behind Vercel's login: the setup ends there for the app (#413). */
3520
+ const noAccess = (outcome) => {
3521
+ if (outcome.status === 'skipped')
3522
+ decline('no-access', store);
3523
+ return outcome;
3524
+ };
3337
3525
  /** What stops the tool before the project is vouched for: the address got nothing, or, before the push, the lock is not known yet and the gate's call asks. */
3338
3526
  const nothing = (why) => (at ? `Refused: nothing was sent to ${at.url}. ${why}` : JSON.stringify({ ...(app ? { appKey: app.appKey } : {}), protected: 'unknown', reason: why }));
3339
3527
  /** The consent screen, naming the project and the app once they are known (#358). */
@@ -3350,9 +3538,9 @@ export function createTools(ctx) {
3350
3538
  */
3351
3539
  const signIn = async () => {
3352
3540
  /** A skip, of the consent or of the sign-in, stands for the run: it is asked once, as the consent alone is. */
3353
- const skip = (reason) => {
3541
+ const skip = (reason, declines = true) => {
3354
3542
  const outcome = { status: 'skipped', reason };
3355
- bypassOutcomes.set(store, outcome);
3543
+ bypassOutcomes.set(store, declines ? noAccess(outcome) : outcome);
3356
3544
  return JSON.stringify(outcome);
3357
3545
  };
3358
3546
  if ((await ask(consentScreen())).skipped)
@@ -3373,7 +3561,8 @@ export function createTools(ctx) {
3373
3561
  hostSignIns.delete('vercel');
3374
3562
  if (signedIn.signedIn === true)
3375
3563
  return null;
3376
- return skip(typeof signedIn.reason === 'string' ? signedIn.reason : "The person skipped Vercel's sign-in, which the bypass secret needs.");
3564
+ // A sign-in that did not complete is not a no.
3565
+ return skip(typeof signedIn.reason === 'string' ? signedIn.reason : "The person skipped Vercel's sign-in, which the bypass secret needs.", typeof signedIn.reason !== 'string');
3377
3566
  };
3378
3567
  // A folder Vercel deploys from GitHub has no link file unless vercel link ran in it (the file is gitignored), so the project is found on Vercel by this repository,
3379
3568
  // with the CLI signed in as the person.
@@ -3419,7 +3608,7 @@ export function createTools(ctx) {
3419
3608
  bypassConsented.add(`${store}/${link.projectId}`);
3420
3609
  return JSON.stringify({ ...of, protected: true, consent: 'given' });
3421
3610
  }
3422
- const outcome = { status: 'skipped', ...of, protected: true, reason: `The person skipped creating a bypass secret before the test drive's push. Vercel's login stands in front of the previews of ${project.name ? `the Vercel project ${project.name}` : 'the Vercel project'}, so a test drive pushed now could not be reviewed. A secret made in the project's Settings → Deployment Protection → Protection Bypass for Automation, named "Impeccable reviews", and saved on the setup page lets reviews in.` };
3611
+ const outcome = noAccess({ status: 'skipped', ...of, protected: true, reason: `The person skipped creating a bypass secret before the test drive's push. Vercel's login stands in front of the previews of ${project.name ? `the Vercel project ${project.name}` : 'the Vercel project'}, so a test drive pushed now could not be reviewed. A secret made in the project's Settings → Deployment Protection → Protection Bypass for Automation, named "Impeccable reviews", and saved on the setup page lets reviews in.` });
3423
3612
  bypassOutcomes.set(store, outcome);
3424
3613
  return JSON.stringify(outcome);
3425
3614
  }
@@ -3474,7 +3663,7 @@ export function createTools(ctx) {
3474
3663
  // A secret the run made stays in the person's Vercel project, whatever comes after: the end screen says so, and where it is revoked (#335).
3475
3664
  made: (held, name) => ctx.onKeyCreated?.(`Impeccable created a Vercel bypass secret${held === 'labelled' ? ', "Impeccable reviews",' : ' without a label'}${name ? ` on the ${name} project` : ''}. Revoke it under the project's Settings → Deployment Protection → Protection Bypass for Automation; reviews stop until a new one is saved.`),
3476
3665
  });
3477
- const outcome = { ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) };
3666
+ const outcome = noAccess({ ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) });
3478
3667
  bypassOutcomes.set(store, outcome);
3479
3668
  if (value) {
3480
3669
  // What is in hand follows the store: a new secret is the one saved now, and the origins the last one opened no longer stand.
@@ -3523,8 +3712,11 @@ export function createTools(ctx) {
3523
3712
  }
3524
3713
  passwordStands.delete(site);
3525
3714
  }
3526
- else if (stands)
3715
+ else if (stands) {
3716
+ if (stands.status === 'skipped')
3717
+ decline('no-access', at.app?.appKey);
3527
3718
  return JSON.stringify(stands);
3719
+ }
3528
3720
  // 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.
3529
3721
  const held = sitePasswords.get(site);
3530
3722
  if (held && (await prove(held.value)).proven) {
@@ -3608,11 +3800,8 @@ export function createTools(ctx) {
3608
3800
  });
3609
3801
  }
3610
3802
  finally {
3611
- if (replaced) {
3612
- ctx.onKeyCreated?.(answer?.status === 'saved'
3613
- ? `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.`
3614
- : `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}`);
3615
- }
3803
+ if (replaced)
3804
+ notePassword(site, answer?.status === 'saved' ? 'set' : 'unsaved');
3616
3805
  }
3617
3806
  const { value, ...told } = answer;
3618
3807
  const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
@@ -3623,6 +3812,9 @@ export function createTools(ctx) {
3623
3812
  lastTeamLogin = told.protection === 'team-login';
3624
3813
  if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
3625
3814
  passwordStands.set(site, passwordOutcome);
3815
+ // The person's no to the password, its replacement or a public site leaves the previews behind Netlify's lock: the setup ends there for the app (#413).
3816
+ if (told.status === 'skipped')
3817
+ decline('no-access', at.app?.appKey);
3626
3818
  if (told.status === 'opened') {
3627
3819
  // The site is public now: its previews open with nothing sent.
3628
3820
  hostedPreviewProven = true;
@@ -3635,9 +3827,11 @@ export function createTools(ctx) {
3635
3827
  if (value) {
3636
3828
  // 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.
3637
3829
  said(`Password works: saved for ${at.app ? appName(at.app.appKey) : site}`);
3830
+ // The tasks screen shows no status line, so the confirmation goes under the list (#416).
3831
+ reportDone(told.password === 'set' ? `Netlify password set on ${site}` : `Netlify password saved for ${site}`);
3638
3832
  // 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.
3639
3833
  if (told.password !== 'set')
3640
- 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}`);
3834
+ notePassword(site, 'stored');
3641
3835
  // What is in hand follows the store: the password saved now is this site's, beside every other site's.
3642
3836
  sitePasswords.set(site, { value, saved: passwordOutcome, apps: new Set([at.app?.appKey ?? '']) });
3643
3837
  hostedPreviewProven = true;
@@ -3664,8 +3858,10 @@ export function createTools(ctx) {
3664
3858
  // The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
3665
3859
  if (at.app)
3666
3860
  lockPending.delete(at.app.appKey);
3667
- if (accessOutcome?.status === 'skipped')
3861
+ if (accessOutcome?.status === 'skipped') {
3862
+ decline('no-access', at.app?.appKey);
3668
3863
  return JSON.stringify(accessOutcome);
3864
+ }
3669
3865
  let proof;
3670
3866
  const prove = async (token) => (proof = sawApp(at.app?.appKey, at.url, await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token })));
3671
3867
  const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
@@ -3740,6 +3936,9 @@ export function createTools(ctx) {
3740
3936
  }
3741
3937
  const pattern = told.status === 'saved' ? proof?.pattern : undefined;
3742
3938
  accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
3939
+ // The person's no to the service token leaves the previews behind Cloudflare Access: the setup ends there for the app (#413).
3940
+ if (told.status === 'skipped')
3941
+ decline('no-access', at.app?.appKey);
3743
3942
  if (told.status === 'saved') {
3744
3943
  hostedPreviewProven = true;
3745
3944
  provenHost = 'Cloudflare Pages';